diff --git a/.env.example b/.env.example index a456e2b803..4420283da7 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,8 @@ export NX_ADD_PLUGINS=false export ESLINT_USE_FLAT_CONFIG=false export VITE_GRAPHQL_ENDPOINT='https://mainnet-gql.tangle.tools/graphql' +# Cloud -> dApp deep-link base URL override (defaults to https://app.tangle.tools/) +export VITE_TANGLE_DAPP_URL='https://app.tangle.tools/' # Credits claim data (off-chain proofs) export VITE_CREDITS_TREE_URL='/data/credits-tree.json' diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 3b7fddb6c2..e05da37de0 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -23,3 +23,12 @@ _Specify any issues that can be closed from these changes (e.g. `Closes #233`)._ ### Screen Recording _If possible provide screenshots and/or a screen recording of proposed change._ + +### Harness Validation (Required for Launch-Flow Impact) + +_If this PR affects any launch flow, attach harness evidence and release-gate output._ + +- [ ] I ran `yarn test:wallet-flows` (or targeted flow subset) and reviewed `suite/report.json`. +- [ ] I checked `suite/release-matrix.md` and confirmed class distribution (`happy-path-pass` / `blocker-or-partial-pass` / `failed`). +- [ ] I ran `yarn test:wallet-flows:gate` and included output summary. +- [ ] For critical flows (`FLOW-001,002,005,010,011,013,014,018,019`), I confirmed `happy-path-pass` (or documented explicit exception + owner sign-off). diff --git a/.github/workflows/auto-sync-master-with-develop.yml b/.github/workflows/auto-sync-master-with-develop.yml index 0df76e1199..dde95c9ed6 100644 --- a/.github/workflows/auto-sync-master-with-develop.yml +++ b/.github/workflows/auto-sync-master-with-develop.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/create-github-app-token@v2 + - uses: actions/create-github-app-token@v3 id: app-token with: app-id: ${{ vars.AUTO_SYNC_BRANCHES_APP_ID }} diff --git a/.github/workflows/check-build.yml b/.github/workflows/check-build.yml index 4387c18ec3..3609756572 100644 --- a/.github/workflows/check-build.yml +++ b/.github/workflows/check-build.yml @@ -28,7 +28,7 @@ jobs: corepack enable - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4.4.0 + uses: actions/setup-node@v6.3.0 with: node-version: ${{ matrix.node-version }} cache: yarn diff --git a/.github/workflows/check-lint.yml b/.github/workflows/check-lint.yml index 75fa625a44..6e9e6d7c95 100644 --- a/.github/workflows/check-lint.yml +++ b/.github/workflows/check-lint.yml @@ -26,7 +26,7 @@ jobs: corepack enable - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4.4.0 + uses: actions/setup-node@v6.3.0 with: node-version: ${{ matrix.node-version }} cache: yarn @@ -56,7 +56,7 @@ jobs: corepack enable - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4.4.0 + uses: actions/setup-node@v6.3.0 with: node-version: ${{ matrix.node-version }} cache: yarn diff --git a/.github/workflows/check-pr-title.yml b/.github/workflows/check-pr-title.yml index 67110eafdb..34f04795df 100644 --- a/.github/workflows/check-pr-title.yml +++ b/.github/workflows/check-pr-title.yml @@ -17,7 +17,7 @@ jobs: corepack enable - name: Set up Node.js environment - uses: actions/setup-node@v4.4.0 + uses: actions/setup-node@v6.3.0 with: node-version: '>=18.18.x' diff --git a/.github/workflows/deploy-storybook-docs.yml b/.github/workflows/deploy-storybook-docs.yml index 8607146aaf..44549ffbec 100644 --- a/.github/workflows/deploy-storybook-docs.yml +++ b/.github/workflows/deploy-storybook-docs.yml @@ -33,7 +33,7 @@ jobs: corepack enable - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4.4.0 + uses: actions/setup-node@v6.3.0 with: node-version: ${{ matrix.node-version }} cache: yarn diff --git a/.github/workflows/release-dapps.yml b/.github/workflows/release-dapps.yml index cf89f6aba8..b1078218b2 100644 --- a/.github/workflows/release-dapps.yml +++ b/.github/workflows/release-dapps.yml @@ -42,7 +42,7 @@ jobs: corepack enable - name: Setup Node.js environment - uses: actions/setup-node@v4.4.0 + uses: actions/setup-node@v6.3.0 with: node-version: '>=18.18.x' cache: yarn diff --git a/.github/workflows/release-libs.yml b/.github/workflows/release-libs.yml index 9fa43d8963..560447304b 100644 --- a/.github/workflows/release-libs.yml +++ b/.github/workflows/release-libs.yml @@ -66,7 +66,7 @@ jobs: corepack enable - name: Setup Node.js environment - uses: actions/setup-node@v4.4.0 + uses: actions/setup-node@v6.3.0 with: node-version: '>=18.18.x' cache: yarn diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index dd0998f997..4e97e5607b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -30,7 +30,7 @@ jobs: corepack enable - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4.4.0 + uses: actions/setup-node@v6.3.0 with: node-version: ${{ matrix.node-version }} cache: yarn diff --git a/.gitignore b/.gitignore index 485dc7b038..f59e9f8b4f 100644 --- a/.gitignore +++ b/.gitignore @@ -108,6 +108,8 @@ vitest.config.*.timestamp* reports/ agent-results/ .agent-wallet-profile/ +.agent-wallet-profile*/ +.wallet-extensions/ .cursor/rules/nx-rules.mdc .github/instructions/nx.instructions.md diff --git a/.lycheeignore b/.lycheeignore index 1ded23fd4c..671b1bf375 100644 --- a/.lycheeignore +++ b/.lycheeignore @@ -50,6 +50,10 @@ https://stats.tangle.tools # Something happened with conventional commits link, temporary disabled to fix the CI https://www.conventionalcommits.org/en/v1.0.0/ +# External sites that block bots or have intermittent Cloudflare errors +https://www.tangle.tools/ +https://openai.com/index/harness-engineering/ + # Files /**/CHANGELOG.md diff --git a/AGENTS.md b/AGENTS.md index 7e6b6cec5a..5d5c88d7e6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,3 +31,8 @@ - Seed scripts (Substrate dev): - `yarn script:setupServices` (create blueprints) - `yarn script:setupStaking` (LST/vault/operator staking fixtures) + +## Harness runbook +- Operating spec: `docs/harness-engineering-spec.md` +- Execution checklist: `docs/harness-engineering-checklist.md` +- Wallet flow suite usage: `docs/wallet-flow-suite.md` diff --git a/CLAUDE.md b/CLAUDE.md index e8c68bcb16..799b1ea746 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -This is the Tangle dApp monorepo - a collection of decentralized applications serving as the frontend for the Tangle Network, a Substrate-based cryptocurrency network in the Polkadot ecosystem. Tangle is a layer 1 for on-demand services where developers can build and monetize decentralized services using Tangle Blueprints. +This is the Tangle dApp monorepo - a collection of decentralized applications for the Tangle Operator Layer for AI services, built on the TNT EVM protocol stack (`tnt-core`). The monorepo uses Nx for fast, extensible building with `apps/` containing interfaces and `libs/` containing shared code. @@ -45,32 +45,66 @@ yarn generate:release # Review version bumps and changelog ## Architecture & Key Concepts ### Applications (apps/) -- **tangle-dapp**: Main dApp for managing Tangle Network assets and MPC services -- **tangle-cloud**: Cloud interface for Tangle services -- **leaderboard**: Validator leaderboard application + +- **tangle-dapp**: Main dApp for staking, delegation, rewards, migration claims, and wallet flows +- **tangle-cloud**: Operator/developer interface for blueprint and service lifecycle management +- **leaderboard**: Points and participation leaderboard ### Libraries (libs/) + - **abstract-api-provider**: Base classes unifying API across providers - **api-provider-environment**: React contexts, app events, error handling - **browser-utils**: Browser utilities (fetch, download, logger, storage) - **dapp-config**: Chain/wallet configurations for dApps - **dapp-types**: Shared TypeScript types and interfaces - **icons**: Shared icon components -- **polkadot-api-provider**: Substrate/Polkadot provider for blockchain interaction +- **polkadot-api-provider**: Legacy chain provider used only by migration-claim flows - **solana-api-provider**: Solana blockchain provider - **tangle-shared-ui**: Tangle-specific logic, hooks, utilities (shared between dApps) - **ui-components**: Generic reusable UI components - **web3-api-provider**: EVM provider for blockchain interaction ### Tech Stack + - **Frontend**: Vite, TypeScript, React, TailwindCSS -- **Blockchain**: PolkadotJS with auto-generated types (`@tangle-network/tangle-substrate-types`) +- **Blockchain**: EVM-first (`viem`/`wagmi`) with limited PolkadotJS usage for migration-claim interoperability - **Build System**: Nx monorepo - **Styling**: TailwindCSS with custom preset ## Development Guidelines +### Execution Posture (Senior IC / Tech Lead) + +- Default to ownership and execution. When a goal is clear, proceed immediately without asking permission to continue. +- Prefer decisive action over proposal loops. Bring work to completion end-to-end (implementation, verification, reporting). +- Escalate only for true external blockers (missing credentials, unavailable infrastructure, irreversible risk), and name the exact blocker. +- Report status with concrete evidence (commands run, pass/fail, remaining gaps), not vague progress language. +- For release-readiness tasks, drive to production-grade confidence: strict validation, explicit failure reasons, and concrete remediation steps. +- Avoid “do you want me to…” phrasing when the expected next step is obvious from context. +- For launch-flow-impacting changes, follow `docs/harness-engineering-spec.md` and complete `docs/harness-engineering-checklist.md` before requesting merge. + +### Harness Release Process (Succinct) + +- Scope launch-impacting work to explicit flow IDs in `docs/launch-readiness-board.csv`. +- Run harness suite: `yarn test:wallet-flows` and inspect `suite/report.json` + `suite/release-matrix.md`. +- Enforce gate: `yarn test:wallet-flows:gate` (or `:strict` when required). +- Critical flows (`FLOW-001,002,005,010,011,013,014,018,019`) must be `happy-path-pass` unless exception owner/ETA is documented. +- Include matrix summary and gate output in PR using the harness section in `.github/PULL_REQUEST_TEMPLATE.md`. + +### Wallet Flow Reliability (agent-browser-driver) + +- Treat wallet E2E as environment-first: do not trust flow results until local chain + indexer + dApp are confirmed on the same network. +- Minimum readiness gate before running wallet flows: + - `http://127.0.0.1:8545` responds to `eth_chainId` with `0x7a69` (31337) + - Hasura GraphQL endpoint is reachable (typically `http://localhost:8080/v1/graphql`) + - dApp is started with local indexer env (`VITE_ENVIO_MAINNET_ENDPOINT` and `VITE_ENVIO_TESTNET_ENDPOINT` pointing to local Hasura) +- Use `scripts/local-env/start-local-env.sh` for deterministic local protocol state; if Docker ports are occupied (commonly `5433`), resolve port collisions first or set alternate `ENVIO_PG_PORT` / `HASURA_EXTERNAL_PORT`. +- Wallet preflight failures (`no-provider`, connector timeout, chain mismatch) must be treated as blockers for strict launch validation; only allow non-strict continuation for exploratory debugging. +- A suite result with `turns=0` is not valid evidence of agentic flow execution; treat it as runtime/LLM execution failure and fix provider/runtime conditions first. +- For local wallet runs, prefer persistent seeded profile + automated prompt settling, and ensure funding checks are active for connected local accounts. + ### Code Style + - Use `const ... => {}` over `function ... () {}` - React components: `const Component: FC = ({ prop1, prop2 }) => { ... }` - Use `useMemo`/`useCallback` when appropriate (skip for simple calculations) @@ -81,40 +115,47 @@ yarn generate:release # Review version bumps and changelog - Avoid `as` type casting and `any` type ### Folder Structure (within apps) + - `utils/`: Utility functions (one function per file, same filename as function name) - `components/`: Reusable "dumb" components specific to the app - `containers/`: "Smart" components with business logic - `hooks/`: React hooks for infrastructure logic - `data/`: Data fetching hooks organized by domain (staking, liquid staking, etc.) - `pages/`: Route pages for react-router-dom -- `abi/`: EVM ABI definitions for Substrate precompiles +- `abi/`: EVM ABI definitions for precompiles/contracts ### Important Notes + - **Localize changes**: Keep changes isolated to relevant projects unless shared libraries are involved - **Package dependencies**: Don't assume packages exist - check imports or root `package.json` first -- **Number handling**: For values > u32 from chain, use `BN` or `bigint`. For u32 or smaller, use `.toNumber()` +- **Number handling**: Prefer `bigint`/`viem` primitives for chain values; avoid introducing new `BN` usage. - **Monorepo scope**: Avoid cross-project changes unless working with shared libs - **Storybook**: Considered legacy, avoid creating/modifying storybook files - **Testing**: No testing libraries currently used or planned ### Branch Strategy + - Main development branch: `develop` - Main branch for releases: `master` - Release PRs should start with `[RELEASE]` in title ### Prerequisites + - Node.js v18.18.x or later - Yarn package manager (v4.7.0) ## Working with Specific Libraries ### tangle-shared-ui + Contains Tangle-specific logic shared between dApps. Use this for functionality tied to Tangle Network context. ### ui-components + Generic, reusable components not tied to any specific context. Should be usable across different dApps. ### API Providers -- Use `polkadot-api-provider` for Substrate/Polkadot interactions + +- Use `polkadot-api-provider` only where migration-claim compatibility requires it - Use `web3-api-provider` for EVM interactions - Use `abstract-api-provider` base classes when creating new providers diff --git a/agent-browser-driver.config.mjs b/agent-browser-driver.config.mjs index 642fc25f67..932bf27684 100644 --- a/agent-browser-driver.config.mjs +++ b/agent-browser-driver.config.mjs @@ -9,17 +9,34 @@ const parseList = (value) => { .filter(Boolean); }; +const parseBoolean = (value, fallback) => { + if (value === undefined) { + return fallback; + } + + const normalized = String(value).trim().toLowerCase(); + if (['1', 'true', 'yes', 'on'].includes(normalized)) { + return true; + } + if (['0', 'false', 'no', 'off'].includes(normalized)) { + return false; + } + + return fallback; +}; + export default { provider: process.env.AGENT_BROWSER_PROVIDER ?? 'openai', model: process.env.AGENT_BROWSER_MODEL ?? 'gpt-4o', - outputDir: process.env.AGENT_BROWSER_OUTPUT_DIR ?? './agent-results/wallet-flows', + outputDir: + process.env.AGENT_BROWSER_OUTPUT_DIR ?? './agent-results/wallet-flows', maxTurns: Number(process.env.AGENT_BROWSER_MAX_TURNS ?? 60), timeoutMs: Number(process.env.AGENT_BROWSER_TIMEOUT_MS ?? 900_000), vision: true, goalVerification: true, screenshotInterval: 2, concurrency: 1, - headless: false, + headless: parseBoolean(process.env.AGENT_BROWSER_HEADLESS, false), wallet: { enabled: true, extensionPaths: parseList(process.env.AGENT_WALLET_EXTENSION_PATHS), diff --git a/apps/leaderboard/CHANGELOG.md b/apps/leaderboard/CHANGELOG.md index b43999832d..06b8991b3c 100644 --- a/apps/leaderboard/CHANGELOG.md +++ b/apps/leaderboard/CHANGELOG.md @@ -40,7 +40,7 @@ - integrate cloud credits ([#3021](https://github.com/tangle-network/dapp/pull/3021)) - **tangle-cloud:** List operators in the Operators page ([#3005](https://github.com/tangle-network/dapp/pull/3005)) - **tangle-cloud:** List all blueprints ([#2987](https://github.com/tangle-network/dapp/pull/2987)) -- **tangle-dapp:** Create restaking & services setup scripts ([#2986](https://github.com/tangle-network/dapp/pull/2986)) +- **tangle-dapp:** Create staking & services setup scripts ([#2986](https://github.com/tangle-network/dapp/pull/2986)) - **tangle-dapp:** Add blueprint selection ([#2941](https://github.com/tangle-network/dapp/pull/2941)) - **tangle-dapp:** Add Protocol Stats Component ([#2966](https://github.com/tangle-network/dapp/pull/2966)) - **tangle-dapp:** Add Phantom wallet to dApp wallet provider ([#2885](https://github.com/tangle-network/dapp/pull/2885)) @@ -48,7 +48,7 @@ ### 🩹 Fixes - **tangle-dapp:** update result type and reduce refetch interval in useCredits ([#3026](https://github.com/tangle-network/dapp/pull/3026)) -- **tangle-dapp:** Asset modal, restake action tabs & wallet dropdown fixes ([#3002](https://github.com/tangle-network/dapp/pull/3002)) +- **tangle-dapp:** Asset modal, staking action tabs & wallet dropdown fixes ([#3002](https://github.com/tangle-network/dapp/pull/3002)) - **tangle-dapp:** Fix Theme Flickering, Disable Link ([#2953](https://github.com/tangle-network/dapp/pull/2953)) ### 🏡 Chore @@ -57,7 +57,7 @@ - bump @storybook/channels from 8.6.12 to 8.6.14 ([#3025](https://github.com/tangle-network/dapp/pull/3025)) - bump @radix-ui/react-tabs from 1.1.4 to 1.1.9 ([#3006](https://github.com/tangle-network/dapp/pull/3006)) - bump @vitest/ui from 3.1.1 to 3.1.2 ([#3007](https://github.com/tangle-network/dapp/pull/3007)) -- **tangle-dapp:** Add Multiple RPC Endpoints Support for Polkadot APIs ([#2990](https://github.com/tangle-network/dapp/pull/2990)) +- **tangle-dapp:** Add multiple RPC endpoint support for chain APIs ([#2990](https://github.com/tangle-network/dapp/pull/2990)) - Add initial Cursor rules ([#2998](https://github.com/tangle-network/dapp/pull/2998)) - bump framer-motion from 12.7.2 to 12.7.4 ([#2993](https://github.com/tangle-network/dapp/pull/2993)) - bump @hookform/resolvers from 3.10.0 to 5.0.1 ([#2972](https://github.com/tangle-network/dapp/pull/2972)) @@ -67,7 +67,7 @@ - bump typescript-eslint from 8.29.1 to 8.30.0 ([#2967](https://github.com/tangle-network/dapp/pull/2967)) - **tangle-dapp:** Update asset selection modal ([#2965](https://github.com/tangle-network/dapp/pull/2965)) - bump actions/create-github-app-token from 1 to 2 ([#2961](https://github.com/tangle-network/dapp/pull/2961)) -- bump @polkadot/keyring from 13.3.1 to 13.4.3 ([#2962](https://github.com/tangle-network/dapp/pull/2962)) +- bump keyring dependency from 13.3.1 to 13.4.3 ([#2962](https://github.com/tangle-network/dapp/pull/2962)) - **tangle-dapp:** Improve Vault Table ([#2956](https://github.com/tangle-network/dapp/pull/2956)) ### 🎨 Styles @@ -100,4 +100,4 @@ ### ❤️ Thank You -- Trung-Tin Pham @AtelyPham \ No newline at end of file +- Trung-Tin Pham @AtelyPham diff --git a/apps/leaderboard/index.html b/apps/leaderboard/index.html index f81250889a..9103c5ddce 100644 --- a/apps/leaderboard/index.html +++ b/apps/leaderboard/index.html @@ -13,7 +13,7 @@ diff --git a/apps/leaderboard/src/features/indexingProgress/components/SyncProgressIndicator.tsx b/apps/leaderboard/src/features/indexingProgress/components/SyncProgressIndicator.tsx index 30947e1d72..d3ae38cf0f 100644 --- a/apps/leaderboard/src/features/indexingProgress/components/SyncProgressIndicator.tsx +++ b/apps/leaderboard/src/features/indexingProgress/components/SyncProgressIndicator.tsx @@ -16,32 +16,12 @@ export const SyncProgressIndicator = ({ }) => { const { data, error, isPending } = useIndexingProgress(network); - const progress = useMemo(() => { - if (!data?.lastProcessedHeight || !data?.targetHeight) { - return 0; - } - - // Round to 2 decimal places - return ( - Math.round((data.lastProcessedHeight / data.targetHeight) * 100 * 100) / - 100 - ); - }, [data?.lastProcessedHeight, data?.targetHeight]); - - const isSynced = useMemo(() => { - if (!data?.lastProcessedHeight || !data?.targetHeight) { - return false; - } - - return data.lastProcessedHeight === data.targetHeight; - }, [data?.lastProcessedHeight, data?.targetHeight]); - const displayContent = useMemo(() => { if (isPending) { return ( <> - Loading indexing status... + Loading indexer activity... ); } @@ -50,53 +30,41 @@ export const SyncProgressIndicator = ({ return ( <> - Error loading indexing status + Indexer status unavailable + + ); + } + + if (!data) { + return ( + <> + + No indexer metadata ); } return ( <> - + - {isSynced ? 'Synced' : 'Indexing'} - + Indexed block - {data?.lastProcessedHeight ? ( - - ) : ( - EMPTY_VALUE_PLACEHOLDER - )} + - - of - - - {data?.targetHeight ? ( - + + ({' '} + {data.numEventsProcessed > 0 ? ( + ) : ( EMPTY_VALUE_PLACEHOLDER - )} - - - - ( - %) + )}{' '} + events) ); - }, [ - isPending, - error, - isSynced, - data?.lastProcessedHeight, - data?.targetHeight, - progress, - ]); + }, [isPending, error, data]); return ( { - if (network === 'MAINNET') { - return ( - import.meta.env.VITE_ENVIO_MAINNET_ENDPOINT || - 'http://localhost:8080/v1/graphql' - ); - } - return ( - import.meta.env.VITE_ENVIO_TESTNET_ENDPOINT || - 'http://localhost:8080/v1/graphql' - ); -}; - interface ChainMetadataRow { first_event_block_number: number; latest_processed_block: number; @@ -40,30 +32,17 @@ interface ChainMetadataRow { chain_id: number; } +const toEnvioNetwork = (network: NetworkType): EnvioNetwork => { + return network === 'MAINNET' ? 'mainnet' : 'testnet'; +}; + const fetcher = async ( network: NetworkType, ): Promise => { - const endpoint = getEndpoint(network); - - const response = await fetch(endpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json', - }, - body: JSON.stringify({ - query: INDEXING_PROGRESS_QUERY, - }), - }); - - if (!response.ok) { - throw new Error('Network response was not ok'); - } - - const result = (await response.json()) as { - data: { chain_metadata: ChainMetadataRow[] }; - errors?: Array<{ message: string }>; - }; + const result = await executeEnvioGraphQL< + { chain_metadata: ChainMetadataRow[] }, + Record + >(INDEXING_PROGRESS_QUERY, {}, toEnvioNetwork(network)); if (result.errors?.length) { console.warn('GraphQL errors:', result.errors); @@ -75,10 +54,11 @@ const fetcher = async ( return null; } - // Envio tracks latest_processed_block, we estimate target as a bit ahead return { - lastProcessedHeight: metadata.latest_processed_block, - targetHeight: metadata.latest_processed_block + 1, // Estimate target + firstEventBlockNumber: metadata.first_event_block_number, + latestProcessedBlock: metadata.latest_processed_block, + numEventsProcessed: metadata.num_events_processed, + chainId: metadata.chain_id, }; }; diff --git a/apps/leaderboard/src/features/leaderboard/components/LeaderboardTable.tsx b/apps/leaderboard/src/features/leaderboard/components/LeaderboardTable.tsx index 90f909cb24..ffc812d83f 100644 --- a/apps/leaderboard/src/features/leaderboard/components/LeaderboardTable.tsx +++ b/apps/leaderboard/src/features/leaderboard/components/LeaderboardTable.tsx @@ -26,7 +26,7 @@ import { useReactTable, } from '@tanstack/react-table'; import cx from 'classnames'; -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { twMerge } from 'tailwind-merge'; import { useLatestTimestamp } from '../../../queries'; import { SyncProgressIndicator } from '../../indexingProgress'; @@ -35,6 +35,7 @@ import { getAccountIdsForRoles, useLeaderboard, useRoleAccounts, + useRoleCounts, } from '../queries'; import { Account } from '../types'; import { createAccountExplorerUrl } from '../utils/createAccountExplorerUrl'; @@ -191,8 +192,10 @@ export const LeaderboardTable = () => { error: timestampError, } = useLatestTimestamp(networkTab); + const { data: roleCounts, isPending: isRoleCountsPending } = + useRoleCounts(networkTab); const { data: roleAccounts, isPending: isRoleAccountsPending } = - useRoleAccounts(networkTab); + useRoleAccounts(networkTab, selectedRoles); const roleFilteredAccountIds = useMemo(() => { if (selectedRoles.length === 0 || !roleAccounts) { @@ -201,15 +204,11 @@ export const LeaderboardTable = () => { return getAccountIdsForRoles(roleAccounts, selectedRoles); }, [selectedRoles, roleAccounts]); - const roleCounts = useMemo(() => { - if (!roleAccounts) return undefined; - return { - operators: roleAccounts.operators.size, - stakers: roleAccounts.stakers.size, - developers: roleAccounts.developers.size, - customers: roleAccounts.customers.size, - }; - }, [roleAccounts]); + useEffect(() => { + setPagination((prev) => + prev.pageIndex === 0 ? prev : { ...prev, pageIndex: 0 }, + ); + }, [searchQuery, networkTab]); // Calculate timestamp for 7 days ago (Envio uses timestamps instead of block numbers) const timestampSevenDaysAgo = useMemo(() => { @@ -317,6 +316,19 @@ export const LeaderboardTable = () => { roleFilteredAccountIds, ]); + const totalRecords = useMemo(() => { + const roleFilteringActive = selectedRoles.length > 0; + if (shouldUseClientSideFiltering || roleFilteringActive) { + return data.length; + } + return leaderboardData?.totalCount ?? data.length; + }, [ + shouldUseClientSideFiltering, + selectedRoles.length, + data.length, + leaderboardData?.totalCount, + ]); + const table = useReactTable({ data, columns: COLUMNS, @@ -326,7 +338,7 @@ export const LeaderboardTable = () => { expanded, pagination, }, - rowCount: leaderboardData?.totalCount, + rowCount: totalRecords, onPaginationChange: setPagination, getCoreRowModel: getCoreRowModel(), onExpandedChange: setExpanded, @@ -373,7 +385,10 @@ export const LeaderboardTable = () => { selectedRoles={selectedRoles} onRoleToggle={handleRoleToggle} onClearAll={handleClearRoles} - isLoading={isRoleAccountsPending} + isLoading={ + isRoleCountsPending || + (selectedRoles.length > 0 && isRoleAccountsPending) + } roleCounts={roleCounts} /> @@ -424,7 +439,7 @@ export const LeaderboardTable = () => { { + const configured = import.meta.env.VITE_LEADERBOARD_EXCLUDED_ACCOUNTS as + | string + | undefined; + + if (!configured || configured.trim().length === 0) { + return [...DEFAULT_EXCLUDED_ACCOUNT_IDS]; + } + + const parsed = configured + .split(',') + .map((entry) => entry.trim().toLowerCase()) + .filter((entry) => entry.length > 0); + + if (parsed.length === 0) { + return [...DEFAULT_EXCLUDED_ACCOUNT_IDS]; + } + + return [...new Set(parsed)]; +}; + +const EXCLUDED_LEADERBOARD_ACCOUNT_IDS = parseExcludedLeaderboardAccountIds(); +const EXCLUDED_LEADERBOARD_ACCOUNT_SET = new Set( + EXCLUDED_LEADERBOARD_ACCOUNT_IDS, +); + +const toEnvioNetwork = (network: NetworkType): EnvioNetwork => { + return network === 'MAINNET' ? 'mainnet' : 'testnet'; +}; + +const isAggregateFieldUnavailable = ( + errors?: Array<{ message: string }>, +): boolean => { + if (!errors || errors.length === 0) { + return false; + } + + return errors.some((error) => { + const message = error.message.toLowerCase(); + return ( + message.includes('cannot query field') && message.includes('_aggregate') + ); + }); +}; + /** - * PointsAccount node from NVO indexer + * PointsAccount node from Envio indexer */ export interface LeaderboardAccountNodeType { id: string; @@ -64,6 +112,15 @@ export interface LeaderboardAccountWithActivity interface LeaderboardQueryResponse { PointsAccount: LeaderboardAccountNodeType[]; + PointsAccount_aggregate?: { + aggregate?: { + count: number; + }; + }; +} + +interface LeaderboardFallbackCountResponse { + PointsAccount: Array<{ id: string }>; } interface ActivityQueryResponse { @@ -74,31 +131,46 @@ interface ActivityQueryResponse { JobCall: Array<{ id: string; caller: string }>; } -const getEndpoint = (network: NetworkType): string => { - if (network === 'MAINNET') { - return ( - import.meta.env.VITE_ENVIO_MAINNET_ENDPOINT || - 'http://localhost:8080/v1/graphql' - ); - } - return ( - import.meta.env.VITE_ENVIO_TESTNET_ENDPOINT || - 'http://localhost:8080/v1/graphql' - ); -}; +interface RoleAccountsResponse { + Operator?: Array<{ id: string }>; + Delegator?: Array<{ id: string }>; + Blueprint?: Array<{ owner: string }>; + JobCall?: Array<{ caller: string }>; +} + +interface RoleCountsResponse { + Operator_aggregate?: { + aggregate?: { count: number }; + }; + Delegator_aggregate?: { + aggregate?: { count: number }; + }; + Blueprint_aggregate?: { + aggregate?: { count: number }; + }; + JobCall_aggregate?: { + aggregate?: { count: number }; + }; +} const LEADERBOARD_QUERY = ` query LeaderboardQuery( $limit: Int! $offset: Int! $timestampSevenDaysAgo: numeric! - $accountIdQuery: String + $accountIdQuery: String! + $excludedAccountIds: [String!] ) { PointsAccount( limit: $limit offset: $offset order_by: { leaderboardPoints: desc } - where: { id: { _ilike: $accountIdQuery } } + where: { + id: { + _ilike: $accountIdQuery + _nin: $excludedAccountIds + } + } ) { id totalPoints @@ -116,6 +188,40 @@ const LEADERBOARD_QUERY = ` totalPoints } } + PointsAccount_aggregate( + where: { + id: { + _ilike: $accountIdQuery + _nin: $excludedAccountIds + } + } + ) { + aggregate { + count + } + } + } +`; + +const LEADERBOARD_COUNT_FALLBACK_QUERY = ` + query LeaderboardCountFallback( + $limit: Int! + $offset: Int! + $accountIdQuery: String! + $excludedAccountIds: [String!] + ) { + PointsAccount( + limit: $limit + offset: $offset + where: { + id: { + _ilike: $accountIdQuery + _nin: $excludedAccountIds + } + } + ) { + id + } } `; @@ -159,6 +265,130 @@ const ACTIVITY_QUERY = ` } `; +const ROLE_ACCOUNTS_QUERY = ` + query RoleAccounts( + $includeOperators: Boolean! + $includeStakers: Boolean! + $includeDevelopers: Boolean! + $includeCustomers: Boolean! + ) { + Operator @include(if: $includeOperators) { + id + } + Delegator( + where: { + _or: [ + { totalDeposited: { _gt: "0" } } + { totalDelegated: { _gt: "0" } } + ] + } + ) @include(if: $includeStakers) { + id + } + Blueprint( + distinct_on: owner + order_by: { owner: asc } + ) @include(if: $includeDevelopers) { + owner + } + JobCall( + distinct_on: caller + order_by: { caller: asc } + ) @include(if: $includeCustomers) { + caller + } + } +`; + +const ROLE_COUNTS_QUERY = ` + query RoleCounts { + Operator_aggregate { + aggregate { + count + } + } + Delegator_aggregate( + where: { + _or: [ + { totalDeposited: { _gt: "0" } } + { totalDelegated: { _gt: "0" } } + ] + } + ) { + aggregate { + count + } + } + Blueprint_aggregate { + aggregate { + count(columns: owner, distinct: true) + } + } + JobCall_aggregate { + aggregate { + count(columns: caller, distinct: true) + } + } + } +`; + +const normalizeAccountIds = (accounts: string[]): Set => { + return new Set( + accounts + .map((entry) => entry.toLowerCase()) + .filter((entry) => !EXCLUDED_LEADERBOARD_ACCOUNT_SET.has(entry)), + ); +}; + +const computeFallbackTotalCount = async ( + envioNetwork: EnvioNetwork, + accountIdQuery: string, +): Promise => { + const fallbackPageSize = 1000; + let totalCount = 0; + let offset = 0; + + while (true) { + const fallbackResult = await executeEnvioGraphQL< + LeaderboardFallbackCountResponse, + { + limit: number; + offset: number; + accountIdQuery: string; + excludedAccountIds: string[]; + } + >( + LEADERBOARD_COUNT_FALLBACK_QUERY, + { + limit: fallbackPageSize, + offset, + accountIdQuery, + excludedAccountIds: EXCLUDED_LEADERBOARD_ACCOUNT_IDS, + }, + envioNetwork, + ); + + if (fallbackResult.errors?.length) { + throw new Error( + `Failed to fetch fallback leaderboard count: ${fallbackResult.errors + .map((error) => error.message) + .join('; ')}`, + ); + } + + const pageSize = fallbackResult.data.PointsAccount.length; + totalCount += pageSize; + + if (pageSize < fallbackPageSize) { + break; + } + + offset += fallbackPageSize; + } + + return totalCount; +}; + const fetchLeaderboard = async ( network: NetworkType, limit: number, @@ -166,42 +396,55 @@ const fetchLeaderboard = async ( timestampSevenDaysAgo: number, accountIdQuery?: string, ): Promise<{ nodes: LeaderboardAccountNodeType[]; totalCount: number }> => { - const endpoint = getEndpoint(network); - - const response = await fetch(endpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json', + const envioNetwork = toEnvioNetwork(network); + const accountQuery = accountIdQuery ? `%${accountIdQuery}%` : '%%'; + + const result = await executeEnvioGraphQL< + LeaderboardQueryResponse, + { + limit: number; + offset: number; + timestampSevenDaysAgo: number; + accountIdQuery: string; + excludedAccountIds: string[]; + } + >( + LEADERBOARD_QUERY, + { + limit, + offset, + timestampSevenDaysAgo, + accountIdQuery: accountQuery, + excludedAccountIds: EXCLUDED_LEADERBOARD_ACCOUNT_IDS, }, - body: JSON.stringify({ - query: LEADERBOARD_QUERY, - variables: { - limit, - offset, - timestampSevenDaysAgo, - accountIdQuery: accountIdQuery ? `%${accountIdQuery}%` : '%%', - }, - }), - }); + envioNetwork, + ); - if (!response.ok) { - throw new Error('Network response was not ok'); + if (result.errors?.length && !isAggregateFieldUnavailable(result.errors)) { + throw new Error( + `Failed to fetch leaderboard data: ${result.errors + .map((error) => error.message) + .join('; ')}`, + ); } - const result = (await response.json()) as { data: LeaderboardQueryResponse }; + const nodes = result.data.PointsAccount; - // Filter out team accounts - const filteredAccounts = result.data.PointsAccount.filter( - (account) => - !TEAM_ACCOUNTS.includes( - account.id.toLowerCase() as (typeof TEAM_ACCOUNTS)[number], - ), + if (!result.errors?.length) { + return { + nodes, + totalCount: result.data.PointsAccount_aggregate?.aggregate?.count ?? 0, + }; + } + + const totalCount = await computeFallbackTotalCount( + envioNetwork, + accountQuery, ); return { - nodes: filteredAccounts, - totalCount: filteredAccounts.length, + nodes, + totalCount, }; }; @@ -209,25 +452,19 @@ const fetchAccountActivity = async ( network: NetworkType, accountId: string, ): Promise => { - const endpoint = getEndpoint(network); - - const response = await fetch(endpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json', - }, - body: JSON.stringify({ - query: ACTIVITY_QUERY, - variables: { accountId }, - }), - }); - - if (!response.ok) { - throw new Error('Network response was not ok'); + const result = await executeEnvioGraphQL< + ActivityQueryResponse, + { accountId: string } + >(ACTIVITY_QUERY, { accountId }, toEnvioNetwork(network)); + + if (result.errors?.length) { + throw new Error( + `Failed to fetch account activity: ${result.errors + .map((error) => error.message) + .join('; ')}`, + ); } - const result = (await response.json()) as { data: ActivityQueryResponse }; return result.data; }; @@ -249,6 +486,7 @@ export function useLeaderboard( offset, timestampSevenDaysAgo, accountIdQuery, + EXCLUDED_LEADERBOARD_ACCOUNT_IDS.join(','), ], queryFn: () => fetchLeaderboard( @@ -273,33 +511,6 @@ export function useAccountActivity(network: NetworkType, accountId: string) { }); } -const ROLE_ACCOUNTS_QUERY = ` - query RoleAccounts { - Operator { - id - } - Delegator(where: { _or: [ - { totalDeposited: { _gt: "0" } }, - { totalDelegated: { _gt: "0" } } - ]}) { - id - } - Blueprint { - owner - } - JobCall { - caller - } - } -`; - -interface RoleAccountsResponse { - Operator: Array<{ id: string }>; - Delegator: Array<{ id: string }>; - Blueprint: Array<{ owner: string }>; - JobCall: Array<{ caller: string }>; -} - export interface RoleAccountsData { operators: Set; stakers: Set; @@ -307,35 +518,92 @@ export interface RoleAccountsData { customers: Set; } +export interface RoleCountsData { + operators: number; + stakers: number; + developers: number; + customers: number; +} + +const fetchRoleCounts = async ( + network: NetworkType, +): Promise => { + const result = await executeEnvioGraphQL< + RoleCountsResponse, + Record + >(ROLE_COUNTS_QUERY, {}, toEnvioNetwork(network)); + + if (result.errors?.length) { + // Keep role filtering functional even if aggregate capabilities vary by environment. + return undefined; + } + + return { + operators: result.data.Operator_aggregate?.aggregate?.count ?? 0, + stakers: result.data.Delegator_aggregate?.aggregate?.count ?? 0, + developers: result.data.Blueprint_aggregate?.aggregate?.count ?? 0, + customers: result.data.JobCall_aggregate?.aggregate?.count ?? 0, + }; +}; + const fetchRoleAccounts = async ( network: NetworkType, + selectedRoles: RoleFilterEnum[], ): Promise => { - const endpoint = getEndpoint(network); + if (selectedRoles.length === 0) { + return { + operators: new Set(), + stakers: new Set(), + developers: new Set(), + customers: new Set(), + }; + } - const response = await fetch(endpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json', + const includeOperators = selectedRoles.includes(RoleFilterEnum.OPERATOR); + const includeStakers = selectedRoles.includes(RoleFilterEnum.STAKER); + const includeDevelopers = selectedRoles.includes(RoleFilterEnum.DEVELOPER); + const includeCustomers = selectedRoles.includes(RoleFilterEnum.CUSTOMER); + + const result = await executeEnvioGraphQL< + RoleAccountsResponse, + { + includeOperators: boolean; + includeStakers: boolean; + includeDevelopers: boolean; + includeCustomers: boolean; + } + >( + ROLE_ACCOUNTS_QUERY, + { + includeOperators, + includeStakers, + includeDevelopers, + includeCustomers, }, - body: JSON.stringify({ - query: ROLE_ACCOUNTS_QUERY, - }), - }); + toEnvioNetwork(network), + ); - if (!response.ok) { - throw new Error('Network response was not ok'); + if (result.errors?.length) { + throw new Error( + `Failed to fetch role accounts: ${result.errors + .map((error) => error.message) + .join('; ')}`, + ); } - const result = (await response.json()) as { data: RoleAccountsResponse }; - return { - operators: new Set(result.data.Operator.map((o) => o.id.toLowerCase())), - stakers: new Set(result.data.Delegator.map((d) => d.id.toLowerCase())), - developers: new Set( - result.data.Blueprint.map((b) => b.owner.toLowerCase()), + operators: normalizeAccountIds( + (result.data.Operator ?? []).map((item) => item.id), + ), + stakers: normalizeAccountIds( + (result.data.Delegator ?? []).map((item) => item.id), + ), + developers: normalizeAccountIds( + (result.data.Blueprint ?? []).map((item) => item.owner), + ), + customers: normalizeAccountIds( + (result.data.JobCall ?? []).map((item) => item.caller), ), - customers: new Set(result.data.JobCall.map((j) => j.caller.toLowerCase())), }; }; @@ -369,10 +637,24 @@ export const getAccountIdsForRoles = ( return accountIds; }; -export function useRoleAccounts(network: NetworkType) { +export function useRoleAccounts( + network: NetworkType, + selectedRoles: RoleFilterEnum[], +) { + const sortedRoles = [...selectedRoles].sort(); + return useQuery({ - queryKey: ['roleAccounts', network], - queryFn: () => fetchRoleAccounts(network), + queryKey: ['roleAccounts', network, sortedRoles.join(',')], + queryFn: () => fetchRoleAccounts(network, sortedRoles), + enabled: sortedRoles.length > 0, staleTime: 30_000, }); } + +export function useRoleCounts(network: NetworkType) { + return useQuery({ + queryKey: ['roleCounts', network], + queryFn: () => fetchRoleCounts(network), + staleTime: 60_000, + }); +} diff --git a/apps/leaderboard/src/pages/index.tsx b/apps/leaderboard/src/pages/index.tsx index 4eabe0f594..cc03399f57 100644 --- a/apps/leaderboard/src/pages/index.tsx +++ b/apps/leaderboard/src/pages/index.tsx @@ -14,8 +14,8 @@ export default function IndexPage() { Tangle leaderboard ranks contributors based on experience points (XP) - earned from network activities like staking, nominating, and running - services.{' '} + earned from network activities like staking, deploying blueprints, and + running services.{' '} + + )} + + ); +}; + +export default AmountInput; diff --git a/apps/tangle-cloud/src/components/payments/CreditAccountCard.tsx b/apps/tangle-cloud/src/components/payments/CreditAccountCard.tsx new file mode 100644 index 0000000000..2b31edfe66 --- /dev/null +++ b/apps/tangle-cloud/src/components/payments/CreditAccountCard.tsx @@ -0,0 +1,85 @@ +import { FC } from 'react'; +import { formatUnits } from 'viem'; +import { shortenHex } from '@tangle-network/ui-components/utils/shortenHex'; +import type { CreditAccountState } from '../../types/shielded'; +import { TOKEN_DECIMALS } from '../../constants/payments'; + +type Props = { + commitment: string; + label?: string; + accountState?: CreditAccountState; + isLoading?: boolean; + onDelete?: () => void; +}; + +const CreditAccountCard: FC = ({ + commitment, + label, + accountState, + isLoading, + onDelete, +}) => { + return ( +
+
+
+ + {label ?? 'Credit Account'} + + +

+ {shortenHex(commitment, 8)} +

+
+ + {isLoading && ( +
+ )} +
+ + {accountState && ( +
+
+ + Balance + + + + {formatUnits(accountState.balance, TOKEN_DECIMALS)} + +
+ +
+
+ Total Funded +

+ {formatUnits(accountState.totalFunded, TOKEN_DECIMALS)} +

+
+ +
+ Total Spent +

+ {formatUnits(accountState.totalSpent, TOKEN_DECIMALS)} +

+
+
+ + {onDelete && ( +
+ +
+ )} +
+ )} +
+ ); +}; + +export default CreditAccountCard; diff --git a/apps/tangle-cloud/src/components/payments/NoteCard.tsx b/apps/tangle-cloud/src/components/payments/NoteCard.tsx new file mode 100644 index 0000000000..fb2c64b787 --- /dev/null +++ b/apps/tangle-cloud/src/components/payments/NoteCard.tsx @@ -0,0 +1,66 @@ +import { FC } from 'react'; +import { formatUnits } from 'viem'; +import { shortenHex } from '@tangle-network/ui-components/utils/shortenHex'; +import type { NoteData } from '../../types/shielded'; +import { TOKEN_DECIMALS } from '../../constants/payments'; + +type Props = { + note: NoteData; + onDelete?: () => void; + compact?: boolean; +}; + +const NoteCard: FC = ({ note, onDelete, compact = false }) => { + if (compact) { + return ( +
+ + {formatUnits(note.amount, TOKEN_DECIMALS)} {note.tokenSymbol} + + + + #{note.index ?? 'pending'} + +
+ ); + } + + return ( +
+
+ + {formatUnits(note.amount, TOKEN_DECIMALS)} {note.tokenSymbol} + + + {onDelete && ( + + )} +
+ +
+
+ Pool + {shortenHex(note.targetAnchor, 6)} +
+ +
+ Chain + {note.targetChainId} +
+ +
+ Index + {note.index ?? 'Unconfirmed'} +
+
+
+ ); +}; + +export default NoteCard; diff --git a/apps/tangle-cloud/src/components/payments/ProofProgressIndicator.tsx b/apps/tangle-cloud/src/components/payments/ProofProgressIndicator.tsx new file mode 100644 index 0000000000..71a4225c05 --- /dev/null +++ b/apps/tangle-cloud/src/components/payments/ProofProgressIndicator.tsx @@ -0,0 +1,84 @@ +import { FC } from 'react'; +import { ProofStage, type ProofProgress } from '../../types/shielded'; + +type Props = { + progress: ProofProgress; +}; + +const STAGE_LABELS: Record = { + [ProofStage.IDLE]: 'Ready', + [ProofStage.FETCHING_ARTIFACTS]: 'Downloading circuit artifacts...', + [ProofStage.SYNCING_LEAVES]: 'Syncing Merkle tree leaves...', + [ProofStage.BUILDING_WITNESS]: 'Building witness inputs...', + [ProofStage.GENERATING_PROOF]: 'Generating ZK proof...', + [ProofStage.SENDING_TX]: 'Sending transaction...', + [ProofStage.DONE]: 'Complete', + [ProofStage.ERROR]: 'Error', +}; + +const STAGE_ORDER = [ + ProofStage.FETCHING_ARTIFACTS, + ProofStage.SYNCING_LEAVES, + ProofStage.BUILDING_WITNESS, + ProofStage.GENERATING_PROOF, + ProofStage.SENDING_TX, +]; + +const ProofProgressIndicator: FC = ({ progress }) => { + const currentIndex = STAGE_ORDER.indexOf(progress.stage); + const isActive = + progress.stage !== ProofStage.IDLE && + progress.stage !== ProofStage.DONE && + progress.stage !== ProofStage.ERROR; + + if (progress.stage === ProofStage.IDLE) { + return null; + } + + return ( +
+
+ + {STAGE_LABELS[progress.stage]} + + + {isActive && ( +
+ )} + + {progress.stage === ProofStage.DONE && ( + + )} + + {progress.stage === ProofStage.ERROR && ( + + )} +
+ + {isActive && ( +
+ {STAGE_ORDER.map((stage, i) => ( +
+ ))} +
+ )} + + {progress.message && ( +

+ {progress.message} +

+ )} +
+ ); +}; + +export default ProofProgressIndicator; diff --git a/apps/tangle-cloud/src/constants/networks.ts b/apps/tangle-cloud/src/constants/networks.ts new file mode 100644 index 0000000000..2ef1140944 --- /dev/null +++ b/apps/tangle-cloud/src/constants/networks.ts @@ -0,0 +1,9 @@ +import { + ANVIL_LOCAL_NETWORK, + BASE_SEPOLIA_NETWORK, +} from '@tangle-network/ui-components/constants/networks'; + +export const TANGLE_CLOUD_NETWORKS = [ + ANVIL_LOCAL_NETWORK, + BASE_SEPOLIA_NETWORK, +]; diff --git a/apps/tangle-cloud/src/constants/payments.ts b/apps/tangle-cloud/src/constants/payments.ts new file mode 100644 index 0000000000..2ad7d1af68 --- /dev/null +++ b/apps/tangle-cloud/src/constants/payments.ts @@ -0,0 +1,11 @@ +// Shielded payment contract addresses — populated per-network via env vars. +export const SHIELDED_GATEWAY_ADDRESS = + import.meta.env.VITE_SHIELDED_GATEWAY_ADDRESS ?? ''; + +export const SHIELDED_CREDITS_ADDRESS = + import.meta.env.VITE_SHIELDED_CREDITS_ADDRESS ?? ''; + +export const WRAPPED_TOKEN_ADDRESS = + import.meta.env.VITE_WRAPPED_TOKEN_ADDRESS ?? ''; + +export const TOKEN_DECIMALS = 18; diff --git a/apps/tangle-cloud/src/containers/payments/CreditBalanceContainer.tsx b/apps/tangle-cloud/src/containers/payments/CreditBalanceContainer.tsx new file mode 100644 index 0000000000..048f7bad15 --- /dev/null +++ b/apps/tangle-cloud/src/containers/payments/CreditBalanceContainer.tsx @@ -0,0 +1,99 @@ +import { FC } from 'react'; +import { + Typography, + Card, + CardVariant, + SkeletonLoader, +} from '@tangle-network/ui-components'; +import { useCreditsContext } from '../../app/CreditsProvider'; +import CreditAccountCard from '../../components/payments/CreditAccountCard'; +import useCreditAccountState from '../../data/payments/useCreditAccountState'; +import type { Hex } from 'viem'; +import type { CreditAccountState } from '../../types/shielded'; + +const CreditAccountWithState: FC<{ + commitment: string; + label?: string; + onRemove: () => void; +}> = ({ commitment, label, onRemove }) => { + const { data, isLoading } = useCreditAccountState(commitment as Hex); + + let accountState: CreditAccountState | undefined; + if (data && typeof data === 'object') { + const d = data as Record; + if ('balance' in d) { + accountState = { + spendingKey: String(d.spendingKey ?? ''), + token: String(d.token ?? ''), + balance: BigInt((d.balance as bigint) ?? 0), + totalFunded: BigInt((d.totalFunded as bigint) ?? 0), + totalSpent: BigInt((d.totalSpent as bigint) ?? 0), + nonce: BigInt((d.nonce as bigint) ?? 0), + }; + } + } + + return ( + + ); +}; + +const CreditBalanceContainer: FC = () => { + const { creditAccounts, removeCreditAccount, isLoading } = + useCreditsContext(); + + if (isLoading) { + return ( +
+ + +
+ + +
+
+ ); + } + + return ( +
+
+ + Credit Accounts + + + + Your anonymous credit accounts. Each shows on-chain balance and usage. + +
+ + {creditAccounts.length === 0 ? ( + + + No credit accounts yet. Fund one from the shielded pool to get + started. + + + ) : ( +
+ {creditAccounts.map((acct) => ( + removeCreditAccount(acct.commitment)} + /> + ))} +
+ )} +
+ ); +}; + +export default CreditBalanceContainer; diff --git a/apps/tangle-cloud/src/containers/payments/DepositContainer.tsx b/apps/tangle-cloud/src/containers/payments/DepositContainer.tsx new file mode 100644 index 0000000000..28b58333e9 --- /dev/null +++ b/apps/tangle-cloud/src/containers/payments/DepositContainer.tsx @@ -0,0 +1,91 @@ +import { FC, useState } from 'react'; +import { useAccount } from 'wagmi'; +import { type Address } from 'viem'; +import { Typography, Alert, Button } from '@tangle-network/ui-components'; +import AmountInput from '../../components/payments/AmountInput'; +import { useShieldedContext } from '../../app/ShieldedProvider'; +import useTokenBalance from '../../data/payments/useTokenBalance'; +import { WRAPPED_TOKEN_ADDRESS } from '../../constants/payments'; + +const DepositContainer: FC = () => { + const { address } = useAccount(); + const { + hasDerivedKeypair, + hasStoredKeypair, + deriveKeypair, + unlockKeypair, + isDerivingKeypair, + } = useShieldedContext(); + + const [amount, setAmount] = useState(''); + + const { data: balance } = useTokenBalance( + WRAPPED_TOKEN_ADDRESS as Address, + address, + ); + + return ( +
+
+ + Deposit to Shielded Pool + + + + Move tokens from your public wallet into the shielded pool. A ZK proof + will be generated and a shielded note stored in your browser. + +
+ + {!hasDerivedKeypair && ( + + + + )} + + + + + + + + {!WRAPPED_TOKEN_ADDRESS && ( + + )} +
+ ); +}; + +export default DepositContainer; diff --git a/apps/tangle-cloud/src/containers/payments/FundCreditsContainer.tsx b/apps/tangle-cloud/src/containers/payments/FundCreditsContainer.tsx new file mode 100644 index 0000000000..4e20de859f --- /dev/null +++ b/apps/tangle-cloud/src/containers/payments/FundCreditsContainer.tsx @@ -0,0 +1,104 @@ +import { FC, useCallback, useState } from 'react'; +import { useAccount } from 'wagmi'; +import { + Typography, + Alert, + Button, + Input, +} from '@tangle-network/ui-components'; +import { useShieldedContext } from '../../app/ShieldedProvider'; +import { useCreditsContext } from '../../app/CreditsProvider'; + +const FundCreditsContainer: FC = () => { + const { address } = useAccount(); + const { hasDerivedKeypair } = useShieldedContext(); + const { generateAndStoreCreditKeys, creditAccounts, isUnlocked } = + useCreditsContext(); + + const [label, setLabel] = useState(''); + const [isGenerating, setIsGenerating] = useState(false); + const [fundedCommitment, setFundedCommitment] = useState(null); + const [error, setError] = useState(null); + + const handleGenerate = useCallback(async () => { + if (!address) return; + + setIsGenerating(true); + setError(null); + setFundedCommitment(null); + + try { + const creditKeys = await generateAndStoreCreditKeys( + label || `Account ${creditAccounts.length + 1}`, + ); + setFundedCommitment(creditKeys.commitment); + setLabel(''); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to generate keys'); + } finally { + setIsGenerating(false); + } + }, [address, label, generateAndStoreCreditKeys, creditAccounts.length]); + + return ( +
+
+ + Create Credit Account + + + + Generate an ephemeral keypair for anonymous credit payments. The keys + are encrypted and stored in your browser. On-chain funding requires + SDK integration. + +
+ + {!hasDerivedKeypair && ( + + )} + +
+ + Account Label (optional) + + + +
+ + {error && } + + {fundedCommitment && ( + + + {fundedCommitment} + + + )} + + +
+ ); +}; + +export default FundCreditsContainer; diff --git a/apps/tangle-cloud/src/containers/payments/SpendAuthContainer.tsx b/apps/tangle-cloud/src/containers/payments/SpendAuthContainer.tsx new file mode 100644 index 0000000000..f3acd40269 --- /dev/null +++ b/apps/tangle-cloud/src/containers/payments/SpendAuthContainer.tsx @@ -0,0 +1,359 @@ +import { FC, useCallback, useState } from 'react'; +import { + isAddress, + keccak256, + encodeAbiParameters, + parseAbiParameters, + concat, + toBytes, + parseUnits, + type Hex, +} from 'viem'; +import { privateKeyToAccount } from 'viem/accounts'; +import { useCreditsContext } from '../../app/CreditsProvider'; +import useCreditAccountState from '../../data/payments/useCreditAccountState'; +import useDomainSeparator from '../../data/payments/useDomainSeparator'; +import type { StoredCreditKeys } from '../../utils/payments/indexedDbCreditStorage'; +import { TOKEN_DECIMALS } from '../../constants/payments'; + +const SPEND_TYPEHASH = keccak256( + toBytes( + 'SpendAuthorization(bytes32 commitment,uint64 serviceId,uint8 jobIndex,uint256 amount,address operator,uint256 nonce,uint64 expiry)', + ), +); + +const validateServiceId = (v: string): bigint | null => { + try { + const n = BigInt(v); + if (n < 0n || n >= 2n ** 64n) return null; + return n; + } catch { + return null; + } +}; + +const validateJobIndex = (v: string): number | null => { + const n = Number(v); + if (!Number.isInteger(n) || n < 0 || n > 255) return null; + return n; +}; + +const validateExpiry = (v: string): number | null => { + const n = Number(v); + if (!Number.isFinite(n) || n <= 0) return null; + return n; +}; + +const SpendAuthContainer: FC = () => { + const { creditAccounts } = useCreditsContext(); + const { data: domainSeparator } = useDomainSeparator(); + + const [selectedAccount, setSelectedAccount] = + useState(null); + const [serviceId, setServiceId] = useState(''); + const [jobIndex, setJobIndex] = useState('0'); + const [amount, setAmount] = useState(''); + const [operator, setOperator] = useState(''); + const [expiryMinutes, setExpiryMinutes] = useState('60'); + const [signedAuth, setSignedAuth] = useState(null); + const [error, setError] = useState(null); + + const { refetch: refetchAccount } = useCreditAccountState( + selectedAccount?.commitment as Hex | undefined, + ); + + const isValidOperator = operator === '' || isAddress(operator); + + const handleSign = useCallback(async () => { + if ( + !selectedAccount || + !operator || + !isAddress(operator) || + !domainSeparator + ) { + return; + } + + if (!selectedAccount.spendingPrivateKey || selectedAccount.isLocked) { + setError( + 'This credit account is locked. Unlock your shielded keypair to sign authorizations.', + ); + return; + } + + // Validate all numeric inputs + const parsedServiceId = validateServiceId(serviceId); + if (parsedServiceId === null) { + setError('Service ID must be a non-negative integer < 2^64'); + return; + } + + const parsedJobIndex = validateJobIndex(jobIndex); + if (parsedJobIndex === null) { + setError('Job index must be 0-255'); + return; + } + + if (!amount) { + setError('Amount is required'); + return; + } + + const parsedExpiry = validateExpiry(expiryMinutes); + if (parsedExpiry === null) { + setError('Expiry must be a positive number of minutes'); + return; + } + + setError(null); + setSignedAuth(null); + + try { + // Refetch to get latest nonce — fail closed if read fails + const { data: freshState } = await refetchAccount(); + if ( + !freshState || + typeof freshState !== 'object' || + !('nonce' in freshState) + ) { + setError( + 'Failed to read credit account state from chain. Cannot determine nonce.', + ); + return; + } + const nonce = BigInt((freshState as { nonce: bigint }).nonce); + + const parsedAmount = parseUnits(amount, TOKEN_DECIMALS); + const expiry = BigInt(Math.floor(Date.now() / 1000) + parsedExpiry * 60); + + // EIP-712 struct hash + const structHash = keccak256( + encodeAbiParameters( + parseAbiParameters( + 'bytes32, bytes32, uint64, uint8, uint256, address, uint256, uint64', + ), + [ + SPEND_TYPEHASH, + selectedAccount.commitment as Hex, + parsedServiceId, + parsedJobIndex, + parsedAmount, + operator as Hex, + nonce, + expiry, + ], + ), + ); + + // EIP-712 digest: \x19\x01 || domainSeparator || structHash + const EIP712_PREFIX = new Uint8Array([0x19, 0x01]); + const digest = keccak256( + concat([ + EIP712_PREFIX, + toBytes(domainSeparator as Hex), + toBytes(structHash), + ]), + ); + + // Raw signature (no personal_sign prefix) + const account = privateKeyToAccount( + selectedAccount.spendingPrivateKey as Hex, + ); + const signature = await account.sign({ hash: digest }); + + const auth = { + commitment: selectedAccount.commitment, + serviceId: parsedServiceId.toString(), + jobIndex: parsedJobIndex, + amount: parsedAmount.toString(), + operator, + nonce: nonce.toString(), + expiry: expiry.toString(), + signature, + }; + + setSignedAuth(JSON.stringify(auth, null, 2)); + } catch (err) { + setError(err instanceof Error ? err.message : 'Signing failed'); + } + }, [ + selectedAccount, + serviceId, + jobIndex, + amount, + operator, + expiryMinutes, + domainSeparator, + refetchAccount, + ]); + + return ( +
+

+ Authorize Spend +

+ +

+ Sign an off-chain EIP-712 spend authorization. No ZK proof needed — just + a cheap signature from your ephemeral spending key. +

+ + {creditAccounts.length === 0 ? ( +
+ No credit accounts. Fund one first from the shielded pool. +
+ ) : ( + <> +
+ + + +
+ +
+
+ + + setServiceId(e.target.value)} + className="w-full p-3 text-sm border rounded-lg border-mono-40 dark:border-mono-160 bg-mono-0 dark:bg-mono-200 outline-none text-mono-200 dark:text-mono-0" + /> +
+ +
+ + + setJobIndex(e.target.value)} + className="w-full p-3 text-sm border rounded-lg border-mono-40 dark:border-mono-160 bg-mono-0 dark:bg-mono-200 outline-none text-mono-200 dark:text-mono-0" + /> +
+
+ +
+ + + { + if (/^[0-9]*\.?[0-9]*$/.test(e.target.value)) { + setAmount(e.target.value); + } + }} + className="w-full p-3 text-sm border rounded-lg border-mono-40 dark:border-mono-160 bg-mono-0 dark:bg-mono-200 outline-none text-mono-200 dark:text-mono-0" + /> +
+ +
+ + + setOperator(e.target.value)} + className={`w-full p-3 text-sm font-mono border rounded-lg bg-mono-0 dark:bg-mono-200 outline-none text-mono-200 dark:text-mono-0 ${ + !isValidOperator + ? 'border-red-50' + : 'border-mono-40 dark:border-mono-160' + }`} + /> +
+ +
+ + + setExpiryMinutes(e.target.value)} + className="w-full p-3 text-sm border rounded-lg border-mono-40 dark:border-mono-160 bg-mono-0 dark:bg-mono-200 outline-none text-mono-200 dark:text-mono-0" + /> +
+ + {error && ( +

{error}

+ )} + + {signedAuth && ( +
+ + Signed Authorization (share with operator) + + +
+                {signedAuth}
+              
+ + +
+ )} + + + + )} +
+ ); +}; + +export default SpendAuthContainer; diff --git a/apps/tangle-cloud/src/containers/payments/WithdrawContainer.tsx b/apps/tangle-cloud/src/containers/payments/WithdrawContainer.tsx new file mode 100644 index 0000000000..69c59d89c8 --- /dev/null +++ b/apps/tangle-cloud/src/containers/payments/WithdrawContainer.tsx @@ -0,0 +1,107 @@ +import { FC, useMemo, useState } from 'react'; +import { isAddress } from 'viem'; +import { + Typography, + Alert, + Button, + Input, + Chip, +} from '@tangle-network/ui-components'; +import AmountInput from '../../components/payments/AmountInput'; +import NoteCard from '../../components/payments/NoteCard'; +import { useShieldedContext } from '../../app/ShieldedProvider'; + +const WithdrawContainer: FC = () => { + const { notes } = useShieldedContext(); + + const [amount, setAmount] = useState(''); + const [recipient, setRecipient] = useState(''); + + const confirmedNotes = useMemo( + () => notes.filter((n) => n.index !== undefined), + [notes], + ); + + const isValidRecipient = recipient === '' || isAddress(recipient); + + return ( +
+
+ + Withdraw from Shielded Pool + + + + Withdraw shielded tokens to a public address. Notes are selected + automatically (FIFO). Change is returned as a new note. + +
+ + {confirmedNotes.length > 0 && ( +
+
+ + Available Notes + + + {confirmedNotes.length} +
+ +
+ {confirmedNotes.slice(0, 5).map((note) => ( + + ))} + + {confirmedNotes.length > 5 && ( + + +{confirmedNotes.length - 5} more + + )} +
+
+ )} + + sum + n.amount, 0n)} + symbol="SHIELDED" + label="Withdraw Amount" + disabled + /> + +
+ + Recipient Address + + + +
+ + + + +
+ ); +}; + +export default WithdrawContainer; diff --git a/apps/tangle-cloud/src/data/payments/useCreditAccountState.ts b/apps/tangle-cloud/src/data/payments/useCreditAccountState.ts new file mode 100644 index 0000000000..88b2161728 --- /dev/null +++ b/apps/tangle-cloud/src/data/payments/useCreditAccountState.ts @@ -0,0 +1,18 @@ +import { useReadContract } from 'wagmi'; +import { SHIELDED_CREDITS_ABI } from '../../abi/payments'; +import { SHIELDED_CREDITS_ADDRESS } from '../../constants/payments'; +import type { Address, Hex } from 'viem'; + +const useCreditAccountState = (commitment: Hex | undefined) => { + return useReadContract({ + address: SHIELDED_CREDITS_ADDRESS as Address, + abi: SHIELDED_CREDITS_ABI, + functionName: 'getAccount', + args: commitment ? [commitment] : undefined, + query: { + enabled: Boolean(SHIELDED_CREDITS_ADDRESS && commitment), + }, + }); +}; + +export default useCreditAccountState; diff --git a/apps/tangle-cloud/src/data/payments/useDomainSeparator.ts b/apps/tangle-cloud/src/data/payments/useDomainSeparator.ts new file mode 100644 index 0000000000..c5077d00b8 --- /dev/null +++ b/apps/tangle-cloud/src/data/payments/useDomainSeparator.ts @@ -0,0 +1,17 @@ +import { useReadContract } from 'wagmi'; +import { SHIELDED_CREDITS_ABI } from '../../abi/payments'; +import { SHIELDED_CREDITS_ADDRESS } from '../../constants/payments'; +import type { Address } from 'viem'; + +const useDomainSeparator = () => { + return useReadContract({ + address: SHIELDED_CREDITS_ADDRESS as Address, + abi: SHIELDED_CREDITS_ABI, + functionName: 'DOMAIN_SEPARATOR', + query: { + enabled: Boolean(SHIELDED_CREDITS_ADDRESS), + }, + }); +}; + +export default useDomainSeparator; diff --git a/apps/tangle-cloud/src/data/payments/useTokenBalance.ts b/apps/tangle-cloud/src/data/payments/useTokenBalance.ts new file mode 100644 index 0000000000..645f61efd2 --- /dev/null +++ b/apps/tangle-cloud/src/data/payments/useTokenBalance.ts @@ -0,0 +1,20 @@ +import { useReadContract } from 'wagmi'; +import { ERC20_ABI } from '../../abi/payments'; +import type { Address } from 'viem'; + +const useTokenBalance = ( + tokenAddress: Address | undefined, + account: Address | undefined, +) => { + return useReadContract({ + address: tokenAddress, + abi: ERC20_ABI, + functionName: 'balanceOf', + args: account ? [account] : undefined, + query: { + enabled: Boolean(tokenAddress && account), + }, + }); +}; + +export default useTokenBalance; diff --git a/apps/tangle-cloud/src/hooks/payments/useKeypair.ts b/apps/tangle-cloud/src/hooks/payments/useKeypair.ts new file mode 100644 index 0000000000..78f0750aad --- /dev/null +++ b/apps/tangle-cloud/src/hooks/payments/useKeypair.ts @@ -0,0 +1,151 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useAccount, useSignMessage } from 'wagmi'; +import { keccak256, toBytes } from 'viem'; +import { encryptData, decryptData } from '../../utils/payments/keyEncryption'; + +const SIGN_MESSAGE = + 'Sign this message to access your Tangle shielded account. This signature is used locally and never sent to any server.'; + +const deriveShieldedKey = (signature: string): string => + keccak256(toBytes(signature + ':shielded-key')); + +const deriveEncryptionInput = (signature: string): string => + keccak256(toBytes(signature + ':encryption')); + +export interface ShieldedKeypair { + privateKey: string; +} + +const STORAGE_KEY = 'tangle-shielded-kp-enc'; + +const useKeypair = () => { + const { address } = useAccount(); + const { signMessageAsync } = useSignMessage(); + const [keypair, setKeypair] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [hasStoredKeypair, setHasStoredKeypair] = useState(false); + + // Track current address to detect stale async completions + const addressRef = useRef(address); + addressRef.current = address; + + useEffect(() => { + setKeypair(null); + setError(null); + setIsLoading(false); // Cancel any stuck loading state from previous address + setHasStoredKeypair( + !!address && localStorage.getItem(`${STORAGE_KEY}:${address}`) !== null, + ); + }, [address]); + + const deriveKeypair = useCallback(async () => { + const callerAddress = address; + if (!callerAddress) { + setError('Wallet not connected'); + return null; + } + + setIsLoading(true); + setError(null); + + try { + const signature = await signMessageAsync({ message: SIGN_MESSAGE }); + + // Guard: address changed while waiting for signature + if (addressRef.current !== callerAddress) return null; + + const privateKey = deriveShieldedKey(signature); + const encInput = deriveEncryptionInput(signature); + const kp: ShieldedKeypair = { privateKey }; + + try { + const encrypted = await encryptData(JSON.stringify(kp), encInput); + localStorage.setItem(`${STORAGE_KEY}:${callerAddress}`, encrypted); + if (addressRef.current === callerAddress) { + setHasStoredKeypair(true); + } + } catch { + // Encryption failed — usable in-memory only + } + + if (addressRef.current === callerAddress) { + setKeypair(kp); + return kp; + } + return null; + } catch (err) { + if (addressRef.current === callerAddress) { + setError(err instanceof Error ? err.message : 'Signature rejected'); + } + return null; + } finally { + if (addressRef.current === callerAddress) { + setIsLoading(false); + } + } + }, [address, signMessageAsync]); + + const unlockKeypair = useCallback(async () => { + const callerAddress = address; + if (!callerAddress) return null; + + const stored = localStorage.getItem(`${STORAGE_KEY}:${callerAddress}`); + if (!stored) return null; + + setIsLoading(true); + setError(null); + + try { + const signature = await signMessageAsync({ message: SIGN_MESSAGE }); + + if (addressRef.current !== callerAddress) return null; + + const encInput = deriveEncryptionInput(signature); + const decrypted = await decryptData(stored, encInput); + const parsed = JSON.parse(decrypted); + if (!parsed?.privateKey || typeof parsed.privateKey !== 'string') { + throw new Error('Invalid keypair data'); + } + + if (addressRef.current === callerAddress) { + const kp = parsed as ShieldedKeypair; + setKeypair(kp); + return kp; + } + return null; + } catch { + if (addressRef.current === callerAddress) { + setError( + 'Failed to unlock — data may be corrupted. Derive a new keypair.', + ); + } + return null; + } finally { + if (addressRef.current === callerAddress) { + setIsLoading(false); + } + } + }, [address, signMessageAsync]); + + const clearKeypair = useCallback(() => { + if (address) { + localStorage.removeItem(`${STORAGE_KEY}:${address}`); + setHasStoredKeypair(false); + } + setKeypair(null); + }, [address]); + + return { + keypair, + deriveKeypair, + unlockKeypair, + clearKeypair, + isLoading, + error, + hasDerivedKeypair: keypair !== null, + hasStoredKeypair, + }; +}; + +export default useKeypair; diff --git a/apps/tangle-cloud/src/pages/blueprints/[id]/page.tsx b/apps/tangle-cloud/src/pages/blueprints/[id]/page.tsx index 6f5745dc92..fa1a558208 100644 --- a/apps/tangle-cloud/src/pages/blueprints/[id]/page.tsx +++ b/apps/tangle-cloud/src/pages/blueprints/[id]/page.tsx @@ -1,5 +1,3 @@ -'use client'; - import BlueprintHeader from '@tangle-network/tangle-shared-ui/components/blueprints/BlueprintHeader'; import OperatorsTable from '@tangle-network/tangle-shared-ui/components/tables/Operators'; import { useBlueprintDetails } from '@tangle-network/tangle-shared-ui/data/graphql'; @@ -13,7 +11,7 @@ import { useMemo, useState, } from 'react'; -import { Link, useNavigate, Navigate } from 'react-router-dom'; +import { Link, useNavigate, Navigate } from 'react-router'; import { PagePath, TangleDAppPagePath } from '../../../types'; import pollWithBackoff from '../../../utils/pollWithBackoff'; import RegistrationDrawer from '../RegistrationDrawer'; diff --git a/apps/tangle-cloud/src/pages/blueprints/layout.tsx b/apps/tangle-cloud/src/pages/blueprints/layout.tsx index 6e36c8a7cd..0894754ccf 100644 --- a/apps/tangle-cloud/src/pages/blueprints/layout.tsx +++ b/apps/tangle-cloud/src/pages/blueprints/layout.tsx @@ -1,13 +1,9 @@ import { PropsWithChildren } from 'react'; -import Header from '../../components/Header'; +import PageLayout from '../../components/PageLayout'; const Layout = ({ children }: PropsWithChildren) => { return ( -
-
- - {children} -
+ {children} ); }; diff --git a/apps/tangle-cloud/src/pages/earnings/layout.tsx b/apps/tangle-cloud/src/pages/earnings/layout.tsx index f27a50b9cf..65d663cd76 100644 --- a/apps/tangle-cloud/src/pages/earnings/layout.tsx +++ b/apps/tangle-cloud/src/pages/earnings/layout.tsx @@ -1,14 +1,8 @@ import { PropsWithChildren } from 'react'; -import Header from '../../components/Header'; +import PageLayout from '../../components/PageLayout'; const Layout = ({ children }: PropsWithChildren) => { - return ( -
-
- - {children} -
- ); + return {children}; }; export default Layout; diff --git a/apps/tangle-cloud/src/pages/instances/InstructionCard.tsx b/apps/tangle-cloud/src/pages/instances/InstructionCard.tsx index 0e78c76009..a8015be5c1 100644 --- a/apps/tangle-cloud/src/pages/instances/InstructionCard.tsx +++ b/apps/tangle-cloud/src/pages/instances/InstructionCard.tsx @@ -1,5 +1,5 @@ import { ComponentProps, createElement, type FC } from 'react'; -import { Link as RouterLink } from 'react-router-dom'; +import { Link as RouterLink } from 'react-router'; import TangleCloudCard from '../../components/TangleCloudCard'; import { Typography } from '@tangle-network/ui-components'; import { CLOUD_INSTRUCTIONS } from '../../constants/cloudInstruction'; diff --git a/apps/tangle-cloud/src/pages/instances/layout.tsx b/apps/tangle-cloud/src/pages/instances/layout.tsx index f27a50b9cf..65d663cd76 100644 --- a/apps/tangle-cloud/src/pages/instances/layout.tsx +++ b/apps/tangle-cloud/src/pages/instances/layout.tsx @@ -1,14 +1,8 @@ import { PropsWithChildren } from 'react'; -import Header from '../../components/Header'; +import PageLayout from '../../components/PageLayout'; const Layout = ({ children }: PropsWithChildren) => { - return ( -
-
- - {children} -
- ); + return {children}; }; export default Layout; diff --git a/apps/tangle-cloud/src/pages/operators/layout.tsx b/apps/tangle-cloud/src/pages/operators/layout.tsx index 6e36c8a7cd..0894754ccf 100644 --- a/apps/tangle-cloud/src/pages/operators/layout.tsx +++ b/apps/tangle-cloud/src/pages/operators/layout.tsx @@ -1,13 +1,9 @@ import { PropsWithChildren } from 'react'; -import Header from '../../components/Header'; +import PageLayout from '../../components/PageLayout'; const Layout = ({ children }: PropsWithChildren) => { return ( -
-
- - {children} -
+ {children} ); }; diff --git a/apps/tangle-cloud/src/pages/operators/manage/layout.tsx b/apps/tangle-cloud/src/pages/operators/manage/layout.tsx index 4f71e1859a..8ac17b26c7 100644 --- a/apps/tangle-cloud/src/pages/operators/manage/layout.tsx +++ b/apps/tangle-cloud/src/pages/operators/manage/layout.tsx @@ -1,13 +1,9 @@ import { PropsWithChildren } from 'react'; -import Header from '../../../components/Header'; +import PageLayout from '../../../components/PageLayout'; const Layout = ({ children }: PropsWithChildren) => { return ( -
-
- - {children} -
+ {children} ); }; diff --git a/apps/tangle-cloud/src/pages/payments/credits.tsx b/apps/tangle-cloud/src/pages/payments/credits.tsx new file mode 100644 index 0000000000..7e0bca2fb5 --- /dev/null +++ b/apps/tangle-cloud/src/pages/payments/credits.tsx @@ -0,0 +1,76 @@ +import { FC, useState } from 'react'; +import { Typography } from '@tangle-network/ui-components'; +import FundCreditsContainer from '../../containers/payments/FundCreditsContainer'; +import SpendAuthContainer from '../../containers/payments/SpendAuthContainer'; +import CreditBalanceContainer from '../../containers/payments/CreditBalanceContainer'; +import RequireWallet from '../../components/RequireWallet'; + +const enum CreditsTab { + BALANCE = 'balance', + FUND = 'fund', + SPEND = 'spend', +} + +const PaymentsCreditsPage: FC = () => { + const [activeTab, setActiveTab] = useState(CreditsTab.BALANCE); + + return ( +
+
+ + Anonymous Credits + + + + Fund prepaid credit accounts for private pay-per-use cloud services. + One ZK proof funds the account; cheap signatures authorize each job. + +
+ + +
+ {( + [ + [CreditsTab.BALANCE, 'Accounts'], + [CreditsTab.FUND, 'Fund'], + [CreditsTab.SPEND, 'Authorize'], + ] as const + ).map(([tab, label]) => ( + + ))} +
+ +
+ {activeTab === CreditsTab.BALANCE ? ( + + ) : activeTab === CreditsTab.FUND ? ( + + ) : ( + + )} +
+
+
+ ); +}; + +export default PaymentsCreditsPage; diff --git a/apps/tangle-cloud/src/pages/payments/layout.tsx b/apps/tangle-cloud/src/pages/payments/layout.tsx new file mode 100644 index 0000000000..11f108731b --- /dev/null +++ b/apps/tangle-cloud/src/pages/payments/layout.tsx @@ -0,0 +1,8 @@ +import { PropsWithChildren } from 'react'; +import PageLayout from '../../components/PageLayout'; + +const PaymentsLayout = ({ children }: PropsWithChildren) => { + return {children}; +}; + +export default PaymentsLayout; diff --git a/apps/tangle-cloud/src/pages/payments/pool.tsx b/apps/tangle-cloud/src/pages/payments/pool.tsx new file mode 100644 index 0000000000..525ec21d50 --- /dev/null +++ b/apps/tangle-cloud/src/pages/payments/pool.tsx @@ -0,0 +1,97 @@ +import { FC, useState } from 'react'; +import { formatUnits } from 'viem'; +import { Typography, Card, CardVariant } from '@tangle-network/ui-components'; +import DepositContainer from '../../containers/payments/DepositContainer'; +import WithdrawContainer from '../../containers/payments/WithdrawContainer'; +import { useShieldedContext } from '../../app/ShieldedProvider'; +import RequireWallet from '../../components/RequireWallet'; +import { TOKEN_DECIMALS } from '../../constants/payments'; + +const enum PoolTab { + DEPOSIT = 'deposit', + WITHDRAW = 'withdraw', +} + +const PaymentsPoolPage: FC = () => { + const { shieldedBalance, notes } = useShieldedContext(); + const [activeTab, setActiveTab] = useState(PoolTab.DEPOSIT); + + return ( +
+
+ + Shielded Pool + + + + Deposit tokens into the VAnchor shielded pool to gain privacy. + Withdraw to a public address or fund anonymous credit accounts. + +
+ + +
+ + + Shielded Balance + + + + {formatUnits(shieldedBalance, TOKEN_DECIMALS)} + + + + + + Unspent Notes + + + + {notes.length} + + +
+ +
+ {( + [ + [PoolTab.DEPOSIT, 'Deposit'], + [PoolTab.WITHDRAW, 'Withdraw'], + ] as const + ).map(([tab, label]) => ( + + ))} +
+ +
+ {activeTab === PoolTab.DEPOSIT ? ( + + ) : ( + + )} +
+
+
+ ); +}; + +export default PaymentsPoolPage; diff --git a/apps/tangle-cloud/src/pages/rewards/layout.tsx b/apps/tangle-cloud/src/pages/rewards/layout.tsx index f27a50b9cf..65d663cd76 100644 --- a/apps/tangle-cloud/src/pages/rewards/layout.tsx +++ b/apps/tangle-cloud/src/pages/rewards/layout.tsx @@ -1,14 +1,8 @@ import { PropsWithChildren } from 'react'; -import Header from '../../components/Header'; +import PageLayout from '../../components/PageLayout'; const Layout = ({ children }: PropsWithChildren) => { - return ( -
-
- - {children} -
- ); + return {children}; }; export default Layout; diff --git a/apps/tangle-cloud/src/pages/services/[id]/page.tsx b/apps/tangle-cloud/src/pages/services/[id]/page.tsx index d9821acd9c..93482b1c05 100644 --- a/apps/tangle-cloud/src/pages/services/[id]/page.tsx +++ b/apps/tangle-cloud/src/pages/services/[id]/page.tsx @@ -369,7 +369,7 @@ const ServiceDetailPage: FC = () => { return (
Invalid Service ID -
@@ -393,7 +393,7 @@ const ServiceDetailPage: FC = () => { This service does not exist or may have been removed. -
@@ -407,7 +407,7 @@ const ServiceDetailPage: FC = () => { diff --git a/apps/tangle-cloud/src/types/index.ts b/apps/tangle-cloud/src/types/index.ts index fb42e97560..2c20dc7d7c 100644 --- a/apps/tangle-cloud/src/types/index.ts +++ b/apps/tangle-cloud/src/types/index.ts @@ -2,7 +2,12 @@ import { PrimitiveField } from '@tangle-network/tangle-shared-ui/types/blueprint import { TANGLE_DAPP_URL } from '@tangle-network/ui-components/constants'; import type { Address } from 'viem'; -export const TANGLE_DAPP_BASE_URL = TANGLE_DAPP_URL; +const ensureTrailingSlash = (url: string): string => + url.endsWith('/') ? url : `${url}/`; + +export const TANGLE_DAPP_BASE_URL = ensureTrailingSlash( + import.meta.env.VITE_TANGLE_DAPP_URL || TANGLE_DAPP_URL, +); export enum PagePath { HOME = '/', @@ -18,15 +23,17 @@ export enum PagePath { OPERATORS_MANAGE = '/operators/manage', REWARDS = '/rewards', EARNINGS = '/earnings', + PAYMENTS_POOL = '/payments/pool', + PAYMENTS_CREDITS = '/payments/credits', NOT_FOUND = '/404', } -export enum TangleDAppPagePath { - STAKING = `${TANGLE_DAPP_URL}staking`, - STAKING_DEPOSIT = `${TANGLE_DAPP_URL}staking/deposit?vault={{vault}}`, - STAKING_DELEGATE = `${TANGLE_DAPP_URL}staking/delegate`, - STAKING_OPERATOR = `${TANGLE_DAPP_URL}staking/operators`, -} +export const TangleDAppPagePath = { + STAKING: `${TANGLE_DAPP_BASE_URL}staking`, + STAKING_DEPOSIT: `${TANGLE_DAPP_BASE_URL}staking/deposit?vault={{vault}}`, + STAKING_DELEGATE: `${TANGLE_DAPP_BASE_URL}staking/delegate`, + STAKING_OPERATOR: `${TANGLE_DAPP_BASE_URL}staking/operators`, +} as const; /** * Asset structure matching the contract's Asset struct. diff --git a/apps/tangle-cloud/src/types/shielded.ts b/apps/tangle-cloud/src/types/shielded.ts new file mode 100644 index 0000000000..1a77cec538 --- /dev/null +++ b/apps/tangle-cloud/src/types/shielded.ts @@ -0,0 +1,38 @@ +// Types mirroring @tangle-network/shielded-sdk interfaces. +// When the SDK is added as a dependency, replace with direct imports. + +export interface NoteData { + sourceChainId: number; + targetChainId: number; + amount: bigint; + tokenSymbol: string; + targetAnchor: string; + privateKey: string; + blinding: string; + index?: number; +} + +export interface CreditAccountState { + spendingKey: string; + token: string; + balance: bigint; + totalFunded: bigint; + totalSpent: bigint; + nonce: bigint; +} + +export const enum ProofStage { + IDLE = 'idle', + FETCHING_ARTIFACTS = 'fetching_artifacts', + SYNCING_LEAVES = 'syncing_leaves', + BUILDING_WITNESS = 'building_witness', + GENERATING_PROOF = 'generating_proof', + SENDING_TX = 'sending_tx', + DONE = 'done', + ERROR = 'error', +} + +export interface ProofProgress { + stage: ProofStage; + message?: string; +} diff --git a/apps/tangle-cloud/src/utils/payments/db.ts b/apps/tangle-cloud/src/utils/payments/db.ts new file mode 100644 index 0000000000..5b0003c0a8 --- /dev/null +++ b/apps/tangle-cloud/src/utils/payments/db.ts @@ -0,0 +1,61 @@ +// Shared IndexedDB instance for all storage. +// Single source of truth for schema upgrades. + +const DB_NAME = 'tangle-private-cloud'; +const DB_VERSION = 1; + +const STORES = { + NOTES: 'shielded-notes', + CREDIT_KEYS: 'credit-keys', +} as const; + +let dbPromise: Promise | null = null; + +export const openDb = (): Promise => { + if (dbPromise) return dbPromise; + + dbPromise = new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION); + + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains(STORES.NOTES)) { + db.createObjectStore(STORES.NOTES); + } + if (!db.objectStoreNames.contains(STORES.CREDIT_KEYS)) { + db.createObjectStore(STORES.CREDIT_KEYS, { keyPath: 'commitment' }); + } + }; + + request.onblocked = () => { + dbPromise = null; + reject(new Error('IndexedDB blocked — close other tabs using this app')); + }; + + request.onsuccess = () => { + const db = request.result; + + // Invalidate cache if connection closes unexpectedly + db.onclose = () => { + dbPromise = null; + }; + + // Handle schema upgrade from another tab + db.onversionchange = () => { + db.close(); + dbPromise = null; + }; + + resolve(db); + }; + + request.onerror = () => { + dbPromise = null; + reject(request.error); + }; + }); + + return dbPromise; +}; + +export { STORES }; diff --git a/apps/tangle-cloud/src/utils/payments/indexedDbCreditStorage.ts b/apps/tangle-cloud/src/utils/payments/indexedDbCreditStorage.ts new file mode 100644 index 0000000000..14b16f13e4 --- /dev/null +++ b/apps/tangle-cloud/src/utils/payments/indexedDbCreditStorage.ts @@ -0,0 +1,136 @@ +import { openDb, STORES } from './db'; +import { encryptData, decryptData } from './keyEncryption'; + +export interface StoredCreditKeys { + commitment: string; + spendingPrivateKey: string; // Empty if locked/undecryptable + spendingPublicKey: string; + salt: string; + label?: string; + createdAt: number; + isLocked: boolean; // True if private key could not be decrypted +} + +interface EncryptedCreditRecord { + commitment: string; + owner: string; + encryptedPrivateKey: string; + spendingPublicKey: string; + salt: string; + label?: string; + createdAt: number; +} + +export const saveCreditKeys = async ( + keys: StoredCreditKeys, + ownerAddress: string, + encryptionKey: string, +): Promise => { + if (!encryptionKey) { + throw new Error( + 'Encryption key required to store credit keys. Unlock your shielded keypair first.', + ); + } + + const db = await openDb(); + const record: EncryptedCreditRecord = { + commitment: keys.commitment, + owner: ownerAddress.toLowerCase(), + encryptedPrivateKey: await encryptData( + keys.spendingPrivateKey, + encryptionKey, + ), + spendingPublicKey: keys.spendingPublicKey, + salt: keys.salt, + label: keys.label, + createdAt: keys.createdAt, + }; + + return new Promise((resolve, reject) => { + const tx = db.transaction(STORES.CREDIT_KEYS, 'readwrite'); + const store = tx.objectStore(STORES.CREDIT_KEYS); + const request = store.put(record); + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); +}; + +export const loadCreditKeysForAddress = async ( + ownerAddress: string, + encryptionKey?: string, +): Promise => { + const db = await openDb(); + const allRecords: EncryptedCreditRecord[] = await new Promise( + (resolve, reject) => { + const tx = db.transaction(STORES.CREDIT_KEYS, 'readonly'); + const store = tx.objectStore(STORES.CREDIT_KEYS); + const request = store.getAll(); + request.onsuccess = () => resolve(request.result ?? []); + request.onerror = () => reject(request.error); + }, + ); + + const owner = ownerAddress.toLowerCase(); + const records = allRecords.filter((r) => r.owner === owner); + + const results: StoredCreditKeys[] = []; + for (const record of records) { + let privateKey = ''; + let isLocked = true; + + if (encryptionKey && record.encryptedPrivateKey) { + try { + privateKey = await decryptData( + record.encryptedPrivateKey, + encryptionKey, + ); + isLocked = false; + } catch { + // Decryption failed — mark as locked, don't expose ciphertext + } + } + + results.push({ + commitment: record.commitment, + spendingPrivateKey: privateKey, + spendingPublicKey: record.spendingPublicKey, + salt: record.salt, + label: record.label, + createdAt: record.createdAt, + isLocked, + }); + } + + return results; +}; + +// Delete only verifies the record belongs to the caller's address +export const deleteCreditKeys = async ( + commitment: string, + ownerAddress: string, +): Promise => { + const db = await openDb(); + + // Read-then-delete to verify ownership + const record: EncryptedCreditRecord | undefined = await new Promise( + (resolve, reject) => { + const tx = db.transaction(STORES.CREDIT_KEYS, 'readonly'); + const store = tx.objectStore(STORES.CREDIT_KEYS); + const request = store.get(commitment); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }, + ); + + if (!record || record.owner !== ownerAddress.toLowerCase()) { + return; // Not found or not owned — no-op + } + + return new Promise((resolve, reject) => { + const tx = db.transaction(STORES.CREDIT_KEYS, 'readwrite'); + const store = tx.objectStore(STORES.CREDIT_KEYS); + const request = store.delete(commitment); + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); +}; diff --git a/apps/tangle-cloud/src/utils/payments/indexedDbNoteStorage.ts b/apps/tangle-cloud/src/utils/payments/indexedDbNoteStorage.ts new file mode 100644 index 0000000000..573259583a --- /dev/null +++ b/apps/tangle-cloud/src/utils/payments/indexedDbNoteStorage.ts @@ -0,0 +1,43 @@ +import { openDb, STORES } from './db'; + +interface NoteStorage { + load(): Promise; + save(notes: string[]): Promise; +} + +// Per-address note storage to prevent cross-wallet note leakage +export class IndexedDbNoteStorage implements NoteStorage { + private readonly storageKey: string; + + constructor(address?: string) { + this.storageKey = address + ? `notes:${address.toLowerCase()}` + : 'notes:anonymous'; + } + + async load(): Promise { + const db = await openDb(); + return new Promise((resolve, reject) => { + const tx = db.transaction(STORES.NOTES, 'readonly'); + const store = tx.objectStore(STORES.NOTES); + const request = store.get(this.storageKey); + + request.onsuccess = () => { + resolve(Array.isArray(request.result) ? request.result : []); + }; + request.onerror = () => reject(request.error); + }); + } + + async save(notes: string[]): Promise { + const db = await openDb(); + return new Promise((resolve, reject) => { + const tx = db.transaction(STORES.NOTES, 'readwrite'); + const store = tx.objectStore(STORES.NOTES); + const request = store.put(notes, this.storageKey); + + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); + } +} diff --git a/apps/tangle-cloud/src/utils/payments/keyEncryption.ts b/apps/tangle-cloud/src/utils/payments/keyEncryption.ts new file mode 100644 index 0000000000..0f68ade45d --- /dev/null +++ b/apps/tangle-cloud/src/utils/payments/keyEncryption.ts @@ -0,0 +1,68 @@ +// Encrypt/decrypt key material using AES-GCM with a key derived from +// the wallet signature via PBKDF2. Per-user random salt stored alongside ciphertext. + +const IV_LENGTH = 12; +const SALT_LENGTH = 16; + +const deriveEncryptionKey = async ( + walletSignature: string, + salt: Uint8Array, +): Promise => { + const keyMaterial = await crypto.subtle.importKey( + 'raw', + new TextEncoder().encode(walletSignature), + 'PBKDF2', + false, + ['deriveKey'], + ); + + return crypto.subtle.deriveKey( + { name: 'PBKDF2', salt, iterations: 100_000, hash: 'SHA-256' }, + keyMaterial, + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt', 'decrypt'], + ); +}; + +// Output format: base64(salt[16] || iv[12] || ciphertext || tag[16]) +export const encryptData = async ( + plaintext: string, + walletSignature: string, +): Promise => { + const salt = crypto.getRandomValues(new Uint8Array(SALT_LENGTH)); + const key = await deriveEncryptionKey(walletSignature, salt); + const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH)); + const encrypted = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, + key, + new TextEncoder().encode(plaintext), + ); + + const combined = new Uint8Array( + SALT_LENGTH + IV_LENGTH + encrypted.byteLength, + ); + combined.set(salt); + combined.set(iv, SALT_LENGTH); + combined.set(new Uint8Array(encrypted), SALT_LENGTH + IV_LENGTH); + return btoa(Array.from(combined, (b) => String.fromCharCode(b)).join('')); +}; + +export const decryptData = async ( + ciphertext: string, + walletSignature: string, +): Promise => { + const combined = Uint8Array.from(atob(ciphertext), (c) => c.charCodeAt(0)); + const salt = combined.slice(0, SALT_LENGTH); + const iv = combined.slice(SALT_LENGTH, SALT_LENGTH + IV_LENGTH); + const encrypted = combined.slice(SALT_LENGTH + IV_LENGTH); + + const key = await deriveEncryptionKey(walletSignature, salt); + const decrypted = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv }, + key, + encrypted, + ); + + return new TextDecoder().decode(decrypted); +}; diff --git a/apps/tangle-cloud/tsconfig.app.json b/apps/tangle-cloud/tsconfig.app.json index b878d1562b..8e5b72aca7 100644 --- a/apps/tangle-cloud/tsconfig.app.json +++ b/apps/tangle-cloud/tsconfig.app.json @@ -2,6 +2,7 @@ "extends": "./tsconfig.json", "compilerOptions": { "outDir": "../../dist/out-tsc", + "target": "ES2020", "types": [ "node", "../../node_modules/@nx/react/typings/cssmodule.d.ts", diff --git a/apps/tangle-dapp/src/app/app.spec.tsx b/apps/tangle-dapp/src/app/app.spec.tsx index dbee374d01..4651d7ff20 100644 --- a/apps/tangle-dapp/src/app/app.spec.tsx +++ b/apps/tangle-dapp/src/app/app.spec.tsx @@ -1,6 +1,6 @@ import { render, screen } from '@testing-library/react'; import type { PropsWithChildren } from 'react'; -import { MemoryRouter, Outlet } from 'react-router'; +import { MemoryRouter } from 'react-router'; import { LiquidStakingAction, LiquidStakingTab, @@ -35,22 +35,6 @@ vi.mock('../pages/bridge', () => ({ default: () =>
, })); -vi.mock('../pages/claim', () => ({ - default: () =>
, -})); - -vi.mock('../pages/claim/layout', () => ({ - default: () => ( -
- -
- ), -})); - -vi.mock('../pages/claim/success', () => ({ - default: () =>
, -})); - vi.mock('../pages/claim/migration', () => ({ default: () =>
, })); @@ -85,18 +69,10 @@ describe('App', () => { expect(screen.getByTestId('dashboard-page')).toBeTruthy(); }); - it('renders claim index route', () => { + it('renders canonical claim route', async () => { renderAt('/claim'); - expect(screen.getByTestId('claim-layout')).toBeTruthy(); - expect(screen.getByTestId('claim-page')).toBeTruthy(); - }); - - it('renders claim success route', () => { - renderAt('/claim/success'); - - expect(screen.getByTestId('claim-layout')).toBeTruthy(); - expect(screen.getByTestId('claim-success-page')).toBeTruthy(); + expect(await screen.findByTestId('claim-migration-page')).toBeTruthy(); }); it('renders bridge route', () => { @@ -160,10 +136,10 @@ describe('App', () => { ); }); - it('renders the claim migration route', () => { + it('redirects legacy claim migration route to canonical claim route', async () => { renderAt('/claim/migration'); - expect(screen.getByTestId('claim-migration-page')).toBeTruthy(); + expect(await screen.findByTestId('claim-migration-page')).toBeTruthy(); }); it('renders not found for unsupported native staking route', () => { diff --git a/apps/tangle-dapp/src/app/app.tsx b/apps/tangle-dapp/src/app/app.tsx index 8cb2a10fce..786fae3e6a 100644 --- a/apps/tangle-dapp/src/app/app.tsx +++ b/apps/tangle-dapp/src/app/app.tsx @@ -1,13 +1,10 @@ +import { lazy, Suspense } from 'react'; import { Navigate, Route, Routes } from 'react-router'; import Layout from '../containers/Layout'; import DashboardPage from '../pages/dashboard'; import BlueprintsPage from '../pages/blueprints'; import BlueprintDetailsPage from '../pages/blueprints/[id]'; import BridgePage from '../pages/bridge'; -import ClaimPage from '../pages/claim'; -import ClaimLayout from '../pages/claim/layout'; -import ClaimSuccessPage from '../pages/claim/success'; -import MigrationClaimPage from '../pages/claim/migration'; import NotFoundPage from '../pages/notFound'; import { PagePath } from '../types'; import Providers from './providers'; @@ -19,6 +16,9 @@ import { LiquidStakingAction, LiquidStakingTab, } from '../constants'; +import Spinner from '@tangle-network/icons/Spinner'; + +const MigrationClaimPage = lazy(() => import('../pages/claim/migration')); function App() { return ( @@ -32,18 +32,23 @@ function App() { element={} /> - }> - } /> - - } - /> - - + + +
+ } + > + + + } + /> } + element={} /> } /> diff --git a/apps/tangle-dapp/src/app/providers.tsx b/apps/tangle-dapp/src/app/providers.tsx index 72c1095904..f2ae83ef48 100644 --- a/apps/tangle-dapp/src/app/providers.tsx +++ b/apps/tangle-dapp/src/app/providers.tsx @@ -3,6 +3,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { config } from '@tangle-network/dapp-config/wagmi-config'; import { DataSourceProvider } from '@tangle-network/tangle-shared-ui/context/DataSourceContext'; import { IndexerStatusProvider } from '@tangle-network/tangle-shared-ui/context/IndexerStatusContext'; +import useLocalChainGuard from '@tangle-network/tangle-shared-ui/hooks/useLocalChainGuard'; import useNetworkSync from '@tangle-network/tangle-shared-ui/hooks/useNetworkSync'; import { UIProvider } from '@tangle-network/ui-components'; import { @@ -29,6 +30,11 @@ const TANGLE_DAPP_NETWORKS = [ // Component to sync network store with wagmi chain const NetworkSync: FC = ({ children }) => { useNetworkSync(TANGLE_DAPP_NETWORKS); + useLocalChainGuard({ + enabled: + import.meta.env.DEV && import.meta.env.VITE_FORCE_LOCAL_CHAIN === 'true', + targetChainId: ANVIL_LOCAL_NETWORK.evmChainId ?? 31337, + }); return children; }; @@ -43,6 +49,12 @@ const envSchema = z.object({ const Providers = ({ children }: PropsWithChildren): ReactNode => { const [queryClient] = useState(() => new QueryClient()); + const reconnectOnMount = (() => { + const override = import.meta.env.VITE_WALLET_RECONNECT_ON_MOUNT; + if (override === 'true') return true; + if (override === 'false') return false; + return true; + })(); const { OFAC_COUNTRY_CODES: blockedCountryCodes, @@ -51,7 +63,7 @@ const Providers = ({ children }: PropsWithChildren): ReactNode => { return ( - + diff --git a/apps/tangle-dapp/src/components/AmountInput.tsx b/apps/tangle-dapp/src/components/AmountInput.tsx deleted file mode 100644 index f90da1b2a5..0000000000 --- a/apps/tangle-dapp/src/components/AmountInput.tsx +++ /dev/null @@ -1,145 +0,0 @@ -import { BN } from '@polkadot/util'; -import { TANGLE_TOKEN_DECIMALS } from '@tangle-network/dapp-config/constants/tangle'; -import useNetworkStore from '@tangle-network/tangle-shared-ui/context/useNetworkStore'; -import { Button, Input } from '@tangle-network/ui-components'; -import { FC, ReactNode, useCallback, useEffect, useMemo, useRef } from 'react'; -import { twMerge } from 'tailwind-merge'; - -import useInputAmount from '@tangle-network/tangle-shared-ui/hooks/useInputAmount'; -import InputWrapper, { - InputWrapperProps, -} from '@tangle-network/tangle-shared-ui/components/InputWrapper'; - -export type AmountInputProps = { - id: string; - title: string; - min?: BN | null; - max?: BN | null; - minErrorMessage?: string; - maxErrorMessage?: string; - showMaxAction?: boolean; - amount: BN | null; - decimals?: number; - isDisabled?: boolean; - wrapperOverrides?: Partial; - errorOnEmptyValue?: boolean; - setAmount: (newAmount: BN | null) => void; - setErrorMessage?: (error: string | null) => void; - placeholder?: string; - wrapperClassName?: string; - bodyClassName?: string; - dropdownBodyClassName?: string; - showErrorMessage?: boolean; - inputClassName?: string; -}; - -const AmountInput: FC = ({ - id, - title, - amount, - setAmount, - min = null, - max = null, - // Default to the Tangle token decimals. - decimals = TANGLE_TOKEN_DECIMALS, - minErrorMessage, - maxErrorMessage, - showMaxAction = true, - isDisabled = false, - wrapperOverrides, - errorOnEmptyValue = false, - setErrorMessage, - placeholder, - wrapperClassName, - bodyClassName, - dropdownBodyClassName, - showErrorMessage = true, - inputClassName, -}) => { - const inputRef = useRef(null); - const { nativeTokenSymbol } = useNetworkStore(); - - const { displayAmount, errorMessage, handleChange, setDisplayAmount } = - useInputAmount({ - amount, - min, - max, - decimals, - errorOnEmptyValue, - setAmount, - minErrorMessage, - maxErrorMessage, - }); - - // Set the error message in the parent component. - useEffect(() => { - if (setErrorMessage !== undefined) { - setErrorMessage(errorMessage); - } - }, [errorMessage, setErrorMessage]); - - const setMaxAmount = useCallback(() => { - if (max !== null && amount?.toString() !== max.toString()) { - setAmount(max); - setDisplayAmount(max); - } - }, [amount, max, setAmount, setDisplayAmount]); - - const actions: ReactNode = useMemo( - () => ( - <> - {max !== null && showMaxAction && ( - - )} - - {wrapperOverrides?.actions} - - ), - [max, showMaxAction, amount, isDisabled, setMaxAmount, wrapperOverrides], - ); - - return ( - - - - ); -}; - -export default AmountInput; diff --git a/apps/tangle-dapp/src/components/AvatarWithText.tsx b/apps/tangle-dapp/src/components/AvatarWithText.tsx deleted file mode 100644 index 0308e52f63..0000000000 --- a/apps/tangle-dapp/src/components/AvatarWithText.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { isEthereumAddress } from '@polkadot/util-crypto'; -import { getFlexBasic } from '@tangle-network/icons/utils'; -import { Avatar } from '@tangle-network/ui-components/components/Avatar'; -import { Typography } from '@tangle-network/ui-components/typography/Typography'; -import { shortenHex } from '@tangle-network/ui-components/utils/shortenHex'; -import { shortenString } from '@tangle-network/ui-components/utils/shortenString'; -import { toSubstrateAddress } from '@tangle-network/ui-components/utils/toSubstrateAddress'; -import useNetworkStore from '@tangle-network/tangle-shared-ui/context/useNetworkStore'; -import isEqual from 'lodash/isEqual'; -import { type ComponentProps, memo, type ReactNode, useMemo } from 'react'; -import { twMerge } from 'tailwind-merge'; -import { isHex } from 'viem'; - -type Props = ComponentProps<'div'> & { - accountAddress: string; - description?: ReactNode; - identityName?: string | null; - overrideAvatarProps?: Partial>; - overrideTypographyProps?: Partial>; -}; - -const AvatarWithText = ({ - accountAddress, - className, - description, - identityName, - overrideAvatarProps, - overrideTypographyProps, - ...props -}: Props) => { - const ss58Prefix = useNetworkStore((store) => store.network.ss58Prefix); - - const tangleFormattedAddress = useMemo(() => { - return isEthereumAddress(accountAddress) - ? accountAddress - : toSubstrateAddress(accountAddress, ss58Prefix); - }, [accountAddress, ss58Prefix]); - - return ( -
- - -
- - {identityName || - (isHex(tangleFormattedAddress) - ? shortenHex(tangleFormattedAddress) - : shortenString(tangleFormattedAddress))} - - - {description} -
-
- ); -}; - -export default memo(AvatarWithText, (prevProps, nextProps) => - isEqual(prevProps, nextProps), -); diff --git a/apps/tangle-dapp/src/components/BondedTokensBalanceInfo.tsx b/apps/tangle-dapp/src/components/BondedTokensBalanceInfo.tsx deleted file mode 100644 index 991ffc7832..0000000000 --- a/apps/tangle-dapp/src/components/BondedTokensBalanceInfo.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { LockUnlockLineIcon, TimeLineIcon } from '@tangle-network/icons'; -import useNetworkStore from '@tangle-network/tangle-shared-ui/context/useNetworkStore'; -import { Typography } from '@tangle-network/ui-components'; -import { type FC } from 'react'; - -import formatTangleBalance from '../utils/formatTangleBalance'; -import { BN } from '@polkadot/util'; - -type Props = { - type: 'unbonded' | 'unbonding'; - value: BN; -}; - -export const BondedTokensBalanceInfo: FC = ({ type, value }) => { - const { nativeTokenSymbol } = useNetworkStore(); - - return ( -
-
- {type === 'unbonded' ? : } - - - {type === 'unbonded' ? 'Unbonded:' : 'Unbonding:'} - -
- - - {formatTangleBalance(value, nativeTokenSymbol)} - -
- ); -}; diff --git a/apps/tangle-dapp/src/components/Lists/AssetList.tsx b/apps/tangle-dapp/src/components/Lists/AssetList.tsx deleted file mode 100644 index 6dc7444123..0000000000 --- a/apps/tangle-dapp/src/components/Lists/AssetList.tsx +++ /dev/null @@ -1,156 +0,0 @@ -import { BN } from '@polkadot/util'; -import { ArrowRightUp, Search, TokenIcon } from '@tangle-network/icons'; -import { - AmountFormatStyle, - formatDisplayAmount, - Input, - ListItem, - shortenHex, - Typography, -} from '@tangle-network/ui-components'; -import { ScrollArea } from '@tangle-network/ui-components/components/ScrollArea'; -import { EvmAddress } from '@tangle-network/ui-components/types/address'; -import { ComponentProps, useMemo, useState } from 'react'; -import { twMerge } from 'tailwind-merge'; -import { ListCardWrapper } from './ListCardWrapper'; - -export type AssetConfig = { - id: string; - name?: string; - symbol: string; - optionalSymbol?: string; - balance?: BN; - explorerUrl: string | null; - address?: EvmAddress; - decimals: number; -}; - -type AssetListProps = { - title?: string; - onClose: () => void; - assets: AssetConfig[]; - onSelectAsset: (asset: AssetConfig) => void; - overrideScrollAreaProps?: ComponentProps; -}; - -export const AssetList = ({ - assets, - onClose, - title = 'Select Asset', - overrideScrollAreaProps, - onSelectAsset, -}: AssetListProps) => { - const [searchQuery, setSearchQuery] = useState(''); - - const filteredAssets = useMemo(() => { - return assets - .filter((asset) => - asset.symbol.toLowerCase().includes(searchQuery.toLowerCase()), - ) - .sort((a, b) => { - if (!a.balance) return 1; - if (!b.balance) return -1; - return b.balance.cmp(a.balance); - }); - }, [assets, searchQuery]); - - return ( - -
- } - placeholder="Search assets by name" - isControlled - value={searchQuery} - onChange={setSearchQuery} - inputClassName="placeholder:text-mono-80 dark:placeholder:text-mono-120 " - /> -
- - -
    - {filteredAssets.map((asset, idx) => ( - { - onSelectAsset(asset); - onClose?.(); - }} - className="cursor-pointer w-full flex items-center gap-4 justify-between max-w-full min-h-[60px] py-[12px] px-6" - > -
    - - -
    - - {asset.name === undefined - ? asset.symbol - : `${asset.name} (${asset.symbol})`} - - - {asset.explorerUrl && ( - - - {asset.address - ? shortenHex(asset.address) - : 'View Explorer'} - - - - - )} -
    -
    - -
    - - {`${formatDisplayAmount( - asset.balance ?? new BN(0), - asset.decimals, - AmountFormatStyle.SHORT, - )} ${asset.symbol}`} - - - - Balance - -
    -
    - ))} -
-
-
- ); -}; diff --git a/apps/tangle-dapp/src/components/Lists/AssetListItem.tsx b/apps/tangle-dapp/src/components/Lists/AssetListItem.tsx index b1e6d9e273..5698d406ef 100644 --- a/apps/tangle-dapp/src/components/Lists/AssetListItem.tsx +++ b/apps/tangle-dapp/src/components/Lists/AssetListItem.tsx @@ -3,7 +3,6 @@ import { ChainType } from '@tangle-network/dapp-types'; import { AmountFormatStyle, CopyWithTooltip, - formatDisplayAmount, isEvmAddress, shortenHex, Typography, @@ -11,14 +10,14 @@ import { import { makeExplorerUrl } from '@tangle-network/api-provider-environment/transaction/utils'; import { useActiveChain } from '@tangle-network/api-provider-environment/hooks/useActiveChain'; import LogoListItem from './LogoListItem'; -import { BN } from '@polkadot/util'; import { getAssetLabelColorClasses } from '../../utils/getAssetLabelColorClasses'; +import { formatTokenAmount } from '../../utils/formatTokenAmount'; type Props = { assetId: string; name?: string; symbol: string; - balance: BN; + balance: bigint; decimals: number; rightBottomText?: string; leftBottomContentTwo?: string; @@ -39,7 +38,7 @@ const AssetListItem = ({ }: Props) => { const activeChain = useActiveChain(); - const fmtBalance = formatDisplayAmount( + const fmtBalance = formatTokenAmount( balance, decimals, AmountFormatStyle.SHORT, diff --git a/apps/tangle-dapp/src/components/Sidebar/sidebarProps.tsx b/apps/tangle-dapp/src/components/Sidebar/sidebarProps.tsx index 4253c0ebea..20e3357b27 100644 --- a/apps/tangle-dapp/src/components/Sidebar/sidebarProps.tsx +++ b/apps/tangle-dapp/src/components/Sidebar/sidebarProps.tsx @@ -55,7 +55,7 @@ const SIDEBAR_STATIC_ITEMS: SideBarItemProps[] = [ }, { name: 'Claim TNT', - href: PagePath.CLAIM_MIGRATION, + href: PagePath.CLAIM, isInternal: true, Icon: GiftLineIcon, subItems: [], diff --git a/apps/tangle-dapp/src/components/TxHistoryDrawer.tsx b/apps/tangle-dapp/src/components/TxHistoryDrawer.tsx index b6282024ca..e5fb2ce438 100644 --- a/apps/tangle-dapp/src/components/TxHistoryDrawer.tsx +++ b/apps/tangle-dapp/src/components/TxHistoryDrawer.tsx @@ -1,4 +1,3 @@ -import { BN } from '@polkadot/util'; import * as Dialog from '@radix-ui/react-dialog'; import { CheckboxCircleLine, @@ -14,7 +13,6 @@ import { Button, Chip, CopyWithTooltip, - formatDisplayAmount, isEvmAddress, isSubstrateAddress, shortenHex, @@ -34,6 +32,7 @@ import useTxHistoryStore, { import useEvmAddress from '@tangle-network/tangle-shared-ui/hooks/useEvmAddress'; import { useEvmAssetMetadatas } from '@tangle-network/tangle-shared-ui/hooks/useEvmAssetMetadatas'; import ExternalLink from './ExternalLink'; +import { formatTokenAmount } from '../utils/formatTokenAmount'; const TxHistoryDrawer = () => { const activeEvmAddress = useEvmAddress(); @@ -203,12 +202,14 @@ const DetailRow: FC = ({ const isSharesKey = /shares/i.test(label); const formattedValue = useMemo(() => { + const decimals = tokenMetadata?.decimals ?? 18; + if (typeof value === 'number') { // For amount keys, format with decimals; otherwise just add commas if (isAmountKey) { - const decimals = tokenMetadata?.decimals ?? 18; - const formatted = formatDisplayAmount( - new BN(value), + const rawAmount = BigInt(Math.max(0, Math.trunc(value))); + const formatted = formatTokenAmount( + rawAmount, decimals, AmountFormatStyle.SHORT, ); @@ -233,9 +234,8 @@ const DetailRow: FC = ({ if (typeof value === 'string') { // For amount-related keys with numeric strings, format as token amounts if (isAmountKey && isNumericString(value)) { - const decimals = tokenMetadata?.decimals ?? 18; - const formatted = formatDisplayAmount( - new BN(value), + const formatted = formatTokenAmount( + BigInt(value), decimals, AmountFormatStyle.SHORT, ); @@ -249,9 +249,7 @@ const DetailRow: FC = ({ return value; } - // BN value - format with decimals - const decimals = tokenMetadata?.decimals ?? 18; - const formatted = formatDisplayAmount( + const formatted = formatTokenAmount( value, decimals, AmountFormatStyle.SHORT, @@ -265,7 +263,7 @@ const DetailRow: FC = ({ }, [value, isAmountKey, isSharesKey, tokenMetadata, nativeTokenSymbol]); const rawValue = useMemo(() => { - if (BN.isBN(value)) { + if (typeof value === 'bigint') { return value.toString(); } diff --git a/apps/tangle-dapp/src/components/account/Balance.tsx b/apps/tangle-dapp/src/components/account/Balance.tsx index a3665bf005..254e4dcd1d 100644 --- a/apps/tangle-dapp/src/components/account/Balance.tsx +++ b/apps/tangle-dapp/src/components/account/Balance.tsx @@ -1,13 +1,12 @@ -import { BN } from '@polkadot/util'; import { AmountFormatStyle, EMPTY_VALUE_PLACEHOLDER, - formatDisplayAmount, InfoIconWithTooltip, Typography, } from '@tangle-network/ui-components'; import { FC, useMemo } from 'react'; import { useAccount, useBalance } from 'wagmi'; +import { formatTokenAmount } from '../../utils/formatTokenAmount'; const Balance: FC = () => { const { address } = useAccount(); @@ -25,10 +24,8 @@ const Balance: FC = () => { return null; } - const balanceBn = new BN(nativeBalance.value.toString()); - - return formatDisplayAmount( - balanceBn, + return formatTokenAmount( + nativeBalance.value, nativeBalance.decimals, AmountFormatStyle.SHORT, ); diff --git a/apps/tangle-dapp/src/components/claims/ClaimRecipientInput.tsx b/apps/tangle-dapp/src/components/claims/ClaimRecipientInput.tsx deleted file mode 100644 index 9ff9f41941..0000000000 --- a/apps/tangle-dapp/src/components/claims/ClaimRecipientInput.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { isEthereumAddress } from '@polkadot/util-crypto'; -import { Avatar } from '@tangle-network/ui-components/components/Avatar'; -import { TextField } from '@tangle-network/ui-components/components/TextField'; -import { Typography } from '@tangle-network/ui-components/typography/Typography'; -import type { FC } from 'react'; - -type Props = { - recipient: string; - setRecipient: (recipient: string) => void; - error: string; - isDisabled?: boolean; -}; - -const ClaimRecipientInput: FC = ({ - error, - recipient: value, - setRecipient: setValue, - isDisabled, -}) => { - return ( -
- - Airdrop Recipient (EVM or Substrate) - - -
- - - {/** - * Value should be always defined, otherwise the avatar component - * will throw an error. Ensure that no empty string is passed to the - * avatar component by defaulting to `0x00` if the value is an empty - * string. - */} - - - - setValue(e.target.value)} - /> - -
-
- ); -}; - -export default ClaimRecipientInput; diff --git a/apps/tangle-dapp/src/components/claims/ClaimingAccountInput.tsx b/apps/tangle-dapp/src/components/claims/ClaimingAccountInput.tsx deleted file mode 100644 index 1e4542be6c..0000000000 --- a/apps/tangle-dapp/src/components/claims/ClaimingAccountInput.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import { useWebContext } from '@tangle-network/api-provider-environment/webb-context'; -import { - WebbError, - WebbErrorCodes, -} from '@tangle-network/dapp-types/WebbError'; -import { ChevronDown } from '@tangle-network/icons'; -import { WebbWeb3Provider } from '@tangle-network/web3-api-provider/webb-provider'; -import { useUIContext } from '@tangle-network/ui-components'; -import { Avatar } from '@tangle-network/ui-components/components/Avatar'; -import { - AccountDropdownBody, - Dropdown, - DropdownButton, -} from '@tangle-network/ui-components/components/Dropdown'; -import { Typography } from '@tangle-network/ui-components/typography/Typography'; -import type { FC } from 'react'; -import { twMerge } from 'tailwind-merge'; - -import useWalletAccounts from '../../hooks/useWalletAccounts'; -import { BaseError } from 'viem'; - -type Props = { - activeAccountAddress: string; - isDisabled?: boolean; -}; - -const ClaimingAccountInput: FC = ({ - activeAccountAddress, - isDisabled, -}) => { - const { activeApi, setActiveAccount } = useWebContext(); - const { notificationApi } = useUIContext(); - const accounts = useWalletAccounts(); - - const handleEvmSwitch = async ( - walletClient: WebbWeb3Provider['walletClient'], - ) => { - try { - await walletClient.requestPermissions({ eth_accounts: {} }); - } catch (error) { - const message = - error instanceof BaseError - ? error.shortMessage - : WebbError.from(WebbErrorCodes.SwitchAccountFailed).message; - - notificationApi({ variant: 'error', message }); - } - }; - - return ( -
- - Claiming Account (EVM or Substrate) - - - {activeApi instanceof WebbWeb3Provider ? ( - - ) : ( - - } - > - {activeAccountAddress} - - - { - return { - address: account.address, - name: account.name, - onClick: () => setActiveAccount(account.originalAccount), - }; - })} - /> - - )} -
- ); -}; - -export default ClaimingAccountInput; diff --git a/apps/tangle-dapp/src/components/index.ts b/apps/tangle-dapp/src/components/index.ts index 442479e83a..3923da72e6 100644 --- a/apps/tangle-dapp/src/components/index.ts +++ b/apps/tangle-dapp/src/components/index.ts @@ -1,4 +1,3 @@ -export * from './BondedTokensBalanceInfo'; export { default as CardWithTangleLogo } from './CardWithTangleLogo'; export * from './Sidebar'; export * from './skeleton'; diff --git a/apps/tangle-dapp/src/components/tableCells/TokenAmountCell.tsx b/apps/tangle-dapp/src/components/tableCells/TokenAmountCell.tsx deleted file mode 100644 index ea5e48f87b..0000000000 --- a/apps/tangle-dapp/src/components/tableCells/TokenAmountCell.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { BN } from '@polkadot/util'; -import useNetworkStore from '@tangle-network/tangle-shared-ui/context/useNetworkStore'; -import { - AmountFormatStyle, - formatDisplayAmount, -} from '@tangle-network/ui-components'; -import { FC, useMemo } from 'react'; -import { twMerge } from 'tailwind-merge'; - -import formatTangleBalance from '../../utils/formatTangleBalance'; - -export type TokenAmountCellProps = { - amount: BN; - className?: string; - symbol?: string; - decimals?: number; - formatStyle?: AmountFormatStyle; -}; - -const TokenAmountCell: FC = ({ - amount, - className, - symbol, - decimals, - formatStyle: format = AmountFormatStyle.EXACT, -}) => { - const { nativeTokenSymbol } = useNetworkStore(); - - const formattedBalance = useMemo(() => { - // Default to Tangle decimals if not provided. - if (decimals === undefined) { - return formatTangleBalance(amount); - } - - return formatDisplayAmount(amount, decimals, format); - }, [amount, decimals, format]); - - const parts = formattedBalance.split('.'); - const integerPart = parts[0]; - const decimalPart = parts.at(1); - - return ( - - {integerPart} - - - {decimalPart !== undefined && `.${decimalPart}`}{' '} - {typeof symbol === 'string' ? symbol : nativeTokenSymbol} - - - ); -}; - -export default TokenAmountCell; diff --git a/apps/tangle-dapp/src/components/tables/StakingAssetsTable.tsx b/apps/tangle-dapp/src/components/tables/StakingAssetsTable.tsx index fc87e05f8a..a34d9ca8a4 100644 --- a/apps/tangle-dapp/src/components/tables/StakingAssetsTable.tsx +++ b/apps/tangle-dapp/src/components/tables/StakingAssetsTable.tsx @@ -1,5 +1,4 @@ import { FC, useMemo } from 'react'; -import { BN } from '@polkadot/util'; import { TokenIcon } from '@tangle-network/icons'; import Spinner from '@tangle-network/icons/Spinner'; import { useChainId } from 'wagmi'; @@ -10,7 +9,6 @@ import { Avatar, AmountFormatStyle, CopyWithTooltip, - formatDisplayAmount, EMPTY_VALUE_PLACEHOLDER, shortenHex, } from '@tangle-network/ui-components'; @@ -35,6 +33,7 @@ import type { import type { Delegator } from '@tangle-network/tangle-shared-ui/data/graphql/useDelegator'; import { getCachedTokenMetadata } from '@tangle-network/dapp-config/tokenMetadata'; import { PagePath, QueryParamKey } from '../../types'; +import { formatTokenAmount } from '../../utils/formatTokenAmount'; interface Props { assets: StakingAsset[]; @@ -48,10 +47,10 @@ interface StakingAssetRow { symbol: string; name: string; decimals: number; - wallet: BN; - deposited: BN; - delegated: BN; - protocolTvl: BN; + wallet: bigint; + deposited: bigint; + delegated: bigint; + protocolTvl: bigint; tokenAddress: Address; } @@ -133,8 +132,8 @@ const getColumns = (chainId: number) => [ header: () => , cell: (props) => ( - {props.getValue().gtn(0) - ? formatDisplayAmount( + {props.getValue() > BigInt(0) + ? formatTokenAmount( props.getValue(), props.row.original.decimals, AmountFormatStyle.SHORT, @@ -147,8 +146,8 @@ const getColumns = (chainId: number) => [ header: () => , cell: (props) => ( - {props.getValue().gtn(0) - ? formatDisplayAmount( + {props.getValue() > BigInt(0) + ? formatTokenAmount( props.getValue(), props.row.original.decimals, AmountFormatStyle.SHORT, @@ -161,8 +160,8 @@ const getColumns = (chainId: number) => [ header: () => , cell: (props) => ( - {props.getValue().gtn(0) - ? formatDisplayAmount( + {props.getValue() > BigInt(0) + ? formatTokenAmount( props.getValue(), props.row.original.decimals, AmountFormatStyle.SHORT, @@ -175,7 +174,7 @@ const getColumns = (chainId: number) => [ header: () => , cell: (props) => ( - {formatDisplayAmount( + {formatTokenAmount( props.getValue(), props.row.original.decimals, AmountFormatStyle.SHORT, @@ -228,17 +227,10 @@ export const StakingAssetsTable: FC = ({ ); const protocolAsset = protocolAssetMap.get(tokenKey); - const walletBalance = asset.balance ?? BigInt(0); - const wallet = new BN(walletBalance.toString()); - const deposited = new BN( - (position?.totalDeposited ?? BigInt(0)).toString(), - ); - const delegated = new BN( - (position?.delegatedAmount ?? BigInt(0)).toString(), - ); - const protocolTvl = new BN( - (protocolAsset?.currentDeposits ?? BigInt(0)).toString(), - ); + const wallet = asset.balance ?? BigInt(0); + const deposited = position?.totalDeposited ?? BigInt(0); + const delegated = position?.delegatedAmount ?? BigInt(0); + const protocolTvl = protocolAsset?.currentDeposits ?? BigInt(0); return { id: asset.id, diff --git a/apps/tangle-dapp/src/data/evm/useContractWrite.ts b/apps/tangle-dapp/src/data/evm/useContractWrite.ts deleted file mode 100644 index 5e13aed683..0000000000 --- a/apps/tangle-dapp/src/data/evm/useContractWrite.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { HexString } from '@polkadot/util/types'; -import { useWebContext } from '@tangle-network/api-provider-environment'; -import chainsPopulated from '@tangle-network/dapp-config/chains/chainsPopulated'; -import { - calculateTypedChainId, - ChainType, -} from '@tangle-network/dapp-types/TypedChainId'; -import ensureError from '@tangle-network/tangle-shared-ui/utils/ensureError'; -import assert from 'assert'; -import { useCallback } from 'react'; -import { - Abi as ViemAbi, - ContractFunctionArgs, - ContractFunctionName, -} from 'viem'; -import { - simulateContract, - waitForTransactionReceipt, - writeContract, -} from 'viem/actions'; -import { mainnet, sepolia } from 'viem/chains'; -import { useConnectorClient } from 'wagmi'; - -import { TxName } from '../../constants'; -import { IS_PRODUCTION_ENV } from '../../constants/env'; -import useEvmAddress20 from '@tangle-network/tangle-shared-ui/hooks/useEvmAddress'; -import useTxNotification from '../../hooks/useTxNotification'; -import { type NotificationSteps } from '@tangle-network/tangle-shared-ui/hooks/useTxNotification'; - -import useNetworkStore from '@tangle-network/tangle-shared-ui/context/useNetworkStore'; - -const RECEIPT_TIMEOUT_MS = 180_000; - -const isReceiptTimeoutError = (error: Error): boolean => { - return ( - error.name === 'WaitForTransactionReceiptTimeoutError' || - /wait for transaction receipt|timed out|timeout/i.test(error.message) - ); -}; - -export type ContractWriteOptions< - Abi extends ViemAbi, - FunctionName extends ContractFunctionName, -> = { - txName: TxName; - address: HexString; - functionName: FunctionName; - args: ContractFunctionArgs; - notificationStep?: NotificationSteps; -}; - -const useContractWrite = (abi: Abi) => { - const { data: connectorClient } = useConnectorClient(); - const activeEvmAddress20 = useEvmAddress20(); - const { activeChain, activeWallet, switchChain } = useWebContext(); - const { notifyProcessing, notifySuccess, notifyError } = useTxNotification(); - - const createExplorerTxUrl = useNetworkStore( - (store) => store.network.createExplorerTxUrl, - ); - - const write = useCallback( - async < - FunctionName extends ContractFunctionName, - >( - options: ContractWriteOptions, - ) => { - assert( - connectorClient !== undefined, - "Should not be able to call this function if the client isn't ready yet", - ); - - assert( - activeEvmAddress20 !== null, - 'Should not be able to call this function if there is no active EVM account', - ); - - // On development, switch to the Sepolia chain if it's not already active. - // This is because there are dummy contracts deployed to Sepolia for testing. - if ( - !IS_PRODUCTION_ENV && - activeChain !== null && - activeChain !== undefined && - activeChain.id !== sepolia.id && - activeWallet !== undefined - ) { - const typedChainId = calculateTypedChainId(ChainType.EVM, sepolia.id); - const targetChain = chainsPopulated[typedChainId]; - - await switchChain(targetChain, activeWallet); - } - - notifyProcessing(options.txName, options.notificationStep); - - try { - const { request } = await simulateContract(connectorClient, { - chain: IS_PRODUCTION_ENV ? mainnet : sepolia, - address: options.address, - functionName: options.functionName, - account: activeEvmAddress20, - abi: abi as ViemAbi, - args: options.args as unknown[], - }); - - const txHash = await writeContract(connectorClient, request); - - const txReceipt = await waitForTransactionReceipt(connectorClient, { - hash: txHash, - timeout: RECEIPT_TIMEOUT_MS, - }); - - const explorerUrl = createExplorerTxUrl(true, txHash); - - if (txReceipt.status === 'success') { - notifySuccess(options.txName, explorerUrl); - } else { - notifyError( - options.txName, - `${options.txName} reverted. Explorer: ${explorerUrl ?? 'unavailable'}`, - ); - } - - return txReceipt.status === 'success'; - } catch (possibleError) { - const error = ensureError(possibleError); - const normalizedError = isReceiptTimeoutError(error) - ? new Error( - `Transaction confirmation timed out after ${Math.round( - RECEIPT_TIMEOUT_MS / 1000, - )} seconds`, - ) - : error; - - notifyError(options.txName, normalizedError); - - return false; - } - }, - [ - abi, - activeChain, - activeEvmAddress20, - activeWallet, - connectorClient, - createExplorerTxUrl, - notifyError, - notifyProcessing, - notifySuccess, - switchChain, - ], - ); - - // Only provide the write function once the connector client is ready, - // and there is an active EVM account. - return connectorClient === undefined || activeEvmAddress20 === null - ? null - : write; -}; - -export default useContractWrite; diff --git a/apps/tangle-dapp/src/features/claimCredits/components/ClaimCreditsModal.tsx b/apps/tangle-dapp/src/features/claimCredits/components/ClaimCreditsModal.tsx index 014ae8116d..eb3acb8a72 100644 --- a/apps/tangle-dapp/src/features/claimCredits/components/ClaimCreditsModal.tsx +++ b/apps/tangle-dapp/src/features/claimCredits/components/ClaimCreditsModal.tsx @@ -11,16 +11,13 @@ import { } from '@tangle-network/ui-components'; import useNetworkStore from '@tangle-network/tangle-shared-ui/context/useNetworkStore'; import { TxStatus } from '@tangle-network/tangle-shared-ui/hooks/useSubstrateTx'; -import { BN } from '@polkadot/util'; import { TANGLE_TOKEN_DECIMALS } from '@tangle-network/dapp-config'; -import { - AmountFormatStyle, - formatDisplayAmount, -} from '@tangle-network/ui-components'; +import { AmountFormatStyle } from '@tangle-network/ui-components'; import useCredits from '../../../data/credits/useCredits'; import useClaimCreditsTx from '../../../data/credits/useClaimCreditsTx'; import { meetsMinimumClaimThreshold } from '../../../utils/creditConstraints'; import CreditVelocityTooltip from './CreditVelocityTooltip'; +import { formatTokenAmount } from '../../../utils/formatTokenAmount'; type Props = { isOpen: boolean; @@ -63,8 +60,8 @@ const ClaimCreditsModal: FC = ({ isOpen, setIsOpen }) => { }, [offchainAccountId, execute, refetch, setIsOpen, data]); const formattedAmount = data?.amount - ? formatDisplayAmount( - new BN(data.amount.toString()), + ? formatTokenAmount( + data.amount, TANGLE_TOKEN_DECIMALS, AmountFormatStyle.SHORT, ) diff --git a/apps/tangle-dapp/src/features/claimCredits/components/CreditVelocityTooltip.tsx b/apps/tangle-dapp/src/features/claimCredits/components/CreditVelocityTooltip.tsx index c9873c2357..e32ec48b41 100644 --- a/apps/tangle-dapp/src/features/claimCredits/components/CreditVelocityTooltip.tsx +++ b/apps/tangle-dapp/src/features/claimCredits/components/CreditVelocityTooltip.tsx @@ -7,15 +7,12 @@ import { TooltipTrigger, } from '@tangle-network/ui-components'; import { TANGLE_TOKEN_DECIMALS } from '@tangle-network/dapp-config'; -import { - formatDisplayAmount, - AmountFormatStyle, -} from '@tangle-network/ui-components'; -import { BN } from '@polkadot/util'; +import { AmountFormatStyle } from '@tangle-network/ui-components'; import { getCreditsNeededForMinimum, MINIMUM_CLAIMABLE_CREDITS, } from '../../../utils/creditConstraints'; +import { formatTokenAmount } from '../../../utils/formatTokenAmount'; type Props = { currentAmount: bigint | null | undefined; @@ -31,13 +28,9 @@ const CreditVelocityTooltip: FC = ({ [currentAmount], ); - // Convert decimal values to token units (multiply by 10^decimals) for BN - const formattedMinimum = formatDisplayAmount( - new BN( - BigInt( - Math.round(MINIMUM_CLAIMABLE_CREDITS * 10 ** TANGLE_TOKEN_DECIMALS), - ).toString(), - ), + // Convert decimal display minimum to raw token units. + const formattedMinimum = formatTokenAmount( + BigInt(Math.round(MINIMUM_CLAIMABLE_CREDITS * 10 ** TANGLE_TOKEN_DECIMALS)), TANGLE_TOKEN_DECIMALS, AmountFormatStyle.SHORT, ); diff --git a/apps/tangle-dapp/src/hooks/useFormatNativeTokenAmount.ts b/apps/tangle-dapp/src/hooks/useFormatNativeTokenAmount.ts deleted file mode 100644 index 0130773d78..0000000000 --- a/apps/tangle-dapp/src/hooks/useFormatNativeTokenAmount.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { BN } from '@polkadot/util'; -import useNetworkStore from '@tangle-network/tangle-shared-ui/context/useNetworkStore'; -import { useCallback } from 'react'; - -import formatTangleBalance from '../utils/formatTangleBalance'; - -// Centralized formatter for native token amounts with the active network symbol. -export default function useFormatNativeTokenAmount() { - const { nativeTokenSymbol } = useNetworkStore(); - - const formatNativeTokenAmount = useCallback( - (amount: BN) => { - return formatTangleBalance(amount, nativeTokenSymbol); - }, - [nativeTokenSymbol], - ); - - return formatNativeTokenAmount; -} diff --git a/apps/tangle-dapp/src/hooks/useWalletAccounts.ts b/apps/tangle-dapp/src/hooks/useWalletAccounts.ts deleted file mode 100644 index f1450e31d9..0000000000 --- a/apps/tangle-dapp/src/hooks/useWalletAccounts.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { encodeAddress } from '@polkadot/util-crypto'; -import { HexString } from '@polkadot/util/types'; -import { Account } from '@tangle-network/abstract-api-provider'; -import { useWebContext } from '@tangle-network/api-provider-environment'; -import useNetworkStore from '@tangle-network/tangle-shared-ui/context/useNetworkStore'; -import { - SolanaAddress, - SubstrateAddress, -} from '@tangle-network/ui-components/types/address'; -import assertSolanaAddress from '@tangle-network/ui-components/utils/assertSolanaAddress'; -import assertSubstrateAddress from '@tangle-network/ui-components/utils/assertSubstrateAddress'; -import { isEvmAddress } from '@tangle-network/ui-components/utils/isEvmAddress20'; -import { isSolanaAddress } from '@tangle-network/ui-components/utils/isSolanaAddress'; -import { useMemo } from 'react'; - -export type WalletAccount = { - name: string; - address: HexString | SubstrateAddress | SolanaAddress; - originalAccount: Account; -}; - -const useWalletAccounts = (): WalletAccount[] => { - const { network } = useNetworkStore(); - const { accounts: webContextAccounts } = useWebContext(); - - const accounts = useMemo(() => { - return webContextAccounts.map((account) => { - let address: HexString | SubstrateAddress | SolanaAddress; - - if (isSolanaAddress(account.address)) { - address = assertSolanaAddress(account.address); - } else if (isEvmAddress(account.address)) { - address = account.address; - } else { - // If it's a Substrate address, encode it using the active network's SS58 prefix. - const encodedSubstrateAddress = - network.ss58Prefix !== undefined - ? encodeAddress(account.address, network.ss58Prefix) - : account.address; - - address = assertSubstrateAddress(encodedSubstrateAddress); - } - - return { - name: account.name, - address, - originalAccount: account, - } satisfies WalletAccount; - }); - }, [network.ss58Prefix, webContextAccounts]); - - return accounts; -}; - -export default useWalletAccounts; diff --git a/apps/tangle-dapp/src/pages/claim/EligibleSection.tsx b/apps/tangle-dapp/src/pages/claim/EligibleSection.tsx deleted file mode 100644 index 244c51fabe..0000000000 --- a/apps/tangle-dapp/src/pages/claim/EligibleSection.tsx +++ /dev/null @@ -1,413 +0,0 @@ -import type { SubmittableExtrinsic } from '@polkadot/api/types'; -import type { ISubmittableResult } from '@polkadot/types/types'; -import { BN_ZERO, hexToU8a, stringToU8a, u8aToString } from '@polkadot/util'; -import { - decodeAddress, - isEthereumAddress, - keccakAsHex, -} from '@polkadot/util-crypto'; -import { useConnectWallet } from '@tangle-network/api-provider-environment/ConnectWallet'; -import { useWebContext } from '@tangle-network/api-provider-environment/webb-context'; -import { - WebbError, - WebbErrorCodes, -} from '@tangle-network/dapp-types/WebbError'; -import useNetworkStore from '@tangle-network/tangle-shared-ui/context/useNetworkStore'; -import { getApiPromise } from '@tangle-network/tangle-shared-ui/utils/polkadot/api'; -import { isValidAddress } from '@tangle-network/ui-components'; -import Button from '@tangle-network/ui-components/components/buttons/Button'; -import { CheckBox } from '@tangle-network/ui-components/components/CheckBox'; -import { useUIContext } from '@tangle-network/ui-components/hooks/useUIContext'; -import { Typography } from '@tangle-network/ui-components/typography/Typography'; -import { shortenHex } from '@tangle-network/ui-components/utils/shortenHex'; -import { shortenString } from '@tangle-network/ui-components/utils/shortenString'; -import assert from 'assert'; -import { FC, useCallback, useEffect, useState } from 'react'; -import { useNavigate, useSearchParams } from 'react-router'; -import { isHex } from 'viem'; - -import ClaimingAccountInput from '../../components/claims/ClaimingAccountInput'; -import ClaimRecipientInput from '../../components/claims/ClaimRecipientInput'; -import toAsciiHex from '../../utils/claims/toAsciiHex'; -import formatTangleBalance from '../../utils/formatTangleBalance'; -import getStatement, { Statement } from '../../utils/getStatement'; -import type { ClaimInfoType } from './types'; -import useActiveAccountAddress from '@tangle-network/tangle-shared-ui/hooks/useActiveAccountAddress'; - -enum Step { - INPUT_ADDRESS, - SIGN, - SENDING_TX, -} - -const CLAIM_SUCCESS_QUERY_PARAMS = { - TX_HASH: 'h', - RPC_ENDPOINT: 'rpcEndpoint', -} as const; - -type Props = { - claimInfo: ClaimInfoType; - setIsClaiming: (isClaiming: boolean) => void; -}; - -const EligibleSection: FC = ({ - claimInfo: { totalAmount, vestingAmount, isRegularStatement }, - setIsClaiming, -}) => { - assert( - totalAmount.gte(vestingAmount), - "Total amount can't be less than vesting amount", - ); - - const { activeApi } = useWebContext(); - const { toggleModal } = useConnectWallet(); - const { notificationApi } = useUIContext(); - const [searchParams] = useSearchParams(); - const navigate = useNavigate(); - const rpcEndpoints = useNetworkStore((store) => store.network.wsRpcEndpoints); - const { nativeTokenSymbol } = useNetworkStore(); - const activeAccountAddress = useActiveAccountAddress(); - - const [recipient, setRecipient] = useState( - activeAccountAddress ?? 'Loading...', - ); - - const [recipientErrorMsg, setRecipientErrorMsg] = useState(''); - const [step, setStep] = useState(Step.INPUT_ADDRESS); - const [statement, setStatement] = useState(null); - const [hasReadStatement, setHasReadStatement] = useState(false); - - // Validate recipient input address after 500 ms - useEffect(() => { - const timeout = setTimeout(() => { - if (recipient && !isValidAddress(recipient)) { - setRecipientErrorMsg('Invalid address'); - } else { - setRecipientErrorMsg(''); - } - }, 500); - - return () => clearTimeout(timeout); - }, [recipient]); - - // get statement - useEffect(() => { - const fetchStatement = async () => { - try { - const api = await getApiPromise(rpcEndpoints); - const systemChain = await api.rpc.system.chain(); - const statement = getStatement( - systemChain.toHuman(), - isRegularStatement, - ); - setStatement(statement); - } catch (error) { - notificationApi({ - message: - typeof error === 'string' - ? `Error: ${error}` - : error instanceof Error - ? error.message - : 'Failed to get statement', - variant: 'error', - }); - } - }; - - fetchStatement(); - }, [rpcEndpoints, isRegularStatement, notificationApi]); - - const handleClaimClick = useCallback(async () => { - if (!activeApi || activeAccountAddress === null) { - const message = !activeApi - ? WebbError.getErrorMessage(WebbErrorCodes.ApiNotReady).message - : WebbError.getErrorMessage(WebbErrorCodes.NoAccountAvailable).message; - - notificationApi.addToQueue({ - variant: 'error', - message, - }); - - return; - } - - try { - setIsClaiming(true); - setStep(Step.SIGN); - - const api = await getApiPromise(rpcEndpoints); - const accountId = activeAccountAddress; - const isEvmRecipient = isEthereumAddress(recipient); - const isEvmSigner = isEthereumAddress(accountId); - - const statementSentence = statement?.sentence || ''; - - const prefix = api.consts.claims.prefix.toU8a(true); - - const payload = preparePayload( - prefix, - statementSentence, - recipient, - isEvmSigner, - isEvmRecipient, - ); - - const signature = await activeApi.sign(payload); - - setStep(Step.SENDING_TX); - - const tx = api.tx.claims.claimAttest( - isEvmRecipient ? { EVM: recipient } : { Native: recipient }, // destAccount - isEvmSigner ? { EVM: accountId } : { Native: accountId }, // signer - isEvmSigner ? { EVM: signature } : { Native: signature }, // signature - statementSentence, - ); - - const txReceiptHash = await sendTransaction(tx); - const newSearchParams = new URLSearchParams(searchParams.toString()); - - newSearchParams.set(CLAIM_SUCCESS_QUERY_PARAMS.TX_HASH, txReceiptHash); - newSearchParams.set( - CLAIM_SUCCESS_QUERY_PARAMS.RPC_ENDPOINT, - rpcEndpoints[0], - ); - - navigate(`claim/success?${newSearchParams.toString()}`, { - preventScrollReset: false, - }); - } catch (error) { - setIsClaiming(false); - notificationApi.addToQueue({ - variant: 'error', - message: - error instanceof Error - ? error.message - : typeof error === 'string' - ? `Error: ${error}` - : 'Failed to sign & send transaction', - secondaryMessage: error instanceof Error ? undefined : String(error), - }); - - setStep(Step.INPUT_ADDRESS); - } - }, [ - activeApi, - activeAccountAddress, - notificationApi, - setIsClaiming, - rpcEndpoints, - recipient, - statement?.sentence, - searchParams, - navigate, - ]); - - if (activeAccountAddress === null) { - return null; - } - - return ( -
-
-
- - - -
- -
-
- - You will receive the total balance of... - - - - {`${formatTangleBalance(totalAmount, nativeTokenSymbol)} `} - {isValidAddress(recipient) - ? `to ${ - isHex(recipient) - ? shortenHex(recipient) - : shortenString(recipient) - }` - : ''} - -
- - {/* Only show this when there's vesting amount */} - {vestingAmount.gt(BN_ZERO) && ( -
- {/* Free Balance */} - - {`${formatTangleBalance( - totalAmount.sub(vestingAmount), - nativeTokenSymbol, - )}`}{' '} - will be available immediately as free balance. - - - {/* Vesting: based on Tangle Genesis Allocations */} - {/* https://docs.tangle.tools/network/tokenomics/allocation */} - - {`${formatTangleBalance(vestingAmount, nativeTokenSymbol)}`}{' '} - will be vested over 24 months with a 1 month cliff. - -
- )} -
- - - Note: You can claim your $TNT airdrop to a Substrate or an EVM - address. - -
- - {statement !== null && ( -
- { - setHasReadStatement(!hasReadStatement); - }} - wrapperClassName="pt-0.5" - /> - - - {`I have read and understood the terms and conditions of the statement provided at `} - - {statement.url} - - -
- )} - -
- - - -
-
- ); -}; - -export default EligibleSection; - -function preparePayload( - prefix: Uint8Array, - statementSentence: string, - recipient: string, - isEvmSigner: boolean, - isEvmRecipient: boolean, -): string { - const statementBytes = stringToU8a(statementSentence); - - const addressEncoded = toAsciiHex( - isEvmRecipient ? hexToU8a(recipient) : decodeAddress(recipient), - ); - - const message = new Uint8Array( - prefix.length + addressEncoded.length + statementBytes.length, - ); - - message.set(prefix, 0); - message.set(addressEncoded, prefix.length); - message.set(statementBytes, prefix.length + addressEncoded.length); - - if (isEvmSigner) { - return u8aToString(message); - } - - // Otherwise, we need to hash the payload - return keccakAsHex(message); -} - -function sendTransaction( - tx: SubmittableExtrinsic<'promise', ISubmittableResult>, -) { - console.debug(`Sending transaction with args ${tx.args.toString()}`); - - return new Promise((resolve, reject) => { - tx.send(async (result) => { - const status = result.status; - const events = result.events.filter( - ({ event: { section } }) => section === 'system', - ); - - if (status.isInBlock || status.isFinalized) { - for (const event of events) { - const { - event: { method }, - } = event; - const dispatchError = result.dispatchError; - - if (dispatchError && method === 'ExtrinsicFailed') { - let message: string = dispatchError.type; - - if (dispatchError.isModule) { - try { - const mod = dispatchError.asModule; - const error = dispatchError.registry.findMetaError(mod); - - message = `${error.section}.${error.name}`; - } catch (error) { - console.error(error); - reject(message); - } - } else if (dispatchError.isToken) { - message = `${dispatchError.type}.${dispatchError.asToken.type}`; - } - - reject(message); - } else if (method === 'ExtrinsicSuccess' && status.isFinalized) { - // Resolve with the block hash - resolve(status.asFinalized.toString()); - } - } - } - }).catch((error) => { - console.error(error); - reject(error); - }); - }); -} - -function getLoadingText(step: Step) { - switch (step) { - case Step.SIGN: - return 'Signing...'; - case Step.SENDING_TX: - return 'Sending transaction...'; - default: - return ''; - } -} diff --git a/apps/tangle-dapp/src/pages/claim/Loading.tsx b/apps/tangle-dapp/src/pages/claim/Loading.tsx deleted file mode 100644 index a6b4f1c71f..0000000000 --- a/apps/tangle-dapp/src/pages/claim/Loading.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import Spinner from '@tangle-network/icons/Spinner'; -import { AppTemplate } from '@tangle-network/ui-components/containers/AppTemplate'; - -export default function Loading() { - return ( - - - - ); -} diff --git a/apps/tangle-dapp/src/pages/claim/NotEligibleSection.tsx b/apps/tangle-dapp/src/pages/claim/NotEligibleSection.tsx deleted file mode 100644 index 9f191d6778..0000000000 --- a/apps/tangle-dapp/src/pages/claim/NotEligibleSection.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { useConnectWallet } from '@tangle-network/api-provider-environment/ConnectWallet'; -import { Button } from '@tangle-network/ui-components'; -import type { FC } from 'react'; - -import ClaimingAccountInput from '../../components/claims/ClaimingAccountInput'; -import useActiveAccountAddress from '@tangle-network/tangle-shared-ui/hooks/useActiveAccountAddress'; - -const NotEligibleSection: FC = () => { - const activeAccountAddress = useActiveAccountAddress(); - const { toggleModal } = useConnectWallet(); - - if (activeAccountAddress === null) { - return null; - } - - return ( -
- - -
- -
-
- ); -}; - -export default NotEligibleSection; diff --git a/apps/tangle-dapp/src/pages/claim/index.tsx b/apps/tangle-dapp/src/pages/claim/index.tsx deleted file mode 100644 index fe2a44848c..0000000000 --- a/apps/tangle-dapp/src/pages/claim/index.tsx +++ /dev/null @@ -1,233 +0,0 @@ -import { BN_ZERO } from '@polkadot/util'; -import { isEthereumAddress } from '@polkadot/util-crypto'; -import { useConnectWallet } from '@tangle-network/api-provider-environment/ConnectWallet'; -import { useWebContext } from '@tangle-network/api-provider-environment/webb-context'; -import { PresetTypedChainId } from '@tangle-network/dapp-types/ChainId'; -import { Spinner } from '@tangle-network/icons'; -import useNetworkStore from '@tangle-network/tangle-shared-ui/context/useNetworkStore'; -import { getApiRx } from '@tangle-network/tangle-shared-ui/utils/polkadot/api'; -import Button from '@tangle-network/ui-components/components/buttons/Button'; -import { AppTemplate } from '@tangle-network/ui-components/containers/AppTemplate'; -import { useUIContext } from '@tangle-network/ui-components/hooks/useUIContext'; -import { Typography } from '@tangle-network/ui-components/typography/Typography'; -import { FC, useEffect, useMemo, useState } from 'react'; -import { combineLatest, Subscription } from 'rxjs'; - -import useActiveAccountAddress from '@tangle-network/tangle-shared-ui/hooks/useActiveAccountAddress'; -import EligibleSection from './EligibleSection'; -import NotEligibleSection from './NotEligibleSection'; -import type { ClaimInfoType } from './types'; - -const ClaimPage: FC = () => { - const { toggleModal, isWalletConnected } = useConnectWallet(); - const { loading, isConnecting } = useWebContext(); - const activeAccountAddress = useActiveAccountAddress(); - const { notificationApi } = useUIContext(); - const rpcEndpoints = useNetworkStore((store) => store.network.wsRpcEndpoints); - const { nativeTokenSymbol } = useNetworkStore(); - - // Default to null to indicate that we are still checking - // If false, then we know that the user is not eligible - // Otherwise, the state will be the claim info. - const [claimInfo, setClaimInfo] = useState( - null, - ); - const [isClaiming, setIsClaiming] = useState(false); - - const { title, subTitle } = useMemo(() => { - if (claimInfo === null) { - return { - title: 'Check Eligibility', - subTitle: 'CLAIM AIRDROP', - }; - } - - if (claimInfo || isClaiming) { - return { - title: 'Airdrop Available', - subTitle: 'CONGRATULATIONS!', - }; - } - - return { - title: 'Not Eligible', - subTitle: 'OOPS!', - }; - }, [claimInfo, isClaiming]); - - useEffect(() => { - if (activeAccountAddress === null) { - return; - } - - let isMounted = true; - let sub: Subscription | null = null; - const accountAddress = activeAccountAddress; - - const fetchClaimData = async () => { - try { - const apiRx = await getApiRx(rpcEndpoints); - - const params = isEthereumAddress(accountAddress) - ? { EVM: accountAddress } - : { Native: accountAddress }; - - sub = combineLatest([ - apiRx.query.claims.claims(params), - apiRx.query.claims.signing(params), - apiRx.query.claims.vesting(params), - ]).subscribe(([claimAmount, statement, vestingInfo]) => { - if (claimAmount.isNone || statement.isNone) { - setClaimInfo(false); - return; - } - - if (isMounted) { - const totalClaim = claimAmount.unwrap(); - let vestingAmount = BN_ZERO; - - if (vestingInfo.isSome) { - vestingAmount = vestingInfo - .unwrap() - .reduce((acc, item) => acc.add(item[0]), BN_ZERO); - } - - const claimResult: ClaimInfoType = { - isRegularStatement: statement.unwrap().isRegular, - totalAmount: totalClaim, - vestingAmount, - }; - setClaimInfo(claimResult); - } - }); - } catch (error) { - if (isMounted) { - notificationApi({ - message: - typeof error === 'string' - ? `Error: ${error}` - : error instanceof Error - ? error.message - : 'Failed to check eligibility', - variant: 'error', - }); - } - } - }; - - fetchClaimData(); - - return () => { - isMounted = false; - sub?.unsubscribe(); - }; - }, [activeAccountAddress, rpcEndpoints, nativeTokenSymbol, notificationApi]); - - return ( - - - - - - {claimInfo === null ? ( - <> - As part of {"Tangle's"} initial launch, the Tangle network is - distributing 5 million TNT tokens to the community. Check - eligibility below to see if your account qualifies for the TNT - airdrop! - - ) : claimInfo || isClaiming ? ( - <> - This account is eligible for the $TNT airdrop! Review details - below, and start the claiming process. - - ) : ( - <> - This account is not eligible for the $TNT airdrop. You can still - participate in the Tangle network by acquiring $TNT or try again - with a different account by disconnecting your current wallet. - - )} - - - - - {/* Wallet not connected */} - {!isWalletConnected && ( - <> - - Connect your EVM or Substrate wallet to check eligibility: - - -
- - - -
- - )} - - {isWalletConnected && claimInfo === null && ( - <> - - Checking eligibility - - - - - )} - - {isWalletConnected && isClaiming && ( - <> - - Claiming - - - - - )} - - {/* Eligible */} - {isWalletConnected && !isClaiming && claimInfo && ( - - )} - - {/* Not Eligible */} - {isWalletConnected && !isClaiming && claimInfo === false && ( - - )} -
-
- ); -}; - -export default ClaimPage; diff --git a/apps/tangle-dapp/src/pages/claim/layout.tsx b/apps/tangle-dapp/src/pages/claim/layout.tsx deleted file mode 100644 index 7939d63ed1..0000000000 --- a/apps/tangle-dapp/src/pages/claim/layout.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { Card, CardVariant } from '@tangle-network/ui-components'; -import { Divider } from '@tangle-network/ui-components/components/Divider'; -import { AppTemplate } from '@tangle-network/ui-components/containers/AppTemplate'; -import FAQSection from '@tangle-network/ui-components/containers/FAQSection'; -import type { FC } from 'react'; -import { Outlet } from 'react-router'; -import faqItems from '../../constants/faq'; - -const Layout: FC = () => { - return ( - - - {/** Outlet is used to render the child routes */} - - - - - - - - - - ); -}; - -export default Layout; diff --git a/apps/tangle-dapp/src/pages/claim/success/SuccessClient.tsx b/apps/tangle-dapp/src/pages/claim/success/SuccessClient.tsx deleted file mode 100644 index 70abed07bc..0000000000 --- a/apps/tangle-dapp/src/pages/claim/success/SuccessClient.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { ShieldedCheckLineIcon } from '@tangle-network/icons'; -import { KeyValueWithButton } from '@tangle-network/ui-components/components/KeyValueWithButton'; -import { AppTemplate } from '@tangle-network/ui-components/containers/AppTemplate'; -import { Typography } from '@tangle-network/ui-components/typography/Typography'; -import { type FC } from 'react'; -import { Hash } from 'viem'; - -const SuccessClient: FC<{ blockHash: Hash }> = ({ blockHash }) => { - return ( - - - - -
- - - - You have successfully claimed TNT airdrop! Your transaction has been - confirmed on the Tangle network. View transaction details on the - explorer link below. - - - {blockHash && ( - - )} -
-
-
- ); -}; - -export default SuccessClient; diff --git a/apps/tangle-dapp/src/pages/claim/success/index.tsx b/apps/tangle-dapp/src/pages/claim/success/index.tsx deleted file mode 100644 index 66ffef8dcf..0000000000 --- a/apps/tangle-dapp/src/pages/claim/success/index.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { isHex } from '@polkadot/util'; -import { getApiPromise } from '@tangle-network/tangle-shared-ui/utils/polkadot/api'; - -import type { HexString } from '@polkadot/util/types'; -import { useEffect, useState } from 'react'; -import { useNavigate, useSearchParams } from 'react-router'; -import { PagePath } from '../../../types'; -import Loading from '../Loading'; -import SuccessClient from './SuccessClient'; - -const isBlockHashExistOnChain = async ( - api: NonNullable>>, - blockHash: string, -) => { - try { - await api.rpc.chain.getBlock(blockHash); - - return true; - } catch { - return false; - } -}; - -const Page = () => { - const [searchParams] = useSearchParams(); - const navigate = useNavigate(); - const [validBlockHash, setValidBlockHash] = useState(null); - - useEffect(() => { - const checkBlockHash = async () => { - const blockHash = searchParams.get('h'); - const rpcEndpoint = searchParams.get('rpcEndpoint'); - - if (!rpcEndpoint || typeof rpcEndpoint !== 'string') { - navigate(PagePath.CLAIM_AIRDROP, { replace: true }); - return; - } - - const api = await getApiPromise(rpcEndpoint); - - const isValid = - typeof blockHash === 'string' && - isHex(blockHash) && - (await isBlockHashExistOnChain(api, blockHash)); - - if (!isValid) { - navigate(PagePath.CLAIM_AIRDROP, { replace: true }); - } else { - setValidBlockHash(blockHash); - } - }; - - checkBlockHash(); - }, [searchParams, navigate]); - - if (validBlockHash === null) { - return ; - } - - return ; -}; - -export default Page; diff --git a/apps/tangle-dapp/src/pages/claim/types.ts b/apps/tangle-dapp/src/pages/claim/types.ts deleted file mode 100644 index 9a66e8e45e..0000000000 --- a/apps/tangle-dapp/src/pages/claim/types.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { BN } from '@polkadot/util'; - -export type ClaimInfoType = { - totalAmount: BN; - vestingAmount: BN; - isRegularStatement: boolean; -}; diff --git a/apps/tangle-dapp/src/pages/liquid-staking/create-vault/index.tsx b/apps/tangle-dapp/src/pages/liquid-staking/create-vault/index.tsx index 2dcebe8161..4db134ab31 100644 --- a/apps/tangle-dapp/src/pages/liquid-staking/create-vault/index.tsx +++ b/apps/tangle-dapp/src/pages/liquid-staking/create-vault/index.tsx @@ -36,7 +36,6 @@ import { TxStatus } from '@tangle-network/tangle-shared-ui/hooks/useContractWrit import filterBy from '@tangle-network/tangle-shared-ui/utils/filterBy'; import { Switcher } from '@tangle-network/ui-components/components/Switcher'; import { CheckBox } from '@tangle-network/ui-components/components/CheckBox'; -import { BN } from '@polkadot/util'; import OperatorListItem from '../../../components/Lists/OperatorListItem'; import AssetListItem from '../../../components/Lists/AssetListItem'; @@ -505,7 +504,7 @@ const CreateVaultForm: FC = () => { assetId={asset.id} name={asset.metadata.name} symbol={asset.metadata.symbol} - balance={new BN((asset.balance ?? BigInt(0)).toString())} + balance={asset.balance ?? BigInt(0)} decimals={asset.metadata.decimals} /> )} diff --git a/apps/tangle-dapp/src/pages/staking/delegate/index.tsx b/apps/tangle-dapp/src/pages/staking/delegate/index.tsx index cbda69ffb0..356fb4369d 100644 --- a/apps/tangle-dapp/src/pages/staking/delegate/index.tsx +++ b/apps/tangle-dapp/src/pages/staking/delegate/index.tsx @@ -21,7 +21,6 @@ import { FC, useCallback, useEffect, useMemo, useRef } from 'react'; import useFormSetValue from '../../../hooks/useFormSetValue'; import { SubmitHandler, useForm } from 'react-hook-form'; import { Address, formatUnits, parseUnits } from 'viem'; -import { BN } from '@polkadot/util'; import { useAccount, useChainId, usePublicClient } from 'wagmi'; import { useQuery } from '@tanstack/react-query'; import ErrorMessage from '@tangle-network/tangle-shared-ui/components/ErrorMessage'; @@ -124,7 +123,7 @@ const StakingDelegateForm: FC = () => { const { assets: stakingAssets } = useStakingAssets({ enabled: Boolean(userAddress), }); - const { data: operatorMap } = useOperatorMap(); + const { data: operatorMap } = useOperatorMap({ status: 'ACTIVE' }); const blueprintSelection = useBlueprintStore((store) => store.selection); const tokenAddresses = useMemo(() => { @@ -718,7 +717,7 @@ const StakingDelegateForm: FC = () => { assetId={asset.id} name={asset.name} symbol={asset.symbol} - balance={new BN(asset.availableBalance.toString())} + balance={asset.availableBalance} decimals={asset.decimals} rightBottomText="Available" /> diff --git a/apps/tangle-dapp/src/pages/staking/deposit/DepositForm.tsx b/apps/tangle-dapp/src/pages/staking/deposit/DepositForm.tsx index a791ba23e2..088fc63b3f 100644 --- a/apps/tangle-dapp/src/pages/staking/deposit/DepositForm.tsx +++ b/apps/tangle-dapp/src/pages/staking/deposit/DepositForm.tsx @@ -16,7 +16,6 @@ import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import useFormSetValue from '../../../hooks/useFormSetValue'; import { SubmitHandler, useForm } from 'react-hook-form'; import { erc20Abi, formatUnits, parseUnits, zeroAddress, Address } from 'viem'; -import { BN } from '@polkadot/util'; import { useChainId } from 'wagmi'; import ErrorMessage from '@tangle-network/tangle-shared-ui/components/ErrorMessage'; import ActionButtonBase from '../../../components/staking/ActionButtonBase'; @@ -573,7 +572,7 @@ const DepositForm: FC = () => { assetId={assetItem.id} name={assetItem.metadata.name} symbol={assetItem.metadata.symbol} - balance={new BN((assetItem.balance ?? BigInt(0)).toString())} + balance={assetItem.balance ?? BigInt(0)} decimals={assetItem.metadata.decimals} /> )} diff --git a/apps/tangle-dapp/src/pages/staking/undelegate/index.tsx b/apps/tangle-dapp/src/pages/staking/undelegate/index.tsx index 2f8971a831..9cba08012b 100644 --- a/apps/tangle-dapp/src/pages/staking/undelegate/index.tsx +++ b/apps/tangle-dapp/src/pages/staking/undelegate/index.tsx @@ -19,7 +19,6 @@ import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { type SubmitHandler, useForm } from 'react-hook-form'; import { twMerge } from 'tailwind-merge'; import { Address, formatUnits, parseUnits } from 'viem'; -import { BN } from '@polkadot/util'; import { useAccount, useChainId } from 'wagmi'; import ErrorMessage from '@tangle-network/tangle-shared-ui/components/ErrorMessage'; import StakingDetailCard from '../../../components/StakingDetailCard'; @@ -52,14 +51,12 @@ import type { EvmAddress } from '@tangle-network/ui-components/types/address'; import ListModal from '@tangle-network/tangle-shared-ui/components/ListModal'; import filterBy from '@tangle-network/tangle-shared-ui/utils/filterBy'; import LogoListItem from '../../../components/Lists/LogoListItem'; -import { - AmountFormatStyle, - formatDisplayAmount, -} from '@tangle-network/ui-components'; +import { AmountFormatStyle } from '@tangle-network/ui-components'; import MULTI_ASSET_DELEGATION_ABI from '@tangle-network/tangle-shared-ui/abi/multiAssetDelegation'; import { getContractsByChainId } from '@tangle-network/dapp-config/contracts'; import { useResilientReadContract } from '@tangle-network/tangle-shared-ui/hooks/useResilientReadContract'; import { useResilientReadContracts } from '@tangle-network/tangle-shared-ui/hooks/useResilientReadContracts'; +import { formatTokenAmount } from '../../../utils/formatTokenAmount'; // Delegation item with metadata for selection type DelegationItem = { @@ -757,8 +754,8 @@ const StakingUndelegateForm: FC = () => { filterBy(query, [item.operatorAddress, item.tokenSymbol]) } renderItem={(item) => { - const fmtBalance = formatDisplayAmount( - new BN(item.availableToUnstake.toString()), + const fmtBalance = formatTokenAmount( + item.availableToUnstake, item.tokenDecimals, AmountFormatStyle.SHORT, ); diff --git a/apps/tangle-dapp/src/pages/staking/withdraw/index.tsx b/apps/tangle-dapp/src/pages/staking/withdraw/index.tsx index 4a29b8f282..9e3776f17f 100644 --- a/apps/tangle-dapp/src/pages/staking/withdraw/index.tsx +++ b/apps/tangle-dapp/src/pages/staking/withdraw/index.tsx @@ -15,7 +15,6 @@ import { Typography } from '@tangle-network/ui-components/typography/Typography' import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { type SubmitHandler, useForm } from 'react-hook-form'; import { Address, formatUnits, parseUnits } from 'viem'; -import { BN } from '@polkadot/util'; import { useAccount, useBlockNumber, useChainId, usePublicClient } from 'wagmi'; import { useQuery } from '@tanstack/react-query'; import ErrorMessage from '@tangle-network/tangle-shared-ui/components/ErrorMessage'; @@ -740,7 +739,7 @@ const StakingWithdrawForm: FC = () => { diff --git a/apps/tangle-dapp/src/types/astarDappStaking.ts b/apps/tangle-dapp/src/types/astarDappStaking.ts deleted file mode 100644 index afe7b1553f..0000000000 --- a/apps/tangle-dapp/src/types/astarDappStaking.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { - bool, - BTreeMap, - Compact, - Enum, - Option, - Struct, - u8, - u16, - u32, - u128, - Vec, -} from '@polkadot/types'; -import { AccountId32, Permill } from '@polkadot/types/interfaces'; -import { Codec } from '@polkadot/types/types'; - -interface PalletDappStakingV3PeriodType extends Enum { - readonly isVoting: boolean; - readonly isBuildAndEarn: boolean; - readonly type: 'Voting' | 'BuildAndEarn'; -} - -interface PalletDappStakingV3TierLabel extends Enum {} - -interface PalletDappStakingV3PeriodInfo extends Struct { - readonly number: Compact; - readonly subperiod: PalletDappStakingV3PeriodType; - readonly nextSubperiodStartEra: Compact; -} - -interface PalletDappStakingV3UnlockingChunk extends Struct { - readonly amount: Compact; - readonly unlockBlock: Compact; -} - -export interface PalletDappStakingV3StakeAmount extends Struct { - readonly voting: Compact; - readonly buildAndEarn: Compact; - readonly era: Compact; - readonly period: Compact; -} - -export interface PalletDappStakingV3ProtocolState extends Struct { - readonly era: Compact; - readonly nextEraStart: Compact; - readonly periodInfo: PalletDappStakingV3PeriodInfo; - readonly maintenance: bool; -} - -export interface PalletDappStakingV3DAppInfo extends Struct { - readonly owner: AccountId32; - readonly id: Compact; - readonly rewardBeneficiary: Option; -} - -export interface SmartContractAddress extends Struct { - isEvm: boolean; - asEvm?: Codec; - isWasm: boolean; - asWasm?: Codec; -} - -export interface PalletDappStakingV3AccountLedger extends Struct { - readonly locked: Compact; - readonly unlocking: Vec; - readonly staked: PalletDappStakingV3StakeAmount; - readonly stakedFuture: Option; - readonly contractStakeCount: Compact; -} - -export interface PalletDappStakingV3SingularStakingInfo extends Struct { - readonly staked: PalletDappStakingV3StakeAmount; - readonly loyalStaker: bool; -} - -export interface PalletDappStakingV3PeriodEndInfo extends Struct { - readonly bonusRewardPool: Compact; - readonly totalVpStake: Compact; - readonly finalEra: Compact; -} - -export interface PalletDappStakingV3EraRewardSpan extends Struct { - readonly span: Vec; - readonly firstEra: Compact; - readonly lastEra: Compact; -} - -interface PalletDappStakingV3EraReward extends Struct { - readonly stakerRewardPool: Compact; - readonly staked: Compact; - readonly dappRewardPool: Compact; -} - -export interface PalletDappStakingV3DAppTierRewards extends Struct { - readonly dapps: BTreeMap, Compact>; - readonly rewards: Vec; - readonly period: Compact; - readonly rankRewards: Vec; -} - -export interface PalletDappStakingV3EraInfo extends Struct { - readonly totalLocked: Compact; - readonly unlocking: Compact; - readonly currentStakeAmount: PalletDappStakingV3StakeAmount; - readonly nextStakeAmount: PalletDappStakingV3StakeAmount; -} - -export interface PalletDappStakingV3ContractStakeAmount extends Struct { - readonly staked: PalletDappStakingV3StakeAmount; - readonly stakedFuture: Option; - readonly tierLabel: Option; -} - -export interface PalletDappStakingV3TiersConfiguration extends Struct { - readonly numberOfSlots: Compact; - readonly slotsPerTier: Vec; - readonly rewardPortion: Vec; - readonly tierThresholds: Vec; -} - -interface PalletDappStakingV3TierThreshold extends Enum { - readonly isFixedTvlAmount: boolean; - readonly asFixedTvlAmount: { - readonly amount: u128; - } & Struct; - readonly isDynamicTvlAmount: boolean; - readonly asDynamicTvlAmount: { - readonly amount: u128; - readonly minimumAmount: u128; - } & Struct; - readonly type: 'FixedTvlAmount' | 'DynamicTvlAmount'; -} diff --git a/apps/tangle-dapp/src/types/index.ts b/apps/tangle-dapp/src/types/index.ts index 957bfce97e..8317f423ae 100644 --- a/apps/tangle-dapp/src/types/index.ts +++ b/apps/tangle-dapp/src/types/index.ts @@ -1,15 +1,8 @@ -import type { - SpStakingExposurePage, - SpStakingPagedExposureMetadata, -} from '@polkadot/types/lookup'; -import type { BN } from '@polkadot/util'; - export enum PagePath { DASHBOARD = '/', NOMINATION = '/nomination', NOMINATION_VALIDATOR = '/nomination/:validatorAddress', - CLAIM_AIRDROP = '/claim', - CLAIM_AIRDROP_SUCCESS = '/claim/success', + CLAIM = '/claim', CLAIM_MIGRATION = '/claim/migration', BRIDGE = '/bridge', BLUEPRINTS = '/blueprints', @@ -168,7 +161,7 @@ export enum StakingProfileType { SHARED = 'Shared', } -export type DistributionDataType = Record; +export type DistributionDataType = Record; /** * There are phase 1 jobs in Substrate @@ -179,9 +172,9 @@ export type Service = { participants: string[]; threshold?: number; jobsCount?: number; - earnings?: BN; - expirationBlock: BN; - ttlBlock: BN; + earnings?: bigint; + expirationBlock: bigint; + ttlBlock: bigint; permittedCaller?: string; }; @@ -217,8 +210,8 @@ export enum NetworkFeature { export type ExposureMap = Record< string, { - exposure: SpStakingExposurePage; - exposureMeta: SpStakingPagedExposureMetadata; + exposure: unknown; + exposureMeta: unknown; } >; diff --git a/apps/tangle-dapp/src/utils/calculateCommission.ts b/apps/tangle-dapp/src/utils/calculateCommission.ts deleted file mode 100644 index 71d140578e..0000000000 --- a/apps/tangle-dapp/src/utils/calculateCommission.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { BN } from '@polkadot/util'; -import { Decimal } from 'decimal.js'; - -const PERBILL_FACTOR = 1_000_000_000; - -/** - * @returns Commission in fractional form (0-1 decimal - * representing a percentage). - */ -const calculateCommission = (commissionPerbill: BN): number => { - if (commissionPerbill.isZero()) { - return 0; - } - - return new Decimal(commissionPerbill.toString()) - .div(PERBILL_FACTOR) - .toNumber(); -}; - -export default calculateCommission; diff --git a/apps/tangle-dapp/src/utils/calculateInflation.ts b/apps/tangle-dapp/src/utils/calculateInflation.ts deleted file mode 100644 index b7cab0ecc7..0000000000 --- a/apps/tangle-dapp/src/utils/calculateInflation.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { ApiPromise } from '@polkadot/api'; -import { getInflationParams } from '@polkadot/apps-config'; -import { BN, BN_MILLION } from '@polkadot/util'; - -// Source - https://github.com/polkadot-js/apps/blob/80759592b9f01996e67175e5dd4bdd89b58322ad/packages/react-hooks/src/useInflation.ts#L19C50-L19C50 -export const calculateInflation = ( - api: ApiPromise, - totalStaked: BN, - totalIssuance: BN, - numAuctions: BN, -) => { - const { - auctionAdjust, - auctionMax, - falloff, - maxInflation, - minInflation, - stakeTarget, - } = getInflationParams(api); - - const stakedFraction = - totalStaked.isZero() || totalIssuance.isZero() - ? 0 - : totalStaked.mul(BN_MILLION).div(totalIssuance).toNumber() / - BN_MILLION.toNumber(); - - // Ideal is less based on the actual auctions, see - // https://github.com/paritytech/polkadot/blob/816cb64ea16102c6c79f6be2a917d832d98df757/runtime/kusama/src/lib.rs#L531 - const idealStake = - stakeTarget - Math.min(auctionMax, numAuctions.toNumber()) * auctionAdjust; - - const idealInterest = maxInflation / idealStake; - - // inflation calculations, see - // https://github.com/paritytech/substrate/blob/0ba251c9388452c879bfcca425ada66f1f9bc802/frame/staking/reward-fn/src/lib.rs#L28-L54 - const inflation = - 100 * - (minInflation + - (stakedFraction <= idealStake - ? stakedFraction * (idealInterest - minInflation / idealStake) - : (idealInterest * idealStake - minInflation) * - Math.pow(2, (idealStake - stakedFraction) / falloff))); - - return { - idealInterest, - idealStake, - inflation, - stakedFraction, - stakedReturn: stakedFraction ? inflation / stakedFraction : 0, - }; -}; diff --git a/apps/tangle-dapp/src/utils/claims/toAsciiHex.ts b/apps/tangle-dapp/src/utils/claims/toAsciiHex.ts deleted file mode 100644 index fba6f34af4..0000000000 --- a/apps/tangle-dapp/src/utils/claims/toAsciiHex.ts +++ /dev/null @@ -1,17 +0,0 @@ -export default function toAsciiHex(str: Uint8Array): Uint8Array { - const arr: number[] = []; - - const pushNibble = (c: number) => - arr.push( - c < 10 - ? c + 48 // 0 - : 97 - 10 + c, // a - ); - - for (let i = 0, strLen = str.length; i < strLen; i++) { - pushNibble(str[i] / 16); - pushNibble(str[i] % 16); - } - - return new Uint8Array(arr); -} diff --git a/apps/tangle-dapp/src/utils/compareSubstrateAddresses.ts b/apps/tangle-dapp/src/utils/compareSubstrateAddresses.ts deleted file mode 100644 index 7adcb6296a..0000000000 --- a/apps/tangle-dapp/src/utils/compareSubstrateAddresses.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { decodeAddress } from '@polkadot/util-crypto'; - -/** - * Compare two Substrate addresses to see if they are the same. - * - * This is useful when comparing addresses from different sources - * that may have different SS58 formats, which will cause a display - * mismatch, but the underlying account is the same. - */ -const compareSubstrateAddresses = (a: string, b: string): boolean => { - const publicKeyA = decodeAddress(a); - const publicKeyB = decodeAddress(b); - - // Compare the public keys as strings. This will bypass situations - // where the SS58 format is different, but the account is the same. - return publicKeyA.toString() === publicKeyB.toString(); -}; - -export default compareSubstrateAddresses; diff --git a/apps/tangle-dapp/src/utils/formatTangleBalance.ts b/apps/tangle-dapp/src/utils/formatTangleBalance.ts index b822e7d61f..ed984c44d8 100644 --- a/apps/tangle-dapp/src/utils/formatTangleBalance.ts +++ b/apps/tangle-dapp/src/utils/formatTangleBalance.ts @@ -1,18 +1,14 @@ -import { BN } from '@polkadot/util'; -import { ToBn } from '@polkadot/util/types'; import { TANGLE_TOKEN_DECIMALS } from '@tangle-network/dapp-config/constants/tangle'; import { TangleTokenSymbol } from '@tangle-network/tangle-shared-ui/types'; -import { - AmountFormatStyle, - formatDisplayAmount, -} from '@tangle-network/ui-components'; +import { AmountFormatStyle } from '@tangle-network/ui-components'; +import { formatTokenAmount } from './formatTokenAmount'; const formatTangleBalance = ( - balance: BN | bigint | ToBn, + balance: bigint, tokenSymbol?: TangleTokenSymbol, ): string => { - const formattedAmount = formatDisplayAmount( - new BN(balance.toString()), + const formattedAmount = formatTokenAmount( + balance, TANGLE_TOKEN_DECIMALS, AmountFormatStyle.SHORT, ); diff --git a/apps/tangle-dapp/src/utils/formatTokenAmount.ts b/apps/tangle-dapp/src/utils/formatTokenAmount.ts new file mode 100644 index 0000000000..02bd88a7a1 --- /dev/null +++ b/apps/tangle-dapp/src/utils/formatTokenAmount.ts @@ -0,0 +1,70 @@ +import { AmountFormatStyle } from '@tangle-network/ui-components'; +import { formatUnits } from 'viem'; + +type FormatTokenAmountOptions = { + fractionMaxLength?: number; +}; + +const addCommas = (value: string): string => { + const sign = value.startsWith('-') ? '-' : ''; + const unsigned = sign ? value.slice(1) : value; + const [integerPart, fractionPart] = unsigned.split('.'); + const withCommas = integerPart.replace(/\B(?=(\d{3})+(?!\d))/g, ','); + return fractionPart === undefined + ? `${sign}${withCommas}` + : `${sign}${withCommas}.${fractionPart}`; +}; + +const trimFraction = ( + fraction: string, + maxLength: number | undefined, +): string => { + const trimmedToMax = + maxLength === undefined ? fraction : fraction.slice(0, maxLength); + return trimmedToMax.replace(/0+$/, ''); +}; + +export const formatTokenAmount = ( + amount: bigint, + decimals: number, + style: AmountFormatStyle, + options?: FormatTokenAmountOptions, +): string => { + if (style === AmountFormatStyle.SI) { + const asUnits = formatUnits(amount, decimals); + const asNumber = Number(asUnits); + if (Number.isFinite(asNumber)) { + return new Intl.NumberFormat('en-US', { + notation: 'compact', + compactDisplay: 'short', + maximumFractionDigits: 2, + }).format(asNumber); + } + return addCommas(asUnits); + } + + const value = formatUnits(amount, decimals); + const [integerPart, fractionPart = ''] = value.split('.'); + + const maxFraction = + style === AmountFormatStyle.SHORT + ? (options?.fractionMaxLength ?? 4) + : options?.fractionMaxLength; + const normalizedFraction = trimFraction(fractionPart, maxFraction); + + if ( + style === AmountFormatStyle.SHORT && + amount !== BigInt(0) && + integerPart === '0' && + normalizedFraction.length === 0 && + (options?.fractionMaxLength ?? 4) > 0 + ) { + const nearZero = `${'0'.repeat((options?.fractionMaxLength ?? 4) - 1)}1`; + return `<0.${nearZero}`; + } + + const integerWithCommas = addCommas(integerPart); + return normalizedFraction.length > 0 + ? `${integerWithCommas}.${normalizedFraction}` + : integerWithCommas; +}; diff --git a/apps/tangle-dapp/src/utils/getBlockDate.ts b/apps/tangle-dapp/src/utils/getBlockDate.ts deleted file mode 100644 index cc0ca1b203..0000000000 --- a/apps/tangle-dapp/src/utils/getBlockDate.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { BN } from '@polkadot/util'; - -/** - * Calculates the estimated date of a block based on the expected block time, - * the current block number, and the target block number. - * - * Note: RPC can only store a fixed number of recent blocks at a time, - * so the API cannot query blocks that are far in the past. Therefore, - * we need to get the current block number and then do the calculation here - * - * @param babeExpectedBlockTime The expected block time (in milliseconds) - * @param currentBlockNumber The current block number. - * @param blockNumber The target block number. - * @returns The estimated date of the target block, or null if any of the input values are null. - */ -export default function getBlockDate( - babeExpectedBlockTime: BN | null, - currentBlockNumber: BN | null, - blockNumber: BN | null, -): Date | null { - if ( - babeExpectedBlockTime === null || - currentBlockNumber === null || - blockNumber === null - ) { - return null; - } - - const isPast = currentBlockNumber.gt(blockNumber); - - const difference = isPast - ? currentBlockNumber.sub(blockNumber) - : blockNumber.sub(currentBlockNumber); - - const timeRemainingInMs = babeExpectedBlockTime.mul(difference).toNumber(); - - return new Date( - Date.now() + (isPast ? -timeRemainingInMs : timeRemainingInMs), - ); -} diff --git a/apps/tangle-dapp/src/utils/getExposureMap.ts b/apps/tangle-dapp/src/utils/getExposureMap.ts deleted file mode 100644 index 749e840a95..0000000000 --- a/apps/tangle-dapp/src/utils/getExposureMap.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { ApiRx } from '@polkadot/api'; -import type { - DeriveStakingElected, - DeriveStakingWaiting, -} from '@polkadot/api-derive/types'; -import { - SpStakingExposurePage, - SpStakingPagedExposureMetadata, -} from '@polkadot/types/lookup'; - -import { ExposureMap } from '../types'; - -export function getExposureMap( - api: ApiRx, - derive: DeriveStakingElected | DeriveStakingWaiting, -): ExposureMap { - const emptyExposure = api.createType( - 'SpStakingExposurePage', - ); - - const emptyExposureMeta = api.createType( - 'SpStakingPagedExposureMetadata', - ); - - return derive.info.reduce( - (exposureMap, { accountId, exposureMeta, exposurePaged }) => { - const exp = exposurePaged.isSome && exposurePaged.unwrap(); - const expMeta = exposureMeta.isSome && exposureMeta.unwrap(); - - exposureMap[accountId.toString()] = { - exposure: exp || emptyExposure, - exposureMeta: expMeta || emptyExposureMeta, - }; - - return exposureMap; - }, - {} as Record< - string, - { - exposure: SpStakingExposurePage; - exposureMeta: SpStakingPagedExposureMetadata; - } - >, - ); -} diff --git a/apps/tangle-dapp/src/utils/getModuleConstant.ts b/apps/tangle-dapp/src/utils/getModuleConstant.ts deleted file mode 100644 index 1312da121c..0000000000 --- a/apps/tangle-dapp/src/utils/getModuleConstant.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { ApiBase } from '@polkadot/api/base'; -import type { ApiTypes, AugmentedConsts } from '@polkadot/api/types'; -import type { MapKnownKeys } from '@tangle-network/dapp-types/utils/types'; -import has from 'lodash/has'; - -export default function getModuleConstant< - TApiType extends ApiTypes, - TModule extends keyof AugmentedConsts, - TConst extends keyof MapKnownKeys[TModule]>, - R extends ApiBase['consts'][TModule][TConst], - TDefault = null, ->( - api: ApiBase, - module: TModule, - constant: TConst, - defaultValue: TDefault = null as TDefault, -): R | TDefault { - return has(api.consts, [module, constant]) - ? (api.consts[module][constant] as R | TDefault) - : (defaultValue as R | TDefault); -} diff --git a/apps/tangle-dapp/src/utils/getStatement.ts b/apps/tangle-dapp/src/utils/getStatement.ts deleted file mode 100644 index deb348e90f..0000000000 --- a/apps/tangle-dapp/src/utils/getStatement.ts +++ /dev/null @@ -1,66 +0,0 @@ -export interface Statement { - sentence: string; - url: string; -} - -function getPolkadot(isRegularStatement?: boolean | null): Statement | null { - if (isRegularStatement === null || isRegularStatement === undefined) { - return null; - } - - const url = isRegularStatement - ? 'https://statement.polkadot.network/regular.html' - : 'https://statement.polkadot.network/saft.html'; - const hash = isRegularStatement - ? 'Qmc1XYqT6S39WNp2UeiRUrZichUWUPpGEThDE6dAb3f6Ny' - : 'QmXEkMahfhHJPzT3RjkXiZVFi77ZeVeuxtAjhojGRNYckz'; - - return { - sentence: `I hereby agree to the terms of the statement whose SHA-256 multihash is ${hash}. (This may be found at the URL: ${url})`, - url, - }; -} - -function getTangle(isRegularStatement?: boolean | null): Statement | null { - if (isRegularStatement === null || isRegularStatement === undefined) { - return null; - } - - const url = isRegularStatement - ? 'https://statement.tangle.tools/airdrop-statement.html' - : 'https://statement.tangle.tools/safe-claim-statement'; - - const hash = isRegularStatement - ? '5627de05cfe235cd4ffa0d6375c8a5278b89cc9b9e75622fa2039f4d1b43dadf' - : '7eae145b00c1912c8b01674df5df4ad9abcf6d18ea3f33d27eb6897a762f4273'; - - return { - sentence: `I hereby agree to the terms of the statement whose sha2256sum is ${hash}. (This may be found at the URL: ${url})`, - url, - }; -} - -function getStatement( - network: string, - isRegularStatement?: boolean | null, -): Statement | null { - const normalizedNetwork = network.trim().toLowerCase(); - - if ( - normalizedNetwork === 'polkadot' || - normalizedNetwork === 'polkadot cc1' - ) { - return getPolkadot(isRegularStatement); - } - - if ( - normalizedNetwork.startsWith('tangle') || - normalizedNetwork.includes('local') - ) { - return getTangle(isRegularStatement); - } - - return null; -} - -export default getStatement; diff --git a/apps/tangle-dapp/src/utils/getSubstrateLockId.ts b/apps/tangle-dapp/src/utils/getSubstrateLockId.ts deleted file mode 100644 index 589f6e5c68..0000000000 --- a/apps/tangle-dapp/src/utils/getSubstrateLockId.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { U8aFixed } from '@polkadot/types'; -import { u8aToString } from '@polkadot/util'; - -import { SubstrateLockId } from '../constants'; - -/** - * Substrate lock ids are in the form of a `U8aFixed`, which is a 32-byte - * fixed-length array. - * - * This function converts the lock id to a more - * human-readable format, which is also typed under a TypeScript enum - * to improve type safety. - */ -function getSubstrateLockId(rawLockId: U8aFixed): SubstrateLockId { - const lockIdString = u8aToString(rawLockId); - - return Object.values(SubstrateLockId).includes( - lockIdString as SubstrateLockId, - ) - ? (lockIdString as SubstrateLockId) - : SubstrateLockId.OTHER; -} - -export default getSubstrateLockId; diff --git a/apps/tangle-dapp/src/utils/hasQuery.ts b/apps/tangle-dapp/src/utils/hasQuery.ts deleted file mode 100644 index 6d9db09b54..0000000000 --- a/apps/tangle-dapp/src/utils/hasQuery.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { ApiBase } from '@polkadot/api/base'; -import type { ApiTypes, AugmentedQueries } from '@polkadot/api/types'; -import type { MapKnownKeys } from '@tangle-network/dapp-types/utils/types'; -import has from 'lodash/has'; - -function hasQuery< - TApiTypes extends ApiTypes, - Module extends keyof AugmentedQueries, - Method extends keyof MapKnownKeys[Module]>, ->( - api: ApiBase, - module: Module, - methods?: Method | Method[], -): boolean { - if (Array.isArray(methods)) { - return methods.every((method) => has(api.query, [module, method])); - } else if (typeof methods === 'string') { - return has(api.query, [module, methods]); - } - - return has(api.query, module); -} - -export default hasQuery; diff --git a/apps/tangle-dapp/src/utils/index.ts b/apps/tangle-dapp/src/utils/index.ts index 48a245347c..ab232b7209 100644 --- a/apps/tangle-dapp/src/utils/index.ts +++ b/apps/tangle-dapp/src/utils/index.ts @@ -1,3 +1,2 @@ -export * from './calculateInflation'; export { default as getChartDataAreaColorByServiceType } from './staking/getChartDataAreaColorByServiceType'; export { default as getChipColorOfServiceType } from './staking/getChipColorOfServiceType'; diff --git a/apps/tangle-dapp/src/utils/perbillToFractional.ts b/apps/tangle-dapp/src/utils/perbillToFractional.ts deleted file mode 100644 index 75459237d4..0000000000 --- a/apps/tangle-dapp/src/utils/perbillToFractional.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Perbill } from '@polkadot/types/interfaces'; - -const perbillToFractional = (perbill: Perbill): number => { - return perbill.toNumber() / 1_000_000_000; -}; - -export default perbillToFractional; diff --git a/apps/tangle-dapp/src/utils/permillToPercentage.ts b/apps/tangle-dapp/src/utils/permillToPercentage.ts deleted file mode 100644 index 5700ace974..0000000000 --- a/apps/tangle-dapp/src/utils/permillToPercentage.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Permill } from '@polkadot/types/interfaces'; - -const permillToPercentage = (permill: Permill): number => { - return permill.toNumber() / 1_000_000; -}; - -export default permillToPercentage; diff --git a/apps/tangle-dapp/src/utils/polkadot/balance.ts b/apps/tangle-dapp/src/utils/polkadot/balance.ts deleted file mode 100644 index 4ad334317f..0000000000 --- a/apps/tangle-dapp/src/utils/polkadot/balance.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { PalletBalancesAccountData } from '@polkadot/types/lookup'; -import { BN, BN_ZERO, bnMax } from '@polkadot/util'; - -export const calculateTransferableBalance = ( - accInfo: PalletBalancesAccountData, -): BN => { - const maxFrozen = bnMax( - accInfo.frozen ?? BN_ZERO, - 'miscFrozen' in accInfo && BN.isBN(accInfo.miscFrozen) - ? accInfo.miscFrozen - : BN_ZERO, - 'feeFrozen' in accInfo && BN.isBN(accInfo.feeFrozen) - ? accInfo.feeFrozen - : BN_ZERO, - ); - - const transferable = BN.max( - accInfo.free.sub(maxFrozen).sub(accInfo.reserved ?? BN_ZERO), - BN_ZERO, - ); - - return transferable; -}; diff --git a/apps/tangle-dapp/src/utils/polkadot/index.ts b/apps/tangle-dapp/src/utils/polkadot/index.ts deleted file mode 100644 index a69dad2044..0000000000 --- a/apps/tangle-dapp/src/utils/polkadot/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './balance'; -export * from './nominators'; diff --git a/apps/tangle-dapp/src/utils/polkadot/nominators.ts b/apps/tangle-dapp/src/utils/polkadot/nominators.ts deleted file mode 100644 index 1471dd1a1e..0000000000 --- a/apps/tangle-dapp/src/utils/polkadot/nominators.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { getApiPromise } from '@tangle-network/tangle-shared-ui/utils/polkadot/api'; -import { getAccountInfo } from '@tangle-network/tangle-shared-ui/utils/polkadot/identity'; - -export const getValidatorIdentityName = async ( - rpcEndpoints: string[], - validatorAddress: string, -): Promise => { - const validatorAccountInfo = await getAccountInfo( - rpcEndpoints, - validatorAddress, - ); - - if (validatorAccountInfo?.name) { - return validatorAccountInfo.name; - } - - // Default the name to be the validator's address if the - // validator has no identity set. - return validatorAddress; -}; - -export const getValidatorCommission = async ( - rpcEndpoint: string, - validatorAddress: string, -): Promise => { - const api = await getApiPromise(rpcEndpoint); - const validatorPrefs = await api.query.staking.validators(validatorAddress); - const commissionRate = validatorPrefs.commission.unwrap().toNumber(); - const commission = commissionRate / 10_000_000; - - return commission.toString(); -}; diff --git a/apps/tangle-dapp/src/utils/scaleAmountByPercentage.ts b/apps/tangle-dapp/src/utils/scaleAmountByPercentage.ts deleted file mode 100644 index 0a18cc1f0e..0000000000 --- a/apps/tangle-dapp/src/utils/scaleAmountByPercentage.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { BN } from '@polkadot/util'; - -const scaleAmountByPercentage = (amount: BN, percentage: number): BN => { - // Scale factor for 4 decimal places (0.xxxx). - const scale = new BN(10_000); - - // Scale the percentage to an integer. - const scaledPercentage = new BN(Math.round(percentage * scale.toNumber())); - - // Multiply the amount by the scaled percentage and then divide by the scale. - return amount.mul(scaledPercentage).div(scale); -}; - -export default scaleAmountByPercentage; diff --git a/apps/tangle-dapp/src/utils/sortByBn.ts b/apps/tangle-dapp/src/utils/sortByBn.ts deleted file mode 100644 index 8aab600c0e..0000000000 --- a/apps/tangle-dapp/src/utils/sortByBn.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { BN } from '@polkadot/util'; -import { SortingFn } from '@tanstack/react-table'; - -const sortByBn = ( - selector: (row: T) => BN | null | undefined, -): SortingFn => { - return (rowA, rowB) => { - const bnA = selector(rowA.original); - const bnB = selector(rowB.original); - - // Prioritize B if A is undefined or null. - if (bnA === undefined || bnA === null) { - return 1; - } - // Prioritize A if B is undefined or null. - else if (bnB === undefined || bnB === null) { - return -1; - } - - return bnA.cmp(bnB); - }; -}; - -export default sortByBn; diff --git a/converge-progress.md b/converge-progress.md new file mode 100644 index 0000000000..8de56cd392 --- /dev/null +++ b/converge-progress.md @@ -0,0 +1,40 @@ +# Converge Progress + +## Target +- **Branch**: release/merge-develop-2026-03-20 +- **PR**: #3150 +- **Status**: IN_PROGRESS + +## Current State +- **Last commit**: yarn.lock fix (post-merge regeneration) +- **Last updated**: 2026-03-20T18:00:00Z +- **Round**: 1 + +## Workflow Status +| Workflow | Job | Status | Since Round | +|----------|-----|--------|-------------| +| Build | Install deps | FAILURE (yarn.lock) | 0 | +| Test | Install deps | FAILURE (yarn.lock) | 0 | +| Lint/Format | Install deps | FAILURE (yarn.lock) | 0 | +| PR Title | Install deps | FAILURE (yarn.lock) | 0 | +| auto-review | Auto review PR | FAILURE (bot, non-blocking) | 0 | +| Netlify checks | Header/Pages/Redirect | FAILURE (deploy preview, non-blocking) | 0 | +| CodeQL | - | SUCCESS | 0 | +| links | - | SUCCESS | 0 | + +## Round History +| Round | Commit | Fixed | Remaining | Timestamp | +|-------|--------|-------|-----------|-----------| +| 1 | yarn.lock regen | yarn install failure (stale lockfile from merge) | waiting for CI | 2026-03-20 | + +## Completed Fixes +- [x] **Round 1**: Regenerated yarn.lock — merge left stale resolutions causing YN0028 post-resolution validation failure + +## Remaining Failures +- [ ] Waiting for CI re-run to confirm fix + +## Blocked / Needs Human +- Netlify deploy preview checks (Header rules, Pages changed) show FAILURE but are NEUTRAL on develop PRs — likely non-blocking for master merge + +## Pre-existing on Base Branch +- auto-review bot always fails (it's a review bot, not a CI check) diff --git a/docs/app-state-audit-2026-03-05.md b/docs/app-state-audit-2026-03-05.md new file mode 100644 index 0000000000..ddce06562b --- /dev/null +++ b/docs/app-state-audit-2026-03-05.md @@ -0,0 +1,138 @@ +# App State Audit (2026-03-05) + +## Scope + +- Apps reviewed: `tangle-dapp`, `tangle-cloud`, `leaderboard` +- Source-of-truth cross-check: `~/code/tnt-core/indexer/schema.graphql` +- Goals: + - current runtime/build/test state + - indexer connectivity status + - design/UX/system-level gaps + +## Executive Summary + +- `tangle-dapp`: healthy build/test state; indexer wiring uses shared Envio utilities with on-chain fallback. +- `tangle-cloud`: healthy build/test state; indexer wiring uses shared Envio utilities and tx refresh patterns. +- `leaderboard`: connected to `tnt-core` Envio schema and functional, but has correctness and scalability gaps that should be fixed before calling it production-grade. + +## Follow-Up Status (Updated In `drew/process-and-app-audit`) + +- Addressed: + - leaderboard copy/meta removed legacy terminology + - leaderboard endpoint/query execution now uses shared Envio utility instead of ad-hoc fetch endpoint logic + - leaderboard total count now comes from aggregate query with fallback pagination count + - role-account query now runs only for selected roles and deduplicates developer/customer ids via `distinct_on` +- Still open: + - leaderboard currently has no dedicated automated tests + - role-account filtering can still be expensive at very large scale (reduced but not eliminated) + - sync indicator still relies on `chain_metadata` availability in Hasura/Envio runtime + +## Verification Evidence + +- `yarn nx test tangle-dapp` -> pass (`31` tests) +- `yarn nx test tangle-cloud` -> pass (`49` tests) +- `yarn nx test leaderboard` -> pass but no tests found +- `yarn nx build tangle-dapp` -> pass +- `yarn nx build tangle-cloud` -> pass +- `yarn nx build leaderboard` -> pass + +## Indexer Connectivity Validation + +### `leaderboard` is connected to `tnt-core` indexer entities + +- Leaderboard queries `PointsAccount` + `snapshots`: [`apps/leaderboard/src/features/leaderboard/queries/leaderboardQuery.ts`](../apps/leaderboard/src/features/leaderboard/queries/leaderboardQuery.ts) +- Role filters query `Operator`, `Delegator`, `Blueprint`, `JobCall`: [`apps/leaderboard/src/features/leaderboard/queries/leaderboardQuery.ts`](../apps/leaderboard/src/features/leaderboard/queries/leaderboardQuery.ts) +- These entities/fields exist in `tnt-core` schema: + - `PointsAccount` / `PointsSnapshot`: `tnt-core/indexer/schema.graphql` + - `Operator`, `Blueprint`, `Service`, `JobCall`: `tnt-core/indexer/schema.graphql` + - `Delegator`: `tnt-core/indexer/schema.graphql` + +### Caveat + +- `chain_metadata` query in leaderboard sync chip is Hasura/Envio metadata-table dependent and not part of the app-level GraphQL schema contract: + - [`apps/leaderboard/src/features/indexingProgress/queries/indexingProgressQuery.ts`](../apps/leaderboard/src/features/indexingProgress/queries/indexingProgressQuery.ts) + +## Findings (Ordered by Severity) + +### High + +1. Pagination count is incorrect in leaderboard + +- `totalCount` is set to the current page filtered length, not global count: + - [`apps/leaderboard/src/features/leaderboard/queries/leaderboardQuery.ts`](../apps/leaderboard/src/features/leaderboard/queries/leaderboardQuery.ts) +- Impact: pagination controls and UX can be incorrect on multi-page datasets. +- Status: addressed in `drew/process-and-app-audit` (aggregate count + fallback). + +2. Role filtering query does unbounded full-table scans + +- Queries all `Operator`, filtered `Delegator`, all `Blueprint`, all `JobCall` with no paging/aggregation: + - [`apps/leaderboard/src/features/leaderboard/queries/leaderboardQuery.ts`](../apps/leaderboard/src/features/leaderboard/queries/leaderboardQuery.ts) +- Impact: slow queries and degraded UX as data grows. +- Status: partially addressed in `drew/process-and-app-audit` (query only selected roles + `distinct_on` for developer/customer sources). + +### Medium + +3. Indexing “target” is synthetic (`latest + 1`) + +- [`apps/leaderboard/src/features/indexingProgress/queries/indexingProgressQuery.ts`](../apps/leaderboard/src/features/indexingProgress/queries/indexingProgressQuery.ts) +- Impact: “Synced” can be noisy/misleading. +- Status: addressed in `drew/process-and-app-audit` (indicator now reports indexed block/activity, not synthetic sync). + +4. Leaderboard has zero automated tests + +- No `*.test.*`/`*.spec.*` files under `apps/leaderboard/src`. +- Impact: regressions are likely to slip through. + +5. Team-account filter placeholder is not production-ready + +- Placeholder zero address only: + - [`apps/leaderboard/src/features/leaderboard/queries/leaderboardQuery.ts`](../apps/leaderboard/src/features/leaderboard/queries/leaderboardQuery.ts) +- Impact: internal accounts may appear in rankings. +- Status: partially addressed in `drew/process-and-app-audit` (supports env-configured exclusion list, still needs production values). + +### Low + +6. Product copy drift in leaderboard + +- Hero copy previously used legacy terms: + - [`apps/leaderboard/src/pages/index.tsx`](../apps/leaderboard/src/pages/index.tsx) +- Impact: terminology drift vs current EVM/operator-layer framing. +- Status: addressed in `drew/process-and-app-audit`. + +7. Bundle size is high across apps + +- Build outputs include large chunks (notably `tangle-dapp` and `tangle-cloud`). +- Impact: page-load/perf cost on slower clients. + +## Design/System Audit Snapshot + +- Shared visual system is consistent across apps (common UI provider/layout patterns). +- `leaderboard` is structurally minimal (single index route): + - [`apps/leaderboard/src/app/app.tsx`](../apps/leaderboard/src/app/app.tsx) +- Immediate design quality opportunity is not style mismatch, but trust/clarity: + - fix pagination truth + - fix sync indicator semantics + - align copy and data filters to production policy + +## Recommended Remediation Checklist + +### P0 (Do Now) + +- [ ] Add aggregate count query for leaderboard pagination (or disable fake total pagination). +- [ ] Replace unbounded role-account query with server-side role-filtered leaderboard query strategy. +- [ ] Make sync indicator explicit (`indexed block`, `network head`, `lag`) or mark as “index activity” instead of “synced”. + +### P1 (Next) + +- [ ] Add leaderboard tests: + - pagination/total-count behavior + - role filter correctness + - endpoint fallback behavior + - sync indicator rendering states +- [ ] Move leaderboard endpoint resolution to shared `executeEnvioGraphQL` utility to avoid drift. +- [ ] Configure real team account exclusion list via env/config instead of hardcoded placeholder. + +### P2 (Cleanup) + +- [ ] Update leaderboard hero copy to current protocol terminology. +- [ ] Performance pass: chunking strategy + heavy icon/network asset lazy loading. diff --git a/docs/cloud-qa/LOCAL_BLUEPRINT_DEPLOY.md b/docs/cloud-qa/LOCAL_BLUEPRINT_DEPLOY.md index e71602e3e3..3a8102d6e0 100644 --- a/docs/cloud-qa/LOCAL_BLUEPRINT_DEPLOY.md +++ b/docs/cloud-qa/LOCAL_BLUEPRINT_DEPLOY.md @@ -285,17 +285,17 @@ echo "Using broadcast file: $BROADCAST_FILE" # Extract proxy addresses (deployed in order: Staking, Tangle) PROXIES=$(jq -r '[.transactions[] | select(.contractName == "ERC1967Proxy") | .contractAddress] | .[]' "$BROADCAST_FILE") -RESTAKING=$(echo "$PROXIES" | sed -n '1p') +STAKING=$(echo "$PROXIES" | sed -n '1p') TANGLE=$(echo "$PROXIES" | sed -n '2p') STATUS_REGISTRY=$(jq -r '.transactions[] | select(.contractName == "OperatorStatusRegistry") | .contractAddress' "$BROADCAST_FILE" | head -1) echo "Contract addresses:" echo " TANGLE: $TANGLE" -echo " RESTAKING: $RESTAKING" +echo " STAKING: $STAKING" echo " STATUS_REGISTRY: $STATUS_REGISTRY" # Export for use in deployment -export TANGLE RESTAKING STATUS_REGISTRY +export TANGLE STAKING STATUS_REGISTRY ``` ### Setup Keystore @@ -324,7 +324,7 @@ WS_RPC_URL=ws://127.0.0.1:8546 KEYSTORE_PATH=./deployer-keystore BLUEPRINT_KEYSTORE_URI=./deployer-keystore TANGLE_CONTRACT=$TANGLE -RESTAKING_CONTRACT=$RESTAKING +STAKING_CONTRACT=$STAKING STATUS_REGISTRY_CONTRACT=$STATUS_REGISTRY BLUEPRINT_ID=0 SERVICE_ID=0 @@ -839,7 +839,7 @@ forge script script/v2/DeployContractsOnly.s.sol:DeployContractsOnly \ # Save addresses (deterministic with Anvil) export TANGLE=0xCf7Ed3AccA5a467e9e704C703E8D87F634fB0Fc9 -export RESTAKING=0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512 +export STAKING=0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512 export STATUS_REGISTRY=0x99bbA657f2BbC93c02D617f8bA121cB8Fc104Acf ``` @@ -877,7 +877,7 @@ WS_RPC_URL=ws://127.0.0.1:8546 KEYSTORE_PATH=./deployer-keystore BLUEPRINT_KEYSTORE_URI=./deployer-keystore TANGLE_CONTRACT=$TANGLE -RESTAKING_CONTRACT=$RESTAKING +STAKING_CONTRACT=$STAKING STATUS_REGISTRY_CONTRACT=$STATUS_REGISTRY BLUEPRINT_ID=0 SERVICE_ID=0 @@ -1103,7 +1103,7 @@ brew install jq # macOS If `cargo tangle blueprint deploy` fails: 1. Verify the HTTP server is running on port 8081 -2. Check that the contract addresses were exported correctly: `echo $TANGLE $RESTAKING $STATUS_REGISTRY` +2. Check that the contract addresses were exported correctly: `echo $TANGLE $STAKING $STATUS_REGISTRY` 3. Verify the settings.env file has correct values ### Full Manual Setup Issues diff --git a/docs/harness-engineering-checklist.md b/docs/harness-engineering-checklist.md new file mode 100644 index 0000000000..2066456824 --- /dev/null +++ b/docs/harness-engineering-checklist.md @@ -0,0 +1,51 @@ +# Harness Engineering Checklist + +Use this checklist for launch-flow-impacting work. + +## Design + +- [ ] Flow scope is mapped to `flow_id` values in `docs/launch-readiness-board.csv`. +- [ ] Acceptance criteria distinguish happy-path vs explicit blocker path. +- [ ] Critical-flow impact is identified up front. +- [ ] Flow owner and release/harness owner are assigned in the PR. + +## Implementation + +- [ ] Criteria updates are route-resilient (canonical-route recheck where needed). +- [ ] Tx checks include objective signals (tx history delta and/or explicit blocker copy). +- [ ] New env toggles are documented in `docs/wallet-flow-suite.md`. + +## Verification + +- [ ] Run suite (full or targeted): `yarn test:wallet-flows`. +- [ ] Inspect `suite/report.json` for `verified` and `agentSuccess`. +- [ ] Inspect `suite/release-matrix.md` classification counts. +- [ ] Run gate: `yarn test:wallet-flows:gate`. +- [ ] If release strictness is required, run: `yarn test:wallet-flows:gate:strict`. +- [ ] Confirm matrix artifacts exist (`json`, `csv`, `md`) under suite output. + +## Critical Flows + +- [ ] `FLOW-001` happy-path-pass +- [ ] `FLOW-002` happy-path-pass +- [ ] `FLOW-005` happy-path-pass +- [ ] `FLOW-010` happy-path-pass +- [ ] `FLOW-011` happy-path-pass +- [ ] `FLOW-013` happy-path-pass +- [ ] `FLOW-014` happy-path-pass +- [ ] `FLOW-018` happy-path-pass +- [ ] `FLOW-019` happy-path-pass + +## PR Hygiene + +- [ ] PR description includes matrix summary (happy/blocker/failed). +- [ ] Any blocker-or-partial critical flow has explicit exception, owner, and ETA. +- [ ] Evidence links are included (artifact directory or CI artifact URLs). +- [ ] If launch-impacting, PR approval includes a release-captain signoff. + +## Post-Merge + +- [ ] If semantics changed, update `CLAUDE.md` runbook section. +- [ ] If recurring flake found, add flow id to flaky rerun set in spec. +- [ ] File a follow-up for fixture/indexer reliability if blocker-pass rate is rising. +- [ ] Update weekly trend snapshot with this run's matrix totals. diff --git a/docs/harness-engineering-spec.md b/docs/harness-engineering-spec.md new file mode 100644 index 0000000000..be503b2d44 --- /dev/null +++ b/docs/harness-engineering-spec.md @@ -0,0 +1,167 @@ +# Harness Engineering Operating Spec + +Last updated: 2026-03-05 + +## Why This Exists + +This repo has strong momentum but still leaks reliability through: +- flow verification that can pass without happy-path completion +- drift between “what docs say” and “what release gates enforce” +- scattered operational knowledge across AGENTS/CLAUDE/docs/PR threads +- weak mechanical governance on release evidence quality + +This spec defines the senior-level operating model to convert harness work into predictable release outcomes. + +## Scope + +In scope: +- launch-critical dApp flows validated by the wallet flow suite +- release-go/no-go evidence used by maintainers +- repository process changes that make agent execution more reliable + +Out of scope: +- full native restaking UX (deprioritized) +- replacing manual signoff for flows that require external non-local actors + +## Source Principles + +Based on OpenAI Harness Engineering guidance: +- optimize for stable maps, not giant prompts +- enforce output quality mechanically (not by intention) +- classify evidence quality explicitly (not pass/fail only) +- continuously prune stale knowledge and keep docs compact + +Reference: +- https://openai.com/index/harness-engineering/ + +## Current Gaps In This Repo + +1. `verified` and `agentSuccess` can diverge, but were historically treated as equivalent in go/no-go conversations. +2. Launch evidence was captured, but not classified into quality tiers for release decisions. +3. Critical flows did not have hard happy-path enforcement by default. +4. PR reviews lacked a required harness evidence checklist. +5. No single script existed to fail release gate when matrix quality degraded. +6. Harness process details were spread across files without one operating contract. + +## Target Operating Model + +### 1) Two-Layer Pass Semantics + +- `verified=true`: + - criteria passed (tx delta OR explicit blocker state) +- `agentSuccess=true`: + - agent completed intended narrative without terminal tool/runtime failure + +Both are reported. Never collapse them into one metric. + +### 2) Matrix-Based Evidence + +Every run produces matrix artifacts: +- `suite/release-matrix.json` +- `suite/release-matrix.csv` +- `suite/release-matrix.md` + +Each flow is classified as: +- `happy-path-pass` +- `blocker-or-partial-pass` +- `failed` + +### 3) Critical-Flow Strictness + +Critical flows require happy-path completion (`agentSuccess=true`) even when `verified=true`. + +Default critical set: +- `FLOW-001`, `FLOW-002`, `FLOW-005`, `FLOW-010`, `FLOW-011`, `FLOW-013`, `FLOW-014`, `FLOW-018`, `FLOW-019` + +### 4) Mechanical Gate Script + +Release gate is enforced by: +- `yarn test:wallet-flows:gate` + +Script behavior: +- fails on `failed` rows above threshold +- fails on missing critical flow rows +- fails when critical flows are not `happy-path-pass` (unless explicitly overridden) + +### 5) PR Governance + +PR template requires harness evidence for launch-flow-impacting changes: +- report artifact review +- release matrix review +- gate script output +- critical flow exceptions explicitly documented + +### 6) Ownership And Escalation + +- Flow owner: feature owner who changed launch-flow behavior. +- Harness owner: engineer running/triaging suite output for release cut. +- Escalation owner: release captain when critical-flow gate fails. + +Escalation rules: +- critical flow failing: block merge to release branch until fixed or exception signed off +- blocker-or-partial trend worsening for 2 consecutive release cycles: open remediation issue with owner and ETA +- missing evidence in PR: do not approve launch-impacting changes + +### 7) CI Policy + +- Pre-merge (required): + - lint/type/build + - harness gate output for launch-flow-impacting PRs +- Nightly (required): + - full wallet flow suite + - matrix trend snapshot committed/attached as artifact +- Weekly hygiene (required): + - rerun known flaky flows + - open targeted cleanup PRs for recurring failure patterns + +### 8) Definition Of Done (Launch-Flow Changes) + +All must be true: +- code merged with tests/checks passing +- release matrix generated and attached +- `failed=0` +- all critical flows are `happy-path-pass` +- any non-critical blocker-or-partial rows have owner + ETA + issue link +- docs updated if semantics/criteria changed + +## Required Commands + +Run suite: +- `yarn test:wallet-flows` + +Run gate: +- `yarn test:wallet-flows:gate` + +Strict blocker cap: +- `yarn test:wallet-flows:gate:strict` + +## SLOs (Release Quality) + +Release candidate targets: +- `failed = 0` +- critical flows: all `happy-path-pass` +- blocker/partial flows: explicitly justified, tracked, and owner-assigned + +Escalation: +- any critical regression blocks merge to release branch +- any blocker growth trend over 2 consecutive release cycles requires remediation plan + +## Change Management + +When flow criteria are modified: +1. rerun impacted flow IDs +2. rerun known flaky set (`FLOW-007`, `FLOW-013`, `FLOW-016`) +3. update docs if semantics changed +4. include before/after matrix summary in PR + +## 30/60 Day Rollout + +Within 30 days: +1. enforce PR harness validation section for launch-impacting PRs +2. require gate output in release candidate PR descriptions +3. publish weekly matrix trend summary + +Within 60 days: +1. add nightly suite + gate CI job +2. add auto-generated matrix trend dashboard doc +3. codify recurring cleanup cadence as a standing maintenance task diff --git a/docs/wallet-flow-suite.md b/docs/wallet-flow-suite.md index 0479969382..5b06649be2 100644 --- a/docs/wallet-flow-suite.md +++ b/docs/wallet-flow-suite.md @@ -1,8 +1,9 @@ # Wallet Flow Suite (Launch Manual Sign-Off) -Last updated: 2026-03-03 +Last updated: 2026-03-04 ## Purpose + This suite provides executable browser-agent coverage for every `ready-manual-signoff` launch flow in `docs/launch-readiness-board.csv`. - Runner: `scripts/agent-browser/run-wallet-flow-suite.mjs` @@ -12,6 +13,7 @@ This suite provides executable browser-agent coverage for every `ready-manual-si The runner validates case coverage against `docs/launch-readiness-board.csv` and fails fast if any launch flow is missing. ## Prerequisites + 1. Start both apps: - `yarn nx serve tangle-dapp` (`http://localhost:4200`) - `yarn nx serve tangle-cloud` (`http://localhost:4300`) @@ -20,44 +22,86 @@ The runner validates case coverage against `docs/launch-readiness-board.csv` and 4. Provide an LLM key (`OPENAI_API_KEY` or equivalent supported by `agent-browser-driver`). Optional wallet env vars: + - `AGENT_WALLET_EXTENSION_PATHS=/abs/path/to/metamask,/abs/path/to/rabby` - `AGENT_WALLET_USER_DATA_DIR=/abs/path/to/.agent-wallet-profile` +- `AGENT_STRICT_WALLET_PREFLIGHT=false` to allow non-blocking preflight (default is strict/fail-closed) +- `AGENT_WALLET_ALLOW_HEADLESS=true` to force wallet runs in headless mode (default is headful for extension stability) +- `AGENT_REQUIRE_AGENT_SUCCESS=true` to require agent narrative success for all flows +- `AGENT_REQUIRE_AGENT_SUCCESS_FLOWS=FLOW-001,FLOW-002,...` to enforce agent-success gate for specific flows (defaults to critical tx flows) + +Notes: + +- `scripts/agent-browser/run-wallet-flow-suite-docker.sh` auto-loads `.env.local` (if present) before launching Docker. +- Wallet-required flows fail fast when no wallet extension path/profile extension is available. ## Commands + - List all covered launch flows: - `yarn test:wallet-flows:list` +- List + filter flows: + - `yarn test:wallet-flows -- --list --flow FLOW-015` - Run all launch flows: - `yarn test:wallet-flows` +- Run all launch flows in Docker + Xvfb (recommended on Linux hosts without a desktop session): + - `yarn test:wallet-flows:docker` - Run one flow: - - `yarn test:wallet-flows --flow FLOW-001` + - `yarn test:wallet-flows -- --flow FLOW-001` - Run by persona: - - `yarn test:wallet-flows --persona user` + - `yarn test:wallet-flows -- --persona user` - Run service/blueprint-id-dependent flows with explicit ids: - - `yarn test:wallet-flows --blueprint-id 1 --service-id 1` + - `yarn test:wallet-flows -- --blueprint-id 1 --service-id 1` +- Override LLM runtime directly from CLI: + - `yarn test:wallet-flows -- --provider openai --model gpt-4o --api-key $OPENAI_API_KEY` + - `yarn test:wallet-flows -- --base-url http://localhost:4000/v1 --api-key local-dev-key` + +## Verification Semantics + +- Default pass requires: + - `verified=true` (all declared criteria pass) +- Critical-flow dual gate is enabled by default for: + - `FLOW-001`, `FLOW-002`, `FLOW-005`, `FLOW-010`, `FLOW-011`, `FLOW-013`, `FLOW-014`, `FLOW-018`, `FLOW-019` + - these flows require both `verified=true` and `agentSuccess=true` unless overridden via `AGENT_REQUIRE_AGENT_SUCCESS_FLOWS` +- Global strict mode (`AGENT_REQUIRE_AGENT_SUCCESS=true`) requires `agentSuccess=true` for every flow. +- Flow dependencies are expanded automatically when defined in case metadata. +- `tx-outcome` flows pass when either: + - a new terminal transaction status (`finalized` or `failed`) is observed in current-run `tx-history`, or + - an explicit non-actionable blocker state is visible (permissions, missing wallet dependency, empty inventory, etc.). +- Tx-history visibility flows (`FLOW-012`, `FLOW-016`) pass when transaction UI is reachable and either: + - a current-run terminal transaction exists, or + - explicit empty-state copy is visible (`No transactions yet.`). +- `FLOW-015` passes when it reaches dApp `/staking/delegate`, or when cloud operators page explicitly shows empty-state (`No Operators Found` / `Register as Operator`). ## Covered Launch Flows -| Flow ID | Persona | Flow | Start Surface | -|---|---|---|---| -| FLOW-001 | user | staking deposit | `/staking/deposit` | -| FLOW-002 | user | staking delegate | `/staking/delegate` | -| FLOW-003 | user | staking undelegate | `/staking/undelegate` | -| FLOW-004 | user | staking withdraw | `/staking/withdraw` | -| FLOW-005 | user | staking rewards claim | `/staking/rewards` | -| FLOW-006 | user | staking route surface | `/staking/deposit` + native-route negative check | -| FLOW-007 | user | migration claim submission | `/claim/migration` | -| FLOW-009 | customer | blueprint discovery/details | `/blueprints` | -| FLOW-010 | customer | deploy blueprint request | `/blueprints/:id/deploy` (or `/blueprints` fallback) | -| FLOW-011 | customer | service ACL/funding/job | `/services/:id` (or `/instances` fallback) | -| FLOW-012 | customer | cloud tx history/notifier | `/instances` | -| FLOW-013 | operator | pending request approve/reject | `/instances` | -| FLOW-014 | operator | join/leave/exit lifecycle | `/services/:id` (or `/instances` fallback) | -| FLOW-015 | operator | operators page stake deep-link | `/operators` | -| FLOW-016 | operator | operator tx lifecycle traceability | `/instances` | -| FLOW-017 | operator | security requirements read resilience | `/services/:id` (or `/instances` fallback) | -| FLOW-018 | developer | blueprint registration/create/manage | `/blueprints` + `/blueprints/create` + `/blueprints/manage` | -| FLOW-019 | developer | operator batch register hook | `/blueprints` | + +| Flow ID | Persona | Flow | Start Surface | +| -------- | --------- | ------------------------------------- | ----------------------------------------------------------- | +| FLOW-001 | user | staking deposit | `/staking/deposit` | +| FLOW-002 | user | staking delegate | `/staking/delegate` | +| FLOW-003 | user | staking undelegate | `/staking/undelegate` | +| FLOW-004 | user | staking withdraw | `/staking/withdraw` | +| FLOW-005 | user | staking rewards claim | `/staking/rewards` | +| FLOW-006 | user | staking route surface | `/staking/deposit` + native-route negative check | +| FLOW-007 | user | migration claim submission | `/claim/migration` | +| FLOW-009 | customer | blueprint discovery/details | `/blueprints` | +| FLOW-010 | customer | deploy blueprint request | `/blueprints/:id/deploy` (or `/blueprints` fallback) | +| FLOW-011 | customer | service ACL/funding/job | `/services/:id` (or `/instances` fallback) | +| FLOW-012 | customer | cloud tx history/notifier | `/instances` | +| FLOW-013 | operator | pending request approve/reject | `/instances` | +| FLOW-014 | operator | join/leave/exit lifecycle | `/services/:id` (or `/instances` fallback) | +| FLOW-015 | operator | operators page stake deep-link | `/operators` | +| FLOW-016 | operator | operator tx lifecycle traceability | `/instances` | +| FLOW-017 | operator | security requirements read resilience | `/services/:id` (or `/instances` fallback) | +| FLOW-018 | developer | blueprint registration/create/manage | `/blueprints` + `/blueprints/create` + `/blueprints/manage` | +| FLOW-019 | developer | operator batch register hook | `/blueprints` | ## Artifacts and Exit Criteria + - Artifacts are written to `agent-results/wallet-flows/` by default. +- Runner also writes release matrix artifacts under `agent-results/.../suite/`: + - `release-matrix.json` + - `release-matrix.csv` + - `release-matrix.md` + - classification: `happy-path-pass`, `blocker-or-partial-pass`, `failed` - Runner exits non-zero when any case fails or is skipped. - Use generated report artifacts plus tx hashes/request ids as launch sign-off evidence. diff --git a/libs/dapp-config/src/utils/ensureHex.ts b/libs/dapp-config/src/utils/ensureHex.ts index 1e0fa05921..9bac69c95d 100644 --- a/libs/dapp-config/src/utils/ensureHex.ts +++ b/libs/dapp-config/src/utils/ensureHex.ts @@ -1,12 +1,11 @@ -import { HexString } from '@polkadot/util/types'; import { Hex } from 'viem'; -const ensureHex = (maybeHex: string): HexString => { +const ensureHex = (maybeHex: string): Hex => { if (maybeHex.startsWith('0x')) { return maybeHex as Hex; } - return `0x${maybeHex}`; + return `0x${maybeHex}` as Hex; }; export default ensureHex; diff --git a/libs/dapp-config/src/wagmi-config.ts b/libs/dapp-config/src/wagmi-config.ts index c5d4f4c6fb..7cbe5670c5 100644 --- a/libs/dapp-config/src/wagmi-config.ts +++ b/libs/dapp-config/src/wagmi-config.ts @@ -7,6 +7,9 @@ const WALLETCONNECT_PROJECT_ID = process.env.VITE_WALLETCONNECT_PROJECT_ID ?? process.env.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID ?? '3e45c77c9b5d51c8dcf9db03f6c4f826'; // Tangle's WalletConnect project ID +const ENABLE_FAMILY_WALLET = + process.env.VITE_ENABLE_FAMILY_WALLET === 'true' || + process.env.NEXT_PUBLIC_ENABLE_FAMILY_WALLET === 'true'; // Create config using ConnectKit's getDefaultConfig helper // This automatically sets up all popular wallets with EIP-6963 detection @@ -14,10 +17,16 @@ const config = createConfig( getDefaultConfig({ appName: 'Tangle Network', walletConnectProjectId: WALLETCONNECT_PROJECT_ID, + enableFamily: ENABLE_FAMILY_WALLET, chains, transports: chains.reduce( (acc, chain) => { - acc[chain.id] = http(); + const publicRpcUrl = + 'public' in chain.rpcUrls + ? chain.rpcUrls.public?.http?.[0] + : undefined; + const rpcUrl = chain.rpcUrls.default.http[0] ?? publicRpcUrl; + acc[chain.id] = typeof rpcUrl === 'string' ? http(rpcUrl) : http(); return acc; }, {} as Record>, diff --git a/libs/tangle-shared-ui/src/components/ConnectWalletButton/ConnectWalletButton.tsx b/libs/tangle-shared-ui/src/components/ConnectWalletButton/ConnectWalletButton.tsx index 00e72d92cf..7bb7bd6f4a 100644 --- a/libs/tangle-shared-ui/src/components/ConnectWalletButton/ConnectWalletButton.tsx +++ b/libs/tangle-shared-ui/src/components/ConnectWalletButton/ConnectWalletButton.tsx @@ -37,6 +37,7 @@ const ConnectWalletButton = ({ className }: ConnectWalletButtonProps) => {
{!isReady ? (