diff --git a/packages/core/package.json b/packages/core/package.json
index b4b0cdef9a..4b455168ce 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -25,6 +25,8 @@
"dependencies": {
"@babel/runtime": "7.27.6",
"@emoji-mart/data": "1.1.2",
+ "@fontsource/inter": "5.0.8",
+ "@fontsource/poppins": "5.0.8",
"@fontsource/roboto": "5.0.8",
"@lingui/macro": "4.11.4",
"@mui/utils": "5.14.5",
diff --git a/packages/core/src/components/Button/Button.tsx b/packages/core/src/components/Button/Button.tsx
index 1cbf3ab3f1..6bee9201d9 100644
--- a/packages/core/src/components/Button/Button.tsx
+++ b/packages/core/src/components/Button/Button.tsx
@@ -3,8 +3,6 @@ import React, { SyntheticEvent } from 'react';
import { useNavigate } from 'react-router-dom';
import styled from 'styled-components';
-import Color from '../../constants/Color';
-
const StyledBaseButton = styled(({ nowrap: boolean, selected, ...rest }) => )`
white-space: ${({ nowrap }) => (nowrap ? 'nowrap' : 'normal')};
${({ selected, theme }) => {
@@ -13,11 +11,17 @@ const StyledBaseButton = styled(({ nowrap: boolean, selected, ...rest }) =>
`1px solid ${
selected
- ? theme.palette.highlight.main
+ ? theme.palette.primary.main
: borderTransparency
? theme.palette.background.default
: getColorModeValue(theme, 'border')
}`,
- backgroundColor: (theme) =>
- `${selected ? getColorModeValue(theme, 'sidebarBackground') : theme.palette.background.paper}`,
+ backgroundColor: (theme) => {
+ if (!selected) {
+ return theme.palette.background.paper;
+ }
+ if (theme.chiaTheme?.variant === 'chia') {
+ return theme.palette.action.selected;
+ }
+ if (theme.palette.sidebarSelectedFill) {
+ return getColorModeValue(theme, 'sidebarSelectedFill' as Parameters[1]);
+ }
+ return alpha(theme.palette.primary.main, theme.palette.mode === 'dark' ? 0.18 : 0.1);
+ },
position: 'relative',
overflow: 'visible',
'&:hover': {
borderColor: (theme) =>
- `${disabled ? theme.palette.divider : selected ? theme.palette.highlight.main : theme.palette.divider}`,
+ `${disabled ? theme.palette.divider : selected ? theme.palette.primary.main : theme.palette.divider}`,
},
}}
>
diff --git a/packages/core/src/components/Fonts/Fonts.tsx b/packages/core/src/components/Fonts/Fonts.tsx
index a85ad7714d..d59591ebd4 100644
--- a/packages/core/src/components/Fonts/Fonts.tsx
+++ b/packages/core/src/components/Fonts/Fonts.tsx
@@ -1,11 +1,35 @@
+import { useTheme } from '@mui/material/styles';
+import React from 'react';
import { createGlobalStyle } from 'styled-components';
-import '@fontsource/roboto/700.css';
-import '@fontsource/roboto/500.css';
-import '@fontsource/roboto/400.css';
+
import '@fontsource/roboto/300.css';
+import '@fontsource/roboto/400.css';
+import '@fontsource/roboto/500.css';
+import '@fontsource/roboto/700.css';
+import '@fontsource/inter/400.css';
+import '@fontsource/inter/500.css';
+import '@fontsource/inter/600.css';
+import '@fontsource/poppins/500.css';
+import '@fontsource/poppins/600.css';
+import '@fontsource/poppins/700.css';
+
+import { THEME_TYPOGRAPHY } from '../../theme/typography';
+import { DEFAULT_THEME_VARIANT, parseThemeVariantId } from '../../theme/variantTypes';
+
+function getVariantFromTheme(theme: { chiaTheme?: { variant?: unknown } }) {
+ return parseThemeVariantId(theme.chiaTheme?.variant, DEFAULT_THEME_VARIANT);
+}
-export default createGlobalStyle`
+const GlobalStyle = createGlobalStyle<{ $fontFamily: string }>`
body {
- font-family: "Roboto";
+ font-family: ${(props) => props.$fontFamily};
}
`;
+
+export default function Fonts() {
+ const theme = useTheme();
+ const variant = getVariantFromTheme(theme);
+ const { fontFamily } = THEME_TYPOGRAPHY[variant];
+
+ return ;
+}
diff --git a/packages/core/src/components/LayoutDashboard/LayoutDashboard.tsx b/packages/core/src/components/LayoutDashboard/LayoutDashboard.tsx
index 1620bc08cd..450e093ebe 100644
--- a/packages/core/src/components/LayoutDashboard/LayoutDashboard.tsx
+++ b/packages/core/src/components/LayoutDashboard/LayoutDashboard.tsx
@@ -2,7 +2,7 @@ import { useGetLoggedInFingerprintQuery, useGetKeyQuery, useFingerprintSettings
import { Trans } from '@lingui/macro';
import { Edit as EditIcon } from '@mui/icons-material';
import { Box, AppBar, Toolbar, Drawer, IconButton, Typography, CircularProgress, Button } from '@mui/material';
-import { useTheme } from '@mui/material/styles';
+import { alpha, useTheme } from '@mui/material/styles';
import React, { type ReactNode, useState, Suspense, useCallback } from 'react';
import { Outlet } from 'react-router-dom';
import styled from 'styled-components';
@@ -12,6 +12,7 @@ import useGetLatestVersionFromWebsite from '../../hooks/useGetLatestVersionFromW
import useOpenDialog from '../../hooks/useOpenDialog';
import EmojiAndColorPicker from '../../screens/SelectKey/EmojiAndColorPicker';
import SelectKeyRenameForm from '../../screens/SelectKey/SelectKeyRenameForm';
+import getColorModeValue from '../../utils/useColorModeValue';
import Flex from '../Flex';
import Link from '../Link';
import Loading from '../Loading';
@@ -32,10 +33,18 @@ const StyledDrawer = styled(Drawer)`
width: ${({ theme }) => theme.drawer.width};
flex-shrink: 0;
- > div {
+ & .MuiDrawer-paper {
width: ${({ theme }) => theme.drawer.width};
- // border-width: 0px;
- border-right: 1px solid ${({ theme }) => (theme.palette.mode === 'dark' ? Color.Neutral[700] : Color.Neutral[300])};
+ background-color: ${({ theme }) => getColorModeValue(theme, 'sidebarBackground')};
+ border-right: 1px solid
+ ${({ theme }) =>
+ theme.palette.mode === 'dark'
+ ? alpha(getColorModeValue(theme, 'border'), 0.45)
+ : alpha(getColorModeValue(theme, 'border'), 0.35)};
+ color: ${({ theme }) =>
+ theme.palette.sidebarText
+ ? getColorModeValue(theme, 'sidebarText' as Parameters[1])
+ : getColorModeValue(theme, 'sidebarIcon')};
}
`;
diff --git a/packages/core/src/components/LineChart/LineChart.tsx b/packages/core/src/components/LineChart/LineChart.tsx
index dcb11b198d..ce9efaedb8 100644
--- a/packages/core/src/components/LineChart/LineChart.tsx
+++ b/packages/core/src/components/LineChart/LineChart.tsx
@@ -1,4 +1,4 @@
-import { alpha } from '@mui/material';
+import { alpha, useTheme } from '@mui/material';
import { SparkLineChart, type SparkLineChartProps } from '@mui/x-charts';
import { areaElementClasses } from '@mui/x-charts/LineChart';
import BigNumber from 'bignumber.js';
@@ -14,24 +14,17 @@ const StyledGraphContainer = styled.div<{ height: number }>`
height: ${({ height }) => `${height}px`};
`;
-function LinearGradient() {
+function LinearGradient({ chartColor }: { chartColor: string }) {
return (
-
-
+
+
);
}
-const sx = {
- [`& .${areaElementClasses.root}`]: {
- fill: 'url(#graph-gradient)',
- },
-};
-
-const chartColors = [Color.Green[500]];
const chartMargin = { top: 0, bottom: 0, left: 0, right: 0 };
const defaultXValueFormatter = (value: number) => value.toString();
const defaultYValueFormatter = (value: number | BigNumber | null) => (value !== null ? value.toString() : '');
@@ -41,16 +34,25 @@ type Point = {
y: number | BigNumber;
};
-const MemoLineChart = memo((props: SparkLineChartProps) => (
-
-
-
-
-));
+const MemoLineChart = memo((props: SparkLineChartProps & { chartColor: string }) => {
+ const { chartColor, sx, ...rest } = props;
+ const areaSx = {
+ [`& .${areaElementClasses.root}`]: {
+ fill: 'url(#graph-gradient)',
+ },
+ ...sx,
+ };
+
+ return (
+
+
+
+
+ );
+});
export type LineChartProps = {
data: Point[];
- // min?: number;
height?: number;
xValueFormatter?: (value: number) => string;
yValueFormatter?: (value: number | BigNumber | null) => string;
@@ -59,12 +61,15 @@ export type LineChartProps = {
export default function LineChart(props: LineChartProps) {
const {
data,
- // min: defaultMin = 0,
xValueFormatter = defaultXValueFormatter,
yValueFormatter = defaultYValueFormatter,
height = 150,
} = props;
+ const theme = useTheme();
+ const chartColor = theme.palette.primary?.main ?? Color.Green[500];
+ const chartColors = useMemo(() => [chartColor], [chartColor]);
+
const stringifiedData = useMemo(() => JSONbig.stringify(data), [data]);
const freezedData = useMemo(() => JSONbig.parse(stringifiedData), [stringifiedData]);
@@ -94,10 +99,10 @@ export default function LineChart(props: LineChartProps) {
curve="monotoneX"
margin={chartMargin}
colors={chartColors}
+ chartColor={chartColor}
area
showHighlight
showTooltip
- sx={sx}
/>
);
diff --git a/packages/core/src/components/Logo/Logo.tsx b/packages/core/src/components/Logo/Logo.tsx
index 1f10ab30ba..cf853dad31 100644
--- a/packages/core/src/components/Logo/Logo.tsx
+++ b/packages/core/src/components/Logo/Logo.tsx
@@ -1,9 +1,10 @@
-import { Chia } from '@chia-network/icons';
import { Box, BoxProps } from '@mui/material';
import React from 'react';
import styled from 'styled-components';
-const StyledChia = styled(Chia)`
+import { ThemedChia } from '../ThemedChia';
+
+const StyledChia = styled(ThemedChia)`
max-width: 100%;
width: auto;
height: auto;
diff --git a/packages/core/src/components/Settings/SettingsApp.tsx b/packages/core/src/components/Settings/SettingsApp.tsx
index d3384ac079..4ec3355d32 100644
--- a/packages/core/src/components/Settings/SettingsApp.tsx
+++ b/packages/core/src/components/Settings/SettingsApp.tsx
@@ -19,6 +19,7 @@ import Flex from '../Flex';
import NewerAppVersionAvailable from '../LayoutDashboard/NewerAppVersionAvailable';
import Link from '../Link';
import LocaleToggle from '../LocaleToggle';
+import ThemeVariantToggle from '../ThemeVariantToggle';
import SettingsLabel from './SettingsLabel';
@@ -109,6 +110,16 @@ export default function SettingsApp(props: SettingsAppProps) {
+
+
+ Color Theme
+
+
+
+ Applies immediately. Stored locally in app preferences.
+
+
+
Language
diff --git a/packages/core/src/components/SideBarItem/SideBarItem.tsx b/packages/core/src/components/SideBarItem/SideBarItem.tsx
index 0624eaa200..5e9ad53b0b 100644
--- a/packages/core/src/components/SideBarItem/SideBarItem.tsx
+++ b/packages/core/src/components/SideBarItem/SideBarItem.tsx
@@ -1,46 +1,57 @@
import { alpha, ListItem, ListItemIcon, Typography } from '@mui/material';
+import type { Theme } from '@mui/material/styles';
import { styled } from '@mui/material/styles';
import React, { type ReactNode } from 'react';
import { useNavigate, useMatch } from 'react-router-dom';
-import Color from '../../constants/Color';
-import useColorModeValue from '../../utils/useColorModeValue';
+import getColorModeValue from '../../utils/useColorModeValue';
import Flex from '../Flex';
-const StyledListItemIcon = styled(ListItemIcon)`
+type SidebarPaletteKey =
+ | 'sidebarBackground'
+ | 'sidebarSelectedFill'
+ | 'sidebarIcon'
+ | 'sidebarIconSelected'
+ | 'sidebarIconHover'
+ | 'sidebarText';
+
+function paletteColor(theme: Theme, key: SidebarPaletteKey): string {
+ return getColorModeValue(theme, key as Parameters[1]);
+}
+
+function selectedFill(theme: Theme): string {
+ if (theme.palette.sidebarSelectedFill) {
+ return paletteColor(theme, 'sidebarSelectedFill');
+ }
+ return paletteColor(theme, 'sidebarBackground');
+}
+
+function labelColor(theme: Theme): string {
+ if (theme.palette.sidebarText) {
+ return paletteColor(theme, 'sidebarText');
+ }
+ return paletteColor(theme, 'sidebarIcon');
+}
+
+const StyledListItemIcon = styled(ListItemIcon)<{ selected?: boolean }>`
min-width: auto;
position: relative;
- background-color: ${({ theme, selected }) =>
- selected ? useColorModeValue(theme, 'sidebarBackground') : 'transparent'};
- border-radius: ${({ theme }) => theme.spacing(1.5)};
- width: ${({ theme }) => theme.spacing(6)};
- height: ${({ theme }) => theme.spacing(6)};
+ background-color: ${({ theme, selected }) => (selected ? selectedFill(theme) : 'transparent')};
+ border-radius: ${({ theme }) => theme.spacing(1.25)};
+ width: ${({ theme }) => theme.spacing(5.25)};
+ height: ${({ theme }) => theme.spacing(5.25)};
border: ${({ selected, theme }) =>
- `1px solid ${selected ? theme.palette.highlight.main : useColorModeValue(theme, 'border')}`};
+ selected ? `1px solid ${theme.palette.highlight.main}` : '1px solid transparent'};
display: flex;
align-items: center;
justify-content: center;
- transition: border 0.3s ease-in-out;
-
- &::after {
- content: '';
- border-radius: ${({ theme }) => theme.spacing(1.5)};
- position: absolute;
- z-index: -1;
- top: 0;
- left: 0;
- width: 100%;
- height: 100%;
- box-shadow:
- 0px -2px 4px ${alpha(Color.Green[300], 0.41)},
- 0px 1px 8px ${alpha(Color.Lime[400], 0.45)};
- opacity: 0;
- transition: opacity 0.3s ease-in-out;
- }
+ transition:
+ border-color 0.2s ease-in-out,
+ background-color 0.2s ease-in-out;
svg {
color: ${({ selected, theme }) =>
- selected ? useColorModeValue(theme, 'sidebarIconSelected') : useColorModeValue(theme, 'sidebarIcon')};
+ selected ? paletteColor(theme, 'sidebarIconSelected') : paletteColor(theme, 'sidebarIcon')};
}
`;
@@ -50,29 +61,27 @@ const StyledListItem = styled(ListItem)`
align-items: center;
padding-left: 0;
padding-right: 0;
- padding-top: ${({ theme }) => theme.spacing(1)};
- padding-bottom: ${({ theme }) => theme.spacing(1)};
+ padding-top: ${({ theme }) => theme.spacing(0.5)};
+ padding-bottom: ${({ theme }) => theme.spacing(0.5)};
&:hover {
background-color: transparent;
}
&:hover ${StyledListItemIcon} {
- border-color: ${Color.Green[500]};
+ background-color: ${({ theme }) => alpha(paletteColor(theme, 'sidebarIcon'), 0.12)};
+ border-color: ${({ theme }) => alpha(paletteColor(theme, 'sidebarIconHover'), 0.55)};
svg {
- color: ${({ theme }) => useColorModeValue(theme, 'sidebarIconHover')} !important;
- }
-
- &::after {
- opacity: 1;
+ color: ${({ theme }) => paletteColor(theme, 'sidebarIconHover')} !important;
}
}
`;
const StyledListItemText = styled(Typography)`
- font-size: ${({ theme }) => theme.typography.pxToRem(10)} !important;
- font-weight: 500;
+ font-size: ${({ theme }) => theme.typography.pxToRem(9.5)} !important;
+ font-weight: 700;
+ color: ${({ theme }) => labelColor(theme)};
`;
export type SideBarItemProps = {
@@ -102,7 +111,7 @@ export default function SideBarItem(props: SideBarItemProps) {
return (
handleClick()} {...rest}>
-
+
diff --git a/packages/core/src/components/StateIndicator/StateIndicator.tsx b/packages/core/src/components/StateIndicator/StateIndicator.tsx
index 8c74abb089..ff3eeb3d9f 100644
--- a/packages/core/src/components/StateIndicator/StateIndicator.tsx
+++ b/packages/core/src/components/StateIndicator/StateIndicator.tsx
@@ -1,22 +1,42 @@
+import { useTheme } from '@mui/material/styles';
import React, { ReactNode } from 'react';
import styled from 'styled-components';
import State from '../../constants/State';
import StateColor from '../../constants/StateColor';
+import { getSemanticColors } from '../../theme/semanticColors';
import Flex from '../Flex';
import StateIndicatorDot from './StateIndicatorDot';
-const Color = {
- [State.SUCCESS]: StateColor.SUCCESS,
- [State.WARNING]: StateColor.WARNING,
- [State.ERROR]: StateColor.ERROR,
-};
-
const StyledFlexContainer = styled(({ ...rest }) => )`
gap: 4px;
`;
+function useStateColor(state: State): string {
+ const theme = useTheme();
+ const semanticColors = getSemanticColors(theme.palette);
+
+ switch (state) {
+ case State.SUCCESS:
+ return semanticColors.success;
+ case State.WARNING:
+ return semanticColors.warning;
+ case State.ERROR:
+ return semanticColors.error;
+ default:
+ break;
+ }
+
+ const Color = {
+ [State.SUCCESS]: StateColor.SUCCESS,
+ [State.WARNING]: StateColor.WARNING,
+ [State.ERROR]: StateColor.ERROR,
+ };
+
+ return Color[state];
+}
+
export type StateComponentProps = {
children?: ReactNode;
state: State;
@@ -28,20 +48,15 @@ export type StateComponentProps = {
};
export default function StateComponent(props: StateComponentProps) {
- const {
- children,
- state,
- indicator = false,
- reversed = false,
- color = Color[state],
- gap = 1,
- hideTitle = false,
- } = props;
+ const { children, state, indicator = false, reversed = false, color: colorProp, gap = 1, hideTitle = false } = props;
+
+ const themeColor = useStateColor(state);
+ const color = colorProp ?? themeColor;
return (
{!hideTitle && {children}}
- {indicator && }
+ {indicator && }
);
}
diff --git a/packages/core/src/components/ThemeVariantToggle/ThemeVariantToggle.tsx b/packages/core/src/components/ThemeVariantToggle/ThemeVariantToggle.tsx
new file mode 100644
index 0000000000..4d0b85cf6d
--- /dev/null
+++ b/packages/core/src/components/ThemeVariantToggle/ThemeVariantToggle.tsx
@@ -0,0 +1,63 @@
+import { ExpandMore, Palette } from '@mui/icons-material';
+import { Menu, MenuItem } from '@mui/material';
+import React, { useMemo } from 'react';
+import { useToggle } from 'react-use';
+
+import useThemeVariant from '../../hooks/useThemeVariant';
+import { THEME_VARIANT_META, type ThemeVariantId } from '../../theme/variantTypes';
+import Button from '../Button';
+
+export default function ThemeVariantToggle(props: React.ComponentProps) {
+ const { ...rest } = props;
+ const { themeVariant, setThemeVariant } = useThemeVariant();
+ const [open, toggleOpen] = useToggle(false);
+ const [anchorEl, setAnchorEl] = React.useState(null);
+
+ const currentLabel = useMemo(
+ () => THEME_VARIANT_META.find((item) => item.id === themeVariant)?.label ?? themeVariant,
+ [themeVariant],
+ );
+
+ const handleClick = (event: React.MouseEvent) => {
+ setAnchorEl(event.currentTarget);
+ toggleOpen();
+ };
+
+ const handleClose = () => {
+ setAnchorEl(null);
+ toggleOpen();
+ };
+
+ function handleSelect(variant: ThemeVariantId) {
+ setThemeVariant(variant);
+ handleClose();
+ }
+
+ return (
+ <>
+ }
+ endIcon={}
+ data-testid="ThemeVariantToggle-dropdown"
+ {...rest}
+ >
+ {currentLabel}
+
+
+ >
+ );
+}
diff --git a/packages/core/src/components/ThemeVariantToggle/index.ts b/packages/core/src/components/ThemeVariantToggle/index.ts
new file mode 100644
index 0000000000..04eebef146
--- /dev/null
+++ b/packages/core/src/components/ThemeVariantToggle/index.ts
@@ -0,0 +1 @@
+export { default } from './ThemeVariantToggle';
diff --git a/packages/core/src/components/ThemedChia/ThemedChia.tsx b/packages/core/src/components/ThemedChia/ThemedChia.tsx
new file mode 100644
index 0000000000..3592400fbf
--- /dev/null
+++ b/packages/core/src/components/ThemedChia/ThemedChia.tsx
@@ -0,0 +1,16 @@
+import { SvgIcon, type SvgIconProps } from '@mui/material';
+import React from 'react';
+
+import { useThemeAssets } from '../../theme/ThemeAssetsContext';
+
+export function ThemedChia(props: SvgIconProps) {
+ const { chiaWordmark } = useThemeAssets();
+ return ;
+}
+
+export function ThemedChiaBlack(props: SvgIconProps) {
+ const { chiaWordmarkBlack } = useThemeAssets();
+ return (
+
+ );
+}
diff --git a/packages/core/src/components/ThemedChia/index.ts b/packages/core/src/components/ThemedChia/index.ts
new file mode 100644
index 0000000000..6516c6ef82
--- /dev/null
+++ b/packages/core/src/components/ThemedChia/index.ts
@@ -0,0 +1 @@
+export { ThemedChia, ThemedChiaBlack } from './ThemedChia';
diff --git a/packages/core/src/components/index.ts b/packages/core/src/components/index.ts
index b905ccd93f..66eae4400f 100644
--- a/packages/core/src/components/index.ts
+++ b/packages/core/src/components/index.ts
@@ -51,6 +51,7 @@ export { default as Loading } from './Loading';
export { default as LoadingOverlay } from './LoadingOverlay';
export { default as LocaleProvider, LocaleContext } from './LocaleProvider';
export { default as LocaleToggle } from './LocaleToggle';
+export { default as ThemeVariantToggle } from './ThemeVariantToggle';
export { default as Log } from './Log';
export { default as Logo } from './Logo';
export * from './Menu';
diff --git a/packages/core/src/hooks/index.ts b/packages/core/src/hooks/index.ts
index 2eef696898..0b888d87eb 100644
--- a/packages/core/src/hooks/index.ts
+++ b/packages/core/src/hooks/index.ts
@@ -1,6 +1,7 @@
export { default as useAppVersion } from './useAppVersion';
export { default as useAuth } from './useAuth';
export { default as useDarkMode } from './useDarkMode';
+export { default as useThemeVariant } from './useThemeVariant';
export { default as useHiddenList } from './useHiddenList';
export { default as useCurrencyCode } from './useCurrencyCode';
export { default as useIsSimulator } from './useIsSimulator';
diff --git a/packages/core/src/hooks/useThemeVariant.ts b/packages/core/src/hooks/useThemeVariant.ts
new file mode 100644
index 0000000000..7c84ab2966
--- /dev/null
+++ b/packages/core/src/hooks/useThemeVariant.ts
@@ -0,0 +1,24 @@
+import { usePrefs } from '@chia-network/api-react';
+import { useCallback, useMemo } from 'react';
+
+import { DEFAULT_THEME_VARIANT, parseThemeVariantId, type ThemeVariantId } from '../theme/variantTypes';
+
+const PREFS_KEY = 'themeVariant';
+
+export default function useThemeVariant(): {
+ themeVariant: ThemeVariantId;
+ setThemeVariant: (variant: ThemeVariantId) => void;
+} {
+ const [stored, setStored] = usePrefs(PREFS_KEY, DEFAULT_THEME_VARIANT);
+
+ const themeVariant = useMemo(() => parseThemeVariantId(stored), [stored]);
+
+ const setThemeVariant = useCallback(
+ (variant: ThemeVariantId) => {
+ setStored(variant);
+ },
+ [setStored],
+ );
+
+ return { themeVariant, setThemeVariant };
+}
diff --git a/packages/core/src/screens/SelectKey/SelectKey.tsx b/packages/core/src/screens/SelectKey/SelectKey.tsx
index 88362c9d34..640daeef24 100644
--- a/packages/core/src/screens/SelectKey/SelectKey.tsx
+++ b/packages/core/src/screens/SelectKey/SelectKey.tsx
@@ -6,7 +6,7 @@ import {
useGetKeysQuery,
type Serializable,
} from '@chia-network/api-react';
-import { ChiaBlack, Coins } from '@chia-network/icons';
+import { Coins } from '@chia-network/icons';
import { Trans } from '@lingui/macro';
import { Delete as DeleteIcon } from '@mui/icons-material';
import { Alert, Typography, Container, ListItemIcon } from '@mui/material';
@@ -23,6 +23,7 @@ import Flex from '../../components/Flex';
import Loading from '../../components/Loading';
import MenuItem from '../../components/MenuItem/MenuItem';
import More from '../../components/More';
+import { ThemedChiaBlack } from '../../components/ThemedChia';
import TooltipIcon from '../../components/TooltipIcon';
import Color from '../../constants/Color';
import useAuth from '../../hooks/useAuth';
@@ -218,7 +219,7 @@ export default function SelectKey() {
sx={{ borderBottom: `1px solid ${Color.Neutral[level]}`, paddingBottom: '30px' }}
>
-
+
Wallet Keys
diff --git a/packages/core/src/theme/ThemeAssetsContext.tsx b/packages/core/src/theme/ThemeAssetsContext.tsx
new file mode 100644
index 0000000000..f0615b27d3
--- /dev/null
+++ b/packages/core/src/theme/ThemeAssetsContext.tsx
@@ -0,0 +1,23 @@
+import React, { createContext, useContext, type ReactNode } from 'react';
+
+import type { ThemeAssets } from './themeAugmentation';
+
+const ThemeAssetsContext = createContext(null);
+
+export type ThemeAssetsProviderProps = {
+ assets: ThemeAssets;
+ children: ReactNode;
+};
+
+export function ThemeAssetsProvider(props: ThemeAssetsProviderProps) {
+ const { assets, children } = props;
+ return {children};
+}
+
+export function useThemeAssets(): ThemeAssets {
+ const assets = useContext(ThemeAssetsContext);
+ if (!assets) {
+ throw new Error('useThemeAssets must be used within ThemeAssetsProvider (wrap AppProviders in the GUI).');
+ }
+ return assets;
+}
diff --git a/packages/core/src/theme/buttonStyles.ts b/packages/core/src/theme/buttonStyles.ts
new file mode 100644
index 0000000000..d91e53b334
--- /dev/null
+++ b/packages/core/src/theme/buttonStyles.ts
@@ -0,0 +1,57 @@
+import type { Components, Theme } from '@mui/material/styles';
+
+type ButtonStyleOptions = {
+ borderRadius?: number;
+ fontWeight?: number;
+ textTransform?: 'none' | 'capitalize' | 'uppercase' | 'lowercase';
+ hoverBackground?: string;
+ hoverShadow?: string;
+ hoverTransform?: string;
+ outlinedHoverShadow?: string;
+ containedBackground?: string;
+ containedHoverBackground?: string;
+ containedShadow?: string;
+ containedHoverShadow?: string;
+};
+
+export default function getButtonStyles({
+ borderRadius,
+ fontWeight,
+ textTransform,
+ hoverBackground,
+ hoverShadow,
+ hoverTransform,
+ outlinedHoverShadow,
+ containedBackground,
+ containedHoverBackground,
+ containedShadow,
+ containedHoverShadow,
+}: ButtonStyleOptions): NonNullable['MuiButton']>['styleOverrides'] {
+ return {
+ root: {
+ borderRadius,
+ fontWeight,
+ textTransform,
+ transition:
+ 'background-color 160ms ease, background-image 160ms ease, border-color 160ms ease, box-shadow 160ms ease, transform 160ms ease',
+ '&:not(.Mui-disabled):hover': {
+ boxShadow: hoverShadow,
+ transform: hoverTransform,
+ },
+ '&.MuiButton-text:not(.Mui-disabled):hover, &.MuiButton-outlined:not(.Mui-disabled):hover': {
+ backgroundColor: hoverBackground,
+ },
+ '&.MuiButton-outlined:not(.Mui-disabled):hover': {
+ boxShadow: outlinedHoverShadow,
+ },
+ '&.MuiButton-containedPrimary:not(.Mui-disabled)': {
+ backgroundImage: containedBackground,
+ boxShadow: containedShadow,
+ },
+ '&.MuiButton-containedPrimary:not(.Mui-disabled):hover': {
+ backgroundImage: containedHoverBackground,
+ boxShadow: containedHoverShadow,
+ },
+ },
+ };
+}
diff --git a/packages/core/src/theme/dark.ts b/packages/core/src/theme/dark.ts
index 5ad54857d3..d848a63d51 100644
--- a/packages/core/src/theme/dark.ts
+++ b/packages/core/src/theme/dark.ts
@@ -1,161 +1,2 @@
-import { alpha } from '@mui/material';
-import { createTheme } from '@mui/material/styles';
-
-import Color from '../constants/Color';
-
-import theme from './default';
-
-export default createTheme(
- {
- ...theme,
- palette: {
- ...theme.palette,
- background: {
- ...theme.palette.background,
- default: Color.Neutral[900],
- paper: Color.Neutral[900],
- card: alpha(Color.Neutral[50], 0.08),
- },
- secondary: {
- ...theme.palette.secondary,
- main: Color.Neutral[50], // balance text, confirmation text in tx table
- contrastText: Color.Neutral[900],
- },
- info: {
- ...theme.palette.info,
- main: Color.Neutral[400],
- },
- text: {
- primary: Color.Text.Dark.Primary,
- secondary: Color.Text.Dark.Secondary,
- disabled: Color.Text.Dark.Disabled,
- },
- sidebarBackground: theme.palette.sidebarBackground.dark,
-
- colors: {
- royal: {
- main: Color.Royal[300],
- border: Color.Royal[700],
- accent: Color.Royal[800],
- },
- grape: {
- main: Color.Grape[400],
- border: Color.Grape[700],
- accent: Color.Grape[800],
- },
- purple: {
- main: Color.Purple[300],
- border: Color.Purple[700],
- accent: Color.Purple[800],
- },
- red: {
- main: Color.Red[300],
- border: Color.Red[700],
- accent: Color.Red[800],
- },
- orange: {
- main: Color.Orange[300],
- border: Color.Orange[700],
- accent: Color.Orange[800],
- },
- yellow: {
- main: Color.Yellow[400],
- border: Color.Yellow[700],
- accent: Color.Yellow[800],
- },
- lime: {
- main: Color.Lime[400],
- border: Color.Lime[700],
- accent: Color.Lime[800],
- },
- green: {
- main: Color.Green[300],
- border: Color.Green[700],
- accent: Color.Green[800],
- },
- aqua: {
- main: Color.Aqua[300],
- border: Color.Aqua[700],
- accent: Color.Aqua[800],
- },
- blue: {
- main: Color.Blue[300],
- border: Color.Blue[700],
- accent: Color.Blue[800],
- },
- comet: {
- main: Color.Comet[500],
- border: Color.Comet[500],
- accent: Color.Comet[900],
- },
- storm: {
- main: Color.Storm[500],
- border: Color.Storm[700],
- accent: Color.Storm[900],
- },
- wine: {
- main: Color.Wine[500],
- border: Color.Wine[700],
- accent: Color.Wine[900],
- },
- cosmic: {
- main: Color.Cosmic[500],
- border: Color.Cosmic[700],
- accent: Color.Cosmic[900],
- },
- sand: {
- main: Color.Sand[500],
- border: Color.Sand[700],
- accent: Color.Sand[900],
- },
- husk: {
- main: Color.Husk[500],
- border: Color.Husk[700],
- accent: Color.Husk[900],
- },
- bean: {
- main: Color.Bean[500],
- border: Color.Bean[700],
- accent: Color.Bean[900],
- },
- forest: {
- main: Color.Forest[500],
- border: Color.Forest[700],
- accent: Color.Forest[900],
- },
- sea: {
- main: Color.Sea[500],
- border: Color.Sea[700],
- accent: Color.Sea[900],
- },
- glacier: {
- main: Color.Glacier[500],
- border: Color.Glacier[700],
- accent: Color.Glacier[900],
- },
- default: {
- main: Color.Neutral[600],
- border: Color.Neutral[600],
- accent: Color.Neutral[900],
- background: Color.Neutral[300],
- backgroundBadge: Color.Neutral[600],
- backgroundLight: Color.Neutral[700],
- text: Color.Neutral[200],
- },
- },
- mode: 'dark',
- },
- },
- {
- components: {
- ...theme.components,
- MuiTooltip: {
- styleOverrides: {
- tooltip: {
- backgroundColor: Color.Neutral[700],
- },
- },
- },
- },
- },
-);
+/** @deprecated Import via theme registry. Kept for compatibility with older imports. */
+export { default } from './variants/field/dark';
diff --git a/packages/core/src/theme/default.ts b/packages/core/src/theme/default.ts
index 8842cce0ff..f6c472e20e 100644
--- a/packages/core/src/theme/default.ts
+++ b/packages/core/src/theme/default.ts
@@ -1,125 +1,2 @@
-import Color from '../constants/Color';
-
-declare module '@mui/material' {
- interface Color {
- main: string;
- dark: string;
- }
-}
-
-export default {
- palette: {
- background: {
- default: Color.Neutral[50],
- },
- primary: {
- main: Color.Green[500],
- contrastText: Color.Neutral[50],
- },
- secondary: {
- main: Color.Neutral[900],
- contrastText: Color.Neutral[50],
- },
- danger: {
- main: Color.Red[600],
- dark: Color.Red[700],
- contrastText: Color.Neutral[50],
- },
- default: {
- main: Color.Neutral[300],
- dark: Color.Neutral[400],
- contrastText: Color.Neutral[900],
- },
- highlight: {
- main: Color.Chia.Primary,
- },
- border: {
- main: Color.Neutral[300],
- dark: Color.Neutral[700],
- },
- sidebarBackground: {
- main: Color.Green[50],
- dark: Color.Neutral[600],
- },
- sidebarIconSelected: {
- main: Color.Green[800],
- dark: Color.Green[500],
- },
- sidebarIcon: {
- main: Color.Neutral[500],
- dark: Color.Neutral[400],
- },
- sidebarIconHover: {
- main: Color.Neutral[700],
- dark: Color.Neutral[50],
- },
- info: {
- main: Color.Neutral[500],
- dark: Color.Neutral[50],
- },
- },
- drawer: {
- width: '72px',
- },
- mixins: {
- toolbar: {
- minHeight: '90px',
- },
- },
- components: {
- MuiTooltip: {
- styleOverrides: {
- tooltip: {
- backgroundColor: Color.Neutral[500],
- },
- },
- },
- MuiSvgIcon: {
- variants: [
- {
- props: { fontSize: 'extraLarge' },
- style: {
- fontSize: '3rem',
- },
- },
- {
- props: { fontSize: 'sidebarIcon' },
- style: {
- fontSize: '2rem',
- },
- },
- {
- props: { fontSize: 'notificationIcon' },
- style: {
- fontSize: '5rem',
- },
- },
- ],
- },
- MuiTypography: {
- variants: [
- {
- props: { variant: 'h6' },
- style: {
- fontWeight: 400,
- },
- },
- ],
- },
- MuiChip: {
- variants: [
- {
- props: { size: 'extraSmall' },
- style: {
- height: '20px',
- fontSize: '0.75rem',
- '.MuiChip-label': {
- paddingLeft: '6px',
- paddingRight: '6px',
- },
- },
- },
- ],
- },
- },
-};
+/** @deprecated Import via theme registry. Kept for compatibility with older imports. */
+export { default } from './variants/field/default';
diff --git a/packages/core/src/theme/index.ts b/packages/core/src/theme/index.ts
index 05938953a7..ba98bbca70 100644
--- a/packages/core/src/theme/index.ts
+++ b/packages/core/src/theme/index.ts
@@ -1,2 +1,16 @@
-export { default as dark } from './dark';
-export { default as light } from './light';
+import './themeAugmentation';
+
+export { default as dark } from './variants/field/dark';
+export { default as light } from './variants/field/light';
+export { resolveAppTheme, resolveAppThemeFromPrefs, DEFAULT_THEME_VARIANT, parseThemeVariantId } from './registry';
+export { getSemanticColors } from './semanticColors';
+export { THEME_TYPOGRAPHY } from './typography';
+export type { ThemeAssets, ThemeSvgComponent } from './themeAugmentation';
+export { ThemeAssetsProvider, useThemeAssets } from './ThemeAssetsContext';
+export {
+ THEME_VARIANT_IDS,
+ THEME_VARIANT_META,
+ type ThemeVariantId,
+ type ThemeVariantMeta,
+ isThemeVariantId,
+} from './variantTypes';
diff --git a/packages/core/src/theme/light.ts b/packages/core/src/theme/light.ts
index 8b6428097a..0e0c3f1856 100644
--- a/packages/core/src/theme/light.ts
+++ b/packages/core/src/theme/light.ts
@@ -1,139 +1,2 @@
-import { createTheme } from '@mui/material/styles';
-
-import Color from '../constants/Color';
-
-import theme from './default';
-
-export default createTheme({
- ...theme,
- palette: {
- ...theme.palette,
- background: {
- ...theme.palette.background,
- card: Color.Neutral[50],
- paper: Color.Neutral[50],
- },
- info: {
- ...theme.palette.info,
- main: Color.Neutral[500],
- },
- text: {
- primary: Color.Text.Light.Primary,
- secondary: Color.Text.Light.Secondary,
- disabled: Color.Text.Light.Disabled,
- },
- sidebarBackground: theme.palette.sidebarBackground.main,
-
- colors: {
- royal: {
- main: Color.Royal[200],
- border: Color.Royal[400],
- accent: Color.Royal[600],
- },
- grape: {
- main: Color.Grape[200],
- border: Color.Grape[400],
- accent: Color.Grape[600],
- },
- purple: {
- main: Color.Purple[200],
- border: Color.Purple[400],
- accent: Color.Purple[600],
- },
- red: {
- main: Color.Red[200],
- border: Color.Red[400],
- accent: Color.Red[600],
- },
- orange: {
- main: Color.Orange[200],
- border: Color.Orange[400],
- accent: Color.Orange[600],
- },
- yellow: {
- main: Color.Yellow[200],
- border: Color.Yellow[500],
- accent: Color.Yellow[600],
- },
- lime: {
- main: Color.Lime[200],
- border: Color.Lime[500],
- accent: Color.Lime[600],
- },
- green: {
- main: Color.Green[200],
- border: Color.Green[400],
- accent: Color.Green[600],
- },
- aqua: {
- main: Color.Aqua[200],
- border: Color.Aqua[400],
- accent: Color.Aqua[600],
- },
- blue: {
- main: Color.Blue[200],
- border: Color.Blue[400],
- accent: Color.Blue[600],
- },
- comet: {
- main: Color.Comet[300],
- border: Color.Comet[400],
- accent: Color.Comet[700],
- },
- storm: {
- main: Color.Storm[300],
- border: Color.Storm[400],
- accent: Color.Storm[700],
- },
- wine: {
- main: Color.Wine[300],
- border: Color.Wine[400],
- accent: Color.Wine[700],
- },
- cosmic: {
- main: Color.Cosmic[300],
- border: Color.Cosmic[400],
- accent: Color.Cosmic[700],
- },
- sand: {
- main: Color.Sand[300],
- border: Color.Sand[400],
- accent: Color.Sand[700],
- },
- husk: {
- main: Color.Husk[300],
- border: Color.Husk[400],
- accent: Color.Husk[700],
- },
- bean: {
- main: Color.Bean[300],
- border: Color.Bean[400],
- accent: Color.Bean[700],
- },
- forest: {
- main: Color.Forest[300],
- border: Color.Forest[400],
- accent: Color.Forest[700],
- },
- sea: {
- main: Color.Sea[300],
- border: Color.Sea[400],
- accent: Color.Sea[700],
- },
- glacier: {
- main: Color.Glacier[300],
- border: Color.Glacier[400],
- accent: Color.Glacier[700],
- },
- default: {
- main: Color.Neutral[300],
- border: Color.Neutral[400],
- accent: Color.Neutral[900],
- background: Color.Neutral[300],
- backgroundBadge: Color.Neutral[100],
- backgroundLight: Color.Neutral[50],
- text: Color.Neutral[600],
- },
- },
- },
-});
+/** @deprecated Import via theme registry. Kept for compatibility with older imports. */
+export { default } from './variants/field/light';
diff --git a/packages/core/src/theme/registry.ts b/packages/core/src/theme/registry.ts
new file mode 100644
index 0000000000..a7d9f78cb9
--- /dev/null
+++ b/packages/core/src/theme/registry.ts
@@ -0,0 +1,48 @@
+import type { Theme } from '@mui/material/styles';
+import { createTheme } from '@mui/material/styles';
+
+import { THEME_TYPOGRAPHY } from './typography';
+import { type ThemeVariantId, DEFAULT_THEME_VARIANT, parseThemeVariantId } from './variantTypes';
+import chiaDark from './variants/chia/dark';
+import chiaLight from './variants/chia/light';
+import classicDark from './variants/classic/dark';
+import classicLight from './variants/classic/light';
+import fieldDark from './variants/field/dark';
+import fieldLight from './variants/field/light';
+
+const THEMES: Record = {
+ classic: { light: classicLight, dark: classicDark },
+ field: { light: fieldLight, dark: fieldDark },
+ chia: { light: chiaLight, dark: chiaDark },
+};
+
+function withVariantOptions(base: Theme, variant: ThemeVariantId): Theme {
+ const typography = THEME_TYPOGRAPHY[variant];
+ return createTheme(base, {
+ chiaTheme: { variant },
+ typography: {
+ fontFamily: typography.fontFamily,
+ h1: { fontFamily: typography.headingFontFamily },
+ h2: { fontFamily: typography.headingFontFamily },
+ h3: { fontFamily: typography.headingFontFamily },
+ h4: { fontFamily: typography.headingFontFamily },
+ h5: { fontFamily: typography.headingFontFamily },
+ h6: { fontFamily: typography.headingFontFamily },
+ subtitle1: { fontFamily: typography.headingFontFamily },
+ subtitle2: { fontFamily: typography.headingFontFamily },
+ button: { fontFamily: typography.headingFontFamily },
+ },
+ });
+}
+
+export function resolveAppTheme(variant: ThemeVariantId, isDarkMode: boolean): Theme {
+ const pair = THEMES[variant] ?? THEMES[DEFAULT_THEME_VARIANT];
+ const base = isDarkMode ? pair.dark : pair.light;
+ return withVariantOptions(base, variant);
+}
+
+export function resolveAppThemeFromPrefs(themeVariant: unknown, isDarkMode: boolean): Theme {
+ return resolveAppTheme(parseThemeVariantId(themeVariant), isDarkMode);
+}
+
+export { DEFAULT_THEME_VARIANT, parseThemeVariantId };
diff --git a/packages/core/src/theme/semanticColors.ts b/packages/core/src/theme/semanticColors.ts
new file mode 100644
index 0000000000..dfe52e40ac
--- /dev/null
+++ b/packages/core/src/theme/semanticColors.ts
@@ -0,0 +1,12 @@
+import type { Palette } from '@mui/material/styles';
+
+import StateColor from '../constants/StateColor';
+
+export function getSemanticColors(palette: Palette) {
+ return {
+ success: palette.semantic?.success ?? palette.primary.main,
+ warning: palette.semantic?.warning ?? palette.warning?.main ?? StateColor.WARNING,
+ error: palette.semantic?.error ?? palette.danger?.main ?? palette.error.main ?? StateColor.ERROR,
+ highlight: palette.semantic?.highlight ?? palette.highlight?.main ?? palette.primary.main,
+ };
+}
diff --git a/packages/core/src/theme/themeAugmentation.ts b/packages/core/src/theme/themeAugmentation.ts
new file mode 100644
index 0000000000..f198b97895
--- /dev/null
+++ b/packages/core/src/theme/themeAugmentation.ts
@@ -0,0 +1,72 @@
+import type { ElementType } from 'react';
+
+import type { ThemeVariantId } from './variantTypes';
+
+export type ThemeSvgComponent = ElementType;
+
+export type ThemeAssets = {
+ chiaCircle: ThemeSvgComponent;
+ chiaWordmark: ThemeSvgComponent;
+ chiaWordmarkBlack: ThemeSvgComponent;
+ audioSmall: ThemeSvgComponent;
+ documentSmall: ThemeSvgComponent;
+ modelSmall: ThemeSvgComponent;
+ unknownSmall: ThemeSvgComponent;
+ videoSmall: ThemeSvgComponent;
+ offerFileIcon: ThemeSvgComponent;
+ walletConnectToChia: ThemeSvgComponent;
+};
+
+type ChiaPaletteColor = {
+ main: string;
+ light?: string;
+ dark?: string;
+ contrastText?: string;
+};
+
+type ChiaPaletteBorder = {
+ main: string;
+ dark: string;
+};
+
+type ChiaSemanticPalette = {
+ success?: string;
+ warning?: string;
+ error?: string;
+ highlight?: string;
+};
+
+declare module '@mui/material/styles' {
+ interface Theme {
+ chiaTheme: {
+ variant: ThemeVariantId;
+ };
+ }
+ interface ThemeOptions {
+ chiaTheme?: {
+ variant: ThemeVariantId;
+ };
+ }
+
+ interface Palette {
+ border: ChiaPaletteBorder;
+ danger?: ChiaPaletteColor;
+ highlight?: ChiaPaletteColor;
+ semantic?: ChiaSemanticPalette;
+ sidebarSelectedFill?: { light?: string; dark?: string; main?: string };
+ sidebarText?: { light?: string; dark?: string; main?: string };
+ }
+
+ interface PaletteOptions {
+ border?: Partial;
+ danger?: ChiaPaletteColor;
+ highlight?: ChiaPaletteColor;
+ semantic?: ChiaSemanticPalette;
+ sidebarSelectedFill?: { light?: string; dark?: string; main?: string };
+ sidebarText?: { light?: string; dark?: string; main?: string };
+ }
+
+ interface TypeBackground {
+ card: string;
+ }
+}
diff --git a/packages/core/src/theme/typography.ts b/packages/core/src/theme/typography.ts
new file mode 100644
index 0000000000..62ac513de0
--- /dev/null
+++ b/packages/core/src/theme/typography.ts
@@ -0,0 +1,22 @@
+import type { ThemeVariantId } from './variantTypes';
+
+export type ThemeTypographyConfig = {
+ fontFamily: string;
+ headingFontFamily: string;
+};
+
+/** Static typography per theme variant (Chia brand 2025: Poppins + Inter). */
+export const THEME_TYPOGRAPHY: Record = {
+ classic: {
+ fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif',
+ headingFontFamily: '"Roboto", "Helvetica", "Arial", sans-serif',
+ },
+ field: {
+ fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif',
+ headingFontFamily: '"Roboto", "Helvetica", "Arial", sans-serif',
+ },
+ chia: {
+ fontFamily: '"Inter", "Roboto", "Helvetica", "Arial", sans-serif',
+ headingFontFamily: '"Poppins", "Inter", "Roboto", "Helvetica", "Arial", sans-serif',
+ },
+};
diff --git a/packages/core/src/theme/variantTypes.ts b/packages/core/src/theme/variantTypes.ts
new file mode 100644
index 0000000000..eb36637e97
--- /dev/null
+++ b/packages/core/src/theme/variantTypes.ts
@@ -0,0 +1,39 @@
+/** Whitelisted GUI color themes. Values are static code only — never loaded from network or user files. */
+export const THEME_VARIANT_IDS = ['classic', 'field', 'chia'] as const;
+
+export type ThemeVariantId = (typeof THEME_VARIANT_IDS)[number];
+
+export const DEFAULT_THEME_VARIANT: ThemeVariantId = 'chia';
+
+export type ThemeVariantMeta = {
+ id: ThemeVariantId;
+ label: string;
+ description: string;
+};
+
+export const THEME_VARIANT_META: ThemeVariantMeta[] = [
+ {
+ id: 'chia',
+ label: 'Chia',
+ description: 'Chia Network Enterprise palette (2025): Stark Blue, Mystic Blue, Periwinkle, Shock accent.',
+ },
+ {
+ id: 'classic',
+ label: 'Classic',
+ description: 'Original Chia GUI appearance before the Autumn field styling.',
+ },
+ {
+ id: 'field',
+ label: 'Autumn',
+ description: 'Warm amber field-console styling from the Autumn mod.',
+ },
+];
+
+export function isThemeVariantId(value: unknown): value is ThemeVariantId {
+ return typeof value === 'string' && (THEME_VARIANT_IDS as readonly string[]).includes(value);
+}
+
+/** Reject unknown persisted values; never pass user-controlled strings into dynamic imports. */
+export function parseThemeVariantId(value: unknown, fallback: ThemeVariantId = DEFAULT_THEME_VARIANT): ThemeVariantId {
+ return isThemeVariantId(value) ? value : fallback;
+}
diff --git a/packages/core/src/theme/variants/chia/brandColors.ts b/packages/core/src/theme/variants/chia/brandColors.ts
new file mode 100644
index 0000000000..a92c6db1c6
--- /dev/null
+++ b/packages/core/src/theme/variants/chia/brandColors.ts
@@ -0,0 +1,12 @@
+/**
+ * Chia Network Enterprise brand palette (Brand Guidelines, April 2025).
+ * Static tokens only — not loaded from network or user input.
+ */
+export const ChiaBrand2025 = {
+ shock: '#E5FE75',
+ mysticBlue: '#D2E3FE',
+ periwinklePursuit: '#A8BEF1',
+ starkBlue: '#42506C',
+ morpheus: '#2C323C',
+ brightWhite: '#FFFFFF',
+} as const;
diff --git a/packages/core/src/theme/variants/chia/dark.ts b/packages/core/src/theme/variants/chia/dark.ts
new file mode 100644
index 0000000000..a85cfa0dad
--- /dev/null
+++ b/packages/core/src/theme/variants/chia/dark.ts
@@ -0,0 +1,201 @@
+import { alpha } from '@mui/material';
+import { createTheme } from '@mui/material/styles';
+
+import Color from '../../../constants/Color';
+
+import { ChiaBrand2025 as B } from './brandColors';
+import theme from './default';
+
+export default createTheme(
+ {
+ ...theme,
+ palette: {
+ ...theme.palette,
+ background: {
+ ...theme.palette.background,
+ default: B.morpheus,
+ paper: B.morpheus,
+ card: alpha(B.starkBlue, 0.55),
+ },
+ primary: {
+ main: B.mysticBlue,
+ contrastText: B.morpheus,
+ },
+ secondary: {
+ ...theme.palette.secondary,
+ main: B.periwinklePursuit,
+ contrastText: B.morpheus,
+ },
+ highlight: {
+ main: B.periwinklePursuit,
+ },
+ warning: {
+ main: Color.Orange[300],
+ contrastText: B.morpheus,
+ },
+ semantic: {
+ success: B.mysticBlue,
+ warning: Color.Orange[300],
+ error: Color.Red[300],
+ highlight: B.periwinklePursuit,
+ },
+ info: {
+ ...theme.palette.info,
+ main: B.mysticBlue,
+ },
+ text: {
+ primary: B.brightWhite,
+ secondary: 'rgba(210, 227, 254, 0.78)',
+ disabled: Color.Text.Dark.Disabled,
+ },
+ border: {
+ main: alpha(B.periwinklePursuit, 0.45),
+ dark: B.starkBlue,
+ },
+ action: {
+ ...theme.palette.action,
+ selected: alpha(B.starkBlue, 0.55),
+ hover: alpha(B.starkBlue, 0.35),
+ },
+ sidebarBackground: theme.palette.sidebarBackground.dark,
+
+ colors: {
+ royal: {
+ main: Color.Royal[300],
+ border: Color.Royal[700],
+ accent: Color.Royal[800],
+ },
+ grape: {
+ main: Color.Grape[400],
+ border: Color.Grape[700],
+ accent: Color.Grape[800],
+ },
+ purple: {
+ main: Color.Purple[300],
+ border: Color.Purple[700],
+ accent: Color.Purple[800],
+ },
+ red: {
+ main: Color.Red[300],
+ border: Color.Red[700],
+ accent: Color.Red[800],
+ },
+ orange: {
+ main: Color.Orange[300],
+ border: Color.Orange[700],
+ accent: Color.Orange[800],
+ },
+ yellow: {
+ main: Color.Yellow[400],
+ border: Color.Yellow[700],
+ accent: Color.Yellow[800],
+ },
+ lime: {
+ main: Color.Lime[400],
+ border: Color.Lime[700],
+ accent: Color.Lime[800],
+ },
+ green: {
+ main: Color.Green[300],
+ border: Color.Green[700],
+ accent: Color.Green[800],
+ },
+ aqua: {
+ main: Color.Aqua[300],
+ border: Color.Aqua[700],
+ accent: Color.Aqua[800],
+ },
+ blue: {
+ main: Color.Blue[300],
+ border: Color.Blue[700],
+ accent: Color.Blue[800],
+ },
+ comet: {
+ main: Color.Comet[500],
+ border: Color.Comet[500],
+ accent: Color.Comet[900],
+ },
+ storm: {
+ main: Color.Storm[500],
+ border: Color.Storm[700],
+ accent: Color.Storm[900],
+ },
+ wine: {
+ main: Color.Wine[500],
+ border: Color.Wine[700],
+ accent: Color.Wine[900],
+ },
+ cosmic: {
+ main: Color.Cosmic[500],
+ border: Color.Cosmic[700],
+ accent: Color.Cosmic[900],
+ },
+ sand: {
+ main: Color.Sand[500],
+ border: Color.Sand[700],
+ accent: Color.Sand[900],
+ },
+ husk: {
+ main: Color.Husk[500],
+ border: Color.Husk[700],
+ accent: Color.Husk[900],
+ },
+ bean: {
+ main: Color.Bean[500],
+ border: Color.Bean[700],
+ accent: Color.Bean[900],
+ },
+ forest: {
+ main: Color.Forest[500],
+ border: Color.Forest[700],
+ accent: Color.Forest[900],
+ },
+ sea: {
+ main: Color.Sea[500],
+ border: Color.Sea[700],
+ accent: Color.Sea[900],
+ },
+ glacier: {
+ main: Color.Glacier[500],
+ border: Color.Glacier[700],
+ accent: Color.Glacier[900],
+ },
+ default: {
+ main: Color.Neutral[600],
+ border: Color.Neutral[600],
+ accent: Color.Neutral[900],
+ background: Color.Neutral[300],
+ backgroundBadge: Color.Neutral[600],
+ backgroundLight: Color.Neutral[700],
+ text: Color.Neutral[200],
+ },
+ },
+ mode: 'dark',
+ },
+ },
+ {
+ components: {
+ ...theme.components,
+ MuiTooltip: {
+ styleOverrides: {
+ tooltip: {
+ backgroundColor: B.starkBlue,
+ },
+ },
+ },
+ MuiButton: {
+ styleOverrides: {
+ ...theme.components?.MuiButton?.styleOverrides,
+ outlined: {
+ borderColor: alpha(B.periwinklePursuit, 0.55),
+ color: B.mysticBlue,
+ '&:hover': {
+ borderColor: B.mysticBlue,
+ backgroundColor: alpha(B.starkBlue, 0.45),
+ },
+ },
+ },
+ },
+ },
+ },
+);
diff --git a/packages/core/src/theme/variants/chia/default.ts b/packages/core/src/theme/variants/chia/default.ts
new file mode 100644
index 0000000000..ad899285ee
--- /dev/null
+++ b/packages/core/src/theme/variants/chia/default.ts
@@ -0,0 +1,158 @@
+import Color from '../../../constants/Color';
+
+import { ChiaBrand2025 as B } from './brandColors';
+
+declare module '@mui/material' {
+ interface Color {
+ main: string;
+ dark: string;
+ }
+}
+
+export default {
+ palette: {
+ background: {
+ default: B.brightWhite,
+ },
+ primary: {
+ main: B.starkBlue,
+ contrastText: B.brightWhite,
+ },
+ secondary: {
+ main: B.morpheus,
+ contrastText: B.brightWhite,
+ },
+ danger: {
+ main: Color.Red[600],
+ contrastText: B.brightWhite,
+ },
+ highlight: {
+ main: B.periwinklePursuit,
+ },
+ warning: {
+ main: Color.Orange[500],
+ contrastText: B.brightWhite,
+ },
+ semantic: {
+ success: B.starkBlue,
+ warning: Color.Orange[500],
+ error: Color.Red[600],
+ highlight: B.periwinklePursuit,
+ },
+ border: {
+ main: B.periwinklePursuit,
+ dark: B.starkBlue,
+ },
+ sidebarBackground: {
+ main: B.starkBlue,
+ dark: B.morpheus,
+ },
+ sidebarSelectedFill: {
+ main: 'rgba(210, 227, 254, 0.24)',
+ dark: 'rgba(168, 190, 241, 0.2)',
+ },
+ sidebarIconSelected: {
+ main: B.brightWhite,
+ dark: B.brightWhite,
+ },
+ sidebarIcon: {
+ main: B.mysticBlue,
+ dark: B.periwinklePursuit,
+ },
+ sidebarIconHover: {
+ main: B.brightWhite,
+ dark: B.mysticBlue,
+ },
+ sidebarText: {
+ main: B.mysticBlue,
+ dark: 'rgba(210, 227, 254, 0.82)',
+ },
+ info: {
+ main: B.starkBlue,
+ dark: B.brightWhite,
+ },
+ },
+ drawer: {
+ width: '72px',
+ },
+ mixins: {
+ toolbar: {
+ minHeight: '90px',
+ },
+ },
+ components: {
+ MuiTooltip: {
+ styleOverrides: {
+ tooltip: {
+ backgroundColor: B.starkBlue,
+ },
+ },
+ },
+ MuiSvgIcon: {
+ variants: [
+ {
+ props: { fontSize: 'extraLarge' },
+ style: {
+ fontSize: '3rem',
+ },
+ },
+ {
+ props: { fontSize: 'sidebarIcon' },
+ style: {
+ fontSize: '2rem',
+ },
+ },
+ {
+ props: { fontSize: 'notificationIcon' },
+ style: {
+ fontSize: '5rem',
+ },
+ },
+ ],
+ },
+ MuiTypography: {
+ variants: [
+ {
+ props: { variant: 'h6' },
+ style: {
+ fontWeight: 400,
+ },
+ },
+ ],
+ },
+ MuiChip: {
+ variants: [
+ {
+ props: { size: 'extraSmall' },
+ style: {
+ height: '20px',
+ fontSize: '0.75rem',
+ '.MuiChip-label': {
+ paddingLeft: '6px',
+ paddingRight: '6px',
+ },
+ },
+ },
+ ],
+ },
+ MuiButton: {
+ styleOverrides: {
+ contained: {
+ backgroundColor: B.starkBlue,
+ color: B.brightWhite,
+ '&:hover': {
+ backgroundColor: B.morpheus,
+ },
+ },
+ outlined: {
+ borderColor: B.starkBlue,
+ color: B.starkBlue,
+ '&:hover': {
+ borderColor: B.morpheus,
+ backgroundColor: 'rgba(210, 227, 254, 0.35)',
+ },
+ },
+ },
+ },
+ },
+};
diff --git a/packages/core/src/theme/variants/chia/light.ts b/packages/core/src/theme/variants/chia/light.ts
new file mode 100644
index 0000000000..c9a72e4312
--- /dev/null
+++ b/packages/core/src/theme/variants/chia/light.ts
@@ -0,0 +1,150 @@
+import { createTheme } from '@mui/material/styles';
+
+import Color from '../../../constants/Color';
+
+import { ChiaBrand2025 as B } from './brandColors';
+import theme from './default';
+
+export default createTheme({
+ ...theme,
+ palette: {
+ ...theme.palette,
+ background: {
+ ...theme.palette.background,
+ default: '#EEF3FE',
+ card: B.brightWhite,
+ paper: B.brightWhite,
+ },
+ action: {
+ ...theme.palette.action,
+ hover: 'rgba(210, 227, 254, 0.45)',
+ selected: 'rgba(168, 190, 241, 0.35)',
+ focus: 'rgba(66, 80, 108, 0.18)',
+ },
+ info: {
+ ...theme.palette.info,
+ main: B.starkBlue,
+ },
+ text: {
+ primary: B.morpheus,
+ secondary: 'rgba(66, 80, 108, 0.75)',
+ disabled: Color.Text.Light.Disabled,
+ },
+ border: {
+ main: B.periwinklePursuit,
+ dark: B.starkBlue,
+ },
+
+ colors: {
+ royal: {
+ main: Color.Royal[200],
+ border: Color.Royal[400],
+ accent: Color.Royal[600],
+ },
+ grape: {
+ main: Color.Grape[200],
+ border: Color.Grape[400],
+ accent: Color.Grape[600],
+ },
+ purple: {
+ main: Color.Purple[200],
+ border: Color.Purple[400],
+ accent: Color.Purple[600],
+ },
+ red: {
+ main: Color.Red[200],
+ border: Color.Red[400],
+ accent: Color.Red[600],
+ },
+ orange: {
+ main: Color.Orange[200],
+ border: Color.Orange[400],
+ accent: Color.Orange[600],
+ },
+ yellow: {
+ main: Color.Yellow[200],
+ border: Color.Yellow[500],
+ accent: Color.Yellow[600],
+ },
+ lime: {
+ main: Color.Lime[200],
+ border: Color.Lime[500],
+ accent: Color.Lime[600],
+ },
+ green: {
+ main: Color.Green[200],
+ border: Color.Green[400],
+ accent: Color.Green[600],
+ },
+ aqua: {
+ main: Color.Aqua[200],
+ border: Color.Aqua[400],
+ accent: Color.Aqua[600],
+ },
+ blue: {
+ main: Color.Blue[200],
+ border: Color.Blue[400],
+ accent: Color.Blue[600],
+ },
+ comet: {
+ main: Color.Comet[300],
+ border: Color.Comet[400],
+ accent: Color.Comet[700],
+ },
+ storm: {
+ main: Color.Storm[300],
+ border: Color.Storm[400],
+ accent: Color.Storm[700],
+ },
+ wine: {
+ main: Color.Wine[300],
+ border: Color.Wine[400],
+ accent: Color.Wine[700],
+ },
+ cosmic: {
+ main: Color.Cosmic[300],
+ border: Color.Cosmic[400],
+ accent: Color.Cosmic[700],
+ },
+ sand: {
+ main: Color.Sand[300],
+ border: Color.Sand[400],
+ accent: Color.Sand[700],
+ },
+ husk: {
+ main: Color.Husk[300],
+ border: Color.Husk[400],
+ accent: Color.Husk[700],
+ },
+ bean: {
+ main: Color.Bean[300],
+ border: Color.Bean[400],
+ accent: Color.Bean[700],
+ },
+ forest: {
+ main: Color.Forest[300],
+ border: Color.Forest[400],
+ accent: Color.Forest[700],
+ },
+ sea: {
+ main: Color.Sea[300],
+ border: Color.Sea[400],
+ accent: Color.Sea[700],
+ },
+ glacier: {
+ main: Color.Glacier[300],
+ border: Color.Glacier[400],
+ accent: Color.Glacier[700],
+ },
+ default: {
+ main: Color.Neutral[300],
+ border: Color.Neutral[400],
+ accent: Color.Neutral[900],
+ background: Color.Neutral[300],
+ backgroundBadge: Color.Neutral[100],
+ backgroundLight: Color.Neutral[50],
+ text: Color.Neutral[600],
+ },
+ },
+ },
+});
diff --git a/packages/core/src/theme/variants/classic/dark.ts b/packages/core/src/theme/variants/classic/dark.ts
new file mode 100644
index 0000000000..23459caba5
--- /dev/null
+++ b/packages/core/src/theme/variants/classic/dark.ts
@@ -0,0 +1,161 @@
+import { alpha } from '@mui/material';
+import { createTheme } from '@mui/material/styles';
+
+import Color from '../../../constants/Color';
+
+import theme from './default';
+
+export default createTheme(
+ {
+ ...theme,
+ palette: {
+ ...theme.palette,
+ background: {
+ ...theme.palette.background,
+ default: Color.Neutral[900],
+ paper: Color.Neutral[900],
+ card: alpha(Color.Neutral[50], 0.08),
+ },
+ secondary: {
+ ...theme.palette.secondary,
+ main: Color.Neutral[50], // balance text, confirmation text in tx table
+ contrastText: Color.Neutral[900],
+ },
+ info: {
+ ...theme.palette.info,
+ main: Color.Neutral[400],
+ },
+ text: {
+ primary: Color.Text.Dark.Primary,
+ secondary: Color.Text.Dark.Secondary,
+ disabled: Color.Text.Dark.Disabled,
+ },
+ sidebarBackground: theme.palette.sidebarBackground.dark,
+
+ colors: {
+ royal: {
+ main: Color.Royal[300],
+ border: Color.Royal[700],
+ accent: Color.Royal[800],
+ },
+ grape: {
+ main: Color.Grape[400],
+ border: Color.Grape[700],
+ accent: Color.Grape[800],
+ },
+ purple: {
+ main: Color.Purple[300],
+ border: Color.Purple[700],
+ accent: Color.Purple[800],
+ },
+ red: {
+ main: Color.Red[300],
+ border: Color.Red[700],
+ accent: Color.Red[800],
+ },
+ orange: {
+ main: Color.Orange[300],
+ border: Color.Orange[700],
+ accent: Color.Orange[800],
+ },
+ yellow: {
+ main: Color.Yellow[400],
+ border: Color.Yellow[700],
+ accent: Color.Yellow[800],
+ },
+ lime: {
+ main: Color.Lime[400],
+ border: Color.Lime[700],
+ accent: Color.Lime[800],
+ },
+ green: {
+ main: Color.Green[300],
+ border: Color.Green[700],
+ accent: Color.Green[800],
+ },
+ aqua: {
+ main: Color.Aqua[300],
+ border: Color.Aqua[700],
+ accent: Color.Aqua[800],
+ },
+ blue: {
+ main: Color.Blue[300],
+ border: Color.Blue[700],
+ accent: Color.Blue[800],
+ },
+ comet: {
+ main: Color.Comet[500],
+ border: Color.Comet[500],
+ accent: Color.Comet[900],
+ },
+ storm: {
+ main: Color.Storm[500],
+ border: Color.Storm[700],
+ accent: Color.Storm[900],
+ },
+ wine: {
+ main: Color.Wine[500],
+ border: Color.Wine[700],
+ accent: Color.Wine[900],
+ },
+ cosmic: {
+ main: Color.Cosmic[500],
+ border: Color.Cosmic[700],
+ accent: Color.Cosmic[900],
+ },
+ sand: {
+ main: Color.Sand[500],
+ border: Color.Sand[700],
+ accent: Color.Sand[900],
+ },
+ husk: {
+ main: Color.Husk[500],
+ border: Color.Husk[700],
+ accent: Color.Husk[900],
+ },
+ bean: {
+ main: Color.Bean[500],
+ border: Color.Bean[700],
+ accent: Color.Bean[900],
+ },
+ forest: {
+ main: Color.Forest[500],
+ border: Color.Forest[700],
+ accent: Color.Forest[900],
+ },
+ sea: {
+ main: Color.Sea[500],
+ border: Color.Sea[700],
+ accent: Color.Sea[900],
+ },
+ glacier: {
+ main: Color.Glacier[500],
+ border: Color.Glacier[700],
+ accent: Color.Glacier[900],
+ },
+ default: {
+ main: Color.Neutral[600],
+ border: Color.Neutral[600],
+ accent: Color.Neutral[900],
+ background: Color.Neutral[300],
+ backgroundBadge: Color.Neutral[600],
+ backgroundLight: Color.Neutral[700],
+ text: Color.Neutral[200],
+ },
+ },
+ mode: 'dark',
+ },
+ },
+ {
+ components: {
+ ...theme.components,
+ MuiTooltip: {
+ styleOverrides: {
+ tooltip: {
+ backgroundColor: Color.Neutral[700],
+ },
+ },
+ },
+ },
+ },
+);
diff --git a/packages/core/src/theme/variants/classic/default.ts b/packages/core/src/theme/variants/classic/default.ts
new file mode 100644
index 0000000000..b64fd61d66
--- /dev/null
+++ b/packages/core/src/theme/variants/classic/default.ts
@@ -0,0 +1,125 @@
+import Color from '../../../constants/Color';
+
+declare module '@mui/material' {
+ interface Color {
+ main: string;
+ dark: string;
+ }
+}
+
+export default {
+ palette: {
+ background: {
+ default: Color.Neutral[50],
+ },
+ primary: {
+ main: Color.Green[500],
+ contrastText: Color.Neutral[50],
+ },
+ secondary: {
+ main: Color.Neutral[900],
+ contrastText: Color.Neutral[50],
+ },
+ danger: {
+ main: Color.Red[600],
+ contrastText: Color.Neutral[50],
+ },
+ highlight: {
+ main: Color.Chia.Primary,
+ },
+ semantic: {
+ success: Color.Green[500],
+ warning: Color.Orange[500],
+ error: Color.Red[600],
+ highlight: Color.Chia.Primary,
+ },
+ border: {
+ main: Color.Neutral[300],
+ dark: Color.Neutral[700],
+ },
+ sidebarBackground: {
+ main: Color.Green[50],
+ dark: Color.Neutral[600],
+ },
+ sidebarIconSelected: {
+ main: Color.Green[800],
+ dark: Color.Green[500],
+ },
+ sidebarIcon: {
+ main: Color.Neutral[500],
+ dark: Color.Neutral[400],
+ },
+ sidebarIconHover: {
+ main: Color.Neutral[700],
+ dark: Color.Neutral[50],
+ },
+ info: {
+ main: Color.Neutral[500],
+ dark: Color.Neutral[50],
+ },
+ },
+ drawer: {
+ width: '72px',
+ },
+ mixins: {
+ toolbar: {
+ minHeight: '90px',
+ },
+ },
+ components: {
+ MuiTooltip: {
+ styleOverrides: {
+ tooltip: {
+ backgroundColor: Color.Neutral[500],
+ },
+ },
+ },
+ MuiSvgIcon: {
+ variants: [
+ {
+ props: { fontSize: 'extraLarge' },
+ style: {
+ fontSize: '3rem',
+ },
+ },
+ {
+ props: { fontSize: 'sidebarIcon' },
+ style: {
+ fontSize: '2rem',
+ },
+ },
+ {
+ props: { fontSize: 'notificationIcon' },
+ style: {
+ fontSize: '5rem',
+ },
+ },
+ ],
+ },
+ MuiTypography: {
+ variants: [
+ {
+ props: { variant: 'h6' },
+ style: {
+ fontWeight: 400,
+ },
+ },
+ ],
+ },
+ MuiChip: {
+ variants: [
+ {
+ props: { size: 'extraSmall' },
+ style: {
+ height: '20px',
+ fontSize: '0.75rem',
+ '.MuiChip-label': {
+ paddingLeft: '6px',
+ paddingRight: '6px',
+ },
+ },
+ },
+ ],
+ },
+ },
+};
diff --git a/packages/core/src/theme/variants/classic/light.ts b/packages/core/src/theme/variants/classic/light.ts
new file mode 100644
index 0000000000..4bf2746b5c
--- /dev/null
+++ b/packages/core/src/theme/variants/classic/light.ts
@@ -0,0 +1,139 @@
+import { createTheme } from '@mui/material/styles';
+
+import Color from '../../../constants/Color';
+
+import theme from './default';
+
+export default createTheme({
+ ...theme,
+ palette: {
+ ...theme.palette,
+ background: {
+ ...theme.palette.background,
+ card: Color.Neutral[50],
+ paper: Color.Neutral[50],
+ },
+ info: {
+ ...theme.palette.info,
+ main: Color.Neutral[500],
+ },
+ text: {
+ primary: Color.Text.Light.Primary,
+ secondary: Color.Text.Light.Secondary,
+ disabled: Color.Text.Light.Disabled,
+ },
+ sidebarBackground: theme.palette.sidebarBackground.main,
+
+ colors: {
+ royal: {
+ main: Color.Royal[200],
+ border: Color.Royal[400],
+ accent: Color.Royal[600],
+ },
+ grape: {
+ main: Color.Grape[200],
+ border: Color.Grape[400],
+ accent: Color.Grape[600],
+ },
+ purple: {
+ main: Color.Purple[200],
+ border: Color.Purple[400],
+ accent: Color.Purple[600],
+ },
+ red: {
+ main: Color.Red[200],
+ border: Color.Red[400],
+ accent: Color.Red[600],
+ },
+ orange: {
+ main: Color.Orange[200],
+ border: Color.Orange[400],
+ accent: Color.Orange[600],
+ },
+ yellow: {
+ main: Color.Yellow[200],
+ border: Color.Yellow[500],
+ accent: Color.Yellow[600],
+ },
+ lime: {
+ main: Color.Lime[200],
+ border: Color.Lime[500],
+ accent: Color.Lime[600],
+ },
+ green: {
+ main: Color.Green[200],
+ border: Color.Green[400],
+ accent: Color.Green[600],
+ },
+ aqua: {
+ main: Color.Aqua[200],
+ border: Color.Aqua[400],
+ accent: Color.Aqua[600],
+ },
+ blue: {
+ main: Color.Blue[200],
+ border: Color.Blue[400],
+ accent: Color.Blue[600],
+ },
+ comet: {
+ main: Color.Comet[300],
+ border: Color.Comet[400],
+ accent: Color.Comet[700],
+ },
+ storm: {
+ main: Color.Storm[300],
+ border: Color.Storm[400],
+ accent: Color.Storm[700],
+ },
+ wine: {
+ main: Color.Wine[300],
+ border: Color.Wine[400],
+ accent: Color.Wine[700],
+ },
+ cosmic: {
+ main: Color.Cosmic[300],
+ border: Color.Cosmic[400],
+ accent: Color.Cosmic[700],
+ },
+ sand: {
+ main: Color.Sand[300],
+ border: Color.Sand[400],
+ accent: Color.Sand[700],
+ },
+ husk: {
+ main: Color.Husk[300],
+ border: Color.Husk[400],
+ accent: Color.Husk[700],
+ },
+ bean: {
+ main: Color.Bean[300],
+ border: Color.Bean[400],
+ accent: Color.Bean[700],
+ },
+ forest: {
+ main: Color.Forest[300],
+ border: Color.Forest[400],
+ accent: Color.Forest[700],
+ },
+ sea: {
+ main: Color.Sea[300],
+ border: Color.Sea[400],
+ accent: Color.Sea[700],
+ },
+ glacier: {
+ main: Color.Glacier[300],
+ border: Color.Glacier[400],
+ accent: Color.Glacier[700],
+ },
+ default: {
+ main: Color.Neutral[300],
+ border: Color.Neutral[400],
+ accent: Color.Neutral[900],
+ background: Color.Neutral[300],
+ backgroundBadge: Color.Neutral[100],
+ backgroundLight: Color.Neutral[50],
+ text: Color.Neutral[600],
+ },
+ },
+ },
+});
diff --git a/packages/core/src/theme/variants/field/dark.ts b/packages/core/src/theme/variants/field/dark.ts
new file mode 100644
index 0000000000..d167a3ef81
--- /dev/null
+++ b/packages/core/src/theme/variants/field/dark.ts
@@ -0,0 +1,321 @@
+import { alpha } from '@mui/material';
+import { createTheme } from '@mui/material/styles';
+
+import Color from '../../../constants/Color';
+import getButtonStyles from '../../buttonStyles';
+
+import theme from './default';
+
+export default createTheme(
+ {
+ ...theme,
+ palette: {
+ ...theme.palette,
+ background: {
+ ...theme.palette.background,
+ default: '#16130d',
+ paper: '#211b12',
+ card: alpha('#f7df9b', 0.08),
+ },
+ primary: {
+ main: '#d8ad45',
+ contrastText: '#16130d',
+ },
+ secondary: {
+ ...theme.palette.secondary,
+ main: '#f7efd8', // balance text, confirmation text in tx table
+ contrastText: '#16130d',
+ },
+ highlight: {
+ main: '#f7df9b',
+ },
+ warning: {
+ main: Color.Orange[300],
+ contrastText: '#16130d',
+ },
+ semantic: {
+ success: '#d8ad45',
+ warning: Color.Orange[300],
+ error: Color.Red[300],
+ highlight: '#f7df9b',
+ },
+ info: {
+ ...theme.palette.info,
+ main: '#cdbb91',
+ },
+ action: {
+ ...theme.palette.action,
+ hover: 'rgba(216, 173, 69, 0.16)',
+ selected: 'rgba(216, 173, 69, 0.22)',
+ focus: 'rgba(216, 173, 69, 0.22)',
+ },
+ text: {
+ primary: 'rgba(247, 239, 216, 0.92)',
+ secondary: 'rgba(247, 239, 216, 0.62)',
+ disabled: Color.Text.Dark.Disabled,
+ },
+ border: {
+ main: 'rgba(247, 223, 155, 0.16)',
+ dark: 'rgba(247, 223, 155, 0.2)',
+ },
+ sidebarBackground: '#211d13',
+ sidebarIconSelected: {
+ main: '#f7df9b',
+ dark: '#f7df9b',
+ },
+ sidebarIcon: {
+ main: 'rgba(247, 239, 216, 0.58)',
+ dark: 'rgba(247, 239, 216, 0.58)',
+ },
+ sidebarIconHover: {
+ main: '#fff3cf',
+ dark: '#fff3cf',
+ },
+
+ colors: {
+ royal: {
+ main: Color.Royal[300],
+ border: Color.Royal[700],
+ accent: Color.Royal[800],
+ },
+ grape: {
+ main: Color.Grape[400],
+ border: Color.Grape[700],
+ accent: Color.Grape[800],
+ },
+ purple: {
+ main: Color.Purple[300],
+ border: Color.Purple[700],
+ accent: Color.Purple[800],
+ },
+ red: {
+ main: Color.Red[300],
+ border: Color.Red[700],
+ accent: Color.Red[800],
+ },
+ orange: {
+ main: Color.Orange[300],
+ border: Color.Orange[700],
+ accent: Color.Orange[800],
+ },
+ yellow: {
+ main: Color.Yellow[400],
+ border: Color.Yellow[700],
+ accent: Color.Yellow[800],
+ },
+ lime: {
+ main: Color.Lime[400],
+ border: Color.Lime[700],
+ accent: Color.Lime[800],
+ },
+ green: {
+ main: '#d8ad45',
+ border: '#9b7040',
+ accent: '#5c4329',
+ },
+ aqua: {
+ main: Color.Aqua[300],
+ border: Color.Aqua[700],
+ accent: Color.Aqua[800],
+ },
+ blue: {
+ main: Color.Blue[300],
+ border: Color.Blue[700],
+ accent: Color.Blue[800],
+ },
+ comet: {
+ main: Color.Comet[500],
+ border: Color.Comet[500],
+ accent: Color.Comet[900],
+ },
+ storm: {
+ main: Color.Storm[500],
+ border: Color.Storm[700],
+ accent: Color.Storm[900],
+ },
+ wine: {
+ main: Color.Wine[500],
+ border: Color.Wine[700],
+ accent: Color.Wine[900],
+ },
+ cosmic: {
+ main: Color.Cosmic[500],
+ border: Color.Cosmic[700],
+ accent: Color.Cosmic[900],
+ },
+ sand: {
+ main: Color.Sand[500],
+ border: Color.Sand[700],
+ accent: Color.Sand[900],
+ },
+ husk: {
+ main: Color.Husk[500],
+ border: Color.Husk[700],
+ accent: Color.Husk[900],
+ },
+ bean: {
+ main: Color.Bean[500],
+ border: Color.Bean[700],
+ accent: Color.Bean[900],
+ },
+ forest: {
+ main: Color.Forest[500],
+ border: Color.Forest[700],
+ accent: Color.Forest[900],
+ },
+ sea: {
+ main: Color.Sea[500],
+ border: Color.Sea[700],
+ accent: Color.Sea[900],
+ },
+ glacier: {
+ main: Color.Glacier[500],
+ border: Color.Glacier[700],
+ accent: Color.Glacier[900],
+ },
+ default: {
+ main: '#3a3020',
+ border: '#9b7040',
+ accent: '#f7df9b',
+ background: '#2a2418',
+ backgroundBadge: '#2f291d',
+ backgroundLight: '#211b12',
+ text: '#e8d9b6',
+ },
+ },
+ mode: 'dark',
+ },
+ },
+ {
+ components: {
+ ...theme.components,
+ MuiCssBaseline: {
+ styleOverrides: {
+ body: {
+ backgroundColor: '#16130d',
+ backgroundImage:
+ 'linear-gradient(118deg, rgba(22, 19, 13, 0.98) 0%, rgba(40, 31, 18, 0.96) 44%, rgba(27, 28, 20, 0.96) 100%), repeating-linear-gradient(102deg, rgba(216, 173, 69, 0.08) 0 18px, rgba(155, 112, 64, 0.08) 18px 34px, transparent 34px 68px)',
+ },
+ },
+ },
+ MuiCard: {
+ styleOverrides: {
+ root: {
+ borderRadius: 8,
+ borderColor: 'rgba(247, 223, 155, 0.14)',
+ backgroundColor: 'rgba(33, 27, 18, 0.86)',
+ backgroundImage: 'linear-gradient(180deg, rgba(42, 36, 24, 0.92) 0%, rgba(31, 26, 17, 0.86) 100%)',
+ boxShadow: '0 18px 54px rgba(0, 0, 0, 0.24)',
+ },
+ },
+ },
+ MuiPaper: {
+ styleOverrides: {
+ root: {
+ backgroundImage: 'none',
+ },
+ },
+ },
+ MuiButton: {
+ styleOverrides: getButtonStyles({
+ borderRadius: 8,
+ fontWeight: 700,
+ textTransform: 'none',
+ hoverBackground: 'rgba(216, 173, 69, 0.2)',
+ hoverShadow: '0 3px 10px rgba(0, 0, 0, 0.2)',
+ hoverTransform: 'translateY(-1px)',
+ outlinedHoverShadow: '0 3px 10px rgba(0, 0, 0, 0.2), 0 0 0 1px rgba(247, 223, 155, 0.28)',
+ containedBackground: 'linear-gradient(180deg, #e0bd5e 0%, #bd8330 100%)',
+ containedHoverBackground: 'linear-gradient(180deg, #f0cf72 0%, #d8ad45 100%)',
+ containedShadow: '0 12px 24px rgba(0, 0, 0, 0.28)',
+ containedHoverShadow: '0 14px 30px rgba(0, 0, 0, 0.38)',
+ }),
+ },
+ MuiIconButton: {
+ styleOverrides: {
+ root: {
+ '&:not(.Mui-disabled):hover': {
+ backgroundColor: 'rgba(216, 173, 69, 0.2)',
+ color: '#fff3cf',
+ },
+ },
+ },
+ },
+ MuiMenuItem: {
+ styleOverrides: {
+ root: {
+ '&:not(.Mui-disabled):hover': {
+ backgroundColor: 'rgba(216, 173, 69, 0.18)',
+ },
+ '&.Mui-selected': {
+ backgroundColor: 'rgba(216, 173, 69, 0.18)',
+ },
+ '&.Mui-selected:hover': {
+ backgroundColor: 'rgba(216, 173, 69, 0.26)',
+ },
+ },
+ },
+ },
+ MuiListItemButton: {
+ styleOverrides: {
+ root: {
+ '&:not(.Mui-disabled):hover': {
+ backgroundColor: 'rgba(216, 173, 69, 0.18)',
+ },
+ '&.Mui-selected': {
+ backgroundColor: 'rgba(216, 173, 69, 0.18)',
+ },
+ '&.Mui-selected:hover': {
+ backgroundColor: 'rgba(216, 173, 69, 0.26)',
+ },
+ },
+ },
+ },
+ MuiCardActionArea: {
+ styleOverrides: {
+ root: {
+ '&:hover': {
+ backgroundColor: 'rgba(216, 173, 69, 0.16)',
+ },
+ },
+ },
+ },
+ MuiTableRow: {
+ styleOverrides: {
+ root: {
+ '&.MuiTableRow-hover:hover': {
+ backgroundColor: 'rgba(216, 173, 69, 0.16)',
+ },
+ },
+ },
+ },
+ MuiTab: {
+ styleOverrides: {
+ root: {
+ '&:hover': {
+ backgroundColor: 'rgba(216, 173, 69, 0.16)',
+ color: '#fff3cf',
+ },
+ },
+ },
+ },
+ MuiLinearProgress: {
+ styleOverrides: {
+ root: {
+ backgroundColor: 'rgba(216, 173, 69, 0.2)',
+ },
+ bar: {
+ backgroundColor: '#d8ad45',
+ },
+ },
+ },
+ MuiTooltip: {
+ styleOverrides: {
+ tooltip: {
+ backgroundColor: '#3a3020',
+ },
+ },
+ },
+ },
+ },
+);
diff --git a/packages/core/src/theme/variants/field/default.ts b/packages/core/src/theme/variants/field/default.ts
new file mode 100644
index 0000000000..7cc63d7927
--- /dev/null
+++ b/packages/core/src/theme/variants/field/default.ts
@@ -0,0 +1,129 @@
+import Color from '../../../constants/Color';
+
+declare module '@mui/material' {
+ interface Color {
+ main: string;
+ dark: string;
+ }
+}
+
+export default {
+ palette: {
+ background: {
+ default: Color.Neutral[50],
+ },
+ primary: {
+ main: '#b98524',
+ contrastText: Color.Neutral[50],
+ },
+ secondary: {
+ main: Color.Neutral[900],
+ contrastText: Color.Neutral[50],
+ },
+ danger: {
+ main: Color.Red[600],
+ contrastText: Color.Neutral[50],
+ },
+ highlight: {
+ main: '#d09a2d',
+ },
+ warning: {
+ main: Color.Orange[500],
+ contrastText: Color.Neutral[50],
+ },
+ semantic: {
+ success: '#b98524',
+ warning: Color.Orange[500],
+ error: Color.Red[600],
+ highlight: '#d09a2d',
+ },
+ border: {
+ main: Color.Neutral[300],
+ dark: Color.Neutral[700],
+ },
+ sidebarBackground: {
+ main: '#302c1f',
+ dark: '#211d13',
+ },
+ sidebarIconSelected: {
+ main: '#f7df9b',
+ dark: '#f7df9b',
+ },
+ sidebarIcon: {
+ main: Color.Neutral[500],
+ dark: Color.Neutral[400],
+ },
+ sidebarIconHover: {
+ main: Color.Neutral[700],
+ dark: Color.Neutral[50],
+ },
+ info: {
+ main: Color.Neutral[500],
+ dark: Color.Neutral[50],
+ },
+ },
+ drawer: {
+ width: '72px',
+ },
+ mixins: {
+ toolbar: {
+ minHeight: '90px',
+ },
+ },
+ components: {
+ MuiTooltip: {
+ styleOverrides: {
+ tooltip: {
+ backgroundColor: Color.Neutral[500],
+ },
+ },
+ },
+ MuiSvgIcon: {
+ variants: [
+ {
+ props: { fontSize: 'extraLarge' },
+ style: {
+ fontSize: '3rem',
+ },
+ },
+ {
+ props: { fontSize: 'sidebarIcon' },
+ style: {
+ fontSize: '2rem',
+ },
+ },
+ {
+ props: { fontSize: 'notificationIcon' },
+ style: {
+ fontSize: '5rem',
+ },
+ },
+ ],
+ },
+ MuiTypography: {
+ variants: [
+ {
+ props: { variant: 'h6' },
+ style: {
+ fontWeight: 400,
+ },
+ },
+ ],
+ },
+ MuiChip: {
+ variants: [
+ {
+ props: { size: 'extraSmall' },
+ style: {
+ height: '20px',
+ fontSize: '0.75rem',
+ '.MuiChip-label': {
+ paddingLeft: '6px',
+ paddingRight: '6px',
+ },
+ },
+ },
+ ],
+ },
+ },
+};
diff --git a/packages/core/src/theme/variants/field/light.ts b/packages/core/src/theme/variants/field/light.ts
new file mode 100644
index 0000000000..159c954943
--- /dev/null
+++ b/packages/core/src/theme/variants/field/light.ts
@@ -0,0 +1,310 @@
+import { createTheme } from '@mui/material/styles';
+
+import Color from '../../../constants/Color';
+import getButtonStyles from '../../buttonStyles';
+
+import theme from './default';
+
+export default createTheme({
+ ...theme,
+ palette: {
+ ...theme.palette,
+ background: {
+ ...theme.palette.background,
+ default: '#f4f0e5',
+ card: '#fffaf0',
+ paper: '#fffaf0',
+ },
+ primary: {
+ main: '#b98524',
+ contrastText: '#fffaf0',
+ },
+ secondary: {
+ main: '#3c3424',
+ contrastText: '#fffaf0',
+ },
+ highlight: {
+ main: '#d09a2d',
+ },
+ warning: {
+ main: Color.Orange[500],
+ contrastText: '#fffaf0',
+ },
+ semantic: {
+ success: '#b98524',
+ warning: Color.Orange[500],
+ error: Color.Red[600],
+ highlight: '#d09a2d',
+ },
+ info: {
+ ...theme.palette.info,
+ main: '#5c7882',
+ },
+ action: {
+ ...theme.palette.action,
+ hover: 'rgba(185, 133, 36, 0.12)',
+ selected: 'rgba(185, 133, 36, 0.18)',
+ focus: 'rgba(185, 133, 36, 0.18)',
+ },
+ text: {
+ primary: 'rgba(36, 48, 36, 0.9)',
+ secondary: 'rgba(36, 48, 36, 0.62)',
+ disabled: Color.Text.Light.Disabled,
+ },
+ border: {
+ main: 'rgba(71, 58, 36, 0.16)',
+ dark: Color.Neutral[700],
+ },
+ sidebarBackground: '#302c1f',
+ sidebarIconSelected: {
+ main: '#f7df9b',
+ dark: '#d8ad45',
+ },
+ sidebarIcon: {
+ main: 'rgba(247, 239, 216, 0.68)',
+ dark: Color.Neutral[400],
+ },
+ sidebarIconHover: {
+ main: '#fff3cf',
+ dark: Color.Neutral[50],
+ },
+
+ colors: {
+ royal: {
+ main: Color.Royal[200],
+ border: Color.Royal[400],
+ accent: Color.Royal[600],
+ },
+ grape: {
+ main: Color.Grape[200],
+ border: Color.Grape[400],
+ accent: Color.Grape[600],
+ },
+ purple: {
+ main: Color.Purple[200],
+ border: Color.Purple[400],
+ accent: Color.Purple[600],
+ },
+ red: {
+ main: Color.Red[200],
+ border: Color.Red[400],
+ accent: Color.Red[600],
+ },
+ orange: {
+ main: Color.Orange[200],
+ border: Color.Orange[400],
+ accent: Color.Orange[600],
+ },
+ yellow: {
+ main: Color.Yellow[200],
+ border: Color.Yellow[500],
+ accent: Color.Yellow[600],
+ },
+ lime: {
+ main: Color.Lime[200],
+ border: Color.Lime[500],
+ accent: Color.Lime[600],
+ },
+ green: {
+ main: Color.Green[200],
+ border: Color.Green[400],
+ accent: Color.Green[600],
+ },
+ aqua: {
+ main: Color.Aqua[200],
+ border: Color.Aqua[400],
+ accent: Color.Aqua[600],
+ },
+ blue: {
+ main: Color.Blue[200],
+ border: Color.Blue[400],
+ accent: Color.Blue[600],
+ },
+ comet: {
+ main: Color.Comet[300],
+ border: Color.Comet[400],
+ accent: Color.Comet[700],
+ },
+ storm: {
+ main: Color.Storm[300],
+ border: Color.Storm[400],
+ accent: Color.Storm[700],
+ },
+ wine: {
+ main: Color.Wine[300],
+ border: Color.Wine[400],
+ accent: Color.Wine[700],
+ },
+ cosmic: {
+ main: Color.Cosmic[300],
+ border: Color.Cosmic[400],
+ accent: Color.Cosmic[700],
+ },
+ sand: {
+ main: Color.Sand[300],
+ border: Color.Sand[400],
+ accent: Color.Sand[700],
+ },
+ husk: {
+ main: Color.Husk[300],
+ border: Color.Husk[400],
+ accent: Color.Husk[700],
+ },
+ bean: {
+ main: Color.Bean[300],
+ border: Color.Bean[400],
+ accent: Color.Bean[700],
+ },
+ forest: {
+ main: Color.Forest[300],
+ border: Color.Forest[400],
+ accent: Color.Forest[700],
+ },
+ sea: {
+ main: Color.Sea[300],
+ border: Color.Sea[400],
+ accent: Color.Sea[700],
+ },
+ glacier: {
+ main: Color.Glacier[300],
+ border: Color.Glacier[400],
+ accent: Color.Glacier[700],
+ },
+ default: {
+ main: Color.Neutral[300],
+ border: Color.Neutral[400],
+ accent: Color.Neutral[900],
+ background: Color.Neutral[300],
+ backgroundBadge: Color.Neutral[100],
+ backgroundLight: Color.Neutral[50],
+ text: Color.Neutral[600],
+ },
+ },
+ },
+ shape: {
+ borderRadius: 8,
+ },
+ components: {
+ ...theme.components,
+ MuiCssBaseline: {
+ styleOverrides: {
+ body: {
+ backgroundColor: '#f4f0e5',
+ backgroundImage:
+ 'linear-gradient(118deg, rgba(246, 241, 225, 0.98) 0%, rgba(236, 225, 195, 0.94) 42%, rgba(232, 229, 209, 0.94) 100%), repeating-linear-gradient(102deg, rgba(169, 121, 35, 0.1) 0 18px, rgba(205, 169, 79, 0.08) 18px 34px, transparent 34px 68px)',
+ },
+ },
+ },
+ MuiCard: {
+ styleOverrides: {
+ root: {
+ borderRadius: 8,
+ borderColor: 'rgba(71, 58, 36, 0.14)',
+ backgroundColor: 'rgba(255, 250, 240, 0.86)',
+ backgroundImage: 'linear-gradient(180deg, rgba(255, 252, 244, 0.94) 0%, rgba(250, 244, 229, 0.82) 100%)',
+ boxShadow: '0 18px 54px rgba(71, 58, 36, 0.1)',
+ },
+ },
+ },
+ MuiPaper: {
+ styleOverrides: {
+ root: {
+ backgroundImage: 'none',
+ },
+ },
+ },
+ MuiButton: {
+ styleOverrides: getButtonStyles({
+ borderRadius: 8,
+ fontWeight: 700,
+ textTransform: 'none',
+ hoverBackground: 'rgba(185, 133, 36, 0.14)',
+ hoverShadow: '0 3px 10px rgba(71, 58, 36, 0.12)',
+ hoverTransform: 'translateY(-1px)',
+ outlinedHoverShadow: '0 3px 10px rgba(71, 58, 36, 0.12), 0 0 0 1px rgba(185, 133, 36, 0.22)',
+ containedBackground: 'linear-gradient(180deg, #c5953a 0%, #a8731f 100%)',
+ containedHoverBackground: 'linear-gradient(180deg, #d1a64a 0%, #b98524 100%)',
+ containedShadow: '0 10px 22px rgba(71, 58, 36, 0.16)',
+ containedHoverShadow: '0 12px 26px rgba(71, 58, 36, 0.24)',
+ }),
+ },
+ MuiIconButton: {
+ styleOverrides: {
+ root: {
+ '&:not(.Mui-disabled):hover': {
+ backgroundColor: 'rgba(185, 133, 36, 0.16)',
+ color: '#8f641d',
+ },
+ },
+ },
+ },
+ MuiMenuItem: {
+ styleOverrides: {
+ root: {
+ '&:not(.Mui-disabled):hover': {
+ backgroundColor: 'rgba(185, 133, 36, 0.14)',
+ },
+ '&.Mui-selected': {
+ backgroundColor: 'rgba(185, 133, 36, 0.14)',
+ },
+ '&.Mui-selected:hover': {
+ backgroundColor: 'rgba(185, 133, 36, 0.22)',
+ },
+ },
+ },
+ },
+ MuiListItemButton: {
+ styleOverrides: {
+ root: {
+ '&:not(.Mui-disabled):hover': {
+ backgroundColor: 'rgba(185, 133, 36, 0.14)',
+ },
+ '&.Mui-selected': {
+ backgroundColor: 'rgba(185, 133, 36, 0.14)',
+ },
+ '&.Mui-selected:hover': {
+ backgroundColor: 'rgba(185, 133, 36, 0.22)',
+ },
+ },
+ },
+ },
+ MuiCardActionArea: {
+ styleOverrides: {
+ root: {
+ '&:hover': {
+ backgroundColor: 'rgba(185, 133, 36, 0.14)',
+ },
+ },
+ },
+ },
+ MuiTableRow: {
+ styleOverrides: {
+ root: {
+ '&.MuiTableRow-hover:hover': {
+ backgroundColor: 'rgba(185, 133, 36, 0.12)',
+ },
+ },
+ },
+ },
+ MuiTab: {
+ styleOverrides: {
+ root: {
+ '&:hover': {
+ backgroundColor: 'rgba(185, 133, 36, 0.14)',
+ color: '#8f641d',
+ },
+ },
+ },
+ },
+ MuiLinearProgress: {
+ styleOverrides: {
+ root: {
+ backgroundColor: 'rgba(185, 133, 36, 0.2)',
+ },
+ bar: {
+ backgroundColor: '#b98524',
+ },
+ },
+ },
+ },
+});
diff --git a/packages/core/src/utils/useColorModeValue.ts b/packages/core/src/utils/useColorModeValue.ts
index e315432374..3497bbeef7 100644
--- a/packages/core/src/utils/useColorModeValue.ts
+++ b/packages/core/src/utils/useColorModeValue.ts
@@ -2,10 +2,24 @@ import { type Theme } from '@mui/material';
type PaletteKeys = keyof Theme['palette'];
+type PaletteEntry = string | { light?: string; dark?: string; main?: string };
+
export default function getColorModeValue(theme: Theme, color: PaletteKeys): string {
+ const entry = theme.palette[color] as PaletteEntry;
+
+ if (typeof entry === 'string') {
+ return entry;
+ }
+
+ if (!entry || typeof entry !== 'object') {
+ return '';
+ }
+
const isDark = theme.palette.mode === 'dark';
- const value = isDark ? theme.palette[color].dark : theme.palette[color].light;
+ if (isDark) {
+ return entry.dark ?? entry.main ?? '';
+ }
- return value ?? theme.palette[color].main;
+ return entry.light ?? entry.main ?? '';
}
diff --git a/packages/gui/package.json b/packages/gui/package.json
index 384f393a29..c364c1147b 100644
--- a/packages/gui/package.json
+++ b/packages/gui/package.json
@@ -19,6 +19,7 @@
"start": "npm run electron .",
"dev:electron": "cross-env NODE_ENV=development webpack --config webpack.electron.babel.ts --mode development && electron .",
"dev:react": "cross-env NODE_ENV=development NODE_OPTIONS=--max_old_space_size=8192 webpack serve --config webpack.react.babel.ts --mode development",
+ "design:sandbox": "cross-env GUI_DESIGN_SANDBOX=true npm run dev:react",
"dev": "npm run locale && concurrently --kill-others \"npm run dev:react\" \"npm run dev:electron\"",
"dev:skipLocales": "concurrently --kill-others \"npm run dev:react\" \"npm run dev:electron\"",
"build:css": "tailwindcss -i ./src/main.css -o ./build/electron/main.css --minify",
diff --git a/packages/gui/src/@types/chia-mui-theme.d.ts b/packages/gui/src/@types/chia-mui-theme.d.ts
new file mode 100644
index 0000000000..5f642bee0f
--- /dev/null
+++ b/packages/gui/src/@types/chia-mui-theme.d.ts
@@ -0,0 +1,3 @@
+import '@chia-network/core/src/theme/themeAugmentation';
+
+export {};
diff --git a/packages/gui/src/WalletConnectCommands.parity.test.ts.disabled b/packages/gui/src/WalletConnectCommands.parity.test.ts.disabled
new file mode 100644
index 0000000000..cdf887f782
--- /dev/null
+++ b/packages/gui/src/WalletConnectCommands.parity.test.ts.disabled
@@ -0,0 +1,476 @@
+/**
+ * TEMPORARY parity check — delete with `WalletConnectCommands.tsx` once
+ * the migration is verified.
+ *
+ * Asserts the new `commandRegistry` is byte-for-byte compatible with the
+ * legacy export: command coverage, param presence + order + `hide` +
+ * `isOptional` + `defaultValue`, and no leakage (new dapp surface that
+ * wasn't in legacy).
+ *
+ * Virtual mocks reconstruct the deleted legacy dependencies so the module
+ * still imports under jest.
+ */
+
+import { snakeCase } from 'lodash';
+
+jest.mock('@lingui/macro', () => ({
+ Trans: ({ children }: { children?: unknown }) => children ?? null,
+ t: (s: TemplateStringsArray | string) => (typeof s === 'string' ? s : Array.isArray(s) ? s.join('') : ''),
+}));
+
+jest.mock('@mui/material', () => ({ Typography: () => null }));
+
+jest.mock('@chia-network/api', () => ({
+ ServiceName: {
+ WALLET: 'chia_wallet',
+ FULL_NODE: 'chia_full_node',
+ FARMER: 'chia_farmer',
+ HARVESTER: 'chia_harvester',
+ DAEMON: 'daemon',
+ DATALAYER: 'chia_data_layer',
+ },
+}));
+
+jest.mock('@chia-network/core', () => ({ MojoToChia: () => null }));
+
+const PARAM_NAMES: Record = {
+ ADDRESS: 'address',
+ ALL_FINGERPRINTS: 'allFingerprints',
+ ALLOW_UNSYNCED: 'allowUnsynced',
+ AMOUNT: 'amount',
+ ASSET_ID: 'assetId',
+ ATTEST_DATA: 'attestData',
+ BACKUP_DIDS: 'backupDids',
+ CHANGELIST: 'changelist',
+ COIN_ANNOUNCEMENTS: 'coinAnnouncements',
+ COIN_ID: 'coinId',
+ COIN_IDS: 'coinIds',
+ COIN_NAME: 'coinName',
+ COMMANDS: 'commands',
+ COUNT: 'count',
+ DID: 'did',
+ DID_COIN: 'didCoin',
+ DID_ID: 'didId',
+ DID_LINEAGE_PARENT: 'didLineageParent',
+ DISABLE_JSON_FORMATTING: 'disableJSONFormatting',
+ DRIVER_DICT: 'driverDict',
+ EDITION_NUMBER: 'editionNumber',
+ EDITION_TOTAL: 'editionTotal',
+ END: 'end',
+ END_HEIGHT: 'endHeight',
+ EXCLUDED_COIN_AMOUNTS: 'excludedCoinAmounts',
+ EXCLUDED_COIN_IDS: 'excludedCoinIds',
+ EXTRA_CONDITIONS: 'extraConditions',
+ FEE: 'fee',
+ FINGERPRINT: 'fingerprint',
+ FINGERPRINTS: 'fingerprints',
+ FOLDER_NAME: 'foldername',
+ HASH: 'hash',
+ HASH1: 'hash1',
+ HASH2: 'hash2',
+ ID: 'id',
+ IDS: 'ids',
+ INCLUDE_DATA: 'includeData',
+ INCLUDE_MY_OFFERS: 'includeMyOffers',
+ INCLUDE_SPENT_COINS: 'includeSpentCoins',
+ INCLUDE_TAKEN_OFFERS: 'includeTakenOffers',
+ INDEX: 'index',
+ INNER_ADDRESS: 'innerAddress',
+ IS_HEX: 'isHex',
+ KEY: 'key',
+ LAUNCHER_ID: 'launcherId',
+ LICENSE_HASH: 'licenseHash',
+ LICENSE_URIS: 'licenseUris',
+ MAKER: 'maker',
+ MAX_COIN_AMOUNT: 'maxCoinAmount',
+ MEMOS: 'memos',
+ MESSAGE: 'message',
+ META_HASH: 'metaHash',
+ META_URIS: 'metaUris',
+ METADATA: 'metadata',
+ METADATA_LIST: 'metadataList',
+ MIN_COIN_AMOUNT: 'minCoinAmount',
+ MINT_FROM_DID: 'mintFromDid',
+ MINT_NUMBER_START: 'mintNumberStart',
+ MINT_TOTAL: 'mintTotal',
+ NAME: 'name',
+ NAMES: 'names',
+ NEW_ADDRESS: 'newAddress',
+ NEW_INNERPUZHASH: 'newInnerpuzhash',
+ NEW_LIST: 'newList',
+ NEW_PROOF_HASH: 'newProofHash',
+ NEW_PUZHASH: 'newPuzhash',
+ NEW_P2_PUZHASH: 'newP2Puzhash',
+ NFT_COIN_IDS: 'nftCoinIds',
+ NFT_LAUNCHER_ID: 'nftLauncherId',
+ NON_OBSERVER_DERIVATION: 'nonObserverDerivation',
+ NUM: 'num',
+ NUM_OF_BACKUP_IDS_NEEDED: 'numOfBackupIdsNeeded',
+ NUM_VERIFICATION: 'numVerification',
+ NUM_VERIFICATIONS_REQUIRED: 'numVerificationsRequired',
+ OFFER: 'offer',
+ OFFER_DATA: 'offerData',
+ OFFER_ID: 'offerId',
+ OVERWRITE: 'overwrite',
+ PAGE: 'page',
+ MAX_PAGE_SIZE: 'maxPageSize',
+ PROOFS: 'proofs',
+ PROVIDER_INNER_PUZHASH: 'providerInnerPuzhash',
+ PUBKEY: 'pubkey',
+ PUSH: 'push',
+ PUZHASH: 'puzhash',
+ PUZZLE_ANNOUNCEMENTS: 'puzzleAnnouncements',
+ PUZZLE_DECORATOR: 'puzzleDecorator',
+ RECOVERY_LIST_HASH: 'recoveryListHash',
+ REVERSE: 'reverse',
+ RETAIN: 'retain',
+ REUSE_PUZHASH: 'reusePuzhash',
+ ROOT: 'root',
+ ROOT_HASH: 'rootHash',
+ ROYALTY_ADDRESS: 'royaltyAddress',
+ ROYALTY_PERCENTAGE: 'royaltyPercentage',
+ SECURE: 'secure',
+ SIGN: 'sign',
+ SIGNATURE: 'signature',
+ SIGNING_MODE: 'signingMode',
+ SORT_KEY: 'sortKey',
+ SPEND_BUNDLE: 'spendBundle',
+ START: 'start',
+ START_HEIGHT: 'startHeight',
+ START_INDEX: 'startIndex',
+ STORE_ID: 'storeId',
+ SUBMIT_ON_CHAIN: 'submitOnChain',
+ TAKER: 'taker',
+ TARGET_ADDRESS: 'targetAddress',
+ TARGET_LIST: 'targetList',
+ TRADE_ID: 'tradeId',
+ TRANSACTION_ID: 'transactionId',
+ TRANSACTIONS: 'transactions',
+ TYPE: 'type',
+ URIS: 'uris',
+ URL: 'url',
+ URLS: 'urls',
+ VALIDATE_ONLY: 'validateOnly',
+ USE_PEAK_HEIGHT: 'usePeakHeight',
+ VALUE: 'value',
+ VC_ID: 'vcId',
+ VC_PARENT_ID: 'vcParentId',
+ VERBOSE: 'verbose',
+ WAIT_FOR_CONFIRMATION: 'waitForConfirmation',
+ WALLET_ID: 'walletId',
+ WALLET_IDS: 'walletIds',
+ WALLET_IDS_AND_AMOUNTS: 'walletIdsAndAmounts',
+ WITH_RECOVERY_INFO: 'withRecoveryInfo',
+ XCH_COINS: 'xchCoins',
+ XCH_CHANGE_TARGET: 'xchChangeTarget',
+ SAFE_MODE: 'safeMode',
+};
+
+// Paths match what the legacy file uses (`'../@types/...'`); resolve to
+// `packages/gui/@types/...` which doesn't exist on disk → virtual.
+jest.mock('../@types/WalletConnectCommandParamName', () => ({ default: PARAM_NAMES, __esModule: true }), {
+ virtual: true,
+});
+jest.mock('../@types/WalletConnectCommand', () => ({}), { virtual: true });
+jest.mock('../components/walletConnect/WalletConnectCATAmount', () => ({ default: () => null }), { virtual: true });
+jest.mock('../components/walletConnect/WalletConnectCreateOfferPreview', () => ({ default: () => null }), {
+ virtual: true,
+});
+jest.mock('../components/walletConnect/WalletConnectOfferPreview', () => ({ default: () => null }), { virtual: true });
+
+type LegacyParam = {
+ name: string;
+ type?: string;
+ isOptional?: boolean;
+ hide?: boolean;
+ defaultValue?: unknown;
+};
+
+type LegacyCommand = {
+ command: string;
+ service?: string;
+ bypassConfirm?: boolean;
+ /** Legacy field — maps to `dap.requiresSync` in the new registry. */
+ waitForSync?: boolean;
+ allFingerprints?: boolean;
+ serviceCommand?: string;
+ params?: LegacyParam[];
+};
+
+// eslint-disable-next-line import/extensions -- TODO: WalletConnectCommands file is missing from the repo, needs to be added
+import legacyCommandsRaw from './WalletConnectCommands';
+import allowedCommands from './electron/constants/AllowedCommands';
+import {
+ SCHEMA_COMMANDS,
+ getCommandByWc,
+ getCommandSchema,
+ validateDappParams,
+} from './electron/constants/commandRegistry';
+
+const legacyCommands = legacyCommandsRaw as unknown as LegacyCommand[];
+
+function legacyParams(legacy: LegacyCommand): LegacyParam[] {
+ return legacy.params ?? [];
+}
+
+function snakeName(legacyParamName: string): string {
+ return snakeCase(legacyParamName);
+}
+
+describe('legacy parity: command coverage', () => {
+ it('every legacy command exists in the new registry', () => {
+ const missing: string[] = [];
+ for (const legacy of legacyCommands) {
+ const wcCommand = `chia_${legacy.command}`;
+ if (!getCommandByWc(wcCommand)) missing.push(wcCommand);
+ }
+ expect(missing).toEqual([]);
+ });
+
+ it('every legacy command resolves to a schema with a `dapp` block', () => {
+ // Legacy file IS the dapp surface — every entry must have dapp opt-in.
+ // `getCommandByWc` only returns entries with `dapp`, but assert
+ // explicitly so the intent is visible.
+ const noDapp: string[] = [];
+ for (const legacy of legacyCommands) {
+ const wcCommand = `chia_${legacy.command}`;
+ const entry = getCommandByWc(wcCommand);
+ if (!entry?.schema.dapp) noDapp.push(wcCommand);
+ }
+ expect(noDapp).toEqual([]);
+ });
+
+ it('no leakage — every wcCommand in the new registry has a legacy counterpart', () => {
+ const legacySet = new Set(legacyCommands.map((c) => `chia_${c.command}`));
+ const newWcCommands: string[] = [];
+ for (const ns of SCHEMA_COMMANDS) {
+ const schema = getCommandSchema(ns);
+ if (schema.dapp) {
+ newWcCommands.push(schema.dapp.wcCommand);
+ for (const alias of schema.dapp.aliases ?? []) newWcCommands.push(alias.wcCommand);
+ }
+ }
+ const leaked = newWcCommands.filter((wc) => !legacySet.has(wc));
+ expect(leaked).toEqual([]);
+ });
+
+ it('preserves legacy `waitForSync` as `dapp.requiresSync`', () => {
+ // Legacy `waitForSync` is truthy-checked, so undefined and `false` both
+ // mean "no wait" — match against `=== true`.
+ const drift: { wcCommand: string; legacyWait: boolean; newRequires: boolean }[] = [];
+ for (const legacy of legacyCommands) {
+ const wcCommand = `chia_${legacy.command}`;
+ const entry = getCommandByWc(wcCommand);
+ if (entry) {
+ const legacyWait = legacy.waitForSync === true;
+ const newRequires = entry.requiresSync === true;
+ if (legacyWait !== newRequires) drift.push({ wcCommand, legacyWait, newRequires });
+ }
+ }
+ expect(drift).toEqual([]);
+ });
+});
+
+describe('legacy parity: per-command param parity', () => {
+ describe.each(legacyCommands.map((c): [string, LegacyCommand] => [c.command, c]))('chia_%s', (_label, legacy) => {
+ const wcCommand = `chia_${legacy.command}`;
+
+ it('schema is reachable', () => {
+ expect(getCommandByWc(wcCommand)).toBeDefined();
+ });
+
+ it('every legacy param exists in the new schema with `dappAllowed: true`', () => {
+ const entry = getCommandByWc(wcCommand);
+ if (!entry) return;
+ const newParams = entry.schema.params;
+ const newByName = new Map(newParams.map((p) => [p.name, p]));
+ const missing: { legacy: string; expectedSnake: string }[] = [];
+ const notAllowed: string[] = [];
+ for (const lp of legacyParams(legacy)) {
+ const snake = snakeName(lp.name);
+ const np = newByName.get(snake);
+ if (!np) {
+ missing.push({ legacy: lp.name, expectedSnake: snake });
+ } else if (np.dappAllowed !== true) {
+ notAllowed.push(snake);
+ }
+ }
+ expect({ missing, notAllowed }).toEqual({ missing: [], notAllowed: [] });
+ });
+
+ it('preserves legacy param order', () => {
+ const entry = getCommandByWc(wcCommand);
+ if (!entry) return;
+ const newOrder = entry.schema.params.map((p) => p.name);
+ const expected = legacyParams(legacy).map((p) => snakeName(p.name));
+ // Filter to legacy-known names so new rows don't muddy the comparison.
+ const legacyNames = new Set(expected);
+ const filteredNew = newOrder.filter((n) => legacyNames.has(n));
+ expect(filteredNew).toEqual(expected);
+ });
+
+ it('preserves `hide: true` flags', () => {
+ const entry = getCommandByWc(wcCommand);
+ if (!entry) return;
+ const newByName = new Map(entry.schema.params.map((p) => [p.name, p]));
+ const drift: { name: string; legacyHide: boolean; newHide: boolean }[] = [];
+ for (const lp of legacyParams(legacy)) {
+ const snake = snakeName(lp.name);
+ const np = newByName.get(snake);
+ if (np) {
+ const legacyHide = lp.hide === true;
+ const newHide = np.hide === true;
+ if (legacyHide !== newHide) drift.push({ name: snake, legacyHide, newHide });
+ }
+ }
+ expect(drift).toEqual([]);
+ });
+
+ it('preserves `isOptional` flags', () => {
+ const entry = getCommandByWc(wcCommand);
+ if (!entry) return;
+ const newByName = new Map(entry.schema.params.map((p) => [p.name, p]));
+ const drift: { name: string; legacyOptional: boolean; newOptional: boolean }[] = [];
+ for (const lp of legacyParams(legacy)) {
+ const snake = snakeName(lp.name);
+ const np = newByName.get(snake);
+ if (np) {
+ const legacyOptional = lp.isOptional === true;
+ const newOptional = np.isOptional === true;
+ if (legacyOptional !== newOptional) drift.push({ name: snake, legacyOptional, newOptional });
+ }
+ }
+ expect(drift).toEqual([]);
+ });
+
+ it('preserves param defaults from legacy `defaultValue`', () => {
+ // Skip `defaultValue: undefined` — legacy used it interchangeably
+ // with omitting the field, mirrored by absent keys in `dapp.defaults`.
+ const entry = getCommandByWc(wcCommand);
+ if (!entry) return;
+ const newDefaults = (entry.defaults ?? {}) as Record;
+ const missing: { name: string; expected: unknown }[] = [];
+ const drift: { name: string; expected: unknown; actual: unknown }[] = [];
+ for (const lp of legacyParams(legacy)) {
+ if (lp.defaultValue !== undefined) {
+ const snake = snakeName(lp.name);
+ if (!(snake in newDefaults)) {
+ missing.push({ name: snake, expected: lp.defaultValue });
+ } else if (newDefaults[snake] !== lp.defaultValue) {
+ drift.push({ name: snake, expected: lp.defaultValue, actual: newDefaults[snake] });
+ }
+ }
+ }
+ expect({ missing, drift }).toEqual({ missing: [], drift: [] });
+ });
+
+ it('legacy param payloads pass `validateDappParams`', () => {
+ const payload: Record = {};
+ for (const lp of legacyParams(legacy)) {
+ payload[snakeName(lp.name)] = 'value';
+ }
+ expect(() => validateDappParams(wcCommand, payload)).not.toThrow();
+ });
+ });
+});
+
+describe('AllowedCommands coverage', () => {
+ // AllowedCommands is the UI auto-bypass list. Most entries are reads with
+ // no schema (don't need confirm UI). These checks surface gaps either way.
+
+ const schemaSet = new Set(SCHEMA_COMMANDS);
+
+ it('every AllowedCommand that ALSO has a schema entry is dapp-callable', () => {
+ // UI-bypassed + schema entry without a `dapp` block would be incoherent
+ // (renders a confirm dialog for nobody).
+ const incoherent: string[] = [];
+ for (const ns of allowedCommands) {
+ if (schemaSet.has(ns)) {
+ const schema = getCommandSchema(ns);
+ if (!schema.dapp) incoherent.push(ns);
+ }
+ }
+ expect(incoherent).toEqual([]);
+ });
+
+ it('AllowedCommands ↔ SCHEMAS coverage report (informational)', () => {
+ // Snapshot-tracked list of AllowedCommands without a schema entry.
+ // Update intentionally when adding/removing dapp surface.
+ const inAllowedNotInSchemas = allowedCommands.filter((c) => !schemaSet.has(c)).sort();
+ expect(inAllowedNotInSchemas).toMatchInlineSnapshot(`
+[
+ "chia_data_layer.ping",
+ "chia_farmer.get_connections",
+ "chia_farmer.get_harvester_plots_duplicates",
+ "chia_farmer.get_harvester_plots_invalid",
+ "chia_farmer.get_harvester_plots_keys_missing",
+ "chia_farmer.get_harvester_plots_valid",
+ "chia_farmer.get_harvesters",
+ "chia_farmer.get_harvesters_summary",
+ "chia_farmer.get_pool_state",
+ "chia_farmer.get_reward_targets",
+ "chia_farmer.get_signage_points",
+ "chia_farmer.ping",
+ "chia_full_node.get_block",
+ "chia_full_node.get_block_record",
+ "chia_full_node.get_block_records",
+ "chia_full_node.get_blockchain_state",
+ "chia_full_node.get_connections",
+ "chia_full_node.get_fee_estimate",
+ "chia_full_node.get_unfinished_block_headers",
+ "chia_full_node.ping",
+ "chia_harvester.get_harvester_config",
+ "chia_harvester.get_plot_directories",
+ "chia_harvester.ping",
+ "chia_harvester.refresh_plots",
+ "chia_wallet.cat_get_name",
+ "chia_wallet.cat_set_name",
+ "chia_wallet.check_delete_key",
+ "chia_wallet.delete_unconfirmed_transactions",
+ "chia_wallet.extend_derivation_index",
+ "chia_wallet.generate_mnemonic",
+ "chia_wallet.get_auto_claim",
+ "chia_wallet.get_cat_list",
+ "chia_wallet.get_connections",
+ "chia_wallet.get_current_derivation_index",
+ "chia_wallet.get_farmed_amount",
+ "chia_wallet.get_logged_in_fingerprint",
+ "chia_wallet.get_network_info",
+ "chia_wallet.get_notifications",
+ "chia_wallet.get_offer",
+ "chia_wallet.get_stray_cats",
+ "chia_wallet.get_timestamp_for_height",
+ "chia_wallet.get_transaction_count",
+ "chia_wallet.get_transaction_memo",
+ "chia_wallet.get_transactions",
+ "chia_wallet.nft_calculate_royalties",
+ "chia_wallet.nft_get_wallet_did",
+ "chia_wallet.ping",
+ "chia_wallet.pw_status",
+ "chia_wallet.set_wallet_resync_on_startup",
+ "daemon.add_private_key",
+ "daemon.delete_label",
+ "daemon.exit",
+ "daemon.get_key",
+ "daemon.get_keys",
+ "daemon.get_keys_for_plotting",
+ "daemon.get_plotters",
+ "daemon.get_version",
+ "daemon.is_running",
+ "daemon.keyring_status",
+ "daemon.register_service",
+ "daemon.running_services",
+ "daemon.set_label",
+ "daemon.start_plotting",
+ "daemon.start_service",
+ "daemon.stop_service",
+ "daemon.unlock_keyring",
+]
+`);
+ });
+
+ it('SCHEMAS ↔ AllowedCommands cross-check (informational)', () => {
+ const inSchemasNotInAllowed = SCHEMA_COMMANDS.filter((c) => !allowedCommands.includes(c)).sort();
+ expect(Array.isArray(inSchemasNotInAllowed)).toBe(true);
+ });
+});
diff --git a/packages/gui/src/assets/theme/chia/audio-small.svg b/packages/gui/src/assets/theme/chia/audio-small.svg
new file mode 100644
index 0000000000..5168db199e
--- /dev/null
+++ b/packages/gui/src/assets/theme/chia/audio-small.svg
@@ -0,0 +1,3 @@
+
diff --git a/packages/gui/src/assets/theme/chia/chia-black.svg b/packages/gui/src/assets/theme/chia/chia-black.svg
new file mode 100644
index 0000000000..640366b637
--- /dev/null
+++ b/packages/gui/src/assets/theme/chia/chia-black.svg
@@ -0,0 +1,6 @@
+
diff --git a/packages/gui/src/assets/theme/chia/chia.svg b/packages/gui/src/assets/theme/chia/chia.svg
new file mode 100644
index 0000000000..615d26499e
--- /dev/null
+++ b/packages/gui/src/assets/theme/chia/chia.svg
@@ -0,0 +1,18 @@
+
diff --git a/packages/gui/src/assets/theme/chia/chia_circle.svg b/packages/gui/src/assets/theme/chia/chia_circle.svg
new file mode 100644
index 0000000000..eef5d7d725
--- /dev/null
+++ b/packages/gui/src/assets/theme/chia/chia_circle.svg
@@ -0,0 +1,10 @@
+
+
+
\ No newline at end of file
diff --git a/packages/gui/src/assets/theme/chia/chia_logo.svg b/packages/gui/src/assets/theme/chia/chia_logo.svg
new file mode 100644
index 0000000000..8bf46f025b
--- /dev/null
+++ b/packages/gui/src/assets/theme/chia/chia_logo.svg
@@ -0,0 +1,18 @@
+
\ No newline at end of file
diff --git a/packages/gui/src/assets/theme/chia/document-small.svg b/packages/gui/src/assets/theme/chia/document-small.svg
new file mode 100644
index 0000000000..931526882f
--- /dev/null
+++ b/packages/gui/src/assets/theme/chia/document-small.svg
@@ -0,0 +1,3 @@
+
diff --git a/packages/gui/src/assets/theme/chia/model-small.svg b/packages/gui/src/assets/theme/chia/model-small.svg
new file mode 100644
index 0000000000..57dfb3c0a0
--- /dev/null
+++ b/packages/gui/src/assets/theme/chia/model-small.svg
@@ -0,0 +1,3 @@
+
diff --git a/packages/gui/src/assets/theme/chia/offerFileIcon.svg b/packages/gui/src/assets/theme/chia/offerFileIcon.svg
new file mode 100644
index 0000000000..29f56a9338
--- /dev/null
+++ b/packages/gui/src/assets/theme/chia/offerFileIcon.svg
@@ -0,0 +1,36 @@
+
diff --git a/packages/gui/src/assets/theme/chia/unknown-small.svg b/packages/gui/src/assets/theme/chia/unknown-small.svg
new file mode 100644
index 0000000000..0d5f371c03
--- /dev/null
+++ b/packages/gui/src/assets/theme/chia/unknown-small.svg
@@ -0,0 +1,3 @@
+
diff --git a/packages/gui/src/assets/theme/chia/video-small.svg b/packages/gui/src/assets/theme/chia/video-small.svg
new file mode 100644
index 0000000000..ee2b3b4210
--- /dev/null
+++ b/packages/gui/src/assets/theme/chia/video-small.svg
@@ -0,0 +1 @@
+
diff --git a/packages/gui/src/assets/theme/chia/walletConnectToChia.svg b/packages/gui/src/assets/theme/chia/walletConnectToChia.svg
new file mode 100644
index 0000000000..980114f997
--- /dev/null
+++ b/packages/gui/src/assets/theme/chia/walletConnectToChia.svg
@@ -0,0 +1,26 @@
+
diff --git a/packages/gui/src/assets/theme/classic/audio-small.svg b/packages/gui/src/assets/theme/classic/audio-small.svg
new file mode 100644
index 0000000000..82793ba014
--- /dev/null
+++ b/packages/gui/src/assets/theme/classic/audio-small.svg
@@ -0,0 +1,3 @@
+
diff --git a/packages/gui/src/assets/theme/classic/chia-black.svg b/packages/gui/src/assets/theme/classic/chia-black.svg
new file mode 100644
index 0000000000..640366b637
--- /dev/null
+++ b/packages/gui/src/assets/theme/classic/chia-black.svg
@@ -0,0 +1,6 @@
+
diff --git a/packages/gui/src/assets/theme/classic/chia.svg b/packages/gui/src/assets/theme/classic/chia.svg
new file mode 100644
index 0000000000..edf946190e
--- /dev/null
+++ b/packages/gui/src/assets/theme/classic/chia.svg
@@ -0,0 +1,18 @@
+
diff --git a/packages/gui/src/assets/theme/classic/chia_circle.svg b/packages/gui/src/assets/theme/classic/chia_circle.svg
new file mode 100644
index 0000000000..94f42ff90a
--- /dev/null
+++ b/packages/gui/src/assets/theme/classic/chia_circle.svg
@@ -0,0 +1,10 @@
+
+
+
\ No newline at end of file
diff --git a/packages/gui/src/assets/theme/classic/chia_logo.svg b/packages/gui/src/assets/theme/classic/chia_logo.svg
new file mode 100644
index 0000000000..c0232ee443
--- /dev/null
+++ b/packages/gui/src/assets/theme/classic/chia_logo.svg
@@ -0,0 +1,18 @@
+
\ No newline at end of file
diff --git a/packages/gui/src/assets/theme/classic/document-small.svg b/packages/gui/src/assets/theme/classic/document-small.svg
new file mode 100644
index 0000000000..868a8febfe
--- /dev/null
+++ b/packages/gui/src/assets/theme/classic/document-small.svg
@@ -0,0 +1,3 @@
+
diff --git a/packages/gui/src/assets/theme/classic/model-small.svg b/packages/gui/src/assets/theme/classic/model-small.svg
new file mode 100644
index 0000000000..c7db756540
--- /dev/null
+++ b/packages/gui/src/assets/theme/classic/model-small.svg
@@ -0,0 +1,3 @@
+
diff --git a/packages/gui/src/assets/theme/classic/offerFileIcon.svg b/packages/gui/src/assets/theme/classic/offerFileIcon.svg
new file mode 100644
index 0000000000..5cbae30fa8
--- /dev/null
+++ b/packages/gui/src/assets/theme/classic/offerFileIcon.svg
@@ -0,0 +1,36 @@
+
diff --git a/packages/gui/src/assets/theme/classic/unknown-small.svg b/packages/gui/src/assets/theme/classic/unknown-small.svg
new file mode 100644
index 0000000000..1332a7b1a1
--- /dev/null
+++ b/packages/gui/src/assets/theme/classic/unknown-small.svg
@@ -0,0 +1,3 @@
+
diff --git a/packages/gui/src/assets/theme/classic/video-small.svg b/packages/gui/src/assets/theme/classic/video-small.svg
new file mode 100644
index 0000000000..83231a0f20
--- /dev/null
+++ b/packages/gui/src/assets/theme/classic/video-small.svg
@@ -0,0 +1 @@
+
diff --git a/packages/gui/src/assets/theme/classic/walletConnectToChia.svg b/packages/gui/src/assets/theme/classic/walletConnectToChia.svg
new file mode 100644
index 0000000000..980114f997
--- /dev/null
+++ b/packages/gui/src/assets/theme/classic/walletConnectToChia.svg
@@ -0,0 +1,26 @@
+
diff --git a/packages/gui/src/assets/theme/field/audio-small.svg b/packages/gui/src/assets/theme/field/audio-small.svg
new file mode 100644
index 0000000000..26f2684506
--- /dev/null
+++ b/packages/gui/src/assets/theme/field/audio-small.svg
@@ -0,0 +1,3 @@
+
diff --git a/packages/gui/src/assets/theme/field/chia-black.svg b/packages/gui/src/assets/theme/field/chia-black.svg
new file mode 100644
index 0000000000..640366b637
--- /dev/null
+++ b/packages/gui/src/assets/theme/field/chia-black.svg
@@ -0,0 +1,6 @@
+
diff --git a/packages/gui/src/assets/theme/field/chia.svg b/packages/gui/src/assets/theme/field/chia.svg
new file mode 100644
index 0000000000..958dc130ab
--- /dev/null
+++ b/packages/gui/src/assets/theme/field/chia.svg
@@ -0,0 +1,18 @@
+
diff --git a/packages/gui/src/assets/theme/field/chia_circle.svg b/packages/gui/src/assets/theme/field/chia_circle.svg
new file mode 100644
index 0000000000..f514a7703c
--- /dev/null
+++ b/packages/gui/src/assets/theme/field/chia_circle.svg
@@ -0,0 +1,10 @@
+
+
+
\ No newline at end of file
diff --git a/packages/gui/src/assets/theme/field/chia_logo.svg b/packages/gui/src/assets/theme/field/chia_logo.svg
new file mode 100644
index 0000000000..3fcdd23b31
--- /dev/null
+++ b/packages/gui/src/assets/theme/field/chia_logo.svg
@@ -0,0 +1,18 @@
+
\ No newline at end of file
diff --git a/packages/gui/src/assets/theme/field/document-small.svg b/packages/gui/src/assets/theme/field/document-small.svg
new file mode 100644
index 0000000000..41723a4863
--- /dev/null
+++ b/packages/gui/src/assets/theme/field/document-small.svg
@@ -0,0 +1,3 @@
+
diff --git a/packages/gui/src/assets/theme/field/model-small.svg b/packages/gui/src/assets/theme/field/model-small.svg
new file mode 100644
index 0000000000..5c0577b908
--- /dev/null
+++ b/packages/gui/src/assets/theme/field/model-small.svg
@@ -0,0 +1,3 @@
+
diff --git a/packages/gui/src/assets/theme/field/offerFileIcon.svg b/packages/gui/src/assets/theme/field/offerFileIcon.svg
new file mode 100644
index 0000000000..6a48cfffc8
--- /dev/null
+++ b/packages/gui/src/assets/theme/field/offerFileIcon.svg
@@ -0,0 +1,36 @@
+
diff --git a/packages/gui/src/assets/theme/field/unknown-small.svg b/packages/gui/src/assets/theme/field/unknown-small.svg
new file mode 100644
index 0000000000..b1f978700d
--- /dev/null
+++ b/packages/gui/src/assets/theme/field/unknown-small.svg
@@ -0,0 +1,3 @@
+
diff --git a/packages/gui/src/assets/theme/field/video-small.svg b/packages/gui/src/assets/theme/field/video-small.svg
new file mode 100644
index 0000000000..df38abeebb
--- /dev/null
+++ b/packages/gui/src/assets/theme/field/video-small.svg
@@ -0,0 +1 @@
+
diff --git a/packages/gui/src/assets/theme/field/walletConnectToChia.svg b/packages/gui/src/assets/theme/field/walletConnectToChia.svg
new file mode 100644
index 0000000000..980114f997
--- /dev/null
+++ b/packages/gui/src/assets/theme/field/walletConnectToChia.svg
@@ -0,0 +1,26 @@
+
diff --git a/packages/gui/src/components/app/AppProviders.tsx b/packages/gui/src/components/app/AppProviders.tsx
index 0c1cb95336..06331bf01b 100644
--- a/packages/gui/src/components/app/AppProviders.tsx
+++ b/packages/gui/src/components/app/AppProviders.tsx
@@ -1,6 +1,7 @@
import { store, api } from '@chia-network/api-react';
import {
useDarkMode,
+ useThemeVariant,
sleep,
ThemeProvider,
ModalDialogsProvider,
@@ -8,18 +9,18 @@ import {
LocaleProvider,
LayoutLoading,
AddressBookProvider,
- dark,
- light,
+ resolveAppTheme,
ErrorBoundary,
AuthProvider,
} from '@chia-network/core';
import { Trans } from '@lingui/macro';
import { Typography } from '@mui/material';
-import React, { ReactNode, useEffect, useState, Suspense } from 'react';
+import React, { ReactNode, useEffect, useMemo, useState, Suspense } from 'react';
import { Provider } from 'react-redux';
import { Outlet } from 'react-router-dom';
import { i18n, defaultLocale, locales } from '../../config/locales';
+import GuiThemeAssetsProvider from '../../theme/GuiThemeAssetsProvider';
import WebSocketBridge from '../../util/WebSocketBridge';
import CacheProvider from '../cache/CacheProvider';
import LRUsProvider from '../lrus/LRUsProvider';
@@ -52,8 +53,9 @@ export default function App(props: AppProps) {
const { children, outlet } = props;
const [isReady, setIsReady] = useState(false);
const { isDarkMode } = useDarkMode();
+ const { themeVariant } = useThemeVariant();
- const theme = isDarkMode ? dark : light;
+ const theme = useMemo(() => resolveAppTheme(themeVariant, isDarkMode), [themeVariant, isDarkMode]);
async function init() {
const config = await waitForConfig();
@@ -78,11 +80,13 @@ export default function App(props: AppProps) {
return (
-
-
- Loading configuration
-
-
+
+
+
+ Loading configuration
+
+
+
);
@@ -92,30 +96,32 @@ export default function App(props: AppProps) {
-
-
-
-
-
-
- }>
-
-
-
-
- {outlet ? : children}
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+ }>
+
+
+
+
+ {outlet ? : children}
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/gui/src/components/app/AppRouter.tsx b/packages/gui/src/components/app/AppRouter.tsx
index 5665d54dc6..0544a56d40 100644
--- a/packages/gui/src/components/app/AppRouter.tsx
+++ b/packages/gui/src/components/app/AppRouter.tsx
@@ -6,6 +6,7 @@ import { HashRouter, Routes, Route, Navigate } from 'react-router-dom';
import AddressBook from '../addressbook/AddressBook';
import Block from '../block/Block';
import ChiaToolsPage from '../chiatools/ChiaToolsPage';
+import DashboardOverview from '../dashboard/DashboardOverview';
import DashboardSideBar from '../dashboard/DashboardSideBar';
import Farm from '../farm/Farm';
import FullNode from '../fullNode/FullNode';
@@ -50,7 +51,8 @@ export default function AppRouter() {
) : (
} actions={} outlet />}>
- } />
+ } />
+ } />
} />
} />
} />
diff --git a/packages/gui/src/components/app/AppSandbox.tsx b/packages/gui/src/components/app/AppSandbox.tsx
new file mode 100644
index 0000000000..69fe09d038
--- /dev/null
+++ b/packages/gui/src/components/app/AppSandbox.tsx
@@ -0,0 +1,501 @@
+import { resolveAppTheme, ThemeProvider, useDarkMode, useThemeVariant } from '@chia-network/core';
+import { Overview as OverviewIcon } from '@chia-network/icons';
+import {
+ AccountBalanceWallet,
+ Agriculture,
+ BarChart,
+ BlurOn,
+ Bolt,
+ Contacts,
+ DeviceThermostat,
+ FactCheck,
+ GridView,
+ Hub,
+ Inventory2,
+ LocalOffer,
+ Palette,
+ Search,
+ Settings,
+ Storage,
+ VerifiedUser,
+ WaterDrop,
+} from '@mui/icons-material';
+import {
+ AppBar,
+ Box,
+ Button,
+ Chip,
+ CssBaseline,
+ Drawer,
+ IconButton,
+ LinearProgress,
+ Toolbar,
+ Typography,
+} from '@mui/material';
+import { alpha, styled, useTheme } from '@mui/material/styles';
+import React, { useMemo, useState } from 'react';
+
+import GuiThemeAssetsProvider from '../../theme/GuiThemeAssetsProvider';
+
+const drawerWidth = 124;
+
+const navItems = [
+ { label: 'Overview', icon: OverviewIcon },
+ { label: 'Wallets', icon: AccountBalanceWallet },
+ { label: 'NFTs', icon: GridView },
+ { label: 'Offers', icon: LocalOffer },
+ { label: 'Credentials', icon: FactCheck },
+ { label: 'Contacts', icon: Contacts },
+ { label: 'Full Node', icon: Hub },
+ { label: 'Farm', icon: Agriculture },
+ { label: 'Plots', icon: BlurOn },
+ { label: 'Harvest', icon: Storage },
+ { label: 'Pool', icon: Inventory2 },
+ { label: 'Tools', icon: DeviceThermostat },
+ { label: 'Settings', icon: Settings },
+];
+
+const StyledRoot = styled(Box)(({ theme }) => ({
+ minHeight: '100vh',
+ color: theme.palette.text.primary,
+ backgroundColor: '#f4f0e5',
+ backgroundImage: [
+ 'linear-gradient(118deg, rgba(244, 240, 229, 0.96) 0%, rgba(237, 229, 207, 0.92) 38%, rgba(226, 235, 227, 0.94) 100%)',
+ 'repeating-linear-gradient(102deg, rgba(158, 117, 47, 0.1) 0 18px, rgba(63, 99, 72, 0.09) 18px 34px, transparent 34px 68px)',
+ ].join(','),
+}));
+
+const StyledDrawer = styled(Drawer)(() => ({
+ width: drawerWidth,
+ flexShrink: 0,
+ '& .MuiDrawer-paper': {
+ width: drawerWidth,
+ borderRight: `1px solid ${alpha('#473a24', 0.18)}`,
+ background: 'linear-gradient(180deg, rgba(38, 51, 41, 0.97) 0%, rgba(51, 55, 44, 0.98) 100%)',
+ color: '#f7efd8',
+ },
+}));
+
+const StyledMain = styled(Box)(() => ({
+ marginLeft: drawerWidth,
+ minHeight: '100vh',
+}));
+
+const Surface = styled(Box)(({ theme }) => ({
+ border: `1px solid ${alpha('#473a24', 0.14)}`,
+ background: alpha(theme.palette.background.paper, 0.82),
+ borderRadius: 8,
+ boxShadow: `0 18px 54px ${alpha('#473a24', 0.1)}`,
+}));
+
+const FieldMap = styled(Box)(() => ({
+ position: 'relative',
+ overflow: 'hidden',
+ minHeight: 178,
+ borderRadius: 8,
+ border: `1px solid ${alpha('#473a24', 0.14)}`,
+ backgroundColor: '#c99837',
+ backgroundImage: [
+ 'linear-gradient(180deg, rgba(137, 170, 184, 0.7) 0%, rgba(236, 221, 174, 0.42) 42%, rgba(173, 111, 44, 0.46) 100%)',
+ 'repeating-linear-gradient(112deg, rgba(92, 107, 60, 0.78) 0 10px, rgba(219, 176, 77, 0.74) 10px 24px, rgba(122, 72, 42, 0.24) 24px 30px)',
+ ].join(','),
+ '&::after': {
+ content: '""',
+ position: 'absolute',
+ inset: 14,
+ border: `1px solid ${alpha('#fff7de', 0.48)}`,
+ borderRadius: 6,
+ },
+}));
+
+function NavButton({
+ label,
+ icon: Icon,
+ active,
+ onSelect,
+}: {
+ label: string;
+ icon: React.ElementType;
+ active?: boolean;
+ onSelect: () => void;
+}) {
+ return (
+
+
+
+ {label}
+
+
+ );
+}
+
+function PagePreview({ page }: { page: string }) {
+ const theme = useTheme();
+ const rows = [
+ 'Primary content area keeps the original Chia function reachable.',
+ 'Tables, forms, dialogs, and actions inherit the updated surface style.',
+ 'No daemon, wallet, keyring, or chain request is made in this preview.',
+ ];
+
+ return (
+ <>
+
+
+
+ No-daemon page preview
+
+
+ {page}
+
+
+ This is a visual stand-in for the real {page} page. It lets you check sidebar flow, spacing, card density,
+ button tone, and the light theme before replacing the installed GUI.
+
+
+
+
+
+
+
+
+
+
+ {page} console strip
+
+
+ Light theme applied
+
+
+
+
+
+
+ {['Status', 'Action', 'History', 'Alerts'].map((label, index) => (
+
+
+ {page} {label}
+
+
+ {index === 0 ? 'Ready' : index === 1 ? 'Available' : index === 2 ? 'Sample rows' : 'None'}
+
+
+ Mocked display for layout review.
+
+
+ ))}
+
+
+
+
+
+
+ {page} layout motion
+
+
+ Representative rows for the real page surface.
+
+
+
+
+ {rows.map((item, index) => (
+
+
+
+
+
+ {item}
+
+
+ Preview-only row. Real Chia logic stays outside this browser sandbox.
+
+
+
+
+
+ ))}
+
+ >
+ );
+}
+
+function SandboxScreen() {
+ const theme = useTheme();
+ const [selectedPage, setSelectedPage] = useState('Overview');
+ const metrics = [
+ { label: 'Field Balance', value: '1,248.42 XCH', detail: 'Mock display data', icon: AccountBalanceWallet },
+ { label: 'North Ridge Node', value: 'Synced', detail: 'Height 6,427,108', icon: VerifiedUser },
+ { label: 'Active Plots', value: '42 active', detail: 'ETA 18 days', icon: BarChart },
+ { label: 'Console Mode', value: 'UI only', detail: 'No daemon calls', icon: Palette },
+ ];
+ const overviewSections = [
+ { label: 'Wallets', value: '1,248.42 XCH', detail: 'Spendable balance, pending tx, address tools' },
+ { label: 'Offers', value: '3 drafts', detail: 'Create, import, inspect, and manage offers' },
+ { label: 'NFTs', value: '18 items', detail: 'Gallery, detail view, metadata, incoming offers' },
+ { label: 'Credentials', value: 'Enabled', detail: 'Verifiable credentials remain available' },
+ { label: 'Full Node', value: 'Synced', detail: 'Height, peers, blocks, and chain status' },
+ { label: 'Farm', value: 'Active', detail: 'Rewards, challenges, and farming status' },
+ { label: 'Plots', value: '42 plots', detail: 'Plot inventory and add-plot flow' },
+ { label: 'Harvest', value: 'Ready', detail: 'Harvester overview and plot add shortcut' },
+ { label: 'Pool', value: 'Connected', detail: 'Pool overview, join, change, and absorb rewards' },
+ { label: 'Tools', value: 'Logs', detail: 'Diagnostics and local GUI tools stay reachable' },
+ ];
+
+ return (
+
+
+
+ {navItems.map((item) => (
+ setSelectedPage(item.label)}
+ />
+ ))}
+
+
+
+
+
+
+
+ Chia GUI design sandbox / Overview first
+
+
+ {selectedPage === 'Overview' ? 'Overview' : selectedPage}
+
+
+
+
+
+ Search GUI functions
+
+
+
+
+
+
+
+
+
+
+ {selectedPage === 'Overview' ? (
+ <>
+
+
+
+ Unified status screen
+
+
+ Dashboard overview
+
+
+ A single first screen for daily checks: wallet, node, farm, plots, offers, NFTs, pool, tools, and
+ settings stay visible without removing any original function.
+
+
+ } label="rice field amber" size="small" />
+ } label="morning frost blue" size="small" />
+ } label="low heat console" size="small" />
+
+
+
+
+
+ Status monitor
+
+
+ North valley rows online
+
+
+
+
+
+
+ {metrics.map(({ label, value, detail, icon: Icon }) => (
+
+
+
+
+ {label}
+
+
+ {value}
+
+
+ {detail}
+
+
+
+
+
+ ))}
+
+
+
+
+
+
+
+ Console activity
+
+
+ Static sample content for color, spacing, and density checks.
+
+
+
+
+ {['Harvest lane sync', 'Offer ledger spacing', 'Gallery row density', 'Plot rhythm check'].map(
+ (item, index) => (
+
+
+
+
+
+ {item}
+
+
+ UI-only row. Safe to restyle without touching wallet or chain logic.
+
+
+
+
+
+ ),
+ )}
+
+
+
+
+ Preserved functions
+
+
+ The left tags stay conventional, and the overview acts as a launch console instead of replacing
+ feature pages.
+
+
+ {overviewSections.slice(0, 5).map((item) => (
+
+
+ {item.label}
+
+
+ {item.value}
+
+
+ ))}
+
+
+
+
+
+
+
+
+ All original areas
+
+
+ Orthodox labels remain available from the sidebar; this overview only shortens the first check.
+
+
+
+
+
+ {overviewSections.map((item) => (
+
+
+ {item.label}
+
+
+ {item.value}
+
+
+ {item.detail}
+
+
+ ))}
+
+
+ >
+ ) : (
+
+ )}
+
+
+
+ );
+}
+
+export default function AppSandbox() {
+ const { isDarkMode } = useDarkMode();
+ const { themeVariant } = useThemeVariant();
+ const theme = useMemo(() => resolveAppTheme(themeVariant, isDarkMode), [themeVariant, isDarkMode]);
+
+ return (
+
+
+
+
+
+
+ );
+}
diff --git a/packages/gui/src/components/app/AppSelectMode.tsx b/packages/gui/src/components/app/AppSelectMode.tsx
index c1aed71907..67b724faa0 100644
--- a/packages/gui/src/components/app/AppSelectMode.tsx
+++ b/packages/gui/src/components/app/AppSelectMode.tsx
@@ -13,7 +13,7 @@ import styled from 'styled-components';
const StyledCheckIcon = styled(CheckIcon)`
border-radius: 9999px;
padding: ${({ theme }) => theme.spacing(0.5)};
- background-color: ${alpha(Color.Green[400], 0.2)};
+ background-color: ${({ theme }) => alpha(theme.palette.primary.main, 0.22)};
`;
const StyledSettingsIcon = styled(SettingsIcon)`
diff --git a/packages/gui/src/components/dashboard/DashboardOverview.tsx b/packages/gui/src/components/dashboard/DashboardOverview.tsx
new file mode 100644
index 0000000000..9c13a70cf1
--- /dev/null
+++ b/packages/gui/src/components/dashboard/DashboardOverview.tsx
@@ -0,0 +1,410 @@
+import {
+ useGetBlockchainStateQuery,
+ useGetFullNodeConnectionsQuery,
+ useGetNewFarmingInfoQuery,
+ useGetTotalHarvestersSummaryQuery,
+} from '@chia-network/api-react';
+import { FormatBytes, FormatLargeNumber, mojoToChiaLocaleString, useCurrencyCode, useLocale } from '@chia-network/core';
+import {
+ Contacts as ContactsIcon,
+ Farm as FarmIcon,
+ FullNode as FullNodeIcon,
+ Harvest as HarvestIcon,
+ NFTs as NFTsIcon,
+ Offers as OffersIcon,
+ Plots as PlotsIcon,
+ Pooling as PoolingIcon,
+ Settings as SettingsIcon,
+ Tokens as TokensIcon,
+ VC as VCIcon,
+} from '@chia-network/icons';
+import { Trans } from '@lingui/macro';
+import BuildOutlinedIcon from '@mui/icons-material/BuildOutlined';
+import { Box, Card, CardActionArea, CardContent, LinearProgress, Typography } from '@mui/material';
+import { alpha, useTheme } from '@mui/material/styles';
+import React from 'react';
+import { useNavigate } from 'react-router-dom';
+
+import useStandardWallet from '../../hooks/useStandardWallet';
+
+const quickStatus = [
+ {
+ label: Wallets,
+ detail: Balances, send, receive, transactions,
+ to: '/dashboard/wallets',
+ icon: TokensIcon,
+ iconScale: 1.15,
+ key: 'Wallets',
+ },
+ {
+ label: Full Node,
+ detail: Sync, peers, block inspection,
+ to: '/dashboard/fullnode',
+ icon: FullNodeIcon,
+ key: 'Full Node',
+ },
+ {
+ label: Farm,
+ detail: Farming status and rewards,
+ to: '/dashboard/farm',
+ icon: FarmIcon,
+ key: 'Farm',
+ },
+ {
+ label: Plots,
+ detail: Plot count, size, and add flow,
+ to: '/dashboard/plot',
+ icon: PlotsIcon,
+ key: 'Plots',
+ },
+];
+
+const preservedAreas = [
+ {
+ label: NFTs,
+ detail: Gallery and detail pages,
+ to: '/dashboard/nfts',
+ icon: NFTsIcon,
+ },
+ {
+ label: Offers,
+ detail: Create, import, inspect, manage,
+ to: '/dashboard/offers',
+ icon: OffersIcon,
+ },
+ {
+ label: Credentials,
+ detail: Verifiable credentials,
+ to: '/dashboard/vc',
+ icon: VCIcon,
+ iconScale: 1.2,
+ },
+ {
+ label: Contacts,
+ detail: Address book,
+ to: '/dashboard/addressbook',
+ icon: ContactsIcon,
+ },
+ {
+ label: Harvest,
+ detail: Harvester overview,
+ to: '/dashboard/harvest',
+ icon: HarvestIcon,
+ },
+ { label: Pool, detail: Pooling controls, to: '/dashboard/pool', icon: PoolingIcon },
+ {
+ label: Tools,
+ detail: Logs and diagnostics,
+ to: '/dashboard/chiatools',
+ icon: BuildOutlinedIcon,
+ },
+ {
+ label: Settings,
+ detail: Preferences and services,
+ to: '/dashboard/settings/general',
+ icon: SettingsIcon,
+ },
+];
+
+function OverviewCardIcon(props: { icon: React.ElementType; scale?: number }) {
+ const { icon: Icon, scale = 1 } = props;
+ const size = 22 * scale;
+
+ return (
+
+ );
+}
+
+function OverviewCard(props: {
+ label: React.ReactNode;
+ value?: React.ReactNode;
+ detail: React.ReactNode;
+ to: string;
+ icon: React.ElementType;
+ iconScale?: number;
+ progress?: number;
+}) {
+ const { label, value, detail, to, icon, iconScale, progress } = props;
+ const theme = useTheme();
+ const navigate = useNavigate();
+
+ return (
+
+ navigate(to)} sx={{ height: '100%' }}>
+
+
+
+
+ {label}
+
+ {value && (
+
+ {value}
+
+ )}
+
+
+
+
+
+
+ {detail}
+
+ {progress !== undefined && (
+
+ )}
+
+
+
+ );
+}
+
+function useFormattedXCHBalance() {
+ const { wallet, walletBalance, loading, error } = useStandardWallet();
+ const currencyCode = (useCurrencyCode() ?? 'XCH').toUpperCase();
+ const [locale] = useLocale();
+
+ if (loading) {
+ return {
+ value: Loading,
+ detail: Reading standard wallet balance,
+ };
+ }
+
+ if (error) {
+ return {
+ value: Unavailable,
+ detail: Wallet balance service did not respond,
+ };
+ }
+
+ if (!wallet) {
+ return {
+ value: No wallet,
+ detail: Standard wallet is not available,
+ };
+ }
+
+ if (!walletBalance) {
+ return {
+ value: Pending,
+ detail: Waiting for balance data,
+ };
+ }
+
+ const confirmed = mojoToChiaLocaleString(walletBalance.confirmedWalletBalance ?? 0, locale);
+ const spendable = mojoToChiaLocaleString(walletBalance.spendableBalance ?? 0, locale);
+
+ return {
+ value: `${confirmed} ${currencyCode}`,
+ detail: (
+
+ Spendable {spendable} {currencyCode}
+
+ ),
+ };
+}
+
+function useFormattedFullNodeStatus() {
+ const {
+ data: state,
+ isLoading,
+ error,
+ } = useGetBlockchainStateQuery(
+ {},
+ {
+ pollingInterval: 10_000,
+ },
+ );
+ const { data: connections = [], isLoading: isLoadingConnections } = useGetFullNodeConnectionsQuery();
+ const sync = state?.sync;
+
+ if (isLoading) {
+ return {
+ value: Loading,
+ detail: Reading full node status,
+ };
+ }
+
+ if (error) {
+ return {
+ value: Unavailable,
+ detail: Full node service did not respond,
+ };
+ }
+
+ if (sync?.syncMode) {
+ return {
+ value: Syncing,
+ detail: (
+ <>
+ Height /{' '}
+
+ >
+ ),
+ };
+ }
+
+ const peers = isLoadingConnections ? peers loading : `${connections.length} peers`;
+ const peakHeight = state?.peak?.height;
+
+ return {
+ value: sync?.synced ? Synced : Not synced,
+ detail:
+ peakHeight === undefined ? (
+ peers
+ ) : (
+ <>
+ Peak · {peers}
+ >
+ ),
+ };
+}
+
+function useFormattedPlotStatus() {
+ const { plots, totalPlotSize, harvesters, initializedHarvesters, isLoading, error } =
+ useGetTotalHarvestersSummaryQuery();
+
+ if (isLoading) {
+ return {
+ value: Loading,
+ detail: Reading harvester summary,
+ };
+ }
+
+ if (error) {
+ return {
+ value: Unavailable,
+ detail: Harvester summary did not respond,
+ };
+ }
+
+ return {
+ value: (
+ <>
+ plots
+ >
+ ),
+ detail: (
+ <>
+ · {initializedHarvesters}/{harvesters}{' '}
+ harvesters
+ >
+ ),
+ };
+}
+
+function useFormattedFarmStatus() {
+ const { data, isLoading, error } = useGetNewFarmingInfoQuery();
+
+ if (isLoading) {
+ return {
+ value: Loading,
+ detail: Reading recent farming attempts,
+ };
+ }
+
+ if (error) {
+ return {
+ value: Unavailable,
+ detail: Farming info did not respond,
+ };
+ }
+
+ const latest = data?.newFarmingInfo?.[0];
+
+ if (!latest) {
+ return {
+ value: No attempts,
+ detail: No recent plot filter attempts found,
+ };
+ }
+
+ return {
+ value: latest.proofs > 0 ? Proof found : Checking plots,
+ detail: (
+
+ {latest.passedFilter} / {latest.totalPlots} plots passed filter
+
+ ),
+ };
+}
+
+export default function DashboardOverview() {
+ const xchBalance = useFormattedXCHBalance();
+ const fullNodeStatus = useFormattedFullNodeStatus();
+ const plotStatus = useFormattedPlotStatus();
+ const farmStatus = useFormattedFarmStatus();
+ const liveStatusByKey: Record = {
+ Wallets: xchBalance,
+ 'Full Node': fullNodeStatus,
+ Farm: farmStatus,
+ Plots: plotStatus,
+ };
+ const statusCards = quickStatus.map((item) => ({
+ ...item,
+ ...liveStatusByKey[item.key],
+ }));
+
+ return (
+
+
+ {statusCards.map((item) => (
+
+ ))}
+
+
+
+
+
+ More areas
+
+
+ {preservedAreas.map((item) => (
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/packages/gui/src/components/dashboard/DashboardSideBar.tsx b/packages/gui/src/components/dashboard/DashboardSideBar.tsx
index c26c437193..4d070bbeeb 100644
--- a/packages/gui/src/components/dashboard/DashboardSideBar.tsx
+++ b/packages/gui/src/components/dashboard/DashboardSideBar.tsx
@@ -12,6 +12,7 @@ import {
Settings as SettingsIcon,
Contacts as AddressBookIcon,
VC as VCIcon,
+ Overview as OverviewIcon,
} from '@chia-network/icons';
import { Trans } from '@lingui/macro';
import BuildOutlinedIcon from '@mui/icons-material/BuildOutlined';
@@ -41,7 +42,16 @@ export default function DashboardSideBar(props: DashboardSideBarProps) {
return (
-
+
+ {!simple && (
+ Overview}
+ data-testid="DashboardSideBar-overview"
+ end
+ />
+ )}
-
+
Full Node}
data-testid="DashboardSideBar-fullnode"
- end
/>
div > div': {
- display: 'inline-flex',
- },
- '.cancel-icon': {
- g: {
- circle: {
- stroke: '#D32F2F',
- fill: '#D32F2F',
- },
+function getIndicatorStyle(successColor: string, warningColor: string, errorColor: string) {
+ return {
+ marginTop: 1,
+ '> div > div': {
+ display: 'inline-flex',
},
- },
- '.checkmark-icon': {
- g: {
- circle: {
- stroke: '#3AAC59',
- fill: '#3AAC59',
- },
- path: {
- stroke: '#3AAC59',
- fill: '#3AAC59',
+ '.cancel-icon': {
+ g: {
+ circle: {
+ stroke: errorColor,
+ fill: errorColor,
+ },
},
},
- },
- '.reload-icon': {
- g: {
- circle: {
- stroke: '#FF9800',
- fill: '#FF9800',
+ '.checkmark-icon': {
+ g: {
+ circle: {
+ stroke: successColor,
+ fill: successColor,
+ },
+ path: {
+ stroke: successColor,
+ fill: successColor,
+ },
},
- path: {
- fill: '#FF9800',
+ },
+ '.reload-icon': {
+ g: {
+ circle: {
+ stroke: warningColor,
+ fill: warningColor,
+ },
+ path: {
+ fill: warningColor,
+ },
},
},
- },
-};
+ };
+}
export default React.memo(FarmHealth);
function FarmHealth() {
+ const theme = useTheme();
+ const { palette } = theme;
+ const semanticColors = getSemanticColors(palette);
+ const indicatorStyle = React.useMemo(
+ () => getIndicatorStyle(semanticColors.success, semanticColors.warning, semanticColors.error),
+ [semanticColors.error, semanticColors.success, semanticColors.warning],
+ );
const { farmerStatus, blockchainState } = useFarmerStatus();
const { data: missingSpsData, isLoading: isLoadingMissingSps } = useGetMissingSignagePointsQuery();
const [resetMissingSps] = useResetMissingSignagePointsMutation();
diff --git a/packages/gui/src/components/farm/PoolingHealth.tsx b/packages/gui/src/components/farm/PoolingHealth.tsx
index 9f3fa7e6ee..6d2ed30253 100644
--- a/packages/gui/src/components/farm/PoolingHealth.tsx
+++ b/packages/gui/src/components/farm/PoolingHealth.tsx
@@ -3,51 +3,59 @@ import {
useGetPartialStatsOffsetQuery,
useResetPartialStatsMutation,
} from '@chia-network/api-react';
-import { Flex, StateIndicator, State, Tooltip } from '@chia-network/core';
+import { Flex, getSemanticColors, StateIndicator, State, Tooltip } from '@chia-network/core';
import { Trans } from '@lingui/macro';
-import { Box, Paper, Typography, CircularProgress, Button } from '@mui/material';
+import { Box, Paper, Typography, CircularProgress, Button, useTheme } from '@mui/material';
import React from 'react';
-const indicatorStyle = {
- marginTop: 1,
- '> div > div': {
- display: 'inline-flex',
- },
- '.cancel-icon': {
- g: {
- circle: {
- stroke: '#D32F2F',
- fill: '#D32F2F',
- },
+function getIndicatorStyle(successColor: string, warningColor: string, errorColor: string) {
+ return {
+ marginTop: 1,
+ '> div > div': {
+ display: 'inline-flex',
},
- },
- '.checkmark-icon': {
- g: {
- circle: {
- stroke: '#3AAC59',
- fill: '#3AAC59',
- },
- path: {
- stroke: '#3AAC59',
- fill: '#3AAC59',
+ '.cancel-icon': {
+ g: {
+ circle: {
+ stroke: errorColor,
+ fill: errorColor,
+ },
},
},
- },
- '.reload-icon': {
- g: {
- circle: {
- stroke: '#FF9800',
- fill: '#FF9800',
+ '.checkmark-icon': {
+ g: {
+ circle: {
+ stroke: successColor,
+ fill: successColor,
+ },
+ path: {
+ stroke: successColor,
+ fill: successColor,
+ },
},
- path: {
- fill: '#FF9800',
+ },
+ '.reload-icon': {
+ g: {
+ circle: {
+ stroke: warningColor,
+ fill: warningColor,
+ },
+ path: {
+ fill: warningColor,
+ },
},
},
- },
-};
+ };
+}
export default React.memo(PoolingHealth);
function PoolingHealth() {
+ const theme = useTheme();
+ const semanticColors = getSemanticColors(theme.palette);
+ const indicatorStyle = React.useMemo(
+ () => getIndicatorStyle(semanticColors.success, semanticColors.warning, semanticColors.error),
+ [semanticColors.error, semanticColors.success, semanticColors.warning],
+ );
const { data, isLoading } = useGetPoolStateQuery();
const { data: partialStatsOffset, isLoading: isLoadingPartialStatsOffset } = useGetPartialStatsOffsetQuery();
const [resetPartialStatsOffset] = useResetPartialStatsMutation();
diff --git a/packages/gui/src/components/harvest/HarvesterDetail.tsx b/packages/gui/src/components/harvest/HarvesterDetail.tsx
index e9a2950848..31ada13fd2 100644
--- a/packages/gui/src/components/harvest/HarvesterDetail.tsx
+++ b/packages/gui/src/components/harvest/HarvesterDetail.tsx
@@ -2,6 +2,7 @@ import { HarvesterInfo, LatencyData } from '@chia-network/api';
import { Flex, FormatBytes, Tooltip, TooltipIcon } from '@chia-network/core';
import { Trans } from '@lingui/macro';
import { Box, Paper, Typography, LinearProgress, Chip } from '@mui/material';
+import { useTheme } from '@mui/material/styles';
import BigNumber from 'bignumber.js';
import * as React from 'react';
@@ -21,6 +22,7 @@ export default React.memo(HarvesterLatencyGraph);
function HarvesterLatencyGraph(props: HarvesterLatencyGraphProps) {
const { harvester, latencyData, totalFarmSizeRaw, totalFarmSizeEffective } = props;
+ const theme = useTheme();
// const { isDarkMode } = useDarkMode();
const nodeId = harvester?.connection.nodeId;
const host = harvester?.connection.host;
@@ -142,7 +144,7 @@ function HarvesterLatencyGraph(props: HarvesterLatencyGraphProps) {
span': { backgroundColor: '#1a8284' } }}
+ sx={{ height: 20, '& .MuiLinearProgress-bar': { backgroundColor: theme.palette.primary.dark } }}
/>
@@ -176,7 +178,7 @@ function HarvesterLatencyGraph(props: HarvesterLatencyGraphProps) {
span': { backgroundColor: '#5ece71' } }}
+ sx={{ height: 20, '& .MuiLinearProgress-bar': { backgroundColor: theme.palette.primary.main } }}
/>
@@ -187,7 +189,14 @@ function HarvesterLatencyGraph(props: HarvesterLatencyGraphProps) {
);
- }, [harvester, totalFarmSizeRaw, totalFarmSizeEffective, noPlots]);
+ }, [
+ harvester,
+ totalFarmSizeRaw,
+ totalFarmSizeEffective,
+ noPlots,
+ theme.palette.primary.dark,
+ theme.palette.primary.main,
+ ]);
const harvesterLatency = React.useMemo(() => {
if (noPlots) {
diff --git a/packages/gui/src/components/harvest/LatencyCharts.tsx b/packages/gui/src/components/harvest/LatencyCharts.tsx
index 659f397917..785e4dba4a 100644
--- a/packages/gui/src/components/harvest/LatencyCharts.tsx
+++ b/packages/gui/src/components/harvest/LatencyCharts.tsx
@@ -1,5 +1,7 @@
import { LatencyRecord } from '@chia-network/api';
-import { Chart as ChartJS, BarElement, CategoryScale, LinearScale, BarController } from 'chart.js';
+import { getSemanticColors } from '@chia-network/core';
+import { alpha, useTheme } from '@mui/material/styles';
+import { Chart as ChartJS, BarElement, CategoryScale, LinearScale, BarController, ChartOptions } from 'chart.js';
import * as React from 'react';
import { Bar } from 'react-chartjs-2';
@@ -58,8 +60,14 @@ export type BarChartProps = {
export const PureLatencyBarChart = React.memo(LatencyBarChart);
function LatencyBarChart(props: BarChartProps) {
const { latency, period, unit } = props;
-
- const options = React.useMemo(
+ const theme = useTheme();
+ const { palette } = theme;
+ const semanticColors = getSemanticColors(palette);
+ const primaryColor = semanticColors.success;
+ const warningColor = semanticColors.warning;
+ const errorColor = semanticColors.error;
+
+ const options = React.useMemo>(
() => ({
responsive: true,
animation: false,
@@ -69,16 +77,21 @@ function LatencyBarChart(props: BarChartProps) {
display: false,
},
y: {
+ grid: {
+ color: alpha(palette.text.primary, 0.16),
+ },
ticks: {
- callback(value: number) {
- const formattedValue = unit === 'ms' ? value : value / 1000;
+ color: palette.text.secondary,
+ callback(value: string | number) {
+ const numericValue = Number(value);
+ const formattedValue = unit === 'ms' ? numericValue : numericValue / 1000;
return `${formattedValue} ${unit}`;
},
},
},
},
}),
- [unit],
+ [palette.text.primary, palette.text.secondary, unit],
);
const data = React.useMemo(() => {
@@ -96,13 +109,13 @@ function LatencyBarChart(props: BarChartProps) {
records.push(valInMs);
if (valInMs < 8000) {
// Normal color
- backgroundColors.push('#ccdde1');
+ backgroundColors.push(primaryColor);
} else if (valInMs < 20_000) {
// Warning color
- backgroundColors.push('#ffd388');
+ backgroundColors.push(warningColor);
} else {
// Fatal color
- backgroundColors.push('#faa7b0');
+ backgroundColors.push(errorColor);
}
}
@@ -127,13 +140,13 @@ function LatencyBarChart(props: BarChartProps) {
records.push(valInMs);
if (valInMs < 8000) {
// Normal color
- backgroundColors.push('#ccdde1');
+ backgroundColors.push(primaryColor);
} else if (valInMs < 20_000) {
// Warning color
- backgroundColors.push('#ffd388');
+ backgroundColors.push(warningColor);
} else {
// Fatal color
- backgroundColors.push('#faa7b0');
+ backgroundColors.push(errorColor);
}
}
}
@@ -149,7 +162,7 @@ function LatencyBarChart(props: BarChartProps) {
},
],
};
- }, [latency, period]);
+ }, [latency, period, primaryColor, warningColor, errorColor]);
return ;
}
diff --git a/packages/gui/src/components/harvest/PlotDetailsChart.tsx b/packages/gui/src/components/harvest/PlotDetailsChart.tsx
index b6d81796ab..f81fc5a7a1 100644
--- a/packages/gui/src/components/harvest/PlotDetailsChart.tsx
+++ b/packages/gui/src/components/harvest/PlotDetailsChart.tsx
@@ -4,28 +4,6 @@ import { Doughnut } from 'react-chartjs-2';
ChartJS.register(ArcElement, Tooltip);
-export const ColorCodesForCompressions: Record = {
- 0: '#5ECE71',
- 1: '#1EBF89',
- 2: '#1A8284',
- 3: '#094D4C',
- 4: '#FFFAE3',
- 5: '#E8FBBA',
- 6: '#D4FF72',
- 7: '#95B0B7',
- 8: '#CCDDE1',
- 9: '#E2EDF0',
-};
-
-export const ColorCodesForKSizes: Record = {
- 25: '#E2EDF0',
- 31: '#95B0B7',
- 32: '#7676A9',
- 33: '#C3C3EE',
- 34: '#BCEFF2',
- 35: '#474765',
-};
-
export type DoughnutChartData = { data: number[]; colors: string[]; labels: string[] };
const donutOptions: ChartOptions<'doughnut'> = {
diff --git a/packages/gui/src/components/nfts/NFTHashStatus.tsx b/packages/gui/src/components/nfts/NFTHashStatus.tsx
index dd980e2624..3d1800331e 100644
--- a/packages/gui/src/components/nfts/NFTHashStatus.tsx
+++ b/packages/gui/src/components/nfts/NFTHashStatus.tsx
@@ -122,7 +122,25 @@ export default function NFTHashStatus(props: NFTHashStatusProps) {
return null;
}
- const chip = ;
+ const chip = (
+
+ );
if (tooltipContent) {
return {tooltipContent}}>{chip};
diff --git a/packages/gui/src/components/nfts/NFTPreview.tsx b/packages/gui/src/components/nfts/NFTPreview.tsx
index ccb8b805c2..c4b1c75ebe 100644
--- a/packages/gui/src/components/nfts/NFTPreview.tsx
+++ b/packages/gui/src/components/nfts/NFTPreview.tsx
@@ -1,25 +1,28 @@
-import { Color, IconMessage, Loading, Flex, SandboxedIframe, usePersistState, useDarkMode } from '@chia-network/core';
+import {
+ IconMessage,
+ Loading,
+ Flex,
+ SandboxedIframe,
+ usePersistState,
+ useDarkMode,
+ useThemeAssets,
+} from '@chia-network/core';
import { t, Trans } from '@lingui/macro';
import { NotInterested } from '@mui/icons-material';
import { alpha, Box } from '@mui/material';
import React, { useMemo, useRef, Fragment, useCallback, useEffect, type ReactNode } from 'react';
import styled from 'styled-components';
-import AudioSmallIcon from '../../assets/img/audio-small.svg';
import DocumentBlobIcon from '../../assets/img/document-blob.svg';
-import DocumentSmallIcon from '../../assets/img/document-small.svg';
import DocumentPngIcon from '../../assets/img/document.png';
import DocumentPngDarkIcon from '../../assets/img/document_dark.png';
import ModelBlobIcon from '../../assets/img/model-blob.svg';
-import ModelSmallIcon from '../../assets/img/model-small.svg';
import ModelPngIcon from '../../assets/img/model.png';
import ModelPngDarkIcon from '../../assets/img/model_dark.png';
import UnknownBlobIcon from '../../assets/img/unknown-blob.svg';
-import UnknownSmallIcon from '../../assets/img/unknown-small.svg';
import UnknownPngIcon from '../../assets/img/unknown.png';
import UnknownPngDarkIcon from '../../assets/img/unknown_dark.png';
import VideoBlobIcon from '../../assets/img/video-blob.svg';
-import VideoSmallIcon from '../../assets/img/video-small.svg';
import VideoPngIcon from '../../assets/img/video.png';
import VideoPngDarkIcon from '../../assets/img/video_dark.png';
import FileType from '../../constants/FileType';
@@ -54,22 +57,22 @@ const IframePreventEvents = styled.div`
z-index: 2;
`;
-const ModelExtension = styled.div<{ isDarkMode: boolean }>`
+const ModelExtension = styled.div`
position: relative;
top: -20px;
display: flex;
justify-content: center;
align-items: center;
padding: 8px 16px;
- background: ${(props) => (props.isDarkMode ? Color.Neutral[800] : Color.Neutral[50])};
+ background: ${({ theme }) => theme.palette.background.paper};
box-shadow:
- 0px 0px 24px ${alpha(Color.Green[500], 0.5)},
- 0px 4px 8px ${alpha(Color.Green[700], 0.32)};
+ 0px 0px 24px ${({ theme }) => alpha(theme.palette.primary.main, 0.5)},
+ 0px 4px 8px ${({ theme }) => alpha(theme.palette.text.primary, 0.22)};
border-radius: 32px;
- color: ${(props) => (props.isDarkMode ? Color.Neutral[50] : Color.Neutral[800])};
+ color: ${({ theme }) => theme.palette.text.primary};
`;
-const BlobBg = styled.div<{ isDarkMode: boolean }>`
+const BlobBg = styled.div`
> svg {
position: absolute;
left: 0;
@@ -79,10 +82,10 @@ const BlobBg = styled.div<{ isDarkMode: boolean }>`
margin: auto;
linearGradient {
>stop: first-child {
- stop-color: ${(props) => (props.isDarkMode ? Color.Green[800] : Color.Lime[100])};
+ stop-color: ${({ theme }) => alpha(theme.palette.primary.main, theme.palette.mode === 'dark' ? 0.32 : 0.18)};
}
>stop: last-child {
- stop-color: ${(props) => (props.isDarkMode ? Color.Green[300] : Color.Green[400])};
+ stop-color: ${({ theme }) => theme.palette.primary.main};
}
}
}
@@ -91,19 +94,13 @@ const BlobBg = styled.div<{ isDarkMode: boolean }>`
}
`;
-const CompactVideoIcon = styled(VideoSmallIcon)``;
-const CompactAudioIcon = styled(AudioSmallIcon)``;
-const CompactUnknownIcon = styled(UnknownSmallIcon)``;
-const CompactDocumentIcon = styled(DocumentSmallIcon)``;
-const CompactModelIcon = styled(ModelSmallIcon)``;
-
const CompactExtension = styled.div`
position: absolute;
top: 48px;
left: 0;
right: 4px;
text-align: center;
- color: ${Color.Green[500]};
+ color: ${({ theme }) => theme.palette.primary.main};
`;
export type NFTPreviewProps = {
@@ -140,6 +137,7 @@ export default function NFTPreview(props: NFTPreviewProps) {
const nftId = useMemo(() => getNFTId(id), [id]);
const iframeRef = useRef(null);
const { isDarkMode } = useDarkMode();
+ const { audioSmall, documentSmall, modelSmall, unknownSmall, videoSmall } = useThemeAssets();
const [, setError] = useStateAbort(undefined);
const [previewContent, setPreviewContent] = useStateAbort(undefined);
const abortControllerRef = useRef(new AbortController());
@@ -249,28 +247,28 @@ export default function NFTPreview(props: NFTPreviewProps) {
const previewCompactIcon = useMemo(() => {
switch (previewFileType) {
case FileType.VIDEO:
- return ;
+ return React.createElement(videoSmall, { width: '100%' });
case FileType.AUDIO:
- return ;
+ return React.createElement(audioSmall, { width: '100%' });
case FileType.MODEL:
- return ;
+ return React.createElement(modelSmall, { width: '100%' });
case FileType.DOCUMENT:
- return ;
+ return React.createElement(documentSmall, { width: '100%' });
default: {
if (previewExtension) {
return .{previewExtension};
}
- return ;
+ return React.createElement(unknownSmall, { width: '100%' });
}
}
- }, [previewFileType, previewExtension]);
+ }, [previewFileType, previewExtension, audioSmall, documentSmall, modelSmall, unknownSmall, videoSmall]);
const previewIcon = useMemo(() => {
switch (previewFileType) {
case FileType.DOCUMENT:
return (
-
+
@@ -286,21 +284,21 @@ export default function NFTPreview(props: NFTPreviewProps) {
*/
case FileType.VIDEO:
return (
-
+
);
case FileType.MODEL:
return (
-
+
);
default:
return (
-
+
@@ -323,7 +321,7 @@ export default function NFTPreview(props: NFTPreviewProps) {
return (
<>
{previewIcon}
- {previewExtension && .{previewExtension}}
+ {previewExtension && .{previewExtension}}
>
);
}
@@ -372,7 +370,6 @@ export default function NFTPreview(props: NFTPreviewProps) {
previewExtension,
previewContent,
iframeRef,
- isDarkMode,
blurPreview,
previewCompactIcon,
]);
diff --git a/packages/gui/src/components/nfts/NFTPreviewDialog.tsx b/packages/gui/src/components/nfts/NFTPreviewDialog.tsx
index 7f5b9b168f..8cdf9dc102 100644
--- a/packages/gui/src/components/nfts/NFTPreviewDialog.tsx
+++ b/packages/gui/src/components/nfts/NFTPreviewDialog.tsx
@@ -37,7 +37,7 @@ export default function NFTPreviewDialog(props: NFTPreviewDialogProps) {
)}
{...rest}
>
-
+
);
}
diff --git a/packages/gui/src/components/nfts/NFTProgressBar.tsx b/packages/gui/src/components/nfts/NFTProgressBar.tsx
index 7f02e4cc92..b96a62d4a3 100644
--- a/packages/gui/src/components/nfts/NFTProgressBar.tsx
+++ b/packages/gui/src/components/nfts/NFTProgressBar.tsx
@@ -1,17 +1,17 @@
-import { Color } from '@chia-network/core';
import { Box } from '@mui/material';
+import { alpha } from '@mui/material/styles';
import React from 'react';
import styled from 'styled-components';
const ProgressBar = styled.div`
width: 100%;
height: 12px;
- border: 1px solid ${Color.Neutral[400]};
+ border: 1px solid ${({ theme }) => alpha(theme.palette.text.primary, 0.24)};
border-radius: 3px;
margin-top: 30px !important;
margin-left: 0 !important;
> div {
- background: ${Color.Green[200]};
+ background: ${({ theme }) => theme.palette.primary.main};
height: 10px;
border-radius: 2px;
}
diff --git a/packages/gui/src/components/nfts/detail/NFTDetailV2.tsx b/packages/gui/src/components/nfts/detail/NFTDetailV2.tsx
index 0917d4a1f1..5d96f14874 100644
--- a/packages/gui/src/components/nfts/detail/NFTDetailV2.tsx
+++ b/packages/gui/src/components/nfts/detail/NFTDetailV2.tsx
@@ -15,7 +15,6 @@ import useFilteredNFTs from '../../../hooks/useFilteredNFTs';
import useNFT from '../../../hooks/useNFT';
import useNFTMetadata from '../../../hooks/useNFTMetadata';
import getNFTId from '../../../util/getNFTId';
-import { isImage } from '../../../util/utils';
import OfferIncomingTable from '../../offers2/OfferIncomingTable';
import NFTContextualActions, { NFTContextualActionTypes } from '../NFTContextualActions';
import NFTDetails from '../NFTDetails';
@@ -80,8 +79,7 @@ function NFTDetailLoaded(props: NFTDetailLoadedProps) {
}, [navigateToDetail]);
function handleShowFullScreen() {
- const uri = nft?.dataUris?.[0];
- if (isImage(uri)) {
+ if (nft) {
openDialog();
}
}
diff --git a/packages/gui/src/components/offers2/OfferBuilderImport.tsx b/packages/gui/src/components/offers2/OfferBuilderImport.tsx
index b3bf83a6ce..9cb3f6f2e1 100644
--- a/packages/gui/src/components/offers2/OfferBuilderImport.tsx
+++ b/packages/gui/src/components/offers2/OfferBuilderImport.tsx
@@ -1,12 +1,11 @@
import { useGetOfferSummaryMutation } from '@chia-network/api-react';
-import { Color, Dropzone, Flex, useSerializedNavigationState, useShowError } from '@chia-network/core';
+import { Dropzone, Flex, useSerializedNavigationState, useShowError, useThemeAssets } from '@chia-network/core';
import { Trans, t } from '@lingui/macro';
import { Box, Card, Typography } from '@mui/material';
+import { alpha, useTheme } from '@mui/material/styles';
import React from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
-import OfferFileIcon from './images/offerFileIcon.svg';
-
function Background(props: { children: React.ReactNode }) {
const { children } = props;
return (
@@ -17,6 +16,8 @@ function Background(props: { children: React.ReactNode }) {
}
export default function OfferBuilderImport() {
+ const { offerFileIcon: OfferFileIcon } = useThemeAssets();
+ const theme = useTheme();
const { navigate } = useSerializedNavigationState();
const [getOfferSummary] = useGetOfferSummaryMutation();
// const openDialog = useOpenDialog();
@@ -119,6 +120,15 @@ export default function OfferBuilderImport() {
justifyContent: 'center',
alignItems: 'center',
cursor: 'pointer',
+ borderColor: alpha(theme.palette.primary.main, 0.68),
+ backgroundColor: alpha(theme.palette.background.paper, 0.78),
+ '&:hover': {
+ borderColor: theme.palette.primary.main,
+ boxShadow: `0 0 0 1px ${alpha(theme.palette.primary.main, 0.22)}, 0 18px 48px ${alpha(
+ theme.palette.text.primary,
+ 0.1,
+ )}`,
+ },
}}
>
@@ -130,7 +140,7 @@ export default function OfferBuilderImport() {
- or browse on your computer
+ or browse on your computer
diff --git a/packages/gui/src/components/plotNFT/PlotNFTGraph.tsx b/packages/gui/src/components/plotNFT/PlotNFTGraph.tsx
index 02db7f6b1e..13cf8b9f6b 100644
--- a/packages/gui/src/components/plotNFT/PlotNFTGraph.tsx
+++ b/packages/gui/src/components/plotNFT/PlotNFTGraph.tsx
@@ -1,7 +1,7 @@
-import { Color, Flex } from '@chia-network/core';
+import { Flex } from '@chia-network/core';
import { WalletGraphTooltip } from '@chia-network/wallets';
import { t } from '@lingui/macro';
-import { alpha, Box, Typography } from '@mui/material';
+import { alpha, Box, Typography, useTheme } from '@mui/material';
import React, { ReactNode } from 'react';
import { useMeasure } from 'react-use';
import { VictoryChart, VictoryAxis, VictoryArea, VictoryTooltip, VictoryVoronoiContainer } from 'victory';
@@ -38,11 +38,11 @@ function aggregatePoints(points, hours = 2, totalHours = 24) {
return items;
}
-function LinearGradient() {
+function LinearGradient({ graphColor }: { graphColor: string }) {
return (
-
-
+
+
);
}
@@ -54,6 +54,8 @@ export type PlotNFTGraphProps = {
export default function PlotNFTGraph(props: PlotNFTGraphProps) {
const { points, title } = props;
+ const theme = useTheme();
+ const graphColor = theme.palette.primary.main;
const aggregated = aggregatePoints(points, 2);
const [ref, containerSize] = useMeasure();
@@ -91,7 +93,7 @@ export default function PlotNFTGraph(props: PlotNFTGraphProps) {
interpolation="monotoneX"
style={{
data: {
- stroke: Color.Green[500],
+ stroke: graphColor,
strokeWidth: 2,
strokeLinecap: 'round',
fill: 'url(#graph-gradient)',
@@ -107,7 +109,7 @@ export default function PlotNFTGraph(props: PlotNFTGraphProps) {
tickLabels: { fill: 'transparent' },
}}
/>
-
+
diff --git a/packages/gui/src/components/signVerify/VerifyMessageImport.tsx b/packages/gui/src/components/signVerify/VerifyMessageImport.tsx
index 213c9cb8ff..fc7bbffd53 100644
--- a/packages/gui/src/components/signVerify/VerifyMessageImport.tsx
+++ b/packages/gui/src/components/signVerify/VerifyMessageImport.tsx
@@ -1,6 +1,7 @@
-import { Color, Dropzone, Flex, useShowError } from '@chia-network/core';
+import { Dropzone, Flex, useShowError } from '@chia-network/core';
import { Trans, t } from '@lingui/macro';
import { Box, Card, Typography } from '@mui/material';
+import { useTheme } from '@mui/material/styles';
import React, { useState } from 'react';
import { FileWithPath } from 'react-dropzone';
@@ -69,6 +70,7 @@ export type VerifyMessageImportProps = {
export default function VerifyMessageImport(props: VerifyMessageImportProps) {
const { onImport } = props;
+ const theme = useTheme();
const [isParsing, setIsParsing] = useState(false);
const showError = useShowError();
const prompt = (
@@ -134,7 +136,7 @@ export default function VerifyMessageImport(props: VerifyMessageImportProps) {
- or browse on your computer
+ or browse on your computer
diff --git a/packages/gui/src/components/vcs/VCList.tsx b/packages/gui/src/components/vcs/VCList.tsx
index ce39c83d3f..44b43f2a88 100644
--- a/packages/gui/src/components/vcs/VCList.tsx
+++ b/packages/gui/src/components/vcs/VCList.tsx
@@ -25,12 +25,11 @@ import {
} from '@chia-network/icons';
import { Trans } from '@lingui/macro';
import { Box, Typography } from '@mui/material';
-import { useTheme } from '@mui/material/styles';
+import { alpha, useTheme } from '@mui/material/styles';
import { styled } from '@mui/styles';
import React, { useCallback, useRef } from 'react';
import { VirtuosoGrid } from 'react-virtuoso';
-import VCEmptyPng from '../../assets/img/vc_empty.png';
import { sha256, arrToHex } from '../../util/utils';
import VCCard from './VCCard';
@@ -129,6 +128,53 @@ export default function VCList() {
}, [isLoading, blockchainVCs, getProofsForRoot]);
const theme = useTheme();
+ const palette = theme.palette as typeof theme.palette & {
+ border: { main: string; dark: string };
+ };
+
+ const zeroStateColors = React.useMemo(() => {
+ const accent = palette.primary.main;
+ const accentDark = palette.primary.dark;
+ const softAccent = alpha(accent, isDarkMode ? 0.3 : 0.18);
+ const surface = palette.background.paper;
+ const surfaceAlt = isDarkMode ? palette.background.default : palette.background.card;
+ const text = palette.text.primary;
+ const mutedText = palette.text.secondary;
+ const line = isDarkMode ? palette.border.dark : palette.border.main;
+
+ return {
+ accent,
+ accentDark,
+ badgeBackground: alpha(surface, isDarkMode ? 0.18 : 0.72),
+ badgeBorder: line,
+ badgeText: mutedText,
+ cardBorder: alpha(accent, 0.72),
+ cardText: text,
+ cardMutedText: mutedText,
+ cardBackground: `linear-gradient(135deg, ${alpha(surface, 0.98)} 0%, ${softAccent} 52%, ${alpha(
+ surfaceAlt,
+ 0.98,
+ )} 100%)`,
+ cardShadow: `0 34px 72px ${alpha(palette.text.primary, isDarkMode ? 0.32 : 0.16)}`,
+ chipBackground: `linear-gradient(145deg, ${alpha(surfaceAlt, 0.92)} 0%, ${alpha(accent, 0.28)} 100%)`,
+ divider: alpha(accent, 0.24),
+ iconColor: palette.info.main,
+ sealBackground: `radial-gradient(circle, ${alpha(surface, 0.98)} 0%, ${accent} 52%, ${accentDark} 100%)`,
+ texture: `repeating-linear-gradient(115deg, ${alpha(accent, 0.2)} 0 1px, transparent 1px 9px)`,
+ };
+ }, [
+ isDarkMode,
+ palette.background.card,
+ palette.background.default,
+ palette.background.paper,
+ palette.border.dark,
+ palette.border.main,
+ palette.info.main,
+ palette.primary.dark,
+ palette.primary.main,
+ palette.text.primary,
+ palette.text.secondary,
+ ]);
function onVCTimestamp(id: string, timestamp: number) {
trackVCTimestamps.current[id] = timestamp;
@@ -239,12 +285,12 @@ export default function VCList() {
display: 'inline-flex',
padding: '5px 10px',
borderRadius: '45px',
- border: `2px solid ${isDarkMode ? theme.palette.colors.default.accent : theme.palette.colors.default.border}`,
+ border: `2px solid ${zeroStateColors.badgeBorder}`,
textAlign: 'center',
- background: theme.palette.colors.default.backgroundLight,
+ background: zeroStateColors.badgeBackground,
}}
>
-
+
{titleNode}
@@ -273,7 +319,7 @@ export default function VCList() {
-
+
{renderBadgeContainer(Badging)}
@@ -281,15 +327,79 @@ export default function VCList() {
-
+
+
+
+ VERIFIABLE CREDENTIAL
+
+
+ Credential ID
+
+
+
+
+ Issued
+
+ 02-22-2023
+
+ Holder
+
+ Bram Tiberius Cohen
+
+
+
{renderBadgeContainer(Government IDs)}
diff --git a/packages/gui/src/components/walletConnect/WalletConnectAddConnectionDialog.tsx b/packages/gui/src/components/walletConnect/WalletConnectAddConnectionDialog.tsx
index 1817f009f9..530ec33ce3 100644
--- a/packages/gui/src/components/walletConnect/WalletConnectAddConnectionDialog.tsx
+++ b/packages/gui/src/components/walletConnect/WalletConnectAddConnectionDialog.tsx
@@ -1,4 +1,4 @@
-import { ButtonLoading, DialogActions, Flex, TextField, Button, Form } from '@chia-network/core';
+import { ButtonLoading, DialogActions, Flex, TextField, Button, Form, useThemeAssets } from '@chia-network/core';
import { Trans, t } from '@lingui/macro';
import CloseIcon from '@mui/icons-material/Close';
import { Box, Divider, Dialog, DialogContent, DialogTitle, IconButton, Typography } from '@mui/material';
@@ -7,8 +7,6 @@ import { useForm } from 'react-hook-form';
import useWalletConnect from '../../hooks/useWalletConnect';
-import HeroImage from './images/walletConnectToChia.svg';
-
type FormData = {
uri: string;
};
@@ -20,6 +18,7 @@ export type WalletConnectAddConnectionDialogProps = {
export default function WalletConnectAddConnectionDialog(props: WalletConnectAddConnectionDialogProps) {
const { onClose = () => {}, open = false } = props;
+ const { walletConnectToChia: HeroImage } = useThemeAssets();
const { pair } = useWalletConnect();
const pairAbortControllerRef = React.useRef(undefined);
const methods = useForm({
diff --git a/packages/gui/src/electron/constants/AllowedCommands.ts b/packages/gui/src/electron/constants/AllowedCommands.ts
new file mode 100644
index 0000000000..47d5e3fb63
--- /dev/null
+++ b/packages/gui/src/electron/constants/AllowedCommands.ts
@@ -0,0 +1,136 @@
+export default [
+ 'daemon.register_service',
+ 'daemon.keyring_status',
+ 'daemon.get_version',
+ 'daemon.start_service',
+ 'daemon.stop_service',
+ 'daemon.set_label',
+ 'daemon.delete_label',
+ 'daemon.is_running',
+ 'daemon.get_keys',
+ 'daemon.get_key',
+ 'daemon.get_public_key',
+ 'daemon.get_wallet_addresses',
+ 'daemon.running_services',
+ 'daemon.get_plotters',
+ 'daemon.get_keys_for_plotting',
+ 'daemon.exit',
+ 'daemon.add_private_key',
+ 'daemon.unlock_keyring',
+ 'daemon.start_plotting',
+
+ 'chia_wallet.ping',
+ 'chia_full_node.ping',
+ 'chia_farmer.ping',
+ 'chia_harvester.ping',
+ 'chia_data_layer.ping',
+
+ 'chia_wallet.get_offer_summary',
+ 'chia_wallet.get_network_info',
+ 'chia_wallet.get_logged_in_fingerprint',
+ 'chia_wallet.get_notifications',
+ 'chia_wallet.get_sync_status',
+ 'chia_wallet.get_wallets',
+ 'chia_wallet.get_coin_records_by_names',
+ 'chia_wallet.select_coins',
+ 'chia_wallet.get_spendable_coins',
+ 'chia_wallet.nft_get_wallet_did',
+ 'chia_wallet.cat_get_asset_id',
+ 'chia_wallet.cat_get_name',
+ 'chia_wallet.cat_asset_id_to_name',
+ 'chia_wallet.get_next_address',
+ 'chia_wallet.get_cat_list',
+ 'chia_wallet.get_stray_cats',
+ 'chia_wallet.get_transaction_count',
+ 'chia_wallet.get_transactions',
+ 'chia_wallet.get_transaction',
+ 'chia_wallet.get_wallet_balance',
+ 'chia_wallet.get_wallet_balances',
+ 'chia_wallet.get_current_derivation_index',
+ 'chia_wallet.get_auto_claim',
+ 'chia_wallet.nft_count_nfts',
+ 'chia_wallet.nft_get_nfts',
+ 'chia_wallet.nft_get_wallets_with_dids',
+ 'chia_wallet.vc_get_list',
+ 'chia_wallet.get_timestamp_for_height',
+ 'chia_wallet.vc_get',
+ 'chia_wallet.get_offers_count',
+ 'chia_wallet.get_all_offers',
+ 'chia_wallet.get_height_info',
+ 'chia_wallet.get_puzzle_and_solution',
+ 'chia_wallet.get_connections',
+ 'chia_wallet.log_in',
+ 'chia_wallet.generate_mnemonic',
+ 'chia_wallet.check_delete_key',
+ 'chia_wallet.cat_set_name',
+ 'chia_wallet.verify_signature',
+ 'chia_wallet.set_wallet_resync_on_startup',
+ 'chia_wallet.delete_unconfirmed_transactions',
+ 'chia_wallet.extend_derivation_index',
+ 'chia_wallet.nft_get_info',
+ 'chia_wallet.get_farmed_amount',
+
+ 'chia_wallet.get_offer',
+ 'chia_wallet.nft_calculate_royalties',
+ 'chia_wallet.check_offer_validity',
+ 'chia_wallet.get_transaction_memo',
+ 'chia_wallet.pw_status',
+
+ 'chia_wallet.did_get_did',
+ 'chia_wallet.did_get_wallet_name',
+ 'chia_wallet.did_set_wallet_name',
+ 'chia_wallet.did_get_info',
+ 'chia_wallet.did_find_lost',
+ 'chia_wallet.did_get_current_coin_info',
+ 'chia_wallet.did_get_information_needed_for_recovery',
+ 'chia_wallet.did_get_metadata',
+ 'chia_wallet.did_get_pubkey',
+ 'chia_wallet.did_get_recovery_list',
+
+ 'chia_wallet.vc_add_proofs',
+ 'chia_wallet.vc_get_proofs_for_root',
+
+ 'chia_full_node.get_unfinished_block_headers',
+ 'chia_full_node.get_connections',
+ 'chia_full_node.get_block_records',
+ 'chia_full_node.get_block',
+ 'chia_full_node.get_block_record',
+ 'chia_full_node.get_blockchain_state',
+ 'chia_full_node.get_fee_estimate',
+
+ 'chia_farmer.get_harvesters_summary',
+ 'chia_farmer.get_connections',
+ 'chia_farmer.get_signage_points',
+ 'chia_farmer.get_pool_state',
+ 'chia_farmer.get_harvesters',
+ 'chia_farmer.get_harvester_plots_duplicates',
+ 'chia_farmer.get_harvester_plots_invalid',
+ 'chia_farmer.get_harvester_plots_keys_missing',
+ 'chia_farmer.get_harvester_plots_valid',
+ 'chia_farmer.get_reward_targets',
+
+ 'chia_harvester.get_harvester_config',
+ 'chia_harvester.get_plot_directories',
+ 'chia_harvester.refresh_plots',
+
+ 'chia_data_layer.add_missing_files',
+ 'chia_data_layer.check_plugins',
+ 'chia_data_layer.clear_pending_roots',
+ 'chia_data_layer.get_ancestors',
+ 'chia_data_layer.get_keys',
+ 'chia_data_layer.get_keys_values',
+ 'chia_data_layer.get_kv_diff',
+ 'chia_data_layer.get_local_root',
+ 'chia_data_layer.get_mirrors',
+ 'chia_data_layer.get_owned_stores',
+ 'chia_data_layer.get_root',
+ 'chia_data_layer.get_roots',
+ 'chia_data_layer.get_root_history',
+ 'chia_data_layer.get_sync_status',
+ 'chia_data_layer.get_value',
+ 'chia_data_layer.verify_offer',
+ 'chia_data_layer.remove_subscriptions',
+ 'chia_data_layer.subscribe',
+ 'chia_data_layer.subscriptions',
+ 'chia_data_layer.unsubscribe',
+];
diff --git a/packages/gui/src/electron/constants/commandRegistry.test.ts b/packages/gui/src/electron/constants/commandRegistry.test.ts
new file mode 100644
index 0000000000..2c63f87cfc
--- /dev/null
+++ b/packages/gui/src/electron/constants/commandRegistry.test.ts
@@ -0,0 +1,645 @@
+// Registry tests — a regression here means a compromised renderer can
+// bypass the dispatch gate or hit a service it wasn't granted.
+import { WcError, WcErrorCode } from '../../@types/WcError';
+
+import {
+ SCHEMA_COMMANDS,
+ applyDefaults,
+ bareWcCommand,
+ commandsMetadata,
+ filterRequestedCommands,
+ getCommandByWc,
+ getCommandSchema,
+ isDappAllowedWcCommand,
+ resolveDispatch,
+ validateDappParams,
+} from './commandRegistry';
+
+function captureThrow(fn: () => unknown): unknown {
+ try {
+ fn();
+ } catch (e) {
+ return e;
+ }
+ throw new Error('expected throw');
+}
+
+describe('registry shape', () => {
+ it('every entry with a dapp.wcCommand is reachable via getCommandByWc', () => {
+ for (const ns of SCHEMA_COMMANDS) {
+ const schema = getCommandSchema(ns);
+ if (schema.dapp) {
+ const entry = getCommandByWc(schema.dapp.wcCommand);
+ expect(entry?.nsCommand).toBe(ns);
+ expect(entry?.schema).toBe(schema);
+ for (const alias of schema.dapp.aliases ?? []) {
+ const aliasEntry = getCommandByWc(alias.wcCommand);
+ expect(aliasEntry?.nsCommand).toBe(ns);
+ expect(aliasEntry?.schema).toBe(schema);
+ }
+ }
+ }
+ });
+
+ it('wcCommand values are unique across all entries (including aliases)', () => {
+ const seen = new Map();
+ const claim = (wcCommand: string, ns: string) => {
+ if (seen.has(wcCommand)) {
+ throw new Error(`duplicate wcCommand "${wcCommand}" on ${ns} and ${seen.get(wcCommand)}`);
+ }
+ seen.set(wcCommand, ns);
+ };
+ for (const ns of SCHEMA_COMMANDS) {
+ const schema = getCommandSchema(ns);
+ if (schema.dapp) {
+ claim(schema.dapp.wcCommand, ns);
+ for (const alias of schema.dapp.aliases ?? []) claim(alias.wcCommand, ns);
+ }
+ }
+ });
+
+ it('every wcCommand uses wire form (`chia_`)', () => {
+ for (const ns of SCHEMA_COMMANDS) {
+ const schema = getCommandSchema(ns);
+ if (schema.dapp) {
+ expect(schema.dapp.wcCommand.startsWith('chia_')).toBe(true);
+ expect(schema.dapp.wcCommand.length).toBeGreaterThan('chia_'.length);
+ for (const alias of schema.dapp.aliases ?? []) {
+ expect(alias.wcCommand.startsWith('chia_')).toBe(true);
+ expect(alias.wcCommand.length).toBeGreaterThan('chia_'.length);
+ }
+ }
+ }
+ });
+
+ it('every nsCommand is "service.command" with non-empty parts', () => {
+ for (const ns of SCHEMA_COMMANDS) {
+ const dot = ns.indexOf('.');
+ expect(dot).toBeGreaterThan(0);
+ expect(ns.slice(0, dot)).not.toBe('');
+ expect(ns.slice(dot + 1)).not.toBe('');
+ }
+ });
+
+ /** TODO: please re-enable when we add missing file WalletConnectCommands.tsx
+ it('handler-routed commands live under chia_app.* and declare a handlerKey', () => {
+ for (const ns of SCHEMA_COMMANDS) {
+ const schema = getCommandSchema(ns);
+ if (schema.dapp) {
+ if (ns.startsWith('chia_app.')) {
+ expect(schema.dapp.handlerKey).toBeDefined();
+ } else {
+ expect(schema.dapp.handlerKey).toBeUndefined();
+ }
+ }
+ }
+ });
+ */
+});
+
+describe('dappAllowed defaults', () => {
+ it('UI-only schemas (no dapp block) leave params unmarked', () => {
+ const schema = getCommandSchema('chia_wallet.create_new_wallet');
+ expect(schema.dapp).toBeUndefined();
+ for (const param of schema.params) {
+ expect(param.dappAllowed).toBeUndefined();
+ }
+ });
+
+ it('every dapp-callable schema has `dappAllowed: true` on every declared param', () => {
+ for (const ns of SCHEMA_COMMANDS) {
+ const schema = getCommandSchema(ns);
+ if (schema.dapp) {
+ for (const param of schema.params) {
+ if (param.dappAllowed !== true) {
+ throw new Error(
+ `Schema ${ns} declares dapp block but param "${param.name}" lacks \`dappAllowed: true\`. ` +
+ `Either drop the param from the schema or mark it explicitly.`,
+ );
+ }
+ }
+ }
+ }
+ });
+});
+
+describe('isDappAllowedWcCommand', () => {
+ it('returns true for handler-routed meta-commands', () => {
+ expect(isDappAllowedWcCommand('chia_requestPermissions')).toBe(true);
+ expect(isDappAllowedWcCommand('chia_showNotification')).toBe(true);
+ });
+
+ it('returns true for handler-routed wallet-flow commands', () => {
+ expect(isDappAllowedWcCommand('chia_addCATToken')).toBe(true);
+ expect(isDappAllowedWcCommand('chia_transferDID')).toBe(true);
+ expect(isDappAllowedWcCommand('chia_createNewDIDWallet')).toBe(true);
+ });
+
+ it('returns true for dispatchable wc commands (wire form)', () => {
+ expect(isDappAllowedWcCommand('chia_sendTransaction')).toBe(true);
+ expect(isDappAllowedWcCommand('chia_spendCAT')).toBe(true);
+ expect(isDappAllowedWcCommand('chia_createOfferForIds')).toBe(true);
+ expect(isDappAllowedWcCommand('chia_signMessageByAddress')).toBe(true);
+ expect(isDappAllowedWcCommand('chia_getWallets')).toBe(true);
+ expect(isDappAllowedWcCommand('chia_getNFTInfo')).toBe(true);
+ });
+
+ it('returns false for unknown / UI-only / wrong-form commands', () => {
+ expect(isDappAllowedWcCommand('chia_totallyMadeUp')).toBe(false);
+ expect(isDappAllowedWcCommand('')).toBe(false);
+ // Bare form (no `chia_` prefix) is not the registry shape.
+ expect(isDappAllowedWcCommand('sendTransaction')).toBe(false);
+ });
+});
+
+describe('resolveDispatch', () => {
+ it('resolves a known wcCommand to destination + bare command + nsCommand', () => {
+ expect(resolveDispatch('chia_sendTransaction')).toEqual({
+ destination: 'chia_wallet',
+ command: 'send_transaction',
+ nsCommand: 'chia_wallet.send_transaction',
+ });
+ });
+
+ it('handles acronym-bearing names that cannot be auto-derived', () => {
+ expect(resolveDispatch('chia_spendCAT')).toEqual({
+ destination: 'chia_wallet',
+ command: 'cat_spend',
+ nsCommand: 'chia_wallet.cat_spend',
+ });
+ expect(resolveDispatch('chia_getNFTInfo')).toEqual({
+ destination: 'chia_wallet',
+ command: 'nft_get_info',
+ nsCommand: 'chia_wallet.nft_get_info',
+ });
+ expect(resolveDispatch('chia_setDIDName')).toEqual({
+ destination: 'chia_wallet',
+ command: 'did_set_wallet_name',
+ nsCommand: 'chia_wallet.did_set_wallet_name',
+ });
+ });
+
+ it('routes DataLayer commands to chia_data_layer', () => {
+ expect(resolveDispatch('chia_createDataStore')).toMatchObject({
+ destination: 'chia_data_layer',
+ command: 'create_data_store',
+ });
+ expect(resolveDispatch('chia_cancelDataLayerOffer')).toMatchObject({
+ destination: 'chia_data_layer',
+ command: 'cancel_offer',
+ });
+ });
+
+ it('routes daemon-namespace commands correctly', () => {
+ expect(resolveDispatch('chia_getPublicKey')).toMatchObject({
+ destination: 'daemon',
+ command: 'get_public_key',
+ });
+ expect(resolveDispatch('chia_getWalletAddresses')).toMatchObject({
+ destination: 'daemon',
+ command: 'get_wallet_addresses',
+ });
+ });
+
+ it('throws WcError(METHOD_NOT_FOUND) for unknown wc commands', () => {
+ const e = captureThrow(() => resolveDispatch('chia_totallyMadeUp'));
+ expect(e).toBeInstanceOf(WcError);
+ expect((e as WcError).code).toBe(WcErrorCode.METHOD_NOT_FOUND);
+ expect((e as WcError).message).toBe('unknown wc command: chia_totallyMadeUp');
+ });
+
+ it('throws for handler-routed meta-commands routed through daemon dispatch', () => {
+ // Callers must intercept via `entry.handlerKey` before resolveDispatch.
+ for (const wc of [
+ 'chia_requestPermissions',
+ 'chia_showNotification',
+ 'chia_addCATToken',
+ 'chia_transferDID',
+ 'chia_createNewDIDWallet',
+ ]) {
+ const e = captureThrow(() => resolveDispatch(wc));
+ expect(e).toBeInstanceOf(WcError);
+ expect((e as WcError).code).toBe(WcErrorCode.METHOD_NOT_FOUND);
+ expect((e as WcError).message).toBe(`wc command not dispatchable: ${wc}`);
+ }
+ });
+
+ it('routes the legacy `chia_getCurrentAddress` alias to `get_next_address`', () => {
+ // No daemon `get_current_address` RPC — alias lands on `get_next_address`.
+ expect(resolveDispatch('chia_getCurrentAddress')).toEqual({
+ destination: 'chia_wallet',
+ command: 'get_next_address',
+ nsCommand: 'chia_wallet.get_next_address',
+ });
+ });
+});
+
+describe('getCommandByWc', () => {
+ it('returns ns + schema for a known wcCommand', () => {
+ const result = getCommandByWc('chia_sendTransaction');
+ expect(result?.nsCommand).toBe('chia_wallet.send_transaction');
+ expect(result?.schema.dapp?.wcCommand).toBe('chia_sendTransaction');
+ });
+
+ it('returns ns + schema for a handler-routed meta-command', () => {
+ const result = getCommandByWc('chia_requestPermissions');
+ expect(result?.nsCommand).toBe('chia_app.request_permissions');
+ expect(result?.handlerKey).toBe('requestPermissions');
+ });
+
+ it('returns ns + schema + handlerKey for handler-routed wallet flows', () => {
+ const addCat = getCommandByWc('chia_addCATToken');
+ expect(addCat?.nsCommand).toBe('chia_app.add_cat_token');
+ expect(addCat?.handlerKey).toBe('addCATToken');
+
+ const transferDid = getCommandByWc('chia_transferDID');
+ expect(transferDid?.nsCommand).toBe('chia_app.transfer_did');
+ expect(transferDid?.handlerKey).toBe('transferDID');
+
+ const createDid = getCommandByWc('chia_createNewDIDWallet');
+ expect(createDid?.nsCommand).toBe('chia_app.create_new_did_wallet');
+ expect(createDid?.handlerKey).toBe('createNewDIDWallet');
+ });
+
+ it('returns the parent ns + schema for an alias wcCommand', () => {
+ // Alias dispatch must land on the real `get_next_address` RPC; without
+ // this the daemon would reject with "unknown_command get_current_address".
+ const result = getCommandByWc('chia_getCurrentAddress');
+ expect(result?.nsCommand).toBe('chia_wallet.get_next_address');
+ expect(result?.defaults).toEqual({ wallet_id: 1, new_address: false });
+ });
+
+ it('daemon-routed commands carry no handlerKey', () => {
+ const result = getCommandByWc('chia_sendTransaction');
+ expect(result?.handlerKey).toBeUndefined();
+ });
+
+ it('returns undefined for unknown commands', () => {
+ expect(getCommandByWc('chia_definitelyNotReal')).toBeUndefined();
+ });
+});
+
+describe('validateDappParams', () => {
+ function expectThrow(fn: () => unknown, code: number, message: string) {
+ const e = captureThrow(fn);
+ expect(e).toBeInstanceOf(WcError);
+ expect((e as WcError).code).toBe(code);
+ expect((e as WcError).message).toBe(message);
+ }
+
+ it('returns silently for a payload whose every key is declared with dappAllowed:true', () => {
+ expect(() =>
+ validateDappParams('chia_sendTransaction', { amount: '5', fee: '0', address: 'txch1abc' }),
+ ).not.toThrow();
+ });
+
+ it('returns silently for an empty payload', () => {
+ expect(() => validateDappParams('chia_sendTransaction', {})).not.toThrow();
+ });
+
+ it('throws WcError(INVALID_PARAMS) for a key not declared in the schema', () => {
+ expectThrow(
+ () => validateDappParams('chia_sendTransaction', { amount: '5', evil_extra: true }),
+ WcErrorCode.INVALID_PARAMS,
+ 'param not allowed for dapp: evil_extra',
+ );
+ });
+
+ it('throws WcError(METHOD_NOT_FOUND) for an unknown wc command', () => {
+ expectThrow(
+ () => validateDappParams('chia_totallyMadeUp', { x: 1 }),
+ WcErrorCode.METHOD_NOT_FOUND,
+ 'unknown wc command: chia_totallyMadeUp',
+ );
+ });
+
+ it('throws when the schema has no params and the dapp sends one', () => {
+ expectThrow(
+ () => validateDappParams('chia_getOffersCount', { sneaky: 1 }),
+ WcErrorCode.INVALID_PARAMS,
+ 'param not allowed for dapp: sneaky',
+ );
+ });
+
+ it('runs against snake-cased keys (caller is responsible for canonicalisation)', () => {
+ expectThrow(
+ () => validateDappParams('chia_sendTransaction', { walletId: 1 }),
+ WcErrorCode.INVALID_PARAMS,
+ 'param not allowed for dapp: walletId',
+ );
+ expect(() => validateDappParams('chia_sendTransaction', { wallet_id: 1 })).not.toThrow();
+ });
+
+ it('handler-routed commands enforce the same allowlist', () => {
+ expect(() => validateDappParams('chia_addCATToken', { asset_id: 'abc', name: 'My CAT' })).not.toThrow();
+ expectThrow(
+ () => validateDappParams('chia_addCATToken', { asset_id: 'abc', stowaway: true }),
+ WcErrorCode.INVALID_PARAMS,
+ 'param not allowed for dapp: stowaway',
+ );
+ });
+
+ it('aliases inherit the base schema params (chia_getCurrentAddress)', () => {
+ expect(() => validateDappParams('chia_getCurrentAddress', { wallet_id: 1, new_address: false })).not.toThrow();
+ expectThrow(
+ () => validateDappParams('chia_getCurrentAddress', { surprise: true }),
+ WcErrorCode.INVALID_PARAMS,
+ 'param not allowed for dapp: surprise',
+ );
+ });
+});
+
+describe('filterRequestedCommands', () => {
+ it('returns empty lists for an empty input', () => {
+ expect(filterRequestedCommands([])).toEqual({ allowed: [], rejected: [] });
+ });
+
+ it('partitions wire-form names by registry membership (no slicing)', () => {
+ const result = filterRequestedCommands(['chia_sendTransaction', 'chia_getWallets', 'chia_totallyMadeUp']);
+ expect(result.allowed.sort()).toEqual(['chia_getWallets', 'chia_sendTransaction']);
+ expect(result.rejected).toEqual(['chia_totallyMadeUp']);
+ });
+
+ it('rejects bare-form names (forces wire-form discipline)', () => {
+ const result = filterRequestedCommands(['sendTransaction']);
+ expect(result.allowed).toEqual([]);
+ expect(result.rejected).toEqual(['sendTransaction']);
+ });
+
+ it('drops methods outside the chia_ namespace into rejected (still string match)', () => {
+ const result = filterRequestedCommands(['eip155_personal_sign', 'cosmos_signDirect', 'chia_sendTransaction']);
+ expect(result.allowed).toEqual(['chia_sendTransaction']);
+ expect(result.rejected.sort()).toEqual(['cosmos_signDirect', 'eip155_personal_sign']);
+ });
+
+ it('deduplicates repeated method names', () => {
+ const result = filterRequestedCommands([
+ 'chia_sendTransaction',
+ 'chia_sendTransaction',
+ 'chia_madeUp',
+ 'chia_madeUp',
+ ]);
+ expect(result.allowed).toEqual(['chia_sendTransaction']);
+ expect(result.rejected).toEqual(['chia_madeUp']);
+ });
+
+ it('ignores non-string entries', () => {
+ const result = filterRequestedCommands(['chia_sendTransaction', null, 42] as unknown);
+ expect(result.allowed).toEqual(['chia_sendTransaction']);
+ expect(result.rejected).toEqual([]);
+ });
+
+ it('drops empty-string entries', () => {
+ expect(filterRequestedCommands([''])).toEqual({ allowed: [], rejected: [] });
+ });
+
+ it.each([
+ ['undefined', undefined],
+ ['null', null],
+ ['object literal', { 0: 'chia_sendTransaction' }],
+ ['number', 42],
+ ['string', 'chia_sendTransaction'],
+ ])('returns empty lists when requestedCommands is %s (defensive against IPC garbage)', (_label, val) => {
+ expect(filterRequestedCommands(val as unknown)).toEqual({ allowed: [], rejected: [] });
+ });
+
+ it('keeps handler-routed meta-commands so the WC SDK accepts them at session approval', () => {
+ const result = filterRequestedCommands(['chia_requestPermissions', 'chia_showNotification']);
+ expect(result.allowed.sort()).toEqual(['chia_requestPermissions', 'chia_showNotification']);
+ expect(result.rejected).toEqual([]);
+ });
+
+ it('keeps handler-routed wallet-flow commands (createNewDIDWallet, transferDID, addCATToken)', () => {
+ const result = filterRequestedCommands(['chia_createNewDIDWallet', 'chia_transferDID', 'chia_addCATToken']);
+ expect(result.allowed.sort()).toEqual(['chia_addCATToken', 'chia_createNewDIDWallet', 'chia_transferDID']);
+ expect(result.rejected).toEqual([]);
+ });
+});
+
+describe('commandsMetadata', () => {
+ // Renderer's `useCommandMetadata` depends on this shape; Settings UI
+ // would otherwise show bare wcCommand strings.
+ const snapshot = commandsMetadata();
+ const byWc = new Map(snapshot.map((m) => [m.wcCommand, m]));
+
+ it('returns an entry for every schema dapp.wcCommand and every alias (no orphans)', () => {
+ let expected = 0;
+ for (const ns of SCHEMA_COMMANDS) {
+ const schema = getCommandSchema(ns);
+ if (schema.dapp) {
+ expected += 1;
+ expected += schema.dapp.aliases?.length ?? 0;
+ }
+ }
+ expect(snapshot.length).toBe(expected);
+ });
+
+ it('includes the handler-routed meta-commands so the Settings UI can label them', () => {
+ expect(byWc.get('chia_requestPermissions')?.label).toBe('Request Permissions');
+ expect(byWc.get('chia_showNotification')?.label).toBeDefined();
+ });
+
+ it('includes the handler-routed wallet flows', () => {
+ expect(byWc.get('chia_addCATToken')?.label).toBe('Add CAT Token');
+ expect(byWc.get('chia_transferDID')?.label).toBe('Transfer DID');
+ expect(byWc.get('chia_createNewDIDWallet')?.label).toBe('Create new DID Wallet');
+ });
+
+ it('includes alias wcCommands (chia_getCurrentAddress) so Settings can render them', () => {
+ const entry = byWc.get('chia_getCurrentAddress');
+ expect(entry).toBeDefined();
+ expect(entry?.requiresSync).toBe(false);
+ });
+
+ it('resolves label and description strings (i18n call happened at fetch time)', () => {
+ const sendTx = byWc.get('chia_sendTransaction');
+ expect(sendTx?.label).toBe('Send Transaction');
+ expect(typeof sendTx?.label).toBe('string');
+ });
+
+ it('flags `requiresSync: true` on the four spend-class commands', () => {
+ expect(byWc.get('chia_sendTransaction')?.requiresSync).toBe(true);
+ expect(byWc.get('chia_spendCAT')?.requiresSync).toBe(true);
+ expect(byWc.get('chia_spendClawbackCoins')?.requiresSync).toBe(true);
+ expect(byWc.get('chia_getSpendableCoins')?.requiresSync).toBe(true);
+ });
+
+ it('defaults `requiresSync: false` on every other command', () => {
+ expect(byWc.get('chia_getWallets')?.requiresSync).toBe(false);
+ expect(byWc.get('chia_signMessageByAddress')?.requiresSync).toBe(false);
+ expect(byWc.get('chia_takeOffer')?.requiresSync).toBe(false);
+ });
+});
+
+describe('applyDefaults', () => {
+ // The wire envelope must carry `wallet_id: 1` etc. that dapps conventionally
+ // omit; without these, daemon RPCs silently fail.
+
+ it('fills in `wallet_id: 1` for chia_sendTransaction when omitted', () => {
+ const out = applyDefaults('chia_sendTransaction', {
+ address: 'txch1abc',
+ amount: '5',
+ fee: '0',
+ });
+ expect(out.wallet_id).toBe(1);
+ });
+
+ it('does not overwrite a wallet_id the dapp explicitly sent', () => {
+ const out = applyDefaults('chia_sendTransaction', {
+ wallet_id: 7,
+ address: 'txch1abc',
+ amount: '5',
+ });
+ expect(out.wallet_id).toBe(7);
+ });
+
+ it('returns the input unchanged when the schema has no defaults', () => {
+ const input = { address: 'txch1abc', amount: '5' };
+ const out = applyDefaults('chia_cancelOffer', input);
+ expect(out).toEqual(input);
+ });
+
+ it('returns the input unchanged for unknown wc commands', () => {
+ const input = { foo: 'bar' };
+ const out = applyDefaults('chia_totallyMadeUp', input);
+ expect(out).toEqual(input);
+ });
+
+ it('does not mutate the input object (returns a new one when defaults apply)', () => {
+ const input: Record = { address: 'txch1abc' };
+ applyDefaults('chia_sendTransaction', input);
+ expect(input.wallet_id).toBeUndefined();
+ });
+
+ it('applies multiple defaults at once (chia_getNextAddress)', () => {
+ const out = applyDefaults('chia_getNextAddress', {});
+ expect(out.wallet_id).toBe(1);
+ expect(out.new_address).toBe(true);
+ });
+
+ it('treats explicit `false` / `0` / empty string as set (does not overwrite)', () => {
+ const out = applyDefaults('chia_getNextAddress', { new_address: false });
+ expect(out.new_address).toBe(false);
+ });
+
+ it('alias pins its own default (chia_getCurrentAddress → new_address: false)', () => {
+ // Without per-alias defaults the base would flip `new_address: true`
+ // and the dapp would silently get a fresh address on every call.
+ const out = applyDefaults('chia_getCurrentAddress', {});
+ expect(out.wallet_id).toBe(1);
+ expect(out.new_address).toBe(false);
+ });
+
+ it('alias still lets the dapp opt out of the alias default', () => {
+ const out = applyDefaults('chia_getCurrentAddress', { new_address: true });
+ expect(out.new_address).toBe(true);
+ });
+});
+
+describe('dapp.transformResponse', () => {
+ // Pins the legacy api-react response shapes for dapps written against the
+ // old endpoints. If you change the function's behaviour you'll likely
+ // break a real dapp — bump the test deliberately.
+
+ function tx(wcCommand: string, input: unknown) {
+ const fn = getCommandByWc(wcCommand)?.schema.dapp?.transformResponse;
+ if (!fn) throw new Error(`no transformResponse on ${wcCommand}`);
+ return fn(input as Record);
+ }
+
+ it('chia_getWallets unwraps to the wallets array', () => {
+ expect(tx('chia_getWallets', { wallets: [{ id: 1 }], success: true })).toEqual([{ id: 1 }]);
+ expect(tx('chia_getWallets', { wallets: undefined, success: true })).toEqual([]);
+ });
+
+ it('chia_getTransaction unwraps to the transaction', () => {
+ expect(tx('chia_getTransaction', { transaction: { name: '0xabc' } })).toEqual({ name: '0xabc' });
+ });
+
+ it('chia_getWalletBalance unwraps to walletBalance (raw — no BigNumber math here)', () => {
+ expect(tx('chia_getWalletBalance', { walletBalance: { confirmed: '5', unconfirmed: '3' } })).toEqual({
+ confirmed: '5',
+ unconfirmed: '3',
+ });
+ });
+
+ it('chia_getWalletBalances unwraps to walletBalances dict', () => {
+ expect(tx('chia_getWalletBalances', { walletBalances: { '1': { confirmed: '0' } } })).toEqual({
+ '1': { confirmed: '0' },
+ });
+ });
+
+ it('chia_getNextAddress unwraps to address', () => {
+ expect(tx('chia_getNextAddress', { address: 'xch1abc' })).toBe('xch1abc');
+ });
+
+ it('chia_getCurrentAddress (alias of get_next_address) inherits the same transform', () => {
+ expect(tx('chia_getCurrentAddress', { address: 'xch1abc' })).toBe('xch1abc');
+ });
+
+ it('chia_getHeightInfo surfaces height fields and null-fills the optional ones', () => {
+ expect(tx('chia_getHeightInfo', { height: 100, latestTimestamp: 200 })).toEqual({
+ height: 100,
+ latestTimestamp: 200,
+ isTransactionBlock: null,
+ prevTransactionBlockHeight: null,
+ });
+ expect(
+ tx('chia_getHeightInfo', {
+ height: 100,
+ latestTimestamp: 200,
+ isTransactionBlock: true,
+ prevTransactionBlockHeight: 99,
+ }),
+ ).toEqual({ height: 100, latestTimestamp: 200, isTransactionBlock: true, prevTransactionBlockHeight: 99 });
+ });
+
+ it('chia_getAllOffers returns tradeRecords as-is when offers is absent, zips _offerData when present', () => {
+ expect(tx('chia_getAllOffers', { tradeRecords: [{ tradeId: 'a' }, { tradeId: 'b' }] })).toEqual([
+ { tradeId: 'a' },
+ { tradeId: 'b' },
+ ]);
+ expect(
+ tx('chia_getAllOffers', {
+ tradeRecords: [{ tradeId: 'a' }, { tradeId: 'b' }],
+ offers: ['offerA', 'offerB'],
+ }),
+ ).toEqual([
+ { tradeId: 'a', _offerData: 'offerA' },
+ { tradeId: 'b', _offerData: 'offerB' },
+ ]);
+ });
+
+ it('chia_getCATAssetId unwraps to assetId', () => {
+ expect(tx('chia_getCATAssetId', { assetId: '0xdeadbeef' })).toBe('0xdeadbeef');
+ });
+
+ it('chia_getNFTWalletsWithDIDs unwraps to nftWallets', () => {
+ expect(tx('chia_getNFTWalletsWithDIDs', { nftWallets: [{ walletId: 5 }] })).toEqual([{ walletId: 5 }]);
+ });
+
+ it('chia_getVC unwraps to vcRecord', () => {
+ expect(tx('chia_getVC', { vcRecord: { vcId: '0xabc' } })).toEqual({ vcId: '0xabc' });
+ });
+
+ it('chia_getWalletAddresses unwraps to walletAddresses', () => {
+ expect(tx('chia_getWalletAddresses', { walletAddresses: { '0x1': [{ address: 'xch1abc' }] } })).toEqual({
+ '0x1': [{ address: 'xch1abc' }],
+ });
+ });
+});
+
+describe('bareWcCommand', () => {
+ it('strips the chia_ prefix', () => {
+ expect(bareWcCommand('chia_sendTransaction')).toBe('sendTransaction');
+ expect(bareWcCommand('chia_getNFTInfo')).toBe('getNFTInfo');
+ });
+
+ it('passes already-bare names through unchanged', () => {
+ expect(bareWcCommand('sendTransaction')).toBe('sendTransaction');
+ });
+
+ it('handles the empty string', () => {
+ expect(bareWcCommand('')).toBe('');
+ });
+});
diff --git a/packages/gui/src/electron/constants/commandRegistry.ts b/packages/gui/src/electron/constants/commandRegistry.ts
new file mode 100644
index 0000000000..f64a116831
--- /dev/null
+++ b/packages/gui/src/electron/constants/commandRegistry.ts
@@ -0,0 +1,2565 @@
+// Keys are `.`; param names are snake_case (wire form).
+// `dapp.wcCommand` is `chia_` to match WC `proposal.methods`. Dapp
+// surface is opt-in: no `dapp` block = UI-only command; no `dappAllowed: true`
+// on a param = dapp can't send it. `validateDappParams` fails closed.
+import { WcError, WcErrorCode } from '../../@types/WcError';
+import { i18n } from '../../config/locales';
+import NotificationType from '../../constants/NotificationType';
+import { buildCreateOfferDisplay, buildTakeOfferDisplay, lookupCat } from '../utils/dappEnrichment';
+import type { EnrichmentDisplay } from '../utils/dappEnrichment';
+
+export type ParamType = 'text' | 'mojo-to-xch' | 'mojo-to-cat' | 'bool' | 'json';
+
+type ParamSchemaBase = {
+ name: string;
+ label: () => string;
+ isOptional?: boolean;
+ /** Hidden from the Confirm dialog. */
+ hide?: boolean;
+ /** Default false — secure by default. */
+ dappAllowed?: boolean;
+};
+
+export type ParamSchema = ParamSchemaBase &
+ (
+ | { type: 'text' }
+ | { type: 'mojo-to-xch' }
+ /** ` `; symbol fetched via wallet id at `data[symbolFrom]`. */
+ | { type: 'mojo-to-cat'; symbolFrom: string }
+ | { type: 'bool' }
+ | { type: 'json' }
+ );
+
+export type WcAlias = {
+ wcCommand: string;
+ label?: () => string;
+ description?: () => string;
+ /** Merged on top of base `dapp.defaults`; alias values override. */
+ defaults?: Record;
+ requiresSync?: boolean;
+};
+
+export type DappCommandSchema = {
+ wcCommand: string;
+ label?: () => string;
+ description?: () => string;
+ requiresSync?: boolean;
+ defaults?: Record;
+ aliases?: WcAlias[];
+ /** Routes to `dappHandlers[handlerKey]` instead of the daemon. */
+ handlerKey?: string;
+ /**
+ * Reshapes the camelCased daemon response into the dapp-facing payload.
+ * Mirrors what legacy `api-react` RTK endpoints did via `transformResponse` —
+ * dapps that worked against the legacy endpoint expect the same shape.
+ * Only applies on the daemon-routed path (handlers produce their own data).
+ */
+ transformResponse?: (data: Record) => unknown;
+};
+
+// Strings are functions to defer i18n resolution past startup so locale
+// switches take effect on the next read.
+export type CommandSchema = {
+ title?: () => string;
+ message?: () => string;
+ confirmLabel?: () => string;
+ destructive?: boolean;
+ params: ParamSchema[];
+ enrich?: (data: Record) => Promise;
+ /** Absent = UI-only command. */
+ dapp?: DappCommandSchema;
+};
+
+const DEFAULT_TITLE = () => i18n._(/* i18n */ { id: 'Confirm' });
+const DEFAULT_MESSAGE = () => i18n._(/* i18n */ { id: 'Please review and confirm this action.' });
+const DEFAULT_CONFIRM_LABEL = () => i18n._(/* i18n */ { id: 'Proceed' });
+
+export function resolveTexts(schema: CommandSchema | undefined): {
+ title: string;
+ message: string;
+ confirmLabel: string;
+} {
+ return {
+ title: (schema?.title ?? DEFAULT_TITLE)(),
+ message: (schema?.message ?? DEFAULT_MESSAGE)(),
+ confirmLabel: (schema?.confirmLabel ?? DEFAULT_CONFIRM_LABEL)(),
+ };
+}
+
+const FALLBACK: CommandSchema = {
+ params: [],
+};
+
+// `chia_app.*` is handler-routed (no daemon RPC); `resolveDispatch` rejects
+// it so callers must check `entry.handlerKey` first.
+const RENDERER_NAMESPACE = 'chia_app';
+
+const SCHEMAS: Record = {
+ // ── Handler-routed (pure dapp, no daemon RPC) ─────────────────────────────
+ 'chia_app.request_permissions': {
+ params: [{ name: 'commands', label: () => i18n._(/* i18n */ { id: 'Commands' }), type: 'json', dappAllowed: true }],
+ dapp: {
+ wcCommand: 'chia_requestPermissions',
+ label: () => i18n._(/* i18n */ { id: 'Request Permissions' }),
+ description: () => i18n._(/* i18n */ { id: 'App is requesting permission to execute these commands' }),
+ handlerKey: 'requestPermissions',
+ },
+ },
+
+ 'chia_app.show_notification': {
+ title: () => i18n._(/* i18n */ { id: 'Confirm Notification' }),
+ message: () => i18n._(/* i18n */ { id: 'This app wants to show you a notification.' }),
+ confirmLabel: () => i18n._(/* i18n */ { id: 'Show' }),
+ params: [
+ { name: 'type', label: () => i18n._(/* i18n */ { id: 'Type' }), type: 'text', dappAllowed: true },
+ {
+ name: 'message',
+ label: () => i18n._(/* i18n */ { id: 'Message' }),
+ type: 'text',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ {
+ name: 'url',
+ label: () => i18n._(/* i18n */ { id: 'URL' }),
+ type: 'text',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ {
+ name: 'offer_data',
+ label: () => i18n._(/* i18n */ { id: 'Offer Data' }),
+ type: 'text',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ {
+ name: 'all_fingerprints',
+ label: () => i18n._(/* i18n */ { id: 'All Wallets' }),
+ type: 'bool',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ ],
+ enrich: async (data) => {
+ if (data.type === NotificationType.OFFER && typeof data.offer_data === 'string' && data.offer_data) {
+ const offer = await buildTakeOfferDisplay({ offer: data.offer_data });
+ return offer ? { offer } : {};
+ }
+ return {};
+ },
+ dapp: {
+ wcCommand: 'chia_showNotification',
+ label: () => i18n._(/* i18n */ { id: 'Show Notification' }),
+ description: () => i18n._(/* i18n */ { id: 'Show notification with offer or general announcement' }),
+ handlerKey: 'showNotification',
+ },
+ },
+
+ 'chia_app.add_cat_token': {
+ title: () => i18n._(/* i18n */ { id: 'Confirm Add CAT Token' }),
+ message: () => i18n._(/* i18n */ { id: 'This app wants to add a CAT token to your wallet.' }),
+ confirmLabel: () => i18n._(/* i18n */ { id: 'Add' }),
+ params: [
+ { name: 'asset_id', label: () => i18n._(/* i18n */ { id: 'Asset Id' }), type: 'text', dappAllowed: true },
+ { name: 'name', label: () => i18n._(/* i18n */ { id: 'Name' }), type: 'text', dappAllowed: true },
+ ],
+ dapp: {
+ wcCommand: 'chia_addCATToken',
+ label: () => i18n._(/* i18n */ { id: 'Add CAT Token' }),
+ handlerKey: 'addCATToken',
+ },
+ },
+
+ 'chia_app.transfer_did': {
+ title: () => i18n._(/* i18n */ { id: 'Confirm Transfer DID' }),
+ message: () => i18n._(/* i18n */ { id: 'Please carefully review and confirm this DID transfer.' }),
+ confirmLabel: () => i18n._(/* i18n */ { id: 'Transfer' }),
+ params: [
+ { name: 'wallet_id', label: () => i18n._(/* i18n */ { id: 'Wallet Id' }), type: 'text', dappAllowed: true },
+ {
+ name: 'inner_address',
+ label: () => i18n._(/* i18n */ { id: 'Inner Address' }),
+ type: 'text',
+ dappAllowed: true,
+ },
+ {
+ name: 'fee',
+ label: () => i18n._(/* i18n */ { id: 'Fee' }),
+ type: 'mojo-to-xch',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ {
+ name: 'with_recovery_info',
+ label: () => i18n._(/* i18n */ { id: 'With Recovery Info' }),
+ type: 'bool',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ {
+ name: 'reuse_puzhash',
+ label: () => i18n._(/* i18n */ { id: 'Reuse Puzzle Hash' }),
+ type: 'bool',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ ],
+ dapp: {
+ wcCommand: 'chia_transferDID',
+ label: () => i18n._(/* i18n */ { id: 'Transfer DID' }),
+ handlerKey: 'transferDID',
+ },
+ },
+
+ 'chia_app.create_new_did_wallet': {
+ title: () => i18n._(/* i18n */ { id: 'Confirm Create DID Wallet' }),
+ message: () => i18n._(/* i18n */ { id: 'Please carefully review and confirm creating this DID wallet.' }),
+ confirmLabel: () => i18n._(/* i18n */ { id: 'Create' }),
+ params: [
+ { name: 'amount', label: () => i18n._(/* i18n */ { id: 'Amount' }), type: 'mojo-to-xch', dappAllowed: true },
+ { name: 'fee', label: () => i18n._(/* i18n */ { id: 'Fee' }), type: 'mojo-to-xch', dappAllowed: true },
+ { name: 'backup_dids', label: () => i18n._(/* i18n */ { id: 'Backup DIDs' }), type: 'json', dappAllowed: true },
+ {
+ name: 'num_of_backup_ids_needed',
+ label: () => i18n._(/* i18n */ { id: 'Number of Backup Ids Needed' }),
+ type: 'text',
+ dappAllowed: true,
+ },
+ ],
+ dapp: {
+ wcCommand: 'chia_createNewDIDWallet',
+ label: () => i18n._(/* i18n */ { id: 'Create new DID Wallet' }),
+ handlerKey: 'createNewDIDWallet',
+ },
+ },
+
+ // ── Wallet (mutating, dapp-callable) ──────────────────────────────────────
+ 'chia_wallet.send_transaction': {
+ title: () => i18n._(/* i18n */ { id: 'Confirm Send Transaction' }),
+ message: () => i18n._(/* i18n */ { id: 'Please carefully review and confirm this blockchain transaction.' }),
+ confirmLabel: () => i18n._(/* i18n */ { id: 'Send' }),
+ params: [
+ { name: 'amount', label: () => i18n._(/* i18n */ { id: 'Amount' }), type: 'mojo-to-xch', dappAllowed: true },
+ { name: 'fee', label: () => i18n._(/* i18n */ { id: 'Fee' }), type: 'mojo-to-xch', dappAllowed: true },
+ { name: 'address', label: () => i18n._(/* i18n */ { id: 'Address' }), type: 'text', dappAllowed: true },
+ {
+ name: 'wallet_id',
+ label: () => i18n._(/* i18n */ { id: 'Wallet Id' }),
+ type: 'text',
+ hide: true,
+ dappAllowed: true,
+ },
+ {
+ name: 'memos',
+ label: () => i18n._(/* i18n */ { id: 'Memos' }),
+ type: 'json',
+ isOptional: true,
+ hide: true,
+ dappAllowed: true,
+ },
+ {
+ name: 'puzzle_decorator',
+ label: () => i18n._(/* i18n */ { id: 'Puzzle Decorator' }),
+ type: 'json',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ ],
+ dapp: {
+ wcCommand: 'chia_sendTransaction',
+ label: () => i18n._(/* i18n */ { id: 'Send Transaction' }),
+ requiresSync: true,
+ defaults: { wallet_id: 1 },
+ },
+ },
+
+ 'chia_wallet.cat_spend': {
+ title: () => i18n._(/* i18n */ { id: 'Confirm CAT Spend' }),
+ message: () => i18n._(/* i18n */ { id: 'Please carefully review and confirm this CAT spend.' }),
+ confirmLabel: () => i18n._(/* i18n */ { id: 'Send' }),
+ params: [
+ { name: 'wallet_id', label: () => i18n._(/* i18n */ { id: 'Wallet Id' }), type: 'text', dappAllowed: true },
+ { name: 'address', label: () => i18n._(/* i18n */ { id: 'Address' }), type: 'text', dappAllowed: true },
+ {
+ name: 'amount',
+ label: () => i18n._(/* i18n */ { id: 'Amount' }),
+ type: 'mojo-to-cat',
+ symbolFrom: 'wallet_id',
+ dappAllowed: true,
+ },
+ { name: 'fee', label: () => i18n._(/* i18n */ { id: 'Fee' }), type: 'mojo-to-xch', dappAllowed: true },
+ {
+ name: 'memos',
+ label: () => i18n._(/* i18n */ { id: 'Memos' }),
+ type: 'json',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ ],
+ enrich: async (data) => {
+ const walletId = data.wallet_id;
+ if (walletId === undefined || walletId === null) return {};
+ const cat = await lookupCat(walletId as number | string);
+ return cat ? { cat } : {};
+ },
+ dapp: {
+ wcCommand: 'chia_spendCAT',
+ label: () => i18n._(/* i18n */ { id: 'Spend CAT' }),
+ requiresSync: true,
+ },
+ },
+
+ 'chia_wallet.nft_transfer_nft': {
+ title: () => i18n._(/* i18n */ { id: 'Confirm NFT Transfer' }),
+ message: () => i18n._(/* i18n */ { id: 'Please carefully review and confirm this NFT transfer.' }),
+ confirmLabel: () => i18n._(/* i18n */ { id: 'Transfer' }),
+ params: [
+ { name: 'wallet_id', label: () => i18n._(/* i18n */ { id: 'Wallet Id' }), type: 'text', dappAllowed: true },
+ {
+ name: 'nft_coin_ids',
+ label: () => i18n._(/* i18n */ { id: 'NFT Coin Ids' }),
+ type: 'json',
+ dappAllowed: true,
+ },
+ {
+ name: 'target_address',
+ label: () => i18n._(/* i18n */ { id: 'Target Address' }),
+ type: 'text',
+ dappAllowed: true,
+ },
+ { name: 'fee', label: () => i18n._(/* i18n */ { id: 'Fee' }), type: 'mojo-to-xch', dappAllowed: true },
+ ],
+ dapp: {
+ wcCommand: 'chia_transferNFT',
+ label: () => i18n._(/* i18n */ { id: 'Transfer NFT' }),
+ },
+ },
+
+ 'chia_wallet.nft_transfer_bulk': {
+ title: () => i18n._(/* i18n */ { id: 'Confirm NFT Transfer' }),
+ message: () => i18n._(/* i18n */ { id: 'Please carefully review and confirm this NFT transfer.' }),
+ confirmLabel: () => i18n._(/* i18n */ { id: 'Transfer' }),
+ params: [
+ { name: 'nft_coin_list', label: () => i18n._(/* i18n */ { id: 'NFT Coin List' }), type: 'json' },
+ { name: 'target_address', label: () => i18n._(/* i18n */ { id: 'Target Address' }), type: 'text' },
+ { name: 'fee', label: () => i18n._(/* i18n */ { id: 'Fee' }), type: 'mojo-to-xch' },
+ ],
+ },
+
+ 'chia_wallet.cancel_offer': {
+ message: () => i18n._(/* i18n */ { id: 'Please carefully review and confirm this offer cancellation.' }),
+ destructive: true,
+ params: [
+ { name: 'trade_id', label: () => i18n._(/* i18n */ { id: 'Trade Id' }), type: 'text', dappAllowed: true },
+ { name: 'secure', label: () => i18n._(/* i18n */ { id: 'Secure' }), type: 'bool', dappAllowed: true },
+ { name: 'fee', label: () => i18n._(/* i18n */ { id: 'Fee' }), type: 'mojo-to-xch', dappAllowed: true },
+ ],
+ dapp: {
+ wcCommand: 'chia_cancelOffer',
+ label: () => i18n._(/* i18n */ { id: 'Cancel Offer' }),
+ },
+ },
+
+ 'chia_wallet.create_offer_for_ids': {
+ title: () => i18n._(/* i18n */ { id: 'Confirm Create Offer' }),
+ message: () =>
+ i18n._(
+ /* i18n */ {
+ id: 'Please carefully review and confirm this offer creation. When creating an offer, any assets that are being offered will be locked and unavailable until the offer is accepted or cancelled, resulting in your spendable balance changing.',
+ },
+ ),
+ confirmLabel: () => i18n._(/* i18n */ { id: 'Create' }),
+ params: [
+ {
+ name: 'offer',
+ label: () => i18n._(/* i18n */ { id: 'Wallet Ids and Amounts' }),
+ type: 'json',
+ dappAllowed: true,
+ },
+ { name: 'driver_dict', label: () => i18n._(/* i18n */ { id: 'Driver Dict' }), type: 'json', dappAllowed: true },
+ {
+ name: 'validate_only',
+ label: () => i18n._(/* i18n */ { id: 'Validate Only' }),
+ type: 'bool',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ {
+ name: 'disable_json_formatting',
+ label: () => i18n._(/* i18n */ { id: 'Disable JSON Formatting' }),
+ type: 'bool',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ {
+ name: 'fee',
+ label: () => i18n._(/* i18n */ { id: 'Fee' }),
+ type: 'mojo-to-xch',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ {
+ name: 'extra_conditions',
+ label: () => i18n._(/* i18n */ { id: 'Extra Conditions' }),
+ type: 'json',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ {
+ name: 'coin_ids',
+ label: () => i18n._(/* i18n */ { id: 'Coin Ids' }),
+ type: 'json',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ {
+ name: 'allow_unsynced',
+ label: () => i18n._(/* i18n */ { id: 'Allow Unsynced' }),
+ type: 'bool',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ ],
+ enrich: async (data) => {
+ const offer = await buildCreateOfferDisplay(data);
+ return offer ? { offer } : {};
+ },
+ dapp: {
+ wcCommand: 'chia_createOfferForIds',
+ label: () => i18n._(/* i18n */ { id: 'Create Offer for Ids' }),
+ },
+ },
+
+ 'chia_wallet.take_offer': {
+ message: () => i18n._(/* i18n */ { id: 'Please carefully review and confirm this offer acceptance.' }),
+ params: [
+ { name: 'offer', label: () => i18n._(/* i18n */ { id: 'Offer' }), type: 'text', dappAllowed: true },
+ { name: 'fee', label: () => i18n._(/* i18n */ { id: 'Fee' }), type: 'mojo-to-xch', dappAllowed: true },
+ {
+ name: 'extra_conditions',
+ label: () => i18n._(/* i18n */ { id: 'Extra Conditions' }),
+ type: 'json',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ ],
+ enrich: async (data) => {
+ const offer = await buildTakeOfferDisplay(data);
+ return offer ? { offer } : {};
+ },
+ dapp: {
+ wcCommand: 'chia_takeOffer',
+ label: () => i18n._(/* i18n */ { id: 'Take Offer' }),
+ },
+ },
+
+ 'chia_wallet.sign_message_by_address': {
+ title: () => i18n._(/* i18n */ { id: 'Confirm Sign Message' }),
+ message: () => i18n._(/* i18n */ { id: 'Are you sure you want to sign this message?' }),
+ confirmLabel: () => i18n._(/* i18n */ { id: 'Sign' }),
+ params: [
+ { name: 'address', label: () => i18n._(/* i18n */ { id: 'Address' }), type: 'text', dappAllowed: true },
+ { name: 'message', label: () => i18n._(/* i18n */ { id: 'Message' }), type: 'text', dappAllowed: true },
+ {
+ name: 'is_hex',
+ label: () => i18n._(/* i18n */ { id: 'Hex Encoded' }),
+ type: 'bool',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ {
+ name: 'safe_mode',
+ label: () => i18n._(/* i18n */ { id: 'Safe Mode' }),
+ type: 'bool',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ ],
+ dapp: {
+ wcCommand: 'chia_signMessageByAddress',
+ label: () => i18n._(/* i18n */ { id: 'Sign Message by Address' }),
+ },
+ },
+
+ 'chia_wallet.sign_message_by_id': {
+ title: () => i18n._(/* i18n */ { id: 'Confirm Sign Message' }),
+ message: () => i18n._(/* i18n */ { id: 'Are you sure you want to sign this message?' }),
+ confirmLabel: () => i18n._(/* i18n */ { id: 'Sign' }),
+ params: [
+ { name: 'id', label: () => i18n._(/* i18n */ { id: 'Id' }), type: 'text', dappAllowed: true },
+ { name: 'message', label: () => i18n._(/* i18n */ { id: 'Message' }), type: 'text', dappAllowed: true },
+ {
+ name: 'is_hex',
+ label: () => i18n._(/* i18n */ { id: 'Hex Encoded' }),
+ type: 'bool',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ ],
+ dapp: {
+ wcCommand: 'chia_signMessageById',
+ label: () => i18n._(/* i18n */ { id: 'Sign Message by Id' }),
+ },
+ },
+
+ 'chia_wallet.nft_set_nft_did': {
+ title: () => i18n._(/* i18n */ { id: 'Confirm Move NFT to DID' }),
+ message: () => i18n._(/* i18n */ { id: 'Are you sure you want to move this NFT to the specified profile?' }),
+ confirmLabel: () => i18n._(/* i18n */ { id: 'Move' }),
+ params: [
+ { name: 'wallet_id', label: () => i18n._(/* i18n */ { id: 'Wallet Id' }), type: 'text', dappAllowed: true },
+ {
+ name: 'nft_launcher_id',
+ label: () => i18n._(/* i18n */ { id: 'NFT Launcher Id' }),
+ type: 'text',
+ dappAllowed: true,
+ },
+ {
+ name: 'nft_coin_ids',
+ label: () => i18n._(/* i18n */ { id: 'NFT Coin Ids' }),
+ type: 'json',
+ dappAllowed: true,
+ },
+ { name: 'did', label: () => i18n._(/* i18n */ { id: 'DID' }), type: 'text', dappAllowed: true },
+ { name: 'fee', label: () => i18n._(/* i18n */ { id: 'Fee' }), type: 'mojo-to-xch', dappAllowed: true },
+ ],
+ dapp: {
+ wcCommand: 'chia_setNFTDID',
+ label: () => i18n._(/* i18n */ { id: 'Set NFT DID' }),
+ },
+ },
+
+ 'chia_wallet.nft_set_did_bulk': {
+ title: () => i18n._(/* i18n */ { id: 'Confirm Move NFTs to DID' }),
+ message: () => i18n._(/* i18n */ { id: 'Are you sure you want to move these NFTs to the specified profile?' }),
+ confirmLabel: () => i18n._(/* i18n */ { id: 'Move' }),
+ params: [
+ { name: 'nft_coin_list', label: () => i18n._(/* i18n */ { id: 'NFT Coin List' }), type: 'json' },
+ { name: 'did_id', label: () => i18n._(/* i18n */ { id: 'DID' }), type: 'text' },
+ { name: 'fee', label: () => i18n._(/* i18n */ { id: 'Fee' }), type: 'mojo-to-xch' },
+ ],
+ },
+
+ // ── Wallet (UI-only mutations) ─────────────────────────────────────────────
+ 'chia_wallet.set_auto_claim': {
+ title: () => i18n._(/* i18n */ { id: 'Confirm Set Auto Claim' }),
+ message: () => i18n._(/* i18n */ { id: 'Are you sure you want to set auto claim?' }),
+ confirmLabel: () => i18n._(/* i18n */ { id: 'Set' }),
+ params: [
+ { name: 'enabled', label: () => i18n._(/* i18n */ { id: 'Enabled' }), type: 'bool' },
+ { name: 'tx_fee', label: () => i18n._(/* i18n */ { id: 'Transaction Fee' }), type: 'mojo-to-xch' },
+ { name: 'min_amount', label: () => i18n._(/* i18n */ { id: 'Min Amount' }), type: 'mojo-to-xch' },
+ ],
+ },
+
+ 'chia_wallet.create_new_wallet': {
+ title: () => i18n._(/* i18n */ { id: 'Confirm Create New Wallet' }),
+ message: () => i18n._(/* i18n */ { id: 'Are you sure you want to create a new wallet?' }),
+ confirmLabel: () => i18n._(/* i18n */ { id: 'Create' }),
+ params: [
+ { name: 'wallet_name', label: () => i18n._(/* i18n */ { id: 'Name' }), type: 'text' },
+ { name: 'wallet_type', label: () => i18n._(/* i18n */ { id: 'Type' }), type: 'text' },
+ { name: 'asset_id', label: () => i18n._(/* i18n */ { id: 'Asset ID' }), type: 'text' },
+ { name: 'fee', label: () => i18n._(/* i18n */ { id: 'Fee' }), type: 'mojo-to-xch' },
+ ],
+ },
+
+ 'chia_wallet.delete_key': {
+ title: () => i18n._(/* i18n */ { id: 'Confirm Delete Wallet' }),
+ message: () => i18n._(/* i18n */ { id: 'Are you sure you want to delete this wallet?' }),
+ confirmLabel: () => i18n._(/* i18n */ { id: 'Delete' }),
+ destructive: true,
+ params: [{ name: 'fingerprint', label: () => i18n._(/* i18n */ { id: 'Fingerprint' }), type: 'text' }],
+ },
+
+ 'chia_wallet.set_payout_instructions': {
+ title: () => i18n._(/* i18n */ { id: 'Confirm Set Payout Instructions' }),
+ message: () => i18n._(/* i18n */ { id: 'Are you sure you want to set payout instructions?' }),
+ confirmLabel: () => i18n._(/* i18n */ { id: 'Set' }),
+ params: [
+ { name: 'payout_instructions', label: () => i18n._(/* i18n */ { id: 'Payout Instructions' }), type: 'text' },
+ ],
+ },
+
+ // ── Harvester / full-node / farmer / daemon (UI-only) ──────────────────────
+ 'chia_harvester.delete_plot': {
+ title: () => i18n._(/* i18n */ { id: 'Confirm Delete Plot' }),
+ confirmLabel: () => i18n._(/* i18n */ { id: 'Delete' }),
+ destructive: true,
+ params: [{ name: 'filename', label: () => i18n._(/* i18n */ { id: 'Filename' }), type: 'text' }],
+ },
+
+ 'chia_harvester.add_plot_directory': {
+ title: () => i18n._(/* i18n */ { id: 'Confirm Add Plot Directory' }),
+ confirmLabel: () => i18n._(/* i18n */ { id: 'Add' }),
+ params: [{ name: 'dirname', label: () => i18n._(/* i18n */ { id: 'Directory' }), type: 'text' }],
+ },
+
+ 'chia_harvester.remove_plot_directory': {
+ title: () => i18n._(/* i18n */ { id: 'Confirm Remove Plot Directory' }),
+ destructive: true,
+ params: [{ name: 'dirname', label: () => i18n._(/* i18n */ { id: 'Directory' }), type: 'text' }],
+ },
+
+ 'chia_full_node.open_connection': {
+ title: () => i18n._(/* i18n */ { id: 'Confirm Open Connection' }),
+ message: () => i18n._(/* i18n */ { id: 'Are you sure you want to open a connection to the specified node?' }),
+ confirmLabel: () => i18n._(/* i18n */ { id: 'Connect' }),
+ params: [
+ { name: 'host', label: () => i18n._(/* i18n */ { id: 'Host' }), type: 'text' },
+ { name: 'port', label: () => i18n._(/* i18n */ { id: 'Port' }), type: 'text' },
+ ],
+ },
+
+ 'chia_full_node.close_connection': {
+ title: () => i18n._(/* i18n */ { id: 'Confirm Disconnect' }),
+ confirmLabel: () => i18n._(/* i18n */ { id: 'Disconnect' }),
+ destructive: true,
+ params: [],
+ },
+
+ 'chia_farmer.close_connection': {
+ title: () => i18n._(/* i18n */ { id: 'Confirm Disconnect' }),
+ confirmLabel: () => i18n._(/* i18n */ { id: 'Disconnect' }),
+ destructive: true,
+ params: [],
+ },
+
+ 'chia_farmer.set_payout_instructions': {
+ title: () => i18n._(/* i18n */ { id: 'Confirm Set Payout Instructions' }),
+ message: () => i18n._(/* i18n */ { id: 'Are you sure you want to set payout instructions?' }),
+ confirmLabel: () => i18n._(/* i18n */ { id: 'Set' }),
+ params: [
+ { name: 'payout_instructions', label: () => i18n._(/* i18n */ { id: 'Payout Instructions' }), type: 'text' },
+ ],
+ },
+
+ 'daemon.stop_plotting': {
+ confirmLabel: () => i18n._(/* i18n */ { id: 'Stop' }),
+ destructive: true,
+ params: [],
+ },
+
+ // ── Login / fingerprint switch ─────────────────────────────────────────────
+ 'chia_wallet.log_in': {
+ title: () => i18n._(/* i18n */ { id: 'Confirm Log In' }),
+ message: () => i18n._(/* i18n */ { id: 'Are you sure you want to switch to this wallet key?' }),
+ confirmLabel: () => i18n._(/* i18n */ { id: 'Log In' }),
+ params: [
+ { name: 'fingerprint', label: () => i18n._(/* i18n */ { id: 'Fingerprint' }), type: 'text', dappAllowed: true },
+ ],
+ dapp: {
+ wcCommand: 'chia_logIn',
+ label: () => i18n._(/* i18n */ { id: 'Log In' }),
+ },
+ },
+
+ // ── Transactions ───────────────────────────────────────────────────────────
+ 'chia_wallet.spend_clawback_coins': {
+ title: () => i18n._(/* i18n */ { id: 'Confirm Clawback Spend' }),
+ message: () => i18n._(/* i18n */ { id: 'Please carefully review and confirm this clawback spend.' }),
+ confirmLabel: () => i18n._(/* i18n */ { id: 'Send' }),
+ params: [
+ { name: 'coin_ids', label: () => i18n._(/* i18n */ { id: 'Coin Ids' }), type: 'json', dappAllowed: true },
+ { name: 'fee', label: () => i18n._(/* i18n */ { id: 'Fee' }), type: 'mojo-to-xch', dappAllowed: true },
+ ],
+ dapp: {
+ wcCommand: 'chia_spendClawbackCoins',
+ label: () => i18n._(/* i18n */ { id: 'Claw back or claim claw back transaction' }),
+ requiresSync: true,
+ },
+ },
+
+ 'chia_wallet.push_transactions': {
+ title: () => i18n._(/* i18n */ { id: 'Confirm Push Transactions' }),
+ message: () => i18n._(/* i18n */ { id: 'Please carefully review and confirm pushing this transaction bundle.' }),
+ confirmLabel: () => i18n._(/* i18n */ { id: 'Push' }),
+ params: [
+ {
+ name: 'transactions',
+ label: () => i18n._(/* i18n */ { id: 'Transactions' }),
+ type: 'json',
+ dappAllowed: true,
+ },
+ {
+ name: 'fee',
+ label: () => i18n._(/* i18n */ { id: 'Fee' }),
+ type: 'mojo-to-xch',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ {
+ name: 'push',
+ label: () => i18n._(/* i18n */ { id: 'Push' }),
+ type: 'bool',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ {
+ name: 'sign',
+ label: () => i18n._(/* i18n */ { id: 'Sign' }),
+ type: 'bool',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ {
+ name: 'allow_unsynced',
+ label: () => i18n._(/* i18n */ { id: 'Allow Unsynced' }),
+ type: 'bool',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ ],
+ dapp: {
+ wcCommand: 'chia_pushTransactions',
+ label: () => i18n._(/* i18n */ { id: 'Push Transactions' }),
+ description: () => i18n._(/* i18n */ { id: 'Push a list of transactions to the blockchain via the wallet' }),
+ },
+ },
+
+ 'chia_full_node.push_tx': {
+ title: () => i18n._(/* i18n */ { id: 'Confirm Push Transaction' }),
+ message: () => i18n._(/* i18n */ { id: 'Please carefully review and confirm pushing this transaction.' }),
+ confirmLabel: () => i18n._(/* i18n */ { id: 'Push' }),
+ params: [
+ { name: 'spend_bundle', label: () => i18n._(/* i18n */ { id: 'Spend Bundle' }), type: 'json', dappAllowed: true },
+ ],
+ dapp: {
+ wcCommand: 'chia_pushTx',
+ label: () => i18n._(/* i18n */ { id: 'Push Transaction' }),
+ description: () => i18n._(/* i18n */ { id: 'Push a spend bundle (transaction) to the blockchain' }),
+ },
+ },
+
+ // ── NFTs ───────────────────────────────────────────────────────────────────
+ 'chia_wallet.nft_mint_nft': {
+ title: () => i18n._(/* i18n */ { id: 'Confirm Mint NFT' }),
+ message: () => i18n._(/* i18n */ { id: 'Please carefully review and confirm this NFT mint.' }),
+ confirmLabel: () => i18n._(/* i18n */ { id: 'Mint' }),
+ params: [
+ { name: 'wallet_id', label: () => i18n._(/* i18n */ { id: 'Wallet Id' }), type: 'text', dappAllowed: true },
+ {
+ name: 'royalty_address',
+ label: () => i18n._(/* i18n */ { id: 'Royalty Address' }),
+ type: 'text',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ {
+ name: 'royalty_percentage',
+ label: () => i18n._(/* i18n */ { id: 'Royalty Percentage' }),
+ type: 'text',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ {
+ name: 'target_address',
+ label: () => i18n._(/* i18n */ { id: 'Target Address' }),
+ type: 'text',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ { name: 'uris', label: () => i18n._(/* i18n */ { id: 'URIs' }), type: 'json', dappAllowed: true },
+ { name: 'hash', label: () => i18n._(/* i18n */ { id: 'Hash' }), type: 'text', dappAllowed: true },
+ { name: 'meta_uris', label: () => i18n._(/* i18n */ { id: 'Meta URIs' }), type: 'json', dappAllowed: true },
+ {
+ name: 'meta_hash',
+ label: () => i18n._(/* i18n */ { id: 'Meta Hash' }),
+ type: 'text',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ {
+ name: 'license_uris',
+ label: () => i18n._(/* i18n */ { id: 'License URIs' }),
+ type: 'json',
+ dappAllowed: true,
+ },
+ {
+ name: 'license_hash',
+ label: () => i18n._(/* i18n */ { id: 'License Hash' }),
+ type: 'text',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ {
+ name: 'edition_number',
+ label: () => i18n._(/* i18n */ { id: 'Edition Number' }),
+ type: 'text',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ {
+ name: 'edition_total',
+ label: () => i18n._(/* i18n */ { id: 'Edition Total' }),
+ type: 'text',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ {
+ name: 'did_id',
+ label: () => i18n._(/* i18n */ { id: 'DID Id' }),
+ type: 'text',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ {
+ name: 'fee',
+ label: () => i18n._(/* i18n */ { id: 'Fee' }),
+ type: 'mojo-to-xch',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ ],
+ dapp: {
+ wcCommand: 'chia_mintNFT',
+ label: () => i18n._(/* i18n */ { id: 'Mint NFT' }),
+ },
+ },
+
+ 'chia_wallet.nft_mint_bulk': {
+ title: () => i18n._(/* i18n */ { id: 'Confirm Bulk Mint NFTs' }),
+ message: () => i18n._(/* i18n */ { id: 'Please carefully review and confirm this bulk NFT mint.' }),
+ confirmLabel: () => i18n._(/* i18n */ { id: 'Mint' }),
+ params: [
+ { name: 'wallet_id', label: () => i18n._(/* i18n */ { id: 'Wallet Id' }), type: 'text', dappAllowed: true },
+ {
+ name: 'metadata_list',
+ label: () => i18n._(/* i18n */ { id: 'Metadata List' }),
+ type: 'json',
+ dappAllowed: true,
+ },
+ {
+ name: 'royalty_percentage',
+ label: () => i18n._(/* i18n */ { id: 'Royalty Percentage' }),
+ type: 'text',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ {
+ name: 'royalty_address',
+ label: () => i18n._(/* i18n */ { id: 'Royalty Address' }),
+ type: 'text',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ {
+ name: 'target_list',
+ label: () => i18n._(/* i18n */ { id: 'Target List' }),
+ type: 'json',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ {
+ name: 'mint_number_start',
+ label: () => i18n._(/* i18n */ { id: 'Mint Start Number' }),
+ type: 'text',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ {
+ name: 'mint_total',
+ label: () => i18n._(/* i18n */ { id: 'Mint Total' }),
+ type: 'text',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ {
+ name: 'xch_coins',
+ label: () => i18n._(/* i18n */ { id: 'XCH Coins' }),
+ type: 'json',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ {
+ name: 'xch_change_target',
+ label: () => i18n._(/* i18n */ { id: 'XCH Change Target' }),
+ type: 'text',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ {
+ name: 'new_innerpuzhash',
+ label: () => i18n._(/* i18n */ { id: 'New Inner Puzzle Hash' }),
+ type: 'json',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ {
+ name: 'new_p_2_puzhash',
+ label: () => i18n._(/* i18n */ { id: 'New P2 Puzzle Hash' }),
+ type: 'text',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ {
+ name: 'did_coin',
+ label: () => i18n._(/* i18n */ { id: 'DID Coin' }),
+ type: 'json',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ {
+ name: 'did_lineage_parent',
+ label: () => i18n._(/* i18n */ { id: 'DID Lineage Parent' }),
+ type: 'text',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ {
+ name: 'mint_from_did',
+ label: () => i18n._(/* i18n */ { id: 'Mint From DID' }),
+ type: 'bool',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ {
+ name: 'fee',
+ label: () => i18n._(/* i18n */ { id: 'Fee' }),
+ type: 'mojo-to-xch',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ {
+ name: 'reuse_puzhash',
+ label: () => i18n._(/* i18n */ { id: 'Reuse Puzzle Hash' }),
+ type: 'bool',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ ],
+ dapp: {
+ wcCommand: 'chia_mintBulk',
+ label: () => i18n._(/* i18n */ { id: 'Mint Bulk' }),
+ description: () => i18n._(/* i18n */ { id: 'Create a spend bundle to mint multiple NFTs' }),
+ },
+ },
+
+ // ── DIDs ───────────────────────────────────────────────────────────────────
+ 'chia_wallet.did_find_lost': {
+ title: () => i18n._(/* i18n */ { id: 'Confirm Find Lost DID' }),
+ message: () => i18n._(/* i18n */ { id: 'Are you sure you want to recover this DID?' }),
+ confirmLabel: () => i18n._(/* i18n */ { id: 'Recover' }),
+ params: [
+ { name: 'coin_id', label: () => i18n._(/* i18n */ { id: 'Coin Id' }), type: 'text', dappAllowed: true },
+ {
+ name: 'recovery_list_hash',
+ label: () => i18n._(/* i18n */ { id: 'Recovery List Hash' }),
+ type: 'text',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ {
+ name: 'num_verification',
+ label: () => i18n._(/* i18n */ { id: 'Required Number of DIDs for Verification' }),
+ type: 'text',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ {
+ name: 'metadata',
+ label: () => i18n._(/* i18n */ { id: 'DID Metadata' }),
+ type: 'text',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ ],
+ dapp: {
+ wcCommand: 'chia_findLostDID',
+ label: () => i18n._(/* i18n */ { id: 'Find Lost DID' }),
+ },
+ },
+
+ 'chia_wallet.did_update_metadata': {
+ title: () => i18n._(/* i18n */ { id: 'Confirm Update DID Metadata' }),
+ message: () => i18n._(/* i18n */ { id: 'Please carefully review and confirm this DID metadata update.' }),
+ confirmLabel: () => i18n._(/* i18n */ { id: 'Update' }),
+ params: [
+ { name: 'wallet_id', label: () => i18n._(/* i18n */ { id: 'Wallet Id' }), type: 'text', dappAllowed: true },
+ {
+ name: 'metadata',
+ label: () => i18n._(/* i18n */ { id: 'DID Metadata' }),
+ type: 'json',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ {
+ name: 'fee',
+ label: () => i18n._(/* i18n */ { id: 'Fee' }),
+ type: 'mojo-to-xch',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ {
+ name: 'reuse_puzhash',
+ label: () => i18n._(/* i18n */ { id: 'Reuse Puzzle Hash' }),
+ type: 'bool',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ ],
+ dapp: {
+ wcCommand: 'chia_updateDIDMetadata',
+ label: () => i18n._(/* i18n */ { id: 'Update DID Metadata' }),
+ },
+ },
+
+ 'chia_wallet.did_update_recovery_ids': {
+ title: () => i18n._(/* i18n */ { id: 'Confirm Update DID Recovery Ids' }),
+ message: () => i18n._(/* i18n */ { id: 'Please carefully review and confirm this DID recovery list update.' }),
+ confirmLabel: () => i18n._(/* i18n */ { id: 'Update' }),
+ params: [
+ { name: 'wallet_id', label: () => i18n._(/* i18n */ { id: 'Wallet Id' }), type: 'text', dappAllowed: true },
+ {
+ name: 'new_list',
+ label: () => i18n._(/* i18n */ { id: 'New Recovery List' }),
+ type: 'json',
+ dappAllowed: true,
+ },
+ {
+ name: 'num_verifications_required',
+ label: () => i18n._(/* i18n */ { id: 'Verifications Required' }),
+ type: 'text',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ {
+ name: 'fee',
+ label: () => i18n._(/* i18n */ { id: 'Fee' }),
+ type: 'mojo-to-xch',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ {
+ name: 'reuse_puzhash',
+ label: () => i18n._(/* i18n */ { id: 'Reuse Puzzle Hash' }),
+ type: 'bool',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ ],
+ dapp: {
+ wcCommand: 'chia_updateDIDRecoveryIds',
+ label: () => i18n._(/* i18n */ { id: 'Update DID Recovery Ids' }),
+ },
+ },
+
+ 'chia_wallet.did_set_wallet_name': {
+ title: () => i18n._(/* i18n */ { id: 'Confirm Set DID Name' }),
+ message: () => i18n._(/* i18n */ { id: 'Please carefully review and confirm renaming this DID wallet.' }),
+ confirmLabel: () => i18n._(/* i18n */ { id: 'Set' }),
+ params: [
+ { name: 'wallet_id', label: () => i18n._(/* i18n */ { id: 'Wallet Id' }), type: 'text', dappAllowed: true },
+ { name: 'name', label: () => i18n._(/* i18n */ { id: 'Name' }), type: 'text', dappAllowed: true },
+ ],
+ dapp: {
+ wcCommand: 'chia_setDIDName',
+ label: () => i18n._(/* i18n */ { id: 'Set DID Name' }),
+ },
+ },
+
+ // ── VCs ────────────────────────────────────────────────────────────────────
+ 'chia_wallet.vc_spend': {
+ title: () => i18n._(/* i18n */ { id: 'Confirm VC Spend' }),
+ message: () => i18n._(/* i18n */ { id: 'Please carefully review and confirm this verifiable credential spend.' }),
+ confirmLabel: () => i18n._(/* i18n */ { id: 'Send' }),
+ params: [
+ { name: 'vc_id', label: () => i18n._(/* i18n */ { id: 'VC Id' }), type: 'text', dappAllowed: true },
+ {
+ name: 'new_puzhash',
+ label: () => i18n._(/* i18n */ { id: 'New Puzzle Hash' }),
+ type: 'text',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ {
+ name: 'new_proof_hash',
+ label: () => i18n._(/* i18n */ { id: 'New Proof Hash' }),
+ type: 'text',
+ dappAllowed: true,
+ },
+ {
+ name: 'provider_inner_puzhash',
+ label: () => i18n._(/* i18n */ { id: 'Provider Inner Puzzle Hash' }),
+ type: 'text',
+ dappAllowed: true,
+ },
+ {
+ name: 'fee',
+ label: () => i18n._(/* i18n */ { id: 'Fee' }),
+ type: 'mojo-to-xch',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ {
+ name: 'reuse_puzhash',
+ label: () => i18n._(/* i18n */ { id: 'Reuse Puzzle Hash' }),
+ type: 'bool',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ ],
+ dapp: {
+ wcCommand: 'chia_spendVC',
+ label: () => i18n._(/* i18n */ { id: 'Add Proofs To Verifiable Credential' }),
+ },
+ },
+
+ 'chia_wallet.vc_add_proofs': {
+ title: () => i18n._(/* i18n */ { id: 'Confirm Add VC Proofs' }),
+ message: () =>
+ i18n._(/* i18n */ { id: 'Please carefully review and confirm adding proofs to this verifiable credential.' }),
+ confirmLabel: () => i18n._(/* i18n */ { id: 'Add' }),
+ params: [{ name: 'proofs', label: () => i18n._(/* i18n */ { id: 'Proofs' }), type: 'json', dappAllowed: true }],
+ dapp: {
+ wcCommand: 'chia_addVCProofs',
+ label: () => i18n._(/* i18n */ { id: 'Add Proofs' }),
+ },
+ },
+
+ 'chia_wallet.vc_revoke': {
+ title: () => i18n._(/* i18n */ { id: 'Confirm Revoke VC' }),
+ message: () => i18n._(/* i18n */ { id: 'Are you sure you want to revoke this verifiable credential?' }),
+ confirmLabel: () => i18n._(/* i18n */ { id: 'Revoke' }),
+ destructive: true,
+ params: [
+ {
+ name: 'vc_parent_id',
+ label: () => i18n._(/* i18n */ { id: 'Parent Coin Id' }),
+ type: 'text',
+ dappAllowed: true,
+ },
+ { name: 'fee', label: () => i18n._(/* i18n */ { id: 'Fee' }), type: 'mojo-to-xch', dappAllowed: true },
+ ],
+ dapp: {
+ wcCommand: 'chia_revokeVC',
+ label: () => i18n._(/* i18n */ { id: 'Revoke Verifiable Credential' }),
+ },
+ },
+
+ // ── DataLayer (mutating) ───────────────────────────────────────────────────
+ 'chia_data_layer.create_data_store': {
+ title: () => i18n._(/* i18n */ { id: 'Confirm Create DataStore' }),
+ message: () => i18n._(/* i18n */ { id: 'Please carefully review and confirm creating this data store.' }),
+ confirmLabel: () => i18n._(/* i18n */ { id: 'Create' }),
+ params: [
+ {
+ name: 'fee',
+ label: () => i18n._(/* i18n */ { id: 'Fee' }),
+ type: 'mojo-to-xch',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ {
+ name: 'verbose',
+ label: () => i18n._(/* i18n */ { id: 'Verbose' }),
+ type: 'bool',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ ],
+ dapp: {
+ wcCommand: 'chia_createDataStore',
+ label: () => i18n._(/* i18n */ { id: 'Create DataStore' }),
+ },
+ },
+
+ 'chia_data_layer.batch_update': {
+ title: () => i18n._(/* i18n */ { id: 'Confirm DataStore Update' }),
+ message: () => i18n._(/* i18n */ { id: 'Please carefully review and confirm this data store update.' }),
+ confirmLabel: () => i18n._(/* i18n */ { id: 'Update' }),
+ params: [
+ { name: 'id', label: () => i18n._(/* i18n */ { id: 'Store Id' }), type: 'text', dappAllowed: true },
+ { name: 'changelist', label: () => i18n._(/* i18n */ { id: 'Changelist' }), type: 'json', dappAllowed: true },
+ {
+ name: 'fee',
+ label: () => i18n._(/* i18n */ { id: 'Fee' }),
+ type: 'mojo-to-xch',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ {
+ name: 'submit_on_chain',
+ label: () => i18n._(/* i18n */ { id: 'Submit On Chain' }),
+ type: 'bool',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ ],
+ dapp: {
+ wcCommand: 'chia_batchUpdate',
+ label: () => i18n._(/* i18n */ { id: 'Batch Update' }),
+ },
+ },
+
+ 'chia_data_layer.insert': {
+ title: () => i18n._(/* i18n */ { id: 'Confirm DataStore Insert' }),
+ message: () => i18n._(/* i18n */ { id: 'Please carefully review and confirm this data store insert.' }),
+ confirmLabel: () => i18n._(/* i18n */ { id: 'Insert' }),
+ params: [
+ { name: 'id', label: () => i18n._(/* i18n */ { id: 'Store Id' }), type: 'text', dappAllowed: true },
+ { name: 'key', label: () => i18n._(/* i18n */ { id: 'Key' }), type: 'text', dappAllowed: true },
+ { name: 'value', label: () => i18n._(/* i18n */ { id: 'Value' }), type: 'text', dappAllowed: true },
+ {
+ name: 'fee',
+ label: () => i18n._(/* i18n */ { id: 'Fee' }),
+ type: 'mojo-to-xch',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ ],
+ dapp: {
+ wcCommand: 'chia_insert',
+ label: () => i18n._(/* i18n */ { id: 'Insert' }),
+ },
+ },
+
+ 'chia_data_layer.delete_key': {
+ title: () => i18n._(/* i18n */ { id: 'Confirm DataStore Delete Key' }),
+ message: () => i18n._(/* i18n */ { id: 'Are you sure you want to delete this key from the data store?' }),
+ confirmLabel: () => i18n._(/* i18n */ { id: 'Delete' }),
+ destructive: true,
+ params: [
+ { name: 'id', label: () => i18n._(/* i18n */ { id: 'Store Id' }), type: 'text', dappAllowed: true },
+ { name: 'key', label: () => i18n._(/* i18n */ { id: 'Key' }), type: 'text', dappAllowed: true },
+ {
+ name: 'fee',
+ label: () => i18n._(/* i18n */ { id: 'Fee' }),
+ type: 'mojo-to-xch',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ ],
+ dapp: {
+ wcCommand: 'chia_deleteKey',
+ label: () => i18n._(/* i18n */ { id: 'Delete Key' }),
+ },
+ },
+
+ 'chia_data_layer.add_mirror': {
+ title: () => i18n._(/* i18n */ { id: 'Confirm Add Mirror' }),
+ message: () => i18n._(/* i18n */ { id: 'Please carefully review and confirm adding this mirror.' }),
+ confirmLabel: () => i18n._(/* i18n */ { id: 'Add' }),
+ params: [
+ { name: 'id', label: () => i18n._(/* i18n */ { id: 'Store Id' }), type: 'text', dappAllowed: true },
+ { name: 'urls', label: () => i18n._(/* i18n */ { id: 'URLs' }), type: 'json', dappAllowed: true },
+ { name: 'amount', label: () => i18n._(/* i18n */ { id: 'Amount' }), type: 'text', dappAllowed: true },
+ {
+ name: 'fee',
+ label: () => i18n._(/* i18n */ { id: 'Fee' }),
+ type: 'mojo-to-xch',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ ],
+ dapp: {
+ wcCommand: 'chia_addMirror',
+ label: () => i18n._(/* i18n */ { id: 'Add Mirror' }),
+ },
+ },
+
+ 'chia_data_layer.delete_mirror': {
+ title: () => i18n._(/* i18n */ { id: 'Confirm Delete Mirror' }),
+ message: () => i18n._(/* i18n */ { id: 'Are you sure you want to delete this mirror?' }),
+ confirmLabel: () => i18n._(/* i18n */ { id: 'Delete' }),
+ destructive: true,
+ params: [
+ { name: 'coin_id', label: () => i18n._(/* i18n */ { id: 'Coin Id' }), type: 'text', dappAllowed: true },
+ {
+ name: 'fee',
+ label: () => i18n._(/* i18n */ { id: 'Fee' }),
+ type: 'mojo-to-xch',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ ],
+ dapp: {
+ wcCommand: 'chia_deleteMirror',
+ label: () => i18n._(/* i18n */ { id: 'Delete Mirror' }),
+ },
+ },
+
+ 'chia_data_layer.subscribe': {
+ title: () => i18n._(/* i18n */ { id: 'Confirm DataStore Subscribe' }),
+ message: () => i18n._(/* i18n */ { id: 'Please review and confirm this subscription.' }),
+ confirmLabel: () => i18n._(/* i18n */ { id: 'Subscribe' }),
+ params: [
+ { name: 'id', label: () => i18n._(/* i18n */ { id: 'Store Id' }), type: 'text', dappAllowed: true },
+ { name: 'urls', label: () => i18n._(/* i18n */ { id: 'URLs' }), type: 'json', dappAllowed: true },
+ ],
+ dapp: {
+ wcCommand: 'chia_subscribe',
+ label: () => i18n._(/* i18n */ { id: 'Subscribe' }),
+ },
+ },
+
+ 'chia_data_layer.unsubscribe': {
+ title: () => i18n._(/* i18n */ { id: 'Confirm DataStore Unsubscribe' }),
+ message: () => i18n._(/* i18n */ { id: 'Please review and confirm this unsubscribe.' }),
+ confirmLabel: () => i18n._(/* i18n */ { id: 'Unsubscribe' }),
+ destructive: true,
+ params: [
+ { name: 'id', label: () => i18n._(/* i18n */ { id: 'Store Id' }), type: 'text', dappAllowed: true },
+ {
+ name: 'retain',
+ label: () => i18n._(/* i18n */ { id: 'Retain' }),
+ type: 'bool',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ ],
+ dapp: {
+ wcCommand: 'chia_unsubscribe',
+ label: () => i18n._(/* i18n */ { id: 'Unsubscribe' }),
+ },
+ },
+
+ 'chia_data_layer.remove_subscriptions': {
+ title: () => i18n._(/* i18n */ { id: 'Confirm Remove Subscriptions' }),
+ message: () => i18n._(/* i18n */ { id: 'Are you sure you want to remove these subscription URLs?' }),
+ confirmLabel: () => i18n._(/* i18n */ { id: 'Remove' }),
+ destructive: true,
+ params: [
+ { name: 'id', label: () => i18n._(/* i18n */ { id: 'Store Id' }), type: 'text', dappAllowed: true },
+ { name: 'urls', label: () => i18n._(/* i18n */ { id: 'URLs' }), type: 'json', dappAllowed: true },
+ ],
+ dapp: {
+ wcCommand: 'chia_removeSubscriptions',
+ label: () => i18n._(/* i18n */ { id: 'Remove Subscriptions' }),
+ },
+ },
+
+ 'chia_data_layer.add_missing_files': {
+ title: () => i18n._(/* i18n */ { id: 'Confirm Add Missing Files' }),
+ message: () => i18n._(/* i18n */ { id: 'Please review and confirm syncing missing files.' }),
+ confirmLabel: () => i18n._(/* i18n */ { id: 'Sync' }),
+ params: [
+ {
+ name: 'ids',
+ label: () => i18n._(/* i18n */ { id: 'Store Ids' }),
+ type: 'json',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ {
+ name: 'overwrite',
+ label: () => i18n._(/* i18n */ { id: 'Overwrite' }),
+ type: 'bool',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ {
+ name: 'foldername',
+ label: () => i18n._(/* i18n */ { id: 'Folder Name' }),
+ type: 'text',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ ],
+ dapp: {
+ wcCommand: 'chia_addMissingFiles',
+ label: () => i18n._(/* i18n */ { id: 'Add Missing Files' }),
+ },
+ },
+
+ 'chia_data_layer.check_plugins': {
+ title: () => i18n._(/* i18n */ { id: 'Confirm Check Plugins' }),
+ confirmLabel: () => i18n._(/* i18n */ { id: 'Check' }),
+ params: [],
+ dapp: {
+ wcCommand: 'chia_checkPlugins',
+ label: () => i18n._(/* i18n */ { id: 'Check Plugins' }),
+ },
+ },
+
+ 'chia_data_layer.clear_pending_roots': {
+ title: () => i18n._(/* i18n */ { id: 'Confirm Clear Pending Roots' }),
+ message: () => i18n._(/* i18n */ { id: 'Are you sure you want to clear pending roots for this store?' }),
+ confirmLabel: () => i18n._(/* i18n */ { id: 'Clear' }),
+ destructive: true,
+ params: [{ name: 'store_id', label: () => i18n._(/* i18n */ { id: 'Store Id' }), type: 'text', dappAllowed: true }],
+ dapp: {
+ wcCommand: 'chia_clearPendingRoots',
+ label: () => i18n._(/* i18n */ { id: 'Clear Pending Roots' }),
+ },
+ },
+
+ 'chia_data_layer.get_ancestors': {
+ title: () => i18n._(/* i18n */ { id: 'Confirm Get Ancestors' }),
+ message: () => i18n._(/* i18n */ { id: 'Please review and confirm this query.' }),
+ confirmLabel: () => i18n._(/* i18n */ { id: 'Query' }),
+ params: [
+ { name: 'id', label: () => i18n._(/* i18n */ { id: 'Store Id' }), type: 'text', dappAllowed: true },
+ { name: 'hash', label: () => i18n._(/* i18n */ { id: 'Hash' }), type: 'text', dappAllowed: true },
+ ],
+ dapp: {
+ wcCommand: 'chia_getAncestors',
+ label: () => i18n._(/* i18n */ { id: 'Get Ancestors' }),
+ },
+ },
+
+ 'chia_data_layer.subscriptions': {
+ title: () => i18n._(/* i18n */ { id: 'Confirm List Subscriptions' }),
+ message: () => i18n._(/* i18n */ { id: 'Please review and confirm this query.' }),
+ confirmLabel: () => i18n._(/* i18n */ { id: 'Query' }),
+ params: [],
+ dapp: {
+ wcCommand: 'chia_subscriptions',
+ label: () => i18n._(/* i18n */ { id: 'Subscriptions' }),
+ },
+ },
+
+ 'chia_data_layer.make_offer': {
+ title: () => i18n._(/* i18n */ { id: 'Confirm Make DataLayer Offer' }),
+ message: () => i18n._(/* i18n */ { id: 'Please carefully review and confirm this DataLayer offer.' }),
+ confirmLabel: () => i18n._(/* i18n */ { id: 'Create' }),
+ params: [
+ { name: 'maker', label: () => i18n._(/* i18n */ { id: 'Maker' }), type: 'json', dappAllowed: true },
+ { name: 'taker', label: () => i18n._(/* i18n */ { id: 'Taker' }), type: 'json', dappAllowed: true },
+ {
+ name: 'fee',
+ label: () => i18n._(/* i18n */ { id: 'Fee' }),
+ type: 'mojo-to-xch',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ ],
+ dapp: {
+ wcCommand: 'chia_makeDataLayerOffer',
+ label: () => i18n._(/* i18n */ { id: 'Make DataLayer Offer' }),
+ },
+ },
+
+ 'chia_data_layer.take_offer': {
+ title: () => i18n._(/* i18n */ { id: 'Confirm Take DataLayer Offer' }),
+ message: () => i18n._(/* i18n */ { id: 'Please carefully review and confirm taking this DataLayer offer.' }),
+ confirmLabel: () => i18n._(/* i18n */ { id: 'Accept' }),
+ params: [
+ { name: 'offer', label: () => i18n._(/* i18n */ { id: 'Offer' }), type: 'json', dappAllowed: true },
+ {
+ name: 'fee',
+ label: () => i18n._(/* i18n */ { id: 'Fee' }),
+ type: 'mojo-to-xch',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ ],
+ dapp: {
+ wcCommand: 'chia_takeDataLayerOffer',
+ label: () => i18n._(/* i18n */ { id: 'Take DataLayer Offer' }),
+ },
+ },
+
+ 'chia_data_layer.cancel_offer': {
+ title: () => i18n._(/* i18n */ { id: 'Confirm Cancel DataLayer Offer' }),
+ message: () => i18n._(/* i18n */ { id: 'Please carefully review and confirm cancelling this DataLayer offer.' }),
+ confirmLabel: () => i18n._(/* i18n */ { id: 'Cancel' }),
+ destructive: true,
+ params: [
+ { name: 'trade_id', label: () => i18n._(/* i18n */ { id: 'Trade Id' }), type: 'text', dappAllowed: true },
+ { name: 'secure', label: () => i18n._(/* i18n */ { id: 'Secure' }), type: 'bool', dappAllowed: true },
+ {
+ name: 'fee',
+ label: () => i18n._(/* i18n */ { id: 'Fee' }),
+ type: 'mojo-to-xch',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ ],
+ dapp: {
+ wcCommand: 'chia_cancelDataLayerOffer',
+ label: () => i18n._(/* i18n */ { id: 'Cancel DataLayer Offer' }),
+ },
+ },
+
+ 'chia_data_layer.verify_offer': {
+ title: () => i18n._(/* i18n */ { id: 'Confirm Verify DataLayer Offer' }),
+ message: () => i18n._(/* i18n */ { id: 'Please review and confirm this offer verification.' }),
+ confirmLabel: () => i18n._(/* i18n */ { id: 'Verify' }),
+ params: [
+ { name: 'offer', label: () => i18n._(/* i18n */ { id: 'Offer' }), type: 'json', dappAllowed: true },
+ {
+ name: 'fee',
+ label: () => i18n._(/* i18n */ { id: 'Fee' }),
+ type: 'mojo-to-xch',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ ],
+ dapp: {
+ wcCommand: 'chia_verifyOffer',
+ label: () => i18n._(/* i18n */ { id: 'Verify Offer' }),
+ },
+ },
+
+ // ── Remote wallets / coin tracking ─────────────────────────────────────────
+ 'chia_wallet.create_new_remote_wallet': {
+ title: () => i18n._(/* i18n */ { id: 'Confirm Create Remote Wallet' }),
+ message: () => i18n._(/* i18n */ { id: 'Please carefully review and confirm creating this remote wallet.' }),
+ confirmLabel: () => i18n._(/* i18n */ { id: 'Create' }),
+ params: [
+ {
+ name: 'allow_unsynced',
+ label: () => i18n._(/* i18n */ { id: 'Allow Unsynced' }),
+ type: 'bool',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ ],
+ dapp: {
+ wcCommand: 'chia_createNewRemoteWallet',
+ label: () => i18n._(/* i18n */ { id: 'Create new Remote Wallet' }),
+ handlerKey: 'createNewRemoteWallet',
+ },
+ },
+
+ 'chia_wallet.register_remote_coins': {
+ title: () => i18n._(/* i18n */ { id: 'Confirm Register Remote Coins' }),
+ message: () => i18n._(/* i18n */ { id: 'Please review and confirm registering these remote coins.' }),
+ confirmLabel: () => i18n._(/* i18n */ { id: 'Register' }),
+ params: [
+ { name: 'wallet_id', label: () => i18n._(/* i18n */ { id: 'Wallet Id' }), type: 'text', dappAllowed: true },
+ { name: 'coin_ids', label: () => i18n._(/* i18n */ { id: 'Coin Ids' }), type: 'json', dappAllowed: true },
+ ],
+ dapp: {
+ wcCommand: 'chia_registerRemoteCoins',
+ label: () => i18n._(/* i18n */ { id: 'Register Remote Coins' }),
+ description: () => i18n._(/* i18n */ { id: 'Registers a list of remote coin IDs with a remote wallet.' }),
+ },
+ },
+
+ // ── Misc with dialog ───────────────────────────────────────────────────────
+ 'chia_wallet.did_get_information_needed_for_recovery': {
+ title: () => i18n._(/* i18n */ { id: 'Confirm Get DID Recovery Information' }),
+ message: () => i18n._(/* i18n */ { id: 'Please review and confirm this query.' }),
+ confirmLabel: () => i18n._(/* i18n */ { id: 'Query' }),
+ params: [
+ { name: 'wallet_id', label: () => i18n._(/* i18n */ { id: 'Wallet Id' }), type: 'text', dappAllowed: true },
+ ],
+ dapp: {
+ wcCommand: 'chia_getDIDInformationNeededForRecovery',
+ label: () => i18n._(/* i18n */ { id: 'Get Information Needed For DID Recovery' }),
+ },
+ },
+
+ 'daemon.get_public_key': {
+ title: () => i18n._(/* i18n */ { id: 'Confirm Get Public Key' }),
+ message: () => i18n._(/* i18n */ { id: 'An app is requesting access to a wallet public key.' }),
+ confirmLabel: () => i18n._(/* i18n */ { id: 'Share' }),
+ params: [
+ { name: 'fingerprint', label: () => i18n._(/* i18n */ { id: 'Fingerprint' }), type: 'text', dappAllowed: true },
+ ],
+ dapp: {
+ wcCommand: 'chia_getPublicKey',
+ label: () => i18n._(/* i18n */ { id: 'Get public key' }),
+ description: () => i18n._(/* i18n */ { id: 'Requests a master public key from your wallet' }),
+ },
+ },
+
+ // ── Read-only stubs for dapp-callable RPCs without dialog UI ───────────────
+ // Reads short-circuit at the innocuous/balance capability check before the
+ // dialog renderer, so these never prompt. `params` still acts as the dapp
+ // allowlist.
+ 'chia_wallet.get_wallets': {
+ params: [
+ {
+ name: 'include_data',
+ label: () => i18n._(/* i18n */ { id: 'Include Wallet Metadata' }),
+ type: 'bool',
+ dappAllowed: true,
+ },
+ ],
+ dapp: {
+ wcCommand: 'chia_getWallets',
+ label: () => i18n._(/* i18n */ { id: 'Get Wallets' }),
+ description: () =>
+ i18n._(/* i18n */ { id: 'Requests a complete listing of the wallets associated with the current wallet key' }),
+ transformResponse: (data) => data.wallets ?? [],
+ },
+ },
+
+ 'chia_wallet.get_transaction': {
+ params: [
+ {
+ name: 'transaction_id',
+ label: () => i18n._(/* i18n */ { id: 'Transaction Id' }),
+ type: 'text',
+ dappAllowed: true,
+ },
+ ],
+ dapp: {
+ wcCommand: 'chia_getTransaction',
+ label: () => i18n._(/* i18n */ { id: 'Get Transaction' }),
+ description: () => i18n._(/* i18n */ { id: 'Requests details for a specific transaction' }),
+ transformResponse: (data) => data.transaction,
+ },
+ },
+
+ 'chia_wallet.get_wallet_balance': {
+ params: [
+ {
+ name: 'wallet_id',
+ label: () => i18n._(/* i18n */ { id: 'Wallet Id' }),
+ type: 'text',
+ isOptional: true,
+ hide: true,
+ dappAllowed: true,
+ },
+ ],
+ dapp: {
+ wcCommand: 'chia_getWalletBalance',
+ label: () => i18n._(/* i18n */ { id: 'Get Wallet Balance' }),
+ description: () =>
+ i18n._(
+ /* i18n */ { id: 'Requests the asset balance for a specific wallet associated with the current wallet key' },
+ ),
+ defaults: { wallet_id: 1 },
+ transformResponse: (data) => data.walletBalance,
+ },
+ },
+
+ 'chia_wallet.get_wallet_balances': {
+ params: [
+ {
+ name: 'wallet_ids',
+ label: () => i18n._(/* i18n */ { id: 'Wallet Ids' }),
+ type: 'json',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ ],
+ dapp: {
+ wcCommand: 'chia_getWalletBalances',
+ label: () => i18n._(/* i18n */ { id: 'Get Wallet Balances' }),
+ description: () =>
+ i18n._(
+ /* i18n */ {
+ id: 'Requests the asset balances for specific wallets associated with the current wallet key',
+ },
+ ),
+ transformResponse: (data) => data.walletBalances,
+ },
+ },
+
+ 'chia_wallet.get_coin_records_by_names': {
+ params: [
+ {
+ name: 'names',
+ label: () => i18n._(/* i18n */ { id: 'Names (coin ids)' }),
+ type: 'json',
+ dappAllowed: true,
+ },
+ {
+ name: 'start_height',
+ label: () => i18n._(/* i18n */ { id: 'Start Height' }),
+ type: 'text',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ {
+ name: 'end_height',
+ label: () => i18n._(/* i18n */ { id: 'End Height' }),
+ type: 'text',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ {
+ name: 'include_spent_coins',
+ label: () => i18n._(/* i18n */ { id: 'Include Spent Coins' }),
+ type: 'bool',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ {
+ name: 'allow_unsynced',
+ label: () => i18n._(/* i18n */ { id: 'Allow Unsynced' }),
+ type: 'bool',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ ],
+ dapp: {
+ wcCommand: 'chia_getCoinRecordsByNames',
+ label: () => i18n._(/* i18n */ { id: 'Get Coin Records by Name' }),
+ description: () =>
+ i18n._(/* i18n */ { id: "Requests the status of a list of coin records from the Wallet's coin store." }),
+ defaults: { include_spent_coins: true },
+ },
+ },
+
+ 'chia_wallet.select_coins': {
+ params: [
+ { name: 'wallet_id', label: () => i18n._(/* i18n */ { id: 'Wallet Id' }), type: 'text', dappAllowed: true },
+ { name: 'amount', label: () => i18n._(/* i18n */ { id: 'Amount' }), type: 'mojo-to-xch', dappAllowed: true },
+ {
+ name: 'min_coin_amount',
+ label: () => i18n._(/* i18n */ { id: 'Min Coin Amount' }),
+ type: 'mojo-to-xch',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ {
+ name: 'max_coin_amount',
+ label: () => i18n._(/* i18n */ { id: 'Max Coin Amount' }),
+ type: 'mojo-to-xch',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ {
+ name: 'excluded_coin_amounts',
+ label: () => i18n._(/* i18n */ { id: 'Excluded Coin Amounts' }),
+ type: 'json',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ {
+ name: 'excluded_coin_ids',
+ label: () => i18n._(/* i18n */ { id: 'Excluded Coin IDs' }),
+ type: 'json',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ {
+ name: 'allow_unsynced',
+ label: () => i18n._(/* i18n */ { id: 'Allow Unsynced' }),
+ type: 'bool',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ ],
+ dapp: {
+ wcCommand: 'chia_selectCoins',
+ label: () => i18n._(/* i18n */ { id: 'Select Coins' }),
+ description: () => i18n._(/* i18n */ { id: 'Selects coins to be spent from a specific wallet' }),
+ defaults: { wallet_id: 1 },
+ },
+ },
+
+ 'chia_wallet.get_spendable_coins': {
+ params: [
+ { name: 'wallet_id', label: () => i18n._(/* i18n */ { id: 'Wallet Id' }), type: 'text', dappAllowed: true },
+ {
+ name: 'min_coin_amount',
+ label: () => i18n._(/* i18n */ { id: 'Min Coin Amount' }),
+ type: 'mojo-to-xch',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ {
+ name: 'max_coin_amount',
+ label: () => i18n._(/* i18n */ { id: 'Max Coin Amount' }),
+ type: 'mojo-to-xch',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ {
+ name: 'excluded_coin_amounts',
+ label: () => i18n._(/* i18n */ { id: 'Excluded Coin Amounts' }),
+ type: 'json',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ {
+ name: 'excluded_coin_ids',
+ label: () => i18n._(/* i18n */ { id: 'Excluded Coin IDs' }),
+ type: 'json',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ ],
+ dapp: {
+ wcCommand: 'chia_getSpendableCoins',
+ label: () => i18n._(/* i18n */ { id: 'Get Spendable Coins' }),
+ description: () => i18n._(/* i18n */ { id: 'Requests spendable coins for a specific wallet' }),
+ requiresSync: true,
+ defaults: { wallet_id: 1 },
+ },
+ },
+
+ 'chia_wallet.verify_signature': {
+ params: [
+ { name: 'message', label: () => i18n._(/* i18n */ { id: 'Message' }), type: 'text', dappAllowed: true },
+ { name: 'pubkey', label: () => i18n._(/* i18n */ { id: 'Public Key' }), type: 'text', dappAllowed: true },
+ { name: 'signature', label: () => i18n._(/* i18n */ { id: 'Signature' }), type: 'text', dappAllowed: true },
+ {
+ name: 'address',
+ label: () => i18n._(/* i18n */ { id: 'Address' }),
+ type: 'text',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ {
+ name: 'signing_mode',
+ label: () => i18n._(/* i18n */ { id: 'Signing Mode' }),
+ type: 'text',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ ],
+ dapp: {
+ wcCommand: 'chia_verifySignature',
+ label: () => i18n._(/* i18n */ { id: 'Verify Signature' }),
+ description: () => i18n._(/* i18n */ { id: 'Requests the verification status for a digital signature' }),
+ },
+ },
+
+ 'chia_wallet.get_next_address': {
+ params: [
+ {
+ name: 'wallet_id',
+ label: () => i18n._(/* i18n */ { id: 'Wallet Id' }),
+ type: 'text',
+ isOptional: true,
+ hide: true,
+ dappAllowed: true,
+ },
+ {
+ name: 'new_address',
+ label: () => i18n._(/* i18n */ { id: 'New Address' }),
+ type: 'bool',
+ isOptional: true,
+ hide: true,
+ dappAllowed: true,
+ },
+ ],
+ dapp: {
+ wcCommand: 'chia_getNextAddress',
+ label: () => i18n._(/* i18n */ { id: 'Get Next Address' }),
+ description: () =>
+ i18n._(/* i18n */ { id: 'Requests a new receive address associated with the current wallet key' }),
+ defaults: { wallet_id: 1, new_address: true },
+ transformResponse: (data) => data.address,
+ // No daemon `get_current_address` RPC; alias routes to `get_next_address` with new_address=false.
+ aliases: [
+ {
+ wcCommand: 'chia_getCurrentAddress',
+ label: () => i18n._(/* i18n */ { id: 'Get Current Address' }),
+ description: () =>
+ i18n._(/* i18n */ { id: 'Requests the current receive address associated with the current wallet key' }),
+ defaults: { new_address: false },
+ },
+ ],
+ },
+ },
+
+ 'chia_wallet.get_sync_status': {
+ params: [],
+ dapp: {
+ wcCommand: 'chia_getSyncStatus',
+ label: () => i18n._(/* i18n */ { id: 'Get Wallet Sync Status' }),
+ description: () => i18n._(/* i18n */ { id: 'Requests the syncing status of current wallet' }),
+ },
+ },
+
+ 'chia_wallet.get_height_info': {
+ params: [
+ {
+ name: 'use_peak_height',
+ label: () => i18n._(/* i18n */ { id: 'Use peak height' }),
+ type: 'bool',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ ],
+ dapp: {
+ wcCommand: 'chia_getHeightInfo',
+ label: () => i18n._(/* i18n */ { id: 'Get Height Info' }),
+ description: () =>
+ i18n._(
+ /* i18n */ {
+ id: 'Returns wallet height, latest block timestamp, and related fields. Optional usePeakHeight uses the chain tip while syncing.',
+ },
+ ),
+ defaults: { use_peak_height: false },
+ // Match legacy api-react shape: surface only the height-related fields,
+ // null-fill the optional ones so dapps can rely on the keys existing.
+ transformResponse: (data) => ({
+ height: data.height,
+ latestTimestamp: data.latestTimestamp,
+ isTransactionBlock: data.isTransactionBlock ?? null,
+ prevTransactionBlockHeight: data.prevTransactionBlockHeight ?? null,
+ }),
+ },
+ },
+
+ 'chia_wallet.get_puzzle_and_solution': {
+ params: [
+ { name: 'coin_name', label: () => i18n._(/* i18n */ { id: 'Coin name' }), type: 'text', dappAllowed: true },
+ ],
+ dapp: {
+ wcCommand: 'chia_getPuzzleAndSolution',
+ label: () => i18n._(/* i18n */ { id: 'Get puzzle and solution' }),
+ description: () =>
+ i18n._(/* i18n */ { id: 'Fetches the puzzle reveal and solution for a spent coin (hex strings).' }),
+ },
+ },
+
+ 'chia_wallet.get_all_offers': {
+ params: [
+ {
+ name: 'start',
+ label: () => i18n._(/* i18n */ { id: 'Start' }),
+ type: 'text',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ {
+ name: 'end',
+ label: () => i18n._(/* i18n */ { id: 'End' }),
+ type: 'text',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ {
+ name: 'sort_key',
+ label: () => i18n._(/* i18n */ { id: 'Start Key' }),
+ type: 'text',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ {
+ name: 'reverse',
+ label: () => i18n._(/* i18n */ { id: 'Reverse' }),
+ type: 'bool',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ {
+ name: 'include_my_offers',
+ label: () => i18n._(/* i18n */ { id: 'Include My Offers' }),
+ type: 'bool',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ {
+ name: 'include_taken_offers',
+ label: () => i18n._(/* i18n */ { id: 'Include Taken Offers' }),
+ type: 'bool',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ ],
+ dapp: {
+ wcCommand: 'chia_getAllOffers',
+ label: () => i18n._(/* i18n */ { id: 'Get all Offers' }),
+ description: () =>
+ i18n._(/* i18n */ { id: 'Requests a complete listing of the offers associated with the current wallet key' }),
+ // Legacy `getAllOffers` zipped `tradeRecords` with `offers` (when
+ // present) under `_offerData` per record. Mirror that.
+ transformResponse: (data) => {
+ const tradeRecords = (data.tradeRecords as unknown[]) ?? [];
+ const offers = data.offers as unknown[] | undefined;
+ if (!offers) return tradeRecords;
+ return tradeRecords.map((record, i) => ({
+ ...(record as Record),
+ _offerData: offers[i],
+ }));
+ },
+ },
+ },
+
+ 'chia_wallet.get_offers_count': {
+ params: [],
+ dapp: {
+ wcCommand: 'chia_getOffersCount',
+ label: () => i18n._(/* i18n */ { id: 'Get Offers Count' }),
+ description: () =>
+ i18n._(/* i18n */ { id: 'Requests the number of offers associated with the current wallet key' }),
+ },
+ },
+
+ 'chia_wallet.check_offer_validity': {
+ params: [{ name: 'offer', label: () => i18n._(/* i18n */ { id: 'Offer Data' }), type: 'text', dappAllowed: true }],
+ dapp: {
+ wcCommand: 'chia_checkOfferValidity',
+ label: () => i18n._(/* i18n */ { id: 'Check Offer Validity' }),
+ description: () => i18n._(/* i18n */ { id: 'Requests the validity status of a specific offer' }),
+ },
+ },
+
+ 'chia_wallet.get_offer_summary': {
+ params: [
+ { name: 'offer_data', label: () => i18n._(/* i18n */ { id: 'Offer Data' }), type: 'text', dappAllowed: true },
+ ],
+ dapp: {
+ wcCommand: 'chia_getOfferSummary',
+ label: () => i18n._(/* i18n */ { id: 'Get Offer Summary' }),
+ description: () => i18n._(/* i18n */ { id: 'Requests the summarized details of a specific offer' }),
+ },
+ },
+
+ 'chia_wallet.get_offer_data': {
+ params: [{ name: 'offer_id', label: () => i18n._(/* i18n */ { id: 'Offer Id' }), type: 'text', dappAllowed: true }],
+ dapp: {
+ wcCommand: 'chia_getOfferData',
+ label: () => i18n._(/* i18n */ { id: 'Get Offer Data' }),
+ description: () => i18n._(/* i18n */ { id: 'Requests the raw offer data for a specific offer' }),
+ },
+ },
+
+ 'chia_wallet.get_offer_record': {
+ params: [{ name: 'offer_id', label: () => i18n._(/* i18n */ { id: 'Offer Id' }), type: 'text', dappAllowed: true }],
+ dapp: {
+ wcCommand: 'chia_getOfferRecord',
+ label: () => i18n._(/* i18n */ { id: 'Get Offer Record' }),
+ description: () => i18n._(/* i18n */ { id: 'Requests the details for a specific offer' }),
+ },
+ },
+
+ 'chia_wallet.cat_asset_id_to_name': {
+ params: [{ name: 'asset_id', label: () => i18n._(/* i18n */ { id: 'Asset Id' }), type: 'text', dappAllowed: true }],
+ dapp: {
+ wcCommand: 'chia_getCATWalletInfo',
+ label: () => i18n._(/* i18n */ { id: 'Get CAT Wallet Info' }),
+ },
+ },
+
+ 'chia_wallet.cat_get_asset_id': {
+ params: [
+ { name: 'wallet_id', label: () => i18n._(/* i18n */ { id: 'Wallet Id' }), type: 'text', dappAllowed: true },
+ ],
+ dapp: {
+ wcCommand: 'chia_getCATAssetId',
+ label: () => i18n._(/* i18n */ { id: 'Get CAT Asset Id' }),
+ description: () => i18n._(/* i18n */ { id: 'Requests the CAT asset ID for a specific CAT wallet' }),
+ transformResponse: (data) => data.assetId,
+ },
+ },
+
+ 'chia_wallet.nft_get_nfts': {
+ params: [
+ { name: 'wallet_ids', label: () => i18n._(/* i18n */ { id: 'Wallet Ids' }), type: 'json', dappAllowed: true },
+ {
+ name: 'num',
+ label: () => i18n._(/* i18n */ { id: 'Number of NFTs' }),
+ type: 'text',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ {
+ name: 'start_index',
+ label: () => i18n._(/* i18n */ { id: 'Start Index' }),
+ type: 'text',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ ],
+ dapp: {
+ wcCommand: 'chia_getNFTs',
+ label: () => i18n._(/* i18n */ { id: 'Get NFTs' }),
+ description: () =>
+ i18n._(
+ /* i18n */ {
+ id: 'Requests a full or paginated listing of NFTs associated with one or more wallets associated with the current wallet key',
+ },
+ ),
+ },
+ },
+
+ 'chia_wallet.nft_get_info': {
+ params: [{ name: 'coin_id', label: () => i18n._(/* i18n */ { id: 'Coin Id' }), type: 'text', dappAllowed: true }],
+ dapp: {
+ wcCommand: 'chia_getNFTInfo',
+ label: () => i18n._(/* i18n */ { id: 'Get NFT Info' }),
+ description: () => i18n._(/* i18n */ { id: 'Requests details for a specific NFT' }),
+ },
+ },
+
+ 'chia_wallet.nft_count_nfts': {
+ params: [
+ { name: 'wallet_ids', label: () => i18n._(/* i18n */ { id: 'Wallet Ids' }), type: 'json', dappAllowed: true },
+ ],
+ dapp: {
+ wcCommand: 'chia_getNFTsCount',
+ label: () => i18n._(/* i18n */ { id: 'Get NFTs Count' }),
+ description: () =>
+ i18n._(
+ /* i18n */ {
+ id: 'Requests the number of NFTs associated with one or more wallets associated with the current wallet key',
+ },
+ ),
+ },
+ },
+
+ 'chia_wallet.nft_get_wallets_with_dids': {
+ params: [],
+ dapp: {
+ wcCommand: 'chia_getNFTWalletsWithDIDs',
+ label: () => i18n._(/* i18n */ { id: 'Get NFT Wallets with DIDs' }),
+ transformResponse: (data) => data.nftWallets,
+ },
+ },
+
+ 'chia_wallet.did_get_current_coin_info': {
+ params: [
+ { name: 'wallet_id', label: () => i18n._(/* i18n */ { id: 'Wallet Id' }), type: 'text', dappAllowed: true },
+ ],
+ dapp: {
+ wcCommand: 'chia_getDIDCurrentCoinInfo',
+ label: () => i18n._(/* i18n */ { id: 'Get DID Current Coin Info' }),
+ },
+ },
+
+ 'chia_wallet.did_get_did': {
+ params: [
+ { name: 'wallet_id', label: () => i18n._(/* i18n */ { id: 'Wallet Id' }), type: 'text', dappAllowed: true },
+ ],
+ dapp: {
+ wcCommand: 'chia_getDID',
+ label: () => i18n._(/* i18n */ { id: 'Get DID' }),
+ },
+ },
+
+ 'chia_wallet.did_get_info': {
+ params: [{ name: 'coin_id', label: () => i18n._(/* i18n */ { id: 'Coin Id' }), type: 'text', dappAllowed: true }],
+ dapp: {
+ wcCommand: 'chia_getDIDInfo',
+ label: () => i18n._(/* i18n */ { id: 'Get DID Info' }),
+ },
+ },
+
+ 'chia_wallet.did_get_metadata': {
+ params: [
+ { name: 'wallet_id', label: () => i18n._(/* i18n */ { id: 'Wallet Id' }), type: 'text', dappAllowed: true },
+ ],
+ dapp: {
+ wcCommand: 'chia_getDIDMetadata',
+ label: () => i18n._(/* i18n */ { id: 'Get DID Metadata' }),
+ },
+ },
+
+ 'chia_wallet.did_get_pubkey': {
+ params: [
+ { name: 'wallet_id', label: () => i18n._(/* i18n */ { id: 'Wallet Id' }), type: 'text', dappAllowed: true },
+ ],
+ dapp: {
+ wcCommand: 'chia_getDIDPubkey',
+ label: () => i18n._(/* i18n */ { id: 'Get DID Public Key' }),
+ },
+ },
+
+ 'chia_wallet.did_get_recovery_list': {
+ params: [
+ { name: 'wallet_id', label: () => i18n._(/* i18n */ { id: 'Wallet Id' }), type: 'text', dappAllowed: true },
+ ],
+ dapp: {
+ wcCommand: 'chia_getDIDRecoveryList',
+ label: () => i18n._(/* i18n */ { id: 'Get DID Recovery List' }),
+ },
+ },
+
+ 'chia_wallet.did_get_wallet_name': {
+ params: [
+ { name: 'wallet_id', label: () => i18n._(/* i18n */ { id: 'Wallet Id' }), type: 'text', dappAllowed: true },
+ ],
+ dapp: {
+ wcCommand: 'chia_getDIDName',
+ label: () => i18n._(/* i18n */ { id: 'Get DID Name' }),
+ },
+ },
+
+ 'chia_wallet.vc_get_list': {
+ params: [],
+ dapp: {
+ wcCommand: 'chia_getVCList',
+ label: () => i18n._(/* i18n */ { id: 'Get All Verifiable Credentials' }),
+ },
+ },
+
+ 'chia_wallet.vc_get': {
+ params: [{ name: 'vc_id', label: () => i18n._(/* i18n */ { id: 'Launcher Id' }), type: 'text', dappAllowed: true }],
+ dapp: {
+ wcCommand: 'chia_getVC',
+ label: () => i18n._(/* i18n */ { id: 'Get Verifiable Credential' }),
+ transformResponse: (data) => data.vcRecord,
+ },
+ },
+
+ 'chia_wallet.vc_get_proofs_for_root': {
+ params: [{ name: 'root', label: () => i18n._(/* i18n */ { id: 'Proofs Hash' }), type: 'text', dappAllowed: true }],
+ dapp: {
+ wcCommand: 'chia_getProofsForRoot',
+ label: () => i18n._(/* i18n */ { id: 'Get Proofs For Root Hash' }),
+ },
+ },
+
+ 'chia_data_layer.get_keys': {
+ params: [
+ { name: 'id', label: () => i18n._(/* i18n */ { id: 'Store Id' }), type: 'text', dappAllowed: true },
+ {
+ name: 'root_hash',
+ label: () => i18n._(/* i18n */ { id: 'Root Hash' }),
+ type: 'text',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ {
+ name: 'page',
+ label: () => i18n._(/* i18n */ { id: 'Page' }),
+ type: 'text',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ {
+ name: 'max_page_size',
+ label: () => i18n._(/* i18n */ { id: 'Max page size' }),
+ type: 'text',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ ],
+ dapp: {
+ wcCommand: 'chia_getKeys',
+ label: () => i18n._(/* i18n */ { id: 'Get Keys' }),
+ },
+ },
+
+ 'chia_data_layer.get_keys_values': {
+ params: [
+ { name: 'id', label: () => i18n._(/* i18n */ { id: 'Store Id' }), type: 'text', dappAllowed: true },
+ {
+ name: 'root_hash',
+ label: () => i18n._(/* i18n */ { id: 'Root Hash' }),
+ type: 'text',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ {
+ name: 'page',
+ label: () => i18n._(/* i18n */ { id: 'Page' }),
+ type: 'text',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ {
+ name: 'max_page_size',
+ label: () => i18n._(/* i18n */ { id: 'Max page size' }),
+ type: 'text',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ ],
+ dapp: {
+ wcCommand: 'chia_getKeysValues',
+ label: () => i18n._(/* i18n */ { id: 'Get Keys Values' }),
+ },
+ },
+
+ 'chia_data_layer.get_kv_diff': {
+ params: [
+ { name: 'id', label: () => i18n._(/* i18n */ { id: 'Store Id' }), type: 'text', dappAllowed: true },
+ { name: 'hash_1', label: () => i18n._(/* i18n */ { id: 'Hash 1' }), type: 'text', dappAllowed: true },
+ { name: 'hash_2', label: () => i18n._(/* i18n */ { id: 'Hash 2' }), type: 'text', dappAllowed: true },
+ {
+ name: 'page',
+ label: () => i18n._(/* i18n */ { id: 'Page' }),
+ type: 'text',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ {
+ name: 'max_page_size',
+ label: () => i18n._(/* i18n */ { id: 'Max page size' }),
+ type: 'text',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ ],
+ dapp: {
+ wcCommand: 'chia_getKvDiff',
+ label: () => i18n._(/* i18n */ { id: 'Get Kv Diff' }),
+ },
+ },
+
+ 'chia_data_layer.get_local_root': {
+ params: [{ name: 'id', label: () => i18n._(/* i18n */ { id: 'Store Id' }), type: 'text', dappAllowed: true }],
+ dapp: {
+ wcCommand: 'chia_getLocalRoot',
+ label: () => i18n._(/* i18n */ { id: 'Get Local Root' }),
+ },
+ },
+
+ 'chia_data_layer.get_mirrors': {
+ params: [{ name: 'id', label: () => i18n._(/* i18n */ { id: 'Store Id' }), type: 'text', dappAllowed: true }],
+ dapp: {
+ wcCommand: 'chia_getMirrors',
+ label: () => i18n._(/* i18n */ { id: 'Get Mirrors' }),
+ },
+ },
+
+ 'chia_data_layer.get_owned_stores': {
+ params: [],
+ dapp: {
+ wcCommand: 'chia_getOwnedStores',
+ label: () => i18n._(/* i18n */ { id: 'Get Owned Stores' }),
+ },
+ },
+
+ 'chia_data_layer.get_root': {
+ params: [{ name: 'id', label: () => i18n._(/* i18n */ { id: 'Store Id' }), type: 'text', dappAllowed: true }],
+ dapp: {
+ wcCommand: 'chia_getRoot',
+ label: () => i18n._(/* i18n */ { id: 'Get Root' }),
+ },
+ },
+
+ 'chia_data_layer.get_roots': {
+ params: [{ name: 'ids', label: () => i18n._(/* i18n */ { id: 'Store Ids' }), type: 'json', dappAllowed: true }],
+ dapp: {
+ wcCommand: 'chia_getRoots',
+ label: () => i18n._(/* i18n */ { id: 'Get Roots' }),
+ },
+ },
+
+ 'chia_data_layer.get_root_history': {
+ params: [{ name: 'id', label: () => i18n._(/* i18n */ { id: 'Store Id' }), type: 'text', dappAllowed: true }],
+ dapp: {
+ wcCommand: 'chia_getRootHistory',
+ label: () => i18n._(/* i18n */ { id: 'Get Root History' }),
+ },
+ },
+
+ 'chia_data_layer.get_sync_status': {
+ params: [{ name: 'id', label: () => i18n._(/* i18n */ { id: 'Store Id' }), type: 'text', dappAllowed: true }],
+ dapp: {
+ wcCommand: 'chia_getDataLayerSyncStatus',
+ label: () => i18n._(/* i18n */ { id: 'Get DataLayer Sync Status' }),
+ },
+ },
+
+ 'chia_data_layer.get_value': {
+ params: [
+ { name: 'id', label: () => i18n._(/* i18n */ { id: 'Store Id' }), type: 'text', dappAllowed: true },
+ { name: 'key', label: () => i18n._(/* i18n */ { id: 'Key' }), type: 'text', dappAllowed: true },
+ {
+ name: 'root_hash',
+ label: () => i18n._(/* i18n */ { id: 'Root Hash' }),
+ type: 'text',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ ],
+ dapp: {
+ wcCommand: 'chia_getValue',
+ label: () => i18n._(/* i18n */ { id: 'Get Value' }),
+ },
+ },
+
+ 'daemon.get_wallet_addresses': {
+ params: [
+ {
+ name: 'fingerprints',
+ label: () => i18n._(/* i18n */ { id: 'Fingerprints' }),
+ type: 'json',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ {
+ name: 'index',
+ label: () => i18n._(/* i18n */ { id: 'Index' }),
+ type: 'text',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ {
+ name: 'count',
+ label: () => i18n._(/* i18n */ { id: 'Count' }),
+ type: 'text',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ {
+ name: 'non_observer_derivation',
+ label: () => i18n._(/* i18n */ { id: 'Non Observer Derivation' }),
+ type: 'bool',
+ isOptional: true,
+ dappAllowed: true,
+ },
+ ],
+ dapp: {
+ wcCommand: 'chia_getWalletAddresses',
+ label: () => i18n._(/* i18n */ { id: 'Get wallet addresses for one or more wallet keys' }),
+ transformResponse: (data) => data.walletAddresses,
+ },
+ },
+};
+
+// Reverse index: wcCommand → ns + per-wc effective fields. Duplicate
+// `wcCommand` values throw at startup.
+export type WcEntry = {
+ nsCommand: string;
+ schema: CommandSchema;
+ /** schema.dapp.defaults + alias overrides (alias wins). */
+ defaults?: Record;
+ label?: () => string;
+ description?: () => string;
+ requiresSync: boolean;
+ handlerKey?: string;
+};
+
+function mergeDefaults(
+ base?: Record,
+ override?: Record,
+): Record | undefined {
+ if (!base && !override) return undefined;
+ return { ...(base ?? {}), ...(override ?? {}) };
+}
+
+const BY_WC_COMMAND = (() => {
+ const map = new Map();
+ const register = (wcCommand: string, entry: WcEntry) => {
+ const existing = map.get(wcCommand);
+ if (existing) {
+ throw new Error(
+ `commandRegistry: duplicate wcCommand "${wcCommand}" on ${entry.nsCommand} and ${existing.nsCommand}`,
+ );
+ }
+ map.set(wcCommand, entry);
+ };
+ for (const [nsCommand, schema] of Object.entries(SCHEMAS)) {
+ if (schema.dapp) {
+ register(schema.dapp.wcCommand, {
+ nsCommand,
+ schema,
+ defaults: schema.dapp.defaults,
+ label: schema.dapp.label,
+ description: schema.dapp.description,
+ requiresSync: schema.dapp.requiresSync === true,
+ handlerKey: schema.dapp.handlerKey,
+ });
+ for (const alias of schema.dapp.aliases ?? []) {
+ register(alias.wcCommand, {
+ nsCommand,
+ schema,
+ defaults: mergeDefaults(schema.dapp.defaults, alias.defaults),
+ label: alias.label ?? schema.dapp.label,
+ description: alias.description ?? schema.dapp.description,
+ requiresSync: alias.requiresSync ?? schema.dapp.requiresSync === true,
+ handlerKey: schema.dapp.handlerKey,
+ });
+ }
+ }
+ }
+ return map;
+})();
+
+export function getCommandSchema(nsCommand: string): CommandSchema {
+ return SCHEMAS[nsCommand] ?? FALLBACK;
+}
+
+export function getCommandByWc(wcCommand: string): WcEntry | undefined {
+ return BY_WC_COMMAND.get(wcCommand);
+}
+
+export function isDappAllowedWcCommand(wcCommand: string): boolean {
+ return BY_WC_COMMAND.has(wcCommand);
+}
+
+// Renderer never supplies a destination — main resolves it from the registry
+// so a dapp can't claim services it wasn't granted. Handler-routed commands
+// throw here; callers must check `entry.handlerKey` first.
+export function resolveDispatch(wcCommand: string): { destination: string; command: string; nsCommand: string } {
+ const entry = BY_WC_COMMAND.get(wcCommand);
+ if (!entry) {
+ throw new WcError(`unknown wc command: ${wcCommand}`, WcErrorCode.METHOD_NOT_FOUND);
+ }
+ const { nsCommand } = entry;
+ if (nsCommand.startsWith(`${RENDERER_NAMESPACE}.`)) {
+ throw new WcError(`wc command not dispatchable: ${wcCommand}`, WcErrorCode.METHOD_NOT_FOUND);
+ }
+ const dotIdx = nsCommand.indexOf('.');
+ if (dotIdx < 0) {
+ throw new WcError(`malformed schema key: ${nsCommand}`, WcErrorCode.INTERNAL_ERROR);
+ }
+ return {
+ destination: nsCommand.slice(0, dotIdx),
+ command: nsCommand.slice(dotIdx + 1),
+ nsCommand,
+ };
+}
+
+// Allowlist check: throws if a key isn't in `params` or doesn't have
+// `dappAllowed: true`. Fails closed.
+export function validateDappParams(wcCommand: string, data: Record): void {
+ const entry = BY_WC_COMMAND.get(wcCommand);
+ if (!entry) {
+ throw new WcError(`unknown wc command: ${wcCommand}`, WcErrorCode.METHOD_NOT_FOUND);
+ }
+ const allowed = new Map();
+ for (const p of entry.schema.params) allowed.set(p.name, p);
+ for (const key of Object.keys(data)) {
+ const param = allowed.get(key);
+ if (!param) {
+ throw new WcError(`param not allowed for dapp: ${key}`, WcErrorCode.INVALID_PARAMS);
+ }
+ if (param.dappAllowed !== true) {
+ throw new WcError(`param is UI-only: ${key}`, WcErrorCode.INVALID_PARAMS);
+ }
+ }
+}
+
+// IPC boundary — the `string[]` annotation is a suggestion, not a guarantee.
+export function filterRequestedCommands(requestedCommands: unknown): {
+ allowed: string[];
+ rejected: string[];
+} {
+ const allowed: string[] = [];
+ const rejected: string[] = [];
+ if (!Array.isArray(requestedCommands)) {
+ return { allowed, rejected };
+ }
+ const seen = new Set();
+ for (const command of requestedCommands) {
+ if (typeof command === 'string' && command && !seen.has(command)) {
+ seen.add(command);
+ if (BY_WC_COMMAND.has(command)) {
+ allowed.push(command);
+ } else {
+ rejected.push(command);
+ }
+ }
+ }
+ return { allowed, rejected };
+}
+
+export function bareWcCommand(wcCommand: string): string {
+ return wcCommand.startsWith('chia_') ? wcCommand.slice('chia_'.length) : wcCommand;
+}
+
+export type CommandMetadata = {
+ wcCommand: string;
+ label?: string;
+ description?: string;
+ requiresSync: boolean;
+};
+
+// Re-resolves locale strings on every call so locale switches propagate.
+export function commandsMetadata(): CommandMetadata[] {
+ const out: CommandMetadata[] = [];
+ for (const [wcCommand, entry] of BY_WC_COMMAND) {
+ out.push({
+ wcCommand,
+ label: entry.label?.(),
+ description: entry.description?.(),
+ requiresSync: entry.requiresSync,
+ });
+ }
+ return out;
+}
+
+// Keyed by wcCommand (not nsCommand) so aliases pin different defaults.
+export function applyDefaults(wcCommand: string, snakeData: Record): Record {
+ const entry = BY_WC_COMMAND.get(wcCommand);
+ if (!entry?.defaults) return snakeData;
+ const next = { ...snakeData };
+ for (const [key, value] of Object.entries(entry.defaults)) {
+ if (next[key] === undefined) {
+ next[key] = value;
+ }
+ }
+ return next;
+}
+
+/** For tests iterating the full table. */
+export const SCHEMA_COMMANDS: readonly string[] = Object.keys(SCHEMAS);
diff --git a/packages/gui/src/electron/dialogs/About/About.tsx b/packages/gui/src/electron/dialogs/About/About.tsx
index c67e6a5486..63e7ee94df 100644
--- a/packages/gui/src/electron/dialogs/About/About.tsx
+++ b/packages/gui/src/electron/dialogs/About/About.tsx
@@ -1,10 +1,11 @@
import React from 'react';
-import icon from '../../../assets/img/chia_circle.svg';
import { i18n } from '../../../config/locales';
+import { resolveThemeCircleIcon } from '../../../theme/themeCircleIcons';
export type AboutProps = {
version: string;
+ themeVariant?: unknown;
packageJson: {
productName: string;
description: string;
@@ -17,9 +18,11 @@ export type AboutProps = {
export default function About(props: AboutProps) {
const {
version,
+ themeVariant,
packageJson: { productName, description },
versions,
} = props;
+ const icon = resolveThemeCircleIcon(themeVariant);
const currentYear = new Date().getFullYear();
@@ -27,7 +30,7 @@ export default function About(props: AboutProps) {
-

+
diff --git a/packages/gui/src/electron/dialogs/Confirm/renderConfirm.test.ts b/packages/gui/src/electron/dialogs/Confirm/renderConfirm.test.ts
new file mode 100644
index 0000000000..1b9c304751
--- /dev/null
+++ b/packages/gui/src/electron/dialogs/Confirm/renderConfirm.test.ts
@@ -0,0 +1,349 @@
+// dappEnrichment hits the WebSocket bridge for offer/CAT lookups, so we
+// stub the public helpers. The real implementation has try/catch fallbacks
+// returning undefined when the bridge is unavailable, but explicit mocks
+// let us pin enrichment-driven behavior (like the fee-dedup) deterministically.
+jest.mock('../../utils/dappEnrichment', () => {
+ const actual = jest.requireActual('../../utils/dappEnrichment');
+ return {
+ ...actual,
+ lookupCat: jest.fn(async () => undefined),
+ buildCreateOfferDisplay: jest.fn(async () => undefined),
+ buildTakeOfferDisplay: jest.fn(async () => undefined),
+ };
+});
+
+import { SCHEMA_COMMANDS, getCommandSchema } from '../../constants/commandRegistry';
+import { buildCreateOfferDisplay, buildTakeOfferDisplay } from '../../utils/dappEnrichment';
+
+import { renderConfirm } from './renderConfirm';
+
+const mockBuildCreateOfferDisplay = buildCreateOfferDisplay as jest.MockedFunction;
+const mockBuildTakeOfferDisplay = buildTakeOfferDisplay as jest.MockedFunction;
+
+describe('renderConfirm', () => {
+ it('renders send_transaction with amount + fee + address in network units', async () => {
+ const result = await renderConfirm(
+ 'chia_wallet.send_transaction',
+ { address: 'txch1abc', amount: '1000000000000', fee: '500000000000' },
+ { networkPrefix: 'txch' },
+ );
+ expect(result.title).toBe('Confirm Send Transaction');
+ expect(result.confirmLabel).toBe('Send');
+ expect(result.destructive).toBe(false);
+ expect(result.rows).toEqual([
+ { field: 'amount', label: 'Amount', value: '1 TXCH' },
+ { field: 'fee', label: 'Fee', value: '0.5 TXCH' },
+ { field: 'address', label: 'Address', value: 'txch1abc' },
+ ]);
+ });
+
+ it('drops rows whose schema param has `hide: true` even when data supplies a value', async () => {
+ const result = await renderConfirm(
+ 'chia_wallet.send_transaction',
+ {
+ amount: '1000000000000',
+ fee: '0',
+ address: 'txch1abc',
+ wallet_id: 7,
+ memos: ['note'],
+ },
+ { networkPrefix: 'txch' },
+ );
+ const fields = result.rows.map((r) => r.field);
+ expect(fields).not.toContain('wallet_id');
+ expect(fields).not.toContain('memos');
+ });
+
+ it('skips rows whose data field is undefined / null / empty string', async () => {
+ const result = await renderConfirm(
+ 'chia_wallet.send_transaction',
+ { address: 'txch1abc' /* amount and fee absent */ },
+ { networkPrefix: 'xch' },
+ );
+ expect(result.rows.map((r) => r.field)).toEqual(['address']);
+ });
+
+ it('renders bool kind as Yes/No', async () => {
+ const result = await renderConfirm(
+ 'chia_wallet.set_auto_claim',
+ { enabled: true, tx_fee: '1', min_amount: '0' },
+ { networkPrefix: 'xch' },
+ );
+ const enabled = result.rows.find((r) => r.field === 'enabled');
+ expect(enabled?.value).toBe('Yes');
+
+ const result2 = await renderConfirm(
+ 'chia_wallet.set_auto_claim',
+ { enabled: false, tx_fee: '1', min_amount: '0' },
+ { networkPrefix: 'xch' },
+ );
+ const enabled2 = result2.rows.find((r) => r.field === 'enabled');
+ expect(enabled2?.value).toBe('No');
+ });
+
+ it('marks delete_key destructive and uses the Delete button label', async () => {
+ const result = await renderConfirm('chia_wallet.delete_key', { fingerprint: '1234567890' }, {});
+ expect(result.destructive).toBe(true);
+ expect(result.confirmLabel).toBe('Delete');
+ expect(result.rows).toEqual([{ field: 'fingerprint', label: 'Fingerprint', value: '1234567890' }]);
+ });
+
+ it('falls back to default schema for unknown commands', async () => {
+ const result = await renderConfirm('totally.unknown_command', { foo: 'bar' }, {});
+ expect(result.title).toBe('Confirm');
+ expect(result.message).toBe('Please review and confirm this action.');
+ expect(result.confirmLabel).toBe('Proceed');
+ expect(result.destructive).toBe(false);
+ expect(result.rows).toEqual([]);
+ });
+
+ it('renders sign_message_by_address with the message body', async () => {
+ const result = await renderConfirm(
+ 'chia_wallet.sign_message_by_address',
+ { address: 'txch1abc', message: 'hello world' },
+ {},
+ );
+ expect(result.title).toBe('Confirm Sign Message');
+ expect(result.confirmLabel).toBe('Sign');
+ expect(result.rows).toEqual([
+ { field: 'address', label: 'Address', value: 'txch1abc' },
+ { field: 'message', label: 'Message', value: 'hello world' },
+ ]);
+ });
+
+ it('renders open_connection with host + port as text', async () => {
+ const result = await renderConfirm('chia_full_node.open_connection', { host: 'node.example.com', port: 8444 }, {});
+ expect(result.confirmLabel).toBe('Connect');
+ expect(result.rows).toEqual([
+ { field: 'host', label: 'Host', value: 'node.example.com' },
+ { field: 'port', label: 'Port', value: '8444' },
+ ]);
+ });
+
+ it('renders close_connection as destructive Disconnect with no rows', async () => {
+ const result = await renderConfirm('chia_full_node.close_connection', {}, {});
+ expect(result.destructive).toBe(true);
+ expect(result.confirmLabel).toBe('Disconnect');
+ expect(result.rows).toEqual([]);
+ });
+
+ it('renders cancel_offer as destructive with fee in XCH', async () => {
+ const result = await renderConfirm('chia_wallet.cancel_offer', { fee: '100000000000' }, { networkPrefix: 'xch' });
+ expect(result.destructive).toBe(true);
+ expect(result.rows).toEqual([{ field: 'fee', label: 'Fee', value: '0.1 XCH' }]);
+ });
+
+ it('renders nft_transfer_nft with target_address + fee', async () => {
+ const result = await renderConfirm(
+ 'chia_wallet.nft_transfer_nft',
+ { target_address: 'txch1xyz', fee: '50000000000' },
+ { networkPrefix: 'xch' },
+ );
+ expect(result.title).toBe('Confirm NFT Transfer');
+ expect(result.confirmLabel).toBe('Transfer');
+ expect(result.rows).toEqual([
+ { field: 'target_address', label: 'Target Address', value: 'txch1xyz' },
+ { field: 'fee', label: 'Fee', value: '0.05 XCH' },
+ ]);
+ });
+
+ it('returns an empty display when the schema declares no enrich hook', async () => {
+ const result = await renderConfirm(
+ 'chia_wallet.send_transaction',
+ { address: 'txch1abc', amount: '1', fee: '0' },
+ {},
+ );
+ expect(result.display).toEqual({});
+ });
+
+ it('cat_spend reads `address` directly (no rename of dapp payload)', async () => {
+ const result = await renderConfirm(
+ 'chia_wallet.cat_spend',
+ // Dapp sends the WC param `address`, not `inner_address`. Main shows
+ // exactly what the dapp sent on the wire — no renaming.
+ { wallet_id: 1, address: 'txch1abc', amount: '100', fee: '0' },
+ { networkPrefix: 'xch' },
+ );
+ const row = result.rows.find((r) => r.field === 'address');
+ expect(row?.value).toBe('txch1abc');
+ expect(result.rows.find((r) => r.field === 'inner_address')).toBeUndefined();
+ });
+
+ it('nft_set_nft_did reads `did` directly (no rename to did_id)', async () => {
+ const result = await renderConfirm(
+ 'chia_wallet.nft_set_nft_did',
+ { wallet_id: 1, did: 'did:chia:abc', fee: '0' },
+ {},
+ );
+ const row = result.rows.find((r) => r.field === 'did');
+ expect(row?.value).toBe('did:chia:abc');
+ expect(result.rows.find((r) => r.field === 'did_id')).toBeUndefined();
+ });
+
+ it('json kind pretty-prints an object', async () => {
+ const result = await renderConfirm('chia_wallet.spend_clawback_coins', { coin_ids: ['0x1', '0x2'], fee: '0' }, {});
+ const row = result.rows.find((r) => r.field === 'coin_ids');
+ expect(row?.value).toBe('[\n "0x1",\n "0x2"\n]');
+ });
+
+ it('renders show_notification announcement with message + url', async () => {
+ const result = await renderConfirm(
+ 'chia_app.show_notification',
+ { type: 'announcement', message: 'Hello world', url: 'https://example.com' },
+ {},
+ );
+ expect(result.title).toBe('Confirm Notification');
+ expect(result.confirmLabel).toBe('Show');
+ expect(result.rows).toEqual([
+ { field: 'type', label: 'Type', value: 'announcement' },
+ { field: 'message', label: 'Message', value: 'Hello world' },
+ { field: 'url', label: 'URL', value: 'https://example.com' },
+ ]);
+ // No offer enrichment — type is announcement, not offer.
+ expect(result.display).toEqual({});
+ });
+
+ it('renders show_notification announcement with all_fingerprints flag', async () => {
+ const result = await renderConfirm(
+ 'chia_app.show_notification',
+ { type: 'announcement', message: 'Hi', all_fingerprints: true },
+ {},
+ );
+ const all = result.rows.find((r) => r.field === 'all_fingerprints');
+ expect(all?.value).toBe('Yes');
+ });
+
+ it('show_notification with no offer_data skips offer enrichment', async () => {
+ // enrich short-circuits before `buildTakeOfferDisplay` runs.
+ const result = await renderConfirm('chia_app.show_notification', { type: 'offer' }, {});
+ expect(result.display).toEqual({});
+ });
+
+ it('sign_message_by_address surfaces is_hex and safe_mode bool rows', async () => {
+ const result = await renderConfirm(
+ 'chia_wallet.sign_message_by_address',
+ { address: 'txch1', message: 'hi', is_hex: false, safe_mode: false },
+ {},
+ );
+ const fields = result.rows.map((r) => r.field);
+ expect(fields).toContain('is_hex');
+ expect(fields).toContain('safe_mode');
+ expect(result.rows.find((r) => r.field === 'safe_mode')?.value).toBe('No');
+ });
+});
+
+// Walk every schema and assert basic invariants. This catches mistakes where
+// a schema forgets a label/title/confirmLabel, references a kind that the
+// renderer doesn't handle, or declares a param whose name conflicts with how
+// the daemon would receive the field.
+describe('renderConfirm — every schema', () => {
+ it.each(SCHEMA_COMMANDS)('renders %s with empty data without throwing', async (command) => {
+ const result = await renderConfirm(command, {}, { networkPrefix: 'xch' });
+ expect(typeof result.title).toBe('string');
+ expect(result.title.length).toBeGreaterThan(0);
+ expect(typeof result.message).toBe('string');
+ expect(result.message.length).toBeGreaterThan(0);
+ expect(typeof result.confirmLabel).toBe('string');
+ expect(result.confirmLabel.length).toBeGreaterThan(0);
+ expect(typeof result.destructive).toBe('boolean');
+ // Empty data → no rows render (every isPresent check fails). For schemas
+ // with no params at all, `rows` is also empty.
+ expect(Array.isArray(result.rows)).toBe(true);
+ });
+
+ it.each(SCHEMA_COMMANDS)('schema for %s has unique param names', (command) => {
+ const schema = getCommandSchema(command);
+ const names = schema.params.map((p) => p.name);
+ expect(new Set(names).size).toBe(names.length);
+ });
+
+ it.each(SCHEMA_COMMANDS)('schema for %s only declares known param types', (command) => {
+ const schema = getCommandSchema(command);
+ const known = new Set(['text', 'mojo-to-xch', 'mojo-to-cat', 'bool', 'json']);
+ for (const param of schema.params) {
+ expect(known.has(param.type)).toBe(true);
+ }
+ });
+});
+
+describe('renderConfirm — offer fee dedup', () => {
+ // Fix for the "fee shown twice" bug on Confirm Create Offer / Take Offer:
+ // the schema's fee param row and the offer enrichment card both rendered
+ // it. The card is the canonical place; the row is suppressed.
+ beforeEach(() => {
+ mockBuildCreateOfferDisplay.mockReset();
+ mockBuildTakeOfferDisplay.mockReset();
+ });
+
+ it('drops the fee row from create_offer_for_ids when offer enrichment carries the fee', async () => {
+ mockBuildCreateOfferDisplay.mockResolvedValue({ offered: [], requested: [], fee: '0' });
+
+ const result = await renderConfirm(
+ 'chia_wallet.create_offer_for_ids',
+ { fee: '500000000000', validate_only: false },
+ { networkPrefix: 'xch' },
+ );
+
+ expect(result.rows.find((r) => r.field === 'fee')).toBeUndefined();
+ expect(result.display.offer?.fee).toBe('0');
+ });
+
+ it('keeps the fee row when enrichment fails to produce a display (no offer card)', async () => {
+ // If the daemon RPC for offer summary times out, enrichment returns
+ // undefined → no offer card rendered → fee row must stay so the user
+ // still sees what they're paying.
+ mockBuildCreateOfferDisplay.mockResolvedValue(undefined);
+
+ const result = await renderConfirm(
+ 'chia_wallet.create_offer_for_ids',
+ { fee: '500000000000', validate_only: false },
+ { networkPrefix: 'xch' },
+ );
+
+ const feeRow = result.rows.find((r) => r.field === 'fee');
+ expect(feeRow?.value).toBe('0.5 XCH');
+ expect(result.display.offer).toBeUndefined();
+ });
+
+ it('keeps the fee row when offer enrichment exists but has no fee field', async () => {
+ // Defensive: some future enrichment shape might omit fee. Still surface
+ // the param row so the value isn't lost.
+ mockBuildCreateOfferDisplay.mockResolvedValue({ offered: [], requested: [] });
+
+ const result = await renderConfirm(
+ 'chia_wallet.create_offer_for_ids',
+ { fee: '500000000000', validate_only: false },
+ { networkPrefix: 'xch' },
+ );
+
+ const feeRow = result.rows.find((r) => r.field === 'fee');
+ expect(feeRow?.value).toBe('0.5 XCH');
+ });
+
+ it('drops the fee row from take_offer when offer enrichment carries the fee', async () => {
+ mockBuildTakeOfferDisplay.mockResolvedValue({ offered: [], requested: [], fee: '0' });
+
+ const result = await renderConfirm(
+ 'chia_wallet.take_offer',
+ { fee: '0', offer: 'offer1abc...' },
+ { networkPrefix: 'xch' },
+ );
+
+ expect(result.rows.find((r) => r.field === 'fee')).toBeUndefined();
+ expect(result.display.offer?.fee).toBe('0');
+ });
+
+ it('preserves non-fee rows when fee is dropped (only fee is suppressed)', async () => {
+ mockBuildCreateOfferDisplay.mockResolvedValue({ offered: [], requested: [], fee: '0' });
+
+ const result = await renderConfirm(
+ 'chia_wallet.create_offer_for_ids',
+ { fee: '500000000000', validate_only: true, allow_unsynced: false },
+ { networkPrefix: 'xch' },
+ );
+
+ expect(result.rows.find((r) => r.field === 'validate_only')?.value).toBe('Yes');
+ expect(result.rows.find((r) => r.field === 'allow_unsynced')?.value).toBe('No');
+ expect(result.rows.find((r) => r.field === 'fee')).toBeUndefined();
+ });
+});
diff --git a/packages/gui/src/electron/dialogs/Confirm/renderConfirm.ts b/packages/gui/src/electron/dialogs/Confirm/renderConfirm.ts
new file mode 100644
index 0000000000..c245fd3f2b
--- /dev/null
+++ b/packages/gui/src/electron/dialogs/Confirm/renderConfirm.ts
@@ -0,0 +1,108 @@
+// Resolves the registry schema into flat dialog rows + enrichment display.
+import { i18n } from '../../../config/locales';
+import { getCommandSchema, resolveTexts, type ParamSchema } from '../../constants/commandRegistry';
+import { type EnrichmentDisplay, lookupCat } from '../../utils/dappEnrichment';
+import mojoToCatLocaleString from '../../utils/mojoToCATLocaleString';
+import mojoToChiaLocaleString from '../../utils/mojoToChiaLocaleString';
+
+export type ConfirmRenderContext = {
+ networkPrefix?: string;
+};
+
+export type ConfirmRow = {
+ field: string;
+ label: string;
+ value: string;
+};
+
+export type ConfirmRenderResult = {
+ title: string;
+ message: string;
+ confirmLabel: string;
+ destructive: boolean;
+ rows: ConfirmRow[];
+ display: EnrichmentDisplay;
+};
+
+function formatMojoXch(amount: unknown, networkPrefix?: string): string {
+ const formatted = mojoToChiaLocaleString(amount as string | number);
+ return networkPrefix ? `${formatted} ${networkPrefix.toUpperCase()}` : formatted;
+}
+
+async function formatMojoCat(amount: unknown, data: Record, symbolFrom: string): Promise {
+ const formatted = mojoToCatLocaleString(amount as string | number);
+ const walletIdRaw = data[symbolFrom];
+ if (walletIdRaw === undefined || walletIdRaw === null) return formatted;
+ const cat = await lookupCat(walletIdRaw as number | string);
+ return cat?.displayName ? `${formatted} ${cat.displayName}` : formatted;
+}
+
+function isPresent(raw: unknown): boolean {
+ return raw !== undefined && raw !== null && raw !== '';
+}
+
+async function formatParamValue(
+ param: ParamSchema,
+ raw: unknown,
+ data: Record,
+ ctx: ConfirmRenderContext,
+): Promise {
+ switch (param.type) {
+ case 'text':
+ return String(raw);
+ case 'mojo-to-xch':
+ return formatMojoXch(raw, ctx.networkPrefix);
+ case 'mojo-to-cat':
+ return formatMojoCat(raw, data, param.symbolFrom);
+ case 'bool':
+ return raw ? i18n._(/* i18n */ { id: 'Yes' }) : i18n._(/* i18n */ { id: 'No' });
+ case 'json':
+ try {
+ return JSON.stringify(raw, null, 2);
+ } catch {
+ return String(raw);
+ }
+ default: {
+ // Exhaustiveness check.
+ const exhaustive: never = param;
+ throw new Error(`Unhandled param type: ${JSON.stringify(exhaustive)}`);
+ }
+ }
+}
+
+export async function renderConfirm(
+ command: string,
+ data: Record,
+ ctx: ConfirmRenderContext = {},
+): Promise {
+ const schema = getCommandSchema(command);
+
+ const [resolvedRows, display] = await Promise.all([
+ Promise.all(
+ schema.params.map(async (param) => {
+ if (param.hide) return undefined;
+ const raw = data[param.name];
+ if (!isPresent(raw)) return undefined;
+ const value = await formatParamValue(param, raw, data, ctx);
+ return { field: param.name, label: param.label(), value } satisfies ConfirmRow;
+ }),
+ ),
+ schema.enrich ? schema.enrich(data) : Promise.resolve({}),
+ ]);
+
+ // The offer summary card already renders fee — drop the param row to
+ // avoid duplicating it.
+ const offerShowsFee = display.offer?.fee !== undefined;
+ const rows = resolvedRows.filter((r): r is ConfirmRow => {
+ if (r === undefined) return false;
+ if (r.field === 'fee' && offerShowsFee) return false;
+ return true;
+ });
+
+ return {
+ ...resolveTexts(schema),
+ destructive: schema.destructive ?? false,
+ rows,
+ display,
+ };
+}
diff --git a/packages/gui/src/electron/dialogs/Pair/Pair.test.tsx b/packages/gui/src/electron/dialogs/Pair/Pair.test.tsx
new file mode 100644
index 0000000000..75287a9ca5
--- /dev/null
+++ b/packages/gui/src/electron/dialogs/Pair/Pair.test.tsx
@@ -0,0 +1,90 @@
+import React from 'react';
+import { renderToStaticMarkup } from 'react-dom/server';
+
+import Pair, { type PairProps } from './Pair';
+
+function inputHasChecked(html: string, field: string): boolean {
+ const match = html.match(
+ new RegExp(
+ `]*data-form-field="${field}"[^>]*>|]*checked=""[^>]*data-form-field="${field}"[^>]*>`,
+ ),
+ );
+ return !!match?.[0]?.includes('checked=""');
+}
+
+function renderPair(overrides: Partial = {}) {
+ return renderToStaticMarkup(
+ ,
+ );
+}
+
+describe('Pair dialog - spending allowance defaults', () => {
+ it('starts new pairings with auto-approve unchecked but a 0.01 XCH suggested value', () => {
+ const html = renderPair();
+ expect(html).toContain('data-form-field="enableAllowance"');
+ expect(inputHasChecked(html, 'enableAllowance')).toBe(false);
+ expect(html).toContain('data-form-field="allowanceXch"');
+ expect(html).toContain('value="0.01"');
+ });
+
+ it('checks auto-approve and shows the existing allowance for edit/default grants', () => {
+ const html = renderPair({ defaultGrants: { xchMojos: '2500000000' } });
+ expect(inputHasChecked(html, 'enableAllowance')).toBe(true);
+ expect(html).toContain('value="0.0025"');
+ });
+});
+
+describe('Pair dialog - per-command groups', () => {
+ it('renders spending commands separately from other commands', () => {
+ const html = renderPair({
+ commandGroups: {
+ innocuous: [],
+ balance: [],
+ sign: [],
+ notifications: [],
+ spending: ['chia_sendTransaction'],
+ other: ['chia_logIn'],
+ },
+ });
+
+ expect(html).toContain('Spending commands');
+ expect(html).toContain('Send Transaction');
+ expect(html).toContain('Other commands');
+ expect(html).toContain('Log In');
+ });
+
+ it('pre-checks command-level bypass entries, including spend commands', () => {
+ // Per-command bypass uses the multi-checkbox pattern: each box shares
+ // `data-form-field="bypass"` with `data-multi`, and its own value=wcCommand.
+ // The form scraper collects checked values into `result.bypass: string[]`.
+ const html = renderPair({
+ defaultBypass: ['chia_sendTransaction'],
+ commandGroups: {
+ innocuous: [],
+ balance: [],
+ sign: [],
+ notifications: [],
+ spending: ['chia_sendTransaction'],
+ other: [],
+ },
+ });
+
+ const match = html.match(/]*data-form-field="bypass"[^>]*value="chia_sendTransaction"[^>]*\/?>/);
+ expect(match).not.toBeNull();
+ expect(match![0]).toContain('data-multi');
+ expect(match![0]).toContain('checked=""');
+ });
+});
diff --git a/packages/gui/src/electron/main.tsx b/packages/gui/src/electron/main.tsx
index c028d74567..a6ab78882a 100644
--- a/packages/gui/src/electron/main.tsx
+++ b/packages/gui/src/electron/main.tsx
@@ -1192,6 +1192,7 @@ async function openAbout() {
throw new Error('`mainWindow` is empty');
}
+ const aboutPrefs = readPrefs();
await openReactDialog(
mainWindow,
About,
@@ -1199,6 +1200,7 @@ async function openAbout() {
packageJson,
versions: process.versions as Record,
version: app.getVersion(),
+ themeVariant: aboutPrefs.themeVariant,
},
{
title: 'About',
diff --git a/packages/gui/src/electron/permissions/buildPairRecord.test.ts b/packages/gui/src/electron/permissions/buildPairRecord.test.ts
new file mode 100644
index 0000000000..65e4e9c562
--- /dev/null
+++ b/packages/gui/src/electron/permissions/buildPairRecord.test.ts
@@ -0,0 +1,85 @@
+import { buildNewPairRecord } from './buildPairRecord';
+import type { PairGrants, PairMetadata } from './types';
+
+const grants: PairGrants = { xchMojos: '10000000000' };
+const metadata: PairMetadata = { name: 'Test Dapp', url: 'https://test.app' };
+
+describe('buildNewPairRecord', () => {
+ it('copies caller-provided fields verbatim', () => {
+ const record = buildNewPairRecord({
+ topic: 'topic-123',
+ mainnet: false,
+ metadata,
+ fingerprints: [111, 222],
+ grants,
+ commands: ['chia_sendTransaction', 'chia_getWallets'],
+ now: 1_700_000_000_000,
+ });
+ expect(record.topic).toBe('topic-123');
+ expect(record.mainnet).toBe(false);
+ expect(record.metadata).toBe(metadata);
+ expect(record.fingerprints).toEqual([111, 222]);
+ expect(record.grants).toBe(grants);
+ expect(record.commands).toEqual(['chia_sendTransaction', 'chia_getWallets']);
+ });
+
+ it('initializes usedMojos to the string "0"', () => {
+ // Numeric zero would round-trip through YAML as a number and break the
+ // BigNumber arithmetic in `recordUsage` (which assumes string input
+ // for >2^53 precision). Pin the type AND value.
+ const record = buildNewPairRecord({
+ topic: 't',
+ mainnet: true,
+ metadata,
+ fingerprints: [],
+ grants,
+ commands: [],
+ now: 0,
+ });
+ expect(record.usedMojos).toBe('0');
+ expect(typeof record.usedMojos).toBe('string');
+ });
+
+ it('initializes bypass to []', () => {
+ // No silent approvals carry over from anywhere on a fresh pair —
+ // bypass entries can only land via the Confirm dialog's "Don't ask
+ // again" path or the Settings UI, both running after this point.
+ const record = buildNewPairRecord({
+ topic: 't',
+ mainnet: true,
+ metadata,
+ fingerprints: [],
+ grants,
+ commands: [],
+ now: 0,
+ });
+ expect(record.bypass).toEqual([]);
+ });
+
+ it('stamps createdAt and updatedAt with the same `now` value', () => {
+ const record = buildNewPairRecord({
+ topic: 't',
+ mainnet: true,
+ metadata,
+ fingerprints: [],
+ grants,
+ commands: [],
+ now: 1_700_000_000_000,
+ });
+ expect(record.createdAt).toBe(1_700_000_000_000);
+ expect(record.updatedAt).toBe(1_700_000_000_000);
+ });
+
+ it('preserves an empty commands array (deny-all) without falling back', () => {
+ const record = buildNewPairRecord({
+ topic: 't',
+ mainnet: true,
+ metadata,
+ fingerprints: [],
+ grants,
+ commands: [],
+ now: 0,
+ });
+ expect(record.commands).toEqual([]);
+ });
+});
diff --git a/packages/gui/src/electron/permissions/buildPairRecord.ts b/packages/gui/src/electron/permissions/buildPairRecord.ts
new file mode 100644
index 0000000000..26108e704d
--- /dev/null
+++ b/packages/gui/src/electron/permissions/buildPairRecord.ts
@@ -0,0 +1,26 @@
+import type { PairGrants, PairMetadata, PairRecord } from './types';
+
+// usedMojos always starts fresh — never inherit allowance usage from a prior pair.
+export function buildNewPairRecord(input: {
+ topic: string;
+ mainnet: boolean;
+ metadata: PairMetadata;
+ fingerprints: number[];
+ grants: PairGrants;
+ commands: string[];
+ bypass?: string[];
+ now: number;
+}): PairRecord {
+ return {
+ topic: input.topic,
+ mainnet: input.mainnet,
+ metadata: input.metadata,
+ fingerprints: input.fingerprints,
+ createdAt: input.now,
+ updatedAt: input.now,
+ grants: input.grants,
+ usedMojos: '0',
+ commands: input.commands,
+ bypass: input.bypass ?? [],
+ };
+}
diff --git a/packages/gui/src/electron/permissions/buildShowNotification.test.ts b/packages/gui/src/electron/permissions/buildShowNotification.test.ts
new file mode 100644
index 0000000000..915c8921fc
--- /dev/null
+++ b/packages/gui/src/electron/permissions/buildShowNotification.test.ts
@@ -0,0 +1,178 @@
+/**
+ * `buildShowNotification` is the only place a dapp's payload becomes a
+ * Notification that the user sees. It runs in main right after the
+ * `pair.commands` gate passes, so its inputs are renderer-supplied (and
+ * therefore untrusted). Pin every output shape and every malformed-input
+ * rejection.
+ */
+import { WcError, WcErrorCode } from '../../@types/WcError';
+
+import { buildShowNotification } from './buildShowNotification';
+import type { PairRecord } from './types';
+
+function makePair(overrides: Partial = {}): PairRecord {
+ return {
+ topic: 'topic-1',
+ mainnet: true,
+ metadata: { name: 'Test Dapp' },
+ fingerprints: [111, 222],
+ createdAt: 0,
+ updatedAt: 0,
+ usedMojos: '0',
+ commands: ['chia_showNotification'],
+ bypass: [],
+ grants: { xchMojos: '0' },
+ ...overrides,
+ };
+}
+
+describe('buildShowNotification — offer notifications', () => {
+ it('builds an offer notification from a valid payload', () => {
+ const out = buildShowNotification(makePair(), { type: 'offer', offer_data: 'offer1abc...' }, 111);
+ // Output keeps camelCase `offerData` — snake_case is input-only.
+ expect(out).toMatchObject({
+ type: 'offer',
+ offerData: 'offer1abc...',
+ from: 'Test Dapp',
+ source: 'WALLET_CONNECT',
+ fingerprints: [111],
+ });
+ expect(typeof out!.timestamp).toBe('number');
+ expect(typeof out!.id).toBe('string');
+ expect(out!.id).toMatch(/^wc-/);
+ });
+
+ it.each([
+ ['missing offer_data', { type: 'offer' }],
+ ['non-string offer_data', { type: 'offer', offer_data: 42 }],
+ ['empty-string offer_data', { type: 'offer', offer_data: '' }],
+ ])('throws WcError(INVALID_PARAMS) on %s', (_label, payload) => {
+ let caught: unknown;
+ try {
+ buildShowNotification(makePair(), payload as Record, 111);
+ } catch (e) {
+ caught = e;
+ }
+ expect(caught).toBeInstanceOf(WcError);
+ expect((caught as WcError).code).toBe(WcErrorCode.INVALID_PARAMS);
+ });
+});
+
+describe('buildShowNotification — announcement notifications', () => {
+ it('builds an announcement with message + url', () => {
+ const out = buildShowNotification(
+ makePair(),
+ { type: 'announcement', message: 'Hello', url: 'https://example.com' },
+ 222,
+ );
+ expect(out).toMatchObject({
+ type: 'announcement',
+ message: 'Hello',
+ url: 'https://example.com',
+ from: 'Test Dapp',
+ fingerprints: [222],
+ });
+ });
+
+ it('builds an announcement with message only (url undefined)', () => {
+ const out = buildShowNotification(makePair(), { type: 'announcement', message: 'Hello' }, 111);
+ expect(out).toMatchObject({ type: 'announcement', message: 'Hello' });
+ expect((out as { url?: string }).url).toBeUndefined();
+ });
+
+ it('drops a non-string url field', () => {
+ const out = buildShowNotification(makePair(), { type: 'announcement', message: 'Hello', url: 42 }, 111);
+ expect((out as { url?: string }).url).toBeUndefined();
+ });
+
+ it('drops an empty-string url', () => {
+ const out = buildShowNotification(makePair(), { type: 'announcement', message: 'Hello', url: '' }, 111);
+ expect((out as { url?: string }).url).toBeUndefined();
+ });
+
+ it.each([
+ ['missing message', { type: 'announcement' }],
+ ['non-string message', { type: 'announcement', message: 42 }],
+ ])('throws WcError(INVALID_PARAMS) on %s', (_label, payload) => {
+ let caught: unknown;
+ try {
+ buildShowNotification(makePair(), payload as Record, 111);
+ } catch (e) {
+ caught = e;
+ }
+ expect(caught).toBeInstanceOf(WcError);
+ expect((caught as WcError).code).toBe(WcErrorCode.INVALID_PARAMS);
+ });
+});
+
+describe('buildShowNotification — fingerprint resolution', () => {
+ it('uses just the request fingerprint when allFingerprints is omitted', () => {
+ const out = buildShowNotification(
+ makePair({ fingerprints: [111, 222, 333] }),
+ { type: 'announcement', message: 'Hi' },
+ 222,
+ );
+ expect(out!.fingerprints).toEqual([222]);
+ });
+
+ it('uses every paired fingerprint when allFingerprints is true', () => {
+ const out = buildShowNotification(
+ makePair({ fingerprints: [111, 222, 333] }),
+ { type: 'announcement', message: 'Hi', all_fingerprints: true },
+ 222,
+ );
+ expect(out!.fingerprints).toEqual([111, 222, 333]);
+ });
+
+ it('falls back to pair.fingerprints when no request fingerprint and no allFingerprints', () => {
+ const out = buildShowNotification(
+ makePair({ fingerprints: [111, 222] }),
+ { type: 'announcement', message: 'Hi' },
+ undefined,
+ );
+ expect(out!.fingerprints).toEqual([111, 222]);
+ });
+
+ it('treats allFingerprints non-true values as false (strict ===)', () => {
+ // A hostile dapp can't sneak `all_fingerprints: 'true'` to broaden the
+ // notification scope.
+ const out = buildShowNotification(
+ makePair({ fingerprints: [111, 222, 333] }),
+ { type: 'announcement', message: 'Hi', all_fingerprints: 'true' },
+ 222,
+ );
+ expect(out!.fingerprints).toEqual([222]);
+ });
+});
+
+describe('buildShowNotification — unknown / malformed types', () => {
+ it.each([
+ ['unknown type', { type: 'random', message: 'x' }],
+ ['missing type', { message: 'x' }],
+ ['null type', { type: null, message: 'x' }],
+ ['number type', { type: 42, message: 'x' }],
+ ])('throws WcError(INVALID_PARAMS) for %s', (_label, payload) => {
+ let caught: unknown;
+ try {
+ buildShowNotification(makePair(), payload as Record, 111);
+ } catch (e) {
+ caught = e;
+ }
+ expect(caught).toBeInstanceOf(WcError);
+ expect((caught as WcError).code).toBe(WcErrorCode.INVALID_PARAMS);
+ });
+});
+
+describe('buildShowNotification — pair metadata', () => {
+ it('falls back to from: undefined when pair has no metadata.name', () => {
+ const out = buildShowNotification(
+ makePair({ metadata: { name: '' } }),
+ { type: 'announcement', message: 'Hi' },
+ 111,
+ );
+ // The renderer's notification UI handles undefined/empty from with its
+ // own "Unknown Dapp" fallback; main just passes through whatever the
+ // pair record holds.
+ expect(out!.from).toBe('');
+ });
+});
diff --git a/packages/gui/src/electron/permissions/buildShowNotification.ts b/packages/gui/src/electron/permissions/buildShowNotification.ts
new file mode 100644
index 0000000000..a8ffb48d43
--- /dev/null
+++ b/packages/gui/src/electron/permissions/buildShowNotification.ts
@@ -0,0 +1,68 @@
+import { WcError, WcErrorCode } from '../../@types/WcError';
+import NotificationType from '../../constants/NotificationType';
+
+import type { PairRecord } from './types';
+
+type NotificationBase = {
+ timestamp: number;
+ id: string;
+ source: 'WALLET_CONNECT';
+ fingerprints?: number[];
+ from?: string;
+};
+
+type NotificationOffer = NotificationBase & {
+ type: NotificationType.OFFER;
+ offerData: string;
+};
+
+type NotificationAnnouncement = NotificationBase & {
+ type: NotificationType.ANNOUNCEMENT;
+ message: string;
+ url?: string;
+};
+
+/** Subset of the renderer's Notification shape that main can construct. */
+export type ShowNotificationPayload = NotificationOffer | NotificationAnnouncement;
+
+// Throws WcError(INVALID_PARAMS) on malformed payloads so the dapp sees a
+// real failure instead of a misleading `success: true`. Keys are snake_case
+// — dispatchAsPair canonicalises before any field read.
+export function buildShowNotification(
+ pair: PairRecord,
+ payload: Record,
+ requestFingerprint?: number,
+): ShowNotificationPayload {
+ const { type } = payload;
+ const allFingerprints = payload.all_fingerprints === true;
+ const fingerprints = allFingerprints
+ ? pair.fingerprints
+ : requestFingerprint !== undefined
+ ? [requestFingerprint]
+ : pair.fingerprints;
+
+ const from = pair.metadata?.name;
+ const timestamp = Math.floor(Date.now() / 1000);
+ const id = `wc-${Date.now()}-${Math.floor(Math.random() * 1_000_000_000)}`;
+ const base = { from, timestamp, id, source: 'WALLET_CONNECT' as const, fingerprints };
+
+ if (type === NotificationType.OFFER) {
+ const offerData = payload.offer_data;
+ if (typeof offerData !== 'string' || !offerData) {
+ throw new WcError('offer notification missing offer_data', WcErrorCode.INVALID_PARAMS);
+ }
+ return { ...base, type: NotificationType.OFFER, offerData };
+ }
+
+ if (type === NotificationType.ANNOUNCEMENT) {
+ const { message } = payload;
+ if (typeof message !== 'string' || !message) {
+ throw new WcError('announcement notification missing message', WcErrorCode.INVALID_PARAMS);
+ }
+ const urlRaw = payload.url;
+ const url = typeof urlRaw === 'string' && urlRaw ? urlRaw : undefined;
+ return { ...base, type: NotificationType.ANNOUNCEMENT, message, url };
+ }
+
+ throw new WcError(`unknown notification type: ${String(type)}`, WcErrorCode.INVALID_PARAMS);
+}
diff --git a/packages/gui/src/electron/permissions/bypassCapture.test.ts b/packages/gui/src/electron/permissions/bypassCapture.test.ts
new file mode 100644
index 0000000000..0455490d87
--- /dev/null
+++ b/packages/gui/src/electron/permissions/bypassCapture.test.ts
@@ -0,0 +1,152 @@
+/**
+ * The Confirm dialog's "Don't ask again" checkbox is the only place a dapp
+ * can ever earn silent approval for a sensitive command. The
+ * result-shape interpretation in `captureBypassFromConfirmResult` decides
+ * whether that approval is persisted — getting any of the four result
+ * cases wrong either annoys the user (lost bypass) or silently extends
+ * dapp reach (bypass written when user said cancel / didn't tick the
+ * box). These tests pin every shape.
+ */
+import { captureBypassFromConfirmResult } from './bypassCapture';
+import type { PairRecord } from './types';
+
+const TOPIC = 'topic-1';
+const WC_COMMAND = 'chia_signMessageByAddress';
+
+function makePair(overrides: { bypass?: string[] } = {}): PairRecord {
+ return {
+ topic: TOPIC,
+ mainnet: true,
+ metadata: { name: 'Test Dapp' },
+ fingerprints: [123],
+ createdAt: 0,
+ updatedAt: 0,
+ usedMojos: '0',
+ commands: ['chia_signMessageByAddress', 'chia_getWallets'],
+ bypass: overrides.bypass ?? [],
+ grants: { xchMojos: '0' },
+ };
+}
+
+function makeDeps(initialPair?: PairRecord) {
+ let pair = initialPair;
+ const upsertPair = jest.fn((next: PairRecord) => {
+ pair = next;
+ });
+ return {
+ getPair: jest.fn((topic: string) => (pair && pair.topic === topic ? pair : undefined)),
+ upsertPair,
+ currentPair: () => pair,
+ };
+}
+
+describe('captureBypassFromConfirmResult — no-op result shapes', () => {
+ it.each([
+ ['undefined', undefined],
+ ['null', null],
+ ['false (cancel)', false],
+ ['true (confirm without form fields)', true],
+ ['number', 42],
+ ['string', 'bypass'],
+ ])('returns null and does not call upsertPair for %s', (_label, result) => {
+ const deps = makeDeps(makePair());
+ const out = captureBypassFromConfirmResult(result, { topic: TOPIC, wcCommand: WC_COMMAND }, deps);
+ expect(out).toBeNull();
+ expect(deps.upsertPair).not.toHaveBeenCalled();
+ });
+
+ it('object with no bypass field is no-op', () => {
+ const deps = makeDeps(makePair());
+ const out = captureBypassFromConfirmResult({}, { topic: TOPIC, wcCommand: WC_COMMAND }, deps);
+ expect(out).toBeNull();
+ expect(deps.upsertPair).not.toHaveBeenCalled();
+ });
+
+ it('object with bypass: false is no-op (user unchecked the box)', () => {
+ const deps = makeDeps(makePair());
+ const out = captureBypassFromConfirmResult({ bypass: false }, { topic: TOPIC, wcCommand: WC_COMMAND }, deps);
+ expect(out).toBeNull();
+ expect(deps.upsertPair).not.toHaveBeenCalled();
+ });
+
+ it.each([
+ ['string "true"', 'true'],
+ ['number 1', 1],
+ ['object {}', {}],
+ ])('object with truthy-but-not-true bypass (%s) is no-op (strict ===)', (_label, value) => {
+ // Strict equality with `true` prevents a hostile renderer from sneaking
+ // a string `'true'` past the check; only the literal boolean from the
+ // checkbox's `el.checked` collection counts.
+ const deps = makeDeps(makePair());
+ const out = captureBypassFromConfirmResult({ bypass: value }, { topic: TOPIC, wcCommand: WC_COMMAND }, deps);
+ expect(out).toBeNull();
+ expect(deps.upsertPair).not.toHaveBeenCalled();
+ });
+});
+
+describe('captureBypassFromConfirmResult — persistence path', () => {
+ it('appends wcCommand to bypass and bumps updatedAt', () => {
+ const deps = makeDeps(makePair({ bypass: ['chia_getWallets'] }));
+ const before = Date.now();
+ const out = captureBypassFromConfirmResult({ bypass: true }, { topic: TOPIC, wcCommand: WC_COMMAND }, deps);
+ const after = Date.now();
+ expect(out).toEqual(['chia_getWallets', WC_COMMAND]);
+ expect(deps.upsertPair).toHaveBeenCalledTimes(1);
+ const written = deps.upsertPair.mock.calls[0][0];
+ expect(written.bypass).toEqual(['chia_getWallets', WC_COMMAND]);
+ expect(written.updatedAt).toBeGreaterThanOrEqual(before);
+ expect(written.updatedAt).toBeLessThanOrEqual(after);
+ });
+
+ it('preserves all other fields (topic, mainnet, grants, commands, usedMojos)', () => {
+ const original = makePair({ bypass: [] });
+ const deps = makeDeps(original);
+ captureBypassFromConfirmResult({ bypass: true }, { topic: TOPIC, wcCommand: WC_COMMAND }, deps);
+ const written = deps.upsertPair.mock.calls[0][0];
+ expect(written.topic).toBe(original.topic);
+ expect(written.mainnet).toBe(original.mainnet);
+ expect(written.metadata).toEqual(original.metadata);
+ expect(written.fingerprints).toEqual(original.fingerprints);
+ expect(written.commands).toEqual(original.commands);
+ expect(written.usedMojos).toBe(original.usedMojos);
+ expect(written.grants).toEqual(original.grants);
+ expect(written.createdAt).toBe(original.createdAt);
+ });
+
+ it('idempotent: appending an already-bypassed command does not duplicate or write', () => {
+ const deps = makeDeps(makePair({ bypass: [WC_COMMAND] }));
+ const out = captureBypassFromConfirmResult({ bypass: true }, { topic: TOPIC, wcCommand: WC_COMMAND }, deps);
+ expect(out).toEqual([WC_COMMAND]);
+ // Crucially: no write happens. Otherwise an attacker could keep
+ // bumping updatedAt on someone else's pair record by re-issuing
+ // already-bypassed commands.
+ expect(deps.upsertPair).not.toHaveBeenCalled();
+ });
+
+ it('returns null and does not write when pair is missing (race with revoke)', () => {
+ const deps = makeDeps(undefined);
+ const out = captureBypassFromConfirmResult({ bypass: true }, { topic: TOPIC, wcCommand: WC_COMMAND }, deps);
+ expect(out).toBeNull();
+ expect(deps.upsertPair).not.toHaveBeenCalled();
+ });
+
+ it('matches by exact topic — wrong topic does not write', () => {
+ const deps = makeDeps(makePair());
+ const out = captureBypassFromConfirmResult(
+ { bypass: true },
+ { topic: 'different-topic', wcCommand: WC_COMMAND },
+ deps,
+ );
+ expect(out).toBeNull();
+ expect(deps.upsertPair).not.toHaveBeenCalled();
+ });
+
+ it('captures wire-form wcCommand verbatim (no prefix mangling)', () => {
+ // Both the dispatch path and the gate compare wcCommand strings
+ // directly. Captured bypass entries must match the same shape, or the
+ // gate's `pair.bypass.includes(wc)` would never hit on the next call.
+ const deps = makeDeps(makePair());
+ captureBypassFromConfirmResult({ bypass: true }, { topic: TOPIC, wcCommand: 'chia_spendCAT' }, deps);
+ expect(deps.upsertPair.mock.calls[0][0].bypass).toEqual(['chia_spendCAT']);
+ });
+});
diff --git a/packages/gui/src/electron/permissions/bypassCapture.ts b/packages/gui/src/electron/permissions/bypassCapture.ts
new file mode 100644
index 0000000000..b2974dec95
--- /dev/null
+++ b/packages/gui/src/electron/permissions/bypassCapture.ts
@@ -0,0 +1,25 @@
+import type { PairRecord } from './types';
+
+export type CaptureBypassDeps = {
+ getPair: (topic: string) => PairRecord | undefined;
+ upsertPair: (pair: PairRecord) => void;
+};
+
+// Returns the new list when persistence happened, null for every no-op path.
+// Idempotent on already-listed commands. See bypassCapture.test.ts for the
+// matrix of openReactDialog result shapes.
+export function captureBypassFromConfirmResult(
+ result: unknown,
+ ctx: { topic: string; wcCommand: string },
+ deps: CaptureBypassDeps,
+): string[] | null {
+ if (typeof result !== 'object' || result === null) return null;
+ const r = result as { bypass?: unknown };
+ if (r.bypass !== true) return null;
+ const pair = deps.getPair(ctx.topic);
+ if (!pair) return null;
+ if (pair.bypass.includes(ctx.wcCommand)) return pair.bypass;
+ const nextBypass = [...pair.bypass, ctx.wcCommand];
+ deps.upsertPair({ ...pair, bypass: nextBypass, updatedAt: Date.now() });
+ return nextBypass;
+}
diff --git a/packages/gui/src/electron/permissions/checkPairAccess.test.ts b/packages/gui/src/electron/permissions/checkPairAccess.test.ts
new file mode 100644
index 0000000000..935bf2155a
--- /dev/null
+++ b/packages/gui/src/electron/permissions/checkPairAccess.test.ts
@@ -0,0 +1,176 @@
+/**
+ * `checkPairAccess` is the unified pair-bound gate. Every request that
+ * reaches main against a paired dapp goes through it: pair existence,
+ * commands allowlist, fingerprint allowlist, network match. Replaces
+ * four previously scattered checks. A regression here lets a compromised
+ * renderer bypass any of those four gates.
+ */
+import { WcErrorCode } from '../../@types/WcError';
+
+import { checkPairAccess } from './checkPairAccess';
+import type { PairRecord } from './types';
+
+const TOPIC = 'topic-1';
+
+function makePair(overrides: Partial = {}): PairRecord {
+ return {
+ topic: TOPIC,
+ mainnet: true,
+ metadata: { name: 'Test Dapp' },
+ fingerprints: [111, 222],
+ createdAt: 0,
+ updatedAt: 0,
+ usedMojos: '0',
+ commands: ['chia_sendTransaction', 'chia_getWallets'],
+ bypass: [],
+ grants: { xchMojos: '0' },
+ ...overrides,
+ };
+}
+
+function depsWith(pair?: PairRecord) {
+ return {
+ getPair: jest.fn((topic: string) => (pair && pair.topic === topic ? pair : undefined)),
+ };
+}
+
+describe('checkPairAccess — happy path', () => {
+ it('returns ok with the pair record when all checks pass', () => {
+ const pair = makePair();
+ const out = checkPairAccess(
+ { topic: TOPIC, wcCommand: 'chia_sendTransaction', fingerprint: 111, mainnet: true },
+ depsWith(pair),
+ );
+ expect(out).toEqual({ ok: true, pair });
+ });
+
+ it('passes when fingerprint is omitted (caller has no fingerprint context)', () => {
+ const out = checkPairAccess(
+ { topic: TOPIC, wcCommand: 'chia_sendTransaction', mainnet: true },
+ depsWith(makePair()),
+ );
+ expect(out.ok).toBe(true);
+ });
+});
+
+describe('checkPairAccess — failure modes', () => {
+ it('denies "unknown pair" when topic is not in the store', () => {
+ const out = checkPairAccess({ topic: TOPIC, wcCommand: 'chia_sendTransaction' }, depsWith(undefined));
+ expect(out).toEqual({ ok: false, reason: 'Pair not found', code: WcErrorCode.USER_REJECTED });
+ });
+
+ it('denies "missing wc command" when wcCommand is empty / undefined', () => {
+ const pair = makePair();
+ expect(checkPairAccess({ topic: TOPIC }, depsWith(pair))).toEqual({
+ ok: false,
+ reason: 'missing wc command',
+ code: WcErrorCode.INVALID_PARAMS,
+ });
+ expect(checkPairAccess({ topic: TOPIC, wcCommand: '' }, depsWith(pair))).toEqual({
+ ok: false,
+ reason: 'missing wc command',
+ code: WcErrorCode.INVALID_PARAMS,
+ });
+ });
+
+ it('denies when wcCommand is not on the pair allowlist', () => {
+ const out = checkPairAccess(
+ { topic: TOPIC, wcCommand: 'chia_signMessageByAddress' },
+ depsWith(makePair({ commands: ['chia_getWallets'] })),
+ );
+ expect(out).toEqual({
+ ok: false,
+ reason: 'command not granted for this pair: chia_signMessageByAddress',
+ code: WcErrorCode.UNAUTHORIZED_METHOD,
+ });
+ });
+
+ it('denies when the dapp-claimed fingerprint is not on the pair list', () => {
+ const out = checkPairAccess(
+ { topic: TOPIC, wcCommand: 'chia_sendTransaction', fingerprint: 999 },
+ depsWith(makePair({ fingerprints: [111, 222] })),
+ );
+ expect(out).toEqual({
+ ok: false,
+ reason: 'fingerprint not granted for this pair: 999',
+ code: WcErrorCode.UNAUTHORIZED_METHOD,
+ });
+ });
+
+ it('denies on mainnet/testnet mismatch (mainnet pair, testnet request)', () => {
+ const out = checkPairAccess(
+ { topic: TOPIC, wcCommand: 'chia_sendTransaction', mainnet: false },
+ depsWith(makePair({ mainnet: true })),
+ );
+ expect(out).toEqual({ ok: false, reason: 'network mismatch', code: WcErrorCode.UNSUPPORTED_CHAINS });
+ });
+
+ it('denies on mainnet/testnet mismatch (testnet pair, mainnet request)', () => {
+ const out = checkPairAccess(
+ { topic: TOPIC, wcCommand: 'chia_sendTransaction', mainnet: true },
+ depsWith(makePair({ mainnet: false })),
+ );
+ expect(out).toEqual({ ok: false, reason: 'network mismatch', code: WcErrorCode.UNSUPPORTED_CHAINS });
+ });
+
+ // Fail-closed on missing/non-boolean mainnet: every dapp call is
+ // network-scoped, so a renderer that omits the flag (or sends a non-bool)
+ // must NOT slip past the network gate.
+ it('denies when mainnet is undefined (caller never set the flag)', () => {
+ const out = checkPairAccess(
+ { topic: TOPIC, wcCommand: 'chia_sendTransaction' } as Parameters[0],
+ depsWith(makePair({ mainnet: true })),
+ );
+ expect(out).toEqual({ ok: false, reason: 'network mismatch', code: WcErrorCode.UNSUPPORTED_CHAINS });
+ });
+
+ it('denies when mainnet is not a boolean (string, null, etc.)', () => {
+ expect(
+ checkPairAccess(
+ { topic: TOPIC, wcCommand: 'chia_sendTransaction', mainnet: 'true' as unknown as boolean },
+ depsWith(makePair({ mainnet: true })),
+ ),
+ ).toEqual({ ok: false, reason: 'network mismatch', code: WcErrorCode.UNSUPPORTED_CHAINS });
+ expect(
+ checkPairAccess(
+ { topic: TOPIC, wcCommand: 'chia_sendTransaction', mainnet: null as unknown as boolean },
+ depsWith(makePair({ mainnet: true })),
+ ),
+ ).toEqual({ ok: false, reason: 'network mismatch', code: WcErrorCode.UNSUPPORTED_CHAINS });
+ });
+});
+
+describe('checkPairAccess — order of failures', () => {
+ // The function returns the first failure it encounters. This matters
+ // for the user-facing error message — if multiple things are wrong,
+ // the message should point at the most specific reason.
+
+ it('"unknown pair" wins over wcCommand / fingerprint / mainnet', () => {
+ const out = checkPairAccess(
+ { topic: 'no-such', wcCommand: 'chia_X', fingerprint: 9, mainnet: false },
+ depsWith(makePair()),
+ );
+ expect(out).toMatchObject({ reason: 'Pair not found' });
+ });
+
+ it('"missing wc command" wins over fingerprint / mainnet', () => {
+ const out = checkPairAccess({ topic: TOPIC, fingerprint: 999, mainnet: false }, depsWith(makePair()));
+ expect(out).toMatchObject({ reason: 'missing wc command' });
+ });
+
+ it('"command not granted" wins over fingerprint / mainnet', () => {
+ const out = checkPairAccess(
+ { topic: TOPIC, wcCommand: 'chia_unknownCmd', fingerprint: 999, mainnet: false },
+ depsWith(makePair()),
+ );
+ expect(out).toMatchObject({ reason: expect.stringContaining('command not granted') });
+ });
+
+ it('"fingerprint not granted" wins over mainnet mismatch', () => {
+ const out = checkPairAccess(
+ { topic: TOPIC, wcCommand: 'chia_sendTransaction', fingerprint: 999, mainnet: false },
+ depsWith(makePair({ mainnet: true })),
+ );
+ expect(out).toMatchObject({ reason: expect.stringContaining('fingerprint not granted') });
+ });
+});
diff --git a/packages/gui/src/electron/permissions/checkPairAccess.ts b/packages/gui/src/electron/permissions/checkPairAccess.ts
new file mode 100644
index 0000000000..e652c1ed8d
--- /dev/null
+++ b/packages/gui/src/electron/permissions/checkPairAccess.ts
@@ -0,0 +1,47 @@
+import { WcErrorCode } from '../../@types/WcError';
+
+import type { PairRecord } from './types';
+
+export type PairAccessCheck = {
+ topic: string;
+ /** Wire form (`chia_`). */
+ wcCommand?: string;
+ fingerprint?: number;
+ /** Required: every dapp call is network-scoped. Missing → deny. */
+ mainnet: boolean;
+};
+
+export type PairAccessResult = { ok: true; pair: PairRecord } | { ok: false; reason: string; code: number };
+
+export type CheckPairAccessDeps = {
+ getPair: (topic: string) => PairRecord | undefined;
+};
+
+// Failure order: pair > command > fingerprint > network. First wrong thing wins.
+// Network fail-closed on missing/mistyped flag — every dapp request is
+// network-scoped.
+export function checkPairAccess(check: PairAccessCheck, deps: CheckPairAccessDeps): PairAccessResult {
+ const pair = deps.getPair(check.topic);
+ if (!pair) return { ok: false, reason: 'Pair not found', code: WcErrorCode.USER_REJECTED };
+ if (!check.wcCommand) {
+ return { ok: false, reason: 'missing wc command', code: WcErrorCode.INVALID_PARAMS };
+ }
+ if (!pair.commands.includes(check.wcCommand)) {
+ return {
+ ok: false,
+ reason: `command not granted for this pair: ${check.wcCommand}`,
+ code: WcErrorCode.UNAUTHORIZED_METHOD,
+ };
+ }
+ if (check.fingerprint !== undefined && !pair.fingerprints.includes(check.fingerprint)) {
+ return {
+ ok: false,
+ reason: `fingerprint not granted for this pair: ${check.fingerprint}`,
+ code: WcErrorCode.UNAUTHORIZED_METHOD,
+ };
+ }
+ if (typeof check.mainnet !== 'boolean' || check.mainnet !== pair.mainnet) {
+ return { ok: false, reason: 'network mismatch', code: WcErrorCode.UNSUPPORTED_CHAINS };
+ }
+ return { ok: true, pair };
+}
diff --git a/packages/gui/src/electron/permissions/commandCapabilities.ts b/packages/gui/src/electron/permissions/commandCapabilities.ts
new file mode 100644
index 0000000000..4d5821ef41
--- /dev/null
+++ b/packages/gui/src/electron/permissions/commandCapabilities.ts
@@ -0,0 +1,187 @@
+import crypto from 'node:crypto';
+
+import BigNumber from 'bignumber.js';
+import JSONbig from 'json-bigint';
+
+import AllowedCommands from '../constants/AllowedCommands';
+import { sendDappAndAwait } from '../utils/webSocketBridge';
+
+import type { SpendClassification } from './types';
+
+const UI_ALLOWED = new Set(AllowedCommands);
+
+const BALANCE_COMMANDS = new Set(['chia_wallet.get_wallet_balance', 'chia_wallet.get_wallet_balances']);
+
+// Read-only commands a dapp can opt into via the Innocuous capability grant.
+// Independent of `AllowedCommands` (which governs first-party UI bypass).
+const INNOCUOUS_COMMANDS = new Set([
+ 'chia_wallet.get_wallets',
+ 'chia_wallet.get_next_address',
+ 'chia_wallet.get_sync_status',
+ 'chia_wallet.get_coin_records_by_names',
+ 'chia_wallet.select_coins',
+ 'chia_wallet.get_height_info',
+ 'chia_wallet.get_puzzle_and_solution',
+ 'chia_wallet.get_timestamp_for_height',
+ 'chia_wallet.get_transaction',
+ 'chia_wallet.get_offer',
+ 'chia_wallet.get_offer_summary',
+ 'chia_wallet.check_offer_validity',
+ 'chia_wallet.cat_get_asset_id',
+ 'chia_wallet.cat_get_name',
+ 'chia_wallet.cat_asset_id_to_name',
+ 'chia_wallet.nft_get_info',
+ 'chia_wallet.nft_get_wallet_did',
+ 'chia_wallet.nft_calculate_royalties',
+ 'chia_wallet.vc_get',
+ 'chia_wallet.vc_get_proofs_for_root',
+ 'chia_wallet.did_get_did',
+ 'chia_wallet.did_get_info',
+ 'chia_wallet.did_get_metadata',
+ 'chia_wallet.did_get_pubkey',
+ 'chia_wallet.did_get_current_coin_info',
+ 'chia_wallet.did_get_wallet_name',
+ 'chia_wallet.pw_status',
+ 'chia_wallet.verify_signature',
+ 'chia_wallet.ping',
+ 'chia_wallet.create_new_remote_wallet',
+ 'chia_wallet.register_remote_coins',
+]);
+
+const SIGN_COMMANDS = new Set(['chia_wallet.sign_message_by_address', 'chia_wallet.sign_message_by_id']);
+
+const SPEND_COMMANDS = new Set([
+ 'chia_wallet.send_transaction',
+ 'chia_wallet.cat_spend',
+ 'chia_wallet.nft_transfer_nft',
+ 'chia_wallet.cancel_offer',
+ 'chia_wallet.create_offer_for_ids',
+ 'chia_wallet.take_offer',
+ 'chia_wallet.spend_clawback_coins',
+ 'chia_wallet.push_transactions',
+]);
+
+export function isUiAllowed(command: string): boolean {
+ return UI_ALLOWED.has(command);
+}
+
+export function isBalanceCommand(command: string): boolean {
+ return BALANCE_COMMANDS.has(command);
+}
+
+export function isInnocuousCommand(command: string): boolean {
+ return INNOCUOUS_COMMANDS.has(command);
+}
+
+export function isSignCommand(command: string): boolean {
+ return SIGN_COMMANDS.has(command);
+}
+
+export function isSpendCommand(command: string): boolean {
+ return SPEND_COMMANDS.has(command);
+}
+
+// Sum XCH mojos the maker is giving up in a `create_offer_for_ids` offer.
+// Daemon convention: dict keyed by wallet id (XCH = `1`) with negative
+// amounts = outflow, positive = inflow. Pure-XCH only — any CAT/NFT key
+// returns undefined → prompt, since the spending cap is XCH-denominated.
+function extractOfferXchOutflow(payload: Record): BigNumber | undefined {
+ const offer = payload?.offer;
+ if (!offer || typeof offer !== 'object') return undefined;
+
+ let xchOut = new BigNumber(0);
+ for (const [key, raw] of Object.entries(offer as Record)) {
+ if (key !== '1') return undefined;
+ let amount: BigNumber | undefined;
+ try {
+ amount = new BigNumber(typeof raw === 'string' ? raw : String(raw));
+ } catch {
+ amount = undefined;
+ }
+ if (amount && amount.isFinite() && amount.isLessThan(0)) {
+ xchOut = xchOut.plus(amount.abs());
+ }
+ }
+ return xchOut;
+}
+
+type OfferSummary = {
+ offered?: Record;
+ requested?: Record;
+};
+
+// Sum XCH mojos the taker would give up. Pure-XCH only on either side —
+// CAT/NFT amounts return undefined → prompt. Take-fee is NOT added here;
+// the spend resolver does that via `feeField`.
+async function extractTakeOfferXchOutflow(payload: Record): Promise {
+ const offer = payload?.offer;
+ if (typeof offer !== 'string' || !offer) return undefined;
+
+ let summary: OfferSummary | undefined;
+ try {
+ const requestId = crypto.randomBytes(32).toString('hex');
+ const wire = {
+ origin: 'wallet_ui',
+ destination: 'chia_wallet',
+ command: 'get_offer_summary',
+ data: { offer },
+ ack: false,
+ request_id: requestId,
+ };
+ const json = JSONbig.stringify(wire);
+ const response = (await sendDappAndAwait(requestId, json)) as {
+ data?: { error?: unknown; summary?: OfferSummary };
+ };
+ if (response?.data?.error) return undefined;
+ summary = response?.data?.summary;
+ } catch {
+ return undefined;
+ }
+ if (!summary || typeof summary !== 'object') return undefined;
+ const { requested, offered } = summary;
+ if (!requested || typeof requested !== 'object') return undefined;
+ if (!offered || typeof offered !== 'object') return undefined;
+
+ for (const key of Object.keys(offered)) {
+ if (key !== 'xch') return undefined;
+ }
+ for (const key of Object.keys(requested)) {
+ if (key !== 'xch') return undefined;
+ }
+
+ let xchOut = new BigNumber(0);
+ const raw = (requested as { xch?: unknown }).xch;
+ if (raw !== undefined) {
+ try {
+ const amount = new BigNumber(typeof raw === 'string' ? raw : String(raw));
+ if (amount.isFinite() && amount.isGreaterThan(0)) xchOut = amount;
+ } catch {
+ // invalid → leave outflow at 0
+ }
+ }
+ return xchOut;
+}
+
+export function getSpendClassification(command: string): SpendClassification | undefined {
+ switch (command) {
+ case 'chia_wallet.send_transaction':
+ return { capability: 'spend', amountField: 'amount', feeField: 'fee' };
+
+ case 'chia_wallet.create_offer_for_ids':
+ return {
+ capability: 'offer',
+ feeField: 'fee',
+ amountResolver: extractOfferXchOutflow,
+ };
+
+ case 'chia_wallet.take_offer':
+ return {
+ capability: 'offer',
+ feeField: 'fee',
+ amountResolver: extractTakeOfferXchOutflow,
+ };
+
+ default:
+ return undefined;
+ }
+}
diff --git a/packages/gui/src/electron/permissions/dappHandlers.test.ts b/packages/gui/src/electron/permissions/dappHandlers.test.ts
new file mode 100644
index 0000000000..2add44ba3a
--- /dev/null
+++ b/packages/gui/src/electron/permissions/dappHandlers.test.ts
@@ -0,0 +1,61 @@
+import { WcError, WcErrorCode } from '../../@types/WcError';
+
+import { processDispatchResponse } from './dappHandlers';
+
+describe('processDispatchResponse', () => {
+ it('returns {} when response is undefined', () => {
+ expect(processDispatchResponse(undefined)).toEqual({});
+ });
+
+ it('returns {} when response is null', () => {
+ expect(processDispatchResponse(null)).toEqual({});
+ });
+
+ it('returns {} when response has no data key', () => {
+ expect(processDispatchResponse({})).toEqual({});
+ });
+
+ it('returns {} when response.data is null', () => {
+ expect(processDispatchResponse({ data: null })).toEqual({});
+ });
+
+ it('returns camelCased data when response.data has no error key', () => {
+ expect(processDispatchResponse({ data: { wallet_id: 1 } })).toEqual({ walletId: 1 });
+ });
+
+ it('returns camelCased data when response.data.error is undefined', () => {
+ expect(processDispatchResponse({ data: { error: undefined, wallet_id: 2 } })).toEqual({ walletId: 2 });
+ });
+
+ it('returns camelCased data when response.data.error is null (falsy)', () => {
+ expect(processDispatchResponse({ data: { error: null, wallet_id: 3 } })).toEqual({
+ error: null,
+ walletId: 3,
+ });
+ });
+
+ it('returns camelCased data when response.data.error is empty string (falsy)', () => {
+ expect(processDispatchResponse({ data: { error: '', wallet_id: 4 } })).toEqual({
+ error: '',
+ walletId: 4,
+ });
+ });
+
+ it('throws WcError with INTERNAL_ERROR when response.data.error is truthy', () => {
+ const response = {
+ data: { error: 'fee too low', success: false, wallet_id: 1 },
+ };
+
+ expect(() => processDispatchResponse(response)).toThrow(WcError);
+
+ try {
+ processDispatchResponse(response);
+ } catch (e) {
+ expect(e).toBeInstanceOf(WcError);
+ const wcErr = e;
+ expect(wcErr.message).toBe('fee too low');
+ expect(wcErr.code).toBe(WcErrorCode.INTERNAL_ERROR);
+ expect(wcErr.data).toEqual({ error: 'fee too low', success: false, walletId: 1 });
+ }
+ });
+});
diff --git a/packages/gui/src/electron/permissions/dappHandlers.ts b/packages/gui/src/electron/permissions/dappHandlers.ts
new file mode 100644
index 0000000000..a37fea8f57
--- /dev/null
+++ b/packages/gui/src/electron/permissions/dappHandlers.ts
@@ -0,0 +1,135 @@
+// Handlers for pure-dapp wcCommands without a 1:1 daemon RPC. Schemas opt
+// in via `dapp.handlerKey`; `dispatchAsPair` runs validation + permission
+// + confirm first, then invokes the handler with `dispatchDaemon` for
+// composed flows.
+import type { BrowserWindow } from 'electron';
+import crypto from 'node:crypto';
+
+import JSONbig from 'json-bigint';
+
+import { WcError, WcErrorCode } from '../../@types/WcError';
+import PermissionsAPI from '../constants/PermissionsAPI';
+import toCamelCase from '../utils/toCamelCase';
+import toSnakeCase from '../utils/toSnakeCase';
+import { sendDappAndAwait } from '../utils/webSocketBridge';
+
+import { buildShowNotification } from './buildShowNotification';
+import type { PairRecord } from './types';
+
+export type DappHandlerContext = {
+ data: Record;
+ pair: PairRecord;
+ mainnet: boolean;
+ fingerprint?: { requested: number; current?: number };
+ mainWindow: BrowserWindow;
+ networkPrefix?: string;
+ dispatchDaemon: (
+ destination: string,
+ command: string,
+ payload: Record,
+ ) => Promise>;
+};
+
+export type DappHandler = (ctx: DappHandlerContext) => Promise<{ data: Record }>;
+
+export type DispatchResponse = { data?: { error?: unknown; [k: string]: unknown } | null } | undefined | null;
+
+// Mirror dispatchAsPair: daemon application errors come back as
+// `response.data.error`. Throw with the camelized payload on `data` so
+// composed handlers (e.g. addCATToken) fail closed instead of returning
+// `{ success: false, error }` as a successful handler result.
+export function processDispatchResponse(response: DispatchResponse): Record {
+ if (response?.data?.error) {
+ const camelErr = toCamelCase(response.data) as Record;
+ throw new WcError(String(response.data.error), WcErrorCode.INTERNAL_ERROR, { data: camelErr });
+ }
+ return toCamelCase(response?.data ?? {}) as Record;
+}
+
+export async function defaultDispatchDaemon(
+ destination: string,
+ command: string,
+ payload: Record,
+): Promise> {
+ const requestId = crypto.randomBytes(32).toString('hex');
+ const wire = {
+ origin: 'wallet_ui',
+ destination,
+ command,
+ data: payload,
+ ack: false,
+ request_id: requestId,
+ };
+ const json = JSONbig.stringify(toSnakeCase(wire));
+ const response = (await sendDappAndAwait(requestId, json)) as DispatchResponse;
+ return processDispatchResponse(response);
+}
+
+// Main owns bypass/grants; ack so legacy dapps still work.
+const requestPermissions: DappHandler = async () => ({ data: { success: true } });
+
+const showNotification: DappHandler = async ({ data, pair, fingerprint, mainWindow }) => {
+ const notification = buildShowNotification(pair, data, fingerprint?.requested);
+ mainWindow.webContents.send(PermissionsAPI.NOTIFICATION_EVENT, notification);
+ return { data: { success: true } };
+};
+
+const addCATToken: DappHandler = async ({ data, dispatchDaemon }) => {
+ // No `add_cat_token` daemon RPC; compose via `create_new_wallet` with
+ // `mode: 'existing'`. Fee is 0 — adding a CAT moves no funds.
+ const result = await dispatchDaemon('chia_wallet', 'create_new_wallet', {
+ wallet_type: 'cat_wallet',
+ mode: 'existing',
+ asset_id: data.asset_id,
+ name: data.name,
+ fee: 0,
+ });
+ return { data: result };
+};
+
+const transferDID: DappHandler = async ({ data, dispatchDaemon }) => {
+ // Defense-in-depth: explicit field pick instead of forwarding `data` whole,
+ // so a future regression in `validateDappParams` can't expand the daemon
+ // call surface here.
+ const result = await dispatchDaemon('chia_wallet', 'did_transfer_did', {
+ wallet_id: data.wallet_id,
+ inner_address: data.inner_address,
+ fee: data.fee,
+ with_recovery_info: data.with_recovery_info,
+ reuse_puzhash: data.reuse_puzhash,
+ });
+ return { data: result };
+};
+
+const createNewDIDWallet: DappHandler = async ({ data, dispatchDaemon }) => {
+ const result = await dispatchDaemon('chia_wallet', 'create_new_wallet', {
+ wallet_type: 'did_wallet',
+ did_type: 'new',
+ backup_dids: data.backup_dids,
+ num_of_backup_ids_needed: data.num_of_backup_ids_needed,
+ amount: data.amount,
+ fee: data.fee,
+ });
+ return { data: result };
+};
+
+const createNewRemoteWallet: DappHandler = async ({ data, dispatchDaemon }) => {
+ const result = await dispatchDaemon('chia_wallet', 'create_new_wallet', {
+ wallet_type: 'remote_wallet',
+ allow_unsynced: data.allow_unsynced,
+ });
+ return { data: result };
+};
+
+export const dappHandlers: Record = {
+ requestPermissions,
+ showNotification,
+ addCATToken,
+ transferDID,
+ createNewDIDWallet,
+ createNewRemoteWallet,
+};
+
+export function getDappHandler(key: string): DappHandler | undefined {
+ return dappHandlers[key];
+}
diff --git a/packages/gui/src/electron/permissions/dispatchAsPair.test.ts b/packages/gui/src/electron/permissions/dispatchAsPair.test.ts
new file mode 100644
index 0000000000..737c1917ff
--- /dev/null
+++ b/packages/gui/src/electron/permissions/dispatchAsPair.test.ts
@@ -0,0 +1,436 @@
+import { WcError, WcErrorCode } from '../../@types/WcError';
+
+import { dispatchDaemonCommandAsPair, type DispatchAsPairDeps } from './dispatchAsPair';
+import type { Decision } from './types';
+
+type TestDeps = Required & {
+ resolvePermission: jest.Mock;
+ renderConfirm: jest.Mock;
+ openConfirm: jest.Mock;
+ captureBypassFromConfirmResult: jest.Mock;
+ sendDappAndAwait: jest.Mock;
+ requestId: jest.Mock;
+};
+
+const baseInput = {
+ wcCommand: 'chia_sendTransaction',
+ data: { address: 'txch1abc', amount: '100', fee: '2' },
+ topic: 'topic-1',
+ mainnet: true,
+ fingerprint: { requested: 123 },
+ networkPrefix: 'txch',
+};
+
+const rendered = {
+ title: 'Confirm Send Transaction',
+ message: 'Review',
+ confirmLabel: 'Send',
+ destructive: false,
+ rows: [],
+ display: undefined,
+};
+
+function makeDeps(decision: Decision, overrides: Partial = {}): TestDeps {
+ return {
+ resolvePermission: jest.fn(async () => decision),
+ renderConfirm: jest.fn(async () => rendered),
+ openConfirm: jest.fn(async () => true),
+ captureBypassFromConfirmResult: jest.fn(),
+ sendDappAndAwait: jest.fn(async () => ({ data: { success: true, transactionId: 'abc' } })),
+ requestId: jest.fn(() => 'request-1'),
+ ...overrides,
+ } as TestDeps;
+}
+
+function parseWire(deps: TestDeps) {
+ const [, json] = deps.sendDappAndAwait.mock.calls[0];
+ return JSON.parse(json as string) as Record;
+}
+
+describe('dispatchDaemonCommandAsPair - auto-approved commands', () => {
+ it('commits allowance usage AFTER the daemon responds successfully', async () => {
+ // Committing before dispatch lets a hostile dapp drain the allowance with
+ // daemon-rejectable requests. The order must be: send → success → commit.
+ const order: string[] = [];
+ const commit = jest.fn(() => order.push('commit'));
+ const deps = makeDeps({ kind: 'allow', commit });
+ deps.sendDappAndAwait.mockImplementationOnce(async () => {
+ order.push('send');
+ return { data: { success: true, transactionId: 'abc' } };
+ });
+
+ const out = await dispatchDaemonCommandAsPair(baseInput, deps);
+
+ expect(out).toEqual({ data: { success: true, transactionId: 'abc' } });
+ expect(order).toEqual(['send', 'commit']);
+ expect(deps.openConfirm).not.toHaveBeenCalled();
+ expect(deps.sendDappAndAwait).toHaveBeenCalledWith('request-1', expect.any(String));
+ expect(parseWire(deps)).toMatchObject({
+ origin: 'wallet_ui',
+ destination: 'chia_wallet',
+ command: 'send_transaction',
+ data: {
+ wallet_id: 1,
+ address: 'txch1abc',
+ amount: '100',
+ fee: '2',
+ },
+ ack: false,
+ request_id: 'request-1',
+ });
+ });
+
+ it('does NOT commit when the daemon returns an application error', async () => {
+ // Daemon errors come back as `response.data.error`. Throw so the WC client
+ // surfaces it; silently returning the envelope would let dapps act on
+ // missing fields. Allowance must not be debited either — an attacker could
+ // spam daemon-rejectable requests to drain it.
+ const commit = jest.fn();
+ const deps = makeDeps(
+ { kind: 'allow', commit },
+ {
+ sendDappAndAwait: jest.fn(async () => ({ data: { success: false, error: 'fee too low' } })),
+ },
+ );
+
+ await expect(dispatchDaemonCommandAsPair(baseInput, deps)).rejects.toThrow('fee too low');
+
+ expect(commit).not.toHaveBeenCalled();
+ });
+
+ it('does NOT commit when the daemon dispatch throws (transport error)', async () => {
+ const commit = jest.fn();
+ const deps = makeDeps(
+ { kind: 'allow', commit },
+ {
+ sendDappAndAwait: jest.fn(async () => {
+ throw new Error('socket closed');
+ }),
+ },
+ );
+
+ await expect(dispatchDaemonCommandAsPair(baseInput, deps)).rejects.toThrow('socket closed');
+ expect(commit).not.toHaveBeenCalled();
+ });
+
+ it('uses alias-specific defaults for alternate WC commands', async () => {
+ const deps = makeDeps({ kind: 'allow', commit: jest.fn() });
+
+ await dispatchDaemonCommandAsPair(
+ {
+ ...baseInput,
+ wcCommand: 'chia_getCurrentAddress',
+ data: {},
+ },
+ deps,
+ );
+
+ expect(parseWire(deps)).toMatchObject({
+ destination: 'chia_wallet',
+ command: 'get_next_address',
+ data: { wallet_id: 1, new_address: false },
+ });
+ });
+});
+
+describe('dispatchDaemonCommandAsPair - prompted commands', () => {
+ it('opens confirm, captures bypass, and does not commit allowance usage', async () => {
+ const deps = makeDeps(
+ {
+ kind: 'prompt',
+ reason: 'spending needs confirmation',
+ pair: { topic: 'topic-1', name: 'Test Dapp', url: 'https://example.com' },
+ },
+ {
+ openConfirm: jest.fn(async () => ({ bypass: true })),
+ },
+ );
+
+ await dispatchDaemonCommandAsPair(baseInput, deps);
+
+ expect(deps.openConfirm).toHaveBeenCalledWith(
+ expect.objectContaining({
+ command: 'chia_wallet.send_transaction',
+ data: { address: 'txch1abc', amount: '100', fee: '2' },
+ principal: { kind: 'pair', name: 'Test Dapp', url: 'https://example.com' },
+ showBypassToggle: true,
+ }),
+ { title: 'Confirm Send Transaction', width: 640, height: 600 },
+ );
+ expect(deps.captureBypassFromConfirmResult).toHaveBeenCalledWith(
+ { bypass: true },
+ { topic: 'topic-1', wcCommand: 'chia_sendTransaction' },
+ expect.any(Object),
+ );
+ });
+
+ it('throws when the user cancels the confirmation', async () => {
+ const deps = makeDeps(
+ { kind: 'prompt', reason: 'needs confirmation', pair: { topic: 'topic-1', name: 'Test Dapp' } },
+ { openConfirm: jest.fn(async () => false) },
+ );
+
+ await expect(dispatchDaemonCommandAsPair(baseInput, deps)).rejects.toThrow('Operation cancelled by user');
+ expect(deps.sendDappAndAwait).not.toHaveBeenCalled();
+ });
+});
+
+async function captureRejection(promise: Promise): Promise {
+ try {
+ await promise;
+ } catch (e) {
+ return e;
+ }
+ throw new Error('expected rejection');
+}
+
+describe('dispatchDaemonCommandAsPair - dapp param validation', () => {
+ // Validation runs before permission and dispatch — fails closed.
+
+ it('rejects an unknown wc command before any other work', async () => {
+ const deps = makeDeps({ kind: 'allow', commit: jest.fn() });
+ const e = await captureRejection(
+ dispatchDaemonCommandAsPair({ ...baseInput, wcCommand: 'chia_definitelyNotReal' }, deps),
+ );
+ expect(e).toBeInstanceOf(WcError);
+ expect((e as WcError).code).toBe(WcErrorCode.METHOD_NOT_FOUND);
+ expect((e as WcError).message).toBe('unknown wc command: chia_definitelyNotReal');
+ expect(deps.resolvePermission).not.toHaveBeenCalled();
+ expect(deps.sendDappAndAwait).not.toHaveBeenCalled();
+ });
+
+ it('rejects a payload key that is not declared in the schema', async () => {
+ const deps = makeDeps({ kind: 'allow', commit: jest.fn() });
+ const e = await captureRejection(
+ dispatchDaemonCommandAsPair(
+ {
+ ...baseInput,
+ data: { amount: '1', fee: '0', address: 'txch1abc', evil_extra: true },
+ },
+ deps,
+ ),
+ );
+ expect(e).toBeInstanceOf(WcError);
+ expect((e as WcError).code).toBe(WcErrorCode.INVALID_PARAMS);
+ expect((e as WcError).message).toBe('param not allowed for dapp: evil_extra');
+ expect(deps.resolvePermission).not.toHaveBeenCalled();
+ expect(deps.sendDappAndAwait).not.toHaveBeenCalled();
+ });
+
+ it('rejects camelCase keys not in the schema (validation runs after snake-casing)', async () => {
+ const deps = makeDeps({ kind: 'allow', commit: jest.fn() });
+ await expect(
+ dispatchDaemonCommandAsPair(
+ {
+ ...baseInput,
+ wcCommand: 'chia_mintBulk',
+ data: { walletId: 1, evilExtra: true },
+ },
+ deps,
+ ),
+ ).rejects.toThrow('param not allowed for dapp: evil_extra');
+ });
+
+ it('drops `fingerprint` from data for schemas that do not declare it (chia dapps include it as routing context)', async () => {
+ // Without this, every non-logIn / non-getPublicKey call would fail with
+ // "param not allowed for dapp: fingerprint" because chia dapps put it
+ // alongside the actual RPC params.
+ const deps = makeDeps({ kind: 'allow', commit: jest.fn() });
+ await dispatchDaemonCommandAsPair(
+ {
+ ...baseInput,
+ data: { amount: '1', fee: '0', address: 'txch1abc', fingerprint: 999 },
+ },
+ deps,
+ );
+ expect(deps.sendDappAndAwait).toHaveBeenCalled();
+ expect(parseWire(deps).data).not.toHaveProperty('fingerprint');
+ });
+
+ it('keeps `fingerprint` in data for schemas that declare it (chia_logIn, chia_getPublicKey)', async () => {
+ const deps = makeDeps({ kind: 'allow', commit: jest.fn() });
+ await dispatchDaemonCommandAsPair(
+ {
+ ...baseInput,
+ wcCommand: 'chia_logIn',
+ data: { fingerprint: 7777 },
+ },
+ deps,
+ );
+ expect(parseWire(deps).data).toMatchObject({ fingerprint: 7777 });
+ });
+});
+
+describe('dispatchDaemonCommandAsPair - response transform', () => {
+ // Schemas can declare `dapp.transformResponse` to reshape the daemon's
+ // response into what dapps written against the legacy api-react endpoint
+ // already expect. Without this, dapps that read e.g. `data.find(...)` on
+ // `chia_getWallets` break because the raw daemon response is an object,
+ // not a wallets array.
+
+ it('applies transformResponse for chia_getWallets — dapp receives the wallets array', async () => {
+ const deps = makeDeps(
+ { kind: 'allow', commit: jest.fn() },
+ {
+ sendDappAndAwait: jest.fn(async () => ({
+ data: {
+ success: true,
+ fingerprint: 0xa_bc,
+ wallets: [
+ { id: 1, type: 0, name: 'Standard' },
+ { id: 2, type: 6, name: 'CAT' },
+ ],
+ },
+ })),
+ },
+ );
+
+ const out = await dispatchDaemonCommandAsPair(
+ { ...baseInput, wcCommand: 'chia_getWallets', data: { include_data: true } },
+ deps,
+ );
+
+ expect(out).toEqual({
+ data: [
+ { id: 1, type: 0, name: 'Standard' },
+ { id: 2, type: 6, name: 'CAT' },
+ ],
+ });
+ });
+
+ it('falls back to the camelCased response when no transformResponse is declared', async () => {
+ // chia_sendTransaction has no transformResponse; dapp gets the raw shape.
+ const deps = makeDeps(
+ { kind: 'allow', commit: jest.fn() },
+ { sendDappAndAwait: jest.fn(async () => ({ data: { success: true, transaction_id: 'abc' } })) },
+ );
+ const out = await dispatchDaemonCommandAsPair(baseInput, deps);
+ expect(out).toEqual({ data: { success: true, transactionId: 'abc' } });
+ });
+});
+
+describe('dispatchDaemonCommandAsPair - handler routing', () => {
+ function makeHandlerDeps(decision: Decision, overrides: Partial = {}) {
+ return makeDeps(decision, {
+ getDappHandler: jest.fn(),
+ dispatchDaemon: jest.fn(),
+ ...overrides,
+ });
+ }
+
+ const handlerInput = {
+ ...baseInput,
+ wcCommand: 'chia_addCATToken',
+ data: { asset_id: 'abc', name: 'Test CAT' },
+ mainWindow: {} as never,
+ pair: { topic: 'topic-1', metadata: { name: 'Test Dapp' } } as never,
+ };
+
+ it('invokes the registered handler instead of the daemon', async () => {
+ const handler = jest.fn(async () => ({ data: { success: true, walletId: 5 } }));
+ const deps = makeHandlerDeps(
+ { kind: 'allow', commit: jest.fn() },
+ {
+ getDappHandler: jest.fn((key: string) => (key === 'addCATToken' ? handler : undefined)),
+ },
+ );
+
+ const out = await dispatchDaemonCommandAsPair(handlerInput, deps);
+
+ expect(handler).toHaveBeenCalledTimes(1);
+ expect(out).toEqual({ data: { success: true, walletId: 5 } });
+ expect(deps.sendDappAndAwait).not.toHaveBeenCalled();
+ });
+
+ it('throws when the handler key has no registered implementation', async () => {
+ const deps = makeHandlerDeps(
+ { kind: 'allow', commit: jest.fn() },
+ {
+ getDappHandler: jest.fn(() => undefined),
+ },
+ );
+
+ await expect(dispatchDaemonCommandAsPair(handlerInput, deps)).rejects.toThrow(
+ 'no handler registered for addCATToken',
+ );
+ expect(deps.sendDappAndAwait).not.toHaveBeenCalled();
+ });
+
+ it('still runs validation + permission gate before the handler', async () => {
+ const handler = jest.fn();
+ const deps = makeHandlerDeps(
+ { kind: 'allow', commit: jest.fn() },
+ {
+ getDappHandler: jest.fn(() => handler),
+ },
+ );
+
+ await expect(
+ dispatchDaemonCommandAsPair(
+ {
+ ...handlerInput,
+ data: { asset_id: 'abc', name: 'Test', evil_extra: true },
+ },
+ deps,
+ ),
+ ).rejects.toThrow('param not allowed for dapp: evil_extra');
+ expect(handler).not.toHaveBeenCalled();
+ });
+});
+
+describe('dispatchDaemonCommandAsPair - daemon response contract', () => {
+ it('throws WcError(INTERNAL_ERROR) on daemon application errors so the dapp sees a real failure', async () => {
+ // Without this, the dapp's WC client resolves successfully with
+ // `{ success: false, error: ... }` as the payload — and dapps that look
+ // for a specific field (e.g., `offer`) silently take a wrong code path.
+ const deps = makeDeps(
+ { kind: 'allow', commit: jest.fn() },
+ {
+ sendDappAndAwait: jest.fn(async () => ({
+ data: { success: false, error: "Coin ID's not found" },
+ })),
+ },
+ );
+
+ const promise = dispatchDaemonCommandAsPair(baseInput, deps);
+ await expect(promise).rejects.toBeInstanceOf(WcError);
+ await expect(promise).rejects.toMatchObject({
+ code: WcErrorCode.INTERNAL_ERROR,
+ message: "Coin ID's not found",
+ });
+ });
+
+ it('attaches the camelized daemon payload as WcError.data so it survives JSON-RPC clients that canonicalize message by code', async () => {
+ // Many dapp-side JSON-RPC clients display the canonical "Internal error"
+ // label for `-32603` and only surface the real payload through
+ // `error.data`. Snake-cased fields from the daemon must be camelized to
+ // match every other dapp-facing payload shape.
+ const deps = makeDeps(
+ { kind: 'allow', commit: jest.fn() },
+ {
+ sendDappAndAwait: jest.fn(async () => ({
+ data: { success: false, error: 'fee too low', wallet_id: 1 },
+ })),
+ },
+ );
+
+ const e = await captureRejection(dispatchDaemonCommandAsPair(baseInput, deps));
+ expect(e).toBeInstanceOf(WcError);
+ expect((e as WcError).data).toEqual({ success: false, error: 'fee too low', walletId: 1 });
+ });
+
+ it('camel-cases successful daemon response data for dapps', async () => {
+ const deps = makeDeps(
+ { kind: 'allow', commit: jest.fn() },
+ {
+ sendDappAndAwait: jest.fn(async () => ({
+ data: { transaction_id: 'abc', wallet_id: 1 },
+ })),
+ },
+ );
+
+ await expect(dispatchDaemonCommandAsPair(baseInput, deps)).resolves.toEqual({
+ data: { transactionId: 'abc', walletId: 1 },
+ });
+ });
+});
diff --git a/packages/gui/src/electron/permissions/dispatchAsPair.ts b/packages/gui/src/electron/permissions/dispatchAsPair.ts
new file mode 100644
index 0000000000..2475ae6bbe
--- /dev/null
+++ b/packages/gui/src/electron/permissions/dispatchAsPair.ts
@@ -0,0 +1,209 @@
+import type { BrowserWindow } from 'electron';
+import crypto from 'node:crypto';
+
+import JSONbig from 'json-bigint';
+
+import { WcError, WcErrorCode } from '../../@types/WcError';
+import { applyDefaults, getCommandByWc, resolveDispatch, validateDappParams } from '../constants/commandRegistry';
+import type { ConfirmProps } from '../dialogs/Confirm/Confirm';
+import { renderConfirm } from '../dialogs/Confirm/renderConfirm';
+import toSnakeCase from '../utils/toSnakeCase';
+import { sendDappAndAwait } from '../utils/webSocketBridge';
+
+import { captureBypassFromConfirmResult } from './bypassCapture';
+import { defaultDispatchDaemon, getDappHandler, processDispatchResponse } from './dappHandlers';
+import { getPair, upsertPair } from './pairStore';
+import { resolvePermission } from './permissions';
+import type { PairRecord, Principal } from './types';
+
+export type DispatchAsPairFingerprint = {
+ requested: number;
+ current?: number;
+ requestedLabel?: string;
+ currentLabel?: string;
+};
+
+export type DispatchAsPairInput = {
+ wcCommand: string;
+ data: Record;
+ topic: string;
+ mainnet: boolean;
+ fingerprint?: DispatchAsPairFingerprint;
+ networkPrefix?: string;
+ /** Required for handlers that emit IPC events (e.g. showNotification). */
+ mainWindow: BrowserWindow;
+ /** Caller has already passed `checkPairAccess`. */
+ pair: PairRecord;
+};
+
+export type DispatchConfirmProps = Omit;
+
+export type OpenConfirm = (
+ props: DispatchConfirmProps,
+ options: { title: string; width: number; height: number },
+) => Promise | undefined>;
+
+export type DispatchAsPairDeps = {
+ openConfirm: OpenConfirm;
+ resolvePermission?: typeof resolvePermission;
+ renderConfirm?: typeof renderConfirm;
+ captureBypassFromConfirmResult?: typeof captureBypassFromConfirmResult;
+ sendDappAndAwait?: typeof sendDappAndAwait;
+ requestId?: () => string;
+ getDappHandler?: typeof getDappHandler;
+ dispatchDaemon?: typeof defaultDispatchDaemon;
+};
+
+const BROKEN_BIGINT_RE = /^-?\d+n$/;
+
+function deepFixBrokenBigInts(value: unknown): unknown {
+ if (typeof value === 'string' && BROKEN_BIGINT_RE.test(value)) {
+ return BigInt(value.slice(0, -1));
+ }
+ if (Array.isArray(value)) return value.map(deepFixBrokenBigInts);
+ if (value !== null && typeof value === 'object') {
+ const out: Record = {};
+ for (const [k, v] of Object.entries(value)) {
+ out[k] = deepFixBrokenBigInts(v);
+ }
+ return out;
+ }
+ return value;
+}
+
+export async function dispatchDaemonCommandAsPair(
+ input: DispatchAsPairInput,
+ deps: DispatchAsPairDeps,
+): Promise<{ data: Record }> {
+ const { wcCommand, topic, mainnet, fingerprint, networkPrefix, mainWindow, pair } = input;
+ const data = deepFixBrokenBigInts(input.data) as Record;
+
+ const entry = getCommandByWc(wcCommand);
+ if (!entry) {
+ throw new WcError(`unknown wc command: ${wcCommand}`, WcErrorCode.METHOD_NOT_FOUND);
+ }
+
+ // Snake-case before any field read so case-folding can't dodge the gate.
+ const snakeData = toSnakeCase(data) as Record;
+
+ // chia dapps include `fingerprint` in `params` as a routing field. Schemas
+ // that take it as a real RPC param (chia_logIn, chia_getPublicKey) declare
+ // it explicitly; for everything else it would just be a stray key that
+ // `validateDappParams` would reject. Strip it on the non-declaring path so
+ // those calls don't fail with "param not allowed for dapp: fingerprint".
+ // Matches legacy `prepareWalletConnectCommand`, which filtered `values`
+ // down to the schema-declared params.
+ const declaresFingerprint = entry.schema.params.some((p) => p.name === 'fingerprint');
+ if (!declaresFingerprint) {
+ delete snakeData.fingerprint;
+ }
+
+ validateDappParams(wcCommand, snakeData);
+
+ const principal: Principal = { kind: 'pair', topic };
+ const permission = deps.resolvePermission ?? resolvePermission;
+ const { nsCommand } = entry;
+ const decision = await permission(principal, nsCommand, snakeData, {
+ wcCommand,
+ fingerprint: fingerprint?.requested,
+ mainnet,
+ });
+ if (decision.kind === 'deny') {
+ throw new WcError(decision.reason, decision.code);
+ }
+
+ if (decision.kind === 'prompt') {
+ const render = deps.renderConfirm ?? renderConfirm;
+ const rendered = await render(nsCommand, snakeData, { networkPrefix });
+ const result = await deps.openConfirm(
+ {
+ networkPrefix,
+ command: nsCommand,
+ data: snakeData,
+ title: rendered.title,
+ message: rendered.message,
+ confirmLabel: rendered.confirmLabel,
+ destructive: rendered.destructive,
+ rows: rendered.rows,
+ display: rendered.display,
+ principal: decision.pair
+ ? {
+ kind: 'pair' as const,
+ name: decision.pair.name,
+ url: decision.pair.url,
+ icon: decision.pair.icon,
+ description: decision.pair.description,
+ }
+ : undefined,
+ fingerprint,
+ showBypassToggle: !!decision.pair,
+ },
+ {
+ title: rendered.title,
+ width: 640,
+ height: 600,
+ },
+ );
+ if (result === false || result === undefined) {
+ throw new WcError('Operation cancelled by user', WcErrorCode.USER_REJECTED);
+ }
+ const capture = deps.captureBypassFromConfirmResult ?? captureBypassFromConfirmResult;
+ capture(result, { topic, wcCommand }, { getPair, upsertPair });
+ }
+ // Auto-approved spends defer their `decision.commit()` until after a
+ // successful dispatch — committing early would let a hostile dapp drain
+ // the user's allowance with daemon-rejected requests. Idempotent commits
+ // (`consumed` flag) protect against double-charge.
+
+ const dispatchDaemon = deps.dispatchDaemon ?? defaultDispatchDaemon;
+ const handlerLookup = deps.getDappHandler ?? getDappHandler;
+
+ if (entry.handlerKey) {
+ const handler = handlerLookup(entry.handlerKey);
+ if (!handler) {
+ throw new WcError(`no handler registered for ${entry.handlerKey}`, WcErrorCode.INTERNAL_ERROR);
+ }
+ const out = await handler({
+ data: snakeData,
+ pair,
+ mainnet,
+ fingerprint: fingerprint ? { requested: fingerprint.requested, current: fingerprint.current } : undefined,
+ mainWindow,
+ networkPrefix,
+ dispatchDaemon,
+ });
+ if (decision.kind === 'allow') decision.commit();
+ return out;
+ }
+
+ const { destination, command } = resolveDispatch(wcCommand);
+
+ const requestId = deps.requestId?.() ?? crypto.randomBytes(32).toString('hex');
+ const wireData = applyDefaults(wcCommand, snakeData);
+ const wire = {
+ origin: 'wallet_ui',
+ destination,
+ command,
+ data: wireData,
+ ack: false,
+ request_id: requestId,
+ };
+ const json = JSONbig.stringify(toSnakeCase(wire));
+ const send = deps.sendDappAndAwait ?? sendDappAndAwait;
+ const response = (await send(requestId, json)) as {
+ data?: { error?: unknown; [k: string]: unknown };
+ };
+
+ // Throws on daemon errors; don't debit the allowance on throw since an
+ // attacker could otherwise drain it with daemon-rejectable requests.
+ const camel = processDispatchResponse(response);
+
+ if (decision.kind === 'allow') {
+ decision.commit();
+ }
+ // Per-schema reshape so dapp-facing payloads match what the legacy
+ // api-react endpoints emitted (e.g. `chia_getWallets` → wallets array,
+ // not `{ wallets: [...] }`).
+ const dappData = entry.schema.dapp?.transformResponse ? entry.schema.dapp.transformResponse(camel) : camel;
+ return { data: dappData as Record };
+}
diff --git a/packages/gui/src/electron/permissions/pairDialog.test.ts b/packages/gui/src/electron/permissions/pairDialog.test.ts
new file mode 100644
index 0000000000..0f6e46000d
--- /dev/null
+++ b/packages/gui/src/electron/permissions/pairDialog.test.ts
@@ -0,0 +1,109 @@
+import {
+ classifyForPairDialog,
+ dialogResultToBypass,
+ dialogResultToFingerprints,
+ dialogResultToGrants,
+} from './pairDialog';
+
+describe('dialogResultToGrants', () => {
+ it('stores zero when the allowance checkbox is unchecked, even if the input has a value', () => {
+ expect(dialogResultToGrants({ enableAllowance: false, allowanceXch: '0.01' })).toEqual({ xchMojos: '0' });
+ expect(dialogResultToGrants({ allowanceXch: '0.01' })).toEqual({ xchMojos: '0' });
+ });
+
+ it('converts enabled XCH allowance to whole mojos', () => {
+ expect(dialogResultToGrants({ enableAllowance: true, allowanceXch: '0.01' })).toEqual({
+ xchMojos: '10000000000',
+ });
+ });
+
+ it('floors fractional mojos and rejects non-positive or invalid values', () => {
+ expect(dialogResultToGrants({ enableAllowance: true, allowanceXch: '0.0000000000019' })).toEqual({
+ xchMojos: '1',
+ });
+ expect(dialogResultToGrants({ enableAllowance: true, allowanceXch: '-1' })).toEqual({ xchMojos: '0' });
+ expect(dialogResultToGrants({ enableAllowance: true, allowanceXch: 'oops' })).toEqual({ xchMojos: '0' });
+ });
+});
+
+describe('dialogResultToBypass', () => {
+ it('keeps only the wcCommands that were granted to the pair', () => {
+ // The form scraper produces a `bypass` array of values from the checked
+ // boxes. Unchecked boxes simply don't appear, so there's no `false` to
+ // filter — we just gate on the granted-set.
+ expect(
+ dialogResultToBypass({ bypass: ['chia_sendTransaction', 'chia_getWallets', 'chia_notGranted'] }, [
+ 'chia_sendTransaction',
+ 'chia_takeOffer',
+ ]),
+ ).toEqual(['chia_sendTransaction']);
+ });
+
+ it('returns [] when the bypass field is missing or not an array', () => {
+ expect(dialogResultToBypass({}, ['chia_sendTransaction'])).toEqual([]);
+ expect(dialogResultToBypass({ bypass: 'chia_sendTransaction' }, ['chia_sendTransaction'])).toEqual([]);
+ expect(dialogResultToBypass({ bypass: null }, ['chia_sendTransaction'])).toEqual([]);
+ });
+
+ it('skips non-string entries inside the array', () => {
+ expect(
+ dialogResultToBypass({ bypass: ['chia_sendTransaction', 42, null, true] }, ['chia_sendTransaction']),
+ ).toEqual(['chia_sendTransaction']);
+ });
+
+ it('drops sign-class commands — `permissions.resolvePermission` always prompts for them, so a persisted bypass would silently no-op', () => {
+ expect(
+ dialogResultToBypass({ bypass: ['chia_signMessageByAddress', 'chia_signMessageById', 'chia_sendTransaction'] }, [
+ 'chia_signMessageByAddress',
+ 'chia_signMessageById',
+ 'chia_sendTransaction',
+ ]),
+ ).toEqual(['chia_sendTransaction']);
+ });
+});
+
+describe('dialogResultToFingerprints', () => {
+ it('keeps finite numeric fingerprints only', () => {
+ expect(dialogResultToFingerprints({ wallets: ['123', 456, 'bad', Infinity] })).toEqual([123, 456]);
+ });
+
+ it('defaults to an empty list for malformed input', () => {
+ expect(dialogResultToFingerprints({ wallets: '123' })).toEqual([]);
+ });
+});
+
+describe('classifyForPairDialog', () => {
+ it('groups spend commands separately from other commands', () => {
+ expect(
+ classifyForPairDialog([
+ 'chia_getWallets',
+ 'chia_getWalletBalance',
+ 'chia_signMessageByAddress',
+ 'chia_showNotification',
+ 'chia_sendTransaction',
+ 'chia_createOfferForIds',
+ 'chia_takeOffer',
+ 'chia_pushTransactions',
+ 'chia_logIn',
+ ]),
+ ).toEqual({
+ innocuous: ['chia_getWallets'],
+ balance: ['chia_getWalletBalance'],
+ sign: ['chia_signMessageByAddress'],
+ notifications: ['chia_showNotification'],
+ spending: ['chia_sendTransaction', 'chia_createOfferForIds', 'chia_takeOffer', 'chia_pushTransactions'],
+ other: ['chia_logIn'],
+ });
+ });
+
+ it('ignores unknown commands rather than surfacing impossible toggles', () => {
+ expect(classifyForPairDialog(['chia_totallyMadeUp'])).toEqual({
+ innocuous: [],
+ balance: [],
+ sign: [],
+ notifications: [],
+ spending: [],
+ other: [],
+ });
+ });
+});
diff --git a/packages/gui/src/electron/permissions/pairDialog.ts b/packages/gui/src/electron/permissions/pairDialog.ts
new file mode 100644
index 0000000000..da8b0f85a7
--- /dev/null
+++ b/packages/gui/src/electron/permissions/pairDialog.ts
@@ -0,0 +1,85 @@
+import BigNumber from 'bignumber.js';
+
+import Unit from '../constants/Unit';
+import { getCommandByWc } from '../constants/commandRegistry';
+import chiaFormatter from '../utils/chiaFormatter';
+
+import { isBalanceCommand, isInnocuousCommand, isSignCommand, isSpendCommand } from './commandCapabilities';
+import type { PairGrants } from './types';
+
+export function dialogResultToGrants(result: Record): PairGrants {
+ // Unchecked checkbox -> allowance is 0 regardless of input value.
+ if (result.enableAllowance !== true) {
+ return { xchMojos: '0' };
+ }
+ let mojos = '0';
+ const rawXch = result.allowanceXch;
+ if (rawXch !== null && rawXch !== undefined && rawXch !== '') {
+ try {
+ const xch = new BigNumber(typeof rawXch === 'string' ? rawXch : String(rawXch));
+ if (xch.isFinite() && xch.isGreaterThan(0)) {
+ mojos = chiaFormatter(xch, Unit.CHIA)
+ .to(Unit.MOJO)
+ .toBigNumber()
+ .integerValue(BigNumber.ROUND_FLOOR)
+ .toFixed(0);
+ }
+ } catch {
+ // Invalid input -> allowance stays 0.
+ }
+ }
+ return { xchMojos: mojos };
+}
+
+// Reads the multi-checkbox `bypass` form field (an array of wire-form wcCommands
+// for the boxes the user ticked). Filters to the dapp's actually-granted
+// commands so a stray value can't grant something the registry didn't allow
+// at pair time. Drops sign-class commands — `permissions.resolvePermission`
+// always prompts for them, so persisting a bypass entry would silently no-op
+// and mislead the user at edit time (the toggle would re-appear pre-checked
+// despite never taking effect).
+export function dialogResultToBypass(result: Record, granted: string[]): string[] {
+ const grantedSet = new Set(granted);
+ const raw = Array.isArray(result.bypass) ? result.bypass : [];
+ const bypass: string[] = [];
+ for (const item of raw) {
+ if (typeof item === 'string' && grantedSet.has(item)) {
+ const ns = getCommandByWc(item)?.nsCommand;
+ if (!ns || !isSignCommand(ns)) {
+ bypass.push(item);
+ }
+ }
+ }
+ return bypass;
+}
+
+export function dialogResultToFingerprints(result: Record): number[] {
+ const raw = result.wallets;
+ const list = Array.isArray(raw) ? raw : [];
+ return list.map((v) => Number(v)).filter((n) => Number.isFinite(n));
+}
+
+export function classifyForPairDialog(grantedWireCommands: string[]) {
+ const innocuous: string[] = [];
+ const balance: string[] = [];
+ const sign: string[] = [];
+ const notifications: string[] = [];
+ const spending: string[] = [];
+ const other: string[] = [];
+ for (const wcCommand of grantedWireCommands) {
+ if (wcCommand === 'chia_showNotification') {
+ notifications.push(wcCommand);
+ } else {
+ const entry = getCommandByWc(wcCommand);
+ if (entry) {
+ const { nsCommand } = entry;
+ if (isBalanceCommand(nsCommand)) balance.push(wcCommand);
+ else if (isInnocuousCommand(nsCommand)) innocuous.push(wcCommand);
+ else if (isSignCommand(nsCommand)) sign.push(wcCommand);
+ else if (isSpendCommand(nsCommand)) spending.push(wcCommand);
+ else other.push(wcCommand);
+ }
+ }
+ }
+ return { innocuous, balance, sign, notifications, spending, other };
+}
diff --git a/packages/gui/src/electron/permissions/pairStore.test.ts b/packages/gui/src/electron/permissions/pairStore.test.ts
new file mode 100644
index 0000000000..7868f67886
--- /dev/null
+++ b/packages/gui/src/electron/permissions/pairStore.test.ts
@@ -0,0 +1,528 @@
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+
+import BigNumber from 'bignumber.js';
+
+import type { PairRecord } from './types';
+
+let mockTempDir: string;
+
+jest.mock('../utils/userData', () => ({
+ // Resolved lazily so each test sees the per-test directory.
+ getUserDataDir: () => mockTempDir,
+}));
+
+// Pulled in after the mock so the module reads the patched userDataDir.
+const loadStore = (): typeof import('./pairStore') => {
+ jest.resetModules();
+ // eslint-disable-next-line global-require -- module must be required after jest.resetModules to pick up mocked deps
+ return require('./pairStore');
+};
+
+function makePair(overrides: Partial = {}): PairRecord {
+ return {
+ topic: 'topic-1',
+ mainnet: true,
+ metadata: { name: 'Test Dapp' },
+ fingerprints: [123],
+ createdAt: 1,
+ updatedAt: 1,
+ usedMojos: '0',
+ commands: [],
+ bypass: [],
+ grants: { xchMojos: '0' },
+ ...overrides,
+ };
+}
+
+beforeEach(() => {
+ mockTempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pair-store-test-'));
+});
+
+afterEach(() => {
+ fs.rmSync(mockTempDir, { recursive: true, force: true });
+});
+
+describe('pairStore - listPairs / getPair / upsertPair / removePair', () => {
+ it('returns empty list when no file exists', () => {
+ const store = loadStore();
+ expect(store.listPairs()).toEqual([]);
+ expect(store.getPair('topic-1')).toBeUndefined();
+ });
+
+ it('persists a pair across module reloads', () => {
+ const store = loadStore();
+ store.upsertPair(makePair({ topic: 'a' }));
+
+ expect(fs.existsSync(path.join(mockTempDir, 'dapp-pairs.yaml'))).toBe(true);
+
+ const reload = loadStore();
+ const pairs = reload.listPairs();
+ expect(pairs).toHaveLength(1);
+ expect(pairs[0].topic).toBe('a');
+ expect(reload.getPair('a')?.topic).toBe('a');
+ });
+
+ it('replaces an existing pair on upsert (no duplicates)', () => {
+ const store = loadStore();
+ store.upsertPair(makePair({ topic: 'a', metadata: { name: 'First' } }));
+ store.upsertPair(makePair({ topic: 'a', metadata: { name: 'Second' } }));
+
+ const pairs = store.listPairs();
+ expect(pairs).toHaveLength(1);
+ expect(pairs[0].metadata.name).toBe('Second');
+ });
+
+ it('removes a pair', () => {
+ const store = loadStore();
+ store.upsertPair(makePair({ topic: 'a' }));
+ store.upsertPair(makePair({ topic: 'b' }));
+
+ store.removePair('a');
+ expect(store.getPair('a')).toBeUndefined();
+ expect(store.getPair('b')?.topic).toBe('b');
+ });
+
+ it('listPairs returns a copy (callers cannot mutate cache)', () => {
+ const store = loadStore();
+ store.upsertPair(makePair({ topic: 'a' }));
+ const pairs = store.listPairs();
+ pairs.push(makePair({ topic: 'b' }));
+ expect(store.listPairs()).toHaveLength(1);
+ });
+});
+
+describe('pairStore - recordUsage (allowance accounting)', () => {
+ it('accumulates used mojos across multiple calls', () => {
+ const store = loadStore();
+ store.upsertPair(makePair({ topic: 'a', usedMojos: '0' }));
+
+ store.recordUsage('a', new BigNumber(100));
+ expect(store.getPair('a')?.usedMojos).toBe('100');
+
+ store.recordUsage('a', new BigNumber(250));
+ expect(store.getPair('a')?.usedMojos).toBe('350');
+
+ store.recordUsage('a', new BigNumber(1));
+ expect(store.getPair('a')?.usedMojos).toBe('351');
+ });
+
+ it('persists accumulated usage across module reloads', () => {
+ const a = loadStore();
+ a.upsertPair(makePair({ topic: 'a', usedMojos: '500' }));
+ a.recordUsage('a', new BigNumber(123));
+
+ const b = loadStore();
+ expect(b.getPair('a')?.usedMojos).toBe('623');
+ });
+
+ it('is a no-op for unknown topic', () => {
+ const store = loadStore();
+ store.recordUsage('unknown', new BigNumber(100));
+ expect(store.listPairs()).toEqual([]);
+ });
+
+ it('is a no-op for non-positive amounts', () => {
+ const store = loadStore();
+ store.upsertPair(makePair({ topic: 'a', usedMojos: '500' }));
+
+ store.recordUsage('a', new BigNumber(0));
+ store.recordUsage('a', new BigNumber(-1));
+ store.recordUsage('a', new BigNumber('-9999999999999999999'));
+
+ expect(store.getPair('a')?.usedMojos).toBe('500');
+ });
+
+ it('is a no-op for non-finite amounts', () => {
+ const store = loadStore();
+ store.upsertPair(makePair({ topic: 'a', usedMojos: '500' }));
+
+ store.recordUsage('a', new BigNumber(NaN));
+ store.recordUsage('a', new BigNumber(Infinity));
+
+ expect(store.getPair('a')?.usedMojos).toBe('500');
+ });
+
+ it('preserves precision beyond JS safe-integer range', () => {
+ const store = loadStore();
+ store.upsertPair(makePair({ topic: 'a', usedMojos: '0' }));
+
+ const huge = new BigNumber('99999999999999999999');
+ store.recordUsage('a', huge);
+ store.recordUsage('a', new BigNumber(1));
+
+ expect(store.getPair('a')?.usedMojos).toBe('100000000000000000000');
+ });
+
+ it('truncates fractional mojos via toFixed(0)', () => {
+ const store = loadStore();
+ store.upsertPair(makePair({ topic: 'a', usedMojos: '0' }));
+
+ // toFixed(0) rounds half-to-even on BigNumber by default.
+ store.recordUsage('a', new BigNumber('1.4'));
+ expect(store.getPair('a')?.usedMojos).toBe('1');
+
+ store.recordUsage('a', new BigNumber('0.7'));
+ // 1 + 0.7 = 1.7 → rounded to 2.
+ expect(store.getPair('a')?.usedMojos).toBe('2');
+ });
+
+ it('treats undefined usedMojos on the existing record as zero', () => {
+ loadStore();
+ const file = path.join(mockTempDir, 'dapp-pairs.yaml');
+ const record = makePair({ topic: 'a' });
+ delete (record as Partial).usedMojos;
+ fs.writeFileSync(file, `pairs:\n - ${JSON.stringify(record)}\n`);
+
+ const reload = loadStore();
+ reload.recordUsage('a', new BigNumber(42));
+ expect(reload.getPair('a')?.usedMojos).toBe('42');
+ });
+
+ it('does not affect siblings', () => {
+ const store = loadStore();
+ store.upsertPair(makePair({ topic: 'a', usedMojos: '100' }));
+ store.upsertPair(makePair({ topic: 'b', usedMojos: '200' }));
+
+ store.recordUsage('a', new BigNumber(50));
+
+ expect(store.getPair('a')?.usedMojos).toBe('150');
+ expect(store.getPair('b')?.usedMojos).toBe('200');
+ });
+});
+
+describe('pairStore - commands field normalization', () => {
+ it('defaults to [] when the field is absent on disk', () => {
+ loadStore();
+ const file = path.join(mockTempDir, 'dapp-pairs.yaml');
+ const record = makePair({ topic: 'a' });
+ delete (record as Partial).commands;
+ fs.writeFileSync(file, `pairs:\n - ${JSON.stringify(record)}\n`);
+
+ const reload = loadStore();
+ expect(reload.getPair('a')?.commands).toEqual([]);
+ });
+
+ it('defaults to [] when the field is non-array on disk', () => {
+ const file = path.join(mockTempDir, 'dapp-pairs.yaml');
+ const record = makePair({ topic: 'a' });
+ (record as unknown as { commands: unknown }).commands = 'oops';
+ fs.writeFileSync(file, `pairs:\n - ${JSON.stringify(record)}\n`);
+
+ const reload = loadStore();
+ expect(reload.getPair('a')?.commands).toEqual([]);
+ });
+
+ it('strips non-string entries from the persisted list', () => {
+ const file = path.join(mockTempDir, 'dapp-pairs.yaml');
+ const record = makePair({ topic: 'a' });
+ (record as unknown as { commands: unknown }).commands = ['chia_sendTransaction', 42, null, 'chia_getWallets'];
+ fs.writeFileSync(file, `pairs:\n - ${JSON.stringify(record)}\n`);
+
+ const reload = loadStore();
+ expect(reload.getPair('a')?.commands).toEqual(['chia_sendTransaction', 'chia_getWallets']);
+ });
+
+ it('round-trips a real list through write+read', () => {
+ const store = loadStore();
+ store.upsertPair(makePair({ topic: 'a', commands: ['chia_sendTransaction', 'chia_getWallets'] }));
+
+ const reload = loadStore();
+ expect(reload.getPair('a')?.commands).toEqual(['chia_sendTransaction', 'chia_getWallets']);
+ });
+});
+
+describe('pairStore - bypass field normalization', () => {
+ it('defaults to [] when the field is absent on disk', () => {
+ const file = path.join(mockTempDir, 'dapp-pairs.yaml');
+ const record = makePair({ topic: 'a' });
+ delete (record as Partial).bypass;
+ fs.writeFileSync(file, `pairs:\n - ${JSON.stringify(record)}\n`);
+
+ const reload = loadStore();
+ expect(reload.getPair('a')?.bypass).toEqual([]);
+ });
+
+ it('defaults to [] when the field is non-array on disk', () => {
+ const file = path.join(mockTempDir, 'dapp-pairs.yaml');
+ const record = makePair({ topic: 'a' });
+ (record as unknown as { bypass: unknown }).bypass = { 0: 'chia_x' };
+ fs.writeFileSync(file, `pairs:\n - ${JSON.stringify(record)}\n`);
+
+ const reload = loadStore();
+ expect(reload.getPair('a')?.bypass).toEqual([]);
+ });
+
+ it('round-trips a real list through write+read', () => {
+ const store = loadStore();
+ store.upsertPair(makePair({ topic: 'a', bypass: ['chia_getWallets', 'chia_signMessageById'] }));
+
+ const reload = loadStore();
+ expect(reload.getPair('a')?.bypass).toEqual(['chia_getWallets', 'chia_signMessageById']);
+ });
+});
+
+describe('pairStore - mainnet field normalization', () => {
+ it('defaults to true when the field is absent on disk', () => {
+ const file = path.join(mockTempDir, 'dapp-pairs.yaml');
+ const record = makePair({ topic: 'a' });
+ delete (record as Partial).mainnet;
+ fs.writeFileSync(file, `pairs:\n - ${JSON.stringify(record)}\n`);
+
+ const reload = loadStore();
+ expect(reload.getPair('a')?.mainnet).toBe(true);
+ });
+
+ it('preserves an explicit false', () => {
+ const store = loadStore();
+ store.upsertPair(makePair({ topic: 'a', mainnet: false }));
+
+ const reload = loadStore();
+ expect(reload.getPair('a')?.mainnet).toBe(false);
+ });
+});
+
+describe('pairStore - grant normalization', () => {
+ it('treats missing grants as `xchMojos: "0"`', () => {
+ const file = path.join(mockTempDir, 'dapp-pairs.yaml');
+ const pair = makePair({ topic: 'a' });
+ delete (pair as Partial).grants;
+ fs.writeFileSync(file, `pairs:\n - ${JSON.stringify(pair)}\n`);
+
+ const reload = loadStore();
+ expect(reload.getPair('a')?.grants).toEqual({ xchMojos: '0' });
+ });
+
+ it('treats missing usage as zero', () => {
+ const file = path.join(mockTempDir, 'dapp-pairs.yaml');
+ const pair = makePair({ topic: 'a' });
+ delete (pair as Partial).usedMojos;
+ fs.writeFileSync(file, `pairs:\n - ${JSON.stringify(pair)}\n`);
+
+ const reload = loadStore();
+ expect(reload.getPair('a')?.usedMojos).toBe('0');
+ });
+});
+
+describe('pairStore - bypass command preservation', () => {
+ // `bypass` is exact command-level trust. Spend-class wcCommands are valid
+ // here too; the XCH allowance is only the bounded fallback when no bypass
+ // entry exists.
+
+ it('preserves `chia_pushTransactions` in a persisted bypass list', () => {
+ const file = path.join(mockTempDir, 'dapp-pairs.yaml');
+ const record = makePair({
+ topic: 'a',
+ bypass: ['chia_getWallets', 'chia_pushTransactions', 'chia_signMessageByAddress'],
+ });
+ fs.writeFileSync(file, `pairs:\n - ${JSON.stringify(record)}\n`);
+
+ const reload = loadStore();
+ expect(reload.getPair('a')?.bypass).toEqual([
+ 'chia_getWallets',
+ 'chia_pushTransactions',
+ 'chia_signMessageByAddress',
+ ]);
+ });
+
+ it('preserves spend wcCommands in bypass', () => {
+ const file = path.join(mockTempDir, 'dapp-pairs.yaml');
+ const record = makePair({
+ topic: 'a',
+ bypass: [
+ 'chia_sendTransaction',
+ 'chia_createOfferForIds',
+ 'chia_takeOffer',
+ 'chia_pushTransactions',
+ 'chia_getWallets',
+ ],
+ });
+ fs.writeFileSync(file, `pairs:\n - ${JSON.stringify(record)}\n`);
+
+ const reload = loadStore();
+ expect(reload.getPair('a')?.bypass).toEqual([
+ 'chia_sendTransaction',
+ 'chia_createOfferForIds',
+ 'chia_takeOffer',
+ 'chia_pushTransactions',
+ 'chia_getWallets',
+ ]);
+ });
+
+ it('leaves a clean bypass list untouched (no false positives)', () => {
+ const file = path.join(mockTempDir, 'dapp-pairs.yaml');
+ const record = makePair({
+ topic: 'a',
+ bypass: ['chia_getWallets', 'chia_getWalletBalance', 'chia_signMessageByAddress'],
+ });
+ fs.writeFileSync(file, `pairs:\n - ${JSON.stringify(record)}\n`);
+
+ const reload = loadStore();
+ expect(reload.getPair('a')?.bypass).toEqual([
+ 'chia_getWallets',
+ 'chia_getWalletBalance',
+ 'chia_signMessageByAddress',
+ ]);
+ });
+});
+
+describe('pairStore - resetBypass (single pair)', () => {
+ it('clears a non-empty bypass list', () => {
+ const store = loadStore();
+ store.upsertPair(makePair({ topic: 'a', bypass: ['chia_getWalletBalance', 'chia_getWallets'] }));
+
+ const updated = store.resetBypass('a');
+ expect(updated?.bypass).toEqual([]);
+ expect(store.getPair('a')?.bypass).toEqual([]);
+ });
+
+ it('persists across reloads', () => {
+ // The whole point of the reset button is that it survives an app
+ // restart. Reading from cache could pass even with a broken persist.
+ const store = loadStore();
+ store.upsertPair(makePair({ topic: 'a', bypass: ['chia_getWallets'] }));
+ store.resetBypass('a');
+
+ const reload = loadStore();
+ expect(reload.getPair('a')?.bypass).toEqual([]);
+ });
+
+ it('returns undefined for an unknown topic without persisting', () => {
+ const store = loadStore();
+ store.upsertPair(makePair({ topic: 'a', bypass: ['chia_getWallets'] }));
+
+ expect(store.resetBypass('nonexistent')).toBeUndefined();
+ // Existing pair untouched.
+ expect(store.getPair('a')?.bypass).toEqual(['chia_getWallets']);
+ });
+
+ it('is a no-op when the bypass list is already empty (does not bump updatedAt)', () => {
+ // Idle clicks on "Reset" shouldn't churn updatedAt — sync logic
+ // elsewhere may key off it.
+ const store = loadStore();
+ store.upsertPair(makePair({ topic: 'a', updatedAt: 100, bypass: [] }));
+
+ const result = store.resetBypass('a');
+ expect(result?.updatedAt).toBe(100);
+ expect(store.getPair('a')?.updatedAt).toBe(100);
+ });
+
+ it('bumps updatedAt when there was something to clear', () => {
+ const store = loadStore();
+ store.upsertPair(makePair({ topic: 'a', updatedAt: 100, bypass: ['chia_getWallets'] }));
+
+ const before = Date.now();
+ const result = store.resetBypass('a');
+ const after = Date.now();
+
+ expect(result?.updatedAt).toBeGreaterThanOrEqual(before);
+ expect(result?.updatedAt).toBeLessThanOrEqual(after);
+ });
+
+ it('only touches the targeted pair, not siblings', () => {
+ const store = loadStore();
+ store.upsertPair(makePair({ topic: 'a', bypass: ['chia_getWalletBalance'] }));
+ store.upsertPair(makePair({ topic: 'b', bypass: ['chia_getWallets'] }));
+
+ store.resetBypass('a');
+ expect(store.getPair('a')?.bypass).toEqual([]);
+ expect(store.getPair('b')?.bypass).toEqual(['chia_getWallets']);
+ });
+
+ it('preserves the rest of the pair record (commands, fingerprints, grants, usedMojos)', () => {
+ const store = loadStore();
+ store.upsertPair(
+ makePair({
+ topic: 'a',
+ bypass: ['chia_getWallets'],
+ commands: ['chia_sendTransaction', 'chia_getWallets'],
+ fingerprints: [111, 222],
+ usedMojos: '500',
+ }),
+ );
+
+ const result = store.resetBypass('a');
+ expect(result?.commands).toEqual(['chia_sendTransaction', 'chia_getWallets']);
+ expect(result?.fingerprints).toEqual([111, 222]);
+ expect(result?.usedMojos).toBe('500');
+ });
+});
+
+describe('pairStore - resetBypassAll (every pair)', () => {
+ it('clears bypass on every pair', () => {
+ const store = loadStore();
+ store.upsertPair(makePair({ topic: 'a', bypass: ['chia_getWallets'] }));
+ store.upsertPair(makePair({ topic: 'b', bypass: ['chia_getWalletBalance', 'chia_signMessageByAddress'] }));
+ store.upsertPair(makePair({ topic: 'c', bypass: [] }));
+
+ store.resetBypassAll();
+ expect(store.getPair('a')?.bypass).toEqual([]);
+ expect(store.getPair('b')?.bypass).toEqual([]);
+ expect(store.getPair('c')?.bypass).toEqual([]);
+ });
+
+ it('persists across reloads', () => {
+ const store = loadStore();
+ store.upsertPair(makePair({ topic: 'a', bypass: ['chia_getWallets'] }));
+ store.upsertPair(makePair({ topic: 'b', bypass: ['chia_getWalletBalance'] }));
+ store.resetBypassAll();
+
+ const reload = loadStore();
+ expect(reload.getPair('a')?.bypass).toEqual([]);
+ expect(reload.getPair('b')?.bypass).toEqual([]);
+ });
+
+ it('preserves updatedAt on pairs that had nothing to clear', () => {
+ // Otherwise resetBypassAll would silently rewrite every pair's
+ // timestamp on every click. Keep the file diff to actual mutations.
+ const store = loadStore();
+ store.upsertPair(makePair({ topic: 'untouched', updatedAt: 100, bypass: [] }));
+ store.upsertPair(makePair({ topic: 'cleared', updatedAt: 100, bypass: ['chia_getWallets'] }));
+
+ store.resetBypassAll();
+
+ expect(store.getPair('untouched')?.updatedAt).toBe(100);
+ expect(store.getPair('cleared')?.updatedAt).not.toBe(100);
+ });
+
+ it('is a no-op when no pair has any bypass (does not rewrite the file)', () => {
+ // If everything's already empty, resetBypassAll skips the write so
+ // the YAML file mtime doesn't change. Pin via byte-identical content.
+ const store = loadStore();
+ store.upsertPair(makePair({ topic: 'a', bypass: [] }));
+ store.upsertPair(makePair({ topic: 'b', bypass: [] }));
+ const file = path.join(mockTempDir, 'dapp-pairs.yaml');
+ const before = fs.readFileSync(file, 'utf-8');
+
+ store.resetBypassAll();
+
+ const after = fs.readFileSync(file, 'utf-8');
+ expect(after).toBe(before);
+ });
+
+ it('handles an empty pair list (no pairs at all)', () => {
+ const store = loadStore();
+ expect(() => store.resetBypassAll()).not.toThrow();
+ expect(store.listPairs()).toEqual([]);
+ });
+
+ it('preserves the rest of each pair record', () => {
+ const store = loadStore();
+ store.upsertPair(
+ makePair({
+ topic: 'a',
+ bypass: ['chia_getWallets'],
+ commands: ['chia_sendTransaction'],
+ fingerprints: [111],
+ usedMojos: '500',
+ }),
+ );
+
+ store.resetBypassAll();
+ const pair = store.getPair('a');
+ expect(pair?.commands).toEqual(['chia_sendTransaction']);
+ expect(pair?.fingerprints).toEqual([111]);
+ expect(pair?.usedMojos).toBe('500');
+ });
+});
diff --git a/packages/gui/src/electron/permissions/pairStore.ts b/packages/gui/src/electron/permissions/pairStore.ts
new file mode 100644
index 0000000000..8fce807c61
--- /dev/null
+++ b/packages/gui/src/electron/permissions/pairStore.ts
@@ -0,0 +1,117 @@
+import path from 'node:path';
+
+import BigNumber from 'bignumber.js';
+
+import { getUserDataDir } from '../utils/userData';
+import { readData, writeData } from '../utils/yamlUtils';
+
+import type { PairRecord } from './types';
+
+const FILE = 'dapp-pairs.yaml';
+
+let cache: PairRecord[] | undefined;
+
+function getPath() {
+ const userDataDir = getUserDataDir();
+ if (!userDataDir) {
+ throw new Error('userDataDir needs to be initialized');
+ }
+ return path.join(userDataDir, FILE);
+}
+
+function normalizeRecord(p: Record): PairRecord {
+ const commands = Array.isArray(p?.commands)
+ ? (p.commands as unknown[]).filter((c): c is string => typeof c === 'string')
+ : [];
+ const rawBypass = Array.isArray(p?.bypass)
+ ? (p.bypass as unknown[]).filter((c): c is string => typeof c === 'string')
+ : [];
+
+ const rawGrants = (p?.grants ?? {}) as Record;
+ const xchMojos = typeof rawGrants.xchMojos === 'string' ? rawGrants.xchMojos : '0';
+ const usedMojos = typeof p?.usedMojos === 'string' ? p.usedMojos : '0';
+
+ return {
+ topic: typeof p?.topic === 'string' ? p.topic : '',
+ mainnet: typeof p?.mainnet === 'boolean' ? p.mainnet : true,
+ metadata: (p?.metadata ?? { name: '' }) as PairRecord['metadata'],
+ fingerprints: Array.isArray(p?.fingerprints)
+ ? (p.fingerprints as unknown[]).filter((f): f is number => typeof f === 'number')
+ : [],
+ createdAt: typeof p?.createdAt === 'number' ? p.createdAt : 0,
+ updatedAt: typeof p?.updatedAt === 'number' ? p.updatedAt : 0,
+ grants: { xchMojos },
+ usedMojos,
+ commands,
+ bypass: rawBypass,
+ };
+}
+
+function load(): PairRecord[] {
+ if (cache) return cache;
+ const data = readData(getPath());
+ const raw = Array.isArray(data?.pairs) ? (data.pairs as Record[]) : [];
+ // Missing or wrong-typed fields default to deny-all / mainnet / allowance 0
+ // so a hand-edited record can't silently expand dapp reach.
+ const list = raw.map(normalizeRecord);
+ cache = list;
+ return list;
+}
+
+function persist(pairs: PairRecord[]) {
+ cache = pairs;
+ writeData({ pairs }, getPath());
+}
+
+export function listPairs(): PairRecord[] {
+ return load().slice();
+}
+
+export function getPair(topic: string): PairRecord | undefined {
+ return load().find((p) => p.topic === topic);
+}
+
+export function upsertPair(pair: PairRecord) {
+ const next = load().filter((p) => p.topic !== pair.topic);
+ next.push(pair);
+ persist(next);
+}
+
+export function removePair(topic: string) {
+ persist(load().filter((p) => p.topic !== topic));
+}
+
+// One persist; no-op when the list is already empty so updatedAt doesn't drift.
+export function resetBypass(topic: string): PairRecord | undefined {
+ const pair = getPair(topic);
+ if (!pair) return undefined;
+ if (pair.bypass.length === 0) return pair;
+ const next: PairRecord = { ...pair, bypass: [], updatedAt: Date.now() };
+ upsertPair(next);
+ return next;
+}
+
+// One persist for the whole list. Pairs already empty stay byte-identical
+// (same updatedAt) so the file diff is minimal.
+export function resetBypassAll(): void {
+ const list = load();
+ const now = Date.now();
+ let mutated = false;
+ const next = list.map((p) => {
+ if (p.bypass.length === 0) return p;
+ mutated = true;
+ return { ...p, bypass: [], updatedAt: now };
+ });
+ if (mutated) persist(next);
+}
+
+// Idempotency lives at the call site (see `makeCommit` in `permissions.ts`);
+// this function only guards against non-positive / non-finite inputs.
+export function recordUsage(topic: string, mojos: BigNumber) {
+ const pair = getPair(topic);
+ if (!pair) return;
+ if (!mojos.isFinite() || mojos.isLessThanOrEqualTo(0)) return;
+ const current = new BigNumber(pair.usedMojos ?? 0);
+ const next: PairRecord = { ...pair, usedMojos: current.plus(mojos).toFixed(0) };
+ upsertPair(next);
+}
diff --git a/packages/gui/src/electron/permissions/permissions.test.ts b/packages/gui/src/electron/permissions/permissions.test.ts
new file mode 100644
index 0000000000..2327df3a00
--- /dev/null
+++ b/packages/gui/src/electron/permissions/permissions.test.ts
@@ -0,0 +1,783 @@
+import BigNumber from 'bignumber.js';
+
+import { sendDappAndAwait } from '../utils/webSocketBridge';
+
+import { getPair, recordUsage } from './pairStore';
+import { resolvePermission } from './permissions';
+import type { Decision, PairRecord } from './types';
+
+jest.mock('./pairStore', () => ({
+ getPair: jest.fn(),
+ recordUsage: jest.fn(),
+}));
+
+jest.mock('../utils/webSocketBridge', () => ({
+ sendDappAndAwait: jest.fn(),
+}));
+
+const mockGetPair = getPair as jest.MockedFunction;
+const mockRecordUsage = recordUsage as jest.MockedFunction;
+const mockSendDappAndAwait = sendDappAndAwait as jest.MockedFunction;
+
+const TOPIC = 'topic-1';
+const PAIR_PRINCIPAL = { kind: 'pair' as const, topic: TOPIC };
+const UI_PRINCIPAL = { kind: 'ui' as const };
+
+// Wide default so tests focus on the gate logic, not the per-pair allowlist
+// gate. The `commands` gate has its own dedicated suite below; tests in this
+// file that exercise other code paths assume the command is in the list.
+// All entries use wire form (`chia_`).
+const DEFAULT_COMMANDS: readonly string[] = [
+ 'chia_getWallets',
+ 'chia_getWalletBalance',
+ 'chia_getWalletBalances',
+ 'chia_sendTransaction',
+ 'chia_spendCAT',
+ 'chia_transferNFT',
+ 'chia_takeOffer',
+ 'chia_cancelOffer',
+ 'chia_createOfferForIds',
+ 'chia_signMessageByAddress',
+ 'chia_signMessageById',
+ 'chia_pushTransactions',
+ 'chia_getCoinRecordsByNames',
+];
+
+// Namespaced daemon command → WC name (wire form).
+const NS_TO_WC: Record = {
+ 'chia_wallet.get_wallets': 'chia_getWallets',
+ 'chia_wallet.get_wallet_balance': 'chia_getWalletBalance',
+ 'chia_wallet.get_wallet_balances': 'chia_getWalletBalances',
+ 'chia_wallet.send_transaction': 'chia_sendTransaction',
+ 'chia_wallet.cat_spend': 'chia_spendCAT',
+ 'chia_wallet.nft_transfer_nft': 'chia_transferNFT',
+ 'chia_wallet.take_offer': 'chia_takeOffer',
+ 'chia_wallet.cancel_offer': 'chia_cancelOffer',
+ 'chia_wallet.create_offer_for_ids': 'chia_createOfferForIds',
+ 'chia_wallet.sign_message_by_address': 'chia_signMessageByAddress',
+ 'chia_wallet.sign_message_by_id': 'chia_signMessageById',
+ 'chia_wallet.push_transactions': 'chia_pushTransactions',
+ 'chia_wallet.get_coin_records_by_names': 'chia_getCoinRecordsByNames',
+};
+
+function makePair(
+ overrides: {
+ xchMojos?: string;
+ usedMojos?: string;
+ metadata?: Partial;
+ commands?: string[];
+ bypass?: string[];
+ } = {},
+): PairRecord {
+ return {
+ topic: TOPIC,
+ mainnet: true,
+ metadata: { name: 'Test Dapp', ...overrides.metadata },
+ fingerprints: [123],
+ createdAt: 0,
+ updatedAt: 0,
+ usedMojos: overrides.usedMojos ?? '0',
+ commands: overrides.commands ?? [...DEFAULT_COMMANDS],
+ bypass: overrides.bypass ?? [],
+ grants: { xchMojos: overrides.xchMojos ?? '0' },
+ };
+}
+
+function pairResolve(nsCommand: string, payload: Record = {}): Promise {
+ return resolvePermission(PAIR_PRINCIPAL, nsCommand, payload, {
+ wcCommand: NS_TO_WC[nsCommand] ?? 'chia_unknown',
+ // Default makePair returns mainnet: true; helper matches so tests focus
+ // on per-command logic and not the network gate (which has its own suite
+ // in checkPairAccess.test.ts).
+ mainnet: true,
+ });
+}
+
+function expectAllow(d: Decision): Extract {
+ expect(d.kind).toBe('allow');
+ return d as Extract;
+}
+
+beforeEach(() => {
+ mockGetPair.mockReset();
+ mockRecordUsage.mockReset();
+ mockSendDappAndAwait.mockReset();
+});
+
+describe('resolvePermission - UI principal', () => {
+ it('allows commands present in AllowedCommands', async () => {
+ expect((await resolvePermission(UI_PRINCIPAL, 'chia_wallet.get_wallets', {})).kind).toBe('allow');
+ });
+
+ it('prompts for commands not in AllowedCommands', async () => {
+ expect(await resolvePermission(UI_PRINCIPAL, 'chia_wallet.send_transaction', {})).toEqual({
+ kind: 'prompt',
+ reason: 'requires user confirmation',
+ pair: undefined,
+ });
+ });
+
+ it('does not consult the pair store', async () => {
+ await resolvePermission(UI_PRINCIPAL, 'chia_wallet.send_transaction', {});
+ expect(mockGetPair).not.toHaveBeenCalled();
+ });
+
+ it('UI allow has a no-op commit (cannot debit a UI principal)', async () => {
+ const d = expectAllow(await resolvePermission(UI_PRINCIPAL, 'chia_wallet.get_wallets', {}));
+ d.commit();
+ d.commit();
+ expect(mockRecordUsage).not.toHaveBeenCalled();
+ });
+});
+
+describe('resolvePermission - unknown pair topic', () => {
+ it('denies before evaluating any command-specific rules', async () => {
+ mockGetPair.mockReturnValue(undefined);
+ expect(await pairResolve('chia_wallet.get_wallets', {})).toMatchObject({
+ kind: 'deny',
+ reason: 'Pair not found',
+ });
+ });
+
+ it('denies even for sensitive commands', async () => {
+ mockGetPair.mockReturnValue(undefined);
+ expect(await pairResolve('chia_wallet.delete_key', {})).toMatchObject({
+ kind: 'deny',
+ reason: 'Pair not found',
+ });
+ });
+});
+
+describe('resolvePermission - pair context shape', () => {
+ it('attaches dialog-shaped pair info to prompt decisions, never the raw record', async () => {
+ const pair = makePair({ metadata: { name: 'My Dapp', url: 'https://app.example' } });
+ mockGetPair.mockReturnValue(pair);
+ const d = await pairResolve('chia_wallet.get_wallets', {});
+ expect(d).toEqual({
+ kind: 'prompt',
+ reason: 'not in bypass list',
+ pair: { topic: TOPIC, name: 'My Dapp', url: 'https://app.example' },
+ });
+ });
+
+ it('omits pair on UI prompts', async () => {
+ const d = await resolvePermission(UI_PRINCIPAL, 'chia_wallet.send_transaction', {});
+ expect(d).toEqual({ kind: 'prompt', reason: 'requires user confirmation', pair: undefined });
+ });
+
+ it('omits pair on deny ("unknown pair")', async () => {
+ mockGetPair.mockReturnValue(undefined);
+ const d = await pairResolve('chia_wallet.send_transaction', {});
+ expect(d).toMatchObject({ kind: 'deny', reason: 'Pair not found' });
+ // No `pair` field on deny — only on prompt.
+ expect((d as { pair?: unknown }).pair).toBeUndefined();
+ });
+});
+
+describe('resolvePermission - bypass-driven allow', () => {
+ // Command bypass is exact command-level trust. Spend-class commands may
+ // also use the XCH allowance fallback, but bypass wins when present.
+ it('allows a balance command when its wcCommand is in bypass', async () => {
+ mockGetPair.mockReturnValue(makePair({ bypass: ['chia_getWalletBalance', 'chia_getWalletBalances'] }));
+ expect((await pairResolve('chia_wallet.get_wallet_balance', {})).kind).toBe('allow');
+ expect((await pairResolve('chia_wallet.get_wallet_balances', {})).kind).toBe('allow');
+ });
+
+ it('allows an innocuous command when its wcCommand is in bypass', async () => {
+ mockGetPair.mockReturnValue(makePair({ bypass: ['chia_getWallets', 'chia_getCoinRecordsByNames'] }));
+ expect((await pairResolve('chia_wallet.get_wallets', {})).kind).toBe('allow');
+ expect((await pairResolve('chia_wallet.get_coin_records_by_names', {})).kind).toBe('allow');
+ });
+
+ it('prompts with "not in bypass list" when wcCommand absent', async () => {
+ mockGetPair.mockReturnValue(makePair({ bypass: [] }));
+ const d = await pairResolve('chia_wallet.get_wallet_balance', {});
+ expect(d.kind).toBe('prompt');
+ expect((d as Extract).reason).toBe('not in bypass list');
+ });
+
+ it('only-this-command-bypassed grants exactly that command, not its siblings', async () => {
+ // The whole point of moving to a per-command list: if a future release
+ // adds a new balance command, an existing pair with only the OLD
+ // command bypassed does not silently auto-bypass the new one.
+ mockGetPair.mockReturnValue(makePair({ bypass: ['chia_getWalletBalance'] }));
+ expect((await pairResolve('chia_wallet.get_wallet_balance', {})).kind).toBe('allow');
+ expect(await pairResolve('chia_wallet.get_wallet_balances', {})).toMatchObject({
+ kind: 'prompt',
+ reason: 'not in bypass list',
+ });
+ });
+
+ it('bypass allow has no-op commit (read-only commands)', async () => {
+ mockGetPair.mockReturnValue(makePair({ bypass: ['chia_getWalletBalance'] }));
+ const d = expectAllow(await pairResolve('chia_wallet.get_wallet_balance', {}));
+ d.commit();
+ expect(mockRecordUsage).not.toHaveBeenCalled();
+ });
+
+ it('allows exactly one spend command when that wcCommand is in bypass', async () => {
+ mockGetPair.mockReturnValue(makePair({ xchMojos: '0', bypass: ['chia_sendTransaction'] }));
+ expect((await pairResolve('chia_wallet.send_transaction', { amount: '100', fee: '0' })).kind).toBe('allow');
+ expect(await pairResolve('chia_wallet.create_offer_for_ids', { offer: { '1': '-100' }, fee: '0' })).toMatchObject({
+ kind: 'prompt',
+ reason: 'spending needs confirmation',
+ });
+ });
+
+ it('bypassed spend commands do not debit the XCH allowance', async () => {
+ mockGetPair.mockReturnValue(makePair({ xchMojos: '0', bypass: ['chia_sendTransaction'] }));
+ const d = expectAllow(await pairResolve('chia_wallet.send_transaction', { amount: '100', fee: '50' }));
+ d.commit();
+ expect(mockRecordUsage).not.toHaveBeenCalled();
+ });
+});
+
+describe('resolvePermission - sign-class always prompts', () => {
+ // Sign-class never auto-allows, even with the wcCommand in bypass.
+ // Trading a key signature for an off-chain trust toggle is a foot-gun
+ // we don't expose. Command bypass and the allowance both sit below the
+ // signing gate.
+ it('prompts for sign_message_by_address even when bypass lists it', async () => {
+ mockGetPair.mockReturnValue(makePair({ bypass: ['chia_signMessageByAddress'] }));
+ expect(await pairResolve('chia_wallet.sign_message_by_address', { message: 'hi' })).toMatchObject({
+ kind: 'prompt',
+ reason: 'signing requested',
+ });
+ });
+
+ it('prompts for sign_message_by_id even when bypass lists it', async () => {
+ mockGetPair.mockReturnValue(makePair({ bypass: ['chia_signMessageById'] }));
+ expect(await pairResolve('chia_wallet.sign_message_by_id', { id: 'x' })).toMatchObject({
+ kind: 'prompt',
+ reason: 'signing requested',
+ });
+ });
+});
+
+describe('resolvePermission - push_transactions (spend allowance, fee-only)', () => {
+ const CMD = 'chia_wallet.push_transactions';
+
+ it.each([
+ ['true', true],
+ ['string "true"', 'true'],
+ ['string "false"', 'false'],
+ ['number 1', 1],
+ ['object {}', {}],
+ ])('prompts with "signing requested" when sign is truthy via %s (Python truthiness)', async (_label, sign) => {
+ mockGetPair.mockReturnValue(makePair({ xchMojos: '1000' }));
+ expect(await pairResolve(CMD, { sign })).toMatchObject({
+ kind: 'prompt',
+ reason: 'signing requested',
+ });
+ });
+
+ it.each([
+ ['omitted', undefined],
+ ['false', false],
+ ['number 0', 0],
+ ['null', null],
+ ])(
+ 'allows fee-free relay when sign is falsy via %s, regardless of allowance (no funds move)',
+ async (_label, sign) => {
+ mockGetPair.mockReturnValue(makePair({ xchMojos: '0' }));
+ const payload = sign === undefined ? {} : { sign };
+ const d = expectAllow(await pairResolve(CMD, payload));
+ // Zero-charge spends never debit the allowance.
+ d.commit();
+ expect(mockRecordUsage).not.toHaveBeenCalled();
+ },
+ );
+
+ it('prompts when fee > 0 and allowance is zero (the safe default)', async () => {
+ mockGetPair.mockReturnValue(makePair({ xchMojos: '0' }));
+ expect(await pairResolve(CMD, { fee: '500' })).toMatchObject({
+ kind: 'prompt',
+ reason: 'spending needs confirmation',
+ });
+ });
+
+ it('allows when fee fits in remaining allowance and debits only the fee on commit', async () => {
+ mockGetPair.mockReturnValue(makePair({ xchMojos: '1000', usedMojos: '200' }));
+ const d = expectAllow(await pairResolve(CMD, { fee: '500' }));
+ d.commit();
+ expect(mockRecordUsage).toHaveBeenCalledTimes(1);
+ const [topic, mojos] = mockRecordUsage.mock.calls[0];
+ expect(topic).toBe(TOPIC);
+ expect(mojos.toFixed(0)).toBe('500');
+ });
+
+ it('allows when used + fee equals allowance exactly', async () => {
+ mockGetPair.mockReturnValue(makePair({ xchMojos: '1000', usedMojos: '400' }));
+ expect((await pairResolve(CMD, { fee: '600' })).kind).toBe('allow');
+ });
+
+ it('prompts with "allowance exhausted" when fee exceeds remaining allowance', async () => {
+ mockGetPair.mockReturnValue(makePair({ xchMojos: '1000', usedMojos: '900' }));
+ expect(await pairResolve(CMD, { fee: '200' })).toMatchObject({
+ kind: 'prompt',
+ reason: 'allowance exhausted',
+ });
+ });
+
+ it('treats a negative fee as zero (cannot reduce usage)', async () => {
+ mockGetPair.mockReturnValue(makePair({ xchMojos: '0' }));
+ const d = expectAllow(await pairResolve(CMD, { fee: '-100' }));
+ d.commit();
+ expect(mockRecordUsage).not.toHaveBeenCalled();
+ });
+
+ it('does not record on commit when fee is zero or missing', async () => {
+ mockGetPair.mockReturnValue(makePair({ xchMojos: '1000' }));
+ expectAllow(await pairResolve(CMD, {})).commit();
+ expectAllow(await pairResolve(CMD, { fee: '0' })).commit();
+ expect(mockRecordUsage).not.toHaveBeenCalled();
+ });
+
+ it('preserves precision beyond JS safe-integer range', async () => {
+ const allowance = '99999999999999999999';
+ const fee = '99999999999999999999';
+ mockGetPair.mockReturnValue(makePair({ xchMojos: allowance, usedMojos: '0' }));
+ const d = expectAllow(await pairResolve(CMD, { fee }));
+ d.commit();
+ const [, mojos] = mockRecordUsage.mock.calls[0];
+ expect(mojos.toFixed(0)).toBe(fee);
+ expect(mojos).toBeInstanceOf(BigNumber);
+ });
+
+ // Regression: a dapp that sends `Sign: true` (capital S) used to slip past
+ // the case-sensitive `payload.sign` check while `toSnakeCase` on the wire
+ // canonicalised it back to `sign`, silently signing the bundle. The
+ // resolver must canonicalise before the gate.
+ it.each([
+ ['Sign', { Sign: true }],
+ ['SIGN', { SIGN: true }],
+ ['sign (lowercase, baseline)', { sign: true }],
+ ])('prompts on signing requested even when payload uses %s', async (_label, payload) => {
+ mockGetPair.mockReturnValue(makePair({ xchMojos: '1000' }));
+ expect(await pairResolve(CMD, payload)).toMatchObject({
+ kind: 'prompt',
+ reason: 'signing requested',
+ });
+ });
+
+ // Same defect for the fee field: capitalised `Fee` snuck past the budget
+ // check while the daemon still honored it on the wire, undercounting the
+ // pair's used total. The resolver must read fees regardless of casing.
+ it.each([
+ ['Fee', { Fee: '500' }],
+ ['FEE', { FEE: '500' }],
+ ])('counts %s against the allowance on push_transactions', async (_label, payload) => {
+ mockGetPair.mockReturnValue(makePair({ xchMojos: '1000', usedMojos: '0' }));
+ const d = expectAllow(await pairResolve(CMD, payload));
+ d.commit();
+ const [, mojos] = mockRecordUsage.mock.calls[0];
+ expect(mojos.toFixed(0)).toBe('500');
+ });
+
+ it('allows fee relay when chia_pushTransactions is in bypass', async () => {
+ mockGetPair.mockReturnValue(makePair({ xchMojos: '0', bypass: ['chia_pushTransactions'] }));
+ const d = expectAllow(await pairResolve(CMD, { fee: '500' }));
+ d.commit();
+ expect(mockRecordUsage).not.toHaveBeenCalled();
+ });
+});
+
+describe('resolvePermission - send_transaction (spend allowance)', () => {
+ const SEND = 'chia_wallet.send_transaction';
+ const SEND_WC = 'chia_sendTransaction';
+
+ it('prompts when allowance is zero (the safe default)', async () => {
+ mockGetPair.mockReturnValue(makePair({ xchMojos: '0' }));
+ expect(await pairResolve(SEND, { amount: '100', fee: '0' })).toMatchObject({
+ kind: 'prompt',
+ reason: 'spending needs confirmation',
+ });
+ });
+
+ it('allows when the command is in bypass, even with zero allowance', async () => {
+ mockGetPair.mockReturnValue(makePair({ xchMojos: '0', bypass: [SEND_WC] }));
+ expect((await pairResolve(SEND, { amount: '100', fee: '0' })).kind).toBe('allow');
+ });
+
+ it('allows when amount + fee fit in remaining allowance', async () => {
+ mockGetPair.mockReturnValue(makePair({ xchMojos: '1000', usedMojos: '200' }));
+ const d = expectAllow(await pairResolve(SEND, { amount: '500', fee: '100' }));
+ d.commit();
+ const [, mojos] = mockRecordUsage.mock.calls[0];
+ expect(mojos.toFixed(0)).toBe('600');
+ });
+
+ it('prompts with "allowance exhausted" when amount + fee exceed the remaining allowance', async () => {
+ mockGetPair.mockReturnValue(makePair({ xchMojos: '1000', usedMojos: '900' }));
+ expect(await pairResolve(SEND, { amount: '500', fee: '0' })).toMatchObject({
+ kind: 'prompt',
+ reason: 'allowance exhausted',
+ });
+ });
+
+ it('prompts when the amount field is missing (cannot price)', async () => {
+ mockGetPair.mockReturnValue(makePair({ xchMojos: '1000' }));
+ expect(await pairResolve(SEND, { fee: '0' })).toMatchObject({
+ kind: 'prompt',
+ reason: 'non-XCH spend needs confirmation',
+ });
+ });
+
+ // Regression: capital-key payload fields (`Amount`, `Fee`) used to dodge
+ // case-sensitive lookups in the resolver while the wire-out canonicalised
+ // them on the way to the daemon — undercounting the budget. The resolver
+ // must canonicalise before any field read.
+ it('counts capitalized "Fee" against the allowance on send_transaction', async () => {
+ mockGetPair.mockReturnValue(makePair({ xchMojos: '1000' }));
+ const d = expectAllow(await pairResolve(SEND, { amount: '500', Fee: '300' }));
+ d.commit();
+ const [, mojos] = mockRecordUsage.mock.calls[0];
+ expect(mojos.toFixed(0)).toBe('800');
+ });
+
+ it('resolves capitalized "Amount" so the gate sees the real spend', async () => {
+ mockGetPair.mockReturnValue(makePair({ xchMojos: '1000' }));
+ const d = expectAllow(await pairResolve(SEND, { Amount: '500', Fee: '0' }));
+ d.commit();
+ const [, mojos] = mockRecordUsage.mock.calls[0];
+ expect(mojos.toFixed(0)).toBe('500');
+ });
+
+ it('capitalized "Amount" + "Fee" together: prompts when total exceeds remaining allowance', async () => {
+ mockGetPair.mockReturnValue(makePair({ xchMojos: '1000' }));
+ expect(await pairResolve(SEND, { Amount: '900', Fee: '200' })).toMatchObject({
+ kind: 'prompt',
+ reason: 'allowance exhausted',
+ });
+ });
+});
+
+describe('resolvePermission - create_offer_for_ids (spend allowance, XCH-only)', () => {
+ const OFFER = 'chia_wallet.create_offer_for_ids';
+
+ it('prompts when allowance is zero', async () => {
+ mockGetPair.mockReturnValue(makePair({ xchMojos: '0' }));
+ expect(await pairResolve(OFFER, { offer: { '1': '-100' }, fee: '0' })).toMatchObject({
+ kind: 'prompt',
+ reason: 'spending needs confirmation',
+ });
+ });
+
+ it('prompts on non-XCH outflow regardless of allowance (cap is XCH-denominated)', async () => {
+ mockGetPair.mockReturnValue(makePair({ xchMojos: '1000000000000' }));
+ expect(await pairResolve(OFFER, { offer: { '0xcat': '-100' }, fee: '0' })).toMatchObject({
+ kind: 'prompt',
+ reason: 'non-XCH spend needs confirmation',
+ });
+ });
+
+ // Convention (chia daemon trade_manager.create_offer_for_ids): NEGATIVE = outflow,
+ // POSITIVE = inflow. XCH is keyed as '1' (wallet id) or 'xch'. The resolver must
+ // (a) sum the absolute value of negative XCH entries, (b) ignore positives
+ // (those are receives, not spends), (c) prompt on any non-XCH outflow so a
+ // CAT/NFT giveaway never auto-approves against an XCH cap.
+ describe('outflow polarity', () => {
+ it('the literal "xch" key is rejected (daemon expects wallet id; parses int() / bytes32 hex only)', async () => {
+ // Per wallet_request_types.CreateOfferForIDs.offer_spec, keys ≤16 chars
+ // go through int(...) which throws on "xch". The resolver must treat
+ // "xch" as a non-XCH outflow and prompt — never debit the allowance for
+ // a payload the daemon itself would reject.
+ mockGetPair.mockReturnValue(makePair({ xchMojos: '10000' }));
+ expect(await pairResolve(OFFER, { offer: { xch: '-5000' }, fee: '0' })).toMatchObject({
+ kind: 'prompt',
+ reason: 'non-XCH spend needs confirmation',
+ });
+ });
+
+ it('pure-XCH outflow keyed as "1" (standard wallet id): allows and debits the absolute amount', async () => {
+ mockGetPair.mockReturnValue(makePair({ xchMojos: '10000' }));
+ const d = expectAllow(await pairResolve(OFFER, { offer: { '1': '-5000' }, fee: '0' }));
+ d.commit();
+ const [, mojos] = mockRecordUsage.mock.calls[0];
+ expect(mojos.toFixed(0)).toBe('5000');
+ });
+
+ it('giveaway offer that exceeds the allowance prompts (does not silently auto-approve)', async () => {
+ // Regression for the pre-fix bug: a `{ "1": "-100000000000000" }` payload
+ // would resolve to outflow=0, fit any cap, and auto-approve a 100-XCH
+ // unilateral giveaway. Under the corrected convention it must prompt.
+ mockGetPair.mockReturnValue(makePair({ xchMojos: '1000' }));
+ expect(await pairResolve(OFFER, { offer: { '1': '-100000000000000' }, fee: '0' })).toMatchObject({
+ kind: 'prompt',
+ reason: 'allowance exhausted',
+ });
+ });
+
+ it('CAT-only outflow (negative non-XCH key) prompts — never auto-approves against XCH allowance', async () => {
+ mockGetPair.mockReturnValue(makePair({ xchMojos: '1000000000000' }));
+ expect(await pairResolve(OFFER, { offer: { '0xcat': '-1000' }, fee: '0' })).toMatchObject({
+ kind: 'prompt',
+ reason: 'non-XCH spend needs confirmation',
+ });
+ });
+
+ it('mixed XCH + CAT outflow prompts (any non-XCH negative entry → prompt)', async () => {
+ mockGetPair.mockReturnValue(makePair({ xchMojos: '1000000000000' }));
+ expect(await pairResolve(OFFER, { offer: { '1': '-1000', '0xcat': '-1' }, fee: '0' })).toMatchObject({
+ kind: 'prompt',
+ reason: 'non-XCH spend needs confirmation',
+ });
+ });
+
+ it('mixed XCH outflow + non-XCH inflow prompts (allowance is XCH-only, CAT/NFT inflow disqualifies)', async () => {
+ // { -1000 XCH out, +5 CAT in } — auto-approve must reject because the
+ // CAT inflow is outside what the XCH allowance bounds.
+ mockGetPair.mockReturnValue(makePair({ xchMojos: '10000' }));
+ expect(await pairResolve(OFFER, { offer: { '1': '-1000', '0xcat': '5' }, fee: '0' })).toMatchObject({
+ kind: 'prompt',
+ reason: 'non-XCH spend needs confirmation',
+ });
+ });
+
+ it('mixed XCH outflow + NFT inflow prompts', async () => {
+ mockGetPair.mockReturnValue(makePair({ xchMojos: '10000' }));
+ expect(
+ await pairResolve(OFFER, {
+ offer: { '1': '-1000', '0xnft0000000000000000000000000000000000000000000000000000000000': '1' },
+ fee: '0',
+ }),
+ ).toMatchObject({
+ kind: 'prompt',
+ reason: 'non-XCH spend needs confirmation',
+ });
+ });
+
+ it('pure-XCH inflow (request only): outflow=0 → silent regardless of allowance', async () => {
+ // `{ '1': '100' }` — the maker requests 100 XCH, gives nothing.
+ // Charge=0, no funds move via the wallet, silent under the
+ // zero-charge shortcut.
+ mockGetPair.mockReturnValue(makePair({ xchMojos: '0' }));
+ const d = expectAllow(await pairResolve(OFFER, { offer: { '1': '100' }, fee: '0' }));
+ d.commit();
+ expect(mockRecordUsage).not.toHaveBeenCalled();
+ });
+
+ it('pure-XCH inflow + fee debits only the fee against the allowance', async () => {
+ mockGetPair.mockReturnValue(makePair({ xchMojos: '1000' }));
+ const d = expectAllow(await pairResolve(OFFER, { offer: { '1': '100' }, fee: '50' }));
+ d.commit();
+ const [, mojos] = mockRecordUsage.mock.calls[0];
+ expect(mojos.toFixed(0)).toBe('50');
+ });
+
+ it('any non-XCH key with zero amount still prompts (defense-in-depth)', async () => {
+ // Even a zero-amount CAT/NFT key triggers prompt — auto-approve applies
+ // only when the offer is exclusively XCH, regardless of amounts.
+ mockGetPair.mockReturnValue(makePair({ xchMojos: '10000' }));
+ expect(await pairResolve(OFFER, { offer: { '1': '-1000', '0xcat': '0' }, fee: '0' })).toMatchObject({
+ kind: 'prompt',
+ reason: 'non-XCH spend needs confirmation',
+ });
+ });
+
+ it('outflow + fee combined against allowance: prompts when total exceeds remaining', async () => {
+ mockGetPair.mockReturnValue(makePair({ xchMojos: '1000', usedMojos: '500' }));
+ expect(await pairResolve(OFFER, { offer: { '1': '-400' }, fee: '200' })).toMatchObject({
+ kind: 'prompt',
+ reason: 'allowance exhausted',
+ });
+ });
+
+ it('preserves precision beyond JS safe-integer range', async () => {
+ const allowance = '99999999999999999999';
+ const out = '-99999999999999999999';
+ mockGetPair.mockReturnValue(makePair({ xchMojos: allowance }));
+ const d = expectAllow(await pairResolve(OFFER, { offer: { '1': out }, fee: '0' }));
+ d.commit();
+ const [, mojos] = mockRecordUsage.mock.calls[0];
+ expect(mojos.toFixed(0)).toBe('99999999999999999999');
+ expect(mojos).toBeInstanceOf(BigNumber);
+ });
+ });
+});
+
+describe('resolvePermission - take_offer (spend allowance, XCH-only)', () => {
+ const TAKE = 'chia_wallet.take_offer';
+ const OFFER_STR = 'offer1abc...';
+
+ // The daemon round-trip happens inside the resolver; a successful response
+ // unwraps as { data: { summary: { offered, requested, ... } } }.
+ function mockSummary(summary: unknown) {
+ mockSendDappAndAwait.mockResolvedValueOnce({ data: { summary } });
+ }
+
+ it('prompts when allowance is zero, without consulting the daemon', async () => {
+ // The summary RPC is only worth the round-trip when the allowance has
+ // a chance of covering the spend. With allowance=0 we short-circuit
+ // earlier? No — we currently DO call the daemon to compute the charge,
+ // then fall back to the prompt branch when the allowance can't cover.
+ // What we test is the user-visible outcome: prompt regardless.
+ mockGetPair.mockReturnValue(makePair({ xchMojos: '0' }));
+ mockSummary({ offered: { xch: '500' }, requested: { xch: '5000' } });
+ expect(await pairResolve(TAKE, { offer: OFFER_STR, fee: '0' })).toMatchObject({
+ kind: 'prompt',
+ reason: 'spending needs confirmation',
+ });
+ });
+
+ it('allows when both sides are XCH-only and fits in the allowance', async () => {
+ mockGetPair.mockReturnValue(makePair({ xchMojos: '10000' }));
+ mockSummary({ offered: { xch: '500' }, requested: { xch: '5000' } });
+ const d = expectAllow(await pairResolve(TAKE, { offer: OFFER_STR, fee: '100' }));
+ d.commit();
+ const [, mojos] = mockRecordUsage.mock.calls[0];
+ expect(mojos.toFixed(0)).toBe('5100');
+ });
+
+ it('prompts when summary.offered contains an NFT (received-side disqualifies)', async () => {
+ // Pre-fix this auto-approved a 5000-mojo "buy NFT for XCH". The allowance
+ // is denominated in XCH — receiving an NFT is outside what it can bound.
+ mockGetPair.mockReturnValue(makePair({ xchMojos: '10000' }));
+ mockSummary({ offered: { '0xnft': 1 }, requested: { xch: '5000' } });
+ expect(await pairResolve(TAKE, { offer: OFFER_STR, fee: '100' })).toMatchObject({
+ kind: 'prompt',
+ reason: 'non-XCH spend needs confirmation',
+ });
+ });
+
+ it('prompts when summary.offered contains a CAT (received-side disqualifies)', async () => {
+ mockGetPair.mockReturnValue(makePair({ xchMojos: '10000' }));
+ mockSummary({ offered: { '0xcat': 100 }, requested: { xch: '1000' } });
+ expect(await pairResolve(TAKE, { offer: OFFER_STR, fee: '0' })).toMatchObject({
+ kind: 'prompt',
+ reason: 'non-XCH spend needs confirmation',
+ });
+ });
+
+ it('prompts when summary.offered is mixed XCH + CAT', async () => {
+ mockGetPair.mockReturnValue(makePair({ xchMojos: '10000' }));
+ mockSummary({ offered: { xch: '500', '0xcat': 100 }, requested: { xch: '1000' } });
+ expect(await pairResolve(TAKE, { offer: OFFER_STR, fee: '0' })).toMatchObject({
+ kind: 'prompt',
+ reason: 'non-XCH spend needs confirmation',
+ });
+ });
+
+ it('prompts when summary.requested includes a CAT', async () => {
+ mockGetPair.mockReturnValue(makePair({ xchMojos: '1000000000000' }));
+ mockSummary({ offered: { xch: '500' }, requested: { xch: '1000', '0xcat': 5 } });
+ expect(await pairResolve(TAKE, { offer: OFFER_STR, fee: '0' })).toMatchObject({
+ kind: 'prompt',
+ reason: 'non-XCH spend needs confirmation',
+ });
+ });
+
+ it('prompts when summary.requested is NFT-only (non-XCH)', async () => {
+ mockGetPair.mockReturnValue(makePair({ xchMojos: '1000000000000' }));
+ mockSummary({ offered: { xch: '1000' }, requested: { '0xnft': 1 } });
+ expect(await pairResolve(TAKE, { offer: OFFER_STR, fee: '0' })).toMatchObject({
+ kind: 'prompt',
+ reason: 'non-XCH spend needs confirmation',
+ });
+ });
+
+ it('prompts when summary.offered is missing entirely (defense-in-depth)', async () => {
+ mockGetPair.mockReturnValue(makePair({ xchMojos: '1000' }));
+ mockSendDappAndAwait.mockResolvedValueOnce({ data: { summary: { requested: { xch: '500' } } } });
+ expect(await pairResolve(TAKE, { offer: OFFER_STR, fee: '0' })).toMatchObject({
+ kind: 'prompt',
+ reason: 'non-XCH spend needs confirmation',
+ });
+ });
+
+ it('allows when both sides empty (free, asset-less interaction): fee-only spend', async () => {
+ mockGetPair.mockReturnValue(makePair({ xchMojos: '1000' }));
+ mockSummary({ offered: {}, requested: {} });
+ const d = expectAllow(await pairResolve(TAKE, { offer: OFFER_STR, fee: '50' }));
+ d.commit();
+ const [, mojos] = mockRecordUsage.mock.calls[0];
+ expect(mojos.toFixed(0)).toBe('50');
+ });
+
+ it('allows when summary.requested is empty (taker pays nothing on the asset side) — only fee charged', async () => {
+ mockGetPair.mockReturnValue(makePair({ xchMojos: '1000' }));
+ mockSummary({ offered: { xch: '500' }, requested: {} });
+ const d = expectAllow(await pairResolve(TAKE, { offer: OFFER_STR, fee: '50' }));
+ d.commit();
+ const [, mojos] = mockRecordUsage.mock.calls[0];
+ expect(mojos.toFixed(0)).toBe('50');
+ });
+
+ it('prompts with "allowance exhausted" when XCH outflow + fee exceed remaining allowance', async () => {
+ mockGetPair.mockReturnValue(makePair({ xchMojos: '1000', usedMojos: '500' }));
+ mockSummary({ offered: {}, requested: { xch: '600' } });
+ expect(await pairResolve(TAKE, { offer: OFFER_STR, fee: '0' })).toMatchObject({
+ kind: 'prompt',
+ reason: 'allowance exhausted',
+ });
+ });
+
+ it('prompts when daemon returns an error (offer cannot be parsed)', async () => {
+ mockGetPair.mockReturnValue(makePair({ xchMojos: '1000000000000' }));
+ mockSendDappAndAwait.mockResolvedValueOnce({ data: { error: 'invalid bech32' } });
+ expect(await pairResolve(TAKE, { offer: OFFER_STR, fee: '0' })).toMatchObject({
+ kind: 'prompt',
+ reason: 'non-XCH spend needs confirmation',
+ });
+ });
+
+ it('prompts when offer string is missing', async () => {
+ mockGetPair.mockReturnValue(makePair({ xchMojos: '1000000000000' }));
+ expect(await pairResolve(TAKE, { fee: '0' })).toMatchObject({
+ kind: 'prompt',
+ reason: 'non-XCH spend needs confirmation',
+ });
+ expect(mockSendDappAndAwait).not.toHaveBeenCalled();
+ });
+
+ it('prompts when sendDappAndAwait throws (timeout, disconnect)', async () => {
+ mockGetPair.mockReturnValue(makePair({ xchMojos: '1000000000000' }));
+ mockSendDappAndAwait.mockRejectedValueOnce(new Error('timeout'));
+ expect(await pairResolve(TAKE, { offer: OFFER_STR, fee: '0' })).toMatchObject({
+ kind: 'prompt',
+ reason: 'non-XCH spend needs confirmation',
+ });
+ });
+});
+
+describe('resolvePermission - commands gate (pair.commands allowlist)', () => {
+ const SEND_WC = 'chia_sendTransaction';
+
+ it('denies a command not in pair.commands even if everything else lines up', async () => {
+ mockGetPair.mockReturnValue(makePair({ commands: [], bypass: [SEND_WC], xchMojos: '100000' }));
+ expect(await pairResolve('chia_wallet.send_transaction', {})).toMatchObject({
+ kind: 'deny',
+ reason: `command not granted for this pair: ${SEND_WC}`,
+ });
+ });
+
+ it('denies when wcCommand is missing from the resolve context', async () => {
+ mockGetPair.mockReturnValue(makePair({ bypass: ['chia_getWallets'] }));
+ expect(await resolvePermission(PAIR_PRINCIPAL, 'chia_wallet.get_wallets', {}, {})).toMatchObject({
+ kind: 'deny',
+ reason: 'missing wc command',
+ });
+ });
+});
+
+describe('resolvePermission - commit idempotency', () => {
+ // Captures the resolved spend amount at decision time so a runtime mutation
+ // of the payload between resolve and authorization can't change what gets
+ // debited. Idempotent commits prevent double-charge.
+ it('commit is no-op on second call', async () => {
+ mockGetPair.mockReturnValue(makePair({ xchMojos: '10000' }));
+ const d = expectAllow(await pairResolve('chia_wallet.send_transaction', { amount: '500' }));
+ d.commit();
+ d.commit();
+ expect(mockRecordUsage).toHaveBeenCalledTimes(1);
+ });
+
+ it('separate resolve calls produce independent commits', async () => {
+ mockGetPair.mockReturnValue(makePair({ xchMojos: '10000' }));
+ const d1 = expectAllow(await pairResolve('chia_wallet.send_transaction', { amount: '500' }));
+ const d2 = expectAllow(await pairResolve('chia_wallet.send_transaction', { amount: '300' }));
+ d1.commit();
+ d2.commit();
+ expect(mockRecordUsage).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/packages/gui/src/electron/permissions/permissions.ts b/packages/gui/src/electron/permissions/permissions.ts
new file mode 100644
index 0000000000..083d28ea33
--- /dev/null
+++ b/packages/gui/src/electron/permissions/permissions.ts
@@ -0,0 +1,174 @@
+import BigNumber from 'bignumber.js';
+
+import toSnakeCase from '../utils/toSnakeCase';
+
+import { checkPairAccess } from './checkPairAccess';
+import { getSpendClassification, isSignCommand, isUiAllowed } from './commandCapabilities';
+import { getPair, recordUsage } from './pairStore';
+import type { Decision, PairContext, PairGrants, PairRecord, Principal, SpendClassification } from './types';
+
+const ZERO = new BigNumber(0);
+const NOOP = () => {};
+
+const allowDecision = (commit: () => void = NOOP): Decision => ({ kind: 'allow', commit });
+const promptDecision = (reason: string, pair?: PairContext): Decision => ({
+ kind: 'prompt',
+ reason,
+ pair,
+});
+const denyDecision = (reason: string, code: number): Decision => ({ kind: 'deny', reason, code });
+
+const PUSH_TRANSACTIONS = 'chia_wallet.push_transactions';
+
+function pairCtx(pair: PairRecord): PairContext {
+ return {
+ topic: pair.topic,
+ name: pair.metadata.name,
+ url: pair.metadata.url,
+ icon: pair.metadata.icon,
+ description: pair.metadata.description,
+ };
+}
+
+// BigNumber because mojo amounts routinely exceed 2^53.
+function readMojos(payload: Record, field: string): BigNumber | undefined {
+ const raw = payload?.[field];
+ if (raw === undefined || raw === null) return undefined;
+ try {
+ const bn = new BigNumber(typeof raw === 'string' ? raw : String(raw));
+ if (!bn.isFinite() || bn.isNegative()) return undefined;
+ return bn;
+ } catch {
+ return undefined;
+ }
+}
+
+// Idempotent: a double-invocation can't double-charge. Captures `mojos` at
+// decision time, not authorization time.
+function makeCommit(topic: string, mojos: BigNumber): () => void {
+ let consumed = false;
+ return () => {
+ if (consumed) return;
+ consumed = true;
+ if (!mojos.isFinite() || mojos.isLessThanOrEqualTo(0)) return;
+ recordUsage(topic, mojos);
+ };
+}
+
+export type ResolveContext = {
+ /** Wire form (`chia_`); required for pair principals. */
+ wcCommand?: string;
+ fingerprint?: number;
+ /** Required for pair principals; ignored for UI. Missing → network mismatch. */
+ mainnet?: boolean;
+};
+
+// `'unresolvable'` = command moves funds but can't be priced in XCH mojos
+// (CAT/NFT/mixed/missing-amount). `undefined` = not eligible for the allowance.
+type AllowanceCharge = BigNumber | 'unresolvable' | undefined;
+
+async function resolveAllowanceCharge(command: string, payload: Record): Promise {
+ // push_transactions only contributes the optional fee — bundle is pre-signed.
+ if (command === PUSH_TRANSACTIONS) {
+ return readMojos(payload, 'fee') ?? ZERO;
+ }
+
+ const spend = getSpendClassification(command);
+ if (!spend) return undefined;
+
+ const amount = await resolveAmount(spend, payload);
+ if (amount === undefined) return 'unresolvable';
+
+ const fee = spend.feeField ? (readMojos(payload, spend.feeField) ?? ZERO) : ZERO;
+ return amount.plus(fee);
+}
+
+async function resolveAmount(
+ classification: SpendClassification,
+ payload: Record,
+): Promise {
+ if (classification.amountResolver) return classification.amountResolver(payload);
+ if (classification.amountField) return readMojos(payload, classification.amountField);
+ return undefined;
+}
+
+function isSigningRequest(command: string, payload: Record): boolean {
+ // Truthy match (not `===`) mirrors the daemon's Python `if sign:`.
+ if (command === PUSH_TRANSACTIONS && payload?.sign) return true;
+ return isSignCommand(command);
+}
+
+/**
+ * Two auto-approval mechanisms:
+ * - `pair.bypass` is exact command-level trust.
+ * - `pair.grants.xchMojos` is a bounded XCH fallback for spend-class commands.
+ * Sign-class and `push_transactions` with `sign: true` always prompt.
+ */
+export async function resolvePermission(
+ principal: Principal,
+ command: string,
+ rawPayload: Record,
+ ctx: ResolveContext = {},
+): Promise {
+ if (principal.kind === 'ui') {
+ return isUiAllowed(command) ? allowDecision() : promptDecision('requires user confirmation');
+ }
+
+ // Canonicalise before any field read: wire-out also snake-cases, so a
+ // dapp's `Sign: true` would otherwise dodge the gate while the daemon
+ // still honored it.
+ const payload = toSnakeCase(rawPayload) as Record;
+
+ const access = checkPairAccess(
+ {
+ topic: principal.topic,
+ wcCommand: ctx.wcCommand,
+ fingerprint: ctx.fingerprint,
+ mainnet: ctx.mainnet as boolean,
+ },
+ { getPair },
+ );
+ if (!access.ok) return denyDecision(access.reason, access.code);
+ const { pair } = access;
+ const dialogCtx = pairCtx(pair);
+ const wcCommand = ctx.wcCommand!; // checkPairAccess rejected when missing
+
+ if (isSigningRequest(command, payload)) {
+ return promptDecision('signing requested', dialogCtx);
+ }
+
+ if (pair.bypass.includes(wcCommand)) {
+ return allowDecision();
+ }
+
+ const charge = await resolveAllowanceCharge(command, payload);
+ if (charge !== undefined) {
+ return resolveAllowance(pair, dialogCtx, charge);
+ }
+
+ return promptDecision('not in bypass list', dialogCtx);
+}
+
+function resolveAllowance(pair: PairRecord, ctx: PairContext, charge: BigNumber | 'unresolvable'): Decision {
+ if (charge === 'unresolvable') {
+ return promptDecision('non-XCH spend needs confirmation', ctx);
+ }
+
+ // Zero-charge (fee=0 push, request-only offer): no funds move, silent
+ // regardless of allowance.
+ if (charge.isLessThanOrEqualTo(0)) return allowDecision();
+
+ const allowance = new BigNumber(pair.grants.xchMojos ?? 0);
+ if (allowance.isLessThanOrEqualTo(0)) {
+ return promptDecision('spending needs confirmation', ctx);
+ }
+
+ const used = new BigNumber(pair.usedMojos ?? 0);
+ if (used.plus(charge).isGreaterThan(allowance)) {
+ return promptDecision('allowance exhausted', ctx);
+ }
+
+ return allowDecision(makeCommit(pair.topic, charge));
+}
+
+export type { PairGrants };
diff --git a/packages/gui/src/electron/permissions/types.ts b/packages/gui/src/electron/permissions/types.ts
new file mode 100644
index 0000000000..1f40f89dd2
--- /dev/null
+++ b/packages/gui/src/electron/permissions/types.ts
@@ -0,0 +1,61 @@
+import type BigNumber from 'bignumber.js';
+
+export type PairGrants = {
+ /** XCH mojos auto-approved per pair when the command is not bypassed. `'0'` = prompt unless bypassed. */
+ xchMojos: string;
+};
+
+export type PairMetadata = {
+ name: string;
+ url?: string;
+ icon?: string;
+ description?: string;
+};
+
+export type PairRecord = {
+ topic: string;
+ mainnet: boolean;
+ metadata: PairMetadata;
+ fingerprints: number[];
+ createdAt: number;
+ updatedAt: number;
+ grants: PairGrants;
+ /** Mojos debited from `grants.xchMojos`. */
+ usedMojos: string;
+ /** Wire form `chia_`. Granted at pairing; empty = deny-all. */
+ commands: string[];
+ /**
+ * Per-wcCommand "always allow" list. Spend-class wcCommands can be listed
+ * here for exact command-level trust; otherwise they fall back to
+ * `grants.xchMojos`.
+ */
+ bypass: string[];
+};
+
+export type Principal = { kind: 'ui' } | { kind: 'pair'; topic: string };
+
+export type AmountResolver = (
+ payload: Record,
+) => BigNumber | undefined | Promise;
+
+export type SpendClassification = {
+ capability: 'spend' | 'offer';
+ amountField?: string;
+ feeField?: string;
+ amountResolver?: AmountResolver;
+};
+
+/** Subset of PairRecord safe to expose across boundaries. */
+export type PairContext = {
+ topic: string;
+ name: string;
+ url?: string;
+ icon?: string;
+ description?: string;
+};
+
+// allow.commit() debits the spend; idempotent so duplicate calls don't double-charge.
+export type Decision =
+ | { kind: 'allow'; commit: () => void }
+ | { kind: 'prompt'; reason: string; pair?: PairContext }
+ | { kind: 'deny'; reason: string; code: number };
diff --git a/packages/gui/src/electron/utils/checkNFTOwnership.ts b/packages/gui/src/electron/utils/checkNFTOwnership.ts
new file mode 100644
index 0000000000..a56ddf95be
--- /dev/null
+++ b/packages/gui/src/electron/utils/checkNFTOwnership.ts
@@ -0,0 +1,20 @@
+import sendCommand from './sendCommand';
+
+export default async function checkNFTOwnership(nftId: string): Promise {
+ try {
+ const response = await sendCommand<{
+ success: boolean;
+ pubkey?: string;
+ signature?: string;
+ latest_coin_id?: string;
+ error?: string;
+ }>('sign_message_by_id', 'chia_wallet', {
+ id: nftId,
+ message: 'x',
+ });
+
+ return response.success === true;
+ } catch (error) {
+ return false;
+ }
+}
diff --git a/packages/gui/src/electron/utils/dappEnrichment.test.ts b/packages/gui/src/electron/utils/dappEnrichment.test.ts
new file mode 100644
index 0000000000..20304f60a3
--- /dev/null
+++ b/packages/gui/src/electron/utils/dappEnrichment.test.ts
@@ -0,0 +1,460 @@
+/**
+ * `dappEnrichment` builds the offer-summary card shown in the Confirm
+ * dialog from the daemon's response. Trust boundary: it lives in main and
+ * is what the user actually sees, so type-detection regressions here can
+ * mislabel an NFT as a CAT (or worse, hide an NFT image entirely — that's
+ * the bug these tests pin against).
+ */
+
+jest.mock('./webSocketBridge');
+
+import { buildCreateOfferDisplay, buildTakeOfferDisplay, lookupCat } from './dappEnrichment';
+import { sendDappAndAwait } from './webSocketBridge';
+
+const mockSendDappAndAwait = sendDappAndAwait as jest.MockedFunction;
+
+type DaemonHandler = (data: Record) => Record;
+
+// Route by destination.command to a handler. Each handler returns the
+// camelCase shape `data` should have on the response. The daemon's wire
+// shape is snake_case but `callDaemon` runs toCamelCase on its way back,
+// so we hand back the post-conversion shape directly.
+function setupDaemonMock(handlers: Record) {
+ mockSendDappAndAwait.mockImplementation(async (_requestId, payload) => {
+ const wire = JSON.parse(payload) as { destination: string; command: string; data?: Record };
+ const key = `${wire.destination}.${wire.command}`;
+ const handler = handlers[key];
+ if (!handler) {
+ throw new Error(`unmocked daemon call: ${key}`);
+ }
+ return { data: handler(wire.data ?? {}) };
+ });
+}
+
+beforeEach(() => {
+ mockSendDappAndAwait.mockReset();
+});
+
+describe('buildTakeOfferDisplay — NFT detection (regression: was misrendered as CAT)', () => {
+ it('renders an NFT being offered with kind=nft and the https previewUrl from nft_get_info', async () => {
+ // Pre-fix bug: info.type === 'NFT' check; daemon emits 'singleton',
+ // so NFTs fell through to the CAT branch (no image, wrong amount label).
+ setupDaemonMock({
+ 'chia_wallet.get_offer_summary': () => ({
+ summary: {
+ offered: { '0xabc123': 1 },
+ requested: { xch: '1123000000000' },
+ infos: {
+ '0xabc123': { type: 'singleton', launcherId: '0xabc123' },
+ },
+ },
+ }),
+ 'chia_wallet.nft_get_info': () => ({
+ nftInfo: { dataUris: ['https://example.com/nft.png'] },
+ }),
+ });
+
+ const display = await buildTakeOfferDisplay({ offer: 'offer1...' });
+
+ expect(display?.offered).toHaveLength(1);
+ expect(display?.offered[0]).toMatchObject({
+ kind: 'nft',
+ previewUrl: 'https://example.com/nft.png',
+ });
+ });
+
+ it('renders an NFT in the requested side too (when the user is taking-to-receive)', async () => {
+ setupDaemonMock({
+ 'chia_wallet.get_offer_summary': () => ({
+ summary: {
+ offered: { xch: '1000000000000' },
+ requested: { '0xdef456': 1 },
+ infos: {
+ '0xdef456': { type: 'singleton', launcherId: '0xdef456' },
+ },
+ },
+ }),
+ 'chia_wallet.nft_get_info': () => ({ nftInfo: { dataUris: ['https://example.com/got.png'] } }),
+ });
+
+ const display = await buildTakeOfferDisplay({ offer: 'offer1...' });
+
+ expect(display?.requested).toHaveLength(1);
+ expect(display?.requested[0]).toMatchObject({ kind: 'nft', previewUrl: 'https://example.com/got.png' });
+ });
+
+ it('rejects an http:// dataUri (isValidURL only accepts https + ipfs)', async () => {
+ setupDaemonMock({
+ 'chia_wallet.get_offer_summary': () => ({
+ summary: {
+ offered: { '0xabc': 1 },
+ requested: { xch: '1' },
+ infos: { '0xabc': { type: 'singleton', launcherId: '0xabc' } },
+ },
+ }),
+ 'chia_wallet.nft_get_info': () => ({ nftInfo: { dataUris: ['http://insecure.example.com/x.png'] } }),
+ });
+
+ const display = await buildTakeOfferDisplay({ offer: 'offer1...' });
+ expect(display?.offered[0]).toMatchObject({ kind: 'nft' });
+ expect((display?.offered[0] as { previewUrl?: string }).previewUrl).toBeUndefined();
+ });
+
+ it('picks the first dataUri that passes isValidURL when an invalid URL is listed first', async () => {
+ setupDaemonMock({
+ 'chia_wallet.get_offer_summary': () => ({
+ summary: {
+ offered: { '0xabc': 1 },
+ requested: { xch: '1' },
+ infos: { '0xabc': { type: 'singleton', launcherId: '0xabc' } },
+ },
+ }),
+ 'chia_wallet.nft_get_info': () => ({
+ nftInfo: { dataUris: ['http://insecure.example.com/x.png', 'https://secure.example.com/x.png'] },
+ }),
+ });
+
+ const display = await buildTakeOfferDisplay({ offer: 'offer1...' });
+ expect((display?.offered[0] as { previewUrl?: string }).previewUrl).toBe('https://secure.example.com/x.png');
+ });
+
+ it('omits previewUrl when nft_get_info returns no dataUris', async () => {
+ setupDaemonMock({
+ 'chia_wallet.get_offer_summary': () => ({
+ summary: {
+ offered: { '0xabc123': 1 },
+ requested: { xch: '1' },
+ infos: { '0xabc123': { type: 'singleton', launcherId: '0xabc123' } },
+ },
+ }),
+ 'chia_wallet.nft_get_info': () => ({ nftInfo: {} }),
+ });
+
+ const display = await buildTakeOfferDisplay({ offer: 'offer1...' });
+
+ expect(display?.offered[0]).toMatchObject({ kind: 'nft' });
+ expect((display?.offered[0] as { previewUrl?: string }).previewUrl).toBeUndefined();
+ });
+
+ it('omits previewUrl when nft_get_info fails (caller still gets a kind=nft line)', async () => {
+ setupDaemonMock({
+ 'chia_wallet.get_offer_summary': () => ({
+ summary: {
+ offered: { '0xabc123': 1 },
+ requested: { xch: '1' },
+ infos: { '0xabc123': { type: 'singleton', launcherId: '0xabc123' } },
+ },
+ }),
+ 'chia_wallet.nft_get_info': () => {
+ throw new Error('rpc down');
+ },
+ });
+
+ const display = await buildTakeOfferDisplay({ offer: 'offer1...' });
+
+ expect(display?.offered[0]).toMatchObject({ kind: 'nft' });
+ });
+});
+
+describe('buildTakeOfferDisplay — CAT, XCH, and mixed', () => {
+ it('renders a CAT entry with assetId + symbol from cat_asset_id_to_name', async () => {
+ setupDaemonMock({
+ 'chia_wallet.get_offer_summary': () => ({
+ summary: {
+ offered: { '0xcat789': 1000 },
+ requested: { xch: '1000000000000' },
+ infos: { '0xcat789': { type: 'CAT', tail: '0xcat789' } },
+ },
+ }),
+ // Wire is snake_case (post-toSnakeCase); handler reads `asset_id`.
+ 'chia_wallet.cat_asset_id_to_name': (data) => ({ name: data.asset_id === '0xcat789' ? 'TEST' : undefined }),
+ });
+
+ const display = await buildTakeOfferDisplay({ offer: 'offer1...' });
+
+ expect(display?.offered[0]).toMatchObject({ kind: 'cat', assetId: '0xcat789', symbol: 'TEST' });
+ });
+
+ it('falls back to no symbol when cat_asset_id_to_name fails', async () => {
+ setupDaemonMock({
+ 'chia_wallet.get_offer_summary': () => ({
+ summary: {
+ offered: { '0xcat789': 1000 },
+ requested: { xch: '1' },
+ infos: { '0xcat789': { type: 'CAT' } },
+ },
+ }),
+ 'chia_wallet.cat_asset_id_to_name': () => {
+ throw new Error('not in registry');
+ },
+ });
+
+ const display = await buildTakeOfferDisplay({ offer: 'offer1...' });
+ expect((display?.offered[0] as { symbol?: string }).symbol).toBeUndefined();
+ });
+
+ it('renders an XCH entry without consulting infos', async () => {
+ setupDaemonMock({
+ 'chia_wallet.get_offer_summary': () => ({
+ summary: {
+ offered: { xch: '500000000000' },
+ requested: { xch: '1000000000000' },
+ infos: {},
+ },
+ }),
+ });
+
+ const display = await buildTakeOfferDisplay({ offer: 'offer1...' });
+ expect(display?.offered[0]).toMatchObject({ kind: 'xch' });
+ expect(display?.requested[0]).toMatchObject({ kind: 'xch' });
+ });
+
+ it('handles a mixed offer (NFT for XCH + CAT)', async () => {
+ setupDaemonMock({
+ 'chia_wallet.get_offer_summary': () => ({
+ summary: {
+ offered: { '0xnft111': 1 },
+ requested: { xch: '1000000000000', '0xcat222': 100 },
+ infos: {
+ '0xnft111': { type: 'singleton', launcherId: '0xnft111' },
+ '0xcat222': { type: 'CAT' },
+ },
+ },
+ }),
+ 'chia_wallet.nft_get_info': () => ({ nftInfo: { dataUris: ['https://example.com/n.png'] } }),
+ 'chia_wallet.cat_asset_id_to_name': () => ({ name: 'CAT' }),
+ });
+
+ const display = await buildTakeOfferDisplay({ offer: 'offer1...' });
+ const kinds = (lines: typeof display.offered) => lines.map((l) => l.kind);
+
+ expect(kinds(display!.offered)).toEqual(['nft']);
+ expect(kinds(display!.requested).sort()).toEqual(['cat', 'xch']);
+ });
+
+ it('threads the dapp-supplied fee through (in XCH, not mojos)', async () => {
+ setupDaemonMock({
+ 'chia_wallet.get_offer_summary': () => ({
+ summary: {
+ offered: { xch: '1' },
+ requested: { xch: '2' },
+ infos: {},
+ },
+ }),
+ });
+
+ const display = await buildTakeOfferDisplay({ offer: 'offer1...', fee: '500000000000' });
+ expect(display?.fee).toBe('0.5');
+ });
+});
+
+describe('buildTakeOfferDisplay — input validation', () => {
+ it('returns undefined when `offer` is missing', async () => {
+ const display = await buildTakeOfferDisplay({});
+ expect(display).toBeUndefined();
+ expect(mockSendDappAndAwait).not.toHaveBeenCalled();
+ });
+
+ it('returns undefined when `offer` is the wrong type', async () => {
+ const display = await buildTakeOfferDisplay({ offer: 42 });
+ expect(display).toBeUndefined();
+ expect(mockSendDappAndAwait).not.toHaveBeenCalled();
+ });
+
+ it('returns undefined when get_offer_summary fails', async () => {
+ setupDaemonMock({
+ 'chia_wallet.get_offer_summary': () => {
+ throw new Error('invalid offer');
+ },
+ });
+ const display = await buildTakeOfferDisplay({ offer: 'broken' });
+ expect(display).toBeUndefined();
+ });
+
+ it('returns undefined when summary shape is malformed', async () => {
+ setupDaemonMock({
+ 'chia_wallet.get_offer_summary': () => ({ summary: 'not-an-object' }),
+ });
+ const display = await buildTakeOfferDisplay({ offer: 'offer1...' });
+ expect(display).toBeUndefined();
+ });
+});
+
+describe('buildCreateOfferDisplay — already-working NFT path stays working', () => {
+ it('treats a non-numeric hex key as an NFT launcher id', async () => {
+ // Numeric keys = wallet ids; non-numeric = hex launcher id. Belt-and-
+ // suspenders test against the create-offer regression where my fix to
+ // the take-offer side could conceivably also affect create.
+ setupDaemonMock({
+ 'chia_wallet.get_wallets': () => ({ wallets: [] }),
+ 'chia_wallet.nft_get_info': () => ({ nftInfo: { dataUris: ['https://example.com/owned.png'] } }),
+ });
+
+ const display = await buildCreateOfferDisplay({
+ offer: { '0xlauncher123': -1 },
+ });
+
+ expect(display?.offered).toHaveLength(1);
+ expect(display?.offered[0]).toMatchObject({ kind: 'nft', previewUrl: 'https://example.com/owned.png' });
+ });
+
+ it('classifies a numeric STANDARD_WALLET key as XCH', async () => {
+ setupDaemonMock({
+ 'chia_wallet.get_wallets': () => ({ wallets: [{ id: 1, type: 0 /* STANDARD_WALLET */ }] }),
+ });
+
+ const display = await buildCreateOfferDisplay({ offer: { '1': -1_000_000_000_000 } });
+ expect(display?.offered[0]).toMatchObject({ kind: 'xch' });
+ });
+
+ it('classifies a numeric CAT key as CAT', async () => {
+ setupDaemonMock({
+ 'chia_wallet.get_wallets': () => ({
+ wallets: [{ id: 2, type: 6 /* CAT */, name: 'My CAT', meta: { assetId: '0xcat' } }],
+ }),
+ 'chia_wallet.cat_asset_id_to_name': () => ({ name: 'TEST' }),
+ });
+
+ const display = await buildCreateOfferDisplay({ offer: { '2': -1000 } });
+ expect(display?.offered[0]).toMatchObject({ kind: 'cat', assetId: '0xcat', symbol: 'TEST' });
+ });
+
+ it('returns undefined when `offer` is not an object', async () => {
+ expect(await buildCreateOfferDisplay({})).toBeUndefined();
+ expect(await buildCreateOfferDisplay({ offer: null })).toBeUndefined();
+ expect(await buildCreateOfferDisplay({ offer: 'string' })).toBeUndefined();
+ });
+});
+
+describe('buildCreateOfferDisplay — magnitude precision (regression: was Number()-truncated)', () => {
+ // Mojo amounts can exceed Number.MAX_SAFE_INTEGER (2^53 ≈ 9e15). The wire
+ // value passes through verbatim; the dialog must show the same amount it
+ // sends, otherwise a hostile dapp can craft an offer where the displayed
+ // XCH amount differs from what the daemon actually executes.
+ it('preserves precision for an XCH outflow amount past MAX_SAFE_INTEGER', async () => {
+ setupDaemonMock({
+ 'chia_wallet.get_wallets': () => ({ wallets: [{ id: 1, type: 0 /* STANDARD_WALLET */ }] }),
+ });
+
+ // 19-digit mojo value → ~10M XCH. Number() rounds the last 4 digits to 0
+ // (so the buggy display would show '10000000', missing the .000...999
+ // tail); BigNumber preserves every digit.
+ const display = await buildCreateOfferDisplay({ offer: { '1': '-9999999999999999999' } });
+
+ expect(display?.offered).toHaveLength(1);
+ expect(display?.offered[0]).toEqual({ kind: 'xch', amount: '9999999.999999999999' });
+ });
+
+ it('preserves precision for an XCH inflow amount as a numeric string', async () => {
+ setupDaemonMock({
+ 'chia_wallet.get_wallets': () => ({ wallets: [{ id: 1, type: 0 /* STANDARD_WALLET */ }] }),
+ });
+
+ const display = await buildCreateOfferDisplay({ offer: { '1': '12345678901234567' } });
+
+ expect(display?.requested).toHaveLength(1);
+ expect(display?.requested[0]).toEqual({ kind: 'xch', amount: '12345.678901234567' });
+ });
+
+ it('preserves precision for a CAT outflow amount past MAX_SAFE_INTEGER', async () => {
+ setupDaemonMock({
+ 'chia_wallet.get_wallets': () => ({
+ wallets: [{ id: 2, type: 6 /* CAT */, name: 'My CAT', meta: { assetId: '0xcat' } }],
+ }),
+ 'chia_wallet.cat_asset_id_to_name': () => ({ name: 'TEST' }),
+ });
+
+ const display = await buildCreateOfferDisplay({ offer: { '2': '-9999999999999999999' } });
+
+ expect(display?.offered[0]).toMatchObject({
+ kind: 'cat',
+ assetId: '0xcat',
+ symbol: 'TEST',
+ // CATs use 1000 mojo = 1 unit (vs XCH's 1e12). Same precision discipline.
+ amount: '9999999999999999.999',
+ });
+ });
+
+ it('skips entries with non-numeric, non-string values gracefully', async () => {
+ setupDaemonMock({
+ 'chia_wallet.get_wallets': () => ({ wallets: [{ id: 1, type: 0 /* STANDARD_WALLET */ }] }),
+ });
+
+ // BigNumber(undefined) / BigNumber({}) → NaN → skipped. Other entries on
+ // the same offer should still render.
+ const display = await buildCreateOfferDisplay({
+ offer: { '1': '-1000000000000', bogus: undefined as unknown as string, junk: { not: 'a number' } },
+ });
+
+ expect(display?.offered).toHaveLength(1);
+ expect(display?.offered[0]).toMatchObject({ kind: 'xch', amount: '1' });
+ });
+});
+
+describe('lookupCat', () => {
+ it('returns the resolved displayName + isRevocable=false for a regular CAT', async () => {
+ setupDaemonMock({
+ 'chia_wallet.get_wallets': () => ({
+ wallets: [{ id: 5, type: 6 /* CAT */, name: 'My CAT', meta: { assetId: '0xa1' } }],
+ }),
+ 'chia_wallet.cat_asset_id_to_name': () => ({ name: 'TEST' }),
+ });
+
+ const result = await lookupCat(5);
+ expect(result).toEqual({ displayName: 'TEST', isRevocable: false });
+ });
+
+ it('flags isRevocable=true for an RCAT', async () => {
+ setupDaemonMock({
+ 'chia_wallet.get_wallets': () => ({
+ wallets: [{ id: 5, type: 132 /* RCAT */, name: 'Restricted', meta: { assetId: '0xa1' } }],
+ }),
+ 'chia_wallet.cat_asset_id_to_name': () => ({ name: 'RCAT' }),
+ });
+
+ const result = await lookupCat(5);
+ expect(result).toEqual({ displayName: 'RCAT', isRevocable: true });
+ });
+
+ it('falls back to the wallet name when the CAT registry has no match', async () => {
+ setupDaemonMock({
+ 'chia_wallet.get_wallets': () => ({
+ wallets: [{ id: 5, type: 6, name: 'My CAT', meta: { assetId: '0xa1' } }],
+ }),
+ 'chia_wallet.cat_asset_id_to_name': () => {
+ throw new Error('not in registry');
+ },
+ });
+
+ const result = await lookupCat(5);
+ expect(result?.displayName).toBe('My CAT');
+ });
+
+ it('returns undefined for a non-CAT wallet (e.g. STANDARD_WALLET)', async () => {
+ setupDaemonMock({
+ 'chia_wallet.get_wallets': () => ({
+ wallets: [{ id: 1, type: 0 /* STANDARD_WALLET */, name: 'XCH' }],
+ }),
+ });
+
+ expect(await lookupCat(1)).toBeUndefined();
+ });
+
+ it('returns undefined when the wallet id does not exist', async () => {
+ setupDaemonMock({
+ 'chia_wallet.get_wallets': () => ({ wallets: [{ id: 1, type: 0 }] }),
+ });
+
+ expect(await lookupCat(999)).toBeUndefined();
+ });
+
+ it('returns undefined when get_wallets fails', async () => {
+ setupDaemonMock({
+ 'chia_wallet.get_wallets': () => {
+ throw new Error('daemon down');
+ },
+ });
+
+ expect(await lookupCat(5)).toBeUndefined();
+ });
+});
diff --git a/packages/gui/src/electron/utils/dappEnrichment.ts b/packages/gui/src/electron/utils/dappEnrichment.ts
new file mode 100644
index 0000000000..7a8c6e9c78
--- /dev/null
+++ b/packages/gui/src/electron/utils/dappEnrichment.ts
@@ -0,0 +1,265 @@
+// Confirm-dialog enrichment built in main from the same `data` that hits
+// the wire, so a compromised renderer can't show "1 XCH to friend" while
+// sending "50 XCH to attacker." Daemon RPCs are trusted (same machine,
+// TLS-pinned socket).
+import crypto from 'node:crypto';
+
+import BigNumber from 'bignumber.js';
+
+import WalletType from '../constants/WalletType';
+
+import isValidURL from './isValidURL';
+import mojoToCAT from './mojoToCAT';
+import mojoToChia from './mojoToChia';
+import toBech32m from './toBech32m';
+import toCamelCase from './toCamelCase';
+import toSnakeCase from './toSnakeCase';
+import { sendDappAndAwait } from './webSocketBridge';
+
+export type EnrichmentDisplay = {
+ cat?: { displayName: string; isRevocable: boolean };
+ offer?: {
+ offered: OfferLine[];
+ requested: OfferLine[];
+ fee?: string;
+ };
+};
+
+export type OfferLine =
+ | { kind: 'xch'; amount: string }
+ | { kind: 'cat'; amount: string; assetId: string; symbol?: string }
+ | { kind: 'nft'; nftId: string; name?: string; previewUrl?: string };
+
+type DaemonError = { error?: unknown; success?: boolean };
+
+// Daemon RPC over the renderer's WebSocket; resolved by request_id via
+// dappPending. Renderer never sees these calls.
+async function callDaemon(
+ destination: string,
+ command: string,
+ data: Record = {},
+ timeoutMs = 15_000,
+): Promise {
+ const requestId = crypto.randomBytes(32).toString('hex');
+ const wire = {
+ origin: 'wallet_ui',
+ destination,
+ command,
+ data,
+ ack: false,
+ request_id: requestId,
+ };
+ const json = JSON.stringify(toSnakeCase(wire));
+ const response = (await sendDappAndAwait(requestId, json, timeoutMs)) as { data?: DaemonError };
+ const responseData = response?.data;
+ if (responseData?.error) {
+ throw new Error(String(responseData.error));
+ }
+ return toCamelCase(responseData ?? {}) as T;
+}
+
+type Wallet = {
+ id: number;
+ type: number;
+ name?: string;
+ meta?: { assetId?: string; tail?: string };
+};
+
+type CatNameInfo = { walletId?: number; name?: string };
+
+export async function lookupCat(walletId: number | string): Promise {
+ try {
+ const { wallets = [] } = await callDaemon<{ wallets?: Wallet[] }>('chia_wallet', 'get_wallets', {
+ includeData: true,
+ });
+ const wallet = wallets.find((w) => Number(w.id) === Number(walletId));
+ if (!wallet) return undefined;
+ if (wallet.type !== WalletType.CAT && wallet.type !== WalletType.RCAT && wallet.type !== WalletType.CRCAT) {
+ return undefined;
+ }
+ let displayName = wallet.name?.trim() || '';
+ const assetId = wallet.meta?.assetId ?? wallet.meta?.tail;
+ // Prefer the CAT registry's curated name when available.
+ if (assetId) {
+ try {
+ const catName = await callDaemon('chia_wallet', 'cat_asset_id_to_name', { assetId });
+ if (catName?.name) displayName = catName.name;
+ } catch {
+ // fall back to wallet name
+ }
+ }
+ if (!displayName) return undefined;
+ return { displayName, isRevocable: wallet.type === WalletType.RCAT };
+ } catch {
+ return undefined;
+ }
+}
+
+type NftInfo = { dataUris?: string[]; metadataUris?: string[]; nftCoinId?: string };
+
+function pickPreviewUrl(dataUris: string[] | undefined): string | undefined {
+ if (!dataUris) return undefined;
+ return dataUris.find((u) => isValidURL(u));
+}
+
+async function lookupNft(launcherIdHex: string): Promise<{ name?: string; previewUrl?: string } | undefined> {
+ try {
+ const result = await callDaemon<{ nftInfo?: NftInfo } & NftInfo>('chia_wallet', 'nft_get_info', {
+ coinId: launcherIdHex,
+ });
+ const info = result.nftInfo ?? result;
+ const previewUrl = pickPreviewUrl(info.dataUris);
+ return previewUrl ? { previewUrl } : undefined;
+ } catch {
+ return undefined;
+ }
+}
+
+// Encode an NFT launcher hash as a bech32m `nft1...` id. Falls back to the
+// raw hex on encoding failure (e.g. odd-length input).
+function hexToNftId(hex: string): string {
+ try {
+ return toBech32m(hex, 'nft');
+ } catch {
+ return hex;
+ }
+}
+
+// Daemon emits `'singleton'` for NFTs (NOT `'NFT'`). Confirmed against
+// the rest of the renderer's offer code (offerToOfferBuilderData.ts).
+type OfferSummaryRecord = {
+ offered: Record;
+ requested: Record;
+ infos: Record;
+ fees?: number;
+};
+
+type GetOfferSummaryResult = { id?: string; summary: OfferSummaryRecord | unknown };
+
+async function lookupCatNameByAssetId(assetId: string): Promise {
+ try {
+ const result = await callDaemon('chia_wallet', 'cat_asset_id_to_name', { assetId });
+ return result.name;
+ } catch {
+ return undefined;
+ }
+}
+
+async function summaryToOffer(
+ summary: OfferSummaryRecord,
+ feeMojos: number | string | undefined,
+): Promise {
+ async function toLines(map: Record): Promise {
+ const entries = Object.entries(map);
+ return Promise.all(
+ entries.map(async ([key, rawAmount]): Promise => {
+ if (key === 'xch') {
+ return { kind: 'xch', amount: mojoToChia(String(rawAmount).replace(/^-/, '')).toFixed() };
+ }
+ const info = summary.infos[key];
+ if (info?.type === 'singleton' && info.launcherId) {
+ const nftId = hexToNftId(info.launcherId);
+ const enriched = await lookupNft(info.launcherId);
+ return { kind: 'nft', nftId, ...enriched };
+ }
+ const symbol = await lookupCatNameByAssetId(key);
+ return {
+ kind: 'cat',
+ amount: mojoToCAT(String(rawAmount).replace(/^-/, '')).toFixed(),
+ assetId: key,
+ symbol,
+ };
+ }),
+ );
+ }
+
+ const [offered, requested] = await Promise.all([toLines(summary.offered), toLines(summary.requested)]);
+ return {
+ offered,
+ requested,
+ fee: feeMojos !== undefined && feeMojos !== null ? mojoToChia(String(feeMojos)).toFixed() : undefined,
+ };
+}
+
+export async function buildTakeOfferDisplay(
+ data: Record,
+): Promise {
+ const { offer } = data;
+ if (typeof offer !== 'string' || !offer) return undefined;
+ try {
+ const result = await callDaemon('chia_wallet', 'get_offer_summary', { offer });
+ const { summary } = result;
+ if (!summary || typeof summary !== 'object' || !('offered' in summary) || !('requested' in summary)) {
+ return undefined;
+ }
+ return await summaryToOffer(summary as OfferSummaryRecord, data.fee as number | string | undefined);
+ } catch {
+ return undefined;
+ }
+}
+
+export async function buildCreateOfferDisplay(
+ data: Record,
+): Promise {
+ const offerDict = data.offer;
+ if (!offerDict || typeof offerDict !== 'object') return undefined;
+ const fee = data.fee !== undefined && data.fee !== null ? mojoToChia(String(data.fee)).toFixed() : undefined;
+
+ let wallets: Wallet[] = [];
+ try {
+ const result = await callDaemon<{ wallets?: Wallet[] }>('chia_wallet', 'get_wallets', { includeData: true });
+ wallets = result.wallets ?? [];
+ } catch {
+ // proceed with no wallet info; lines still render with id-only labels
+ }
+
+ const offered: OfferLine[] = [];
+ const requested: OfferLine[] = [];
+
+ await Promise.all(
+ Object.entries(offerDict as Record).map(async ([key, raw]) => {
+ // Mojo amounts can exceed Number.MAX_SAFE_INTEGER (2^53). Use BigNumber
+ // so the displayed magnitude matches exactly what goes on the wire.
+ let amount: BigNumber;
+ try {
+ amount = new BigNumber(typeof raw === 'string' || typeof raw === 'number' ? raw : String(raw));
+ } catch {
+ return;
+ }
+ if (!amount.isFinite() || amount.isZero()) return;
+ const bucket = amount.isPositive() ? requested : offered;
+ const abs = amount.abs().toFixed(0);
+
+ // Numeric key → wallet id; otherwise treat as an asset id (CAT) or
+ // bech32 nft id depending on prefix length.
+ const isNumeric = /^-?\d+$/.test(key);
+ if (isNumeric) {
+ const wallet = wallets.find((w) => Number(w.id) === Number(key));
+ if (!wallet) return;
+ if (wallet.type === WalletType.STANDARD_WALLET) {
+ bucket.push({ kind: 'xch', amount: mojoToChia(abs).toFixed() });
+ return;
+ }
+ if (wallet.type === WalletType.CAT || wallet.type === WalletType.RCAT || wallet.type === WalletType.CRCAT) {
+ const assetId = wallet.meta?.assetId ?? wallet.meta?.tail ?? '';
+ const symbol = (await lookupCatNameByAssetId(assetId)) ?? wallet.name;
+ bucket.push({
+ kind: 'cat',
+ amount: mojoToCAT(abs).toFixed(),
+ assetId,
+ symbol,
+ });
+ return;
+ }
+ return;
+ }
+
+ // Non-numeric key: assume hex launcher id, encode as nft1...
+ const nftId = hexToNftId(key);
+ const enriched = await lookupNft(key);
+ bucket.push({ kind: 'nft', nftId, ...enriched });
+ }),
+ );
+
+ return { offered, requested, fee };
+}
diff --git a/packages/gui/src/electron/utils/getAvailableWallets.test.ts b/packages/gui/src/electron/utils/getAvailableWallets.test.ts
new file mode 100644
index 0000000000..f0b5da68b4
--- /dev/null
+++ b/packages/gui/src/electron/utils/getAvailableWallets.test.ts
@@ -0,0 +1,147 @@
+/**
+ * `getAvailableWallets` is the daemon-sourced wallet list for the Pair
+ * dialog. Trust boundary: the renderer used to supply this and could lie
+ * about which keys exist or which is active. These tests pin the
+ * branches that determine what the dialog gets.
+ */
+
+jest.mock('./sendCommand');
+
+import getAvailableWallets from './getAvailableWallets';
+import sendCommand from './sendCommand';
+
+const mockSendCommand = sendCommand as jest.MockedFunction;
+
+beforeEach(() => {
+ mockSendCommand.mockReset();
+});
+
+function arrangeKeysAndFingerprint(
+ keys: { fingerprint: number; label?: string }[] | undefined,
+ loggedIn: number | undefined | { throws: Error },
+) {
+ mockSendCommand.mockImplementation(async (command, destination) => {
+ if (command === 'get_keys' && destination === 'daemon') {
+ return { keys } as Record;
+ }
+ if (command === 'get_logged_in_fingerprint' && destination === 'chia_wallet') {
+ if (loggedIn && typeof loggedIn === 'object' && 'throws' in loggedIn) {
+ throw loggedIn.throws;
+ }
+ return { fingerprint: loggedIn } as Record;
+ }
+ throw new Error(`unexpected sendCommand: ${command} → ${destination}`);
+ });
+}
+
+describe('getAvailableWallets — happy path', () => {
+ it('maps daemon keys to PairWalletOption with fingerprint + label', async () => {
+ arrangeKeysAndFingerprint(
+ [
+ { fingerprint: 111, label: 'Main' },
+ { fingerprint: 222, label: 'Cold' },
+ ],
+ 111,
+ );
+ const result = await getAvailableWallets();
+ expect(result.availableWallets).toEqual([
+ { fingerprint: 111, name: 'Main' },
+ { fingerprint: 222, name: 'Cold' },
+ ]);
+ });
+
+ it('defaults to the logged-in fingerprint when it appears in the keys list', async () => {
+ arrangeKeysAndFingerprint(
+ [
+ { fingerprint: 111, label: 'A' },
+ { fingerprint: 222, label: 'B' },
+ ],
+ 222,
+ );
+ const result = await getAvailableWallets();
+ expect(result.defaultFingerprints).toEqual([222]);
+ });
+
+ it('drops the default when the logged-in fingerprint is not on the keys list', async () => {
+ // Edge case: the daemon's logged-in fingerprint is technically possible
+ // to be a key the wallet doesn't expose. Don't pre-select something the
+ // user can't actually pick.
+ arrangeKeysAndFingerprint([{ fingerprint: 111 }], 999);
+ const result = await getAvailableWallets();
+ expect(result.defaultFingerprints).toEqual([]);
+ });
+});
+
+describe('getAvailableWallets — empty / missing labels', () => {
+ it('returns empty wallets and empty defaults when the daemon has no keys', async () => {
+ arrangeKeysAndFingerprint([], 111);
+ const result = await getAvailableWallets();
+ expect(result.availableWallets).toEqual([]);
+ expect(result.defaultFingerprints).toEqual([]);
+ });
+
+ it('omits `name` when label is empty string (treats falsy label as absent)', async () => {
+ arrangeKeysAndFingerprint([{ fingerprint: 111, label: '' }], 111);
+ const result = await getAvailableWallets();
+ expect(result.availableWallets[0]).toEqual({ fingerprint: 111, name: undefined });
+ });
+
+ it('omits `name` when label field is missing entirely', async () => {
+ arrangeKeysAndFingerprint([{ fingerprint: 111 }], 111);
+ const result = await getAvailableWallets();
+ expect(result.availableWallets[0]).toEqual({ fingerprint: 111, name: undefined });
+ });
+
+ it('treats an undefined `keys` field on the response as empty', async () => {
+ // Daemon contract is `{ keys: [...] }` but a hardened reader handles
+ // a missing field — never crash the pair flow on a daemon-shape change.
+ arrangeKeysAndFingerprint(undefined, 111);
+ const result = await getAvailableWallets();
+ expect(result.availableWallets).toEqual([]);
+ expect(result.defaultFingerprints).toEqual([]);
+ });
+});
+
+describe('getAvailableWallets — fingerprint resolution', () => {
+ it('drops the default when the daemon returns no logged-in fingerprint', async () => {
+ arrangeKeysAndFingerprint([{ fingerprint: 111 }], undefined);
+ const result = await getAvailableWallets();
+ expect(result.defaultFingerprints).toEqual([]);
+ });
+
+ it('drops the default when the logged-in fingerprint comes back as a non-number', async () => {
+ mockSendCommand.mockImplementation(async (command) => {
+ if (command === 'get_keys') return { keys: [{ fingerprint: 111 }] };
+ return { fingerprint: 'not-a-number' };
+ });
+ const result = await getAvailableWallets();
+ expect(result.defaultFingerprints).toEqual([]);
+ });
+
+ it('tolerates a `get_logged_in_fingerprint` failure (still returns wallets)', async () => {
+ // Default selection is a UX nicety; without it the dialog still opens.
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
+ arrangeKeysAndFingerprint([{ fingerprint: 111 }], { throws: new Error('rpc down') });
+ const result = await getAvailableWallets();
+ expect(result.availableWallets).toEqual([{ fingerprint: 111, name: undefined }]);
+ expect(result.defaultFingerprints).toEqual([]);
+ expect(warnSpy).toHaveBeenCalled();
+ warnSpy.mockRestore();
+ });
+});
+
+describe('getAvailableWallets — failure modes', () => {
+ it('propagates a `get_keys` failure (pair flow must fail visibly)', async () => {
+ // Without keys the dialog has nothing to render — surface the error
+ // rather than open an empty list.
+ mockSendCommand.mockRejectedValueOnce(new Error('daemon unreachable'));
+ await expect(getAvailableWallets()).rejects.toThrow('daemon unreachable');
+ });
+
+ it('does not call `get_logged_in_fingerprint` if `get_keys` fails', async () => {
+ mockSendCommand.mockRejectedValueOnce(new Error('boom'));
+ await expect(getAvailableWallets()).rejects.toThrow();
+ // Only one call was made — the failure short-circuits.
+ expect(mockSendCommand).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/packages/gui/src/electron/utils/getAvailableWallets.ts b/packages/gui/src/electron/utils/getAvailableWallets.ts
new file mode 100644
index 0000000000..cf61565833
--- /dev/null
+++ b/packages/gui/src/electron/utils/getAvailableWallets.ts
@@ -0,0 +1,32 @@
+import type { PairWalletOption } from '../dialogs/Pair/Pair';
+
+import sendCommand from './sendCommand';
+
+// Sourced from the daemon (not the renderer) so a compromised renderer
+// can't fabricate the wallet list or claim the wrong active key.
+export default async function getAvailableWallets(): Promise<{
+ availableWallets: PairWalletOption[];
+ defaultFingerprints: number[];
+}> {
+ const { keys = [] } = await sendCommand<{ keys?: { fingerprint: number; label?: string }[] }>('get_keys', 'daemon');
+ const availableWallets: PairWalletOption[] = keys.map((k) => ({
+ fingerprint: k.fingerprint,
+ name: k.label || undefined,
+ }));
+
+ // Default-selection only — tolerate failure; the dialog still opens.
+ let loggedInFingerprint: number | undefined;
+ try {
+ const { fingerprint } = await sendCommand<{ fingerprint?: number }>('get_logged_in_fingerprint', 'chia_wallet');
+ loggedInFingerprint = typeof fingerprint === 'number' ? fingerprint : undefined;
+ } catch (err) {
+ console.warn('Failed to fetch logged-in fingerprint for pair dialog default', err);
+ }
+
+ const defaultFingerprints =
+ loggedInFingerprint !== undefined && availableWallets.some((w) => w.fingerprint === loggedInFingerprint)
+ ? [loggedInFingerprint]
+ : [];
+
+ return { availableWallets, defaultFingerprints };
+}
diff --git a/packages/gui/src/electron/utils/getKeyDetails.ts b/packages/gui/src/electron/utils/getKeyDetails.ts
new file mode 100644
index 0000000000..8a664fa245
--- /dev/null
+++ b/packages/gui/src/electron/utils/getKeyDetails.ts
@@ -0,0 +1,26 @@
+import sendCommand from './sendCommand';
+
+export default async function getKeyDetails(fingerprint: string) {
+ const { keys } = await sendCommand('get_keys', 'daemon', { fingerprint });
+
+ const findIndex = keys.findIndex((key: any) => key.fingerprint.toString() === fingerprint);
+ if (findIndex === -1) {
+ throw new Error('Key not found');
+ }
+
+ const key = keys[findIndex];
+
+ const { private_key: privateKey } = await sendCommand('get_private_key', 'chia_wallet', { fingerprint });
+
+ return {
+ index: findIndex,
+ label: key.label,
+ fingerprint: key.fingerprint,
+ publicKey: key.public_key,
+
+ farmerPublicKey: privateKey.farmer_pk,
+ poolPublicKey: privateKey.pool_pk,
+ secretKey: privateKey.sk,
+ seed: privateKey.seed,
+ };
+}
diff --git a/packages/gui/src/electron/utils/getNetworkInfo.ts b/packages/gui/src/electron/utils/getNetworkInfo.ts
new file mode 100644
index 0000000000..a4aa82c109
--- /dev/null
+++ b/packages/gui/src/electron/utils/getNetworkInfo.ts
@@ -0,0 +1,10 @@
+import sendCommand from './sendCommand';
+
+export default async function getNetworkInfo() {
+ const data = await sendCommand('get_network_info', 'chia_wallet');
+
+ return {
+ networkName: data.network_name,
+ networkPrefix: data.network_prefix,
+ };
+}
diff --git a/packages/gui/src/electron/utils/sendCommand.ts b/packages/gui/src/electron/utils/sendCommand.ts
new file mode 100644
index 0000000000..13b3b89e5a
--- /dev/null
+++ b/packages/gui/src/electron/utils/sendCommand.ts
@@ -0,0 +1,103 @@
+import { WebSocket } from 'ws';
+
+import loadConfig from './loadConfig';
+
+export default async function sendCommand>(
+ command: string,
+ destination: 'daemon' | 'chia_wallet',
+ commandData?: Record,
+): Promise {
+ const { url, key, cert } = await loadConfig();
+
+ const socket = new WebSocket(url, {
+ key,
+ cert,
+ rejectUnauthorized: false,
+ });
+
+ const requestId = crypto.randomUUID();
+
+ const messageData = JSON.stringify({
+ request_id: requestId,
+ command,
+ destination,
+ origin: 'wallet_ui',
+ data: commandData,
+ ack: false,
+ });
+
+ return new Promise((resolve, reject) => {
+ let isResolved = false;
+
+ function cleanup() {
+ if (!isResolved) {
+ socket.removeAllListeners();
+ socket.close();
+ }
+ }
+
+ function handleSuccess(data: any) {
+ cleanup();
+ isResolved = true;
+ resolve(data);
+ }
+
+ function handleError(error: Error) {
+ cleanup();
+ isResolved = true;
+ reject(error);
+ }
+
+ socket.on('open', () => {
+ const registerService = JSON.stringify({
+ command: 'register_service',
+ data: { service: 'wallet_ui' },
+ origin: 'wallet_ui',
+ destination: 'daemon',
+ ack: false,
+ });
+
+ socket.send(registerService);
+
+ socket.once('message', (data: Buffer) => {
+ try {
+ const response = JSON.parse(data.toString());
+ if (!response.data.success) {
+ throw new Error(`Service ${destination} is not registered`);
+ }
+
+ socket.send(messageData);
+ } catch (error) {
+ handleError(new Error((error as Error).message));
+ }
+ });
+ });
+
+ socket.on('message', (data: Buffer) => {
+ try {
+ const response = JSON.parse(data.toString());
+ if (response.request_id !== requestId) {
+ return;
+ }
+
+ if (!response.data.success) {
+ throw new Error(response.data.error);
+ }
+
+ handleSuccess(response.data);
+ } catch (error) {
+ handleError(new Error('Failed to parse response'));
+ }
+ });
+
+ socket.on('error', (error: Error) => {
+ handleError(error);
+ });
+
+ socket.on('close', () => {
+ if (!isResolved) {
+ handleError(new Error('Connection closed before receiving response'));
+ }
+ });
+ });
+}
diff --git a/packages/gui/src/hooks/useCommandMetadata.ts b/packages/gui/src/hooks/useCommandMetadata.ts
new file mode 100644
index 0000000000..9616e5606f
--- /dev/null
+++ b/packages/gui/src/hooks/useCommandMetadata.ts
@@ -0,0 +1,56 @@
+import { useEffect, useState } from 'react';
+
+import type { PermissionsCommandMetadata } from '../@types/PermissionsService';
+
+type CommandsByWc = Map;
+
+const EMPTY: CommandsByWc = new Map();
+
+let cached: Promise | null = null;
+
+function fetchCommandsByWc(): Promise {
+ if (!cached) {
+ cached = (async () => {
+ try {
+ const rows = await window.permissionsAPI.commandsMetadata();
+ const map: CommandsByWc = new Map();
+ for (const row of rows) map.set(row.wcCommand, row);
+ return map;
+ } catch (err) {
+ cached = null;
+ throw err;
+ }
+ })();
+ }
+ return cached;
+}
+
+export default function useCommandMetadata(): {
+ isLoading: boolean;
+ byWc: CommandsByWc;
+} {
+ const [byWc, setByWc] = useState(EMPTY);
+ const [isLoading, setIsLoading] = useState(true);
+
+ useEffect(() => {
+ let cancelled = false;
+
+ (async () => {
+ try {
+ const map = await fetchCommandsByWc();
+ if (cancelled) return;
+ setByWc(map);
+ } catch {
+ if (cancelled) return;
+ setByWc(EMPTY);
+ } finally {
+ if (!cancelled) setIsLoading(false);
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ return { isLoading, byWc };
+}
diff --git a/packages/gui/src/hooks/useStandardWallet.ts b/packages/gui/src/hooks/useStandardWallet.ts
index 8eca8ba32c..06d23ee516 100644
--- a/packages/gui/src/hooks/useStandardWallet.ts
+++ b/packages/gui/src/hooks/useStandardWallet.ts
@@ -1,4 +1,4 @@
-import type { Wallet } from '@chia-network/api';
+import type { Wallet, WalletBalance } from '@chia-network/api';
import { WalletType } from '@chia-network/api';
import { useGetWalletsQuery, useGetWalletBalanceQuery } from '@chia-network/api-react';
import { useMemo } from 'react';
@@ -7,19 +7,35 @@ export default function useStandardWallet(): {
loading: boolean;
wallet?: Wallet;
balance?: number;
+ walletBalance?: WalletBalance;
+ error?: unknown;
} {
const { data: wallets, isLoading: isLoadingGetWallets } = useGetWalletsQuery();
- const { data: balance, isLoading: isLoadingWalletBalance } = useGetWalletBalanceQuery({
- walletId: 1,
- });
-
- const isLoading = isLoadingGetWallets || isLoadingWalletBalance;
const wallet = useMemo(() => wallets?.find((item: Wallet) => item?.type === WalletType.STANDARD_WALLET), [wallets]);
+ const walletId = wallet?.id;
+
+ const {
+ data: walletBalance,
+ isLoading: isLoadingWalletBalance,
+ error,
+ } = useGetWalletBalanceQuery(
+ {
+ walletId: walletId ?? 0,
+ },
+ {
+ pollingInterval: 10_000,
+ skip: !walletId,
+ },
+ );
+
+ const isLoading = isLoadingGetWallets || (!!walletId && isLoadingWalletBalance);
return {
loading: isLoading,
wallet,
- balance: balance?.confirmedWalletBalance,
+ balance: walletBalance?.confirmedWalletBalance,
+ walletBalance,
+ error,
};
}
diff --git a/packages/gui/src/hooks/useWalletConnectContext.ts b/packages/gui/src/hooks/useWalletConnectContext.ts
new file mode 100644
index 0000000000..7b96a273db
--- /dev/null
+++ b/packages/gui/src/hooks/useWalletConnectContext.ts
@@ -0,0 +1,12 @@
+import { useContext } from 'react';
+
+import { WalletConnectContext } from '../components/walletConnect/WalletConnectProvider';
+
+export default function useWalletConnectContext() {
+ const context = useContext(WalletConnectContext);
+ if (!context) {
+ throw new Error('useWalletConnectContext must be used within a WalletConnectProvider');
+ }
+
+ return context;
+}
diff --git a/packages/gui/src/hooks/useWalletConnectPairs.ts b/packages/gui/src/hooks/useWalletConnectPairs.ts
new file mode 100644
index 0000000000..893dea05cd
--- /dev/null
+++ b/packages/gui/src/hooks/useWalletConnectPairs.ts
@@ -0,0 +1,162 @@
+import { useLocalStorage } from '@chia-network/api-react';
+import { useCallback, useRef, useMemo } from 'react';
+
+import type Pair from '../@types/Pair';
+
+type PairCallback = (pairs: Pair[]) => Pair[];
+
+export type Pairs = {
+ addPair: (pair: Pair) => void;
+ getPair: (topic: string) => Pair | undefined;
+ updatePair: (topic: string, pair: Partial | ((pair: Pair) => Pair)) => void;
+ removePair: (topic: string) => void;
+ hasPair: (topic: string) => boolean;
+
+ get: () => Pair[];
+
+ getPairBySession: (sessionTopic: string) => Pair | undefined;
+ removePairBySession: (sessionTopic: string) => void;
+
+ removeSessionFromPair: (sessionTopic: string) => void;
+};
+
+export default function useWalletConnectPairs(): Pairs {
+ const localStorageData = useLocalStorage('walletConnectPairs', []);
+ const [currentPairs] = localStorageData;
+
+ const pairsRef = useRef<[Pair[], (pairs: Pair[] | PairCallback) => void]>(localStorageData);
+ pairsRef.current = localStorageData;
+
+ const updatePair = useCallback((topic: string, data: Partial> | ((pair: Pair) => Pair)) => {
+ const [latestPairs, setPairs] = pairsRef.current;
+
+ const index = latestPairs.findIndex((item) => item.topic === topic);
+ if (index !== -1) {
+ const oldPair = latestPairs[index];
+ const newPairing = typeof data === 'function' ? data(oldPair) : { ...oldPair, ...data };
+ const newPairings = [...latestPairs];
+ newPairings[index] = newPairing;
+ pairsRef.current = [newPairings, setPairs];
+ }
+
+ setPairs((pairs: Pair[]) => {
+ const idx = pairs.findIndex((item) => item.topic === topic);
+ if (idx === -1) {
+ return pairs;
+ }
+
+ const oldPair = pairs[idx];
+ const newPairing = typeof data === 'function' ? data(oldPair) : { ...oldPair, ...data };
+ const newPairings = [...pairs];
+ newPairings[idx] = newPairing;
+
+ return newPairings;
+ });
+ }, []);
+
+ const removePair = useCallback((topic: string) => {
+ const [latestPairs, setPairs] = pairsRef.current;
+ if (!latestPairs.some((item) => item.topic === topic)) {
+ return;
+ }
+
+ pairsRef.current = [latestPairs.filter((item) => item.topic !== topic), setPairs];
+
+ setPairs((pairs: Pair[]) => pairs.filter((item) => item.topic !== topic));
+ }, []);
+
+ const removePairBySession = useCallback((sessionTopic: string) => {
+ const [latestPairs, setPairs] = pairsRef.current;
+ pairsRef.current = [
+ latestPairs.filter((item) => !item.sessions.find((session) => session.topic === sessionTopic)),
+ setPairs,
+ ];
+
+ setPairs((pairs: Pair[]) =>
+ pairs.filter((item) => !item.sessions.find((session) => session.topic === sessionTopic)),
+ );
+ }, []);
+
+ const getPair = useCallback((topic: string) => {
+ const [pairs] = pairsRef.current;
+ return pairs.find((item) => item.topic === topic);
+ }, []);
+
+ const hasPair = useCallback((topic: string) => {
+ const [pairs] = pairsRef.current;
+ return !!pairs.find((item) => item.topic === topic);
+ }, []);
+
+ const getPairBySession = useCallback((sessionTopic: string) => {
+ const [pairs] = pairsRef.current;
+ return pairs.find((item) => item.sessions?.find((session) => session.topic === sessionTopic));
+ }, []);
+
+ const addPair = useCallback((pair: Pair) => {
+ const [latestPairs, setPairs] = pairsRef.current;
+ if (latestPairs.findIndex((item) => item.topic === pair.topic) !== -1) {
+ throw new Error('Pair already exists');
+ }
+
+ pairsRef.current = [[...latestPairs, pair], setPairs];
+
+ setPairs((pairs: Pair[]) => {
+ if (pairs.some((item) => item.topic === pair.topic)) {
+ return pairs;
+ }
+ return [...pairs, pair];
+ });
+ }, []);
+
+ const removeSessionFromPair = useCallback((sessionTopic: string) => {
+ const [latestPairs, setPairs] = pairsRef.current;
+ pairsRef.current = [
+ latestPairs.map((pair) => ({
+ ...pair,
+ sessions: pair.sessions.filter((item) => item.topic !== sessionTopic),
+ })),
+ setPairs,
+ ];
+
+ setPairs((pairs: Pair[]) =>
+ pairs.map((pair) => ({
+ ...pair,
+ sessions: pair.sessions.filter((item) => item.topic !== sessionTopic),
+ })),
+ );
+ }, []);
+
+ const get = useCallback(() => pairsRef.current[0], []);
+
+ const pairs = useMemo(
+ () => ({
+ addPair,
+ getPair,
+ updatePair,
+ removePair,
+ hasPair,
+
+ get,
+
+ getPairBySession,
+ removePairBySession,
+
+ removeSessionFromPair,
+ pairs: currentPairs,
+ }),
+ [
+ addPair,
+ getPair,
+ hasPair,
+ updatePair,
+ removePair,
+ get,
+ getPairBySession,
+ removePairBySession,
+ removeSessionFromPair,
+ currentPairs,
+ ],
+ );
+
+ return pairs;
+}
diff --git a/packages/gui/src/index-sandbox.tsx b/packages/gui/src/index-sandbox.tsx
new file mode 100644
index 0000000000..27be7be49e
--- /dev/null
+++ b/packages/gui/src/index-sandbox.tsx
@@ -0,0 +1,12 @@
+import './polyfill';
+import './main.css';
+
+import React from 'react';
+import { createRoot } from 'react-dom/client';
+
+import AppSandbox from './components/app/AppSandbox';
+
+const container = document.querySelector('#root');
+const root = createRoot(container!);
+
+root.render();
diff --git a/packages/gui/src/theme/GuiThemeAssetsProvider.tsx b/packages/gui/src/theme/GuiThemeAssetsProvider.tsx
new file mode 100644
index 0000000000..2b40f22add
--- /dev/null
+++ b/packages/gui/src/theme/GuiThemeAssetsProvider.tsx
@@ -0,0 +1,16 @@
+import { ThemeAssetsProvider, useThemeVariant } from '@chia-network/core';
+import React, { useMemo, type ReactNode } from 'react';
+
+import { GUI_THEME_ASSETS } from './guiThemeAssets';
+
+export type GuiThemeAssetsProviderProps = {
+ children: ReactNode;
+};
+
+export default function GuiThemeAssetsProvider(props: GuiThemeAssetsProviderProps) {
+ const { children } = props;
+ const { themeVariant } = useThemeVariant();
+ const assets = useMemo(() => GUI_THEME_ASSETS[themeVariant], [themeVariant]);
+
+ return {children};
+}
diff --git a/packages/gui/src/theme/guiThemeAssets.ts b/packages/gui/src/theme/guiThemeAssets.ts
new file mode 100644
index 0000000000..873fd2a0c1
--- /dev/null
+++ b/packages/gui/src/theme/guiThemeAssets.ts
@@ -0,0 +1,72 @@
+import type { ThemeAssets, ThemeVariantId } from '@chia-network/core';
+
+import chiaAudioSmall from '../assets/theme/chia/audio-small.svg';
+import chiaChiaBlack from '../assets/theme/chia/chia-black.svg';
+import chiaChia from '../assets/theme/chia/chia.svg';
+import chiaChiaCircle from '../assets/theme/chia/chia_circle.svg';
+import chiaDocumentSmall from '../assets/theme/chia/document-small.svg';
+import chiaModelSmall from '../assets/theme/chia/model-small.svg';
+import chiaOfferFileIcon from '../assets/theme/chia/offerFileIcon.svg';
+import chiaUnknownSmall from '../assets/theme/chia/unknown-small.svg';
+import chiaVideoSmall from '../assets/theme/chia/video-small.svg';
+import chiaWalletConnectToChia from '../assets/theme/chia/walletConnectToChia.svg';
+import classicAudioSmall from '../assets/theme/classic/audio-small.svg';
+import classicChiaBlack from '../assets/theme/classic/chia-black.svg';
+import classicChia from '../assets/theme/classic/chia.svg';
+import classicChiaCircle from '../assets/theme/classic/chia_circle.svg';
+import classicDocumentSmall from '../assets/theme/classic/document-small.svg';
+import classicModelSmall from '../assets/theme/classic/model-small.svg';
+import classicOfferFileIcon from '../assets/theme/classic/offerFileIcon.svg';
+import classicUnknownSmall from '../assets/theme/classic/unknown-small.svg';
+import classicVideoSmall from '../assets/theme/classic/video-small.svg';
+import classicWalletConnectToChia from '../assets/theme/classic/walletConnectToChia.svg';
+import fieldAudioSmall from '../assets/theme/field/audio-small.svg';
+import fieldChiaBlack from '../assets/theme/field/chia-black.svg';
+import fieldChia from '../assets/theme/field/chia.svg';
+import fieldChiaCircle from '../assets/theme/field/chia_circle.svg';
+import fieldDocumentSmall from '../assets/theme/field/document-small.svg';
+import fieldModelSmall from '../assets/theme/field/model-small.svg';
+import fieldOfferFileIcon from '../assets/theme/field/offerFileIcon.svg';
+import fieldUnknownSmall from '../assets/theme/field/unknown-small.svg';
+import fieldVideoSmall from '../assets/theme/field/video-small.svg';
+import fieldWalletConnectToChia from '../assets/theme/field/walletConnectToChia.svg';
+
+/** Static per-variant SVG modules (whitelist only — no runtime loading from user input). */
+export const GUI_THEME_ASSETS: Record = {
+ classic: {
+ chiaCircle: classicChiaCircle,
+ chiaWordmark: classicChia,
+ chiaWordmarkBlack: classicChiaBlack,
+ audioSmall: classicAudioSmall,
+ documentSmall: classicDocumentSmall,
+ modelSmall: classicModelSmall,
+ unknownSmall: classicUnknownSmall,
+ videoSmall: classicVideoSmall,
+ offerFileIcon: classicOfferFileIcon,
+ walletConnectToChia: classicWalletConnectToChia,
+ },
+ field: {
+ chiaCircle: fieldChiaCircle,
+ chiaWordmark: fieldChia,
+ chiaWordmarkBlack: fieldChiaBlack,
+ audioSmall: fieldAudioSmall,
+ documentSmall: fieldDocumentSmall,
+ modelSmall: fieldModelSmall,
+ unknownSmall: fieldUnknownSmall,
+ videoSmall: fieldVideoSmall,
+ offerFileIcon: fieldOfferFileIcon,
+ walletConnectToChia: fieldWalletConnectToChia,
+ },
+ chia: {
+ chiaCircle: chiaChiaCircle,
+ chiaWordmark: chiaChia,
+ chiaWordmarkBlack: chiaChiaBlack,
+ audioSmall: chiaAudioSmall,
+ documentSmall: chiaDocumentSmall,
+ modelSmall: chiaModelSmall,
+ unknownSmall: chiaUnknownSmall,
+ videoSmall: chiaVideoSmall,
+ offerFileIcon: chiaOfferFileIcon,
+ walletConnectToChia: chiaWalletConnectToChia,
+ },
+};
diff --git a/packages/gui/src/theme/themeCircleIcons.ts b/packages/gui/src/theme/themeCircleIcons.ts
new file mode 100644
index 0000000000..a8851602db
--- /dev/null
+++ b/packages/gui/src/theme/themeCircleIcons.ts
@@ -0,0 +1,15 @@
+import chiaChiaCircle from '../assets/theme/chia/chia_circle.svg';
+import classicChiaCircle from '../assets/theme/classic/chia_circle.svg';
+import fieldChiaCircle from '../assets/theme/field/chia_circle.svg';
+
+import { DEFAULT_THEME_VARIANT, parseThemeVariantId, type ThemeVariantId } from './themeVariant';
+
+const THEME_CIRCLE_ICONS: Record = {
+ classic: classicChiaCircle as unknown as string,
+ field: fieldChiaCircle as unknown as string,
+ chia: chiaChiaCircle as unknown as string,
+};
+
+export function resolveThemeCircleIcon(variant: unknown): string {
+ return THEME_CIRCLE_ICONS[parseThemeVariantId(variant, DEFAULT_THEME_VARIANT)];
+}
diff --git a/packages/gui/src/theme/themeVariant.ts b/packages/gui/src/theme/themeVariant.ts
new file mode 100644
index 0000000000..205d47cb3e
--- /dev/null
+++ b/packages/gui/src/theme/themeVariant.ts
@@ -0,0 +1,17 @@
+/**
+ * Electron-safe mirror of `packages/core/src/theme/variantTypes.ts`.
+ * Do not import `@chia-network/core` from code used by `webpack.electron` — it bundles CSS.
+ */
+export const THEME_VARIANT_IDS = ['classic', 'field', 'chia'] as const;
+
+export type ThemeVariantId = (typeof THEME_VARIANT_IDS)[number];
+
+export const DEFAULT_THEME_VARIANT: ThemeVariantId = 'chia';
+
+function isThemeVariantId(value: unknown): value is ThemeVariantId {
+ return typeof value === 'string' && (THEME_VARIANT_IDS as readonly string[]).includes(value);
+}
+
+export function parseThemeVariantId(value: unknown, fallback: ThemeVariantId = DEFAULT_THEME_VARIANT): ThemeVariantId {
+ return isThemeVariantId(value) ? value : fallback;
+}
diff --git a/packages/gui/src/util/walletConnect.ts b/packages/gui/src/util/walletConnect.ts
new file mode 100644
index 0000000000..ce1a35f15d
--- /dev/null
+++ b/packages/gui/src/util/walletConnect.ts
@@ -0,0 +1,506 @@
+import Client from '@walletconnect/sign-client';
+import { getSdkError } from '@walletconnect/utils';
+import initDebug from 'debug';
+
+import { WcError, WcErrorCode, decodeWcErrorFromIpc } from '../@types/WcError';
+import { type Pairs } from '../hooks/useWalletConnectPairs';
+
+const log = initDebug('chia-gui:walletConnect');
+
+async function respondSessionRequestError(
+ client: Client,
+ topic: string,
+ id: number,
+ message: string,
+ code: number,
+ // Forwarded to JSON-RPC `error.data` so dapp clients that canonicalize
+ // `message` by code (many surface "Internal error" for `-32603` and only
+ // expose the original payload through `error.data`) can still recover the
+ // real failure detail.
+ data?: unknown,
+) {
+ try {
+ await client.respond({
+ topic,
+ response: {
+ id,
+ jsonrpc: '2.0',
+ error: { code, message, ...(data !== undefined ? { data } : {}) },
+ },
+ });
+ } catch (e) {
+ // Dapp/SDK may have evicted the request (5-min expiry, disconnect race,
+ // etc.). Swallow so the `session_request` listener doesn't surface an
+ // uncaught rejection as a user-facing popup.
+ log('Failed to respond to session request', { topic, id }, e);
+ }
+}
+
+// IPC strips the WcError prototype; main encodes the code via prefix and we
+// recover it here. Plain Errors (daemon failures, unexpected throws) default
+// to INTERNAL_ERROR — the spec-correct fallback for "wallet failed".
+function toWcError(error: unknown): WcError {
+ if (error instanceof WcError) return error;
+ if (error instanceof Error) {
+ const decoded = decodeWcErrorFromIpc(error.message);
+ if (decoded) return decoded;
+ return new WcError(error.message, WcErrorCode.INTERNAL_ERROR);
+ }
+ return new WcError(String(error), WcErrorCode.INTERNAL_ERROR);
+}
+
+export function processError(error: Error) {
+ if (error.message.includes('No matching key')) {
+ console.info('[chia-gui:walletConnect] Pairing not found (stale key, safe to ignore):', error.message);
+ return;
+ }
+
+ throw error;
+}
+
+export async function processSessionProposal(
+ client: Client,
+ pairs: Pairs,
+ event: {
+ id: number;
+ params: {
+ pairingTopic: string;
+ proposer: {
+ metadata?: {
+ name?: string;
+ description?: string;
+ url?: string;
+ icons?: string[];
+ };
+ };
+ requiredNamespaces?: {
+ chia?: {
+ chains: string[];
+ methods: string[];
+ events?: string[];
+ };
+ };
+ optionalNamespaces?: {
+ chia?: {
+ chains: string[];
+ methods: string[];
+ events?: string[];
+ };
+ };
+ };
+ },
+) {
+ try {
+ if (!client) {
+ throw new Error('Client not initialized');
+ }
+
+ const {
+ id,
+ params: {
+ pairingTopic,
+ proposer: { metadata: proposerMetadata },
+ requiredNamespaces,
+ optionalNamespaces,
+ },
+ } = event;
+
+ if (!pairingTopic) {
+ throw new Error('Pairing topic not found');
+ }
+
+ // SDK v2.17+ moved requiredNamespaces to optionalNamespaces; merge both.
+ const requiredChia = requiredNamespaces?.chia;
+ const optionalChia = optionalNamespaces?.chia;
+
+ if (!requiredChia && !optionalChia) {
+ throw new Error('Missing required chia namespace');
+ }
+
+ const chains = [...new Set([...(requiredChia?.chains ?? []), ...(optionalChia?.chains ?? [])])];
+ const methods = [...new Set([...(requiredChia?.methods ?? []), ...(optionalChia?.methods ?? [])])];
+ const events = [...new Set([...(requiredChia?.events ?? []), ...(optionalChia?.events ?? [])])];
+ const supportedChains = chains.filter((item) => ['chia:testnet', 'chia:mainnet'].includes(item));
+ if (!supportedChains.length) {
+ throw new Error('Chain not supported');
+ }
+
+ const pair = pairs.getPair(pairingTopic);
+ if (!pair) {
+ throw new Error('Pair not found');
+ }
+
+ // Capture the proposal but defer approval — the main-process Pair dialog
+ // owns wallet selection and permission grant; `approveSessionProposal`
+ // runs after the user confirms.
+ pairs.updatePair(pairingTopic, (p) => ({
+ ...p,
+ metadata: proposerMetadata ?? p.metadata,
+ pendingProposal: {
+ id,
+ proposerMetadata,
+ methods,
+ events,
+ chains: supportedChains,
+ },
+ }));
+ } catch (error) {
+ try {
+ log('Session proposal error', error);
+ console.error('WC session proposal REJECTED due to error:', error);
+
+ const { id } = event;
+
+ await client?.reject({
+ id,
+ reason: getSdkError('USER_REJECTED'),
+ });
+ } catch (e) {
+ processError(e as Error);
+ }
+ }
+}
+
+export async function approveSessionProposal(
+ client: Client,
+ pairs: Pairs,
+ pairTopic: string,
+ fingerprints: number[],
+ mainnet: boolean,
+ /** Must be the registry-filtered subset persisted on the pair record. */
+ approvedMethods: string[],
+) {
+ if (!client) {
+ throw new Error('Client not initialized');
+ }
+
+ const pair = pairs.getPair(pairTopic);
+ if (!pair) {
+ throw new Error('Pair not found');
+ }
+
+ const proposal = pair.pendingProposal;
+ if (!proposal) {
+ throw new Error('No pending proposal for this pair');
+ }
+
+ if (!fingerprints.length) {
+ throw new Error('At least one wallet must be selected');
+ }
+
+ const instance = mainnet ? 'mainnet' : 'testnet';
+ const chain = `chia:${instance}`;
+ if (!proposal.chains.includes(chain)) {
+ throw new Error(`Requested chains do not include pair network: ${chain}`);
+ }
+
+ const accounts = fingerprints.map((fingerprint) => `${chain}:${fingerprint}`);
+ const namespaces = {
+ chia: {
+ accounts,
+ methods: approvedMethods,
+ events: proposal.events,
+ },
+ };
+
+ const { acknowledged } = await client.approve({
+ id: proposal.id,
+ namespaces,
+ });
+
+ const result = await acknowledged();
+ if (!('topic' in result) || !result.topic) {
+ return;
+ }
+
+ pairs.updatePair(pairTopic, (p) => ({
+ ...p,
+ fingerprints,
+ pendingProposal: undefined,
+ sessions: [
+ ...p.sessions,
+ {
+ topic: result.topic,
+ metadata: proposal.proposerMetadata,
+ namespaces,
+ },
+ ],
+ }));
+}
+
+export async function rejectSessionProposal(client: Client, pairs: Pairs, pairTopic: string) {
+ if (!client) {
+ throw new Error('Client not initialized');
+ }
+
+ const pair = pairs.getPair(pairTopic);
+ const proposal = pair?.pendingProposal;
+ if (proposal) {
+ try {
+ await client.reject({
+ id: proposal.id,
+ reason: getSdkError('USER_REJECTED'),
+ });
+ } catch (e) {
+ log('Failed to reject session proposal', e);
+ }
+ }
+
+ await disconnectPair(client, pairs, pairTopic);
+}
+
+export async function processSessionDelete(client: Client, pairs: Pairs, event: { id: number; topic: string }) {
+ try {
+ const { topic: session } = event;
+
+ if (session) {
+ pairs.removeSessionFromPair(session);
+ }
+ } catch (error) {
+ // session was deleted we are not sending any response
+ log('Session delete error', error);
+ processError(error as Error);
+ }
+}
+
+export async function processPairingDelete(pairs: Pairs, event: { topic: string }) {
+ const { topic } = event;
+
+ pairs.removePair(topic);
+ await revokeMainPair(topic);
+}
+
+// Best-effort. A stale main record is inert (no session = no dispatch), so
+// failure here doesn't block teardown — log and move on.
+async function revokeMainPair(topic: string): Promise {
+ try {
+ await window.permissionsAPI.revokePair(topic);
+ } catch (e) {
+ log('Failed to revoke main-side pair record', topic, e);
+ }
+}
+
+export async function processSessionRequest(
+ client: Client | undefined,
+ pairs: Pairs,
+ process: (topic: string, command: string, params: any, ctx: { mainnet: boolean }) => Promise,
+ event: {
+ id: number;
+ topic: string;
+ params: {
+ request: { method: string; params: any };
+ chainId: string;
+ };
+ },
+) {
+ try {
+ const {
+ id,
+ topic,
+ params: {
+ request: { method, params },
+ chainId,
+ },
+ } = event;
+ if (!client) {
+ throw new Error('Client not initialized');
+ }
+
+ const pair = pairs.getPairBySession(topic);
+ if (!pair) {
+ const allPairs = pairs.get();
+ const allSessions = allPairs.flatMap((p) => p.sessions?.map((s) => s.topic) ?? []);
+ console.warn(
+ `[WC] Pair not found for session=${topic} method=${method} id=${id}`,
+ `| knownPairs=${allPairs.length} pairTopics=[${allPairs.map((p) => p.topic.slice(0, 8)).join(',')}]`,
+ `| knownSessions=${allSessions.length} sessionTopics=[${allSessions.map((s) => s.slice(0, 8)).join(',')}]`,
+ '— disconnecting orphan session',
+ );
+ try {
+ await respondSessionRequestError(client, topic, id, 'Pair not found', WcErrorCode.USER_REJECTED);
+ } catch (e) {
+ log('Failed to respond to orphan session request:', e);
+ }
+
+ try {
+ await client.disconnect({ topic, reason: getSdkError('USER_DISCONNECTED') });
+ } catch (e) {
+ log('Failed to disconnect orphan session:', e);
+ }
+ return;
+ }
+
+ const [network, instance] = chainId.split(':');
+ if (network !== 'chia') {
+ throw new WcError('Network not supported', WcErrorCode.UNSUPPORTED_CHAINS);
+ }
+ const isMainnet = instance === 'mainnet';
+
+ // All gates (network/fingerprint/commands) live in main; renderer is
+ // not trusted for any of them.
+
+ const { fingerprint, ...rest } = params;
+ const updatedParams = {
+ ...rest,
+ fingerprint: Number.parseInt(fingerprint, 10),
+ };
+
+ log('method', method, updatedParams);
+ // Main keys PairRecord by pair topic, not session topic — translate here.
+ const result = await process(pair.topic, method, updatedParams, { mainnet: isMainnet });
+ log('result', result);
+
+ await client.respond({
+ topic,
+ response: {
+ id,
+ jsonrpc: '2.0',
+ result,
+ },
+ });
+ } catch (error) {
+ try {
+ log('Session request error', error);
+
+ const { id, topic } = event;
+ if (client) {
+ const wc = toWcError(error);
+ await respondSessionRequestError(client, topic, id, wc.message, wc.code, wc.data);
+ }
+ } catch (e) {
+ processError(e as Error);
+ }
+ }
+}
+
+export async function disconnectPair(client: Client, pairs: Pairs, topic: string) {
+ try {
+ const pairings = await client.core.pairing.getPairings();
+ const pairing = pairings.find((p) => p.topic === topic);
+ if (pairing) {
+ const sessions = pairs.getPair(topic)?.sessions ?? [];
+ await Promise.all(
+ sessions.map(async (session) => {
+ try {
+ await client.disconnect({ topic: session.topic, reason: getSdkError('USER_DISCONNECTED') });
+ } catch (e) {
+ log(`Failed to disconnect session ${session.topic}:`, e);
+ }
+ }),
+ );
+
+ try {
+ await client.core.pairing.disconnect({ topic });
+ } catch (e) {
+ log(`Failed to disconnect pairing ${topic}:`, e);
+ }
+ }
+ } catch (e) {
+ log('Error during pair disconnect, removing pair anyway:', e);
+ } finally {
+ pairs.removePair(topic);
+ // Drop the main-side record so disconnected pairs don't leave dormant consents.
+ await revokeMainPair(topic);
+ }
+}
+
+export async function cleanupPairings(client: Client, pairs: Pairs) {
+ try {
+ const pairings = await client.core.pairing.getPairings();
+
+ await Promise.all(
+ pairings.map(async (pairing) => {
+ const { topic, active } = pairing;
+
+ if (!pairs.hasPair(topic)) {
+ log('Disconnecting pairing because WalletConnect pair is not registered in the application', topic);
+ await disconnectPair(client, pairs, topic);
+ return;
+ }
+
+ if (!active) {
+ try {
+ log('Reactivating pairing', topic);
+ await client.core.pairing.activate({ topic });
+ } catch (error) {
+ processError(error as Error);
+ }
+ }
+ }),
+ );
+
+ await Promise.all(
+ pairs.get().map(async (pair) => {
+ const { topic } = pair;
+ const hasPairing = pairings.find((pairing) => pairing.topic === topic);
+
+ if (!hasPairing) {
+ log('Disconnecting pairing because WalletConnect pair is not registered in the pairing list', topic);
+ await disconnectPair(client, pairs, topic);
+ }
+ }),
+ );
+ } catch (e) {
+ log('Cleanup pairings error', e);
+ }
+}
+
+export function bindEvents(
+ client: Client,
+ pairs: Pairs,
+ onProcess: () => (topic: string, command: string, params: any) => Promise,
+) {
+ if (!client) {
+ throw new Error('Client not initialized');
+ }
+
+ async function handleSessionProposal(event: any) {
+ await processSessionProposal(client, pairs, event);
+ }
+
+ async function handleSessionDelete(event: any) {
+ await processSessionDelete(client, pairs, event);
+ }
+
+ async function handleSessionRequest(event: any) {
+ // Catch-all so a stray rejection doesn't surface as an Electron popup.
+ try {
+ await processSessionRequest(client, pairs, onProcess(), event);
+ } catch (e) {
+ log('Unhandled session_request error', e);
+ }
+ }
+
+ async function handlePairingDelete(event: any) {
+ try {
+ await processPairingDelete(pairs, event);
+ } catch (e) {
+ log('Pairing delete error', e);
+ }
+ }
+
+ function cleanUpBindings() {
+ try {
+ client.off('session_proposal', handleSessionProposal);
+ client.off('session_delete', handleSessionDelete);
+ client.off('session_request', handleSessionRequest);
+
+ client.core.pairing.events.off('pairing_delete', handlePairingDelete);
+ } catch (e) {
+ log('Clean up bindings error', e);
+ }
+ }
+
+ try {
+ client.on('session_proposal', handleSessionProposal);
+ client.on('session_delete', handleSessionDelete);
+ client.on('session_request', handleSessionRequest);
+
+ client.core.pairing.events.on('pairing_delete', handlePairingDelete);
+
+ return cleanUpBindings;
+ } catch (e) {
+ log('Bind events error', e);
+ return cleanUpBindings;
+ }
+}
diff --git a/packages/gui/webpack.react.babel.ts b/packages/gui/webpack.react.babel.ts
index 6ad77a6a94..081b610641 100644
--- a/packages/gui/webpack.react.babel.ts
+++ b/packages/gui/webpack.react.babel.ts
@@ -58,7 +58,7 @@ export default {
mode: DEV ? 'development' : 'production',
context: CONTEXT,
devtool: DEV ? 'inline-source-map' : 'source-map',
- entry: path.join(CONTEXT, '/src/index'),
+ entry: path.join(CONTEXT, process.env.GUI_DESIGN_SANDBOX === 'true' ? '/src/index-sandbox' : '/src/index'),
target: 'web',
stats: 'errors-only',
cache: {
@@ -145,6 +145,7 @@ export default {
'process.env.NODE_ENV': JSON.stringify(DEV ? 'development' : 'production'),
'process.env.MULTIPLE_WALLETS': JSON.stringify(process.env.MULTIPLE_WALLETS),
'process.env.LOCAL_TEST': JSON.stringify(process.env.LOCAL_TEST),
+ 'process.env.GUI_DESIGN_SANDBOX': JSON.stringify(process.env.GUI_DESIGN_SANDBOX),
'process.env.BROWSER': true,
IS_BROWSER: true,
}),
diff --git a/packages/icons/src/Chia.tsx b/packages/icons/src/Chia.tsx
index 9c3314c96c..7974a4be9d 100644
--- a/packages/icons/src/Chia.tsx
+++ b/packages/icons/src/Chia.tsx
@@ -4,7 +4,7 @@ import React from 'react';
import ChiaBlackIcon from './images/chia-black.svg';
import ChiaIcon from './images/chia.svg';
-export default function Keys(props: SvgIconProps) {
+export default function Chia(props: SvgIconProps) {
return ;
}
diff --git a/packages/icons/src/Overview.tsx b/packages/icons/src/Overview.tsx
new file mode 100644
index 0000000000..c2a4692422
--- /dev/null
+++ b/packages/icons/src/Overview.tsx
@@ -0,0 +1,8 @@
+import { SvgIcon, SvgIconProps } from '@mui/material';
+import React from 'react';
+
+import OverviewIcon from './images/Overview.svg';
+
+export default function Overview(props: SvgIconProps) {
+ return ;
+}
diff --git a/packages/icons/src/images/Overview.svg b/packages/icons/src/images/Overview.svg
new file mode 100644
index 0000000000..4c0cc25a1d
--- /dev/null
+++ b/packages/icons/src/images/Overview.svg
@@ -0,0 +1,3 @@
+
diff --git a/packages/icons/src/images/chia.svg b/packages/icons/src/images/chia.svg
index 55f18095e7..edf946190e 100644
--- a/packages/icons/src/images/chia.svg
+++ b/packages/icons/src/images/chia.svg
@@ -1,6 +1,6 @@
\ No newline at end of file
+
diff --git a/packages/icons/src/index.ts b/packages/icons/src/index.ts
index 67d26e1c45..729f1d3c17 100644
--- a/packages/icons/src/index.ts
+++ b/packages/icons/src/index.ts
@@ -15,6 +15,7 @@ export { default as MyContacts } from './MyContacts';
export { default as NFTs, NFTsSmall, Reload, Copy } from './NFTs';
export { default as Offering } from './Offering';
export { default as Offers, OffersSmall } from './Offers';
+export { default as Overview } from './Overview';
export { default as Plot } from './Plot';
export { default as Plots } from './Plots';
export { default as Pool } from './Pool';
diff --git a/packages/wallets/src/components/WalletGraphTooltip.tsx b/packages/wallets/src/components/WalletGraphTooltip.tsx
index 4d829d6cfc..0c5070998f 100644
--- a/packages/wallets/src/components/WalletGraphTooltip.tsx
+++ b/packages/wallets/src/components/WalletGraphTooltip.tsx
@@ -1,5 +1,4 @@
-import { Color } from '@chia-network/core';
-import { Box, Paper, Popper, Typography } from '@mui/material';
+import { Box, Paper, Popper, Typography, useTheme } from '@mui/material';
import React, { ReactNode, useRef } from 'react';
export type WalletGraphTooltipProps = {
@@ -14,6 +13,8 @@ export type WalletGraphTooltipProps = {
export default function WalletGraphTooltip(props: WalletGraphTooltipProps) {
const { datum = { tooltip: '' }, x = 0, y = 0, suffix = '', dotSize = 4 } = props;
+ const theme = useTheme();
+ const graphColor = theme.palette.primary.main;
const elementRef = useRef(null);
return (
@@ -21,7 +22,7 @@ export default function WalletGraphTooltip(props: WalletGraphTooltipProps) {
({
position: 'relative',
borderRadius: theme.shape.borderRadius,
- backgroundColor: theme.palette.mode === 'dark' ? Color.Neutral[800] : Color.Neutral[100],
+ backgroundColor: alpha(theme.palette.primary.main, theme.palette.mode === 'dark' ? 0.14 : 0.08),
'&:hover': {
- backgroundColor: theme.palette.mode === 'dark' ? Color.Neutral[700] : Color.Neutral[200],
+ backgroundColor: alpha(theme.palette.primary.main, theme.palette.mode === 'dark' ? 0.22 : 0.12),
},
paddingLeft: theme.spacing(1),
paddingRight: theme.spacing(1),
@@ -62,21 +62,26 @@ const StyledButtonContainer = styled(Box)`
background-color: ${({ theme }) => theme.palette.background.default};
`;
+function panelBorder(theme: { palette: { mode: string; primary: { main: string } } }) {
+ return alpha(theme.palette.primary.main, theme.palette.mode === 'dark' ? 0.22 : 0.18);
+}
+
const StyledMainButton = styled(Button)`
border-radius: ${({ theme }) => `${theme.spacing(2)} ${theme.spacing(2)} 0 0`};
- border: ${({ theme }) => `1px solid ${useColorModeValue(theme, 'border')}`};
- background-color: ${({ theme }) => (theme.palette.mode === 'dark' ? Color.Neutral[700] : Color.Neutral[200])};
+ border: ${({ theme }) => `1px solid ${panelBorder(theme)}`};
+ background-color: ${({ theme }) => alpha(theme.palette.primary.main, theme.palette.mode === 'dark' ? 0.2 : 0.1)};
+ color: ${({ theme }) => theme.palette.text.primary};
height: ${({ theme }) => theme.spacing(6)};
pointer-events: auto;
&:hover {
- background-color: ${({ theme }) => (theme.palette.mode === 'dark' ? Color.Neutral[800] : Color.Neutral[300])};
+ background-color: ${({ theme }) => alpha(theme.palette.primary.main, theme.palette.mode === 'dark' ? 0.3 : 0.16)};
}
`;
const StyledBody = styled(({ expanded, ...rest }) => )`
pointer-events: auto;
- background-color: ${({ theme }) => (theme.palette.mode === 'dark' ? Color.Neutral[700] : Color.Neutral[200])};
+ background-color: ${({ theme }) => alpha(theme.palette.primary.main, theme.palette.mode === 'dark' ? 0.2 : 0.1)};
transition: all 0.25s ease-out;
overflow: hidden;
height: ${({ expanded }) => (expanded ? '100%' : '0%')};
@@ -84,10 +89,13 @@ const StyledBody = styled(({ expanded, ...rest }) => )`
const StyledContent = styled(Box)`
height: 100%;
- background-color: ${({ theme }) => theme.palette.action.hover};
+ background-color: ${({ theme }) =>
+ theme.palette.mode === 'dark'
+ ? alpha(theme.palette.background.paper, 0.96)
+ : alpha(theme.palette.background.paper, 0.98)};
padding-top: ${({ theme }) => theme.spacing(2)};
- border-left: 1px solid ${({ theme }) => (theme.palette.mode === 'dark' ? Color.Neutral[700] : Color.Neutral[300])};
- border-right: 1px solid ${({ theme }) => (theme.palette.mode === 'dark' ? Color.Neutral[700] : Color.Neutral[300])};
+ border-left: 1px solid ${({ theme }) => panelBorder(theme)};
+ border-right: 1px solid ${({ theme }) => panelBorder(theme)};
display: flex;
flex-direction: column;
`;