Merge pull request #6681 from nymtech/feature/wallet-investigation
Nym Wallet: deps updates, clipboard/updater/, icon, polishing...
@@ -0,0 +1,41 @@
|
||||
name: ci-nym-wallet-frontend
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'nym-wallet/**'
|
||||
- '.github/workflows/ci-nym-wallet-frontend.yml'
|
||||
|
||||
jobs:
|
||||
types-lint:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: nym-wallet/.nvmrc
|
||||
cache: yarn
|
||||
cache-dependency-path: yarn.lock
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --network-timeout 100000
|
||||
|
||||
- name: Build TypeScript packages (wallet depends on @nymproject/types, etc.)
|
||||
run: yarn build:types
|
||||
|
||||
- name: Build @nymproject/mui-theme and @nymproject/react (wallet imports subpaths)
|
||||
run: yarn build:packages
|
||||
|
||||
- name: Typecheck nym-wallet
|
||||
run: yarn --cwd nym-wallet tsc
|
||||
|
||||
- name: Lint nym-wallet
|
||||
run: yarn --cwd nym-wallet lint
|
||||
|
||||
- name: Yarn audit (workspace lockfile; informational)
|
||||
run: yarn audit --level critical
|
||||
continue-on-error: true
|
||||
|
||||
- name: Unit tests (nym-wallet)
|
||||
run: yarn --cwd nym-wallet test
|
||||
@@ -41,6 +41,9 @@ jobs:
|
||||
sed -i.bak '1s/^/\[profile.dev\]\ndebug = false\n\n/' Cargo.toml
|
||||
git diff
|
||||
|
||||
- name: Ensure nym-wallet/dist exists for Tauri
|
||||
run: mkdir -p nym-wallet/dist
|
||||
|
||||
- name: Build all binaries
|
||||
uses: actions-rs/cargo@v1
|
||||
with:
|
||||
@@ -71,3 +74,16 @@ jobs:
|
||||
with:
|
||||
command: clippy
|
||||
args: --manifest-path nym-wallet/Cargo.toml --workspace --all-features --all-targets -- -D warnings
|
||||
|
||||
- name: Install cargo-audit
|
||||
uses: actions-rs/cargo@v1
|
||||
with:
|
||||
command: install
|
||||
args: cargo-audit --locked
|
||||
|
||||
- name: Cargo audit (nym-wallet workspace)
|
||||
uses: actions-rs/cargo@v1
|
||||
with:
|
||||
command: audit
|
||||
working-directory: nym-wallet
|
||||
continue-on-error: true
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
name: ci-nym-wallet-storybook
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'nym-wallet/**'
|
||||
- '.github/workflows/ci-nym-wallet-storybook.yml'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: arc-linux-latest-dind
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Install rsync
|
||||
run: sudo apt-get install rsync
|
||||
continue-on-error: true
|
||||
|
||||
- uses: rlespinasse/github-slug-action@v3.x
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Setup yarn
|
||||
run: npm install -g yarn
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
toolchain: ${{ vars.REQUIRED_RUSTC_VERSION }}
|
||||
|
||||
- name: Install wasm-pack
|
||||
run: curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh
|
||||
|
||||
- name: Build dependencies
|
||||
run: yarn && yarn build
|
||||
|
||||
- name: Build storybook
|
||||
run: yarn storybook:build
|
||||
working-directory: ./nym-wallet
|
||||
|
||||
- name: Deploy branch to CI www (storybook)
|
||||
continue-on-error: true
|
||||
uses: easingthemes/ssh-deploy@main
|
||||
env:
|
||||
SSH_PRIVATE_KEY: ${{ secrets.CI_WWW_SSH_PRIVATE_KEY }}
|
||||
ARGS: "-rltgoDzvO --delete"
|
||||
SOURCE: "nym-wallet/storybook-static/"
|
||||
REMOTE_HOST: ${{ secrets.CI_WWW_REMOTE_HOST }}
|
||||
REMOTE_USER: ${{ secrets.CI_WWW_REMOTE_USER }}
|
||||
TARGET: ${{ secrets.CI_WWW_REMOTE_TARGET }}/wallet-${{ env.GITHUB_REF_SLUG }}
|
||||
EXCLUDE: "/dist/, /node_modules/"
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install Dependencies (Linux)
|
||||
run: sudo apt-get update && sudo apt-get install -y libwebkit2gtk-4.0-dev build-essential curl wget libssl-dev libgtk-3-dev squashfs-tools
|
||||
run: sudo apt-get update && sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential curl wget libssl-dev libgtk-3-dev squashfs-tools libsoup-3.0-dev libjavascriptcoregtk-4.1-dev
|
||||
if: matrix.os == 'ubuntu-22.04'
|
||||
|
||||
- name: Install rust toolchain
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
- name: Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 21
|
||||
node-version: 22.13.0
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
- name: Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 21
|
||||
node-version: 22.13.0
|
||||
cache: 'yarn'
|
||||
|
||||
- name: Install Rust toolchain
|
||||
@@ -72,6 +72,41 @@ jobs:
|
||||
find target/release/bundle -type d -name "*appimage*" -o -name "*AppImage*" || echo "No AppImage directories found"
|
||||
find target/release/bundle -name "*.AppImage" -o -name "*.appimage" || echo "No AppImage files found"
|
||||
fi
|
||||
|
||||
- name: Inspect AppImage (hook + bundled graphics libs)
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
APPIMAGE_REL=$(find target/release/bundle -name '*.AppImage' | head -n 1)
|
||||
if [ -z "${APPIMAGE_REL}" ]; then
|
||||
echo "No AppImage under target/release/bundle"
|
||||
exit 1
|
||||
fi
|
||||
APPIMAGE_ABS="${GITHUB_WORKSPACE}/nym-wallet/${APPIMAGE_REL}"
|
||||
chmod +x "${APPIMAGE_ABS}"
|
||||
EXTRACT_DIR=$(mktemp -d)
|
||||
cd "${EXTRACT_DIR}"
|
||||
"${APPIMAGE_ABS}" --appimage-extract
|
||||
# Tauri only stages appimage "files" under /usr/ into the AppDir; paths like /apprun-hooks/ never reach the image.
|
||||
# Wayland + WEBKIT_DISABLE_DMABUF_RENDERER defaults are applied in main() instead (see configure_linux_wayland_defaults).
|
||||
HOOK=$(find squashfs-root -name '99-nym-wayland.sh' 2>/dev/null | head -n 1)
|
||||
if [ -n "${HOOK}" ]; then
|
||||
echo "Found legacy apprun hook at ${HOOK}"
|
||||
else
|
||||
echo "No apprun-hooks/99-nym-wayland.sh (expected): Wayland defaults are set in-process."
|
||||
fi
|
||||
find squashfs-root/usr/lib -maxdepth 6 \
|
||||
\( -name 'libwayland-client.so*' -o -name 'libEGL.so*' -o -name 'libgbm.so*' \) \
|
||||
2>/dev/null | sort > "${GITHUB_WORKSPACE}/nym-wallet/appimage-bundled-graphics-libs.txt"
|
||||
wc -l "${GITHUB_WORKSPACE}/nym-wallet/appimage-bundled-graphics-libs.txt"
|
||||
head -50 "${GITHUB_WORKSPACE}/nym-wallet/appimage-bundled-graphics-libs.txt" || true
|
||||
|
||||
- name: Upload AppImage graphics lib inventory
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: nym-wallet-appimage-lib-inventory
|
||||
path: nym-wallet/appimage-bundled-graphics-libs.txt
|
||||
retention-days: 30
|
||||
|
||||
- name: Create AppImage tarball if needed
|
||||
run: |
|
||||
|
||||
@@ -37,10 +37,16 @@ jobs:
|
||||
- name: Setup MSBuild.exe
|
||||
uses: microsoft/setup-msbuild@v2
|
||||
|
||||
# No cache:yarn here: setup-node needs yarn on PATH to populate the cache, but this runner
|
||||
# only gets yarn from the step below.
|
||||
- name: Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 21
|
||||
node-version: 22.13.0
|
||||
|
||||
- name: Install Yarn (classic)
|
||||
shell: bash
|
||||
run: npm install -g yarn@1.22.22
|
||||
|
||||
- name: Download EV CodeSignTool from ssl.com
|
||||
working-directory: nym-wallet/src-tauri
|
||||
@@ -60,25 +66,37 @@ jobs:
|
||||
working-directory: nym-wallet/src-tauri
|
||||
if: ${{ inputs.sign }}
|
||||
shell: bash
|
||||
env:
|
||||
SSL_SIGN_USER: ${{ secrets.SSL_COM_USERNAME }}
|
||||
SSL_SIGN_PASS: ${{ secrets.SSL_COM_PASSWORD }}
|
||||
SSL_SIGN_CRED: ${{ steps.get_credential_ids.outputs.SSL_COM_CREDENTIAL_ID }}
|
||||
SSL_SIGN_TOTP: ${{ secrets.SSL_COM_TOTP_SECRET }}
|
||||
run: |
|
||||
yq eval --inplace '.bundle.windows +=
|
||||
{
|
||||
"signCommand": {
|
||||
"cmd": "C:\Program Files\Git\bin\bash.EXE",
|
||||
"args": [
|
||||
"/c/actions-runner/_work/nym/nym/nym-wallet/src-tauri/CodeSignTool.sh",
|
||||
"sign",
|
||||
"-username ${{ secrets.SSL_COM_USERNAME }}",
|
||||
"-password ${{ secrets.SSL_COM_PASSWORD }}",
|
||||
"-credential_id ${{ steps.get_credential_ids.outputs.SSL_COM_CREDENTIAL_ID }}",
|
||||
"-totp_secret ${{ secrets.SSL_COM_TOTP_SECRET }}",
|
||||
"-program_name NymWallet",
|
||||
"-input_file_path",
|
||||
"%1",
|
||||
"-override"
|
||||
]
|
||||
set -euo pipefail
|
||||
if ! command -v cygpath >/dev/null 2>&1; then
|
||||
echo "cygpath not found; install Git for Windows or use bash from Git SDK"
|
||||
exit 1
|
||||
fi
|
||||
export SCRIPT_UNIX="$(cygpath -u "$GITHUB_WORKSPACE/nym-wallet/src-tauri/CodeSignTool.sh")"
|
||||
yq eval --inplace '
|
||||
.bundle.windows += {
|
||||
"signCommand": {
|
||||
"cmd": "C:/Program Files/Git/bin/bash.exe",
|
||||
"args": [
|
||||
strenv(SCRIPT_UNIX),
|
||||
"sign",
|
||||
("-username " + strenv(SSL_SIGN_USER)),
|
||||
("-password " + strenv(SSL_SIGN_PASS)),
|
||||
("-credential_id " + strenv(SSL_SIGN_CRED)),
|
||||
("-totp_secret " + strenv(SSL_SIGN_TOTP)),
|
||||
"-program_name NymWallet",
|
||||
"-input_file_path",
|
||||
"%1",
|
||||
"-override"
|
||||
]
|
||||
}
|
||||
}
|
||||
}' tauri.conf.json
|
||||
' tauri.conf.json
|
||||
- name: Install project dependencies
|
||||
shell: bash
|
||||
run: cd .. && yarn --network-timeout 100000
|
||||
|
||||
@@ -27,6 +27,7 @@ v6-topology.json
|
||||
/explorer/public/downloads/mixmining.json
|
||||
/explorer/public/downloads/topology.json
|
||||
/nym-wallet/dist/*
|
||||
/nym-wallet/appimage-bundled-graphics-libs.txt
|
||||
/clients/validator/examples/nym-driver-example/current-contract.txt
|
||||
validator-api/v4.json
|
||||
validator-api/v6.json
|
||||
|
||||
@@ -6273,7 +6273,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "nym-credential-proxy"
|
||||
version = "0.3.0"
|
||||
version = "0.3.2-rc"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.7.9",
|
||||
|
||||
@@ -49,7 +49,17 @@ pre-commit:
|
||||
stage_fixed: true
|
||||
rust-lint:
|
||||
glob: "*.rs"
|
||||
run: cargo fmt
|
||||
run: |
|
||||
if [ -f "${HOME}/.cargo/env" ]; then
|
||||
# shellcheck source=/dev/null
|
||||
. "${HOME}/.cargo/env"
|
||||
fi
|
||||
export PATH="${HOME}/.cargo/bin:${PATH}"
|
||||
if ! command -v cargo >/dev/null 2>&1; then
|
||||
echo "lefthook rust-lint: cargo not found (install Rust or ensure ~/.cargo/bin is on PATH); skipping." >&2
|
||||
exit 0
|
||||
fi
|
||||
cargo fmt --all
|
||||
stage_fixed: true
|
||||
cargo-toml-order:
|
||||
glob: "**/Cargo.toml"
|
||||
|
||||
@@ -4,5 +4,19 @@
|
||||
],
|
||||
"parserOptions": {
|
||||
"project": "./tsconfig.eslint.json"
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["**/*.test.ts", "**/*.test.tsx"],
|
||||
"env": { "jest": true }
|
||||
}
|
||||
],
|
||||
"rules": {
|
||||
"@typescript-eslint/lines-between-class-members": "off",
|
||||
"@typescript-eslint/naming-convention": "off",
|
||||
"@typescript-eslint/no-implied-eval": "off",
|
||||
"@typescript-eslint/no-throw-literal": "off",
|
||||
"@typescript-eslint/no-unused-vars": "off",
|
||||
"@typescript-eslint/quotes": "off"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
18
|
||||
22.13.0
|
||||
@@ -1,60 +0,0 @@
|
||||
/* eslint-disable no-param-reassign */
|
||||
const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin');
|
||||
const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin');
|
||||
|
||||
module.exports = {
|
||||
stories: ['../src/**/*.stories.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'],
|
||||
addons: ['@storybook/addon-links', '@storybook/addon-essentials', '@storybook/addon-interactions'],
|
||||
framework: '@storybook/react',
|
||||
core: {
|
||||
builder: 'webpack5',
|
||||
},
|
||||
typescript: { reactDocgen: false },
|
||||
// webpackFinal: async (config, { configType }) => {
|
||||
// // `configType` has a value of 'DEVELOPMENT' or 'PRODUCTION'
|
||||
// // You can change the configuration based on that.
|
||||
// // 'PRODUCTION' is used when building the static version of storybook.
|
||||
webpackFinal: async (config) => {
|
||||
config.module.rules.forEach((rule) => {
|
||||
// look for SVG import rule and replace
|
||||
// NOTE: the rule before modification is /\.(svg|ico|jpg|jpeg|png|apng|gif|eot|otf|webp|ttf|woff|woff2|cur|ani|pdf)(\?.*)?$/
|
||||
if (rule.test?.toString().includes('svg')) {
|
||||
rule.test = /\.(ico|jpg|jpeg|png|apng|gif|eot|otf|webp|ttf|woff|woff2|cur|ani|pdf)(\?.*)?$/;
|
||||
}
|
||||
});
|
||||
|
||||
// handle asset loading with this
|
||||
config.module.rules.unshift({
|
||||
test: /\.svg(\?.*)?$/i,
|
||||
issuer: /\.[jt]sx?$/,
|
||||
use: ['@svgr/webpack'],
|
||||
});
|
||||
|
||||
config.resolve.extensions = ['.tsx', '.ts', '.js'];
|
||||
config.resolve.plugins = [new TsconfigPathsPlugin()];
|
||||
|
||||
config.plugins.push(
|
||||
new ForkTsCheckerWebpackPlugin({
|
||||
typescript: {
|
||||
mode: 'write-references',
|
||||
diagnosticOptions: {
|
||||
semantic: true,
|
||||
syntactic: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
if (!config.resolve.alias) {
|
||||
config.resolve.alias = {};
|
||||
}
|
||||
|
||||
config.resolve.alias['@tauri-apps/api'] = `${__dirname}/mocks/tauri`;
|
||||
|
||||
// Return the altered config
|
||||
return config;
|
||||
},
|
||||
features: {
|
||||
emotionAlias: false,
|
||||
},
|
||||
};
|
||||
@@ -1,8 +0,0 @@
|
||||
/**
|
||||
* This is a mock for Tauri's API package (@tauri-apps/api/app), to prevent stories from being excluded, because they either use
|
||||
* or import dependencies that use Tauri.
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
getVersion: () => undefined,
|
||||
};
|
||||
@@ -1,8 +0,0 @@
|
||||
/**
|
||||
* This is a mock for Tauri's API package (@tauri-apps/api/app), to prevent stories from being excluded, because they either use
|
||||
* or import dependencies that use Tauri.
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
invoke: () => undefined,
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
/**
|
||||
* This is a mock for Tauri's API package (@tauri-apps/api/app), to prevent stories from being excluded, because they either use
|
||||
* or import dependencies that use Tauri.
|
||||
*/
|
||||
@@ -1,113 +0,0 @@
|
||||
const delegations = [
|
||||
{
|
||||
mix_id: 1234,
|
||||
node_identity: 'FiojKW7oY9WQmLCiYAsCA21tpowZHS6zcUoyYm319p6Z',
|
||||
delegated_on_iso_datetime: new Date(2021, 1, 1).toDateString(),
|
||||
unclaimed_rewards: { amount: '0.05', denom: 'nym' },
|
||||
amount: { amount: '10', denom: 'nym' },
|
||||
owner: '',
|
||||
block_height: BigInt(100),
|
||||
cost_params: {
|
||||
profit_margin_percent: '0.04',
|
||||
interval_operating_cost: {
|
||||
amount: '20',
|
||||
denom: 'nym',
|
||||
},
|
||||
},
|
||||
stake_saturation: '0.2',
|
||||
avg_uptime_percent: 0.5,
|
||||
accumulated_by_delegates: { amount: '0', denom: 'nym' },
|
||||
accumulated_by_operator: { amount: '0', denom: 'nym' },
|
||||
uses_vesting_contract_tokens: false,
|
||||
pending_events: [],
|
||||
mixnode_is_unbonding: false,
|
||||
errors: null,
|
||||
},
|
||||
{
|
||||
mix_id: 5678,
|
||||
node_identity: 'DT8S942S8AQs2zKHS9SVo1GyHmuca3pfL2uLhLksJ3D8',
|
||||
unclaimed_rewards: { amount: '0.1', denom: 'nym' },
|
||||
amount: { amount: '100', denom: 'nym' },
|
||||
delegated_on_iso_datetime: new Date(2021, 1, 2).toDateString(),
|
||||
owner: '',
|
||||
block_height: BigInt(4000),
|
||||
stake_saturation: '0.5',
|
||||
avg_uptime_percent: 0.1,
|
||||
cost_params: {
|
||||
profit_margin_percent: '0.04',
|
||||
interval_operating_cost: {
|
||||
amount: '60',
|
||||
denom: 'nym',
|
||||
},
|
||||
},
|
||||
accumulated_by_delegates: { amount: '0', denom: 'nym' },
|
||||
accumulated_by_operator: { amount: '0', denom: 'nym' },
|
||||
uses_vesting_contract_tokens: true,
|
||||
pending_events: [],
|
||||
mixnode_is_unbonding: false,
|
||||
errors: null,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
/**
|
||||
* This is a mock for Tauri's API package (@tauri-apps/api), to prevent stories from being excluded, because they either use
|
||||
* or import dependencies that use Tauri.
|
||||
*/
|
||||
module.exports = {
|
||||
invoke: (operation, args) => {
|
||||
switch (operation) {
|
||||
case 'get_balance': {
|
||||
return {
|
||||
amount: {
|
||||
amount: '100',
|
||||
denom: 'nymt',
|
||||
},
|
||||
printable_balance: '100 NYMT',
|
||||
};
|
||||
}
|
||||
case 'delegate_to_mixnode': {
|
||||
return {
|
||||
logs_json: '[]',
|
||||
data_json: '{}',
|
||||
transaction_hash: '12345',
|
||||
};
|
||||
}
|
||||
case 'simulate_send': {
|
||||
return {
|
||||
amount: {
|
||||
amount: '0.01',
|
||||
denom: 'nym',
|
||||
},
|
||||
};
|
||||
}
|
||||
case 'get_delegation_summary': {
|
||||
return {
|
||||
delegations,
|
||||
total_delegations: {
|
||||
amount: '1000',
|
||||
denom: 'nymt',
|
||||
},
|
||||
total_rewards: {
|
||||
amount: '42',
|
||||
denom: 'nymt',
|
||||
},
|
||||
};
|
||||
}
|
||||
case 'get_pending_delegation_events' : {
|
||||
return [];
|
||||
}
|
||||
case 'migrate_vested_delegations': {
|
||||
delegations[1].uses_vesting_contract_tokens = false;
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
console.error(
|
||||
`Tauri cannot be used in Storybook. The operation requested was "${operation}". You can add mock responses to "nym_wallet/.storybook/mocks/tauri.js" if you need. The default response is "void".`,
|
||||
);
|
||||
return new Promise((resolve, reject) => {
|
||||
reject(new Error(`Tauri operation ${operation} not available in storybook.`));
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -1,10 +0,0 @@
|
||||
/**
|
||||
* This is a mock for Tauri's API package (@tauri-apps/api/window), to prevent stories from being excluded, because they either use
|
||||
* or import dependencies that use Tauri.
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
appWindow: {
|
||||
maximize: () => undefined,
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import { NymWalletThemeWithMode } from '../src/theme/NymWalletTheme';
|
||||
import { Box } from '@mui/material';
|
||||
|
||||
export const parameters = {
|
||||
actions: { argTypesRegex: '^on[A-Z].*' },
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const withThemeProvider = (Story, context) => (
|
||||
<div style={{ display: 'grid', height: '100%', gridTemplateColumns: '50% 50%' }}>
|
||||
<div>
|
||||
<NymWalletThemeWithMode mode="light">
|
||||
<Box
|
||||
p={4}
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateRows: '80vh 2rem',
|
||||
background: (theme) => theme.palette.background.default,
|
||||
color: 'text.primary',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ overflowY: 'auto' }}>
|
||||
<Story {...context} />
|
||||
</Box>
|
||||
<h4 style={{ textAlign: 'center' }}>Light mode</h4>
|
||||
</Box>
|
||||
</NymWalletThemeWithMode>
|
||||
</div>
|
||||
<div>
|
||||
<NymWalletThemeWithMode mode="dark">
|
||||
<Box
|
||||
p={4}
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateRows: '80vh 2rem',
|
||||
background: (theme) => theme.palette.background.default,
|
||||
color: 'text.primary',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ overflowY: 'auto' }}>
|
||||
<Story {...context} />
|
||||
</Box>
|
||||
<h4 style={{ textAlign: 'center' }}>Dark mode</h4>
|
||||
</Box>
|
||||
</NymWalletThemeWithMode>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const decorators = [withThemeProvider];
|
||||
@@ -1,21 +0,0 @@
|
||||
import { Theme } from '@mui/material/styles';
|
||||
|
||||
export const backDropStyles = (theme: Theme) => {
|
||||
const { mode } = theme.palette;
|
||||
return {
|
||||
style: {
|
||||
left: mode === 'light' ? '0' : '50%',
|
||||
width: '50%',
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const modalStyles = (theme: Theme) => {
|
||||
const { mode } = theme.palette;
|
||||
return { left: mode === 'light' ? '25%' : '75%' };
|
||||
};
|
||||
|
||||
export const dialogStyles = (theme: Theme) => {
|
||||
const { mode } = theme.palette;
|
||||
return { left: mode === 'light' ? '-50%' : '50%' };
|
||||
};
|
||||
@@ -15,6 +15,22 @@ The Nym desktop wallet enables you to use the Nym network and take advantage of
|
||||
- `NodeJS >= v16.8.0`
|
||||
- `Rust & cargo >= v1.56`
|
||||
|
||||
## Linux: WebKit and EGL troubleshooting
|
||||
|
||||
Some rolling distributions (for example Arch-based) or Wayland compositors can hit WebKitGTK / EGL errors at startup (for example `EGL_BAD_PARAMETER`, `EGL_BAD_ALLOC`, or `Could not create default EGL display`).
|
||||
|
||||
**Wayland (all Linux bundles, including AppImage):** On startup, when `WAYLAND_DISPLAY` is set, the wallet sets `GDK_BACKEND`, `GDK_SCALE`, `GDK_DPI_SCALE`, and defaults `WEBKIT_DISABLE_DMABUF_RENDERER=1` before the webview initializes. Override if needed: `WEBKIT_DISABLE_DMABUF_RENDERER=0`, or set your own `GDK_*` / `LD_PRELOAD` before launching. (Tauri’s AppImage bundler only copies paths under `/usr/` into the image, so an `apprun-hooks/` file in config would not be included; defaults live in `main()` instead.)
|
||||
|
||||
**`.deb`, installed binary, or `target/release` binary:** You can also set the same variables in a wrapper script or `.desktop` file, for example:
|
||||
|
||||
`Exec=env WEBKIT_DISABLE_DMABUF_RENDERER=1 GDK_BACKEND=wayland GDK_SCALE=1 GDK_DPI_SCALE=0.8 /path/to/NymWallet`
|
||||
|
||||
If problems persist on Wayland, try preloading the system client library (path may vary by distro):
|
||||
|
||||
`LD_PRELOAD=/usr/lib/libwayland-client.so` (or `/usr/lib64/...`).
|
||||
|
||||
**Diagnostic (slow):** `LIBGL_ALWAYS_SOFTWARE=1` forces software GL to confirm a GPU / EGL stack mismatch.
|
||||
|
||||
## Installation prerequisites - Windows
|
||||
|
||||
- When running on Windows you will need to install c++ build tools
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
/** @type {import('jest').Config} */
|
||||
module.exports = {
|
||||
testEnvironment: 'node',
|
||||
roots: ['<rootDir>/src'],
|
||||
testMatch: ['**/*.test.ts'],
|
||||
transform: {
|
||||
'^.+\\.tsx?$': [
|
||||
'ts-jest',
|
||||
{
|
||||
tsconfig: {
|
||||
esModuleInterop: true,
|
||||
module: 'commonjs',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
moduleNameMapper: {
|
||||
'^src/(.*)$': '<rootDir>/src/$1',
|
||||
},
|
||||
};
|
||||
@@ -5,18 +5,21 @@
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"build": "run-s webpack:prod tauri:build",
|
||||
"build:no-sign": "run-s webpack:prod tauri:build:no-sign",
|
||||
"build-macx86": "run-s webpack:prod tauri:buildx86",
|
||||
"dev": "run-p tauri:dev webpack:dev",
|
||||
"lint": "eslint src",
|
||||
"lint:fix": "eslint src --fix",
|
||||
"prebuild": "yarn --cwd .. build",
|
||||
"prestorybook": "yarn --cwd .. build",
|
||||
"prewebpack:dev": "yarn --cwd .. build",
|
||||
"storybook": "start-storybook -p 6006",
|
||||
"storybook:build": "build-storybook",
|
||||
"prewebpack:prod": "yarn check:singletons",
|
||||
"check:singletons": "node scripts/check-mui-singletons.js",
|
||||
"tauri:build": "yarn tauri build",
|
||||
"tauri:build:no-sign": "yarn tauri build --no-sign -b app",
|
||||
"tauri:build:adhoc": "APPLE_SIGNING_IDENTITY=- yarn tauri build -b app",
|
||||
"tauri:dev": "yarn tauri dev",
|
||||
"tauri:buildx86": "yarn tauri build --target x86_64-apple-darwin",
|
||||
"test": "jest --config jest.config.cjs",
|
||||
"tsc": "tsc --noEmit true",
|
||||
"tsc:watch": "tsc --noEmit true --watch",
|
||||
"webpack:dev": "yarn webpack serve --config webpack.dev.js",
|
||||
@@ -26,24 +29,24 @@
|
||||
"@babel/helper-simple-access": "^7.25.9",
|
||||
"@emotion/react": "^11.7.0",
|
||||
"@emotion/styled": "^11.6.0",
|
||||
"@emotion/use-insertion-effect-with-fallbacks": "^1.2.0",
|
||||
"@hookform/resolvers": "^2.8.0",
|
||||
"@mui/icons-material": "^5.2.0",
|
||||
"@mui/material": "^5.2.2",
|
||||
"@mui/styles": "^5.2.2",
|
||||
"@mui/styles": "^5.18.0",
|
||||
"@mui/utils": "^5.7.0",
|
||||
"@nymproject/mui-theme": "^1.0.0",
|
||||
"@nymproject/node-tester": "^1.3.1",
|
||||
"@nymproject/react": "^1.0.0",
|
||||
"@nymproject/types": "^1.0.0",
|
||||
"@storybook/react": "^6.5.15",
|
||||
"@tauri-apps/api": "^2.4.0",
|
||||
"@tauri-apps/plugin-clipboard-manager": "^2.2.2",
|
||||
"@tauri-apps/plugin-opener": "^2.2.6",
|
||||
"@tauri-apps/plugin-shell": "^2.2.1",
|
||||
"@tauri-apps/plugin-updater": "^2.0.0",
|
||||
"@tauri-apps/api": "^2.10.1",
|
||||
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
|
||||
"@tauri-apps/plugin-opener": "^2.5.3",
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||
"@tauri-apps/tauri-forage": "^1.0.0-beta.2",
|
||||
"big.js": "^6.2.1",
|
||||
"bs58": "^4.0.1",
|
||||
"@emotion/cache": "^11.14.0",
|
||||
"clsx": "^1.1.1",
|
||||
"date-fns": "^2.28.0",
|
||||
"joi": "^17.11.0",
|
||||
@@ -51,8 +54,8 @@
|
||||
"notistack": "^2.0.3",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"qrcode.react": "^1.0.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-error-boundary": "^3.1.3",
|
||||
"react-hook-form": "^7.14.2",
|
||||
"react-router-dom": "6",
|
||||
@@ -72,31 +75,31 @@
|
||||
"@babel/preset-typescript": "^7.15.0",
|
||||
"@nymproject/eslint-config-react-typescript": "^1.0.0",
|
||||
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.4",
|
||||
"@storybook/react": "^6.5.15",
|
||||
"@svgr/webpack": "^6.1.1",
|
||||
"@tauri-apps/cli": "^2.4.0",
|
||||
"@testing-library/jest-dom": "^5.14.1",
|
||||
"@testing-library/react": "^12.0.0",
|
||||
"@tauri-apps/cli": "^2.10.1",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/big.js": "^6.1.6",
|
||||
"@types/bs58": "^4.0.1",
|
||||
"@types/jest": "^27.0.1",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/minimatch": "5.1.2",
|
||||
"@types/node": "^16.7.13",
|
||||
"@types/node": "^22.15.29",
|
||||
"@types/qrcode.react": "^1.0.2",
|
||||
"@types/react": "^18.0.26",
|
||||
"@types/react-dom": "^18.0.10",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/semver": "^7.3.8",
|
||||
"@types/uuid": "^8.3.4",
|
||||
"@types/zxcvbn": "^4.4.1",
|
||||
"@typescript-eslint/eslint-plugin": "^5.13.0",
|
||||
"@typescript-eslint/parser": "^5.13.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.56.1",
|
||||
"@typescript-eslint/parser": "^8.56.1",
|
||||
"babel-loader": "^8.3.0",
|
||||
"babel-plugin-root-import": "^6.6.0",
|
||||
"clean-webpack-plugin": "^4.0.0",
|
||||
"css-loader": "^6.7.3",
|
||||
"css-minimizer-webpack-plugin": "^3.0.2",
|
||||
"dotenv-webpack": "^7.0.3",
|
||||
"eslint": "^9.26.0",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-config-airbnb": "^19.0.4",
|
||||
"eslint-config-airbnb-typescript": "^16.1.0",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
@@ -107,13 +110,12 @@
|
||||
"eslint-plugin-prettier": "^4.0.0",
|
||||
"eslint-plugin-react": "^7.29.2",
|
||||
"eslint-plugin-react-hooks": "^4.3.0",
|
||||
"eslint-plugin-storybook": "^0.5.12",
|
||||
"favicons": "^7.0.2",
|
||||
"favicons-webpack-plugin": "^5.0.2",
|
||||
"file-loader": "^6.2.0",
|
||||
"fork-ts-checker-webpack-plugin": "^7.2.1",
|
||||
"html-webpack-plugin": "^5.3.2",
|
||||
"jest": "^27.1.0",
|
||||
"jest": "^30.3.0",
|
||||
"mini-css-extract-plugin": "^2.2.2",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"prettier": "^2.8.7",
|
||||
@@ -121,10 +123,10 @@
|
||||
"react-refresh-typescript": "^2.0.2",
|
||||
"style-loader": "^3.3.1",
|
||||
"thread-loader": "^3.0.4",
|
||||
"ts-jest": "^27.0.5",
|
||||
"ts-jest": "^29.4.9",
|
||||
"ts-loader": "^9.4.2",
|
||||
"tsconfig-paths-webpack-plugin": "^3.5.2",
|
||||
"typescript": "^4.6.2",
|
||||
"typescript": "^5.9.3",
|
||||
"url-loader": "^4.1.1",
|
||||
"webpack": "^5.75.0",
|
||||
"webpack-cli": "^4.8.0",
|
||||
|
||||
@@ -4,6 +4,25 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Nym Wallet</title>
|
||||
<style>
|
||||
/* Match dark theme `background.default` (#242B2D) before React/MUI CssBaseline runs. */
|
||||
html {
|
||||
color-scheme: dark;
|
||||
}
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
background-color: #242b2d;
|
||||
}
|
||||
#root {
|
||||
height: 100%;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
background-color: #242b2d;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -4,6 +4,16 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Nym Wallet Logs</title>
|
||||
<style>
|
||||
html {
|
||||
color-scheme: dark;
|
||||
}
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
background-color: #242b2d;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root-log"></div>
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/* eslint-disable no-console -- build-time diagnostic */
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const walletRoot = path.resolve(__dirname, '..');
|
||||
|
||||
const PKGS = [
|
||||
'@emotion/react',
|
||||
'@emotion/styled',
|
||||
'@emotion/cache',
|
||||
'@mui/material',
|
||||
'@mui/system',
|
||||
'@mui/styled-engine',
|
||||
'@mui/private-theming',
|
||||
'@mui/utils',
|
||||
'@mui/lab',
|
||||
'@mui/icons-material',
|
||||
'react',
|
||||
'react-dom',
|
||||
];
|
||||
|
||||
const probes = [
|
||||
walletRoot,
|
||||
path.join(walletRoot, 'node_modules', '@nymproject', 'react'),
|
||||
path.join(walletRoot, 'node_modules', '@mui', 'material'),
|
||||
];
|
||||
|
||||
let bad = 0;
|
||||
PKGS.forEach((pkg) => {
|
||||
const seen = new Set();
|
||||
probes.forEach((probe) => {
|
||||
try {
|
||||
const p = require.resolve(`${pkg}/package.json`, { paths: [probe] });
|
||||
seen.add(fs.realpathSync(p));
|
||||
} catch {
|
||||
/* probe may not resolve this package */
|
||||
}
|
||||
});
|
||||
if (seen.size > 1) {
|
||||
bad += 1;
|
||||
console.error(`[singleton] DUPLICATE ${pkg}:\n ${[...seen].join('\n ')}`);
|
||||
}
|
||||
});
|
||||
|
||||
process.exit(bad ? 1 : 0);
|
||||
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regenerate macOS/Windows icon bundles from the 1024x1024 master in src-tauri/icons/.
|
||||
# Master file: app-icon-source.png (padded per Apple-style safe zone). Edit that asset, then run:
|
||||
# ./scripts/regenerate-tauri-icons.sh
|
||||
# Requires: python3 with Pillow (`pip install pillow`) for tray_icon.png resize.
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
SRC="$ROOT/src-tauri/icons/app-icon-source.png"
|
||||
yarn --cwd "$ROOT" tauri icon "$SRC" -o "$ROOT/src-tauri/icons"
|
||||
rm -rf "$ROOT/src-tauri/icons/android" "$ROOT/src-tauri/icons/ios"
|
||||
rm -f "$ROOT/src-tauri/icons"/Square*.png "$ROOT/src-tauri/icons/StoreLogo.png"
|
||||
python3 - <<PY
|
||||
from PIL import Image
|
||||
from pathlib import Path
|
||||
icons = Path("$ROOT/src-tauri/icons")
|
||||
src = Image.open(icons / "app-icon-source.png").convert("RGBA")
|
||||
src.resize((128, 128), Image.Resampling.LANCZOS).save(icons / "tray_icon.png")
|
||||
PY
|
||||
@@ -1,2 +0,0 @@
|
||||
[capabilities]
|
||||
shell = { open = true }
|
||||
@@ -14,15 +14,14 @@ default-run = "NymWallet"
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2.1.1", features = [] }
|
||||
tauri-build = { version = "2.5.6", features = [] }
|
||||
|
||||
[dependencies]
|
||||
async-trait = "0.1.68"
|
||||
tauri-plugin-updater = "2.7.0"
|
||||
tauri-plugin-clipboard-manager = "2.0.0"
|
||||
tauri-plugin-shell = "2.2.1"
|
||||
tauri-plugin-process = "2.2.1"
|
||||
tauri-plugin-opener = "2.2.6"
|
||||
tauri-plugin-updater = "2.10.1"
|
||||
tauri-plugin-clipboard-manager = "2.3.2"
|
||||
tauri-plugin-process = "2.3.1"
|
||||
tauri-plugin-opener = "2.5.3"
|
||||
bip39 = { version = "2.0.0", features = ["zeroize", "rand"] }
|
||||
cfg-if = "1.0.0"
|
||||
colored = "2.0"
|
||||
@@ -34,7 +33,6 @@ futures = "0.3.15"
|
||||
itertools = "0.10"
|
||||
log = { version = "0.4", features = ["serde"] }
|
||||
once_cell = "1.7.2"
|
||||
open = "5.3.2"
|
||||
pretty_env_logger = "0.4"
|
||||
reqwest = { version = "0.12.4", features = ["json"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
@@ -42,7 +40,7 @@ serde_json = "1.0"
|
||||
serde_repr = "0.1"
|
||||
strum = { version = "0.27.2", features = ["derive"] }
|
||||
tap = "1"
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri = { version = "2.10.3", features = ["devtools"] }
|
||||
#tendermint-rpc = "0.23.0"
|
||||
time = { version = "0.3.30", features = ["local-offset"] }
|
||||
thiserror = "1.0"
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"opener:allow-open-url",
|
||||
"opener:allow-default-urls",
|
||||
"core:window:allow-set-title",
|
||||
"core:window:allow-maximize",
|
||||
"core:app:allow-version",
|
||||
"clipboard-manager:allow-read-text",
|
||||
"clipboard-manager:allow-write-text",
|
||||
@@ -33,6 +34,6 @@
|
||||
"updater:allow-download-and-install",
|
||||
"updater:allow-install",
|
||||
"core:event:allow-listen",
|
||||
"shell:allow-open"
|
||||
"process:default"
|
||||
]
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
{"main-capability":{"identifier":"main-capability","description":"Default capability for Nym Wallet main window","local":true,"windows":["main","nymWalletApp","log"],"permissions":["core:default","core:path:default","core:event:default","core:window:default","core:app:default","core:resources:default","core:menu:default","core:tray:default","opener:allow-open-url","opener:allow-default-urls","core:window:allow-set-title","core:app:allow-version","clipboard-manager:allow-read-text","clipboard-manager:allow-write-text","updater:default","updater:allow-check","updater:allow-download","updater:allow-download-and-install","updater:allow-install","core:event:allow-listen","shell:allow-open"],"platforms":["linux","macOS","windows"]}}
|
||||
{"main-capability":{"identifier":"main-capability","description":"Default capability for Nym Wallet main window","local":true,"windows":["main","nymWalletApp","log"],"permissions":["core:default","core:path:default","core:event:default","core:window:default","core:app:default","core:resources:default","core:menu:default","core:tray:default","opener:allow-open-url","opener:allow-default-urls","core:window:allow-set-title","core:window:allow-maximize","core:app:allow-version","clipboard-manager:allow-read-text","clipboard-manager:allow-write-text","updater:default","updater:allow-check","updater:allow-download","updater:allow-download-and-install","updater:allow-install","core:event:allow-listen","process:default"],"platforms":["linux","macOS","windows"]}}
|
||||
@@ -37,7 +37,7 @@
|
||||
],
|
||||
"definitions": {
|
||||
"Capability": {
|
||||
"description": "A grouping and boundary mechanism developers can use to isolate access to the IPC layer.\n\nIt controls application windows' and webviews' fine grained access to the Tauri core, application, or plugin commands. If a webview or its window is not matching any capability then it has no access to the IPC layer at all.\n\nThis can be done to create groups of windows, based on their required system access, which can reduce impact of frontend vulnerabilities in less privileged windows. Windows can be added to a capability by exact name (e.g. `main-window`) or glob patterns like `*` or `admin-*`. A Window can have none, one, or multiple associated capabilities.\n\n## Example\n\n```json { \"identifier\": \"main-user-files-write\", \"description\": \"This capability allows the `main` window on macOS and Windows access to `filesystem` write related commands and `dialog` commands to enable programatic access to files selected by the user.\", \"windows\": [ \"main\" ], \"permissions\": [ \"core:default\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] }, ], \"platforms\": [\"macOS\",\"windows\"] } ```",
|
||||
"description": "A grouping and boundary mechanism developers can use to isolate access to the IPC layer.\n\nIt controls application windows' and webviews' fine grained access to the Tauri core, application, or plugin commands. If a webview or its window is not matching any capability then it has no access to the IPC layer at all.\n\nThis can be done to create groups of windows, based on their required system access, which can reduce impact of frontend vulnerabilities in less privileged windows. Windows can be added to a capability by exact name (e.g. `main-window`) or glob patterns like `*` or `admin-*`. A Window can have none, one, or multiple associated capabilities.\n\n## Example\n\n```json { \"identifier\": \"main-user-files-write\", \"description\": \"This capability allows the `main` window on macOS and Windows access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.\", \"windows\": [ \"main\" ], \"permissions\": [ \"core:default\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] }, ], \"platforms\": [\"macOS\",\"windows\"] } ```",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"identifier",
|
||||
@@ -49,7 +49,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"description": "Description of what the capability is intended to allow on associated windows.\n\nIt should contain a description of what the grouped permissions should allow.\n\n## Example\n\nThis capability allows the `main` window access to `filesystem` write related commands and `dialog` commands to enable programatic access to files selected by the user.",
|
||||
"description": "Description of what the capability is intended to allow on associated windows.\n\nIt should contain a description of what the grouped permissions should allow.\n\n## Example\n\nThis capability allows the `main` window access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.",
|
||||
"default": "",
|
||||
"type": "string"
|
||||
},
|
||||
@@ -302,216 +302,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"identifier": {
|
||||
"anyOf": [
|
||||
{
|
||||
"description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`",
|
||||
"type": "string",
|
||||
"const": "shell:default",
|
||||
"markdownDescription": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the execute command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "shell:allow-execute",
|
||||
"markdownDescription": "Enables the execute command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the kill command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "shell:allow-kill",
|
||||
"markdownDescription": "Enables the kill command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the open command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "shell:allow-open",
|
||||
"markdownDescription": "Enables the open command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the spawn command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "shell:allow-spawn",
|
||||
"markdownDescription": "Enables the spawn command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the stdin_write command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "shell:allow-stdin-write",
|
||||
"markdownDescription": "Enables the stdin_write command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the execute command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "shell:deny-execute",
|
||||
"markdownDescription": "Denies the execute command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the kill command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "shell:deny-kill",
|
||||
"markdownDescription": "Denies the kill command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the open command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "shell:deny-open",
|
||||
"markdownDescription": "Denies the open command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the spawn command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "shell:deny-spawn",
|
||||
"markdownDescription": "Denies the spawn command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the stdin_write command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "shell:deny-stdin-write",
|
||||
"markdownDescription": "Denies the stdin_write command without any pre-configured scope."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"then": {
|
||||
"properties": {
|
||||
"allow": {
|
||||
"items": {
|
||||
"title": "ShellScopeEntry",
|
||||
"description": "Shell scope entry.",
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"cmd",
|
||||
"name"
|
||||
],
|
||||
"properties": {
|
||||
"args": {
|
||||
"description": "The allowed arguments for the command execution.",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/ShellScopeEntryAllowedArgs"
|
||||
}
|
||||
]
|
||||
},
|
||||
"cmd": {
|
||||
"description": "The command name. It can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.",
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"name",
|
||||
"sidecar"
|
||||
],
|
||||
"properties": {
|
||||
"args": {
|
||||
"description": "The allowed arguments for the command execution.",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/ShellScopeEntryAllowedArgs"
|
||||
}
|
||||
]
|
||||
},
|
||||
"name": {
|
||||
"description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.",
|
||||
"type": "string"
|
||||
},
|
||||
"sidecar": {
|
||||
"description": "If this command is a sidecar command.",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"deny": {
|
||||
"items": {
|
||||
"title": "ShellScopeEntry",
|
||||
"description": "Shell scope entry.",
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"cmd",
|
||||
"name"
|
||||
],
|
||||
"properties": {
|
||||
"args": {
|
||||
"description": "The allowed arguments for the command execution.",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/ShellScopeEntryAllowedArgs"
|
||||
}
|
||||
]
|
||||
},
|
||||
"cmd": {
|
||||
"description": "The command name. It can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.",
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"name",
|
||||
"sidecar"
|
||||
],
|
||||
"properties": {
|
||||
"args": {
|
||||
"description": "The allowed arguments for the command execution.",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/ShellScopeEntryAllowedArgs"
|
||||
}
|
||||
]
|
||||
},
|
||||
"name": {
|
||||
"description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.",
|
||||
"type": "string"
|
||||
},
|
||||
"sidecar": {
|
||||
"description": "If this command is a sidecar command.",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"identifier": {
|
||||
"description": "Identifier of the permission or permission set.",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/Identifier"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"identifier": {
|
||||
@@ -639,10 +429,10 @@
|
||||
"markdownDescription": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`"
|
||||
},
|
||||
{
|
||||
"description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`",
|
||||
"description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`",
|
||||
"type": "string",
|
||||
"const": "core:app:default",
|
||||
"markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`"
|
||||
"markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the app_hide command without any pre-configured scope.",
|
||||
@@ -656,6 +446,12 @@
|
||||
"const": "core:app:allow-app-show",
|
||||
"markdownDescription": "Enables the app_show command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the bundle_type command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "core:app:allow-bundle-type",
|
||||
"markdownDescription": "Enables the bundle_type command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the default_window_icon command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -680,18 +476,36 @@
|
||||
"const": "core:app:allow-name",
|
||||
"markdownDescription": "Enables the name command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the register_listener command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "core:app:allow-register-listener",
|
||||
"markdownDescription": "Enables the register_listener command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the remove_data_store command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "core:app:allow-remove-data-store",
|
||||
"markdownDescription": "Enables the remove_data_store command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the remove_listener command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "core:app:allow-remove-listener",
|
||||
"markdownDescription": "Enables the remove_listener command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the set_app_theme command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "core:app:allow-set-app-theme",
|
||||
"markdownDescription": "Enables the set_app_theme command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the set_dock_visibility command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "core:app:allow-set-dock-visibility",
|
||||
"markdownDescription": "Enables the set_dock_visibility command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the tauri_version command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -716,6 +530,12 @@
|
||||
"const": "core:app:deny-app-show",
|
||||
"markdownDescription": "Denies the app_show command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the bundle_type command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "core:app:deny-bundle-type",
|
||||
"markdownDescription": "Denies the bundle_type command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the default_window_icon command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -740,18 +560,36 @@
|
||||
"const": "core:app:deny-name",
|
||||
"markdownDescription": "Denies the name command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the register_listener command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "core:app:deny-register-listener",
|
||||
"markdownDescription": "Denies the register_listener command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the remove_data_store command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "core:app:deny-remove-data-store",
|
||||
"markdownDescription": "Denies the remove_data_store command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the remove_listener command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "core:app:deny-remove-listener",
|
||||
"markdownDescription": "Denies the remove_listener command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the set_app_theme command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "core:app:deny-set-app-theme",
|
||||
"markdownDescription": "Denies the set_app_theme command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the set_dock_visibility command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "core:app:deny-set-dock-visibility",
|
||||
"markdownDescription": "Denies the set_dock_visibility command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the tauri_version command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -1460,6 +1298,12 @@
|
||||
"const": "core:webview:allow-reparent",
|
||||
"markdownDescription": "Enables the reparent command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the set_webview_auto_resize command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "core:webview:allow-set-webview-auto-resize",
|
||||
"markdownDescription": "Enables the set_webview_auto_resize command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the set_webview_background_color command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -1562,6 +1406,12 @@
|
||||
"const": "core:webview:deny-reparent",
|
||||
"markdownDescription": "Denies the reparent command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the set_webview_auto_resize command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "core:webview:deny-set-webview-auto-resize",
|
||||
"markdownDescription": "Denies the set_webview_auto_resize command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the set_webview_background_color command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -1910,6 +1760,12 @@
|
||||
"const": "core:window:allow-set-focus",
|
||||
"markdownDescription": "Enables the set_focus command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the set_focusable command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "core:window:allow-set-focusable",
|
||||
"markdownDescription": "Enables the set_focusable command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the set_fullscreen command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -1982,6 +1838,12 @@
|
||||
"const": "core:window:allow-set-shadow",
|
||||
"markdownDescription": "Enables the set_shadow command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the set_simple_fullscreen command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "core:window:allow-set-simple-fullscreen",
|
||||
"markdownDescription": "Enables the set_simple_fullscreen command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the set_size command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -2354,6 +2216,12 @@
|
||||
"const": "core:window:deny-set-focus",
|
||||
"markdownDescription": "Denies the set_focus command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the set_focusable command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "core:window:deny-set-focusable",
|
||||
"markdownDescription": "Denies the set_focusable command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the set_fullscreen command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -2426,6 +2294,12 @@
|
||||
"const": "core:window:deny-set-shadow",
|
||||
"markdownDescription": "Denies the set_shadow command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the set_simple_fullscreen command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "core:window:deny-set-simple-fullscreen",
|
||||
"markdownDescription": "Denies the set_simple_fullscreen command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the set_size command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -2594,72 +2468,6 @@
|
||||
"const": "process:deny-restart",
|
||||
"markdownDescription": "Denies the restart command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`",
|
||||
"type": "string",
|
||||
"const": "shell:default",
|
||||
"markdownDescription": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the execute command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "shell:allow-execute",
|
||||
"markdownDescription": "Enables the execute command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the kill command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "shell:allow-kill",
|
||||
"markdownDescription": "Enables the kill command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the open command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "shell:allow-open",
|
||||
"markdownDescription": "Enables the open command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the spawn command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "shell:allow-spawn",
|
||||
"markdownDescription": "Enables the spawn command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the stdin_write command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "shell:allow-stdin-write",
|
||||
"markdownDescription": "Enables the stdin_write command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the execute command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "shell:deny-execute",
|
||||
"markdownDescription": "Denies the execute command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the kill command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "shell:deny-kill",
|
||||
"markdownDescription": "Denies the kill command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the open command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "shell:deny-open",
|
||||
"markdownDescription": "Denies the open command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the spawn command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "shell:deny-spawn",
|
||||
"markdownDescription": "Denies the spawn command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the stdin_write command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "shell:deny-stdin-write",
|
||||
"markdownDescription": "Denies the stdin_write command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`",
|
||||
"type": "string",
|
||||
@@ -2826,50 +2634,6 @@
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"ShellScopeEntryAllowedArg": {
|
||||
"description": "A command argument allowed to be executed by the webview API.",
|
||||
"anyOf": [
|
||||
{
|
||||
"description": "A non-configurable argument that is passed to the command in the order it was specified.",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "A variable that is set while calling the command from the webview API.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"validator"
|
||||
],
|
||||
"properties": {
|
||||
"raw": {
|
||||
"description": "Marks the validator as a raw regex, meaning the plugin should not make any modification at runtime.\n\nThis means the regex will not match on the entire string by default, which might be exploited if your regex allow unexpected input to be considered valid. When using this option, make sure your regex is correct.",
|
||||
"default": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
"validator": {
|
||||
"description": "[regex] validator to require passed values to conform to an expected input.\n\nThis will require the argument value passed to this variable to match the `validator` regex before it will be executed.\n\nThe regex string is by default surrounded by `^...$` to match the full string. For example the `https?://\\w+` regex would be registered as `^https?://\\w+$`.\n\n[regex]: <https://docs.rs/regex/latest/regex/#syntax>",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"ShellScopeEntryAllowedArgs": {
|
||||
"description": "A set of command arguments allowed to be executed by the webview API.\n\nA value of `true` will allow any arguments to be passed to the command. `false` will disable all arguments. A list of [`ShellScopeEntryAllowedArg`] will set those arguments as the only valid arguments to be passed to the attached command configuration.",
|
||||
"anyOf": [
|
||||
{
|
||||
"description": "Use a simple boolean to allow all or disable all arguments to this command configuration.",
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"description": "A specific set of [`ShellScopeEntryAllowedArg`] that are valid to call for the command configuration.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/ShellScopeEntryAllowedArg"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,7 @@
|
||||
],
|
||||
"definitions": {
|
||||
"Capability": {
|
||||
"description": "A grouping and boundary mechanism developers can use to isolate access to the IPC layer.\n\nIt controls application windows' and webviews' fine grained access to the Tauri core, application, or plugin commands. If a webview or its window is not matching any capability then it has no access to the IPC layer at all.\n\nThis can be done to create groups of windows, based on their required system access, which can reduce impact of frontend vulnerabilities in less privileged windows. Windows can be added to a capability by exact name (e.g. `main-window`) or glob patterns like `*` or `admin-*`. A Window can have none, one, or multiple associated capabilities.\n\n## Example\n\n```json { \"identifier\": \"main-user-files-write\", \"description\": \"This capability allows the `main` window on macOS and Windows access to `filesystem` write related commands and `dialog` commands to enable programatic access to files selected by the user.\", \"windows\": [ \"main\" ], \"permissions\": [ \"core:default\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] }, ], \"platforms\": [\"macOS\",\"windows\"] } ```",
|
||||
"description": "A grouping and boundary mechanism developers can use to isolate access to the IPC layer.\n\nIt controls application windows' and webviews' fine grained access to the Tauri core, application, or plugin commands. If a webview or its window is not matching any capability then it has no access to the IPC layer at all.\n\nThis can be done to create groups of windows, based on their required system access, which can reduce impact of frontend vulnerabilities in less privileged windows. Windows can be added to a capability by exact name (e.g. `main-window`) or glob patterns like `*` or `admin-*`. A Window can have none, one, or multiple associated capabilities.\n\n## Example\n\n```json { \"identifier\": \"main-user-files-write\", \"description\": \"This capability allows the `main` window on macOS and Windows access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.\", \"windows\": [ \"main\" ], \"permissions\": [ \"core:default\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] }, ], \"platforms\": [\"macOS\",\"windows\"] } ```",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"identifier",
|
||||
@@ -49,7 +49,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"description": "Description of what the capability is intended to allow on associated windows.\n\nIt should contain a description of what the grouped permissions should allow.\n\n## Example\n\nThis capability allows the `main` window access to `filesystem` write related commands and `dialog` commands to enable programatic access to files selected by the user.",
|
||||
"description": "Description of what the capability is intended to allow on associated windows.\n\nIt should contain a description of what the grouped permissions should allow.\n\n## Example\n\nThis capability allows the `main` window access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.",
|
||||
"default": "",
|
||||
"type": "string"
|
||||
},
|
||||
@@ -302,216 +302,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"identifier": {
|
||||
"anyOf": [
|
||||
{
|
||||
"description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`",
|
||||
"type": "string",
|
||||
"const": "shell:default",
|
||||
"markdownDescription": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the execute command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "shell:allow-execute",
|
||||
"markdownDescription": "Enables the execute command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the kill command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "shell:allow-kill",
|
||||
"markdownDescription": "Enables the kill command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the open command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "shell:allow-open",
|
||||
"markdownDescription": "Enables the open command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the spawn command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "shell:allow-spawn",
|
||||
"markdownDescription": "Enables the spawn command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the stdin_write command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "shell:allow-stdin-write",
|
||||
"markdownDescription": "Enables the stdin_write command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the execute command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "shell:deny-execute",
|
||||
"markdownDescription": "Denies the execute command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the kill command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "shell:deny-kill",
|
||||
"markdownDescription": "Denies the kill command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the open command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "shell:deny-open",
|
||||
"markdownDescription": "Denies the open command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the spawn command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "shell:deny-spawn",
|
||||
"markdownDescription": "Denies the spawn command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the stdin_write command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "shell:deny-stdin-write",
|
||||
"markdownDescription": "Denies the stdin_write command without any pre-configured scope."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"then": {
|
||||
"properties": {
|
||||
"allow": {
|
||||
"items": {
|
||||
"title": "ShellScopeEntry",
|
||||
"description": "Shell scope entry.",
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"cmd",
|
||||
"name"
|
||||
],
|
||||
"properties": {
|
||||
"args": {
|
||||
"description": "The allowed arguments for the command execution.",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/ShellScopeEntryAllowedArgs"
|
||||
}
|
||||
]
|
||||
},
|
||||
"cmd": {
|
||||
"description": "The command name. It can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.",
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"name",
|
||||
"sidecar"
|
||||
],
|
||||
"properties": {
|
||||
"args": {
|
||||
"description": "The allowed arguments for the command execution.",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/ShellScopeEntryAllowedArgs"
|
||||
}
|
||||
]
|
||||
},
|
||||
"name": {
|
||||
"description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.",
|
||||
"type": "string"
|
||||
},
|
||||
"sidecar": {
|
||||
"description": "If this command is a sidecar command.",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"deny": {
|
||||
"items": {
|
||||
"title": "ShellScopeEntry",
|
||||
"description": "Shell scope entry.",
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"cmd",
|
||||
"name"
|
||||
],
|
||||
"properties": {
|
||||
"args": {
|
||||
"description": "The allowed arguments for the command execution.",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/ShellScopeEntryAllowedArgs"
|
||||
}
|
||||
]
|
||||
},
|
||||
"cmd": {
|
||||
"description": "The command name. It can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.",
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"name",
|
||||
"sidecar"
|
||||
],
|
||||
"properties": {
|
||||
"args": {
|
||||
"description": "The allowed arguments for the command execution.",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/ShellScopeEntryAllowedArgs"
|
||||
}
|
||||
]
|
||||
},
|
||||
"name": {
|
||||
"description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.",
|
||||
"type": "string"
|
||||
},
|
||||
"sidecar": {
|
||||
"description": "If this command is a sidecar command.",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"identifier": {
|
||||
"description": "Identifier of the permission or permission set.",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/Identifier"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"identifier": {
|
||||
@@ -639,10 +429,10 @@
|
||||
"markdownDescription": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`"
|
||||
},
|
||||
{
|
||||
"description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`",
|
||||
"description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`",
|
||||
"type": "string",
|
||||
"const": "core:app:default",
|
||||
"markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`"
|
||||
"markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the app_hide command without any pre-configured scope.",
|
||||
@@ -656,6 +446,12 @@
|
||||
"const": "core:app:allow-app-show",
|
||||
"markdownDescription": "Enables the app_show command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the bundle_type command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "core:app:allow-bundle-type",
|
||||
"markdownDescription": "Enables the bundle_type command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the default_window_icon command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -680,18 +476,36 @@
|
||||
"const": "core:app:allow-name",
|
||||
"markdownDescription": "Enables the name command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the register_listener command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "core:app:allow-register-listener",
|
||||
"markdownDescription": "Enables the register_listener command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the remove_data_store command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "core:app:allow-remove-data-store",
|
||||
"markdownDescription": "Enables the remove_data_store command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the remove_listener command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "core:app:allow-remove-listener",
|
||||
"markdownDescription": "Enables the remove_listener command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the set_app_theme command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "core:app:allow-set-app-theme",
|
||||
"markdownDescription": "Enables the set_app_theme command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the set_dock_visibility command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "core:app:allow-set-dock-visibility",
|
||||
"markdownDescription": "Enables the set_dock_visibility command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the tauri_version command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -716,6 +530,12 @@
|
||||
"const": "core:app:deny-app-show",
|
||||
"markdownDescription": "Denies the app_show command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the bundle_type command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "core:app:deny-bundle-type",
|
||||
"markdownDescription": "Denies the bundle_type command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the default_window_icon command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -740,18 +560,36 @@
|
||||
"const": "core:app:deny-name",
|
||||
"markdownDescription": "Denies the name command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the register_listener command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "core:app:deny-register-listener",
|
||||
"markdownDescription": "Denies the register_listener command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the remove_data_store command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "core:app:deny-remove-data-store",
|
||||
"markdownDescription": "Denies the remove_data_store command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the remove_listener command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "core:app:deny-remove-listener",
|
||||
"markdownDescription": "Denies the remove_listener command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the set_app_theme command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "core:app:deny-set-app-theme",
|
||||
"markdownDescription": "Denies the set_app_theme command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the set_dock_visibility command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "core:app:deny-set-dock-visibility",
|
||||
"markdownDescription": "Denies the set_dock_visibility command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the tauri_version command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -1460,6 +1298,12 @@
|
||||
"const": "core:webview:allow-reparent",
|
||||
"markdownDescription": "Enables the reparent command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the set_webview_auto_resize command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "core:webview:allow-set-webview-auto-resize",
|
||||
"markdownDescription": "Enables the set_webview_auto_resize command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the set_webview_background_color command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -1562,6 +1406,12 @@
|
||||
"const": "core:webview:deny-reparent",
|
||||
"markdownDescription": "Denies the reparent command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the set_webview_auto_resize command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "core:webview:deny-set-webview-auto-resize",
|
||||
"markdownDescription": "Denies the set_webview_auto_resize command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the set_webview_background_color command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -1910,6 +1760,12 @@
|
||||
"const": "core:window:allow-set-focus",
|
||||
"markdownDescription": "Enables the set_focus command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the set_focusable command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "core:window:allow-set-focusable",
|
||||
"markdownDescription": "Enables the set_focusable command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the set_fullscreen command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -1982,6 +1838,12 @@
|
||||
"const": "core:window:allow-set-shadow",
|
||||
"markdownDescription": "Enables the set_shadow command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the set_simple_fullscreen command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "core:window:allow-set-simple-fullscreen",
|
||||
"markdownDescription": "Enables the set_simple_fullscreen command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the set_size command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -2354,6 +2216,12 @@
|
||||
"const": "core:window:deny-set-focus",
|
||||
"markdownDescription": "Denies the set_focus command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the set_focusable command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "core:window:deny-set-focusable",
|
||||
"markdownDescription": "Denies the set_focusable command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the set_fullscreen command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -2426,6 +2294,12 @@
|
||||
"const": "core:window:deny-set-shadow",
|
||||
"markdownDescription": "Denies the set_shadow command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the set_simple_fullscreen command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "core:window:deny-set-simple-fullscreen",
|
||||
"markdownDescription": "Denies the set_simple_fullscreen command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the set_size command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -2594,72 +2468,6 @@
|
||||
"const": "process:deny-restart",
|
||||
"markdownDescription": "Denies the restart command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`",
|
||||
"type": "string",
|
||||
"const": "shell:default",
|
||||
"markdownDescription": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the execute command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "shell:allow-execute",
|
||||
"markdownDescription": "Enables the execute command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the kill command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "shell:allow-kill",
|
||||
"markdownDescription": "Enables the kill command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the open command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "shell:allow-open",
|
||||
"markdownDescription": "Enables the open command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the spawn command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "shell:allow-spawn",
|
||||
"markdownDescription": "Enables the spawn command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the stdin_write command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "shell:allow-stdin-write",
|
||||
"markdownDescription": "Enables the stdin_write command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the execute command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "shell:deny-execute",
|
||||
"markdownDescription": "Denies the execute command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the kill command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "shell:deny-kill",
|
||||
"markdownDescription": "Denies the kill command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the open command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "shell:deny-open",
|
||||
"markdownDescription": "Denies the open command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the spawn command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "shell:deny-spawn",
|
||||
"markdownDescription": "Denies the spawn command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the stdin_write command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "shell:deny-stdin-write",
|
||||
"markdownDescription": "Denies the stdin_write command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`",
|
||||
"type": "string",
|
||||
@@ -2826,50 +2634,6 @@
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"ShellScopeEntryAllowedArg": {
|
||||
"description": "A command argument allowed to be executed by the webview API.",
|
||||
"anyOf": [
|
||||
{
|
||||
"description": "A non-configurable argument that is passed to the command in the order it was specified.",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "A variable that is set while calling the command from the webview API.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"validator"
|
||||
],
|
||||
"properties": {
|
||||
"raw": {
|
||||
"description": "Marks the validator as a raw regex, meaning the plugin should not make any modification at runtime.\n\nThis means the regex will not match on the entire string by default, which might be exploited if your regex allow unexpected input to be considered valid. When using this option, make sure your regex is correct.",
|
||||
"default": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
"validator": {
|
||||
"description": "[regex] validator to require passed values to conform to an expected input.\n\nThis will require the argument value passed to this variable to match the `validator` regex before it will be executed.\n\nThe regex string is by default surrounded by `^...$` to match the full string. For example the `https?://\\w+` regex would be registered as `^https?://\\w+$`.\n\n[regex]: <https://docs.rs/regex/latest/regex/#syntax>",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"ShellScopeEntryAllowedArgs": {
|
||||
"description": "A set of command arguments allowed to be executed by the webview API.\n\nA value of `true` will allow any arguments to be passed to the command. `false` will disable all arguments. A list of [`ShellScopeEntryAllowedArg`] will set those arguments as the only valid arguments to be passed to the attached command configuration.",
|
||||
"anyOf": [
|
||||
{
|
||||
"description": "Use a simple boolean to allow all or disable all arguments to this command configuration.",
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"description": "A specific set of [`ShellScopeEntryAllowedArg`] that are valid to call for the command configuration.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/ShellScopeEntryAllowedArg"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 1008 B After Width: | Height: | Size: 994 B |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 401 KiB After Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 401 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 3.5 KiB |
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env bash
|
||||
# Reference only: Tauri AppImage bundling does not place /apprun-hooks/ into the final image (only /usr/ is staged).
|
||||
# Wayland defaults and WEBKIT_DISABLE_DMABUF_RENDERER are applied in src/main.rs (configure_linux_wayland_defaults).
|
||||
|
||||
if [ -z "${WAYLAND_DISPLAY:-}" ]; then
|
||||
return 0 2>/dev/null || exit 0
|
||||
fi
|
||||
|
||||
if [ -z "${LD_PRELOAD:-}" ]; then
|
||||
for lib_path in \
|
||||
/usr/lib/libwayland-client.so \
|
||||
/usr/lib64/libwayland-client.so \
|
||||
/usr/lib/x86_64-linux-gnu/libwayland-client.so
|
||||
do
|
||||
if [ -f "$lib_path" ]; then
|
||||
export LD_PRELOAD="$lib_path"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
export GDK_BACKEND="${GDK_BACKEND:-wayland}"
|
||||
export GDK_SCALE="${GDK_SCALE:-1}"
|
||||
export GDK_DPI_SCALE="${GDK_DPI_SCALE:-0.8}"
|
||||
|
||||
# Reduces WebKit DMA-BUF / EGL failures on some rolling Mesa + Wayland stacks. Set WEBKIT_DISABLE_DMABUF_RENDERER=0 to opt out.
|
||||
export WEBKIT_DISABLE_DMABUF_RENDERER="${WEBKIT_DISABLE_DMABUF_RENDERER:-1}"
|
||||
@@ -13,10 +13,7 @@ use thiserror::Error;
|
||||
#[derive(Error, Debug)]
|
||||
pub enum BackendError {
|
||||
#[error(transparent)]
|
||||
TypesError {
|
||||
#[from]
|
||||
source: TypesError,
|
||||
},
|
||||
TypesError { source: TypesError },
|
||||
#[error(transparent)]
|
||||
Bip39Error {
|
||||
#[from]
|
||||
@@ -115,6 +112,8 @@ pub enum BackendError {
|
||||
WalletUnexpectedMnemonicAccount,
|
||||
#[error("Failed to derive address from mnemonic")]
|
||||
FailedToDeriveAddress,
|
||||
#[error("Built-in HD derivation path constant failed to parse (internal error)")]
|
||||
InvalidInternalDerivationPath,
|
||||
#[error(transparent)]
|
||||
ValueParseError(#[from] ParseIntError),
|
||||
#[error("The provided coin has an unknown denomination - {0}")]
|
||||
@@ -156,6 +155,10 @@ pub enum BackendError {
|
||||
#[error("there aren't any vesting delegations to migrate")]
|
||||
NoVestingDelegations,
|
||||
|
||||
/// Vesting contract [`nym_vesting_contract_common::VestingContractError::NoAccountForAddress`].
|
||||
#[error("Vesting contract has no account for this address")]
|
||||
VestingContractAccountNotFound,
|
||||
|
||||
#[error("this command has been temporarily disabled")]
|
||||
Disabled,
|
||||
//
|
||||
@@ -172,8 +175,52 @@ impl Serialize for BackendError {
|
||||
}
|
||||
}
|
||||
|
||||
/// Cosmwasm returns vesting [`nym_vesting_contract_common::VestingContractError::NoAccountForAddress`]
|
||||
/// as `VESTING (...): Account does not exist - ...` in the ABCI log.
|
||||
///
|
||||
/// This is **string-based** on RPC/log text: upstream wording changes can break detection or cause
|
||||
/// false positives. Prefer tightening if structured codes become available from the client stack.
|
||||
/// Regression coverage: `vesting_no_account_tests` in this file.
|
||||
fn nyxd_error_is_vesting_contract_no_account(err: &NyxdError) -> bool {
|
||||
fn text_matches_strict(text: &str) -> bool {
|
||||
text.contains("VESTING") && text.contains("Account does not exist")
|
||||
}
|
||||
// Prefer strict match; fall back for ABCI text that omits the `VESTING` prefix. Exclude Nyxd
|
||||
// `NonExistentAccountError` (`... does not exist on the chain`).
|
||||
fn abci_query_vesting_no_account(text: &str) -> bool {
|
||||
text_matches_strict(text)
|
||||
|| (text.contains("Account does not exist")
|
||||
&& !text.contains("does not exist on the chain"))
|
||||
}
|
||||
match err {
|
||||
NyxdError::AbciError {
|
||||
log, pretty_log, ..
|
||||
} => {
|
||||
pretty_log
|
||||
.as_ref()
|
||||
.is_some_and(|s| abci_query_vesting_no_account(s))
|
||||
|| abci_query_vesting_no_account(log)
|
||||
}
|
||||
_ => text_matches_strict(&err.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
impl From<TypesError> for BackendError {
|
||||
fn from(e: TypesError) -> Self {
|
||||
if let TypesError::NyxdError { ref source } = e {
|
||||
if nyxd_error_is_vesting_contract_no_account(source) {
|
||||
return Self::VestingContractAccountNotFound;
|
||||
}
|
||||
}
|
||||
Self::TypesError { source: e }
|
||||
}
|
||||
}
|
||||
|
||||
impl From<NyxdError> for BackendError {
|
||||
fn from(source: NyxdError) -> Self {
|
||||
if nyxd_error_is_vesting_contract_no_account(&source) {
|
||||
return Self::VestingContractAccountNotFound;
|
||||
}
|
||||
match source {
|
||||
NyxdError::AbciError {
|
||||
code: _,
|
||||
@@ -213,3 +260,66 @@ impl From<NymNodeApiClientError> for BackendError {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod vesting_no_account_tests {
|
||||
use super::nyxd_error_is_vesting_contract_no_account;
|
||||
use super::BackendError;
|
||||
use nym_types::error::TypesError;
|
||||
use nym_validator_client::nyxd::error::NyxdError;
|
||||
|
||||
fn abci(log: impl Into<String>, pretty_log: Option<String>) -> NyxdError {
|
||||
NyxdError::AbciError {
|
||||
code: 1,
|
||||
log: log.into(),
|
||||
pretty_log,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_vesting_prefix_in_log() {
|
||||
let e = abci("VESTING (99): Account does not exist - nym1test", None);
|
||||
assert!(nyxd_error_is_vesting_contract_no_account(&e));
|
||||
assert!(matches!(
|
||||
BackendError::from(e),
|
||||
BackendError::VestingContractAccountNotFound
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_match_in_pretty_log() {
|
||||
let e = abci(
|
||||
"raw abci",
|
||||
Some("VESTING: Account does not exist - addr".to_string()),
|
||||
);
|
||||
assert!(nyxd_error_is_vesting_contract_no_account(&e));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_when_vesting_prefix_missing_in_log() {
|
||||
let e = abci("Account does not exist - nym1abc", None);
|
||||
assert!(nyxd_error_is_vesting_contract_no_account(&e));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn excludes_chain_account_not_found_wording() {
|
||||
let e = abci("Account nym1abc does not exist on the chain", None);
|
||||
assert!(!nyxd_error_is_vesting_contract_no_account(&e));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_abci_error_not_matched_loosely() {
|
||||
let e = NyxdError::MalformedGasPrice;
|
||||
assert!(!nyxd_error_is_vesting_contract_no_account(&e));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn types_error_nyxd_wrapper_maps() {
|
||||
let inner = abci("VESTING (1): Account does not exist - x", None);
|
||||
let wrapped = TypesError::NyxdError { source: inner };
|
||||
assert!(matches!(
|
||||
BackendError::from(wrapped),
|
||||
BackendError::VestingContractAccountNotFound
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ use std::str::FromStr;
|
||||
use fern::colors::{Color, ColoredLevelConfig};
|
||||
use serde::Serialize;
|
||||
use serde_repr::{Deserialize_repr, Serialize_repr};
|
||||
use tauri::Emitter;
|
||||
use tauri::{Emitter, Manager};
|
||||
use time::{format_description, OffsetDateTime};
|
||||
|
||||
fn formatted_time() -> String {
|
||||
@@ -24,6 +24,7 @@ fn formatted_time() -> String {
|
||||
}
|
||||
|
||||
pub fn setup_logging(app_handle: tauri::AppHandle) -> Result<(), log::SetLoggerError> {
|
||||
let log_window_app = app_handle.clone();
|
||||
let colors = ColoredLevelConfig::new()
|
||||
.trace(Color::Magenta)
|
||||
.debug(Color::Blue)
|
||||
@@ -61,7 +62,10 @@ pub fn setup_logging(app_handle: tauri::AppHandle) -> Result<(), log::SetLoggerE
|
||||
message: record.args().to_string(),
|
||||
level: record.level().into(),
|
||||
};
|
||||
app_handle.emit("log://log", msg).unwrap();
|
||||
// Tauri 2: target the log webview explicitly so the dedicated window receives events.
|
||||
if let Some(log_win) = log_window_app.get_webview_window("log") {
|
||||
let _ = log_win.emit("log://log", msg);
|
||||
}
|
||||
}));
|
||||
|
||||
base_config
|
||||
|
||||
@@ -4,10 +4,9 @@
|
||||
)]
|
||||
|
||||
use nym_mixnet_contract_common::{Gateway, MixNode};
|
||||
use tauri::menu::{MenuBuilder, MenuItemBuilder, SubmenuBuilder};
|
||||
use tauri::Manager;
|
||||
use tauri_plugin_opener::init as init_opener;
|
||||
use tauri_plugin_shell::init as init_shell;
|
||||
use tauri_plugin_process::init as init_process;
|
||||
use tauri_plugin_updater::Builder as UpdaterBuilder;
|
||||
|
||||
use crate::menu::SHOW_LOG_WINDOW;
|
||||
@@ -30,15 +29,17 @@ mod platform_constants;
|
||||
mod state;
|
||||
mod utils;
|
||||
mod wallet_storage;
|
||||
mod webview_theme;
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
fn main() {
|
||||
dotenvy::dotenv().ok();
|
||||
configure_linux_wayland_defaults();
|
||||
|
||||
let context = tauri::generate_context!();
|
||||
tauri::Builder::default()
|
||||
.plugin(init_shell())
|
||||
.plugin(init_opener())
|
||||
.plugin(init_process())
|
||||
.plugin(UpdaterBuilder::new().build())
|
||||
.plugin(tauri_plugin_clipboard_manager::init())
|
||||
.manage(WalletState::default())
|
||||
@@ -217,25 +218,7 @@ fn main() {
|
||||
app::react::set_react_state,
|
||||
app::react::get_react_state,
|
||||
])
|
||||
.menu(|app| {
|
||||
// Create a menu builder
|
||||
let menu_builder = MenuBuilder::new(app);
|
||||
if ::std::env::var("NYM_WALLET_ENABLE_LOG").is_ok() {
|
||||
let help_text = MenuItemBuilder::with_id(SHOW_LOG_WINDOW, "Show logs")
|
||||
.build(app)
|
||||
.expect("Failed to create menu item");
|
||||
|
||||
let submenu = SubmenuBuilder::new(app, "Help")
|
||||
.items(&[&help_text])
|
||||
.build()
|
||||
.expect("Failed to create help submenu");
|
||||
|
||||
menu_builder.item(&submenu).build()
|
||||
} else {
|
||||
// Build a default menu without the submenu
|
||||
menu_builder.build()
|
||||
}
|
||||
})
|
||||
.menu(menu::build_app_menu)
|
||||
.on_menu_event(|app, event| {
|
||||
if event.id() == SHOW_LOG_WINDOW {
|
||||
let _r = help::log::help_log_toggle_window(app.app_handle().clone());
|
||||
@@ -245,3 +228,32 @@ fn main() {
|
||||
.run(context)
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
|
||||
/// Sets GTK/Wayland-related env vars before GTK or the webview initializes.
|
||||
///
|
||||
/// `std::env::set_var` is `unsafe` in current Rust because mutating the process environment is not
|
||||
/// defined as thread-safe if other threads read the environment concurrently. This runs from
|
||||
/// `main()` before `tauri::Builder` spawns worker threads, so no other Rust threads should be
|
||||
/// reading `std::env` yet. If that ordering ever changes, prefer setting these variables in a
|
||||
/// launcher script (for example AppImage `AppRun`) instead of here.
|
||||
fn configure_linux_wayland_defaults() {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
if std::env::var_os("WAYLAND_DISPLAY").is_some() {
|
||||
if std::env::var_os("GDK_BACKEND").is_none() {
|
||||
unsafe { std::env::set_var("GDK_BACKEND", "wayland") };
|
||||
}
|
||||
|
||||
if std::env::var_os("GDK_SCALE").is_none() {
|
||||
unsafe { std::env::set_var("GDK_SCALE", "1") };
|
||||
}
|
||||
|
||||
if std::env::var_os("GDK_DPI_SCALE").is_none() {
|
||||
unsafe { std::env::set_var("GDK_DPI_SCALE", "0.8") };
|
||||
}
|
||||
if std::env::var_os("WEBKIT_DISABLE_DMABUF_RENDERER").is_none() {
|
||||
unsafe { std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1") };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,36 +1,27 @@
|
||||
use tauri::menu::Menu;
|
||||
use tauri::menu::{MenuBuilder, MenuItemBuilder, SubmenuBuilder};
|
||||
use tauri::menu::{Menu, MenuBuilder, MenuItemBuilder, SubmenuBuilder};
|
||||
use tauri::{AppHandle, Runtime};
|
||||
|
||||
pub const SHOW_LOG_WINDOW: &str = "show_log_window";
|
||||
|
||||
pub trait AddDefaultSubmenus {
|
||||
#[allow(dead_code)]
|
||||
fn add_default_app_submenus(self) -> Self;
|
||||
}
|
||||
pub fn build_app_menu<R: Runtime>(app: &AppHandle<R>) -> tauri::Result<Menu<R>> {
|
||||
let edit_submenu = SubmenuBuilder::new(app, "Edit")
|
||||
.cut()
|
||||
.copy()
|
||||
.paste()
|
||||
.select_all()
|
||||
.build()?;
|
||||
|
||||
impl<R: tauri::Runtime> AddDefaultSubmenus for Menu<R> {
|
||||
#[allow(dead_code)]
|
||||
fn add_default_app_submenus(self) -> Self {
|
||||
if ::std::env::var("NYM_WALLET_ENABLE_LOG").is_ok() {
|
||||
let app_handle = self.app_handle();
|
||||
let mut menu_builder = MenuBuilder::new(app).item(&edit_submenu);
|
||||
|
||||
let help_text = MenuItemBuilder::with_id(SHOW_LOG_WINDOW, "Show logs")
|
||||
.build(app_handle)
|
||||
.expect("Failed to create menu item");
|
||||
if std::env::var("NYM_WALLET_ENABLE_LOG").is_ok() {
|
||||
let help_text = MenuItemBuilder::with_id(SHOW_LOG_WINDOW, "Show logs").build(app)?;
|
||||
|
||||
let submenu = SubmenuBuilder::new(app_handle, "Help")
|
||||
.items(&[&help_text])
|
||||
.build()
|
||||
.expect("Failed to create help submenu");
|
||||
let help_submenu = SubmenuBuilder::new(app, "Help")
|
||||
.items(&[&help_text])
|
||||
.build()?;
|
||||
|
||||
let menu_builder = MenuBuilder::new(app_handle);
|
||||
|
||||
match menu_builder.item(&submenu).build() {
|
||||
Ok(new_menu) => new_menu,
|
||||
Err(_) => self,
|
||||
}
|
||||
} else {
|
||||
self
|
||||
}
|
||||
menu_builder = menu_builder.item(&help_submenu);
|
||||
}
|
||||
|
||||
menu_builder.build()
|
||||
}
|
||||
|
||||
@@ -1,11 +1,51 @@
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
use url::Url;
|
||||
|
||||
/// Validates URL for system opener: `http` and `https` only (no `file:`, `javascript:`, `tauri:`, etc.).
|
||||
pub(crate) fn validate_open_url_scheme(url: &str) -> Result<Url, String> {
|
||||
let parsed = Url::parse(url).map_err(|e| format!("Invalid URL: {e}"))?;
|
||||
match parsed.scheme() {
|
||||
"https" | "http" => Ok(parsed),
|
||||
other => Err(format!("URL scheme not allowed: {other}")),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn open_url(url: String, app_handle: tauri::AppHandle) -> Result<(), String> {
|
||||
println!("Opening URL: {url}");
|
||||
validate_open_url_scheme(&url)?;
|
||||
|
||||
match app_handle.opener().open_url(&url, None::<&str>) {
|
||||
Ok(_) => Ok(()),
|
||||
Err(err) => Err(format!("Failed to open URL: {err}")),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::validate_open_url_scheme;
|
||||
|
||||
#[test]
|
||||
fn allows_http_https() {
|
||||
assert!(validate_open_url_scheme("https://nym.com/").is_ok());
|
||||
assert!(validate_open_url_scheme("http://127.0.0.1:8080/").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_other_schemes() {
|
||||
for url in [
|
||||
"file:///etc/passwd",
|
||||
"javascript:alert(1)",
|
||||
"tauri://localhost/",
|
||||
"data:text/html,hi",
|
||||
"ftp://example.com/",
|
||||
] {
|
||||
let res = validate_open_url_scheme(url);
|
||||
assert!(res.is_err(), "expected reject: {url}");
|
||||
let msg = res.unwrap_err();
|
||||
assert!(
|
||||
msg.contains("not allowed") || msg.contains("Invalid URL"),
|
||||
"{url}: {msg}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,10 +15,29 @@ pub async fn check_version(handle: tauri::AppHandle) -> Result<AppVersion, Backe
|
||||
})?;
|
||||
|
||||
// Then check for updates
|
||||
let update_info = updater.check().await.map_err(|e| {
|
||||
log::error!("An error occurred while checking for app update {e}");
|
||||
BackendError::CheckAppVersionError
|
||||
})?;
|
||||
let update_info = match updater.check().await {
|
||||
Ok(info) => info,
|
||||
Err(e) => {
|
||||
let msg = e.to_string();
|
||||
// Hosted static JSON must include per-platform `signature` (base64) alongside `url`.
|
||||
// Legacy manifests with only `url` fail serde in tauri-plugin-updater 2.x.
|
||||
if msg.contains("missing field") && msg.contains("signature") {
|
||||
let current_version = handle.package_info().version.to_string();
|
||||
log::warn!(
|
||||
"Updater check skipped: manifest at configured endpoint is not Tauri 2-compatible \
|
||||
(missing or invalid `signature` field). Users will not be notified of updates \
|
||||
until the hosted updater.json is republished. Error: {msg}"
|
||||
);
|
||||
return Ok(AppVersion {
|
||||
current_version: current_version.clone(),
|
||||
latest_version: current_version,
|
||||
is_update_available: false,
|
||||
});
|
||||
}
|
||||
log::error!("An error occurred while checking for app update {e}");
|
||||
return Err(BackendError::CheckAppVersionError);
|
||||
}
|
||||
};
|
||||
|
||||
// Process the result
|
||||
if let Some(update) = update_info {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use tauri::Manager;
|
||||
|
||||
use crate::error::BackendError;
|
||||
use crate::webview_theme::NYM_WALLET_WEBVIEW_BG;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn create_main_window(app_handle: tauri::AppHandle) -> Result<(), BackendError> {
|
||||
@@ -32,6 +33,7 @@ async fn create_window(
|
||||
tauri::WebviewUrl::App(new_window_url.into()),
|
||||
)
|
||||
.title("Nym Wallet")
|
||||
.background_color(NYM_WALLET_WEBVIEW_BG)
|
||||
.build()
|
||||
{
|
||||
Ok(window) => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use crate::error::BackendError;
|
||||
use crate::webview_theme::NYM_WALLET_WEBVIEW_BG;
|
||||
use tauri::Manager;
|
||||
|
||||
#[tauri::command]
|
||||
@@ -18,6 +19,7 @@ pub fn help_log_toggle_window(app_handle: tauri::AppHandle) -> Result<(), Backen
|
||||
tauri::WebviewUrl::App("log.html".into()),
|
||||
)
|
||||
.title("Nym Wallet Logs")
|
||||
.background_color(NYM_WALLET_WEBVIEW_BG)
|
||||
.build()
|
||||
{
|
||||
Ok(window) => {
|
||||
|
||||
@@ -18,6 +18,12 @@ use std::collections::HashMap;
|
||||
use strum::IntoEnumIterator;
|
||||
use url::Url;
|
||||
|
||||
fn cosmos_derivation_path() -> Result<DerivationPath, BackendError> {
|
||||
COSMOS_DERIVATION_PATH
|
||||
.parse()
|
||||
.map_err(|_| BackendError::InvalidInternalDerivationPath)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn connect_with_mnemonic(
|
||||
mnemonic: Mnemonic,
|
||||
@@ -48,8 +54,9 @@ pub async fn get_balance(state: tauri::State<'_, WalletState>) -> Result<Balance
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn create_new_mnemonic() -> Mnemonic {
|
||||
random_mnemonic()
|
||||
pub fn create_new_mnemonic() -> Result<Mnemonic, BackendError> {
|
||||
let mut rng = rand::thread_rng();
|
||||
Mnemonic::generate_in_with(&mut rng, Language::English, 24).map_err(Into::into)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -82,11 +89,6 @@ pub async fn logout(state: tauri::State<'_, WalletState>) -> Result<(), BackendE
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn random_mnemonic() -> Mnemonic {
|
||||
let mut rng = rand::thread_rng();
|
||||
Mnemonic::generate_in_with(&mut rng, Language::English, 24).unwrap()
|
||||
}
|
||||
|
||||
async fn _connect_with_mnemonic(
|
||||
mnemonic: Mnemonic,
|
||||
state: tauri::State<'_, WalletState>,
|
||||
@@ -129,12 +131,26 @@ async fn _connect_with_mnemonic(
|
||||
|
||||
let default_nyxd_urls: HashMap<WalletNetwork, Url> = untested_nyxd_urls
|
||||
.iter()
|
||||
.map(|(network, urls)| (*network, urls.iter().next().unwrap().clone()))
|
||||
.collect();
|
||||
.map(|(network, urls)| {
|
||||
let url = urls
|
||||
.iter()
|
||||
.next()
|
||||
.cloned()
|
||||
.ok_or(BackendError::WalletNoDefaultValidator)?;
|
||||
Ok((*network, url))
|
||||
})
|
||||
.collect::<Result<HashMap<_, _>, BackendError>>()?;
|
||||
let default_api_urls: HashMap<WalletNetwork, Url> = untested_api_urls
|
||||
.iter()
|
||||
.map(|(network, urls)| (*network, urls.iter().next().unwrap().clone()))
|
||||
.collect();
|
||||
.map(|(network, urls)| {
|
||||
let url = urls
|
||||
.iter()
|
||||
.next()
|
||||
.cloned()
|
||||
.ok_or(BackendError::WalletNoDefaultValidator)?;
|
||||
Ok((*network, url))
|
||||
})
|
||||
.collect::<Result<HashMap<_, _>, BackendError>>()?;
|
||||
|
||||
let nyxd_urls = pick_good_nyxd_urls(&default_nyxd_urls, &nyxd_urls).await?;
|
||||
let api_urls = pick_good_api_urls(&default_api_urls, &api_urls).await?;
|
||||
@@ -339,7 +355,7 @@ pub fn create_password(mnemonic: Mnemonic, password: UserPassword) -> Result<(),
|
||||
}
|
||||
log::info!("Creating password");
|
||||
|
||||
let hd_path: DerivationPath = COSMOS_DERIVATION_PATH.parse().unwrap();
|
||||
let hd_path: DerivationPath = cosmos_derivation_path()?;
|
||||
// Currently we only support a single, default, login id in the wallet
|
||||
let login_id = wallet_storage::LoginId::new(DEFAULT_LOGIN_ID.to_string());
|
||||
wallet_storage::store_login_with_multiple_accounts(mnemonic, hd_path, login_id, &password)
|
||||
@@ -451,7 +467,7 @@ pub async fn add_account_for_password(
|
||||
state: tauri::State<'_, WalletState>,
|
||||
) -> Result<AccountEntry, BackendError> {
|
||||
log::info!("Adding account for the current password: {account_id}");
|
||||
let hd_path: DerivationPath = COSMOS_DERIVATION_PATH.parse().unwrap();
|
||||
let hd_path: DerivationPath = cosmos_derivation_path()?;
|
||||
// Currently we only support a single, default, login id in the wallet
|
||||
let login_id = wallet_storage::LoginId::new(DEFAULT_LOGIN_ID.to_string());
|
||||
let account_id = wallet_storage::AccountId::new(account_id.to_string());
|
||||
@@ -501,27 +517,24 @@ async fn set_state_with_all_accounts(
|
||||
|
||||
let all_account_ids: Vec<WalletAccountIds> = all_accounts
|
||||
.iter()
|
||||
.map(|account| {
|
||||
.map(|account| -> Result<WalletAccountIds, BackendError> {
|
||||
let mnemonic = account.mnemonic();
|
||||
let addresses: HashMap<WalletNetwork, cosmrs::AccountId> = WalletNetwork::iter()
|
||||
.map(|network| {
|
||||
let config_network: NymNetworkDetails = network.into();
|
||||
(
|
||||
network,
|
||||
derive_address(
|
||||
mnemonic.clone(),
|
||||
&config_network.chain_details.bech32_account_prefix,
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
let addr = derive_address(
|
||||
mnemonic.clone(),
|
||||
&config_network.chain_details.bech32_account_prefix,
|
||||
)?;
|
||||
Ok((network, addr))
|
||||
})
|
||||
.collect();
|
||||
WalletAccountIds {
|
||||
.collect::<Result<HashMap<_, _>, BackendError>>()?;
|
||||
Ok(WalletAccountIds {
|
||||
id: account.id().clone(),
|
||||
addresses,
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
.collect::<Result<Vec<_>, BackendError>>()?;
|
||||
|
||||
let mut w_state = state.write().await;
|
||||
w_state.set_all_accounts(all_account_ids);
|
||||
|
||||
@@ -24,11 +24,14 @@ use nym_validator_client::nyxd::Fee;
|
||||
use nym_validator_client::DirectSigningHttpRpcValidatorClient;
|
||||
use tap::TapFallible;
|
||||
|
||||
/// Pending mixnet epoch events for the wallet, narrowed to delegation-related kinds and this account.
|
||||
/// Uses the same contract query as [`crate::operations::mixnet::interval::get_pending_epoch_events`];
|
||||
/// response shape uses legacy DTOs in `nym_types::deprecated` for the delegation page.
|
||||
#[tauri::command]
|
||||
pub async fn get_pending_delegation_events(
|
||||
state: tauri::State<'_, WalletState>,
|
||||
) -> Result<Vec<WrappedDelegationEvent>, BackendError> {
|
||||
log::info!(">>> [DEPRECATED] Get all pending delegation events");
|
||||
log::info!(">>> Get pending delegation events for account");
|
||||
let guard = state.read().await;
|
||||
let reg = guard.registered_coins()?;
|
||||
let client = guard.current_client()?;
|
||||
|
||||
@@ -16,7 +16,7 @@ pub(crate) async fn locked_coins(
|
||||
block_time: Option<u64>,
|
||||
state: tauri::State<'_, WalletState>,
|
||||
) -> Result<DecCoin, BackendError> {
|
||||
log::info!(">>> Query locked coins");
|
||||
log::debug!(">>> Query locked coins");
|
||||
let guard = state.read().await;
|
||||
let client = guard.current_client()?;
|
||||
|
||||
@@ -28,7 +28,7 @@ pub(crate) async fn locked_coins(
|
||||
)
|
||||
.await?;
|
||||
let display = guard.attempt_convert_to_display_dec_coin(res)?;
|
||||
log::info!("<<< locked coins = {display}");
|
||||
log::debug!("<<< locked coins = {display}");
|
||||
Ok(display)
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ pub(crate) async fn spendable_coins(
|
||||
block_time: Option<u64>,
|
||||
state: tauri::State<'_, WalletState>,
|
||||
) -> Result<DecCoin, BackendError> {
|
||||
log::info!(">>> Query spendable coins");
|
||||
log::debug!(">>> Query spendable coins");
|
||||
let guard = state.read().await;
|
||||
let client = guard.current_client()?;
|
||||
|
||||
@@ -50,7 +50,7 @@ pub(crate) async fn spendable_coins(
|
||||
.await?;
|
||||
|
||||
let display = guard.attempt_convert_to_display_dec_coin(res)?;
|
||||
log::info!("<<< spendable coins = {display}");
|
||||
log::debug!("<<< spendable coins = {display}");
|
||||
Ok(display)
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ pub(crate) async fn spendable_coins(
|
||||
pub(crate) async fn spendable_vested_coins(
|
||||
state: tauri::State<'_, WalletState>,
|
||||
) -> Result<DecCoin, BackendError> {
|
||||
log::info!(">>> Query spendable vested coins");
|
||||
log::debug!(">>> Query spendable vested coins");
|
||||
let guard = state.read().await;
|
||||
let client = guard.current_client()?;
|
||||
|
||||
@@ -68,7 +68,7 @@ pub(crate) async fn spendable_vested_coins(
|
||||
.await?;
|
||||
|
||||
let display = guard.attempt_convert_to_display_dec_coin(res)?;
|
||||
log::info!("<<< spendable vested coins = {display}");
|
||||
log::debug!("<<< spendable vested coins = {display}");
|
||||
Ok(display)
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ pub(crate) async fn spendable_vested_coins(
|
||||
pub(crate) async fn spendable_reward_coins(
|
||||
state: tauri::State<'_, WalletState>,
|
||||
) -> Result<DecCoin, BackendError> {
|
||||
log::info!(">>> Query spendable reward coins");
|
||||
log::debug!(">>> Query spendable reward coins");
|
||||
let guard = state.read().await;
|
||||
let client = guard.current_client()?;
|
||||
|
||||
@@ -86,7 +86,7 @@ pub(crate) async fn spendable_reward_coins(
|
||||
.await?;
|
||||
|
||||
let display = guard.attempt_convert_to_display_dec_coin(res)?;
|
||||
log::info!("<<< spendable reward coins = {display}");
|
||||
log::debug!("<<< spendable reward coins = {display}");
|
||||
Ok(display)
|
||||
}
|
||||
|
||||
@@ -96,7 +96,7 @@ pub(crate) async fn vested_coins(
|
||||
block_time: Option<u64>,
|
||||
state: tauri::State<'_, WalletState>,
|
||||
) -> Result<DecCoin, BackendError> {
|
||||
log::info!(">>> Query vested coins");
|
||||
log::debug!(">>> Query vested coins");
|
||||
let guard = state.read().await;
|
||||
|
||||
let res = guard
|
||||
@@ -109,7 +109,7 @@ pub(crate) async fn vested_coins(
|
||||
.await?;
|
||||
|
||||
let display = guard.attempt_convert_to_display_dec_coin(res)?;
|
||||
log::info!("<<< vested coins = {display}");
|
||||
log::debug!("<<< vested coins = {display}");
|
||||
Ok(display)
|
||||
}
|
||||
|
||||
@@ -119,7 +119,7 @@ pub(crate) async fn vesting_coins(
|
||||
block_time: Option<u64>,
|
||||
state: tauri::State<'_, WalletState>,
|
||||
) -> Result<DecCoin, BackendError> {
|
||||
log::info!(">>> Query vesting coins");
|
||||
log::debug!(">>> Query vesting coins");
|
||||
let guard = state.read().await;
|
||||
|
||||
let res = guard
|
||||
@@ -132,7 +132,7 @@ pub(crate) async fn vesting_coins(
|
||||
.await?;
|
||||
|
||||
let display = guard.attempt_convert_to_display_dec_coin(res)?;
|
||||
log::info!("<<< vesting coins = {display}");
|
||||
log::debug!("<<< vesting coins = {display}");
|
||||
Ok(display)
|
||||
}
|
||||
|
||||
@@ -141,12 +141,12 @@ pub(crate) async fn vesting_start_time(
|
||||
vesting_account_address: &str,
|
||||
state: tauri::State<'_, WalletState>,
|
||||
) -> Result<u64, BackendError> {
|
||||
log::info!(">>> Query vesting start time");
|
||||
log::debug!(">>> Query vesting start time");
|
||||
let res = nyxd_client!(state)
|
||||
.vesting_start_time(vesting_account_address)
|
||||
.await?
|
||||
.seconds();
|
||||
log::info!("<<< vesting start time = {res}");
|
||||
log::debug!("<<< vesting start time = {res}");
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
@@ -155,12 +155,12 @@ pub(crate) async fn vesting_end_time(
|
||||
vesting_account_address: &str,
|
||||
state: tauri::State<'_, WalletState>,
|
||||
) -> Result<u64, BackendError> {
|
||||
log::info!(">>> Query vesting end time");
|
||||
log::debug!(">>> Query vesting end time");
|
||||
let res = nyxd_client!(state)
|
||||
.vesting_end_time(vesting_account_address)
|
||||
.await?
|
||||
.seconds();
|
||||
log::info!("<<< vesting end time = {res}");
|
||||
log::debug!("<<< vesting end time = {res}");
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
@@ -169,7 +169,7 @@ pub(crate) async fn original_vesting(
|
||||
vesting_account_address: &str,
|
||||
state: tauri::State<'_, WalletState>,
|
||||
) -> Result<OriginalVestingResponse, BackendError> {
|
||||
log::info!(">>> Query original vesting");
|
||||
log::debug!(">>> Query original vesting");
|
||||
let guard = state.read().await;
|
||||
let reg = guard.registered_coins()?;
|
||||
|
||||
@@ -180,7 +180,7 @@ pub(crate) async fn original_vesting(
|
||||
.await?;
|
||||
|
||||
let res = OriginalVestingResponse::from_vesting_contract(res, reg)?;
|
||||
log::info!("<<< {res:?}");
|
||||
log::debug!("<<< {res:?}");
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
@@ -188,7 +188,7 @@ pub(crate) async fn original_vesting(
|
||||
pub(crate) async fn get_historical_vesting_staking_reward(
|
||||
state: tauri::State<'_, WalletState>,
|
||||
) -> Result<DecCoin, BackendError> {
|
||||
log::info!(">>> Query historical vesting staking reward coins");
|
||||
log::debug!(">>> Query historical vesting staking reward coins");
|
||||
let guard = state.read().await;
|
||||
let client = guard.current_client()?;
|
||||
|
||||
@@ -197,7 +197,7 @@ pub(crate) async fn get_historical_vesting_staking_reward(
|
||||
.get_historical_vesting_staking_reward(client.nyxd.address().as_ref())
|
||||
.await?;
|
||||
let display = guard.attempt_convert_to_display_dec_coin(res)?;
|
||||
log::info!("<<< historical vesting staking reward coins = {display}");
|
||||
log::debug!("<<< historical vesting staking reward coins = {display}");
|
||||
Ok(display)
|
||||
}
|
||||
|
||||
@@ -205,7 +205,7 @@ pub(crate) async fn get_historical_vesting_staking_reward(
|
||||
pub(crate) async fn get_spendable_vested_coins(
|
||||
state: tauri::State<'_, WalletState>,
|
||||
) -> Result<DecCoin, BackendError> {
|
||||
log::info!(">>> Query spendable vested coins");
|
||||
log::debug!(">>> Query spendable vested coins");
|
||||
let guard = state.read().await;
|
||||
let client = guard.current_client()?;
|
||||
|
||||
@@ -214,7 +214,7 @@ pub(crate) async fn get_spendable_vested_coins(
|
||||
.get_spendable_vested_coins(client.nyxd.address().as_ref())
|
||||
.await?;
|
||||
let display = guard.attempt_convert_to_display_dec_coin(res)?;
|
||||
log::info!("<<< spendable vested coins = {display}");
|
||||
log::debug!("<<< spendable vested coins = {display}");
|
||||
Ok(display)
|
||||
}
|
||||
|
||||
@@ -222,7 +222,7 @@ pub(crate) async fn get_spendable_vested_coins(
|
||||
pub(crate) async fn get_spendable_reward_coins(
|
||||
state: tauri::State<'_, WalletState>,
|
||||
) -> Result<DecCoin, BackendError> {
|
||||
log::info!(">>> Query spendable reward coins");
|
||||
log::debug!(">>> Query spendable reward coins");
|
||||
let guard = state.read().await;
|
||||
let client = guard.current_client()?;
|
||||
|
||||
@@ -231,7 +231,7 @@ pub(crate) async fn get_spendable_reward_coins(
|
||||
.get_spendable_reward_coins(client.nyxd.address().as_ref())
|
||||
.await?;
|
||||
let display = guard.attempt_convert_to_display_dec_coin(res)?;
|
||||
log::info!("<<< spendable reward coins = {display}");
|
||||
log::debug!("<<< spendable reward coins = {display}");
|
||||
Ok(display)
|
||||
}
|
||||
|
||||
@@ -239,7 +239,7 @@ pub(crate) async fn get_spendable_reward_coins(
|
||||
pub(crate) async fn get_delegated_coins(
|
||||
state: tauri::State<'_, WalletState>,
|
||||
) -> Result<DecCoin, BackendError> {
|
||||
log::info!(">>> Query delegated coins");
|
||||
log::debug!(">>> Query delegated coins");
|
||||
let guard = state.read().await;
|
||||
let client = guard.current_client()?;
|
||||
|
||||
@@ -248,7 +248,7 @@ pub(crate) async fn get_delegated_coins(
|
||||
.get_delegated_coins(client.nyxd.address().as_ref())
|
||||
.await?;
|
||||
let display = guard.attempt_convert_to_display_dec_coin(res)?;
|
||||
log::info!("<<< delegated coins = {display}");
|
||||
log::debug!("<<< delegated coins = {display}");
|
||||
Ok(display)
|
||||
}
|
||||
|
||||
@@ -256,7 +256,7 @@ pub(crate) async fn get_delegated_coins(
|
||||
pub(crate) async fn get_pledged_coins(
|
||||
state: tauri::State<'_, WalletState>,
|
||||
) -> Result<DecCoin, BackendError> {
|
||||
log::info!(">>> Query pledged coins");
|
||||
log::debug!(">>> Query pledged coins");
|
||||
let guard = state.read().await;
|
||||
let client = guard.current_client()?;
|
||||
|
||||
@@ -265,7 +265,7 @@ pub(crate) async fn get_pledged_coins(
|
||||
.get_pledged_coins(client.nyxd.address().as_ref())
|
||||
.await?;
|
||||
let display = guard.attempt_convert_to_display_dec_coin(res)?;
|
||||
log::info!("<<< pledged coins = {display}");
|
||||
log::debug!("<<< pledged coins = {display}");
|
||||
Ok(display)
|
||||
}
|
||||
|
||||
@@ -273,7 +273,7 @@ pub(crate) async fn get_pledged_coins(
|
||||
pub(crate) async fn get_staked_coins(
|
||||
state: tauri::State<'_, WalletState>,
|
||||
) -> Result<DecCoin, BackendError> {
|
||||
log::info!(">>> Query staked coins");
|
||||
log::debug!(">>> Query staked coins");
|
||||
let guard = state.read().await;
|
||||
let client = guard.current_client()?;
|
||||
|
||||
@@ -282,7 +282,7 @@ pub(crate) async fn get_staked_coins(
|
||||
.get_staked_coins(client.nyxd.address().as_ref())
|
||||
.await?;
|
||||
let display = guard.attempt_convert_to_display_dec_coin(res)?;
|
||||
log::info!("<<< staked coins = {display}");
|
||||
log::debug!("<<< staked coins = {display}");
|
||||
Ok(display)
|
||||
}
|
||||
|
||||
@@ -290,7 +290,7 @@ pub(crate) async fn get_staked_coins(
|
||||
pub(crate) async fn get_withdrawn_coins(
|
||||
state: tauri::State<'_, WalletState>,
|
||||
) -> Result<DecCoin, BackendError> {
|
||||
log::info!(">>> Query withdrawn coins");
|
||||
log::debug!(">>> Query withdrawn coins");
|
||||
let guard = state.read().await;
|
||||
let client = guard.current_client()?;
|
||||
|
||||
@@ -299,7 +299,7 @@ pub(crate) async fn get_withdrawn_coins(
|
||||
.get_withdrawn_coins(client.nyxd.address().as_ref())
|
||||
.await?;
|
||||
let display = guard.attempt_convert_to_display_dec_coin(res)?;
|
||||
log::info!("<<< pledged coins = {display}");
|
||||
log::debug!("<<< withdrawn coins = {display}");
|
||||
Ok(display)
|
||||
}
|
||||
|
||||
@@ -309,7 +309,7 @@ pub(crate) async fn delegated_free(
|
||||
_block_time: Option<u64>,
|
||||
_state: tauri::State<'_, WalletState>,
|
||||
) -> Result<DecCoin, BackendError> {
|
||||
log::info!(">>> Query delegated free -> THIS QUERY HAS BEEN REMOVED FROM THE CONTRACT");
|
||||
log::warn!(">>> Query delegated free -> THIS QUERY HAS BEEN REMOVED FROM THE CONTRACT");
|
||||
Err(BackendError::RemovedCommand {
|
||||
name: "vesting::queries::delegated_free".to_string(),
|
||||
alternative: "vesting::queries::get_delegated_coins".to_string(),
|
||||
@@ -323,7 +323,7 @@ pub(crate) async fn delegated_vesting(
|
||||
_vesting_account_address: &str,
|
||||
_state: tauri::State<'_, WalletState>,
|
||||
) -> Result<DecCoin, BackendError> {
|
||||
log::info!(">>> Query delegated vesting -> THIS QUERY HAS BEEN REMOVED FROM THE CONTRACT");
|
||||
log::warn!(">>> Query delegated vesting -> THIS QUERY HAS BEEN REMOVED FROM THE CONTRACT");
|
||||
Err(BackendError::RemovedCommand {
|
||||
name: "vesting::queries::delegated_vesting".to_string(),
|
||||
alternative: "vesting::queries::get_delegated_coins".to_string(),
|
||||
@@ -335,7 +335,7 @@ pub(crate) async fn vesting_get_mixnode_pledge(
|
||||
address: &str,
|
||||
state: tauri::State<'_, WalletState>,
|
||||
) -> Result<Option<PledgeData>, BackendError> {
|
||||
log::info!(">>> Query vesting get mixnode pledge");
|
||||
log::debug!(">>> Query vesting get mixnode pledge");
|
||||
let guard = state.read().await;
|
||||
let reg = guard.registered_coins()?;
|
||||
|
||||
@@ -347,7 +347,7 @@ pub(crate) async fn vesting_get_mixnode_pledge(
|
||||
.map(|pledge| PledgeData::from_vesting_contract(pledge, reg))
|
||||
.transpose()?;
|
||||
|
||||
log::info!("<<< {res:?}");
|
||||
log::debug!("<<< {res:?}");
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
@@ -356,7 +356,7 @@ pub(crate) async fn vesting_get_gateway_pledge(
|
||||
address: &str,
|
||||
state: tauri::State<'_, WalletState>,
|
||||
) -> Result<Option<PledgeData>, BackendError> {
|
||||
log::info!(">>> Query vesting get gateway pledge");
|
||||
log::debug!(">>> Query vesting get gateway pledge");
|
||||
let guard = state.read().await;
|
||||
let reg = guard.registered_coins()?;
|
||||
|
||||
@@ -368,7 +368,7 @@ pub(crate) async fn vesting_get_gateway_pledge(
|
||||
.map(|pledge| PledgeData::from_vesting_contract(pledge, reg))
|
||||
.transpose()?;
|
||||
|
||||
log::info!("<<< {res:?}");
|
||||
log::debug!("<<< {res:?}");
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
@@ -377,11 +377,11 @@ pub(crate) async fn get_current_vesting_period(
|
||||
address: &str,
|
||||
state: tauri::State<'_, WalletState>,
|
||||
) -> Result<Period, BackendError> {
|
||||
log::info!(">>> Query current vesting period");
|
||||
log::debug!(">>> Query current vesting period");
|
||||
let res = nyxd_client!(state)
|
||||
.get_current_vesting_period(address)
|
||||
.await?;
|
||||
log::info!("<<< {res:?}");
|
||||
log::debug!("<<< {res:?}");
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
@@ -390,13 +390,13 @@ pub(crate) async fn get_account_info(
|
||||
address: &str,
|
||||
state: tauri::State<'_, WalletState>,
|
||||
) -> Result<VestingAccountInfo, BackendError> {
|
||||
log::info!(">>> Query account info");
|
||||
log::debug!(">>> Query account info");
|
||||
let guard = state.read().await;
|
||||
let res = guard.registered_coins()?;
|
||||
|
||||
let vesting_account = guard.current_client()?.nyxd.get_account(address).await?;
|
||||
let res = VestingAccountInfo::from_vesting_contract(vesting_account, res)?;
|
||||
|
||||
log::info!("<<< {res:?}");
|
||||
log::debug!("<<< {res:?}");
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
@@ -386,8 +386,8 @@ impl WalletStateInner {
|
||||
// All urls for all networks
|
||||
let nyxd_urls = self
|
||||
.get_all_nyxd_urls()
|
||||
.into_iter()
|
||||
.flat_map(|(_, urls)| urls.into_iter());
|
||||
.into_values()
|
||||
.flat_map(|urls| urls.into_iter());
|
||||
|
||||
// Fetch status for all urls
|
||||
let responses = fetch_status_for_urls(nyxd_urls).await?;
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
//! Shared webview chrome (matches wallet dark `background.default` #242B2D).
|
||||
|
||||
use tauri::window::Color;
|
||||
|
||||
pub const NYM_WALLET_WEBVIEW_BG: Color = Color(36, 43, 45, 255);
|
||||
@@ -14,7 +14,7 @@
|
||||
],
|
||||
"resources": [],
|
||||
"externalBin": [],
|
||||
"copyright": "Copyright © 2021-2025 Nym Technologies SA",
|
||||
"copyright": "Copyright © 2021-2026 Nym Technologies SA",
|
||||
"category": "Business",
|
||||
"shortDescription": "Nym desktop wallet allows you to manage your NYM tokens",
|
||||
"longDescription": "",
|
||||
@@ -26,6 +26,9 @@
|
||||
"entitlements": null
|
||||
},
|
||||
"linux": {
|
||||
"appimage": {
|
||||
"bundleMediaFramework": false
|
||||
},
|
||||
"deb": {
|
||||
"depends": []
|
||||
}
|
||||
@@ -40,7 +43,7 @@
|
||||
},
|
||||
"productName": "NymWallet",
|
||||
"mainBinaryName": "NymWallet",
|
||||
"version": "1.2.19",
|
||||
"version": "1.2.20",
|
||||
"identifier": "net.nymtech.wallet",
|
||||
"plugins": {
|
||||
"updater": {
|
||||
@@ -55,15 +58,33 @@
|
||||
"capabilities": [
|
||||
"main-capability"
|
||||
],
|
||||
"csp": "default-src blob: data: filesystem: ws: wss: http: https: tauri: 'unsafe-eval' 'unsafe-inline' 'self' img-src: 'self'; connect-src ipc: http://ipc.localhost"
|
||||
"dangerousDisableAssetCspModification": [
|
||||
"style-src"
|
||||
],
|
||||
"csp": {
|
||||
"default-src": "'self' customprotocol: asset:",
|
||||
"script-src": "'self' 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval'",
|
||||
"style-src": "'unsafe-inline' 'self' https://fonts.googleapis.com",
|
||||
"img-src": "'self' asset: http://asset.localhost https://asset.localhost blob: data:",
|
||||
"font-src": "'self' data: https://fonts.gstatic.com",
|
||||
"connect-src": "ipc: http://ipc.localhost http://127.0.0.1:* http://localhost:* ws://127.0.0.1:* ws://localhost:* wss://127.0.0.1:* wss://localhost:* http: https: ws: wss:",
|
||||
"media-src": "'self' blob: data:",
|
||||
"worker-src": "'self' blob:",
|
||||
"frame-src": "'none'",
|
||||
"object-src": "'none'",
|
||||
"base-uri": "'self'"
|
||||
}
|
||||
},
|
||||
"windows": [
|
||||
{
|
||||
"title": "Nym Wallet",
|
||||
"width": 1268,
|
||||
"height": 768,
|
||||
"minWidth": 1024,
|
||||
"minHeight": 640,
|
||||
"resizable": true,
|
||||
"useHttpsScheme": true
|
||||
"useHttpsScheme": true,
|
||||
"backgroundColor": "#242b2d"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { fetchNymPriceDeduped } from './networkOverview';
|
||||
|
||||
const sampleTokenomics = {
|
||||
quotes: {
|
||||
USD: {
|
||||
price: 0.0331,
|
||||
market_cap: 26_000_000,
|
||||
volume_24h: 1_200_000,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe('fetchNymPriceDeduped', () => {
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('coalesces concurrent requests for the same URL', async () => {
|
||||
let callCount = 0;
|
||||
global.fetch = jest.fn(() => {
|
||||
callCount += 1;
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(sampleTokenomics),
|
||||
} as Response);
|
||||
});
|
||||
|
||||
const url = 'https://api.example.test/v1/nym-price';
|
||||
const p1 = fetchNymPriceDeduped(url);
|
||||
const p2 = fetchNymPriceDeduped(url);
|
||||
const [a, b] = await Promise.all([p1, p2]);
|
||||
|
||||
expect(a).toStrictEqual(sampleTokenomics);
|
||||
expect(b).toStrictEqual(sampleTokenomics);
|
||||
expect(callCount).toBe(1);
|
||||
});
|
||||
|
||||
it('does not coalesce different URLs', async () => {
|
||||
let callCount = 0;
|
||||
global.fetch = jest.fn(() => {
|
||||
callCount += 1;
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(sampleTokenomics),
|
||||
} as Response);
|
||||
});
|
||||
|
||||
await Promise.all([fetchNymPriceDeduped('https://a.test/p'), fetchNymPriceDeduped('https://b.test/p')]);
|
||||
expect(callCount).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
import { addSeconds } from 'date-fns';
|
||||
import type { Network } from 'src/types';
|
||||
|
||||
export type NetworkOverviewEndpoints = {
|
||||
mixnodeStats: string;
|
||||
epochCurrent: string;
|
||||
epochRewards: string;
|
||||
nymPrice: string;
|
||||
observatoryNodesBase: string;
|
||||
};
|
||||
|
||||
export const getNetworkOverviewEndpoints = (network?: Network): NetworkOverviewEndpoints => {
|
||||
if (network === 'SANDBOX' || network === 'QA') {
|
||||
return {
|
||||
mixnodeStats: 'https://sandbox-node-status-api.nymte.ch/v2/mixnodes/stats',
|
||||
epochCurrent: 'https://sandbox-nym-api1.nymtech.net/api/v1/epoch/current',
|
||||
epochRewards: 'https://sandbox-nym-api1.nymtech.net/api/v1/epoch/reward_params',
|
||||
nymPrice: 'https://api.nym.spectredao.net/api/v1/nym-price',
|
||||
observatoryNodesBase: 'https://sandbox-node-status-api.nymte.ch/explorer/v3/nym-nodes',
|
||||
};
|
||||
}
|
||||
return {
|
||||
mixnodeStats: 'https://mainnet-node-status-api.nymtech.cc/v2/mixnodes/stats',
|
||||
epochCurrent: 'https://validator.nymtech.net/api/v1/epoch/current',
|
||||
epochRewards: 'https://validator.nymtech.net/api/v1/epoch/reward_params',
|
||||
nymPrice: 'https://api.nym.spectredao.net/api/v1/nym-price',
|
||||
observatoryNodesBase: 'https://mainnet-node-status-api.nymtech.cc/explorer/v3/nym-nodes',
|
||||
};
|
||||
};
|
||||
|
||||
export interface PacketsAndStakingPoint {
|
||||
date_utc: string;
|
||||
total_packets_received: number;
|
||||
total_packets_sent: number;
|
||||
total_packets_dropped: number;
|
||||
total_stake: number;
|
||||
}
|
||||
|
||||
export interface CurrentEpochApiData {
|
||||
id: number;
|
||||
current_epoch_id: number;
|
||||
current_epoch_start: string;
|
||||
epoch_length: { secs: number; nanos: number };
|
||||
epochs_in_interval: number;
|
||||
total_elapsed_epochs: number;
|
||||
}
|
||||
|
||||
export type CurrentEpochWithEnd = CurrentEpochApiData & { current_epoch_end: string };
|
||||
|
||||
export interface EpochRewardsData {
|
||||
interval: {
|
||||
reward_pool: string;
|
||||
staking_supply: string;
|
||||
staking_supply_scale_factor: string;
|
||||
epoch_reward_budget: string;
|
||||
stake_saturation_point: string;
|
||||
active_set_work_factor: string;
|
||||
interval_pool_emission: string;
|
||||
sybil_resistance: string;
|
||||
};
|
||||
rewarded_set: {
|
||||
entry_gateways: number;
|
||||
exit_gateways: number;
|
||||
mixnodes: number;
|
||||
standby: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface NymTokenomics {
|
||||
quotes: {
|
||||
USD: {
|
||||
price: number;
|
||||
market_cap: number;
|
||||
volume_24h: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
type ObservatoryPage = {
|
||||
page: number;
|
||||
size: number;
|
||||
total: number;
|
||||
items: Array<{ rewarding_details?: { unique_delegations?: number } }>;
|
||||
};
|
||||
|
||||
const jsonHeaders = {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
};
|
||||
|
||||
export async function fetchPacketsAndStaking(url: string): Promise<PacketsAndStakingPoint[]> {
|
||||
const response = await fetch(url, { headers: jsonHeaders });
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch mixnode stats');
|
||||
}
|
||||
const data: PacketsAndStakingPoint[] = await response.json();
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function fetchCurrentEpoch(url: string): Promise<CurrentEpochWithEnd> {
|
||||
const response = await fetch(url, { headers: jsonHeaders, cache: 'no-store' });
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch current epoch');
|
||||
}
|
||||
const data: CurrentEpochApiData = await response.json();
|
||||
const current_epoch_end = addSeconds(new Date(data.current_epoch_start), data.epoch_length.secs).toISOString();
|
||||
return { ...data, current_epoch_end };
|
||||
}
|
||||
|
||||
export async function fetchEpochRewards(url: string): Promise<EpochRewardsData> {
|
||||
const response = await fetch(url, { headers: jsonHeaders, cache: 'no-store' });
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch epoch rewards');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function fetchNymPrice(url: string): Promise<NymTokenomics> {
|
||||
const response = await fetch(url, { headers: jsonHeaders });
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch NYM price');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
const nymPriceInflight = new Map<string, Promise<NymTokenomics>>();
|
||||
|
||||
/** Coalesces concurrent requests for the same price URL (e.g. Balance card + Network overview). */
|
||||
export function fetchNymPriceDeduped(url: string): Promise<NymTokenomics> {
|
||||
const existing = nymPriceInflight.get(url);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const pending = fetchNymPrice(url).finally(() => {
|
||||
nymPriceInflight.delete(url);
|
||||
});
|
||||
nymPriceInflight.set(url, pending);
|
||||
return pending;
|
||||
}
|
||||
|
||||
/** Paginates observatory nodes and sums unique_delegations (same idea as explorer StakersNumberCard). */
|
||||
export async function fetchTotalDelegationsCount(baseUrl: string): Promise<number> {
|
||||
const all: ObservatoryPage['items'] = [];
|
||||
let page = 0;
|
||||
const PAGE_SIZE = 200;
|
||||
let hasMore = true;
|
||||
|
||||
while (hasMore) {
|
||||
/* Paginated API: each request needs the previous page index. */
|
||||
// eslint-disable-next-line no-await-in-loop -- sequential pagination
|
||||
const response = await fetch(`${baseUrl}?page=${page}&size=${PAGE_SIZE}`, { headers: jsonHeaders });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch observatory nodes (page ${page})`);
|
||||
}
|
||||
// eslint-disable-next-line no-await-in-loop -- follows fetch above
|
||||
const data: ObservatoryPage = await response.json();
|
||||
all.push(...data.items);
|
||||
const totalPages = Math.ceil(data.total / PAGE_SIZE);
|
||||
if (page >= totalPages - 1 || data.items.length < PAGE_SIZE) {
|
||||
hasMore = false;
|
||||
} else {
|
||||
page += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return all.reduce((sum, node) => sum + (node.rewarding_details?.unique_delegations ?? 0), 0);
|
||||
}
|
||||
|
||||
export function formatCompactNumber(num: number): string {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
maximumFractionDigits: 2,
|
||||
notation: 'compact',
|
||||
compactDisplay: 'short',
|
||||
}).format(num);
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
import type { ExplorerNymNodeRow } from './nodeStatus';
|
||||
import {
|
||||
clearNodeStatusExplorerCaches,
|
||||
fetchGatewayStatus,
|
||||
fetchGatewayStatusIfBonded,
|
||||
findExplorerNymNodeByIdentity,
|
||||
getNodeStatusBaseUrl,
|
||||
isGatewayRole,
|
||||
normalizeExplorerUptimePercent,
|
||||
parseTotalStakeToNymAmount,
|
||||
} from './nodeStatus';
|
||||
|
||||
describe('getNodeStatusBaseUrl', () => {
|
||||
it('uses mainnet base for MAINNET and unknown', () => {
|
||||
expect(getNodeStatusBaseUrl('MAINNET')).toBe('https://mainnet-node-status-api.nymtech.cc');
|
||||
expect(getNodeStatusBaseUrl(undefined)).toBe('https://mainnet-node-status-api.nymtech.cc');
|
||||
});
|
||||
|
||||
it('uses sandbox base for SANDBOX and QA', () => {
|
||||
expect(getNodeStatusBaseUrl('SANDBOX')).toBe('https://sandbox-node-status-api.nymte.ch');
|
||||
expect(getNodeStatusBaseUrl('QA')).toBe('https://sandbox-node-status-api.nymte.ch');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isGatewayRole', () => {
|
||||
it('is true only for gateway roles', () => {
|
||||
expect(isGatewayRole('entryGateway')).toBe(true);
|
||||
expect(isGatewayRole('exitGateway')).toBe(true);
|
||||
expect(isGatewayRole('layer1')).toBe(false);
|
||||
expect(isGatewayRole(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseTotalStakeToNymAmount', () => {
|
||||
it('divides base unym by 1e6', () => {
|
||||
expect(parseTotalStakeToNymAmount('1000000')).toBe(1);
|
||||
expect(parseTotalStakeToNymAmount('2500000')).toBe(2.5);
|
||||
});
|
||||
|
||||
it('returns 0 for non-finite values', () => {
|
||||
expect(parseTotalStakeToNymAmount('')).toBe(0);
|
||||
expect(parseTotalStakeToNymAmount('x')).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeExplorerUptimePercent', () => {
|
||||
it('maps ratio 0-1 to percent', () => {
|
||||
expect(normalizeExplorerUptimePercent(0.98)).toBe(98);
|
||||
expect(normalizeExplorerUptimePercent(1)).toBe(100);
|
||||
expect(normalizeExplorerUptimePercent(0)).toBe(0);
|
||||
});
|
||||
|
||||
it('leaves values already in 0-100 percent scale', () => {
|
||||
expect(normalizeExplorerUptimePercent(98)).toBe(98);
|
||||
expect(normalizeExplorerUptimePercent(100)).toBe(100);
|
||||
});
|
||||
|
||||
it('parses numeric strings', () => {
|
||||
expect(normalizeExplorerUptimePercent('0.98')).toBe(98);
|
||||
expect(normalizeExplorerUptimePercent(' 98 ')).toBe(98);
|
||||
});
|
||||
|
||||
it('clamps negatives and caps at 100', () => {
|
||||
expect(normalizeExplorerUptimePercent(-1)).toBe(0);
|
||||
expect(normalizeExplorerUptimePercent(150)).toBe(100);
|
||||
});
|
||||
|
||||
it('returns 0 for non-numeric input', () => {
|
||||
expect(normalizeExplorerUptimePercent('')).toBe(0);
|
||||
expect(normalizeExplorerUptimePercent('abc')).toBe(0);
|
||||
expect(normalizeExplorerUptimePercent(Number.NaN)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
function jsonResponse(ok: boolean, status: number, body: unknown): Response {
|
||||
return {
|
||||
ok,
|
||||
status,
|
||||
json: async () => body,
|
||||
} as Response;
|
||||
}
|
||||
|
||||
describe('fetchGatewayStatusIfBonded', () => {
|
||||
const baseUrl = 'https://status.example';
|
||||
const identity = 'nym1abc';
|
||||
|
||||
beforeEach(() => {
|
||||
global.fetch = jest.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('returns null on 404 and 400', async () => {
|
||||
const fetchMock = global.fetch as jest.MockedFunction<typeof fetch>;
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse(false, 404, null));
|
||||
await expect(fetchGatewayStatusIfBonded(baseUrl, identity)).resolves.toBeNull();
|
||||
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse(false, 400, null));
|
||||
await expect(fetchGatewayStatusIfBonded(baseUrl, identity)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('throws on other non-OK responses', async () => {
|
||||
const fetchMock = global.fetch as jest.MockedFunction<typeof fetch>;
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse(false, 500, null));
|
||||
await expect(fetchGatewayStatusIfBonded(baseUrl, identity)).rejects.toThrow('Gateway status request failed (500)');
|
||||
});
|
||||
|
||||
it('returns null when bonded is false or identity mismatches', async () => {
|
||||
const fetchMock = global.fetch as jest.MockedFunction<typeof fetch>;
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
jsonResponse(true, 200, {
|
||||
gateway_identity_key: identity,
|
||||
bonded: false,
|
||||
performance: 0,
|
||||
routing_score: 0,
|
||||
last_updated_utc: 't',
|
||||
}),
|
||||
);
|
||||
await expect(fetchGatewayStatusIfBonded(baseUrl, identity)).resolves.toBeNull();
|
||||
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
jsonResponse(true, 200, {
|
||||
gateway_identity_key: 'other',
|
||||
bonded: true,
|
||||
performance: 0,
|
||||
routing_score: 0,
|
||||
last_updated_utc: 't',
|
||||
}),
|
||||
);
|
||||
await expect(fetchGatewayStatusIfBonded(baseUrl, identity)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('returns payload when bonded and identity matches', async () => {
|
||||
const fetchMock = global.fetch as jest.MockedFunction<typeof fetch>;
|
||||
const payload = {
|
||||
gateway_identity_key: identity,
|
||||
bonded: true,
|
||||
performance: 88,
|
||||
routing_score: 1,
|
||||
last_updated_utc: 't',
|
||||
};
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse(true, 200, payload));
|
||||
await expect(fetchGatewayStatusIfBonded(baseUrl, identity)).resolves.toStrictEqual(payload);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'https://status.example/v2/gateways/nym1abc',
|
||||
expect.objectContaining({ cache: 'no-store' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('strips trailing slash from base URL', async () => {
|
||||
const fetchMock = global.fetch as jest.MockedFunction<typeof fetch>;
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
jsonResponse(true, 200, {
|
||||
gateway_identity_key: identity,
|
||||
bonded: true,
|
||||
performance: 1,
|
||||
routing_score: 0,
|
||||
last_updated_utc: 't',
|
||||
}),
|
||||
);
|
||||
await fetchGatewayStatusIfBonded('https://status.example/', identity);
|
||||
expect(fetchMock).toHaveBeenCalledWith('https://status.example/v2/gateways/nym1abc', expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchGatewayStatus', () => {
|
||||
beforeEach(() => {
|
||||
global.fetch = jest.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('throws when gateway is not bonded', async () => {
|
||||
const fetchMock = global.fetch as jest.MockedFunction<typeof fetch>;
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse(false, 404, null));
|
||||
await expect(fetchGatewayStatus('https://x', 'id')).rejects.toThrow('Gateway status request failed (404)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findExplorerNymNodeByIdentity', () => {
|
||||
const base = 'https://status.example';
|
||||
const identity = 'nodeIdentity1';
|
||||
|
||||
const sampleRow = (key: string): ExplorerNymNodeRow => ({
|
||||
identity_key: key,
|
||||
node_id: 1,
|
||||
bonded: true,
|
||||
uptime: 0.99,
|
||||
total_stake: '1000000',
|
||||
original_pledge: 1,
|
||||
description: { moniker: 'm', website: '', details: '', security_contact: '' },
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
clearNodeStatusExplorerCaches();
|
||||
global.fetch = jest.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
clearNodeStatusExplorerCaches();
|
||||
});
|
||||
|
||||
it('finds row on first page', async () => {
|
||||
const fetchMock = global.fetch as jest.MockedFunction<typeof fetch>;
|
||||
const row = sampleRow(identity);
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse(true, 200, { items: [row], page: 0, size: 200, total: 1 }));
|
||||
const result = await findExplorerNymNodeByIdentity('MAINNET', base, identity);
|
||||
expect(result).toStrictEqual(row);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'https://status.example/explorer/v3/nym-nodes?page=0&size=200',
|
||||
expect.objectContaining({ cache: 'no-store' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('paginates until match', async () => {
|
||||
const fetchMock = global.fetch as jest.MockedFunction<typeof fetch>;
|
||||
const other = sampleRow('other');
|
||||
const row = sampleRow(identity);
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse(true, 200, {
|
||||
items: Array(200).fill(other),
|
||||
page: 0,
|
||||
size: 200,
|
||||
total: 250,
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse(true, 200, {
|
||||
items: [row],
|
||||
page: 1,
|
||||
size: 200,
|
||||
total: 250,
|
||||
}),
|
||||
);
|
||||
const result = await findExplorerNymNodeByIdentity('MAINNET', base, identity);
|
||||
expect(result).toStrictEqual(row);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('throws when explorer returns non-OK', async () => {
|
||||
const fetchMock = global.fetch as jest.MockedFunction<typeof fetch>;
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse(false, 503, null));
|
||||
await expect(findExplorerNymNodeByIdentity('MAINNET', base, identity)).rejects.toThrow(
|
||||
'Explorer nym-nodes request failed (503)',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns null when not found and caches miss', async () => {
|
||||
const fetchMock = global.fetch as jest.MockedFunction<typeof fetch>;
|
||||
fetchMock.mockResolvedValue(jsonResponse(true, 200, { items: [], page: 0, size: 200, total: 0 }));
|
||||
await expect(findExplorerNymNodeByIdentity('MAINNET', base, identity)).resolves.toBeNull();
|
||||
await expect(findExplorerNymNodeByIdentity('MAINNET', base, identity)).resolves.toBeNull();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns cached hit without refetching', async () => {
|
||||
const fetchMock = global.fetch as jest.MockedFunction<typeof fetch>;
|
||||
const row = sampleRow(identity);
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse(true, 200, { items: [row], page: 0, size: 200, total: 1 }));
|
||||
await findExplorerNymNodeByIdentity('MAINNET', base, identity);
|
||||
await findExplorerNymNodeByIdentity('MAINNET', base, identity);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('deduplicates concurrent scans for the same key', async () => {
|
||||
const fetchMock = global.fetch as jest.MockedFunction<typeof fetch>;
|
||||
let resolveFetch!: (v: Response) => void;
|
||||
const fetchPromise = new Promise<Response>((r) => {
|
||||
resolveFetch = r;
|
||||
});
|
||||
fetchMock.mockReturnValueOnce(fetchPromise);
|
||||
|
||||
const p1 = findExplorerNymNodeByIdentity('MAINNET', base, identity);
|
||||
const p2 = findExplorerNymNodeByIdentity('MAINNET', base, identity);
|
||||
resolveFetch(jsonResponse(true, 200, { items: [], page: 0, size: 200, total: 0 }));
|
||||
await expect(Promise.all([p1, p2])).resolves.toStrictEqual([null, null]);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not share cache between networks for the same identity', async () => {
|
||||
const fetchMock = global.fetch as jest.MockedFunction<typeof fetch>;
|
||||
const rowMainnet = sampleRow(identity);
|
||||
const rowSandbox = { ...sampleRow(identity), node_id: 99 };
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(jsonResponse(true, 200, { items: [rowMainnet], page: 0, size: 200, total: 1 }))
|
||||
.mockResolvedValueOnce(jsonResponse(true, 200, { items: [rowSandbox], page: 0, size: 200, total: 1 }));
|
||||
await expect(findExplorerNymNodeByIdentity('MAINNET', base, identity)).resolves.toStrictEqual(rowMainnet);
|
||||
await expect(findExplorerNymNodeByIdentity('SANDBOX', base, identity)).resolves.toStrictEqual(rowSandbox);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,255 @@
|
||||
import type { Network, TNodeRole } from 'src/types';
|
||||
|
||||
const MAINNET_BASE = 'https://mainnet-node-status-api.nymtech.cc';
|
||||
const SANDBOX_BASE = 'https://sandbox-node-status-api.nymte.ch';
|
||||
|
||||
/** Explorer list responses are paginated; cache successful lookups to avoid repeated full scans. */
|
||||
const EXPLORER_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
const EXPLORER_MISS_TTL_MS = 60 * 1000;
|
||||
const EXPLORER_PAGE_SIZE = 200;
|
||||
|
||||
type ExplorerCacheEntry = { data: ExplorerNymNodeRow | null; expiresAt: number };
|
||||
|
||||
const explorerCache = new Map<string, ExplorerCacheEntry>();
|
||||
const explorerInflight = new Map<string, Promise<ExplorerNymNodeRow | null>>();
|
||||
|
||||
/**
|
||||
* Clears explorer list cache and in-flight scans. Call from tests between cases; optional for a future forced refresh UX.
|
||||
*/
|
||||
export function clearNodeStatusExplorerCaches(): void {
|
||||
explorerCache.clear();
|
||||
explorerInflight.clear();
|
||||
}
|
||||
|
||||
export function getNodeStatusBaseUrl(network?: Network): string {
|
||||
if (network === 'SANDBOX' || network === 'QA') {
|
||||
return SANDBOX_BASE;
|
||||
}
|
||||
return MAINNET_BASE;
|
||||
}
|
||||
|
||||
const jsonHeaders = {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
};
|
||||
|
||||
/** Subset of probe outcome used for charts (matches node-status API JSON shape). */
|
||||
export type GatewayProbeOutcome = {
|
||||
as_entry?: { can_connect?: boolean; can_route?: boolean } | null;
|
||||
as_exit?: {
|
||||
can_connect?: boolean;
|
||||
can_route_ip_v4?: boolean;
|
||||
can_route_ip_v6?: boolean;
|
||||
can_route_ip_external_v4?: boolean;
|
||||
can_route_ip_external_v6?: boolean;
|
||||
} | null;
|
||||
socks5?: {
|
||||
can_connect_socks5?: boolean;
|
||||
https_connectivity?: { https_success?: boolean; https_latency_ms?: number };
|
||||
} | null;
|
||||
wg?: {
|
||||
can_handshake_v4?: boolean;
|
||||
can_handshake_v6?: boolean;
|
||||
can_query_metadata_v4?: boolean;
|
||||
can_register?: boolean;
|
||||
can_resolve_dns_v4?: boolean;
|
||||
can_resolve_dns_v6?: boolean;
|
||||
ping_hosts_performance_v4?: number;
|
||||
ping_hosts_performance_v6?: number;
|
||||
ping_ips_performance_v4?: number;
|
||||
ping_ips_performance_v6?: number;
|
||||
download_duration_milliseconds_v4?: number;
|
||||
download_duration_milliseconds_v6?: number;
|
||||
} | null;
|
||||
lp?: {
|
||||
can_connect?: boolean;
|
||||
can_handshake?: boolean;
|
||||
can_register?: boolean;
|
||||
error?: string | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type GatewayStatusPayload = {
|
||||
gateway_identity_key: string;
|
||||
bonded: boolean;
|
||||
performance: number;
|
||||
routing_score: number;
|
||||
last_probe_result?: {
|
||||
outcome?: GatewayProbeOutcome;
|
||||
} | null;
|
||||
last_testrun_utc?: string | null;
|
||||
last_updated_utc: string;
|
||||
description?: {
|
||||
moniker?: string;
|
||||
website?: string;
|
||||
details?: string;
|
||||
security_contact?: string;
|
||||
};
|
||||
explorer_pretty_bond?: {
|
||||
location?: {
|
||||
two_letter_iso_country_code?: string;
|
||||
city?: string;
|
||||
region?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
/** Fields used from `GET /explorer/v3/nym-nodes` items (identity-key match). */
|
||||
export type ExplorerNymNodeRow = {
|
||||
identity_key: string;
|
||||
node_id: number;
|
||||
bonded: boolean;
|
||||
/** Explorer may send percent, ratio 0-1, or a numeric string. */
|
||||
uptime: number | string;
|
||||
total_stake: string;
|
||||
original_pledge: number;
|
||||
description: {
|
||||
moniker: string;
|
||||
website: string;
|
||||
details: string;
|
||||
security_contact: string;
|
||||
};
|
||||
geoip?: {
|
||||
city?: string;
|
||||
country?: string;
|
||||
region?: string;
|
||||
two_letter_iso_country_code?: string;
|
||||
} | null;
|
||||
rewarding_details?: {
|
||||
unique_delegations?: number;
|
||||
} | null;
|
||||
};
|
||||
|
||||
type PagedExplorerNymNodes = {
|
||||
items: ExplorerNymNodeRow[];
|
||||
page: number;
|
||||
size: number;
|
||||
total: number;
|
||||
};
|
||||
|
||||
function explorerCacheKey(network: Network | undefined, identityKey: string): string {
|
||||
return `${network ?? 'MAINNET'}:${identityKey}`;
|
||||
}
|
||||
|
||||
async function fetchExplorerNymNodesPage(baseUrl: string, page: number, size: number): Promise<PagedExplorerNymNodes> {
|
||||
const root = baseUrl.replace(/\/$/, '');
|
||||
const url = `${root}/explorer/v3/nym-nodes?page=${page}&size=${size}`;
|
||||
const response = await fetch(url, { headers: jsonHeaders, cache: 'no-store' });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Explorer nym-nodes request failed (${response.status})`);
|
||||
}
|
||||
return response.json() as Promise<PagedExplorerNymNodes>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Paginates `GET /explorer/v3/nym-nodes` until `identity_key` matches or the list ends.
|
||||
* Results are cached per network + identity (hits: 5 min, misses: 60 s). Concurrent calls share one in-flight scan.
|
||||
*/
|
||||
export function findExplorerNymNodeByIdentity(
|
||||
network: Network | undefined,
|
||||
baseUrl: string,
|
||||
identityKey: string,
|
||||
): Promise<ExplorerNymNodeRow | null> {
|
||||
const key = explorerCacheKey(network, identityKey);
|
||||
const now = Date.now();
|
||||
const cached = explorerCache.get(key);
|
||||
if (cached && cached.expiresAt > now) {
|
||||
return Promise.resolve(cached.data);
|
||||
}
|
||||
|
||||
const pending = explorerInflight.get(key);
|
||||
if (pending) {
|
||||
return pending;
|
||||
}
|
||||
|
||||
const promise = (async (): Promise<ExplorerNymNodeRow | null> => {
|
||||
try {
|
||||
let page = 0;
|
||||
let total = Number.POSITIVE_INFINITY;
|
||||
while (page * EXPLORER_PAGE_SIZE < total) {
|
||||
// eslint-disable-next-line no-await-in-loop -- sequential pagination required
|
||||
const data = await fetchExplorerNymNodesPage(baseUrl, page, EXPLORER_PAGE_SIZE);
|
||||
total = data.total;
|
||||
const found = data.items.find((item) => item.identity_key === identityKey);
|
||||
if (found) {
|
||||
explorerCache.set(key, { data: found, expiresAt: Date.now() + EXPLORER_CACHE_TTL_MS });
|
||||
return found;
|
||||
}
|
||||
const totalPages = Math.ceil(data.total / EXPLORER_PAGE_SIZE);
|
||||
if (page >= totalPages - 1 || data.items.length < EXPLORER_PAGE_SIZE) {
|
||||
break;
|
||||
}
|
||||
page += 1;
|
||||
}
|
||||
explorerCache.set(key, { data: null, expiresAt: Date.now() + EXPLORER_MISS_TTL_MS });
|
||||
return null;
|
||||
} finally {
|
||||
explorerInflight.delete(key);
|
||||
}
|
||||
})();
|
||||
|
||||
explorerInflight.set(key, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
export function isGatewayRole(role: TNodeRole | undefined): boolean {
|
||||
return role === 'entryGateway' || role === 'exitGateway';
|
||||
}
|
||||
|
||||
/**
|
||||
* When the status API lists this identity as a bonded gateway, returns the payload.
|
||||
* Otherwise `null` (404, not bonded, or identity mismatch) so callers can fall back to explorer / mixnode paths.
|
||||
* Still throws on other HTTP errors so real failures surface.
|
||||
*/
|
||||
export async function fetchGatewayStatusIfBonded(
|
||||
baseUrl: string,
|
||||
identityKey: string,
|
||||
): Promise<GatewayStatusPayload | null> {
|
||||
const root = baseUrl.replace(/\/$/, '');
|
||||
const url = `${root}/v2/gateways/${encodeURIComponent(identityKey)}`;
|
||||
const response = await fetch(url, { headers: jsonHeaders, cache: 'no-store' });
|
||||
// Mixnodes are not in the gateway index. The API returns 404 or, for some identities, 400 with a short body.
|
||||
// Treat both as "no gateway row" so the wallet can fall back to explorer. Other 4xx are unexpected; 5xx throws below.
|
||||
if (response.status === 404 || response.status === 400) {
|
||||
return null;
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(`Gateway status request failed (${response.status})`);
|
||||
}
|
||||
const data = (await response.json()) as GatewayStatusPayload;
|
||||
if (!data.bonded || data.gateway_identity_key !== identityKey) {
|
||||
return null;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function fetchGatewayStatus(baseUrl: string, identityKey: string): Promise<GatewayStatusPayload> {
|
||||
const g = await fetchGatewayStatusIfBonded(baseUrl, identityKey);
|
||||
if (!g) {
|
||||
throw new Error('Gateway status request failed (404)');
|
||||
}
|
||||
return g;
|
||||
}
|
||||
|
||||
/** Parse API `total_stake` string (base unym) to NYM display amount. */
|
||||
export function parseTotalStakeToNymAmount(totalStake: string): number {
|
||||
const n = Number.parseFloat(totalStake);
|
||||
if (!Number.isFinite(n)) {
|
||||
return 0;
|
||||
}
|
||||
return n / 1_000_000;
|
||||
}
|
||||
|
||||
/**
|
||||
* API may return 0-100 (percent), 0-1 (ratio), or numeric strings. Values in [0, 1] are treated as a ratio.
|
||||
*/
|
||||
export function normalizeExplorerUptimePercent(raw: unknown): number {
|
||||
const n = typeof raw === 'string' ? Number.parseFloat(raw.trim()) : Number(raw);
|
||||
if (!Number.isFinite(n) || n < 0) {
|
||||
return 0;
|
||||
}
|
||||
if (n <= 1) {
|
||||
return Math.min(100, n * 100);
|
||||
}
|
||||
return Math.min(100, n);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { probeGroupsForChart, socks5LatencyMs, wgComparisonBars } from './nodeStatusCharts';
|
||||
|
||||
describe('probeGroupsForChart', () => {
|
||||
it('returns empty array when outcome is missing', () => {
|
||||
expect(probeGroupsForChart(undefined)).toStrictEqual([]);
|
||||
expect(probeGroupsForChart(null)).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('aggregates entry booleans', () => {
|
||||
expect(
|
||||
probeGroupsForChart({
|
||||
as_entry: { can_connect: true, can_route: false },
|
||||
}),
|
||||
).toStrictEqual([{ name: 'Entry', passed: 1, total: 2, pctPassed: 50 }]);
|
||||
});
|
||||
|
||||
it('skips entry group when no boolean fields are present', () => {
|
||||
expect(probeGroupsForChart({ as_entry: {} })).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('aggregates exit routing flags', () => {
|
||||
const groups = probeGroupsForChart({
|
||||
as_exit: {
|
||||
can_connect: true,
|
||||
can_route_ip_v4: false,
|
||||
can_route_ip_v6: true,
|
||||
},
|
||||
});
|
||||
expect(groups).toContainEqual({ name: 'Exit', passed: 2, total: 3, pctPassed: 67 });
|
||||
});
|
||||
|
||||
it('includes SOCKS5 when connectivity flags exist', () => {
|
||||
expect(
|
||||
probeGroupsForChart({
|
||||
socks5: {
|
||||
can_connect_socks5: true,
|
||||
https_connectivity: { https_success: false, https_latency_ms: 12 },
|
||||
},
|
||||
}),
|
||||
).toStrictEqual([{ name: 'SOCKS5', passed: 1, total: 2, pctPassed: 50 }]);
|
||||
});
|
||||
|
||||
it('includes WireGuard and LP groups from booleans', () => {
|
||||
const groups = probeGroupsForChart({
|
||||
wg: { can_handshake_v4: true, can_handshake_v6: false },
|
||||
lp: { can_connect: true, can_handshake: true, can_register: false },
|
||||
});
|
||||
expect(groups).toContainEqual({ name: 'WireGuard', passed: 1, total: 2, pctPassed: 50 });
|
||||
expect(groups).toContainEqual({ name: 'LP', passed: 2, total: 3, pctPassed: 67 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('wgComparisonBars', () => {
|
||||
it('prefers download duration bars when present', () => {
|
||||
expect(
|
||||
wgComparisonBars({
|
||||
wg: { download_duration_milliseconds_v4: 120, download_duration_milliseconds_v6: 200 },
|
||||
}),
|
||||
).toStrictEqual([
|
||||
{ name: 'IPv4 download', value: 120, kind: 'milliseconds' },
|
||||
{ name: 'IPv6 download', value: 200, kind: 'milliseconds' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back to ping performance as percent', () => {
|
||||
expect(
|
||||
wgComparisonBars({
|
||||
wg: { ping_ips_performance_v4: 0.88, ping_ips_performance_v6: 0.5 },
|
||||
}),
|
||||
).toStrictEqual([
|
||||
{ name: 'IPv4 ping success', value: 88, kind: 'percent' },
|
||||
{ name: 'IPv6 ping success', value: 50, kind: 'percent' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns empty when wg is absent', () => {
|
||||
expect(wgComparisonBars(undefined)).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('socks5LatencyMs', () => {
|
||||
it('returns latency when numeric', () => {
|
||||
expect(
|
||||
socks5LatencyMs({
|
||||
socks5: { https_connectivity: { https_latency_ms: 42 } },
|
||||
}),
|
||||
).toBe(42);
|
||||
});
|
||||
|
||||
it('returns undefined when missing or non-numeric', () => {
|
||||
expect(socks5LatencyMs(undefined)).toBeUndefined();
|
||||
expect(socks5LatencyMs({ socks5: {} })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
import type { GatewayProbeOutcome } from './nodeStatus';
|
||||
|
||||
/** Measures from WireGuard probe: either download latency (ms) or ping success (0-100 %). */
|
||||
export type WgMeasureBar = { name: string; value: number; kind: 'milliseconds' | 'percent' };
|
||||
|
||||
/** Per probe category: percent of checks that passed (0-100). */
|
||||
export type ProbeGroupBar = { name: string; pctPassed: number; passed: number; total: number };
|
||||
|
||||
export function probeGroupsForChart(outcome: GatewayProbeOutcome | undefined | null): ProbeGroupBar[] {
|
||||
const groups: ProbeGroupBar[] = [];
|
||||
|
||||
const pushGroup = (name: string, p: number, t: number) => {
|
||||
if (t === 0) {
|
||||
return;
|
||||
}
|
||||
groups.push({
|
||||
name,
|
||||
passed: p,
|
||||
total: t,
|
||||
pctPassed: Math.round((p / t) * 100),
|
||||
});
|
||||
};
|
||||
|
||||
if (outcome?.as_entry) {
|
||||
const e = outcome.as_entry;
|
||||
let p = 0;
|
||||
let t = 0;
|
||||
if (typeof e.can_connect === 'boolean') {
|
||||
t += 1;
|
||||
if (e.can_connect) {
|
||||
p += 1;
|
||||
}
|
||||
}
|
||||
if (typeof e.can_route === 'boolean') {
|
||||
t += 1;
|
||||
if (e.can_route) {
|
||||
p += 1;
|
||||
}
|
||||
}
|
||||
pushGroup('Entry', p, t);
|
||||
}
|
||||
|
||||
if (outcome?.as_exit) {
|
||||
const x = outcome.as_exit;
|
||||
const keys = [
|
||||
'can_connect',
|
||||
'can_route_ip_v4',
|
||||
'can_route_ip_v6',
|
||||
'can_route_ip_external_v4',
|
||||
'can_route_ip_external_v6',
|
||||
] as const;
|
||||
const exitCounts = keys.reduce(
|
||||
(acc, k) => {
|
||||
const v = x[k];
|
||||
if (typeof v === 'boolean') {
|
||||
return { p: acc.p + (v ? 1 : 0), t: acc.t + 1 };
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{ p: 0, t: 0 },
|
||||
);
|
||||
pushGroup('Exit', exitCounts.p, exitCounts.t);
|
||||
}
|
||||
|
||||
if (outcome?.socks5) {
|
||||
const s = outcome.socks5;
|
||||
let p = 0;
|
||||
let t = 0;
|
||||
if (typeof s.can_connect_socks5 === 'boolean') {
|
||||
t += 1;
|
||||
if (s.can_connect_socks5) {
|
||||
p += 1;
|
||||
}
|
||||
}
|
||||
if (s.https_connectivity && typeof s.https_connectivity.https_success === 'boolean') {
|
||||
t += 1;
|
||||
if (s.https_connectivity.https_success) {
|
||||
p += 1;
|
||||
}
|
||||
}
|
||||
pushGroup('SOCKS5', p, t);
|
||||
}
|
||||
|
||||
if (outcome?.wg) {
|
||||
const w = outcome.wg;
|
||||
const boolKeys = [
|
||||
'can_handshake_v4',
|
||||
'can_handshake_v6',
|
||||
'can_query_metadata_v4',
|
||||
'can_register',
|
||||
'can_resolve_dns_v4',
|
||||
'can_resolve_dns_v6',
|
||||
] as const;
|
||||
const wgCounts = boolKeys.reduce(
|
||||
(acc, k) => {
|
||||
const v = w[k];
|
||||
if (typeof v === 'boolean') {
|
||||
return { p: acc.p + (v ? 1 : 0), t: acc.t + 1 };
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{ p: 0, t: 0 },
|
||||
);
|
||||
pushGroup('WireGuard', wgCounts.p, wgCounts.t);
|
||||
}
|
||||
|
||||
if (outcome?.lp) {
|
||||
const l = outcome.lp;
|
||||
const lpKeys = ['can_connect', 'can_handshake', 'can_register'] as const;
|
||||
const lpCounts = lpKeys.reduce(
|
||||
(acc, k) => {
|
||||
const v = l[k];
|
||||
if (typeof v === 'boolean') {
|
||||
return { p: acc.p + (v ? 1 : 0), t: acc.t + 1 };
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{ p: 0, t: 0 },
|
||||
);
|
||||
pushGroup('LP', lpCounts.p, lpCounts.t);
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
/** Bars for WG download latency (ms) or ping success (percent). Only one family is returned per probe. */
|
||||
export function wgComparisonBars(outcome: GatewayProbeOutcome | undefined | null): WgMeasureBar[] {
|
||||
const w = outcome?.wg;
|
||||
if (!w) {
|
||||
return [];
|
||||
}
|
||||
const v4 = w.download_duration_milliseconds_v4;
|
||||
const v6 = w.download_duration_milliseconds_v6;
|
||||
if (typeof v4 === 'number' || typeof v6 === 'number') {
|
||||
const out: WgMeasureBar[] = [];
|
||||
if (typeof v4 === 'number') {
|
||||
out.push({ name: 'IPv4 download', value: v4, kind: 'milliseconds' });
|
||||
}
|
||||
if (typeof v6 === 'number') {
|
||||
out.push({ name: 'IPv6 download', value: v6, kind: 'milliseconds' });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
const p4 = w.ping_ips_performance_v4;
|
||||
const p6 = w.ping_ips_performance_v6;
|
||||
const out: WgMeasureBar[] = [];
|
||||
if (typeof p4 === 'number') {
|
||||
out.push({ name: 'IPv4 ping success', value: Math.round(p4 * 100), kind: 'percent' });
|
||||
}
|
||||
if (typeof p6 === 'number') {
|
||||
out.push({ name: 'IPv6 ping success', value: Math.round(p6 * 100), kind: 'percent' });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function socks5LatencyMs(outcome: GatewayProbeOutcome | undefined | null): number | undefined {
|
||||
const ms = outcome?.socks5?.https_connectivity?.https_latency_ms;
|
||||
return typeof ms === 'number' ? ms : undefined;
|
||||
}
|
||||
@@ -1,14 +1,43 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import React, { ComponentType, useEffect } from 'react';
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
import { BrowserRouter as Router } from 'react-router-dom';
|
||||
import { BrowserRouter, HashRouter } from 'react-router-dom';
|
||||
import { SnackbarProvider } from 'notistack';
|
||||
import { AppProvider } from './context/main';
|
||||
import { ErrorFallback } from './components';
|
||||
import { NymWalletTheme } from './theme';
|
||||
import { maximizeWindow } from './utils';
|
||||
import { config } from './config';
|
||||
import { useTauriTextEditingClipboard } from './hooks/useTauriTextEditingClipboard';
|
||||
|
||||
type RouterComponent = ComponentType<{ children?: React.ReactNode }>;
|
||||
|
||||
const ClipboardBridge: FCWithChildren = ({ children }) => {
|
||||
useTauriTextEditingClipboard();
|
||||
return children;
|
||||
};
|
||||
|
||||
/** Auth (`index.html`) uses normal paths. Main (`main.html`) must pass {@link HashRouter} - see `main.tsx`. */
|
||||
function selectRouter(): RouterComponent {
|
||||
if (typeof window === 'undefined') {
|
||||
return BrowserRouter;
|
||||
}
|
||||
const { pathname } = window.location;
|
||||
if (pathname === '/main.html' || pathname.endsWith('/main.html')) {
|
||||
return HashRouter;
|
||||
}
|
||||
return BrowserRouter;
|
||||
}
|
||||
|
||||
export const AppCommon = ({
|
||||
children,
|
||||
Router: RouterProp,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
/** When set (main wallet entry), overrides auto-detect. Production Tauri often uses pathname `/` while still loading `main.html`, so main always passes `HashRouter`. */
|
||||
Router?: RouterComponent;
|
||||
}) => {
|
||||
const Router = RouterProp ?? selectRouter();
|
||||
|
||||
export const AppCommon = ({ children }: { children: React.ReactNode }) => {
|
||||
useEffect(() => {
|
||||
// do not maximise in dev mode, because it happens on hot reloading
|
||||
if (!config.IS_DEV_MODE) {
|
||||
@@ -26,7 +55,9 @@ export const AppCommon = ({ children }: { children: React.ReactNode }) => {
|
||||
}}
|
||||
>
|
||||
<AppProvider>
|
||||
<NymWalletTheme>{children}</NymWalletTheme>
|
||||
<NymWalletTheme>
|
||||
<ClipboardBridge>{children}</ClipboardBridge>
|
||||
</NymWalletTheme>
|
||||
</AppProvider>
|
||||
</SnackbarProvider>
|
||||
</Router>
|
||||
|
||||
@@ -2,11 +2,12 @@ import React from 'react';
|
||||
import { Button } from '@mui/material';
|
||||
import { AccountEntry } from '@nymproject/types';
|
||||
import { AccountAvatar } from './AccountAvatar';
|
||||
import { headerControlPillSx } from '../headerControlPillSx';
|
||||
|
||||
export const AccountOverview = ({ account, onClick }: { account: AccountEntry; onClick: () => void }) => (
|
||||
<Button
|
||||
startIcon={<AccountAvatar name={account.id} small />}
|
||||
sx={{ color: 'text.primary', fontSize: 14 }}
|
||||
sx={headerControlPillSx}
|
||||
color="inherit"
|
||||
onClick={onClick}
|
||||
>
|
||||
|
||||
@@ -152,8 +152,20 @@ export const AddAccountModal = () => {
|
||||
useContext(AccountsContext);
|
||||
|
||||
const generateMnemonic = async () => {
|
||||
const mnemon = await createMnemonic();
|
||||
setData((d) => ({ ...d, mnemonic: mnemon }));
|
||||
setError(undefined);
|
||||
try {
|
||||
const mnemon = await createMnemonic();
|
||||
setData((d) => ({ ...d, mnemonic: mnemon }));
|
||||
} catch (e) {
|
||||
setData((d) => ({ ...d, mnemonic: '' }));
|
||||
let message = 'Could not generate a recovery phrase. Try again.';
|
||||
if (typeof e === 'string') {
|
||||
message = e;
|
||||
} else if (e instanceof Error) {
|
||||
message = e.message;
|
||||
}
|
||||
setError(message);
|
||||
}
|
||||
};
|
||||
|
||||
const resetState = () => {
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Box } from '@mui/material';
|
||||
import { ComponentMeta, ComponentStory } from '@storybook/react';
|
||||
import { MockAccountsProvider } from 'src/context/mocks/accounts';
|
||||
import { Accounts } from '../Accounts';
|
||||
|
||||
export default {
|
||||
title: 'Wallet / Multi Account',
|
||||
component: Accounts,
|
||||
} as ComponentMeta<typeof Accounts>;
|
||||
|
||||
export const Default: ComponentStory<typeof Accounts> = () => (
|
||||
<Box display="flex" alignContent="center">
|
||||
<MockAccountsProvider>
|
||||
<Accounts />
|
||||
</MockAccountsProvider>
|
||||
</Box>
|
||||
);
|
||||
@@ -8,7 +8,7 @@ export const ActionsMenu: FCWithChildren<{
|
||||
onOpen: () => void;
|
||||
onClose: () => void;
|
||||
}> = ({ children, open, onOpen, onClose }) => {
|
||||
const anchorEl: any = useRef<HTMLElement>();
|
||||
const anchorEl = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import React, { useContext } from 'react';
|
||||
import { AppBar as MuiAppBar, Grid, IconButton, Toolbar } from '@mui/material';
|
||||
import { AppBar as MuiAppBar, Box, IconButton, Stack, Toolbar } from '@mui/material';
|
||||
import { alpha } from '@mui/material/styles';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Logout, SettingsOutlined as SettingsIcon } from '@mui/icons-material';
|
||||
import { CONTENT_RAIL_MAX_WIDTH_WIDE } from '../layouts/contentRail';
|
||||
import { AppContext } from '../context/main';
|
||||
import { NetworkSelector } from './NetworkSelector';
|
||||
import { MultiAccounts } from './Accounts';
|
||||
@@ -11,24 +13,48 @@ export const AppBar = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<MuiAppBar position="sticky" sx={{ boxShadow: 'none', bgcolor: 'transparent', backgroundImage: 'none', mt: 3 }}>
|
||||
<Toolbar disableGutters>
|
||||
<Grid container justifyContent="space-between" alignItems="center" flexWrap="nowrap">
|
||||
<Grid item container alignItems="center" spacing={1}>
|
||||
<Grid item>
|
||||
<MuiAppBar
|
||||
position="sticky"
|
||||
sx={{
|
||||
boxShadow: 'none',
|
||||
bgcolor: 'transparent',
|
||||
backgroundImage: 'none',
|
||||
pt: { xs: 2, md: 3 },
|
||||
}}
|
||||
>
|
||||
<Toolbar
|
||||
disableGutters
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
width: '100%',
|
||||
minHeight: { xs: 48, sm: 56 },
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
maxWidth: CONTENT_RAIL_MAX_WIDTH_WIDE,
|
||||
mx: 'auto',
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
alignItems: 'center',
|
||||
borderBottom: (theme) => `1px solid ${alpha(theme.palette.divider, 0.55)}`,
|
||||
pb: 1.25,
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" alignItems="center" spacing={1.5} flexWrap="wrap" sx={{ py: 0.5, rowGap: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<MultiAccounts />
|
||||
</Grid>
|
||||
<Grid item>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<NetworkSelector />
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Grid item container justifyContent="flex-end" md={12} lg={5} spacing={2}>
|
||||
<Grid item>
|
||||
</Box>
|
||||
<Stack direction="row" spacing={0.5} alignItems="center">
|
||||
<IconButton size="small" onClick={() => navigate('/settings')} sx={{ color: 'text.primary' }}>
|
||||
<SettingsIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Grid>
|
||||
<Grid item>
|
||||
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={async () => {
|
||||
@@ -39,9 +65,9 @@ export const AppBar = () => {
|
||||
>
|
||||
<Logout fontSize="small" />
|
||||
</IconButton>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Toolbar>
|
||||
</MuiAppBar>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import React from 'react';
|
||||
import { Box, CircularProgress, Fade, Stack, Typography } from '@mui/material';
|
||||
import { alpha, useTheme } from '@mui/material/styles';
|
||||
import { NymWordmark } from '@nymproject/react/logo/NymWordmark';
|
||||
|
||||
export type AppSessionLoadingOverlayProps = {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* In-session full-viewport overlay (blur + card) for account switch and sign-out.
|
||||
* Keeps the app theme unlike {@link LoadingPage} which uses the auth splash look.
|
||||
*/
|
||||
export const AppSessionLoadingOverlay = ({ title, subtitle }: AppSessionLoadingOverlayProps) => {
|
||||
const theme = useTheme();
|
||||
const fill = theme.palette.mode === 'dark' ? '#FFFFFF' : theme.palette.text.primary;
|
||||
|
||||
return (
|
||||
<Fade in timeout={220}>
|
||||
<Box
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-busy="true"
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 2000,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
px: 2,
|
||||
py: 4,
|
||||
bgcolor: (t) => alpha(t.palette.background.default, t.palette.mode === 'dark' ? 0.78 : 0.86),
|
||||
backdropFilter: 'blur(14px)',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
maxWidth: 400,
|
||||
borderRadius: 3,
|
||||
border: (t) => `1px solid ${t.palette.divider}`,
|
||||
bgcolor: (t) => (t.palette.mode === 'dark' ? alpha(t.palette.background.paper, 0.94) : 'background.paper'),
|
||||
boxShadow: (t) => t.palette.nym.nymWallet.shadows.strong,
|
||||
px: { xs: 3, sm: 4 },
|
||||
py: { xs: 3.5, sm: 4 },
|
||||
}}
|
||||
>
|
||||
<Stack spacing={2.5} alignItems="center" textAlign="center">
|
||||
<NymWordmark width={64} fill={fill} />
|
||||
<Stack spacing={0.75} alignItems="center">
|
||||
<Typography variant="h6" component="p" sx={{ fontWeight: 700, lineHeight: 1.25 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
{subtitle ? (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ lineHeight: 1.45, maxWidth: 320 }}>
|
||||
{subtitle}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Stack>
|
||||
<CircularProgress size={40} thickness={4} color="primary" aria-label="Loading" />
|
||||
</Stack>
|
||||
</Box>
|
||||
</Box>
|
||||
</Fade>
|
||||
);
|
||||
};
|
||||
@@ -11,40 +11,66 @@ export const Bond = ({
|
||||
|
||||
disabled: boolean;
|
||||
}) => (
|
||||
<NymCard title="Bonding" borderless>
|
||||
<NymCard hideHeader borderless dataTestid="bond-run-node">
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-end',
|
||||
justifyContent: 'space-between',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
textAlign: 'center',
|
||||
gap: 3,
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2">
|
||||
Bond a nym node. Learn how to set up and run a Nym node{' '}
|
||||
<Link href="https://nym.com/docs/operators/nodes/nym-node" target="_blank">
|
||||
here
|
||||
</Link>
|
||||
</Typography>
|
||||
<Box
|
||||
<Box sx={{ width: '100%' }}>
|
||||
<Typography
|
||||
variant="overline"
|
||||
sx={{
|
||||
display: 'block',
|
||||
color: 'text.secondary',
|
||||
letterSpacing: '0.12em',
|
||||
fontWeight: 600,
|
||||
mb: 0.75,
|
||||
}}
|
||||
>
|
||||
Operator
|
||||
</Typography>
|
||||
<Typography component="h2" variant="h5" sx={{ fontWeight: 700, lineHeight: 1.25 }}>
|
||||
Run a node
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-end',
|
||||
justifyContent: 'space-between',
|
||||
gap: 2,
|
||||
color: 'text.secondary',
|
||||
maxWidth: 640,
|
||||
mx: 'auto',
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
size="large"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
type="button"
|
||||
disableElevation
|
||||
onClick={onBond}
|
||||
disabled={disabled}
|
||||
>
|
||||
Bond
|
||||
</Button>
|
||||
</Box>
|
||||
Bonding locks NYM as pledge so you can run a nym node on the network. You will need enough liquid NYM to cover
|
||||
the minimum pledge and fees. Read the{' '}
|
||||
<Link href="https://nym.com/docs/operators/nodes/nym-node" target="_blank">
|
||||
node setup and bonding guide
|
||||
</Link>{' '}
|
||||
before you continue.
|
||||
</Typography>
|
||||
<Button
|
||||
size="large"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
type="button"
|
||||
disableElevation
|
||||
onClick={onBond}
|
||||
disabled={disabled}
|
||||
sx={{
|
||||
alignSelf: 'stretch',
|
||||
maxWidth: 360,
|
||||
width: '100%',
|
||||
mx: 'auto',
|
||||
}}
|
||||
>
|
||||
Bond
|
||||
</Button>
|
||||
</Box>
|
||||
</NymCard>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Box, Button, Chip, Stack, Tooltip, Typography } from '@mui/material';
|
||||
import { TauriLink as Link } from 'src/components/TauriLinkWrapper';
|
||||
@@ -11,9 +11,22 @@ import { TBondedNymNode } from 'src/requests/nymNodeDetails';
|
||||
import { Node as NodeIcon } from '../../svg-icons/node';
|
||||
import { Cell, Header, NodeTable } from './NodeTable';
|
||||
import { BondedNymNodeActions, TBondedNymNodeActions } from './BondedNymNodeActions';
|
||||
import { NodeOperatorInsights, type NodeStatusMetadata } from './NodeOperatorInsights';
|
||||
|
||||
const textWhenNotName = 'This node has not yet set a name';
|
||||
|
||||
/** Wallet default name vs legacy copy in BondedNymNode. */
|
||||
function isUnsetNodeName(name: string | undefined): boolean {
|
||||
if (!name) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
name.includes('Name has not been set') ||
|
||||
name.includes(textWhenNotName) ||
|
||||
name.toLowerCase().includes('not been set')
|
||||
);
|
||||
}
|
||||
|
||||
const headers: Header[] = [
|
||||
{
|
||||
header: 'Stake',
|
||||
@@ -66,6 +79,7 @@ export const BondedNymNode = ({
|
||||
onActionSelect: (action: TBondedNymNodeActions) => void;
|
||||
}) => {
|
||||
const [nextEpoch, setNextEpoch] = useState<string | Error>();
|
||||
const [statusMeta, setStatusMeta] = useState<NodeStatusMetadata | null>(null);
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
name,
|
||||
@@ -79,8 +93,13 @@ export const BondedNymNode = ({
|
||||
delegators,
|
||||
identityKey,
|
||||
host,
|
||||
uptime,
|
||||
} = nymnode;
|
||||
|
||||
const handleStatusLoaded = useCallback((meta: NodeStatusMetadata) => {
|
||||
setStatusMeta(meta);
|
||||
}, []);
|
||||
|
||||
const getNextInterval = async () => {
|
||||
try {
|
||||
const { nextEpoch: newNextEpoch } = await getIntervalAsDate();
|
||||
@@ -136,22 +155,41 @@ export const BondedNymNode = ({
|
||||
getNextInterval();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setStatusMeta(null);
|
||||
}, [identityKey]);
|
||||
|
||||
const showWalletName = !isUnsetNodeName(name);
|
||||
const apiMoniker = statusMeta?.displayMoniker?.trim();
|
||||
const showApiMoniker = !showWalletName && Boolean(apiMoniker);
|
||||
const locationChip = statusMeta?.locationLabel;
|
||||
|
||||
return (
|
||||
<Stack gap={2}>
|
||||
<NymCard
|
||||
borderless
|
||||
title={
|
||||
<Stack gap={3}>
|
||||
<Box display="flex" alignItems="center" gap={2}>
|
||||
<Stack gap={2}>
|
||||
<Box display="flex" alignItems="center" gap={2} flexWrap="wrap">
|
||||
<Typography variant="h5" fontWeight={600}>
|
||||
Nym node
|
||||
</Typography>
|
||||
{locationChip ? (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ fontWeight: 500 }}>
|
||||
{locationChip}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Box>
|
||||
{name?.includes(textWhenNotName) ? null : (
|
||||
{showWalletName ? (
|
||||
<Typography fontWeight="regular" variant="h6" width="fit-content">
|
||||
{name}
|
||||
</Typography>
|
||||
)}
|
||||
) : null}
|
||||
{showApiMoniker ? (
|
||||
<Typography fontWeight="regular" variant="h6" width="fit-content" color="text.primary">
|
||||
{apiMoniker}
|
||||
</Typography>
|
||||
) : null}
|
||||
<Tooltip title={host} placement="top" arrow>
|
||||
<Box width="fit-content">
|
||||
<IdentityKey identityKey={identityKey} />
|
||||
@@ -190,17 +228,26 @@ export const BondedNymNode = ({
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<NodeTable headers={headers} cells={cells} />
|
||||
{network && (
|
||||
<Typography sx={{ mt: 2, fontSize: 'small' }}>
|
||||
Check more stats of your node on the{' '}
|
||||
<Link href={`${urls(network).networkExplorer}/nodes/${nodeId}`} target="_blank">
|
||||
explorer
|
||||
</Link>
|
||||
</Typography>
|
||||
)}
|
||||
<Stack spacing={3} sx={{ width: '100%', minWidth: 0 }}>
|
||||
<Box sx={{ width: '100%', minWidth: 0 }}>
|
||||
<NodeTable headers={headers} cells={cells} />
|
||||
{network ? (
|
||||
<Typography sx={{ mt: 2, fontSize: 'small' }}>
|
||||
Check more stats of your node on the{' '}
|
||||
<Link href={`${urls(network).networkExplorer}/nodes/${nodeId}`} target="_blank">
|
||||
explorer
|
||||
</Link>
|
||||
</Typography>
|
||||
) : null}
|
||||
</Box>
|
||||
<NodeOperatorInsights
|
||||
network={network}
|
||||
identityKey={identityKey}
|
||||
walletUptime={uptime}
|
||||
onStatusLoaded={handleStatusLoaded}
|
||||
/>
|
||||
</Stack>
|
||||
</NymCard>
|
||||
{/* <NodeStats bondedNode={nymnode} /> */}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,574 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { ExpandMore } from '@mui/icons-material';
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Collapse,
|
||||
Divider,
|
||||
Grid,
|
||||
IconButton,
|
||||
LinearProgress,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { alpha, useTheme, type Theme } from '@mui/material/styles';
|
||||
import type { Network } from 'src/types';
|
||||
import {
|
||||
fetchGatewayStatusIfBonded,
|
||||
findExplorerNymNodeByIdentity,
|
||||
getNodeStatusBaseUrl,
|
||||
normalizeExplorerUptimePercent,
|
||||
type ExplorerNymNodeRow,
|
||||
type GatewayStatusPayload,
|
||||
} from 'src/api/nodeStatus';
|
||||
import { probeGroupsForChart, socks5LatencyMs, wgComparisonBars, type ProbeGroupBar } from 'src/api/nodeStatusCharts';
|
||||
|
||||
const OPERATOR_INSIGHTS_EXPANDED_KEY = 'nymWallet.bonding.operatorInsightsExpanded';
|
||||
|
||||
function readOperatorInsightsExpanded(): boolean {
|
||||
try {
|
||||
const v = localStorage.getItem(OPERATOR_INSIGHTS_EXPANDED_KEY);
|
||||
if (v === null) {
|
||||
return true;
|
||||
}
|
||||
return v === 'true';
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
type InsightsCollapseFrameProps = {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
const InsightsCollapseFrame = ({ title, subtitle, children }: InsightsCollapseFrameProps) => {
|
||||
const theme = useTheme();
|
||||
const [open, setOpen] = useState(readOperatorInsightsExpanded);
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
setOpen((prev) => {
|
||||
const next = !prev;
|
||||
try {
|
||||
localStorage.setItem(OPERATOR_INSIGHTS_EXPANDED_KEY, String(next));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Box sx={{ width: '100%', mt: { xs: 1, md: 0 } }}>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="flex-start"
|
||||
justifyContent="space-between"
|
||||
sx={{
|
||||
gap: 1,
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
'&:focus-visible': {
|
||||
outline: `2px solid ${theme.palette.primary.main}`,
|
||||
outlineOffset: 2,
|
||||
borderRadius: 1,
|
||||
},
|
||||
}}
|
||||
onClick={toggle}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
toggle();
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-expanded={open}
|
||||
>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography variant="h6" component="h2" sx={{ fontWeight: 600 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
|
||||
{subtitle}
|
||||
</Typography>
|
||||
</Box>
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label={open ? 'Collapse operator insights' : 'Expand operator insights'}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggle();
|
||||
}}
|
||||
>
|
||||
<ExpandMore
|
||||
sx={{
|
||||
transform: open ? 'rotate(180deg)' : 'rotate(0deg)',
|
||||
transition: theme.transitions.create('transform', { duration: theme.transitions.duration.shortest }),
|
||||
}}
|
||||
/>
|
||||
</IconButton>
|
||||
</Stack>
|
||||
<Collapse in={open}>
|
||||
<Box sx={{ pt: 2.5 }}>{children}</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const ACCENT = '#8482FD';
|
||||
|
||||
export type NodeStatusMetadata = {
|
||||
displayMoniker?: string;
|
||||
locationLabel?: string;
|
||||
};
|
||||
|
||||
export type NodeOperatorInsightsProps = {
|
||||
network?: Network;
|
||||
identityKey: string;
|
||||
walletUptime?: number;
|
||||
onStatusLoaded?: (meta: NodeStatusMetadata) => void;
|
||||
};
|
||||
|
||||
function chartCardSx(theme: Theme) {
|
||||
return {
|
||||
borderRadius: 2,
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
bgcolor:
|
||||
theme.palette.mode === 'dark'
|
||||
? theme.palette.nym.nymWallet.nav.background
|
||||
: theme.palette.nym.nymWallet.background.subtle,
|
||||
p: 2,
|
||||
height: '100%',
|
||||
} as const;
|
||||
}
|
||||
|
||||
/** Softer digest surface for gateway (single panel, less contrast than nested cards). */
|
||||
function gatewayDigestSx(theme: Theme) {
|
||||
return {
|
||||
borderRadius: 2,
|
||||
border: `1px solid ${alpha(theme.palette.divider, 0.85)}`,
|
||||
background:
|
||||
theme.palette.mode === 'dark'
|
||||
? `linear-gradient(145deg, ${alpha(theme.palette.nym.nymWallet.nav.background, 0.97)} 0%, ${alpha(
|
||||
ACCENT,
|
||||
0.04,
|
||||
)} 100%)`
|
||||
: theme.palette.nym.nymWallet.background.subtle,
|
||||
p: { xs: 2, sm: 2.5 },
|
||||
overflow: 'hidden',
|
||||
} as const;
|
||||
}
|
||||
|
||||
const GatewayProbeRow = ({ theme, row, isLast }: { theme: Theme; row: ProbeGroupBar; isLast: boolean }) => {
|
||||
const { name, pctPassed, passed, total } = row;
|
||||
let barColor: string;
|
||||
if (pctPassed >= 100) {
|
||||
barColor = ACCENT;
|
||||
} else if (pctPassed > 0) {
|
||||
barColor = alpha(ACCENT, 0.65);
|
||||
} else {
|
||||
barColor = alpha(theme.palette.text.disabled, 0.35);
|
||||
}
|
||||
return (
|
||||
<Tooltip title={`${passed} of ${total} checks passed`} arrow placement="top" enterDelay={400}>
|
||||
<Box sx={{ width: '100%' }}>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
spacing={1.5}
|
||||
sx={{
|
||||
py: 1,
|
||||
borderBottom: isLast ? 'none' : `1px solid ${alpha(theme.palette.divider, 0.5)}`,
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ minWidth: 76, color: 'text.secondary', fontWeight: 500 }}>
|
||||
{name}
|
||||
</Typography>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={pctPassed}
|
||||
sx={{
|
||||
height: 5,
|
||||
borderRadius: 2.5,
|
||||
bgcolor: alpha(ACCENT, theme.palette.mode === 'dark' ? 0.1 : 0.14),
|
||||
'& .MuiLinearProgress-bar': {
|
||||
borderRadius: 2.5,
|
||||
bgcolor: barColor,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
minWidth: 56,
|
||||
textAlign: 'right',
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
color: 'text.secondary',
|
||||
}}
|
||||
>
|
||||
{pctPassed}%
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export const NodeOperatorInsights: React.FC<NodeOperatorInsightsProps> = ({
|
||||
network,
|
||||
identityKey,
|
||||
walletUptime,
|
||||
onStatusLoaded,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | undefined>();
|
||||
const [gateway, setGateway] = useState<GatewayStatusPayload | undefined>();
|
||||
const [explorerRow, setExplorerRow] = useState<ExplorerNymNodeRow | undefined>();
|
||||
const [explorerMissing, setExplorerMissing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const baseUrl = getNodeStatusBaseUrl(network);
|
||||
|
||||
const run = async () => {
|
||||
setLoading(true);
|
||||
setError(undefined);
|
||||
setExplorerMissing(false);
|
||||
try {
|
||||
const g = await fetchGatewayStatusIfBonded(baseUrl, identityKey);
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
if (g) {
|
||||
setGateway(g);
|
||||
setExplorerRow(undefined);
|
||||
const loc = g.explorer_pretty_bond?.location;
|
||||
const locationLabel =
|
||||
loc && (loc.city || loc.two_letter_iso_country_code)
|
||||
? [loc.city, loc.two_letter_iso_country_code].filter(Boolean).join(', ')
|
||||
: undefined;
|
||||
onStatusLoaded?.({
|
||||
displayMoniker: g.description?.moniker,
|
||||
locationLabel,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setGateway(undefined);
|
||||
const row = await findExplorerNymNodeByIdentity(network, baseUrl, identityKey);
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
if (row) {
|
||||
setExplorerRow(row);
|
||||
setExplorerMissing(false);
|
||||
const geo = row.geoip;
|
||||
const locationLabel =
|
||||
geo && (geo.city || geo.country) ? [geo.city, geo.country].filter(Boolean).join(', ') : undefined;
|
||||
onStatusLoaded?.({
|
||||
displayMoniker: row.description?.moniker,
|
||||
locationLabel,
|
||||
});
|
||||
} else {
|
||||
setExplorerRow(undefined);
|
||||
setExplorerMissing(true);
|
||||
onStatusLoaded?.({});
|
||||
}
|
||||
} catch (e) {
|
||||
if (!cancelled) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to load node status');
|
||||
setGateway(undefined);
|
||||
setExplorerRow(undefined);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
run().catch(() => {
|
||||
/* errors surfaced via setError inside run */
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [network, identityKey, onStatusLoaded]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Stack spacing={2} sx={{ width: '100%' }}>
|
||||
<Skeleton variant="rounded" height={120} sx={{ borderRadius: 2 }} />
|
||||
<Skeleton variant="rounded" height={180} sx={{ borderRadius: 2 }} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert severity="warning" sx={{ borderRadius: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (gateway) {
|
||||
const outcome = gateway.last_probe_result?.outcome;
|
||||
const probeGroups = probeGroupsForChart(outcome);
|
||||
const wgBars = wgComparisonBars(outcome);
|
||||
const latency = socks5LatencyMs(outcome);
|
||||
const perf = Math.min(100, Math.max(0, gateway.performance));
|
||||
const wgKind = wgBars[0]?.kind;
|
||||
const socksGroup = probeGroups.find((g) => g.name === 'SOCKS5');
|
||||
|
||||
return (
|
||||
<InsightsCollapseFrame
|
||||
title="Operator insights"
|
||||
subtitle="Quiet snapshot of how the network last probed this gateway. Expand for detail."
|
||||
>
|
||||
<Box sx={gatewayDigestSx(theme)}>
|
||||
<Grid container spacing={2.5} alignItems="flex-start">
|
||||
<Grid item xs={12} md={4}>
|
||||
<Stack spacing={2}>
|
||||
<Box>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ letterSpacing: '0.06em', textTransform: 'uppercase', display: 'block', mb: 0.75 }}
|
||||
>
|
||||
Performance
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="h3"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
color: ACCENT,
|
||||
lineHeight: 1.1,
|
||||
fontSize: { xs: '1.65rem', sm: '1.9rem' },
|
||||
}}
|
||||
>
|
||||
{perf}%
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ display: 'block', mt: 0.75, lineHeight: 1.45 }}
|
||||
>
|
||||
Reward-weight score (0-100) from the status API.
|
||||
</Typography>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={perf}
|
||||
sx={{
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
mt: 1.25,
|
||||
bgcolor: alpha(ACCENT, 0.14),
|
||||
'& .MuiLinearProgress-bar': { bgcolor: ACCENT, borderRadius: 2 },
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Divider flexItem sx={{ borderColor: alpha(theme.palette.divider, 0.55) }} />
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>
|
||||
Routing score
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mt: 0.35, fontWeight: 500 }}>
|
||||
{gateway.routing_score > 0 ? gateway.routing_score : 'Not published'}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ display: 'block', mt: 0.75, lineHeight: 1.45 }}
|
||||
>
|
||||
Distinct from performance. Often 0 until the network publishes it.
|
||||
</Typography>
|
||||
</Box>
|
||||
{gateway.last_testrun_utc ? (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontVariantNumeric: 'tabular-nums' }}>
|
||||
Last probe: {gateway.last_testrun_utc}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={8}>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ letterSpacing: '0.06em', textTransform: 'uppercase', display: 'block', mb: 0.5 }}
|
||||
>
|
||||
Categories
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.25, lineHeight: 1.5 }}>
|
||||
Hover a row for pass counts.
|
||||
</Typography>
|
||||
{probeGroups.length === 0 ? (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No probe data yet.
|
||||
</Typography>
|
||||
) : (
|
||||
<Box>
|
||||
{probeGroups.map((row, i) => (
|
||||
<GatewayProbeRow key={row.name} theme={theme} row={row} isLast={i === probeGroups.length - 1} />
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
{socksGroup !== undefined && socksGroup.pctPassed < 100 ? (
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ display: 'block', mt: 1.25, lineHeight: 1.5 }}
|
||||
>
|
||||
SOCKS5 / NR did not fully pass this run - optional path, depends on topology.
|
||||
</Typography>
|
||||
) : null}
|
||||
{latency !== undefined ? (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.75 }}>
|
||||
SOCKS5 HTTPS latency: {latency} ms
|
||||
</Typography>
|
||||
) : null}
|
||||
</Grid>
|
||||
{wgBars.length > 0 ? (
|
||||
<Grid item xs={12}>
|
||||
<Divider sx={{ borderColor: alpha(theme.palette.divider, 0.55), my: 0.25 }} />
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ letterSpacing: '0.06em', textTransform: 'uppercase', display: 'block', mb: 0.75 }}
|
||||
>
|
||||
WireGuard
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.25, lineHeight: 1.5 }}>
|
||||
{wgKind === 'milliseconds'
|
||||
? 'Probe download time over the tunnel (ms). Lower reads faster.'
|
||||
: 'ICMP success rate through the tunnel. Higher is better.'}
|
||||
</Typography>
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.5}>
|
||||
{wgBars.map((b) => (
|
||||
<Box
|
||||
key={b.name}
|
||||
sx={{
|
||||
flex: 1,
|
||||
borderRadius: 1.5,
|
||||
px: 2,
|
||||
py: 1.5,
|
||||
bgcolor: alpha(theme.palette.text.primary, 0.035),
|
||||
border: `1px solid ${alpha(theme.palette.divider, 0.4)}`,
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{b.name}
|
||||
</Typography>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, mt: 0.35, fontVariantNumeric: 'tabular-nums' }}>
|
||||
{b.kind === 'milliseconds' ? `${b.value} ms` : `${b.value}%`}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</Grid>
|
||||
) : null}
|
||||
</Grid>
|
||||
</Box>
|
||||
</InsightsCollapseFrame>
|
||||
);
|
||||
}
|
||||
|
||||
if (explorerRow) {
|
||||
const apiUptime = normalizeExplorerUptimePercent(explorerRow.uptime);
|
||||
const epochUptimePct =
|
||||
walletUptime !== undefined && walletUptime !== null ? normalizeExplorerUptimePercent(walletUptime) : undefined;
|
||||
|
||||
return (
|
||||
<InsightsCollapseFrame
|
||||
title="Operator insights"
|
||||
subtitle="Uptime from the explorer index versus epoch uptime from the chain. Stake, delegators, and saturation stay in the table above."
|
||||
>
|
||||
<Box sx={chartCardSx(theme)}>
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Typography variant="body2" color="text.secondary" fontWeight={600}>
|
||||
Explorer uptime
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ display: 'block', mt: 0.5, mb: 1.25, lineHeight: 1.5 }}
|
||||
>
|
||||
Public index value. The API may send a 0-1 ratio (for example 0.98) - shown here as 0-100%.
|
||||
</Typography>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={apiUptime}
|
||||
sx={{
|
||||
height: 10,
|
||||
borderRadius: 5,
|
||||
bgcolor: alpha(ACCENT, 0.2),
|
||||
'& .MuiLinearProgress-bar': { bgcolor: ACCENT, borderRadius: 5 },
|
||||
}}
|
||||
/>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, mt: 0.75 }}>
|
||||
{apiUptime.toFixed(1)}%
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Typography variant="body2" color="text.secondary" fontWeight={600}>
|
||||
Epoch uptime (wallet)
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ display: 'block', mt: 0.5, mb: 1.25, lineHeight: 1.5 }}
|
||||
>
|
||||
Estimate from your wallet RPC for the current epoch (same scale as the explorer bar).
|
||||
</Typography>
|
||||
{epochUptimePct !== undefined ? (
|
||||
<>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={epochUptimePct}
|
||||
sx={{
|
||||
height: 10,
|
||||
borderRadius: 5,
|
||||
mt: 0,
|
||||
bgcolor: alpha(theme.palette.primary.main, 0.15),
|
||||
}}
|
||||
/>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, mt: 0.75 }}>
|
||||
{epochUptimePct.toFixed(1)}%
|
||||
</Typography>
|
||||
</>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
|
||||
Not available from the wallet for this node.
|
||||
</Typography>
|
||||
)}
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
</InsightsCollapseFrame>
|
||||
);
|
||||
}
|
||||
|
||||
if (explorerMissing) {
|
||||
return (
|
||||
<InsightsCollapseFrame
|
||||
title="Operator insights"
|
||||
subtitle="Explorer index for your node (identity match). Cached after the first full scan."
|
||||
>
|
||||
<Alert severity="info" sx={{ borderRadius: 2 }}>
|
||||
Your node is not in the explorer listing yet, or the identity does not match the index. New bonds can take
|
||||
time to appear. Insights will load automatically once indexed.
|
||||
</Alert>
|
||||
</InsightsCollapseFrame>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -22,7 +22,7 @@ export interface TableProps {
|
||||
}
|
||||
|
||||
export const NodeTable = ({ headers, cells }: TableProps) => (
|
||||
<TableContainer>
|
||||
<TableContainer sx={{ overflowX: 'auto', width: '100%' }}>
|
||||
<Table aria-label="node-table">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
|
||||
@@ -95,6 +95,8 @@ export const NodeSettings = ({
|
||||
return (
|
||||
<SimpleModal
|
||||
open
|
||||
dense
|
||||
accent="primary"
|
||||
hideCloseIcon
|
||||
sx={{ p: 0 }}
|
||||
header={
|
||||
@@ -129,7 +131,11 @@ export const NodeSettings = ({
|
||||
<FormHelperText>Your new profit margin will be applied in the next interval</FormHelperText>
|
||||
</Box>
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<ModalListItem label="Est. fee for this operation will be caculated in the next page" value="" />
|
||||
<ModalListItem
|
||||
label="Next step"
|
||||
value="Fee for this operation is calculated on the next page."
|
||||
layout="stack"
|
||||
/>
|
||||
</Box>
|
||||
<Button variant="contained" fullWidth size="large" onClick={handleValidate} disabled={error}>
|
||||
Next
|
||||
|
||||
@@ -51,6 +51,8 @@ export const UnbondModal = ({ node, onConfirm, onClose, onError }: Props) => {
|
||||
return (
|
||||
<SimpleModal
|
||||
open
|
||||
dense
|
||||
accent="primary"
|
||||
header="Unbond"
|
||||
subHeader="Unbond and remove your node from the mixnet"
|
||||
okLabel="Unbond"
|
||||
|
||||
@@ -104,8 +104,13 @@ export const UpdateBondAmountModal = ({
|
||||
onPrev={resetFeeState}
|
||||
onConfirm={handleConfirm}
|
||||
>
|
||||
<ModalListItem label="New bond details" value={newBondToDisplay()} divider />
|
||||
<ModalListItem label="Change bond details" value={`${currentBond.amount} ${currentBond.denom}`} divider />
|
||||
<ModalListItem label="New bond details" value={newBondToDisplay()} divider layout="stack" />
|
||||
<ModalListItem
|
||||
label="Previous bond"
|
||||
value={`${currentBond.amount} ${currentBond.denom}`}
|
||||
divider
|
||||
layout="stack"
|
||||
/>
|
||||
{userBalance.balance?.amount.amount && fee?.amount?.amount && (
|
||||
<Box sx={{ my: 2 }}>
|
||||
<BalanceWarning fee={fee?.amount?.amount} tx={newBond?.amount} />
|
||||
@@ -117,6 +122,8 @@ export const UpdateBondAmountModal = ({
|
||||
return (
|
||||
<SimpleModal
|
||||
open
|
||||
dense
|
||||
accent="primary"
|
||||
header="Change bond amount"
|
||||
subHeader="Add or reduce amount of tokens on your node"
|
||||
okLabel="Next"
|
||||
@@ -156,7 +163,12 @@ export const UpdateBondAmountModal = ({
|
||||
) : (
|
||||
<ModalListItem label="Node saturation" value={`${stakeSaturation}%`} divider />
|
||||
)}
|
||||
<ModalListItem label="Est. fee for this operation will be calculated in the next page" value="" divider />
|
||||
<ModalListItem
|
||||
label="Next step"
|
||||
value="Fee for this operation is calculated on the next page."
|
||||
divider
|
||||
layout="stack"
|
||||
/>
|
||||
</Box>
|
||||
</Stack>
|
||||
</SimpleModal>
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Tutorial } from './Tutorial';
|
||||
|
||||
export default {
|
||||
title: 'Buy/Tutorial',
|
||||
component: Tutorial,
|
||||
};
|
||||
|
||||
export const TutorialPage = () => <Tutorial />;
|
||||
@@ -1,5 +1,8 @@
|
||||
import React from 'react';
|
||||
import { Box, Typography, Grid, Link, Card, CardContent, Stack } from '@mui/material';
|
||||
import { Box, Typography, Grid, Card, CardContent, Stack, Button } from '@mui/material';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import { safeOpenUrl } from 'src/utils/safeOpenUrl';
|
||||
import BitfinexIcon from 'src/svg-icons/bitfinex.svg';
|
||||
import KrakenIcon from 'src/svg-icons/kraken.svg';
|
||||
import BybitIcon from 'src/svg-icons/bybit.svg';
|
||||
@@ -12,11 +15,13 @@ const ExchangeCard = ({
|
||||
tokenType,
|
||||
url,
|
||||
IconComponent,
|
||||
onOpenExchange,
|
||||
}: {
|
||||
name: string;
|
||||
tokenType: string;
|
||||
url: string;
|
||||
IconComponent: React.FunctionComponent<React.SVGProps<SVGSVGElement>>;
|
||||
onOpenExchange: (name: string, url: string) => void;
|
||||
}) => (
|
||||
<Card
|
||||
variant="outlined"
|
||||
@@ -51,21 +56,26 @@ const ExchangeCard = ({
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{tokenType}
|
||||
</Typography>
|
||||
<Link
|
||||
href={url}
|
||||
target="_blank"
|
||||
variant="body2"
|
||||
<Button
|
||||
variant="text"
|
||||
data-testid="link-get-nym"
|
||||
onClick={() => onOpenExchange(name, url)}
|
||||
sx={{
|
||||
alignSelf: 'flex-start',
|
||||
p: 0,
|
||||
minWidth: 0,
|
||||
textTransform: 'none',
|
||||
textDecoration: 'underline',
|
||||
fontWeight: 500,
|
||||
fontSize: '0.875rem',
|
||||
'&:hover': {
|
||||
textDecoration: 'none',
|
||||
background: 'transparent',
|
||||
},
|
||||
}}
|
||||
>
|
||||
GET NYM
|
||||
</Link>
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
@@ -73,6 +83,22 @@ const ExchangeCard = ({
|
||||
);
|
||||
|
||||
export const Tutorial = () => {
|
||||
const theme = useTheme();
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
|
||||
const openExchange = async (name: string, url: string) => {
|
||||
try {
|
||||
enqueueSnackbar(`Opening ${name} in your default browser - always verify the URL in the address bar.`, {
|
||||
variant: 'info',
|
||||
});
|
||||
await safeOpenUrl(url);
|
||||
} catch (e) {
|
||||
enqueueSnackbar('Could not open the link. Copy the URL from the exchange website instead.', {
|
||||
variant: 'error',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const exchanges = [
|
||||
{
|
||||
name: 'Bitfinex',
|
||||
@@ -107,7 +133,15 @@ export const Tutorial = () => {
|
||||
];
|
||||
|
||||
return (
|
||||
<NymCard borderless title="Where you can get NYM tokens" sx={{ mt: 4 }}>
|
||||
<NymCard
|
||||
borderless
|
||||
title="Where you can get NYM tokens"
|
||||
sx={{
|
||||
backgroundColor: 'background.paper',
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
boxShadow: theme.palette.nym.nymWallet.shadows.light,
|
||||
}}
|
||||
>
|
||||
<Typography mb={3} fontSize={14} sx={{ color: 'text.secondary' }}>
|
||||
You can get NYM tokens from these exchanges
|
||||
</Typography>
|
||||
@@ -115,7 +149,7 @@ export const Tutorial = () => {
|
||||
<Grid container spacing={3}>
|
||||
{exchanges.map((exchange) => (
|
||||
<Grid item xs={12} md={6} lg={4} key={exchange.name}>
|
||||
<ExchangeCard {...exchange} />
|
||||
<ExchangeCard {...exchange} onOpenExchange={openExchange} />
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
@@ -71,6 +71,7 @@ export const ClipboardActions = ({
|
||||
if (!fieldRef) return;
|
||||
|
||||
const keydownHandler = async (e: KeyboardEvent) => {
|
||||
if (e.defaultPrevented) return;
|
||||
// Only handle if the associated field is focused
|
||||
const { activeElement } = document;
|
||||
if (fieldRef.current && activeElement === fieldRef.current) {
|
||||
|
||||
@@ -6,6 +6,11 @@ import { UseFormRegister, UseFormSetValue, FieldValues, Path, FieldErrors } from
|
||||
import { writeText, readText } from '@tauri-apps/plugin-clipboard-manager';
|
||||
import { PasteFromClipboard } from './ClipboardActions';
|
||||
|
||||
/**
|
||||
* Keyboard clipboard helpers for fields that opt in via this module.
|
||||
* App-wide Tauri paste/copy for normal inputs lives in `useTauriTextEditingClipboard` (see
|
||||
* `data-nym-paste-replace`, `data-nym-currency-field`, `data-nym-auth-paste-field` exclusions).
|
||||
*/
|
||||
export const useCopyAllSupport = (
|
||||
inputRef: React.MutableRefObject<HTMLInputElement | HTMLTextAreaElement | null>,
|
||||
onPasteValue?: (value: string) => void,
|
||||
@@ -15,6 +20,7 @@ export const useCopyAllSupport = (
|
||||
|
||||
const handleKeyDown = async (e: Event) => {
|
||||
const keyEvent = e as KeyboardEvent;
|
||||
if (keyEvent.defaultPrevented) return;
|
||||
|
||||
if (document.activeElement !== inputRef.current) return;
|
||||
|
||||
@@ -71,46 +77,47 @@ export const useCopyAllSupport = (
|
||||
}, [inputRef.current, onPasteValue]);
|
||||
};
|
||||
|
||||
export const TextFieldWithPaste = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
TextFieldProps & {
|
||||
onPasteValue?: (value: string) => void;
|
||||
}
|
||||
>(({ onPasteValue, ...props }, ref) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
export type TextFieldWithPasteProps = TextFieldProps & {
|
||||
// eslint-disable-next-line react/require-default-props -- optional on `forwardRef` + intersection props; see `onPasteValue = undefined` in render
|
||||
onPasteValue?: (value: string) => void;
|
||||
};
|
||||
|
||||
useCopyAllSupport(inputRef, onPasteValue);
|
||||
export const TextFieldWithPaste = React.forwardRef<HTMLDivElement, TextFieldWithPasteProps>(
|
||||
({ onPasteValue = undefined, inputProps: userInputProps, InputProps: userInputPropsMui, ...props }, ref) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handlePaste = (pastedText: string) => {
|
||||
onPasteValue?.(pastedText);
|
||||
useCopyAllSupport(inputRef, onPasteValue);
|
||||
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
};
|
||||
const handlePaste = (pastedText: string) => {
|
||||
onPasteValue?.(pastedText);
|
||||
|
||||
return (
|
||||
<TextField
|
||||
{...props}
|
||||
ref={ref}
|
||||
inputRef={inputRef}
|
||||
InputProps={{
|
||||
...props.InputProps,
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const fieldProps = {
|
||||
...props,
|
||||
ref,
|
||||
inputRef,
|
||||
inputProps: {
|
||||
...userInputProps,
|
||||
'data-nym-paste-replace': 'true',
|
||||
},
|
||||
InputProps: {
|
||||
...userInputPropsMui,
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
{onPasteValue && <PasteFromClipboard onPaste={handlePaste} fieldRef={inputRef} />}
|
||||
{props.InputProps?.endAdornment}
|
||||
{userInputPropsMui?.endAdornment}
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
// Add defaultProps to fix the "require-default-props" warning
|
||||
TextFieldWithPaste.defaultProps = {
|
||||
onPasteValue: undefined,
|
||||
};
|
||||
return <TextField {...(fieldProps as TextFieldProps)} />;
|
||||
},
|
||||
);
|
||||
|
||||
export const CurrencyFormFieldWithPaste = ({
|
||||
label,
|
||||
@@ -167,6 +174,7 @@ export const CurrencyFormFieldWithPaste = ({
|
||||
|
||||
const handleKeyDown = async (e: Event) => {
|
||||
const keyEvent = e as KeyboardEvent;
|
||||
if (keyEvent.defaultPrevented) return;
|
||||
if (document.activeElement !== inputRef.current) return;
|
||||
|
||||
// Handle Cmd+A (Select All)
|
||||
@@ -236,7 +244,7 @@ export const CurrencyFormFieldWithPaste = ({
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Box position="relative" width="100%" ref={fieldRef}>
|
||||
<Box position="relative" width="100%" ref={fieldRef} data-nym-currency-field>
|
||||
<CurrencyFormField
|
||||
label={label}
|
||||
fullWidth={fullWidth}
|
||||
@@ -274,6 +282,8 @@ export const HookFormTextFieldWithPaste = <TFieldValues extends FieldValues>({
|
||||
register,
|
||||
setValue,
|
||||
errors,
|
||||
inputProps: userInputProps,
|
||||
InputProps: userInputPropsMui,
|
||||
...props
|
||||
}: {
|
||||
name: Path<TFieldValues>;
|
||||
@@ -295,25 +305,29 @@ export const HookFormTextFieldWithPaste = <TFieldValues extends FieldValues>({
|
||||
// Pass handlePaste to useCopyAllSupport for Cmd+V handling
|
||||
useCopyAllSupport(inputRef, handlePaste);
|
||||
|
||||
return (
|
||||
<TextField
|
||||
{...register(name)}
|
||||
name={name}
|
||||
label={label}
|
||||
error={Boolean(errors[name])}
|
||||
helperText={errors[name]?.message?.toString()}
|
||||
inputRef={inputRef}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<PasteFromClipboard onPaste={handlePaste} fieldRef={inputRef} />
|
||||
</InputAdornment>
|
||||
),
|
||||
...props.InputProps,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
const fieldProps = {
|
||||
...register(name),
|
||||
...props,
|
||||
label,
|
||||
error: Boolean(errors[name]),
|
||||
helperText: errors[name]?.message?.toString(),
|
||||
inputRef,
|
||||
inputProps: {
|
||||
...userInputProps,
|
||||
'data-nym-paste-replace': 'true',
|
||||
},
|
||||
InputProps: {
|
||||
...userInputPropsMui,
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<PasteFromClipboard onPaste={handlePaste} fieldRef={inputRef} />
|
||||
{userInputPropsMui?.endAdornment}
|
||||
</InputAdornment>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
return <TextField {...(fieldProps as TextFieldProps)} />;
|
||||
};
|
||||
|
||||
export const HookFormCurrencyFieldWithPaste = <TFieldValues extends FieldValues>({
|
||||
@@ -361,6 +375,7 @@ export const HookFormCurrencyFieldWithPaste = <TFieldValues extends FieldValues>
|
||||
|
||||
const handleKeyDown = async (e: Event) => {
|
||||
const keyEvent = e as KeyboardEvent;
|
||||
if (keyEvent.defaultPrevented) return;
|
||||
if (document.activeElement !== inputRef.current) return;
|
||||
|
||||
// Handle Cmd+A (Select All)
|
||||
@@ -435,7 +450,7 @@ export const HookFormCurrencyFieldWithPaste = <TFieldValues extends FieldValues>
|
||||
};
|
||||
|
||||
return (
|
||||
<Box position="relative" width="100%" ref={fieldRef}>
|
||||
<Box position="relative" width="100%" ref={fieldRef} data-nym-currency-field>
|
||||
<CurrencyFormField
|
||||
label={label}
|
||||
fullWidth
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import { ComponentMeta, ComponentStory } from '@storybook/react';
|
||||
import { ConfirmTx } from './ConfirmTX';
|
||||
import { ModalListItem } from './Modals/ModalListItem';
|
||||
|
||||
export default {
|
||||
title: 'Wallet / Confirm Transaction',
|
||||
component: ConfirmTx,
|
||||
} as ComponentMeta<typeof ConfirmTx>;
|
||||
|
||||
const Template: ComponentStory<typeof ConfirmTx> = (args) => (
|
||||
<ConfirmTx {...args}>
|
||||
<ModalListItem label="Transaction type" value="Bond" divider />
|
||||
<ModalListItem label="Current bond" value="100 NYM" divider />
|
||||
<ModalListItem label="Additional bond" value="50 NYM" divider />
|
||||
</ConfirmTx>
|
||||
);
|
||||
|
||||
export const Default = Template.bind({});
|
||||
Default.args = {
|
||||
open: true,
|
||||
header: 'Confirm transaction',
|
||||
subheader: 'Confirm and proceed or cancel transaction',
|
||||
fee: { amount: { amount: '0.001', denom: 'nym' }, fee: { Auto: null } },
|
||||
onClose: () => {},
|
||||
onConfirm: async () => {},
|
||||
onPrev: () => {},
|
||||
isStorybook: true,
|
||||
};
|
||||
@@ -1,19 +1,9 @@
|
||||
import React from 'react';
|
||||
import { FeeDetails } from '@nymproject/types';
|
||||
import { Box } from '@mui/material';
|
||||
import { useTheme, Theme } from '@mui/material/styles';
|
||||
import { SimpleModal } from './Modals/SimpleModal';
|
||||
import { ModalFee } from './Modals/ModalFee';
|
||||
import { ModalDivider } from './Modals/ModalDivider';
|
||||
import { backDropStyles, modalStyles } from '../../.storybook/storiesStyles';
|
||||
|
||||
const storybookStyles = (theme: Theme, isStorybook?: boolean, backdropProps?: object) =>
|
||||
isStorybook
|
||||
? {
|
||||
backdropProps: { ...backDropStyles(theme), ...backdropProps },
|
||||
sx: modalStyles(theme),
|
||||
}
|
||||
: {};
|
||||
|
||||
export const ConfirmTx: FCWithChildren<{
|
||||
open: boolean;
|
||||
@@ -23,26 +13,21 @@ export const ConfirmTx: FCWithChildren<{
|
||||
onConfirm: () => Promise<void>;
|
||||
onClose?: () => void;
|
||||
onPrev: () => void;
|
||||
isStorybook?: boolean;
|
||||
children?: React.ReactNode;
|
||||
}> = ({ open, fee, onConfirm, onClose, header, subheader, onPrev, children, isStorybook }) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<SimpleModal
|
||||
open={open}
|
||||
header={header}
|
||||
subHeader={subheader}
|
||||
okLabel="Confirm"
|
||||
onOk={onConfirm}
|
||||
onClose={onClose}
|
||||
onBack={onPrev}
|
||||
{...storybookStyles(theme, isStorybook)}
|
||||
>
|
||||
<Box sx={{ mt: 3 }}>
|
||||
{children}
|
||||
<ModalFee fee={fee} isLoading={false} />
|
||||
<ModalDivider />
|
||||
</Box>
|
||||
</SimpleModal>
|
||||
);
|
||||
};
|
||||
}> = ({ open, fee, onConfirm, onClose, header, subheader, onPrev, children }) => (
|
||||
<SimpleModal
|
||||
open={open}
|
||||
header={header}
|
||||
subHeader={subheader}
|
||||
okLabel="Confirm"
|
||||
onOk={onConfirm}
|
||||
onClose={onClose}
|
||||
onBack={onPrev}
|
||||
>
|
||||
<Box sx={{ mt: 3 }}>
|
||||
{children}
|
||||
<ModalFee fee={fee} isLoading={false} />
|
||||
<ModalDivider />
|
||||
</Box>
|
||||
</SimpleModal>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import { Box } from '@mui/material';
|
||||
import { Box, InputAdornment } from '@mui/material';
|
||||
import { CurrencyFormField } from '@nymproject/react/currency/CurrencyFormField';
|
||||
import { CurrencyDenom, DecCoin } from '@nymproject/types';
|
||||
import { PasteFromClipboard } from './Clipboard/ClipboardActions';
|
||||
@@ -13,6 +13,8 @@ export const CurrencyFormFieldWithPaste = ({
|
||||
required,
|
||||
autoFocus,
|
||||
validationError,
|
||||
endAdornment,
|
||||
showPaste = true,
|
||||
}: {
|
||||
label: string;
|
||||
fullWidth?: boolean;
|
||||
@@ -22,6 +24,10 @@ export const CurrencyFormFieldWithPaste = ({
|
||||
required?: boolean;
|
||||
autoFocus?: boolean;
|
||||
validationError?: string;
|
||||
/** Rendered inside the outlined input (e.g. Max). */
|
||||
endAdornment?: React.ReactNode;
|
||||
/** When false, no paste control; native keyboard paste still works on the input. */
|
||||
showPaste?: boolean;
|
||||
}) => {
|
||||
const fieldRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
@@ -58,6 +64,10 @@ export const CurrencyFormFieldWithPaste = ({
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!showPaste) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const pasteEventHandler = (e: ClipboardEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -91,10 +101,15 @@ export const CurrencyFormFieldWithPaste = ({
|
||||
inputRef.current.removeEventListener('paste', pasteEventHandler as EventListener);
|
||||
}
|
||||
};
|
||||
}, [denom, onChanged]);
|
||||
}, [showPaste, denom, onChanged]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showPaste) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const handleKeyDown = async (e: KeyboardEvent) => {
|
||||
if (e.defaultPrevented) return;
|
||||
if (inputRef.current && document.activeElement === inputRef.current) {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'v') {
|
||||
e.preventDefault();
|
||||
@@ -117,10 +132,10 @@ export const CurrencyFormFieldWithPaste = ({
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [denom, onChanged]);
|
||||
}, [showPaste, denom, onChanged]);
|
||||
|
||||
return (
|
||||
<Box position="relative" width="100%" ref={fieldRef}>
|
||||
<Box position="relative" width="100%" ref={fieldRef} data-nym-currency-field>
|
||||
<CurrencyFormField
|
||||
label={label}
|
||||
fullWidth={fullWidth}
|
||||
@@ -130,18 +145,27 @@ export const CurrencyFormFieldWithPaste = ({
|
||||
required={required}
|
||||
autoFocus={autoFocus}
|
||||
validationError={validationError}
|
||||
mergedInputProps={
|
||||
endAdornment
|
||||
? {
|
||||
endAdornment: <InputAdornment position="end">{endAdornment}</InputAdornment>,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
right: '14px',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
<PasteFromClipboard onPaste={processPastedText} fieldRef={inputRef} />
|
||||
</Box>
|
||||
{showPaste && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
right: endAdornment ? 88 : 14,
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
<PasteFromClipboard onPaste={processPastedText} fieldRef={inputRef} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import React from 'react';
|
||||
import { ComponentMeta } from '@storybook/react';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import { Button, Paper, Typography } from '@mui/material';
|
||||
import { backDropStyles, modalStyles } from '../../../.storybook/storiesStyles';
|
||||
|
||||
import { OverSaturatedBlockerModal } from './DelegateBlocker';
|
||||
|
||||
export default {
|
||||
title: 'Delegation/Components/Delegation Over Saturated Warning Modal',
|
||||
component: OverSaturatedBlockerModal,
|
||||
} as ComponentMeta<typeof OverSaturatedBlockerModal>;
|
||||
|
||||
export const Default = () => {
|
||||
const [open, setOpen] = React.useState<boolean>(false);
|
||||
const handleClick = () => setOpen(true);
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<>
|
||||
<Paper elevation={0} sx={{ px: 4, pt: 2, pb: 4 }}>
|
||||
<h2>Lorem ipsum</h2>
|
||||
<Button variant="contained" onClick={handleClick} sx={{ mb: 3 }}>
|
||||
Show modal
|
||||
</Button>
|
||||
<Typography>
|
||||
Veniam dolor laborum labore sit reprehenderit enim mollit magna nulla adipisicing fugiat. Est ex irure quis
|
||||
sunt velit elit do minim mollit non duis reprehenderit. Eiusmod dolore adipisicing ex nostrud consectetur
|
||||
culpa exercitation do. Ad elit esse ipsum aliqua labore irure laborum qui culpa.
|
||||
</Typography>
|
||||
</Paper>
|
||||
<OverSaturatedBlockerModal
|
||||
open={open}
|
||||
header="Node saturation: 114%"
|
||||
onClose={() => setOpen(false)}
|
||||
backdropProps={backDropStyles(theme)}
|
||||
sx={modalStyles(theme)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -219,7 +219,7 @@ export const DelegateModal: FCWithChildren<{
|
||||
<BalanceWarning fee={fee?.amount?.amount} tx={amount} />
|
||||
</Box>
|
||||
)}
|
||||
<ModalListItem label="Node identity key" value={identityKey} divider />
|
||||
<ModalListItem label="Node identity key" value={identityKey} divider layout="stack" />
|
||||
<ModalListItem label="Amount" value={`${amount} ${denom.toUpperCase()}`} divider />
|
||||
</ConfirmTx>
|
||||
);
|
||||
@@ -249,10 +249,12 @@ export const DelegateModal: FCWithChildren<{
|
||||
header={header || 'Delegate'}
|
||||
okLabel={buttonText || 'Delegate stake'}
|
||||
okDisabled={!isValidated}
|
||||
dense
|
||||
accent="primary"
|
||||
sx={sx}
|
||||
backdropProps={backdropProps}
|
||||
>
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<Box sx={{ mt: 2.5 }}>
|
||||
<TextFieldWithPaste
|
||||
label="Node identity key"
|
||||
fullWidth
|
||||
@@ -264,7 +266,7 @@ export const DelegateModal: FCWithChildren<{
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
</Box>
|
||||
<Box display="flex" gap={2} alignItems="center" sx={{ mt: 3 }}>
|
||||
<Box display="flex" gap={2} alignItems="center" sx={{ mt: 2.5 }}>
|
||||
{hasVestingContract && <TokenPoolSelector disabled={false} onSelect={(pool) => setTokenPool(pool)} />}
|
||||
<CurrencyFormFieldWithPaste
|
||||
label="Amount"
|
||||
@@ -275,7 +277,7 @@ export const DelegateModal: FCWithChildren<{
|
||||
validationError={errorAmount}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<Box sx={{ mt: 2.5 }}>
|
||||
<ModalListItem label="Account balance" value={accountBalance?.toUpperCase()} divider fontWeight={600} />
|
||||
</Box>
|
||||
|
||||
@@ -299,7 +301,11 @@ export const DelegateModal: FCWithChildren<{
|
||||
hidden
|
||||
divider
|
||||
/>
|
||||
<ModalListItem label="Est. fee for this transaction will be calculated in the next page" />
|
||||
<ModalListItem
|
||||
label="Next step"
|
||||
value="Fee for this transaction is calculated on the next page."
|
||||
layout="stack"
|
||||
/>
|
||||
</SimpleModal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import React from 'react';
|
||||
import { ComponentMeta } from '@storybook/react';
|
||||
|
||||
import { DelegationActions } from './DelegationActions';
|
||||
|
||||
export default {
|
||||
title: 'Delegation/Components/Delegation List Item Actions',
|
||||
component: DelegationActions,
|
||||
} as ComponentMeta<typeof DelegationActions>;
|
||||
|
||||
export const Default = () => <DelegationActions />;
|
||||
|
||||
export const RedeemingDisabled = () => <DelegationActions disableRedeemingRewards />;
|
||||
|
||||
export const PendingDelegation = () => <DelegationActions isPending={{ actionType: 'delegate', blockHeight: 1000 }} />;
|
||||
|
||||
export const PendingUndelegation = () => (
|
||||
<DelegationActions isPending={{ actionType: 'undelegate', blockHeight: 1000 }} />
|
||||
);
|
||||
@@ -1,135 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Box, Chip, IconButton, TableCell, TableRow, Tooltip, Typography } from '@mui/material';
|
||||
import { TauriLink as Link } from 'src/components/TauriLinkWrapper';
|
||||
import { decimalToPercentage, DelegationWithEverything } from '@nymproject/types';
|
||||
import { LockOutlined, WarningAmberOutlined } from '@mui/icons-material';
|
||||
import { isDelegation, useDelegationContext } from 'src/context/delegations';
|
||||
import { toPercentIntegerString } from 'src/utils';
|
||||
import { format } from 'date-fns';
|
||||
import { Undelegate } from 'src/svg-icons';
|
||||
import { DelegationListItemActions, DelegationsActionsMenu } from './DelegationActions';
|
||||
|
||||
const getStakeSaturation = (item: DelegationWithEverything) =>
|
||||
!item.stake_saturation ? '-' : `${decimalToPercentage(item.stake_saturation)}%`;
|
||||
|
||||
const getRewardValue = (item: DelegationWithEverything) => {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
const { unclaimed_rewards } = item;
|
||||
return !unclaimed_rewards ? '-' : `${unclaimed_rewards.amount} ${unclaimed_rewards.denom}`;
|
||||
};
|
||||
|
||||
export const DelegationItem = ({
|
||||
item,
|
||||
explorerUrl,
|
||||
nodeIsUnbonded,
|
||||
onItemActionClick,
|
||||
}: {
|
||||
item: DelegationWithEverything;
|
||||
explorerUrl: string;
|
||||
nodeIsUnbonded: boolean;
|
||||
onItemActionClick?: (item: DelegationWithEverything, action: DelegationListItemActions) => void;
|
||||
}) => {
|
||||
const { setDelegationItemErrors } = useDelegationContext();
|
||||
|
||||
const operatingCost = isDelegation(item) && item.cost_params?.interval_operating_cost;
|
||||
|
||||
const tooltipText = () => {
|
||||
if (nodeIsUnbonded) {
|
||||
return 'This node has unbonded and it does not exist anymore. You need to undelegate from it to get your stake and outstanding rewards (if any) back.';
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip arrow title={tooltipText()}>
|
||||
<TableRow key={item.node_identity} sx={{ color: !item.node_identity ? 'error.main' : 'inherit' }}>
|
||||
<TableCell sx={{ color: 'inherit', pr: 1 }} padding="normal">
|
||||
{nodeIsUnbonded ? (
|
||||
'-'
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
{item.errors && (
|
||||
<Tooltip title="Open to view a list of errors that occurred">
|
||||
<IconButton
|
||||
sx={{ mr: 1 }}
|
||||
size="small"
|
||||
onClick={() => setDelegationItemErrors({ nodeId: item.node_identity, errors: item.errors! })}
|
||||
>
|
||||
<WarningAmberOutlined color="warning" fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Link
|
||||
target="_blank"
|
||||
href={`${explorerUrl}/nodes/${item.mix_id}`}
|
||||
text={`${item.node_identity.slice(0, 6)}...${item.node_identity.slice(-6)}`}
|
||||
color="text.primary"
|
||||
noIcon
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell sx={{ color: 'inherit' }}>
|
||||
{isDelegation(item) && (!item.avg_uptime_percent ? '-' : `${item.avg_uptime_percent}%`)}
|
||||
</TableCell>
|
||||
<TableCell sx={{ color: 'inherit' }}>
|
||||
{isDelegation(item) &&
|
||||
(!item.cost_params?.profit_margin_percent
|
||||
? '-'
|
||||
: `${toPercentIntegerString(item.cost_params.profit_margin_percent)}%`)}
|
||||
</TableCell>
|
||||
<TableCell sx={{ color: 'inherit' }}>
|
||||
<Typography style={{ textTransform: 'uppercase', fontSize: 'inherit' }}>
|
||||
{operatingCost ? `${operatingCost.amount} ${operatingCost.denom}` : '-'}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell sx={{ color: 'inherit' }}>{getStakeSaturation(item)}</TableCell>
|
||||
<TableCell sx={{ color: 'inherit' }}>
|
||||
{item.delegated_on_iso_datetime && format(new Date(item.delegated_on_iso_datetime), 'dd/MM/yyyy')}
|
||||
</TableCell>
|
||||
<TableCell sx={{ color: 'inherit' }}>
|
||||
<Typography style={{ textTransform: 'uppercase', fontSize: 'inherit' }}>
|
||||
{isDelegation(item) ? `${item.amount.amount} ${item.amount.denom}` : '-'}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell sx={{ textTransform: 'uppercase', color: 'inherit' }}>{getRewardValue(item)}</TableCell>
|
||||
<TableCell>
|
||||
{item.uses_vesting_contract_tokens && (
|
||||
<Tooltip title="Delegation uses locked tokens">
|
||||
<LockOutlined sx={{ color: 'grey.800' }} fontSize="small" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="center" sx={{ color: 'inherit' }}>
|
||||
{!item.pending_events.length && !nodeIsUnbonded && (
|
||||
<DelegationsActionsMenu
|
||||
onActionClick={(action) => (onItemActionClick ? onItemActionClick(item, action) : undefined)}
|
||||
disableRedeemingRewards={!item.unclaimed_rewards || item.unclaimed_rewards.amount === '0'}
|
||||
disableDelegateMore={item.mixnode_is_unbonding}
|
||||
/>
|
||||
)}
|
||||
{!item.pending_events.length && nodeIsUnbonded && (
|
||||
<IconButton sx={{ color: (t) => t.palette.nym.nymWallet.text.main }}>
|
||||
<Undelegate onClick={() => (onItemActionClick ? onItemActionClick(item, 'undelegate') : undefined)} />
|
||||
</IconButton>
|
||||
)}
|
||||
{item.pending_events.length > 0 && (
|
||||
<Tooltip
|
||||
title="Your changes will take effect when the new epoch starts. There is a new epoch every hour."
|
||||
arrow
|
||||
componentsProps={{
|
||||
tooltip: {
|
||||
sx: {
|
||||
textAlign: 'left',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Chip label="Pending events" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@@ -1,311 +0,0 @@
|
||||
import React from 'react';
|
||||
import { ComponentMeta } from '@storybook/react';
|
||||
|
||||
import { DelegationWithEverything } from '@nymproject/types';
|
||||
import { DelegationList } from './DelegationList';
|
||||
|
||||
export default {
|
||||
title: 'Delegation/Components/Delegation List',
|
||||
component: DelegationList,
|
||||
} as ComponentMeta<typeof DelegationList>;
|
||||
|
||||
const explorerUrl = 'https://sandbox-explorer.nymtech.net/network-components/mixnodes';
|
||||
|
||||
export const items: DelegationWithEverything[] = [
|
||||
{
|
||||
mix_id: 1,
|
||||
node_identity: 'FiojKW7oY9WQmLCiYAsCA21tpowZHS6zcUoyYm319p6Z',
|
||||
delegated_on_iso_datetime: new Date(2021, 1, 1).toDateString(),
|
||||
unclaimed_rewards: { amount: '0.05', denom: 'nym' },
|
||||
amount: { amount: '10', denom: 'nym' },
|
||||
cost_params: {
|
||||
profit_margin_percent: '0.1122323949234',
|
||||
interval_operating_cost: {
|
||||
amount: '40',
|
||||
denom: 'nym',
|
||||
},
|
||||
},
|
||||
accumulated_by_delegates: { amount: '50', denom: 'nym' },
|
||||
accumulated_by_operator: { amount: '100', denom: 'nym' },
|
||||
owner: '',
|
||||
block_height: BigInt(100),
|
||||
stake_saturation: '0.25',
|
||||
avg_uptime_percent: 0.5,
|
||||
uses_vesting_contract_tokens: false,
|
||||
pending_events: [],
|
||||
mixnode_is_unbonding: true,
|
||||
errors: null,
|
||||
},
|
||||
{
|
||||
mix_id: 2,
|
||||
node_identity: 'DT8S942S8AQs2zKHS9SVo1GyHmuca3pfL2uLhLksJ3D8',
|
||||
amount: { amount: '1010', denom: 'nym' },
|
||||
delegated_on_iso_datetime: new Date(2021, 1, 2).toDateString(),
|
||||
unclaimed_rewards: { amount: '0.05', denom: 'nym' },
|
||||
cost_params: {
|
||||
profit_margin_percent: '0.1122323949234',
|
||||
interval_operating_cost: {
|
||||
amount: '40',
|
||||
denom: 'nym',
|
||||
},
|
||||
},
|
||||
accumulated_by_delegates: { amount: '50', denom: 'nym' },
|
||||
accumulated_by_operator: { amount: '200', denom: 'nym' },
|
||||
owner: '',
|
||||
block_height: BigInt(4000),
|
||||
stake_saturation: '0.43',
|
||||
avg_uptime_percent: 0.22,
|
||||
uses_vesting_contract_tokens: true,
|
||||
pending_events: [],
|
||||
mixnode_is_unbonding: true,
|
||||
errors: null,
|
||||
},
|
||||
{
|
||||
mix_id: 3,
|
||||
node_identity: '',
|
||||
amount: { amount: '300', denom: 'nym' },
|
||||
delegated_on_iso_datetime: new Date(2021, 1, 2).toDateString(),
|
||||
unclaimed_rewards: { amount: '0.05', denom: 'nym' },
|
||||
cost_params: {
|
||||
profit_margin_percent: '0.1122323949234',
|
||||
interval_operating_cost: {
|
||||
amount: '50',
|
||||
denom: 'nym',
|
||||
},
|
||||
},
|
||||
accumulated_by_delegates: { amount: '50', denom: 'nym' },
|
||||
accumulated_by_operator: { amount: '300', denom: 'nym' },
|
||||
owner: '',
|
||||
block_height: BigInt(4000),
|
||||
stake_saturation: '0.5',
|
||||
avg_uptime_percent: 0.1,
|
||||
uses_vesting_contract_tokens: true,
|
||||
pending_events: [],
|
||||
mixnode_is_unbonding: true,
|
||||
errors: null,
|
||||
},
|
||||
{
|
||||
mix_id: 4,
|
||||
node_identity: 'DT8S942S8AQs2zKHS9SVo1GyHmuca3pfL2uLhLksJ3D8',
|
||||
amount: { amount: '201', denom: 'nym' },
|
||||
delegated_on_iso_datetime: new Date(2021, 1, 2).toDateString(),
|
||||
unclaimed_rewards: { amount: '0.05', denom: 'nym' },
|
||||
cost_params: {
|
||||
profit_margin_percent: '0.1122323949234',
|
||||
interval_operating_cost: {
|
||||
amount: '60',
|
||||
denom: 'nym',
|
||||
},
|
||||
},
|
||||
accumulated_by_delegates: { amount: '50', denom: 'nym' },
|
||||
accumulated_by_operator: { amount: '202', denom: 'nym' },
|
||||
owner: '',
|
||||
block_height: BigInt(4000),
|
||||
stake_saturation: '0.5',
|
||||
avg_uptime_percent: 0.1,
|
||||
uses_vesting_contract_tokens: true,
|
||||
pending_events: [],
|
||||
mixnode_is_unbonding: true,
|
||||
errors: null,
|
||||
},
|
||||
{
|
||||
mix_id: 5,
|
||||
node_identity: 'DT8S942S8AQs2zKHS9SVo1GyHmuca3pfL2uLhLksJ3D8',
|
||||
amount: { amount: '100', denom: 'nym' },
|
||||
delegated_on_iso_datetime: new Date(2021, 1, 2).toDateString(),
|
||||
unclaimed_rewards: { amount: '0.05', denom: 'nym' },
|
||||
cost_params: {
|
||||
profit_margin_percent: '0.1122323949234',
|
||||
interval_operating_cost: {
|
||||
amount: '80',
|
||||
denom: 'nym',
|
||||
},
|
||||
},
|
||||
accumulated_by_delegates: { amount: '50', denom: 'nym' },
|
||||
accumulated_by_operator: { amount: '100', denom: 'nym' },
|
||||
owner: '',
|
||||
block_height: BigInt(4000),
|
||||
stake_saturation: '0.5',
|
||||
avg_uptime_percent: 0.1,
|
||||
uses_vesting_contract_tokens: true,
|
||||
pending_events: [],
|
||||
mixnode_is_unbonding: true,
|
||||
errors: null,
|
||||
},
|
||||
{
|
||||
mix_id: 6,
|
||||
node_identity: '',
|
||||
amount: { amount: '202', denom: 'nym' },
|
||||
delegated_on_iso_datetime: new Date(2021, 1, 2).toDateString(),
|
||||
unclaimed_rewards: { amount: '0.05', denom: 'nym' },
|
||||
cost_params: {
|
||||
profit_margin_percent: '0.8',
|
||||
interval_operating_cost: {
|
||||
amount: '40',
|
||||
denom: 'nym',
|
||||
},
|
||||
},
|
||||
accumulated_by_delegates: { amount: '50', denom: 'nym' },
|
||||
accumulated_by_operator: { amount: '100', denom: 'nym' },
|
||||
owner: '',
|
||||
block_height: BigInt(4000),
|
||||
stake_saturation: '0.5',
|
||||
avg_uptime_percent: 0.1,
|
||||
uses_vesting_contract_tokens: true,
|
||||
pending_events: [],
|
||||
mixnode_is_unbonding: true,
|
||||
errors: null,
|
||||
},
|
||||
{
|
||||
mix_id: 7,
|
||||
node_identity: 'FiojKW7oY9WQmLCiYAsCA21tpowZHS6zcUoyYm319p6Z',
|
||||
delegated_on_iso_datetime: new Date(2021, 1, 1).toDateString(),
|
||||
unclaimed_rewards: { amount: '0.05', denom: 'nym' },
|
||||
amount: { amount: '202', denom: 'nym' },
|
||||
cost_params: {
|
||||
profit_margin_percent: '0.59',
|
||||
interval_operating_cost: {
|
||||
amount: '40',
|
||||
denom: 'nym',
|
||||
},
|
||||
},
|
||||
accumulated_by_delegates: { amount: '50', denom: 'nym' },
|
||||
accumulated_by_operator: { amount: '100', denom: 'nym' },
|
||||
owner: '',
|
||||
block_height: BigInt(100),
|
||||
stake_saturation: '0.5',
|
||||
avg_uptime_percent: 0.5,
|
||||
uses_vesting_contract_tokens: false,
|
||||
pending_events: [],
|
||||
mixnode_is_unbonding: true,
|
||||
errors: null,
|
||||
},
|
||||
{
|
||||
mix_id: 8,
|
||||
node_identity: 'DT8S942S8AQs2zKHS9SVo1GyHmuca3pfL2uLhLksJ3D8',
|
||||
amount: { amount: '100', denom: 'nym' },
|
||||
delegated_on_iso_datetime: new Date(2021, 1, 2).toDateString(),
|
||||
unclaimed_rewards: { amount: '0.05', denom: 'nym' },
|
||||
cost_params: {
|
||||
profit_margin_percent: '0.1122323949234',
|
||||
interval_operating_cost: {
|
||||
amount: '40',
|
||||
denom: 'nym',
|
||||
},
|
||||
},
|
||||
accumulated_by_delegates: { amount: '50', denom: 'nym' },
|
||||
accumulated_by_operator: { amount: '100', denom: 'nym' },
|
||||
owner: '',
|
||||
block_height: BigInt(4000),
|
||||
stake_saturation: '0.9',
|
||||
avg_uptime_percent: 0.1,
|
||||
uses_vesting_contract_tokens: true,
|
||||
pending_events: [],
|
||||
mixnode_is_unbonding: true,
|
||||
errors: null,
|
||||
},
|
||||
{
|
||||
mix_id: 9,
|
||||
node_identity: '',
|
||||
amount: { amount: '1000', denom: 'nym' },
|
||||
delegated_on_iso_datetime: new Date(2021, 1, 2).toDateString(),
|
||||
unclaimed_rewards: { amount: '0.05', denom: 'nym' },
|
||||
cost_params: {
|
||||
profit_margin_percent: '0.4',
|
||||
interval_operating_cost: {
|
||||
amount: '40',
|
||||
denom: 'nym',
|
||||
},
|
||||
},
|
||||
accumulated_by_delegates: { amount: '50', denom: 'nym' },
|
||||
accumulated_by_operator: { amount: '100', denom: 'nym' },
|
||||
owner: '',
|
||||
block_height: BigInt(4000),
|
||||
stake_saturation: '0.9',
|
||||
avg_uptime_percent: 0.1,
|
||||
uses_vesting_contract_tokens: true,
|
||||
pending_events: [],
|
||||
mixnode_is_unbonding: true,
|
||||
errors: null,
|
||||
},
|
||||
{
|
||||
mix_id: 10,
|
||||
node_identity: 'DT8S942S8AQs2zKHS9SVo1GyHmuca3pfL2uLhLksJ3D8',
|
||||
amount: { amount: '100', denom: 'nym' },
|
||||
delegated_on_iso_datetime: new Date(2021, 1, 2).toDateString(),
|
||||
unclaimed_rewards: { amount: '0.05', denom: 'nym' },
|
||||
cost_params: {
|
||||
profit_margin_percent: '0.1122323949234',
|
||||
interval_operating_cost: {
|
||||
amount: '40',
|
||||
denom: 'nym',
|
||||
},
|
||||
},
|
||||
accumulated_by_delegates: { amount: '50', denom: 'nym' },
|
||||
accumulated_by_operator: { amount: '100', denom: 'nym' },
|
||||
owner: '',
|
||||
block_height: BigInt(4000),
|
||||
stake_saturation: '0.5',
|
||||
avg_uptime_percent: 0.1,
|
||||
uses_vesting_contract_tokens: true,
|
||||
pending_events: [],
|
||||
mixnode_is_unbonding: true,
|
||||
errors: null,
|
||||
},
|
||||
{
|
||||
mix_id: 11,
|
||||
node_identity: 'DT8S942S8AQs2zKHS9SVo1GyHmuca3pfL2uLhLksJ3D8',
|
||||
amount: { amount: '100', denom: 'nym' },
|
||||
delegated_on_iso_datetime: new Date(2021, 1, 2).toDateString(),
|
||||
unclaimed_rewards: { amount: '0.05', denom: 'nym' },
|
||||
cost_params: {
|
||||
profit_margin_percent: '0.1122323949234',
|
||||
interval_operating_cost: {
|
||||
amount: '40',
|
||||
denom: 'nym',
|
||||
},
|
||||
},
|
||||
accumulated_by_delegates: { amount: '50', denom: 'nym' },
|
||||
accumulated_by_operator: { amount: '100', denom: 'nym' },
|
||||
owner: '',
|
||||
block_height: BigInt(4000),
|
||||
stake_saturation: '0.56',
|
||||
avg_uptime_percent: 0.9,
|
||||
uses_vesting_contract_tokens: true,
|
||||
pending_events: [],
|
||||
mixnode_is_unbonding: true,
|
||||
errors: null,
|
||||
},
|
||||
{
|
||||
mix_id: 12,
|
||||
node_identity: '',
|
||||
amount: { amount: '100', denom: 'nym' },
|
||||
delegated_on_iso_datetime: new Date(2021, 1, 2).toDateString(),
|
||||
unclaimed_rewards: { amount: '0.05', denom: 'nym' },
|
||||
cost_params: {
|
||||
profit_margin_percent: '0.1122323949234',
|
||||
interval_operating_cost: {
|
||||
amount: '40',
|
||||
denom: 'nym',
|
||||
},
|
||||
},
|
||||
accumulated_by_delegates: { amount: '50', denom: 'nym' },
|
||||
accumulated_by_operator: { amount: '100', denom: 'nym' },
|
||||
owner: '',
|
||||
block_height: BigInt(4000),
|
||||
stake_saturation: '0.5',
|
||||
avg_uptime_percent: 0.1,
|
||||
uses_vesting_contract_tokens: true,
|
||||
pending_events: [],
|
||||
mixnode_is_unbonding: true,
|
||||
errors: null,
|
||||
},
|
||||
];
|
||||
|
||||
export const WithData = () => <DelegationList items={items} explorerUrl={explorerUrl} />;
|
||||
|
||||
export const Empty = () => <DelegationList items={[]} explorerUrl={explorerUrl} />;
|
||||
|
||||
export const OneItem = () => <DelegationList items={[items[0]]} explorerUrl={explorerUrl} />;
|
||||
|
||||
export const Loading = () => <DelegationList items={[]} isLoading explorerUrl={explorerUrl} />;
|
||||
@@ -4,24 +4,36 @@ import {
|
||||
AlertTitle,
|
||||
Box,
|
||||
Button,
|
||||
Collapse,
|
||||
FormControl,
|
||||
IconButton,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Select,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableSortLabel,
|
||||
TextField,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { visuallyHidden } from '@mui/utils';
|
||||
import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown';
|
||||
import { DelegationWithEverything } from '@nymproject/types';
|
||||
import { alpha } from '@mui/material/styles';
|
||||
import { KeyboardArrowDown, KeyboardArrowUp, LockOutlined, WarningAmberOutlined } from '@mui/icons-material';
|
||||
import { decimalToFloatApproximation, decimalToPercentage, DelegationWithEverything } from '@nymproject/types';
|
||||
import { useSortDelegations } from 'src/hooks/useSortDelegations';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { DelegationListItemActions } from './DelegationActions';
|
||||
import { DelegationItem } from './DelegationItem';
|
||||
import { PendingDelegationItem } from './PendingDelegationItem';
|
||||
import { LoadingModal } from '../Modals/LoadingModal';
|
||||
import { TauriLink as Link } from 'src/components/TauriLinkWrapper';
|
||||
import { format } from 'date-fns';
|
||||
import { Undelegate } from 'src/svg-icons';
|
||||
import { toPercentIntegerString } from 'src/utils';
|
||||
import { InfoTooltip } from '../InfoToolTip';
|
||||
import { DelegationListItemActions, DelegationsActionsMenu } from './DelegationActions';
|
||||
import { PendingDelegationCard } from './PendingDelegationCard';
|
||||
import { isDelegation, isPendingDelegation, TDelegations, useDelegationContext } from '../../context/delegations';
|
||||
import { ErrorModal } from '../Modals/ErrorModal';
|
||||
|
||||
@@ -29,126 +41,37 @@ export type Order = 'asc' | 'desc';
|
||||
type AdditionalTypes = { profit_margin_percent: number; operating_cost: number };
|
||||
export type SortingKeys = keyof AdditionalTypes | keyof DelegationWithEverything;
|
||||
|
||||
// Helper function to check if a delegation item should be filtered
|
||||
const shouldBeFiltered = (item: any): boolean => {
|
||||
// For regular delegations, filter out placeholders
|
||||
if (isDelegation(item)) {
|
||||
// Check if node_identity is empty or just placeholders
|
||||
if (!item.node_identity || item.node_identity === '-' || item.node_identity === '...') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if uptime is a placeholder dash
|
||||
if (typeof item.avg_uptime_percent === 'string' && item.avg_uptime_percent === '-') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// For pending delegations, keep "Delegate" events but filter out "Undelegate" events with empty node_identity
|
||||
if (isPendingDelegation(item)) {
|
||||
// If it's an undelegate event with empty node_identity, filter it out
|
||||
if ((!item.node_identity || item.node_identity === '') && item.event && item.event.kind === 'Undelegate') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Keep all other pending events (including new delegation events)
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
interface EnhancedTableProps {
|
||||
onRequestSort: (event: React.MouseEvent<unknown>, property: string) => void;
|
||||
order: Order;
|
||||
orderBy: string;
|
||||
}
|
||||
|
||||
interface HeadCell {
|
||||
id: string;
|
||||
label: string | React.ReactNode;
|
||||
sortable: boolean;
|
||||
disablePadding?: boolean;
|
||||
align: 'left' | 'center' | 'right';
|
||||
width?: string;
|
||||
}
|
||||
|
||||
const headCells: HeadCell[] = [
|
||||
{ id: 'node_identity', label: 'Node ID', sortable: true, align: 'left', width: '15%' },
|
||||
{ id: 'avg_uptime_percent', label: 'Routing score', sortable: true, align: 'left', width: '10%' },
|
||||
{ id: 'profit_margin_percent', label: 'Profit margin', sortable: true, align: 'left', width: '10%' },
|
||||
{ id: 'operating_cost', label: 'Operating Cost', sortable: true, align: 'left', width: '12%' },
|
||||
{ id: 'stake_saturation', label: 'Stake saturation', sortable: true, align: 'left', width: '10%' },
|
||||
{
|
||||
id: 'delegated_on_iso_datetime',
|
||||
label: 'Delegated on',
|
||||
sortable: true,
|
||||
align: 'left',
|
||||
width: '10%',
|
||||
},
|
||||
{ id: 'amount', label: 'Delegation', sortable: true, align: 'left', width: '12%' },
|
||||
{ id: 'unclaimed_rewards', label: 'Reward', sortable: true, align: 'left', width: '10%' },
|
||||
{ id: 'uses_locked_tokens', label: '', sortable: false, align: 'left', width: '8%' },
|
||||
const SORT_FIELD_OPTIONS: { id: SortingKeys; label: string }[] = [
|
||||
{ id: 'delegated_on_iso_datetime', label: 'Delegated on' },
|
||||
{ id: 'node_identity', label: 'Node ID' },
|
||||
{ id: 'avg_uptime_percent', label: 'Routing score' },
|
||||
{ id: 'profit_margin_percent', label: 'Profit margin' },
|
||||
{ id: 'operating_cost', label: 'Operating cost' },
|
||||
{ id: 'stake_saturation', label: 'Stake saturation' },
|
||||
{ id: 'amount', label: 'Delegation' },
|
||||
{ id: 'unclaimed_rewards', label: 'Reward' },
|
||||
];
|
||||
|
||||
const EnhancedTableHead: FCWithChildren<EnhancedTableProps> = ({ order, orderBy, onRequestSort }) => {
|
||||
const createSortHandler = (property: string) => (event: React.MouseEvent<unknown>) => {
|
||||
onRequestSort(event, property);
|
||||
};
|
||||
|
||||
return (
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
{headCells.map((headCell) => (
|
||||
<TableCell
|
||||
key={headCell.id}
|
||||
align={headCell.align}
|
||||
padding={headCell.disablePadding ? 'none' : 'normal'}
|
||||
sortDirection={orderBy === headCell.id ? order : false}
|
||||
color="secondary"
|
||||
sx={{
|
||||
width: headCell.width,
|
||||
minWidth: headCell.id === 'node_identity' ? '120px' : '80px',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
<TableSortLabel
|
||||
active={orderBy === headCell.id}
|
||||
direction={orderBy === headCell.id ? order : 'asc'}
|
||||
onClick={createSortHandler(headCell.id)}
|
||||
IconComponent={ArrowDropDownIcon}
|
||||
>
|
||||
{headCell.label}
|
||||
{orderBy === headCell.id ? (
|
||||
<Box component="span" sx={visuallyHidden}>
|
||||
{order === 'desc' ? 'sorted descending' : 'sorted ascending'}
|
||||
</Box>
|
||||
) : null}
|
||||
</TableSortLabel>
|
||||
</TableCell>
|
||||
))}
|
||||
<TableCell
|
||||
align="center"
|
||||
sx={{
|
||||
width: '10%',
|
||||
minWidth: '100px',
|
||||
maxWidth: '120px',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<Typography noWrap align="center">
|
||||
Actions
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
);
|
||||
};
|
||||
|
||||
const hasPruningError = (item: any): boolean => {
|
||||
if (!isDelegation(item) || !item.errors) return false;
|
||||
|
||||
@@ -158,43 +81,69 @@ const hasPruningError = (item: any): boolean => {
|
||||
);
|
||||
};
|
||||
|
||||
const getStakeSaturation = (item: DelegationWithEverything) =>
|
||||
!item.stake_saturation ? '-' : `${decimalToPercentage(item.stake_saturation)}%`;
|
||||
|
||||
const getRewardValue = (item: DelegationWithEverything) => {
|
||||
const { unclaimed_rewards } = item;
|
||||
return !unclaimed_rewards ? '-' : `${unclaimed_rewards.amount} ${unclaimed_rewards.denom}`;
|
||||
};
|
||||
|
||||
const saturationNumeric = (item: DelegationWithEverything): number | undefined => {
|
||||
if (!item.stake_saturation) return undefined;
|
||||
return decimalToFloatApproximation(item.stake_saturation);
|
||||
};
|
||||
|
||||
export const DelegationList: FCWithChildren<{
|
||||
isLoading?: boolean;
|
||||
items: TDelegations;
|
||||
onItemActionClick?: (item: DelegationWithEverything, action: DelegationListItemActions) => void;
|
||||
explorerUrl: string;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
}> = ({ isLoading, items, onItemActionClick, explorerUrl }) => {
|
||||
nextEpoch?: string | Error;
|
||||
}> = ({ items, onItemActionClick, explorerUrl, nextEpoch }) => {
|
||||
const [order, setOrder] = React.useState<Order>('asc');
|
||||
const [orderBy, setOrderBy] = React.useState<SortingKeys>('delegated_on_iso_datetime');
|
||||
const [identityFilter, setIdentityFilter] = React.useState('');
|
||||
const [expandedKey, setExpandedKey] = React.useState<string | null>(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { delegationItemErrors, setDelegationItemErrors } = useDelegationContext();
|
||||
const {
|
||||
delegationItemErrors,
|
||||
setDelegationItemErrors,
|
||||
totalDelegations,
|
||||
totalRewards,
|
||||
totalDelegationsAndRewards,
|
||||
isLoading: delegationsSummaryLoading,
|
||||
} = useDelegationContext();
|
||||
|
||||
const handleRequestSort = (_: React.MouseEvent<unknown>, property: any) => {
|
||||
const isAsc = orderBy === property && order === 'asc';
|
||||
setOrder(isAsc ? 'desc' : 'asc');
|
||||
setOrderBy(property);
|
||||
};
|
||||
|
||||
// Get sorted items
|
||||
const sorted = useSortDelegations(items, order, orderBy);
|
||||
|
||||
// Filter out empty placeholder rows
|
||||
const filteredItems = React.useMemo(() => {
|
||||
if (!sorted) return [];
|
||||
return sorted.filter((item) => !shouldBeFiltered(item));
|
||||
}, [sorted]);
|
||||
|
||||
// Check if any delegations have pruning errors
|
||||
const activeDelegations = React.useMemo(
|
||||
() => filteredItems.filter((item): item is DelegationWithEverything => isDelegation(item)),
|
||||
[filteredItems],
|
||||
);
|
||||
|
||||
const pendingItems = React.useMemo(() => filteredItems.filter((item) => isPendingDelegation(item)), [filteredItems]);
|
||||
|
||||
const searchNeedle = identityFilter.trim().toLowerCase();
|
||||
|
||||
const displayedDelegations = React.useMemo(() => {
|
||||
if (!searchNeedle) return activeDelegations;
|
||||
return activeDelegations.filter((d) => d.node_identity.toLowerCase().includes(searchNeedle));
|
||||
}, [activeDelegations, searchNeedle]);
|
||||
|
||||
const activeCount = activeDelegations.length;
|
||||
|
||||
const hasPruningErrors = React.useMemo(() => filteredItems?.some((item) => hasPruningError(item)), [filteredItems]);
|
||||
|
||||
// Navigate to settings page
|
||||
const navigateToSettings = () => {
|
||||
navigate('/settings');
|
||||
};
|
||||
|
||||
// Format error message for display
|
||||
const formatErrorMessage = (message: string) => {
|
||||
if (message.includes('height') && message.includes('not available')) {
|
||||
return 'Due to pruning strategies from validators, please navigate to the Settings tab and change your RPC node for your validator to retrieve your delegations.';
|
||||
@@ -202,9 +151,22 @@ export const DelegationList: FCWithChildren<{
|
||||
return message;
|
||||
};
|
||||
|
||||
const pendingKey = (item: any, suffix: string) =>
|
||||
`pending-${item.event?.mix_id}-${item.event?.address ?? ''}-${item.event?.kind ?? ''}-${
|
||||
item.node_identity ?? ''
|
||||
}-${suffix}`;
|
||||
|
||||
const nextEpochLine =
|
||||
nextEpoch instanceof Error || !nextEpoch ? null : (
|
||||
<Typography fontSize={14} color="text.secondary" sx={{ lineHeight: 1.5 }}>
|
||||
Next epoch starts at <strong>{nextEpoch}</strong>
|
||||
</Typography>
|
||||
);
|
||||
|
||||
const emptyTableMessage = searchNeedle ? 'No delegations match your search.' : 'No delegations to show.';
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Display pruning error alert at the top if needed */}
|
||||
{hasPruningErrors && (
|
||||
<Alert
|
||||
severity="warning"
|
||||
@@ -223,78 +185,412 @@ export const DelegationList: FCWithChildren<{
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Add horizontal scrolling to the table container */}
|
||||
<TableContainer
|
||||
sx={{
|
||||
width: '100%',
|
||||
overflowX: 'auto',
|
||||
'& .MuiTable-root': {
|
||||
tableLayout: 'fixed',
|
||||
minWidth: 650,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{isLoading && <LoadingModal text="Please wait. Refreshing..." />}
|
||||
<ErrorModal
|
||||
open={Boolean(delegationItemErrors)}
|
||||
title={`Delegation errors for Node ID ${delegationItemErrors?.nodeId || 'unknown'}`}
|
||||
message={
|
||||
delegationItemErrors?.errors ? formatErrorMessage(delegationItemErrors.errors) : 'An unknown error occurred'
|
||||
}
|
||||
onClose={() => setDelegationItemErrors(undefined)}
|
||||
/>
|
||||
<Table>
|
||||
<EnhancedTableHead order={order} orderBy={orderBy} onRequestSort={handleRequestSort} />
|
||||
<TableBody>
|
||||
{filteredItems?.length
|
||||
? filteredItems.map((item: any, _index: number) => {
|
||||
if (isPendingDelegation(item)) {
|
||||
const pendingKey = `pending-${item.event.mix_id}-${
|
||||
item.event.address
|
||||
}-${Date.now()}-${Math.random()}`;
|
||||
<Stack spacing={2} sx={{ width: '100%' }}>
|
||||
<Stack spacing={2}>
|
||||
<Box sx={{ maxWidth: 800 }}>
|
||||
<Typography variant="h6" component="h2">
|
||||
Delegations
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
|
||||
{activeCount} active
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
if (
|
||||
item.event &&
|
||||
item.event.kind === 'Delegate' &&
|
||||
(!item.node_identity || item.node_identity === '')
|
||||
) {
|
||||
return (
|
||||
<PendingDelegationItem
|
||||
key={pendingKey}
|
||||
item={{
|
||||
...item,
|
||||
node_identity: `Mix Identity Key ${item.event.mix_id}`,
|
||||
}}
|
||||
explorerUrl={explorerUrl}
|
||||
/>
|
||||
);
|
||||
}
|
||||
<Stack spacing={1.25}>
|
||||
<Stack
|
||||
direction={{ xs: 'column', md: 'row' }}
|
||||
spacing={2}
|
||||
alignItems={{ xs: 'stretch', md: 'flex-end' }}
|
||||
flexWrap="wrap"
|
||||
>
|
||||
<TextField
|
||||
size="small"
|
||||
label="Search identity"
|
||||
value={identityFilter}
|
||||
onChange={(e) => setIdentityFilter(e.target.value)}
|
||||
sx={{
|
||||
minWidth: { xs: '100%', md: 220 },
|
||||
flex: { md: '1 1 200px' },
|
||||
'& .MuiOutlinedInput-root': { borderRadius: 2 },
|
||||
}}
|
||||
/>
|
||||
<FormControl size="small" sx={{ minWidth: 200, '& .MuiOutlinedInput-root': { borderRadius: 2 } }}>
|
||||
<InputLabel id="delegation-sort-field-label">Sort by</InputLabel>
|
||||
<Select
|
||||
labelId="delegation-sort-field-label"
|
||||
label="Sort by"
|
||||
value={orderBy}
|
||||
onChange={(e) => setOrderBy(e.target.value as SortingKeys)}
|
||||
>
|
||||
{SORT_FIELD_OPTIONS.map((opt) => (
|
||||
<MenuItem key={opt.id} value={opt.id}>
|
||||
{opt.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl size="small" sx={{ minWidth: 160, '& .MuiOutlinedInput-root': { borderRadius: 2 } }}>
|
||||
<InputLabel id="delegation-sort-order-label">Order</InputLabel>
|
||||
<Select
|
||||
labelId="delegation-sort-order-label"
|
||||
label="Order"
|
||||
value={order}
|
||||
onChange={(e) => setOrder(e.target.value as Order)}
|
||||
>
|
||||
<MenuItem value="asc">Ascending</MenuItem>
|
||||
<MenuItem value="desc">Descending</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Stack>
|
||||
{nextEpochLine ? <Box sx={{ pt: 0.25 }}>{nextEpochLine}</Box> : null}
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
return <PendingDelegationItem key={pendingKey} item={item} explorerUrl={explorerUrl} />;
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} alignItems="stretch">
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
minHeight: 92,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
borderRadius: 2,
|
||||
border: (t) => `1px solid ${t.palette.divider}`,
|
||||
bgcolor: (t) =>
|
||||
t.palette.mode === 'dark' ? 'nym.nymWallet.nav.background' : 'nym.nymWallet.background.subtle',
|
||||
p: 2.5,
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" alignItems="center" gap={0.5}>
|
||||
<InfoTooltip title="The total amount you have delegated to node(s) in the network. The amount also includes the rewards you have accrued since last time you claimed your rewards" />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Total delegations
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Typography fontWeight={600} fontSize={16} sx={{ mt: 0.5, textTransform: 'uppercase' }}>
|
||||
{delegationsSummaryLoading ? <Skeleton width={140} height={22} /> : totalDelegationsAndRewards ?? '-'}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
minHeight: 92,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
borderRadius: 2,
|
||||
border: (t) => `1px solid ${t.palette.divider}`,
|
||||
bgcolor: (t) =>
|
||||
t.palette.mode === 'dark' ? 'nym.nymWallet.nav.background' : 'nym.nymWallet.background.subtle',
|
||||
p: 2.5,
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" alignItems="center" gap={0.5}>
|
||||
<InfoTooltip title="The initial amount you delegated to the node(s)" />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Original delegations
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Typography fontWeight={600} fontSize={16} sx={{ mt: 0.5, textTransform: 'uppercase' }}>
|
||||
{delegationsSummaryLoading ? <Skeleton width={120} height={22} /> : totalDelegations ?? '-'}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
minHeight: 92,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
borderRadius: 2,
|
||||
border: (t) => `1px solid ${t.palette.divider}`,
|
||||
bgcolor: (t) =>
|
||||
t.palette.mode === 'dark' ? 'nym.nymWallet.nav.background' : 'nym.nymWallet.background.subtle',
|
||||
p: 2.5,
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" alignItems="center" gap={0.5}>
|
||||
<InfoTooltip title="The rewards you have accrued since the last time you claimed your rewards. Rewards are automatically compounded. You can claim your rewards at any time." />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Total rewards
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Typography fontWeight={600} fontSize={16} sx={{ mt: 0.5, textTransform: 'uppercase' }}>
|
||||
{delegationsSummaryLoading ? <Skeleton width={120} height={22} /> : totalRewards ?? '-'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
{pendingItems.length > 0 && (
|
||||
<Stack spacing={1}>
|
||||
<Typography variant="subtitle2" color="text.secondary">
|
||||
Pending
|
||||
</Typography>
|
||||
<Stack spacing={2}>
|
||||
{pendingItems.map((item: any, index: number) => {
|
||||
if (
|
||||
item.event &&
|
||||
item.event.kind === 'Delegate' &&
|
||||
(!item.node_identity || item.node_identity === '')
|
||||
) {
|
||||
return (
|
||||
<PendingDelegationCard
|
||||
key={pendingKey(item, `d-${index}`)}
|
||||
item={{
|
||||
...item,
|
||||
node_identity: `Mix Identity Key ${item.event.mix_id}`,
|
||||
}}
|
||||
explorerUrl={explorerUrl}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PendingDelegationCard key={pendingKey(item, `p-${index}`)} item={item} explorerUrl={explorerUrl} />
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<TableContainer
|
||||
sx={{
|
||||
width: '100%',
|
||||
overflowX: 'auto',
|
||||
borderRadius: 3,
|
||||
border: (t) => `1px solid ${t.palette.divider}`,
|
||||
bgcolor: (t) =>
|
||||
t.palette.mode === 'dark' ? 'nym.nymWallet.nav.background' : 'nym.nymWallet.background.subtle',
|
||||
}}
|
||||
>
|
||||
<ErrorModal
|
||||
open={Boolean(delegationItemErrors)}
|
||||
title={`Delegation errors for Node ID ${delegationItemErrors?.nodeId || 'unknown'}`}
|
||||
message={
|
||||
delegationItemErrors?.errors
|
||||
? formatErrorMessage(delegationItemErrors.errors)
|
||||
: 'An unknown error occurred'
|
||||
}
|
||||
onClose={() => setDelegationItemErrors(undefined)}
|
||||
/>
|
||||
<Table stickyHeader size="small" sx={{ tableLayout: 'fixed' }}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell sx={{ fontWeight: 600, py: 1.25, width: '40%' }}>Node</TableCell>
|
||||
<TableCell align="right" sx={{ fontWeight: 600, py: 1.25, width: '16%' }}>
|
||||
Amount
|
||||
</TableCell>
|
||||
<TableCell align="right" sx={{ fontWeight: 600, py: 1.25, width: '14%' }}>
|
||||
Saturation
|
||||
</TableCell>
|
||||
<TableCell align="right" sx={{ fontWeight: 600, py: 1.25, width: '18%' }}>
|
||||
Reward
|
||||
</TableCell>
|
||||
<TableCell align="right" sx={{ fontWeight: 600, py: 1.25, width: 120, minWidth: 112 }}>
|
||||
Actions
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{displayedDelegations.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} sx={{ py: 1.25 }}>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ py: 1 }}>
|
||||
{emptyTableMessage}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
displayedDelegations.map((item) => {
|
||||
const rowKey = `${item.mix_id}-${item.node_identity}`;
|
||||
const isOpen = expandedKey === rowKey;
|
||||
const nodeIsUnbonded = Boolean(!item.node_identity);
|
||||
const satNum = saturationNumeric(item);
|
||||
let satColor: 'text.secondary' | 'error.main' | 'success.main' = 'text.secondary';
|
||||
if (satNum !== undefined) {
|
||||
satColor = satNum > 1 ? 'error.main' : 'success.main';
|
||||
}
|
||||
|
||||
if (isDelegation(item)) {
|
||||
if (!item.node_identity || item.node_identity === '-' || item.node_identity === '...') {
|
||||
return null;
|
||||
}
|
||||
const operatingCost = item.cost_params?.interval_operating_cost;
|
||||
const uptime = item.avg_uptime_percent;
|
||||
const routingDisplay = uptime != null && String(uptime) !== '-' ? `${uptime}%` : '-';
|
||||
const marginDisplay = item.cost_params?.profit_margin_percent
|
||||
? `${toPercentIntegerString(item.cost_params.profit_margin_percent)}%`
|
||||
: '-';
|
||||
const costDisplay = operatingCost ? `${operatingCost.amount} ${operatingCost.denom}` : '-';
|
||||
const delegatedDisplay = item.delegated_on_iso_datetime
|
||||
? format(new Date(item.delegated_on_iso_datetime), 'dd/MM/yyyy')
|
||||
: '-';
|
||||
|
||||
return (
|
||||
<DelegationItem
|
||||
key={`delegation-${item.mix_id}`}
|
||||
item={item}
|
||||
explorerUrl={explorerUrl}
|
||||
nodeIsUnbonded={Boolean(!item.node_identity)}
|
||||
onItemActionClick={onItemActionClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const unbondedTooltip =
|
||||
'This node has unbonded and it does not exist anymore. You need to undelegate from it to get your stake and outstanding rewards (if any) back.';
|
||||
|
||||
return null;
|
||||
return (
|
||||
<React.Fragment key={rowKey}>
|
||||
<TableRow hover sx={{ '& > *': { borderBottom: 'unset' } }}>
|
||||
<TableCell sx={{ py: 1.25, verticalAlign: 'middle' }}>
|
||||
<Stack direction="row" alignItems="center" spacing={0.5}>
|
||||
<IconButton
|
||||
aria-label="expand row"
|
||||
size="small"
|
||||
onClick={() => setExpandedKey(isOpen ? null : rowKey)}
|
||||
>
|
||||
{isOpen ? <KeyboardArrowUp /> : <KeyboardArrowDown />}
|
||||
</IconButton>
|
||||
<Stack direction="row" alignItems="center" gap={0.5} flexWrap="wrap" sx={{ minWidth: 0 }}>
|
||||
{item.errors && (
|
||||
<Tooltip title="Open to view a list of errors that occurred">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() =>
|
||||
setDelegationItemErrors({ nodeId: item.node_identity, errors: item.errors! })
|
||||
}
|
||||
>
|
||||
<WarningAmberOutlined color="warning" fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
{item.uses_vesting_contract_tokens && (
|
||||
<Tooltip title="Delegation uses locked tokens">
|
||||
<LockOutlined sx={{ color: 'text.secondary', fontSize: 18 }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
{nodeIsUnbonded ? (
|
||||
<Tooltip title={unbondedTooltip} arrow>
|
||||
<Typography color="text.secondary">-</Typography>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Link
|
||||
target="_blank"
|
||||
href={`${explorerUrl}/nodes/${item.mix_id}`}
|
||||
text={`${item.node_identity.slice(0, 6)}...${item.node_identity.slice(-6)}`}
|
||||
color="text.primary"
|
||||
noIcon
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</TableCell>
|
||||
<TableCell align="right" sx={{ py: 1.25, whiteSpace: 'nowrap', verticalAlign: 'middle' }}>
|
||||
{item.amount.amount} {item.amount.denom}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
align="right"
|
||||
sx={{ py: 1.25, color: satColor, whiteSpace: 'nowrap', verticalAlign: 'middle' }}
|
||||
>
|
||||
{getStakeSaturation(item)}
|
||||
</TableCell>
|
||||
<TableCell align="right" sx={{ py: 1.25, whiteSpace: 'nowrap', verticalAlign: 'middle' }}>
|
||||
{getRewardValue(item)}
|
||||
</TableCell>
|
||||
<TableCell align="right" sx={{ py: 1.25, whiteSpace: 'nowrap', verticalAlign: 'middle' }}>
|
||||
{!item.pending_events.length && !nodeIsUnbonded && (
|
||||
<DelegationsActionsMenu
|
||||
onActionClick={(action) =>
|
||||
onItemActionClick ? onItemActionClick(item, action) : undefined
|
||||
}
|
||||
disableRedeemingRewards={!item.unclaimed_rewards || item.unclaimed_rewards.amount === '0'}
|
||||
disableDelegateMore={item.mixnode_is_unbonding}
|
||||
/>
|
||||
)}
|
||||
{!item.pending_events.length && nodeIsUnbonded && (
|
||||
<IconButton sx={{ color: (t) => t.palette.nym.nymWallet.text.main }} size="small">
|
||||
<Undelegate
|
||||
onClick={() => (onItemActionClick ? onItemActionClick(item, 'undelegate') : undefined)}
|
||||
/>
|
||||
</IconButton>
|
||||
)}
|
||||
{item.pending_events.length > 0 && (
|
||||
<Tooltip
|
||||
title="Your changes will take effect when the new epoch starts. There is a new epoch every hour."
|
||||
arrow
|
||||
componentsProps={{
|
||||
tooltip: {
|
||||
sx: { textAlign: 'left' },
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Pending events
|
||||
</Typography>
|
||||
</Tooltip>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell style={{ paddingBottom: 0, paddingTop: 0 }} colSpan={5}>
|
||||
<Collapse in={isOpen} timeout="auto" unmountOnExit>
|
||||
<Box
|
||||
sx={{
|
||||
py: 2,
|
||||
px: 2,
|
||||
borderRadius: 3,
|
||||
overflow: 'hidden',
|
||||
border: (t) =>
|
||||
`1px solid ${alpha(t.palette.divider, t.palette.mode === 'dark' ? 0.35 : 0.5)}`,
|
||||
bgcolor: (t) =>
|
||||
t.palette.mode === 'dark'
|
||||
? alpha(t.palette.common.white, 0.04)
|
||||
: alpha(t.palette.common.black, 0.03),
|
||||
}}
|
||||
>
|
||||
<Table size="small" sx={{ tableLayout: 'fixed' }}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell sx={{ fontWeight: 600, color: 'text.secondary', py: 1, width: '25%' }}>
|
||||
Routing
|
||||
</TableCell>
|
||||
<TableCell sx={{ fontWeight: 600, color: 'text.secondary', py: 1, width: '25%' }}>
|
||||
Margin
|
||||
</TableCell>
|
||||
<TableCell sx={{ fontWeight: 600, color: 'text.secondary', py: 1, width: '25%' }}>
|
||||
NYM cost
|
||||
</TableCell>
|
||||
<TableCell sx={{ fontWeight: 600, color: 'text.secondary', py: 1, width: '25%' }}>
|
||||
Delegated on
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell sx={{ py: 1.25, verticalAlign: 'top', borderBottom: 'none' }}>
|
||||
<Typography variant="body2" color="text.primary">
|
||||
{routingDisplay}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell sx={{ py: 1.25, verticalAlign: 'top', borderBottom: 'none' }}>
|
||||
<Typography variant="body2" color="text.primary">
|
||||
{marginDisplay}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell sx={{ py: 1.25, verticalAlign: 'top', borderBottom: 'none' }}>
|
||||
<Typography variant="body2" color="text.primary">
|
||||
{costDisplay}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell sx={{ py: 1.25, verticalAlign: 'top', borderBottom: 'none' }}>
|
||||
<Typography variant="body2" color="text.primary">
|
||||
{delegatedDisplay}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</React.Fragment>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
import React from 'react';
|
||||
import { ComponentMeta } from '@storybook/react';
|
||||
|
||||
import { Paper, Button } from '@mui/material';
|
||||
import { useTheme, Theme } from '@mui/material/styles';
|
||||
import { Delegations } from './Delegations';
|
||||
import { items } from './DelegationList.stories';
|
||||
import { DelegationModal } from './DelegationModal';
|
||||
import { backDropStyles, modalStyles } from '../../../.storybook/storiesStyles';
|
||||
|
||||
const explorerUrl = 'https://sandbox-explorer.nymtech.net';
|
||||
|
||||
const storybookStyles = (theme: Theme) => ({
|
||||
backdropProps: backDropStyles(theme),
|
||||
sx: modalStyles(theme),
|
||||
});
|
||||
|
||||
export default {
|
||||
title: 'Delegation/Components/Delegation Modals',
|
||||
component: Delegations,
|
||||
} as ComponentMeta<typeof Delegations>;
|
||||
|
||||
const transaction = {
|
||||
url: 'https://sandbox-blocks.nymtech.net/transactions/11ED7B9E21534A9421834F52FED5103DC6E982949C06335F5E12EFC71DAF0CFB',
|
||||
hash: '11ED7B9E21534A9421834F52FED5103DC6E982949C06335F5E12EFC71DAF0CFB',
|
||||
};
|
||||
// Another transaction for Dark Theme to avoid duplicate key errors in rendering
|
||||
const transactionForDarkTheme = {
|
||||
url: 'https://sandbox-blocks.nymtech.net/transactions/11ED7B9E21534A9421834F52FED5103DC6E982949C06335F5E12EFC71DAF0CFO',
|
||||
hash: '11ED7B9E21534A9421834F52FED5103DC6E982949C06335F5E12EFC71DAF0CF0',
|
||||
};
|
||||
|
||||
const Content: FCWithChildren<{ children: React.ReactElement<any, any>; handleClick: () => void }> = ({
|
||||
children,
|
||||
handleClick,
|
||||
}) => (
|
||||
<>
|
||||
<Paper elevation={0} sx={{ px: 4, pt: 2, pb: 4 }}>
|
||||
<h2>Your Delegations</h2>
|
||||
<Button variant="contained" onClick={handleClick} sx={{ mb: 3 }}>
|
||||
Show modal
|
||||
</Button>
|
||||
<Delegations items={items} explorerUrl={explorerUrl} />
|
||||
</Paper>
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
|
||||
export const Loading = () => {
|
||||
const [open, setOpen] = React.useState<boolean>(false);
|
||||
const handleClick = () => setOpen(true);
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<Content handleClick={handleClick}>
|
||||
<DelegationModal
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
status="loading"
|
||||
action="delegate"
|
||||
{...storybookStyles(theme)}
|
||||
/>
|
||||
</Content>
|
||||
);
|
||||
};
|
||||
|
||||
export const DelegateSuccess = () => {
|
||||
const [open, setOpen] = React.useState<boolean>(false);
|
||||
const handleClick = () => setOpen(true);
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<Content handleClick={handleClick}>
|
||||
<DelegationModal
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
status="success"
|
||||
action="delegate"
|
||||
message="You delegated 5 NYM"
|
||||
transactions={theme.palette.mode === 'light' ? [transaction] : [transactionForDarkTheme]}
|
||||
{...storybookStyles(theme)}
|
||||
/>
|
||||
</Content>
|
||||
);
|
||||
};
|
||||
|
||||
export const UndelegateSuccess = () => {
|
||||
const [open, setOpen] = React.useState<boolean>(false);
|
||||
const handleClick = () => setOpen(true);
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<Content handleClick={handleClick}>
|
||||
<DelegationModal
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
status="success"
|
||||
action="undelegate"
|
||||
message="You undelegated 5 NYM"
|
||||
transactions={theme.palette.mode === 'light' ? [transaction] : [transactionForDarkTheme]}
|
||||
{...storybookStyles(theme)}
|
||||
/>
|
||||
</Content>
|
||||
);
|
||||
};
|
||||
|
||||
export const RedeemSuccess = () => {
|
||||
const [open, setOpen] = React.useState<boolean>(false);
|
||||
const handleClick = () => setOpen(true);
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<Content handleClick={handleClick}>
|
||||
<DelegationModal
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
status="success"
|
||||
action="redeem"
|
||||
message="42 NYM"
|
||||
transactions={
|
||||
theme.palette.mode === 'light'
|
||||
? [transaction, transaction]
|
||||
: [transactionForDarkTheme, transactionForDarkTheme]
|
||||
}
|
||||
{...storybookStyles(theme)}
|
||||
/>
|
||||
</Content>
|
||||
);
|
||||
};
|
||||
|
||||
export const RedeemWithVestedSuccess = () => {
|
||||
const [open, setOpen] = React.useState<boolean>(false);
|
||||
const handleClick = () => setOpen(true);
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<Content handleClick={handleClick}>
|
||||
<DelegationModal
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
status="success"
|
||||
action="redeem"
|
||||
message="42 NYM"
|
||||
transactions={
|
||||
theme.palette.mode === 'light'
|
||||
? [transaction, transaction]
|
||||
: [transactionForDarkTheme, transactionForDarkTheme]
|
||||
}
|
||||
{...storybookStyles(theme)}
|
||||
/>
|
||||
</Content>
|
||||
);
|
||||
};
|
||||
|
||||
export const RedeemAllSuccess = () => {
|
||||
const [open, setOpen] = React.useState<boolean>(false);
|
||||
const handleClick = () => setOpen(true);
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<Content handleClick={handleClick}>
|
||||
<DelegationModal
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
status="success"
|
||||
action="redeem-all"
|
||||
message="42 NYM"
|
||||
transactions={
|
||||
theme.palette.mode === 'light'
|
||||
? [transaction, transaction]
|
||||
: [transactionForDarkTheme, transactionForDarkTheme]
|
||||
}
|
||||
{...storybookStyles(theme)}
|
||||
/>
|
||||
</Content>
|
||||
);
|
||||
};
|
||||
|
||||
export const Error = () => {
|
||||
const [open, setOpen] = React.useState<boolean>(false);
|
||||
const handleClick = () => setOpen(true);
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<Content handleClick={handleClick}>
|
||||
<DelegationModal
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
status="error"
|
||||
action="redeem-all"
|
||||
message="Minim esse veniam Lorem id velit Lorem eu eu est. Excepteur labore sunt do proident proident sint aliquip consequat Lorem sint non nulla ad excepteur."
|
||||
transactions={theme.palette.mode === 'light' ? [transaction] : [transactionForDarkTheme]}
|
||||
{...storybookStyles(theme)}
|
||||
/>
|
||||
</Content>
|
||||
);
|
||||
};
|
||||
@@ -1,27 +0,0 @@
|
||||
import React from 'react';
|
||||
import { ComponentMeta } from '@storybook/react';
|
||||
|
||||
import { Paper } from '@mui/material';
|
||||
import { Delegations } from './Delegations';
|
||||
import { items } from './DelegationList.stories';
|
||||
|
||||
const explorerUrl = 'https://sandbox-explorer.nymtech.net';
|
||||
|
||||
export default {
|
||||
title: 'Delegation/Components/Delegations',
|
||||
component: Delegations,
|
||||
} as ComponentMeta<typeof Delegations>;
|
||||
|
||||
export const Default = () => (
|
||||
<Paper elevation={0} sx={{ px: 4, pt: 2, pb: 4 }}>
|
||||
<h2>Your Delegations</h2>
|
||||
<Delegations items={items} explorerUrl={explorerUrl} />
|
||||
</Paper>
|
||||
);
|
||||
|
||||
export const Empty = () => (
|
||||
<Paper elevation={0} sx={{ px: 4, pt: 2, pb: 4 }}>
|
||||
<h2>Your Delegations</h2>
|
||||
<Delegations items={[]} explorerUrl={explorerUrl} />
|
||||
</Paper>
|
||||
);
|
||||
@@ -6,18 +6,12 @@ import { DelegationList } from './DelegationList';
|
||||
import { DelegationListItemActions } from './DelegationActions';
|
||||
|
||||
export const Delegations: FCWithChildren<{
|
||||
isLoading?: boolean;
|
||||
items: DelegationWithEverything[];
|
||||
explorerUrl: string;
|
||||
onDelegationItemActionClick?: (item: DelegationWithEverything, action: DelegationListItemActions) => void;
|
||||
}> = ({ isLoading, items, explorerUrl, onDelegationItemActionClick }) => (
|
||||
}> = ({ items, explorerUrl, onDelegationItemActionClick }) => (
|
||||
<>
|
||||
<DelegationList
|
||||
isLoading={isLoading}
|
||||
items={items}
|
||||
explorerUrl={explorerUrl}
|
||||
onItemActionClick={onDelegationItemActionClick}
|
||||
/>
|
||||
<DelegationList items={items} explorerUrl={explorerUrl} onItemActionClick={onDelegationItemActionClick} />
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<Link href={`${explorerUrl}/network-components/mixnodes/`} target="_blank" rel="noreferrer">
|
||||
Check the{' '}
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Button, Paper, Typography } from '@mui/material';
|
||||
import { useTheme, Theme } from '@mui/material/styles';
|
||||
import { DelegateModal } from './DelegateModal';
|
||||
import { UndelegateModal } from './UndelegateModal';
|
||||
import { backDropStyles, modalStyles } from '../../../.storybook/storiesStyles';
|
||||
|
||||
const storybookStyles = (theme: Theme) => ({
|
||||
backdropProps: backDropStyles(theme),
|
||||
sx: modalStyles(theme),
|
||||
});
|
||||
|
||||
export default {
|
||||
title: 'Delegation/Components/Action Modals',
|
||||
};
|
||||
|
||||
const Background: FCWithChildren<{ onOpen: () => void }> = ({ onOpen }) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<Paper elevation={0} sx={{ px: 4, pt: 2, pb: 4 }}>
|
||||
<h2>Lorem ipsum</h2>
|
||||
<Button variant="contained" onClick={onOpen}>
|
||||
Show modal
|
||||
</Button>
|
||||
<Typography sx={{ color: theme.palette.text.primary }}>
|
||||
Veniam dolor laborum labore sit reprehenderit enim mollit magna nulla adipisicing fugiat. Est ex irure quis sunt
|
||||
velit elit do minim mollit non duis reprehenderit. Eiusmod dolore adipisicing ex nostrud consectetur culpa
|
||||
exercitation do. Ad elit esse ipsum aliqua labore irure laborum qui culpa.
|
||||
</Typography>
|
||||
<Typography sx={{ color: theme.palette.text.primary }}>
|
||||
Occaecat commodo excepteur anim ut officia dolor laboris dolore id occaecat enim qui eiusmod occaecat aliquip ad
|
||||
tempor. Labore amet laborum magna amet consequat dolor cupidatat in consequat sunt aliquip magna laboris tempor
|
||||
culpa est magna. Sit tempor cillum culpa sint ipsum nostrud ullamco voluptate exercitation dolore magna elit ut
|
||||
mollit.
|
||||
</Typography>
|
||||
<Typography sx={{ color: theme.palette.text.primary }}>
|
||||
Labore voluptate elit amet ipsum qui officia duis in et occaecat culpa ex do non labore mollit. Cillum cupidatat
|
||||
duis ea dolore laboris laboris sunt duis anim consectetur cupidatat nulla ad minim sunt ea. Aliqua amet commodo
|
||||
est irure sint magna sunt. Pariatur dolore commodo labore quis incididunt proident duis voluptate exercitation
|
||||
in duis. Occaecat aliqua laboris reprehenderit nostrud est aute pariatur fugiat anim. Dolore sunt cillum ea
|
||||
aliquip consectetur laborum ipsum qui veniam Lorem consectetur adipisicing velit magna aute. Amet tempor quis
|
||||
excepteur minim culpa velit Lorem enim ad.
|
||||
</Typography>
|
||||
<Typography sx={{ color: theme.palette.text.primary }}>
|
||||
Mollit laborum exercitation excepteur laboris adipisicing ipsum veniam cillum mollit voluptate do. Amet et anim
|
||||
Lorem mollit minim duis cupidatat non. Consectetur sit deserunt nisi nisi non excepteur dolor eiusmod aute aute
|
||||
irure anim dolore ipsum et veniam.
|
||||
</Typography>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export const Delegate = () => {
|
||||
const [open, setOpen] = React.useState<boolean>(false);
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<>
|
||||
<Background onOpen={() => setOpen(true)} />
|
||||
<DelegateModal
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
onOk={async () => setOpen(false)}
|
||||
denom="nym"
|
||||
estimatedReward={50.423}
|
||||
accountBalance="425.2345053"
|
||||
nodeUptimePercentage={99.28394}
|
||||
profitMarginPercentage="11.12334234"
|
||||
rewardInterval="monthlyish"
|
||||
hasVestingContract={false}
|
||||
{...storybookStyles(theme)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const DelegateBelowMinimum = () => {
|
||||
const [open, setOpen] = React.useState<boolean>(false);
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<>
|
||||
<Background onOpen={() => setOpen(true)} />
|
||||
<DelegateModal
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
onOk={async () => setOpen(false)}
|
||||
denom="nym"
|
||||
estimatedReward={425.2345053}
|
||||
nodeUptimePercentage={99.28394}
|
||||
profitMarginPercentage="11.12334234"
|
||||
rewardInterval="monthlyish"
|
||||
initialAmount="0.1"
|
||||
hasVestingContract={false}
|
||||
{...storybookStyles(theme)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const DelegateMore = () => {
|
||||
const [open, setOpen] = React.useState<boolean>(false);
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<>
|
||||
<Background onOpen={() => setOpen(true)} />
|
||||
<DelegateModal
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
onOk={async () => setOpen(false)}
|
||||
header="Delegate more"
|
||||
buttonText="Delegate more"
|
||||
denom="nym"
|
||||
estimatedReward={50.423}
|
||||
accountBalance="425.2345053"
|
||||
nodeUptimePercentage={99.28394}
|
||||
profitMarginPercentage="11.12334234"
|
||||
rewardInterval="monthlyish"
|
||||
hasVestingContract={false}
|
||||
{...storybookStyles(theme)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const Undelegate = () => {
|
||||
const [open, setOpen] = React.useState<boolean>(false);
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<>
|
||||
<Background onOpen={() => setOpen(true)} />
|
||||
<UndelegateModal
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
onOk={async () => setOpen(false)}
|
||||
currency="nym"
|
||||
amount={150}
|
||||
mixId={1234}
|
||||
identityKey="AA6RfeY8DttMD3CQKoayV6mss5a5FC3RoH75Kmcujyxx"
|
||||
usesVestingContractTokens={false}
|
||||
{...storybookStyles(theme)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
import React from 'react';
|
||||
import { Box, Chip, Paper, Stack, Tooltip, Typography } from '@mui/material';
|
||||
import { WrappedDelegationEvent } from '@nymproject/types';
|
||||
import { TauriLink as Link } from 'src/components/TauriLinkWrapper';
|
||||
|
||||
export const PendingDelegationCard = ({ item, explorerUrl }: { item: WrappedDelegationEvent; explorerUrl: string }) => (
|
||||
<Paper
|
||||
variant="outlined"
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 3,
|
||||
bgcolor: (t) => (t.palette.mode === 'dark' ? 'nym.nymWallet.nav.background' : 'nym.nymWallet.background.subtle'),
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
<Stack spacing={1.5} direction={{ xs: 'column', sm: 'row' }} alignItems={{ sm: 'center' }} flexWrap="wrap">
|
||||
<Link
|
||||
target="_blank"
|
||||
href={`${explorerUrl}/nodes/${item.event.mix_id}`}
|
||||
text={`${item.node_identity.slice(0, 6)}...${item.node_identity.slice(-6)}`}
|
||||
color="text.primary"
|
||||
noIcon
|
||||
/>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{item.event.amount?.amount} {item.event.amount?.denom?.toUpperCase() ?? 'NYM'}
|
||||
</Typography>
|
||||
<Box sx={{ flex: 1 }} />
|
||||
<Tooltip
|
||||
title={
|
||||
<Box sx={{ textAlign: 'left' }}>
|
||||
Your delegation of {item.event.amount?.amount} {item.event.amount?.denom} will take effect when the new
|
||||
epoch starts. There is a new epoch every hour.
|
||||
</Box>
|
||||
}
|
||||
arrow
|
||||
PopperProps={{
|
||||
sx: {
|
||||
'& .MuiTooltip-tooltip': { textAlign: 'left' },
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Chip label="Pending" size="small" color="primary" variant="outlined" />
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
@@ -1,49 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Box, Chip, TableCell, TableRow, Tooltip } from '@mui/material';
|
||||
import { WrappedDelegationEvent } from '@nymproject/types';
|
||||
import { TauriLink as Link } from 'src/components/TauriLinkWrapper';
|
||||
|
||||
export const PendingDelegationItem = ({ item, explorerUrl }: { item: WrappedDelegationEvent; explorerUrl: string }) => (
|
||||
<TableRow key={item.node_identity}>
|
||||
<TableCell>
|
||||
<Link
|
||||
target="_blank"
|
||||
href={`${explorerUrl}/nodes/${item.event.mix_id}`}
|
||||
text={`${item.node_identity.slice(0, 6)}...${item.node_identity.slice(-6)}`}
|
||||
color="text.primary"
|
||||
noIcon
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>-</TableCell>
|
||||
<TableCell>-</TableCell>
|
||||
<TableCell>-</TableCell>
|
||||
<TableCell>-</TableCell>
|
||||
<TableCell>-</TableCell>
|
||||
<TableCell>
|
||||
<Box sx={{ textAlign: 'left' }}>{item.event.amount?.amount} NYM</Box>
|
||||
</TableCell>
|
||||
<TableCell>-</TableCell>
|
||||
<TableCell sx={{ textAlign: 'center' }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', width: '100%' }}>
|
||||
<Tooltip
|
||||
title={
|
||||
<div style={{ textAlign: 'center', width: '100%' }}>
|
||||
Your delegation of {item.event.amount?.amount} {item.event.amount?.denom} will take effect when the new
|
||||
epoch starts. There is a new epoch every hour.
|
||||
</div>
|
||||
}
|
||||
arrow
|
||||
PopperProps={{
|
||||
sx: {
|
||||
'& .MuiTooltip-tooltip': {
|
||||
textAlign: 'center',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Chip label="Pending Events" />
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
@@ -1,20 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import { ComponentMeta, ComponentStory } from '@storybook/react';
|
||||
import { Box } from '@mui/material';
|
||||
import { BalanceWarning } from './FeeWarning';
|
||||
|
||||
export default {
|
||||
title: 'Wallet / Balance warning',
|
||||
component: BalanceWarning,
|
||||
} as ComponentMeta<typeof BalanceWarning>;
|
||||
|
||||
const Template: ComponentStory<typeof BalanceWarning> = (args) => (
|
||||
<Box mt={2} height={800}>
|
||||
<BalanceWarning {...args} />
|
||||
</Box>
|
||||
);
|
||||
|
||||
export const WithWarning = Template.bind({});
|
||||
WithWarning.args = {
|
||||
fee: '200',
|
||||
};
|
||||