diff --git a/.changeset/chatty-llamas-worry.md b/.changeset/chatty-llamas-worry.md new file mode 100644 index 000000000..f68fb8229 --- /dev/null +++ b/.changeset/chatty-llamas-worry.md @@ -0,0 +1,6 @@ +--- +"@fuel-wallet/types": minor +"fuels-wallet": minor +--- + +added SRC20 custom assets name, symbol and decimal resolve from indexer diff --git a/.changeset/six-chairs-collect.md b/.changeset/six-chairs-collect.md new file mode 100644 index 000000000..6058aaae8 --- /dev/null +++ b/.changeset/six-chairs-collect.md @@ -0,0 +1,5 @@ +--- +"fuels-wallet": patch +--- + +Require chainId on add network diff --git a/.github/workflows/pr-tests-e2e-contracts.yml b/.github/workflows/pr-tests-e2e-contracts.yml new file mode 100644 index 000000000..a1eeb8c3b --- /dev/null +++ b/.github/workflows/pr-tests-e2e-contracts.yml @@ -0,0 +1,55 @@ +name: Tests E2E - Contracts + +on: + pull_request: + branches: [main, master, sdk-v2] + types: [opened, synchronize, reopened] + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + tests-e2e-contracts: + name: Test + runs-on: buildjet-8vcpu-ubuntu-2204 + steps: + - uses: actions/checkout@v3 + - uses: FuelLabs/github-actions/setups/node@master + with: + node-version: 20.11.0 + pnpm-version: 9.5.0 + - uses: FuelLabs/github-actions/setups/docker@master + with: + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + - uses: ./.github/actions/setup-rust + + - name: Run PNPM install + id: pnpm-cache + run: + pnpm recursive install --frozen-lockfile + + - name: Start Test Node + run: pnpm node:up + + - name: Generate .env app + run: cp packages/app/.env.example packages/app/.env + + - name: Build & Deploy Contracts + run: pnpm deploy:contracts + working-directory: ./packages/e2e-contract-tests + + - name: Run E2E Contract Tests - Local + uses: ./.github/actions/e2e-tests-contracts + with: + providerUrl: "http://localhost:4000/v1/graphql" + masterMnemonic: ${{ secrets.VITE_MASTER_WALLET_MNEMONIC }} + genesisSecret: "0xa449b1ffee0e2205fa924c6740cc48b3b473aa28587df6dab12abc245d1f5298" + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-e2e-contract-tests-report + path: packages/e2e-contract-tests/playwright-results + retention-days: 30 \ No newline at end of file diff --git a/.github/workflows/pr-tests-e2e-crx-lock.yml b/.github/workflows/pr-tests-e2e-crx-lock.yml new file mode 100644 index 000000000..4954dd8a9 --- /dev/null +++ b/.github/workflows/pr-tests-e2e-crx-lock.yml @@ -0,0 +1,59 @@ +name: Tests E2E - CRX Lock + +on: + pull_request: + branches: [main, master, sdk-v2] + types: [opened, synchronize, reopened] + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + tests-e2e-crx-lock: + name: Test + runs-on: buildjet-8vcpu-ubuntu-2204 + timeout-minutes: 5 + steps: + - uses: actions/checkout@v3 + - uses: FuelLabs/github-actions/setups/node@master + with: + node-version: 20.11.0 + pnpm-version: 9.5.0 + - uses: FuelLabs/github-actions/setups/docker@master + with: + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Start Test Node + run: pnpm node:up + + - name: Generate .env + run: cp packages/app/.env.example packages/app/.env + + - name: Build Application + run: pnpm build:app + env: + ## increase node.js m memory limit for building + ## with sourcemaps + NODE_OPTIONS: "--max-old-space-size=4096" + + # E2E tests running with Playwright + - name: Install Playwright Browsers + run: npx playwright install --with-deps chromium + + - name: Run E2E Tests + run: xvfb-run --auto-servernum -- pnpm test:e2e:crx-lock + timeout-minutes: 3 + env: + NODE_ENV: test + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-app-crx-lock-report + path: packages/app/playwright-results + retention-days: 30 + + - name: Stop Test Node + run: pnpm node:clean diff --git a/.github/workflows/pr-tests-e2e.yml b/.github/workflows/pr-tests-e2e.yml new file mode 100644 index 000000000..6ab616850 --- /dev/null +++ b/.github/workflows/pr-tests-e2e.yml @@ -0,0 +1,59 @@ +name: Tests E2E + +on: + pull_request: + branches: [main, master, sdk-v2] + types: [opened, synchronize, reopened] + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + tests-e2e: + name: Test + timeout-minutes: 20 + runs-on: buildjet-8vcpu-ubuntu-2204 + steps: + - uses: actions/checkout@v3 + - uses: FuelLabs/github-actions/setups/node@master + with: + node-version: 20.11.0 + pnpm-version: 9.5.0 + - uses: FuelLabs/github-actions/setups/docker@master + with: + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Start Test Node + run: pnpm node:up + + - name: Generate .env + run: cp packages/app/.env.example packages/app/.env + + - name: Build Application + run: pnpm build:app + env: + ## increase node.js m memory limit for building + ## with sourcemaps + NODE_OPTIONS: "--max-old-space-size=4096" + + # E2E tests running with Playwright + - name: Install Playwright Browsers + run: npx playwright install --with-deps chromium + + - name: Run E2E Tests + run: xvfb-run --auto-servernum -- pnpm test:e2e + timeout-minutes: 15 + env: + NODE_ENV: test + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-app-report + path: packages/app/playwright-results + retention-days: 30 + + - name: Stop Test Node + run: pnpm node:clean diff --git a/.github/workflows/pr-tests-jest.yml b/.github/workflows/pr-tests-jest.yml new file mode 100644 index 000000000..1facc762a --- /dev/null +++ b/.github/workflows/pr-tests-jest.yml @@ -0,0 +1,50 @@ +name: Tests Unit + +on: + pull_request: + branches: [main, master, sdk-v2] + types: [opened, synchronize, reopened] + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + tests-jest: + name: Test + runs-on: buildjet-8vcpu-ubuntu-2204 + steps: + - uses: actions/checkout@v3 + - uses: FuelLabs/github-actions/setups/node@master + with: + node-version: 20.11.0 + pnpm-version: 9.5.0 + - uses: FuelLabs/github-actions/setups/docker@master + with: + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Start Test Node + run: pnpm node:up + + - name: Generate .env + run: cp packages/app/.env.example packages/app/.env + + # Unit tests running with JEST + - name: Find PR number + uses: jwalton/gh-find-current-pr@v1 + id: findPr + + - name: Build libs + run: | + pnpm build:libs + + - name: Run Jest Tests + run: | + pnpm test:ci + timeout-minutes: 10 + env: + NODE_OPTIONS: "--max-old-space-size=4096" + + - name: Stop Test Node + run: pnpm node:clean diff --git a/.github/workflows/pr-tests.yml b/.github/workflows/pr-tests.yml deleted file mode 100644 index 203f08d6d..000000000 --- a/.github/workflows/pr-tests.yml +++ /dev/null @@ -1,183 +0,0 @@ -name: Tests - -on: - pull_request: - branches: [main, master, sdk-v2] - types: [opened, synchronize, reopened] - -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - -jobs: - tests-jest: - name: JEST Tests - runs-on: buildjet-8vcpu-ubuntu-2204 - steps: - - uses: actions/checkout@v3 - - uses: FuelLabs/github-actions/setups/node@master - with: - node-version: 20.11.0 - pnpm-version: 9.5.0 - - uses: FuelLabs/github-actions/setups/docker@master - with: - username: ${{ github.repository_owner }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Start Test Node - run: pnpm node:up - - - name: Generate .env - run: cp packages/app/.env.example packages/app/.env - - # Unit tests running with JEST - - name: Find PR number - uses: jwalton/gh-find-current-pr@v1 - id: findPr - - - name: Build libs - run: | - pnpm build:libs - - - name: Run Jest Tests - run: | - pnpm test:ci - timeout-minutes: 10 - env: - NODE_OPTIONS: "--max-old-space-size=4096" - - - name: Stop Test Node - run: pnpm node:clean - - tests-e2e: - name: E2E Tests - timeout-minutes: 20 - runs-on: buildjet-8vcpu-ubuntu-2204 - steps: - - uses: actions/checkout@v3 - - uses: FuelLabs/github-actions/setups/node@master - with: - node-version: 20.11.0 - pnpm-version: 9.5.0 - - uses: FuelLabs/github-actions/setups/docker@master - with: - username: ${{ github.repository_owner }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Start Test Node - run: pnpm node:up - - - name: Generate .env - run: cp packages/app/.env.example packages/app/.env - - - name: Build Application - run: pnpm build:app - env: - ## increase node.js m memory limit for building - ## with sourcemaps - NODE_OPTIONS: "--max-old-space-size=4096" - - # E2E tests running with Playwright - - name: Install Playwright Browsers - run: npx playwright install --with-deps chromium - - - name: Run E2E Tests - run: xvfb-run --auto-servernum -- pnpm test:e2e - timeout-minutes: 15 - env: - NODE_ENV: test - - - uses: actions/upload-artifact@v4 - if: always() - with: - name: playwright-app-report - path: packages/app/playwright-results - retention-days: 30 - - - name: Stop Test Node - run: pnpm node:clean - - tests-e2e-crx-lock: - name: E2E Tests - Lock CRX - runs-on: buildjet-8vcpu-ubuntu-2204 - timeout-minutes: 5 - steps: - - uses: actions/checkout@v3 - - uses: FuelLabs/github-actions/setups/node@master - with: - node-version: 20.11.0 - pnpm-version: 9.5.0 - - uses: FuelLabs/github-actions/setups/docker@master - with: - username: ${{ github.repository_owner }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Start Test Node - run: pnpm node:up - - - name: Generate .env - run: cp packages/app/.env.example packages/app/.env - - - name: Build Application - run: pnpm build:app - env: - ## increase node.js m memory limit for building - ## with sourcemaps - NODE_OPTIONS: "--max-old-space-size=4096" - - # E2E tests running with Playwright - - name: Install Playwright Browsers - run: npx playwright install --with-deps chromium - - - name: Run E2E Tests - run: xvfb-run --auto-servernum -- pnpm test:e2e:crx-lock - timeout-minutes: 3 - env: - NODE_ENV: test - - - uses: actions/upload-artifact@v4 - if: always() - with: - name: playwright-app-crx-lock-report - path: packages/app/playwright-results - retention-days: 30 - - - name: Stop Test Node - run: pnpm node:clean - - tests-e2e-contracts: - name: E2E Contract Tests - Local - runs-on: buildjet-8vcpu-ubuntu-2204 - steps: - - uses: actions/checkout@v3 - - uses: FuelLabs/github-actions/setups/node@master - with: - node-version: 20.11.0 - pnpm-version: 9.5.0 - - uses: FuelLabs/github-actions/setups/docker@master - with: - username: ${{ github.repository_owner }} - password: ${{ secrets.GITHUB_TOKEN }} - - uses: ./.github/actions/setup-rust - - - name: Run PNPM install - id: pnpm-cache - run: - pnpm recursive install --frozen-lockfile - - - name: Start Test Node - run: pnpm node:up - - - name: Generate .env app - run: cp packages/app/.env.example packages/app/.env - - - name: Build & Deploy Contracts - run: pnpm deploy:contracts - working-directory: ./packages/e2e-contract-tests - - - name: Run E2E Contract Tests - Local - uses: ./.github/actions/e2e-tests-contracts - with: - providerUrl: "http://localhost:4000/v1/graphql" - masterMnemonic: ${{ secrets.VITE_MASTER_WALLET_MNEMONIC }} - genesisSecret: "0xa449b1ffee0e2205fa924c6740cc48b3b473aa28587df6dab12abc245d1f5298" diff --git a/package.json b/package.json index 6c0c44bf4..2028fc055 100644 --- a/package.json +++ b/package.json @@ -123,7 +123,8 @@ "serve-static@<1.16.0": ">=1.16.0", "rollup@>=4.0.0 <4.22.4": ">=4.22.4", "fuels": "0.96.1", - "secp256k1@=5.0.0": ">=5.0.1" + "secp256k1@=5.0.0": ">=5.0.1", + "elliptic@<6.6.0": ">=6.6.0" } } } diff --git a/packages/app/package.json b/packages/app/package.json index 261e7e6b1..c722799df 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -10,7 +10,7 @@ "build:crx": "./scripts/build.sh --app=crx", "build:storybook": "./scripts/build.sh --app=storybook", "dev": "vite", - "dev:crx": "vite --config vite.crx.config.ts", + "dev:crx": "vite --config vite.crx.config.ts --mode $NODE_ENV", "dev:storybook": "storybook dev -p 6006", "preview": "vite preview", "test": "jest --verbose", @@ -58,7 +58,7 @@ "tai64": "1.0.0", "vite-plugin-markdown": "2.2.0", "xstate": "4.38.2", - "yup": "1.3.2" + "yup": "1.4.0" }, "devDependencies": { "@crxjs/vite-plugin": "2.0.0-beta.28", diff --git a/packages/app/playwright.config.ts b/packages/app/playwright.config.ts index baafe1605..c241661af 100644 --- a/packages/app/playwright.config.ts +++ b/packages/app/playwright.config.ts @@ -2,15 +2,17 @@ import { join } from 'node:path'; import { type PlaywrightTestConfig, defineConfig } from '@playwright/test'; import './load.envs.cts'; -const PORT = process.env.PORT || 3000; +const PORT = process.env.PORT; +const IS_CI = process.env.CI; export const playwrightConfig: PlaywrightTestConfig = { workers: 1, + retries: 1, testMatch: 'playwright/**/*.test.ts', testDir: 'playwright/', outputDir: 'playwright-results/', // stop on first failure - maxFailures: 1, + maxFailures: IS_CI ? 1 : undefined, reporter: [ ['list', { printSteps: true }], ['html', { outputFolder: './playwright-html/' }], @@ -24,9 +26,9 @@ export const playwrightConfig: PlaywrightTestConfig = { use: { baseURL: `http://localhost:${PORT}/`, permissions: ['clipboard-read', 'clipboard-write'], - headless: false, trace: 'on-first-retry', actionTimeout: 5000, + screenshot: 'only-on-failure', }, // ignore lock test because it takes too long and it will be tested in a separate config testIgnore: ['playwright/crx/lock.test.ts'], diff --git a/packages/app/playwright/crx/crx.test.ts b/packages/app/playwright/crx/crx.test.ts index 3c3c3b5fe..d2f3ffc18 100644 --- a/packages/app/playwright/crx/crx.test.ts +++ b/packages/app/playwright/crx/crx.test.ts @@ -1,4 +1,4 @@ -import type { Account as WalletAccount } from '@fuel-wallet/types'; +import type { NetworkData, Account as WalletAccount } from '@fuel-wallet/types'; import { type Locator, type Page, expect } from '@playwright/test'; import { @@ -18,7 +18,7 @@ import { CUSTOM_ASSET_INPUT_2, CUSTOM_ASSET_INPUT_3, CUSTOM_ASSET_INPUT_4, - FUEL_NETWORK, + FUEL_LOCAL_NETWORK, PRIVATE_KEY, mockData, } from '../mocks'; @@ -26,6 +26,7 @@ import { import { Address, type Asset, + CHAIN_IDS, type NetworkFuel, Provider, Signer, @@ -43,6 +44,39 @@ import { waitWalletToLoad, } from './utils'; +export const NETWORK_IGNITION = { + id: '1', + name: 'Ignition', + url: 'https://mainnet.fuel.network/v1/graphql', + chainId: CHAIN_IDS.fuel.mainnet, + explorerUrl: 'https://app.fuel.network', + bridgeUrl: 'https://app.fuel.network/bridge', + isSelected: true, +}; +export const NETWORK_TESTNET = { + id: '2', + name: 'Fuel Sepolia Testnet', + url: 'https://testnet.fuel.network/v1/graphql', + chainId: CHAIN_IDS.fuel.testnet, + explorerUrl: 'https://app-testnet.fuel.network', + faucetUrl: 'https://faucet-testnet.fuel.network/', + bridgeUrl: 'https://app-testnet.fuel.network/bridge', + isSelected: false, +}; +export const NETWORK_DEVNET = { + id: '3', + name: 'Fuel Sepolia Devnet', + url: 'https://devnet.fuel.network/v1/graphql', + chainId: CHAIN_IDS.fuel.devnet, + explorerUrl: 'https://app-devnet.fuel.network', + faucetUrl: 'https://faucet-devnet.fuel.network/', + isSelected: false, +}; + +export const DEFAULT_NETWORKS: Array< + NetworkData & { faucetUrl?: string; bridgeUrl?: string; hidden?: boolean } +> = [NETWORK_IGNITION, NETWORK_TESTNET, NETWORK_DEVNET]; + const WALLET_PASSWORD = 'Qwe123456$'; // Increase timeout for this test @@ -186,12 +220,12 @@ test.describe('FuelWallet Extension', () => { return page; }); - await test.step('Should select local network', async () => { + await test.step('Should mock initial data', async () => { const page = await context.newPage(); - await mockData(page); + await mockData(page, 1, DEFAULT_NETWORKS); + await popupPage.reload(); await waitWalletToLoad(popupPage); - await getByAriaLabel(popupPage, 'Selected Network').click(); - await getElementByText(popupPage, 'Local network').click(); + await page.close(); }); await test.step('Add more accounts', async () => { @@ -266,7 +300,14 @@ test.describe('FuelWallet Extension', () => { predicate: (page) => page.url().includes(extensionId), }); - await hasText(connectPage, /connect/i); + await expect + .poll( + async () => { + return await hasText(connectPage, /connect/i).catch(() => false); + }, + { timeout: 10000 } + ) + .toBeTruthy(); // Account 1 should be toggled by default const toggleAccountOneLocator = await getByAriaLabel( @@ -308,6 +349,109 @@ test.describe('FuelWallet Extension', () => { await connectAccounts(); }); + await test.step('window.fuel.selectNetwork() for selecting a network', async () => { + async function testSelectNetwork(network: NetworkData) { + const selectingNetwork = blankPage.evaluate( + async ([network]) => { + return window.fuel.selectNetwork(network); + }, + [network] + ); + const selectNetworkPage = await context.waitForEvent('page', { + predicate: (page) => page.url().includes(extensionId), + }); + await hasText(selectNetworkPage, 'Switching To'); + await hasText(selectNetworkPage, network.name); + + await getButtonByText(selectNetworkPage, /switch network/i).click(); + await expect(selectingNetwork).resolves.toBeDefined(); + } + + await testSelectNetwork(NETWORK_TESTNET); + await popupPage.waitForTimeout(2000); + await testSelectNetwork(NETWORK_DEVNET); + await popupPage.waitForTimeout(2000); + await testSelectNetwork(NETWORK_IGNITION); + await popupPage.waitForTimeout(2000); + }); + + await test.step('window.fuel.selectNetwork() for adding a network', async () => { + async function addLocalNetwork() { + const selectingNetwork = blankPage.evaluate( + async ([network]) => { + return window.fuel.selectNetwork(network); + }, + [FUEL_LOCAL_NETWORK] + ); + + const selectNetworkPage = await context.waitForEvent('page', { + predicate: (page) => page.url().includes(extensionId), + }); + + await hasText(selectNetworkPage, 'Review the Network to be added:'); + await hasText(selectNetworkPage, 'Local network'); + await getButtonByText(selectNetworkPage, /add network/i).click(); + await expect(selectingNetwork).resolves.toBeDefined(); + await popupPage.reload(); + await waitWalletToLoad(popupPage); + } + + const initialNetworkAmount = 3; + const networkSelectorBeforeAdd = getByAriaLabel( + popupPage, + 'Selected Network' + ); + await networkSelectorBeforeAdd.click(); + + // Check initial amount of networks + const itemBeforeAdd = popupPage.locator('[aria-label*=fuel_network]'); + const networkItemsCountBeforeAdd = await itemBeforeAdd.count(); + expect(networkItemsCountBeforeAdd).toEqual(initialNetworkAmount); + + await addLocalNetwork(); + + const networkSelectorAfterAdd = getByAriaLabel( + popupPage, + 'Selected Network' + ); + await networkSelectorAfterAdd.click(); + + const itemAfterAdd = popupPage.locator('[aria-label*=fuel_network]'); + const networkItemsCountAfterAdd = await itemAfterAdd.count(); + expect(networkItemsCountAfterAdd).toEqual(initialNetworkAmount + 1); + + // Remove network so we can test adding it again + let testnetNetwork: Locator; + for (let i = 0; i < networkItemsCountAfterAdd; i += 1) { + const text = await itemAfterAdd.nth(i).innerText(); + if (text.includes('Local')) { + testnetNetwork = itemAfterAdd.nth(i); + } + } + await testnetNetwork.getByLabel(/Remove/).click(); + await hasText(popupPage, /Are you sure/i); + await getButtonByText(popupPage, /confirm/i).click(); + const itemsAfterRemove = popupPage.locator('[aria-label*=fuel_network]'); + await expect(itemsAfterRemove).toHaveCount(initialNetworkAmount); + + // Add network + await addLocalNetwork(); + + // Check initial amount of networks + const networkSelectorAfterFinished = getByAriaLabel( + popupPage, + 'Selected Network' + ); + await networkSelectorAfterFinished.click(); + + const itemsAfterAdd = popupPage.locator('[aria-label*=fuel_network]'); + await expect(itemsAfterAdd).toHaveCount(initialNetworkAmount + 1); + + // // Check if added network is selected + await expect(networkSelectorAfterFinished).toHaveText(/Local/); + await getByAriaLabel(popupPage, 'Close dialog').click(); + }); + await test.step('window.fuel.disconnect()', async () => { const isDisconnected = blankPage.evaluate(async () => { return window.fuel.disconnect(); @@ -398,9 +542,6 @@ test.describe('FuelWallet Extension', () => { await test.step('Changing to not connected wallet should keep Account 1 as connected', async () => { await switchAccount(popupPage, 'Account 2'); - // delay to avoid the page to get the wrong currentAccount - await delay(2000); - const currentAccountPromise = await blankPage.evaluate(async () => { return window.fuel.currentAccount(); }); @@ -707,65 +848,6 @@ test.describe('FuelWallet Extension', () => { ).rejects.toThrow(); }); - await test.step('window.fuel.addNetwork()', async () => { - function addNetwork(network: string) { - return blankPage.evaluate( - async ([network]) => { - return window.fuel.addNetwork(network); - }, - [network] - ); - } - - async function testAddNetwork() { - const addingNetwork = addNetwork(FUEL_NETWORK.testnet); - - const addNetworkPage = await context.waitForEvent('page', { - predicate: (page) => page.url().includes(extensionId), - }); - - await hasText(addNetworkPage, 'Review the Network to be added:'); - await getButtonByText(addNetworkPage, /add network/i).click(); - await expect(addingNetwork).resolves.toBeDefined(); - await popupPage.reload(); - } - - const initialNetworkAmount = 4; - let networkSelector = getByAriaLabel(popupPage, 'Selected Network'); - await networkSelector.click(); - - // Check initial amount of networks - const itemsAfterRemove = popupPage.locator('[aria-label*=fuel_network]'); - const networkItemsCount = await itemsAfterRemove.count(); - expect(networkItemsCount).toEqual(initialNetworkAmount); - - // Remove network so we can test adding it again - let testnetNetwork: Locator; - for (let i = 0; i < networkItemsCount; i += 1) { - const text = await itemsAfterRemove.nth(i).innerText(); - if (text.includes('Fuel Sepolia Testnet')) { - testnetNetwork = itemsAfterRemove.nth(i); - } - } - await testnetNetwork.getByLabel(/Remove/).click(); - await hasText(popupPage, /Are you sure/i); - await getButtonByText(popupPage, /confirm/i).click(); - await expect(itemsAfterRemove).toHaveCount(initialNetworkAmount - 1); - - // Add network - await testAddNetwork(); - - // Check initial amount of networks - await networkSelector.click(); - const itemsAfterAdd = popupPage.locator('[aria-label*=fuel_network]'); - await expect(itemsAfterAdd).toHaveCount(initialNetworkAmount); - - // Check if added network is selected - networkSelector = getByAriaLabel(popupPage, 'Selected Network'); - await expect(networkSelector).toHaveText(/Fuel Sepolia Testnet/); - await getByAriaLabel(popupPage, 'Close dialog').click(); - }); - await test.step('window.fuel.on("currentAccount") to a connected account', async () => { // Switch to account 2 await switchAccount(popupPage, 'Account 2'); diff --git a/packages/app/playwright/crx/utils/popup.ts b/packages/app/playwright/crx/utils/popup.ts index 721df9398..5449013e0 100644 --- a/packages/app/playwright/crx/utils/popup.ts +++ b/packages/app/playwright/crx/utils/popup.ts @@ -26,14 +26,17 @@ export async function switchAccount(popupPage: Page, name: string) { } await getByAriaLabel(popupPage, 'Accounts').click(); - // Add position to click on the element and not on copy button + + await popupPage.waitForTimeout(5000); await hasText(popupPage, name); + // Add position to click on the element and not on copy button await getByAriaLabel(popupPage, name).click({ position: { x: 10, y: 10, }, }); + await popupPage.waitForTimeout(2000); await waitAriaLabel(popupPage, `${name} selected`); // Return account to be used on tests diff --git a/packages/app/playwright/e2e/HomeWallet.test.ts b/packages/app/playwright/e2e/HomeWallet.test.ts index 47b72d862..ca8e95d02 100644 --- a/packages/app/playwright/e2e/HomeWallet.test.ts +++ b/packages/app/playwright/e2e/HomeWallet.test.ts @@ -34,7 +34,10 @@ test.describe('HomeWallet', () => { await getInputByName(faucetTab, 'agreement2').click(); await getInputByName(faucetTab, 'agreement3').click(); await getInputByValue(faucetTab, 'Give me Test Ether').click(); + await hasText(faucetTab, 'Test Ether sent to the wallet'); await page.bringToFront(); + await page.waitForTimeout(2000); + await page.reload(); await hasText(page, /Ethereum/i); await hasText(page, /ETH.0\.002/i); await getByAriaLabel(page, 'Selected Network').click(); diff --git a/packages/app/playwright/e2e/Networks.test.ts b/packages/app/playwright/e2e/Networks.test.ts index db0cc33a2..9afd6cc75 100644 --- a/packages/app/playwright/e2e/Networks.test.ts +++ b/packages/app/playwright/e2e/Networks.test.ts @@ -1,6 +1,7 @@ import type { Browser, Page } from '@playwright/test'; import test, { chromium, expect } from '@playwright/test'; +import { CHAIN_IDS } from 'fuels'; import { getButtonByText, getByAriaLabel, @@ -96,7 +97,7 @@ test.describe('Networks', () => { await expect(items.first()).toHaveAttribute('data-active', 'true'); }); - test('should be able to add a new network', async () => { + test('should NOT be able to add a new network with wrong chain ID', async () => { await visit(page, '/wallet'); await getByAriaLabel(page, 'Selected Network').click(); await hasText(page, /Add new network/i); @@ -106,14 +107,199 @@ test.describe('Networks', () => { const urlInput = getInputByName(page, 'url'); await expect(urlInput).toBeFocused(); await urlInput.fill('https://testnet.fuel.network/v1/graphql'); + const chainIdInput = getInputByName(page, 'chainId'); + await chainIdInput.fill('9999'); await hasText(page, /Test connection/i); + await expect + .poll( + async () => + await getByAriaLabel(page, 'Test connection') + .isEnabled() + .catch(() => false), + { + timeout: 15000, + } + ) + .toBeTruthy(); await getByAriaLabel(page, 'Test connection').click(); - await hasText(page, /Fuel Sepolia Testnet/i, 0, 15000); - await expect(buttonCreate).toBeEnabled(); + await expect + .poll( + async () => + await hasText( + page, + /Informed Chain ID does not match the network Chain ID./i + ).catch(() => false), + { + timeout: 15000, + } + ) + .toBeTruthy(); + }); + + test('should be able to add a new network with a manual chain ID', async () => { + await visit(page, '/wallet'); + await getByAriaLabel(page, 'Selected Network').click(); + await hasText(page, /Add new network/i); + await getByAriaLabel(page, 'Add network').click(); + const buttonCreate = getButtonByText(page, /add/i); + await expect(buttonCreate).toBeDisabled(); + const urlInput = getInputByName(page, 'url'); + await expect(urlInput).toBeFocused(); + await urlInput.fill('https://testnet.fuel.network/v1/graphql'); + const chainIdInput = getInputByName(page, 'chainId'); + await chainIdInput.fill(CHAIN_IDS.fuel.testnet.toString()); + await hasText(page, /Test connection/i); + await expect + .poll( + async () => + await getByAriaLabel(page, 'Test connection') + .isEnabled() + .catch(() => false), + { + timeout: 15000, + } + ) + .toBeTruthy(); + await getByAriaLabel(page, 'Test connection').click(); + await expect + .poll( + async () => + await hasText(page, /Fuel Sepolia Testnet/i) + .then(() => true) + .catch(() => false), + { + timeout: 15000, + } + ) + .toBeTruthy(); + console.log('asd waiting for button to be enabled'); + await expect + .poll(async () => await buttonCreate.isEnabled(), { + timeout: 15000, + }) + .toBeTruthy(); + console.log('asd button is enabled'); + await buttonCreate.click(); + // Wait for popup to close + await expect + .poll( + async () => { + return await hasText(page, /Add network/i).catch(() => false); + }, + { + timeout: 15000, + } + ) + .toBeFalsy(); + await expect + .poll( + async () => { + await reload(page); + return await hasText(page, /Fuel Sepolia Testnet/i).catch( + () => false + ); + }, + { + timeout: 15000, + } + ) + .toBeTruthy(); + }); + + test('should be able to add a new network with wrong chainId then correct chainId', async () => { + await visit(page, '/wallet'); + await getByAriaLabel(page, 'Selected Network').click(); + await hasText(page, /Add new network/i); + await getByAriaLabel(page, 'Add network').click(); + const buttonCreate = getButtonByText(page, /add/i); + await expect(buttonCreate).toBeDisabled(); + const urlInput = getInputByName(page, 'url'); + await expect(urlInput).toBeFocused(); + await urlInput.fill('https://mainnet.fuel.network/v1/graphql'); + const chainIdInput = getInputByName(page, 'chainId'); + await chainIdInput.fill('999999'); + await hasText(page, /Test connection/i); + await expect + .poll( + async () => + await getByAriaLabel(page, 'Test connection') + .isEnabled() + .catch(() => false), + { + timeout: 15000, + } + ) + .toBeTruthy(); + await getByAriaLabel(page, 'Test connection').click(); + await expect + .poll( + async () => + await hasText( + page, + /Informed Chain ID does not match the network Chain ID./i + ).catch(() => false), + { + timeout: 15000, + } + ) + .toBeTruthy(); + await urlInput.fill('https://testnet.fuel.network/v1/graphql'); + await chainIdInput.fill(CHAIN_IDS.fuel.testnet.toString()); + await hasText(page, /Test connection/i); + await expect + .poll( + async () => + await getByAriaLabel(page, 'Test connection') + .isEnabled() + .catch(() => false), + { + timeout: 15000, + } + ) + .toBeTruthy(); + await getByAriaLabel(page, 'Test connection').click(); + await expect + .poll( + async () => + await hasText(page, /Fuel Sepolia Testnet/i) + .then(() => true) + .catch(() => false), + { + timeout: 15000, + } + ) + .toBeTruthy(); + console.log('asd waiting for button to be enabled'); + await expect + .poll(async () => await buttonCreate.isEnabled(), { + timeout: 15000, + }) + .toBeTruthy(); + console.log('asd button is enabled'); await buttonCreate.click(); - // Wait for save and close popup; - await page.waitForTimeout(2000); - await reload(page); - await hasText(page, /Fuel Sepolia Testnet/i); + await expect + .poll( + async () => { + return await hasText(page, /Add network/i).catch(() => false); + }, + { + timeout: 15000, + } + ) + .toBeFalsy(); + // Wait for popup to close + await expect + .poll( + async () => { + await reload(page); + return await hasText(page, /Fuel Sepolia Testnet/i).catch( + () => false + ); + }, + { + timeout: 15000, + } + ) + .toBeTruthy(); }); }); diff --git a/packages/app/playwright/mocks/database.ts b/packages/app/playwright/mocks/database.ts index 72197706f..c4bf45f8d 100644 --- a/packages/app/playwright/mocks/database.ts +++ b/packages/app/playwright/mocks/database.ts @@ -8,6 +8,7 @@ import type { Page } from '@playwright/test'; import type { Asset, AssetFuel, WalletManagerAccount } from 'fuels'; import { Address, + CHAIN_IDS, Mnemonic, TESTNET_NETWORK_URL, WalletManager, @@ -31,6 +32,7 @@ export const DEFAULT_NETWORKS: Array = [ isSelected: true, name: 'Local', url: VITE_FUEL_PROVIDER_URL, + faucetUrl: 'http://localhost:4040', }, { id: '2', @@ -126,8 +128,9 @@ export const ALT_ASSET = { ], }; -export const FUEL_NETWORK = { - testnet: TESTNET_NETWORK_URL, +export const FUEL_LOCAL_NETWORK = { + url: 'http://localhost:4000/v1/graphql', + chainId: CHAIN_IDS.fuel.testnet, }; export async function getAccount(page: Page) { diff --git a/packages/app/src/networks.ts b/packages/app/src/networks.ts index ea13866fb..e20b2c301 100644 --- a/packages/app/src/networks.ts +++ b/packages/app/src/networks.ts @@ -1,12 +1,5 @@ import type { NetworkData } from '@fuel-wallet/types'; import { CHAIN_IDS } from 'fuels'; -import { - IS_DEVELOPMENT, - IS_TEST, - VITE_EXPLORER_URL, - VITE_FUEL_FAUCET_URL, - VITE_FUEL_PROVIDER_URL, -} from './config'; export const DEFAULT_NETWORKS: Array< NetworkData & { faucetUrl?: string; bridgeUrl?: string; hidden?: boolean } @@ -37,17 +30,3 @@ export const DEFAULT_NETWORKS: Array< isSelected: false, }, ]; - -if ( - (IS_DEVELOPMENT || IS_TEST) && - !DEFAULT_NETWORKS.find((n) => n.url === VITE_FUEL_PROVIDER_URL) -) { - DEFAULT_NETWORKS.push({ - name: 'Local network', - url: VITE_FUEL_PROVIDER_URL, - chainId: CHAIN_IDS.fuel.testnet, - explorerUrl: VITE_EXPLORER_URL, - faucetUrl: VITE_FUEL_FAUCET_URL, - isSelected: false, - }); -} diff --git a/packages/app/src/systems/Account/components/BalanceAssets/BalanceAssets.tsx b/packages/app/src/systems/Account/components/BalanceAssets/BalanceAssets.tsx index 09254b34c..7956c3c25 100644 --- a/packages/app/src/systems/Account/components/BalanceAssets/BalanceAssets.tsx +++ b/packages/app/src/systems/Account/components/BalanceAssets/BalanceAssets.tsx @@ -35,7 +35,6 @@ export const BalanceAssets = ({ function toggle() { setShowUnknown((s) => !s); } - return ( {balancesToShow.map((balance) => ( diff --git a/packages/app/src/systems/Account/services/account.ts b/packages/app/src/systems/Account/services/account.ts index 0e99a099a..3c2005bb0 100644 --- a/packages/app/src/systems/Account/services/account.ts +++ b/packages/app/src/systems/Account/services/account.ts @@ -6,6 +6,7 @@ import type { CoinAsset, } from '@fuel-wallet/types'; import { Address, type Provider, bn } from 'fuels'; +import { AssetsCache } from '~/systems/Asset/cache/AssetsCache'; import { AssetService } from '~/systems/Asset/services'; import { getFuelAssetByAssetId } from '~/systems/Asset/utils'; import type { Maybe } from '~/systems/Core/types'; @@ -109,16 +110,34 @@ export class AccountService { const nextBalancesWithAssets = await balances.reduce( async (acc, balance) => { const prev = await acc; - const asset = await getFuelAssetByAssetId({ - assets, - assetId: balance.assetId, - }); + const asset = { + fuel: await getFuelAssetByAssetId({ + assets, + assetId: balance.assetId, + }), + }; + try { + const assetCached = await AssetsCache.getInstance().getAsset({ + chainId: provider.getChainId(), + assetId: balance.assetId, + provider, + }); + + if (assetCached && asset.fuel) { + asset.fuel = { + ...asset.fuel, + ...assetCached, + indexed: true, + }; + } + } catch (_) {} + return [ ...prev, { ...balance, amount: balance.amount, - asset, + asset: asset.fuel, }, ]; }, diff --git a/packages/app/src/systems/Asset/cache/AssetsCache.ts b/packages/app/src/systems/Asset/cache/AssetsCache.ts new file mode 100644 index 000000000..5a63fab3a --- /dev/null +++ b/packages/app/src/systems/Asset/cache/AssetsCache.ts @@ -0,0 +1,119 @@ +import type { AssetData } from '@fuel-wallet/types'; +import type { Asset, Provider } from 'fuels'; +import { db } from '~/systems/Core/utils/database'; +import { isNft } from '../utils/isNft'; + +type Endpoint = { + chainId: number; + url: string; +}; + +export class AssetsCache { + private cache: { [chainId: number]: { [assetId: string]: Asset } }; + private static instance: AssetsCache; + private endpoints: Endpoint[] = [ + { + chainId: 9889, + url: 'https://explorer-indexer-mainnet.fuel.network', + }, + { + chainId: 0, + url: 'https://explorer-indexer-testnet.fuel.network', + }, + ]; + private storage: IndexedAssetsDB; + + private constructor() { + this.cache = {}; + this.storage = new IndexedAssetsDB(); + } + + private getIndexerEndpoint(chainId: number) { + return this.endpoints.find( + (endpoint: Endpoint) => endpoint.chainId === chainId + ); + } + + private async fetchAssetFromIndexer(url: string, assetId: string) { + try { + const timeout = new Promise((_, reject) => + setTimeout(() => reject(new Error('Timeout')), 2000) + ); + // limit on 2000ms request time + const response = await Promise.race([ + fetch(`${url}/assets/${assetId}`), + timeout, + ]); + if (response instanceof Response) { + return response.json(); + } + } catch (_e: unknown) {} + } + + async getAsset({ + chainId, + assetId, + provider, + }: { chainId: number; assetId: string; provider: Provider }) { + if (chainId == null || !assetId) { + return; + } + const endpoint = this.getIndexerEndpoint(chainId); + if (!endpoint) return; + // try to get from memory cache first + this.cache[endpoint.chainId] = this.cache[endpoint.chainId] || {}; + if (this.cache[endpoint.chainId][assetId]) { + return this.cache[endpoint.chainId][assetId]; + } + // get from indexed db if not in memory + const savedAsset = await this.storage.getItem( + `${endpoint.chainId}/${assetId}` + ); + if (savedAsset) { + this.cache[endpoint.chainId][assetId] = savedAsset; + return savedAsset; + } + const assetFromIndexer = await this.fetchAssetFromIndexer( + endpoint.url, + assetId + ); + if (!assetFromIndexer) return; + + const isNftAsset = await isNft({ + assetId, + contractId: assetFromIndexer.contractId, + provider, + }); + + const asset = { + ...assetFromIndexer, + isNft: isNftAsset, + }; + + this.cache[endpoint.chainId][assetId] = asset; + this.storage.setItem(`${endpoint.chainId}/${assetId}`, asset); + return asset; + } + + static getInstance() { + if (!AssetsCache.instance) { + AssetsCache.instance = new AssetsCache(); + } + return AssetsCache.instance; + } +} + +class IndexedAssetsDB { + async getItem(key: string) { + return db.transaction('r', db.indexedAssets, async () => { + const asset = await db.indexedAssets.get({ key }); + return asset; + }); + } + + async setItem(key: string, data: AssetData) { + await db.transaction('rw', db.indexedAssets, async () => { + await db.indexedAssets.put({ key, ...data }); + }); + } +} diff --git a/packages/app/src/systems/Asset/components/AssetItem/AssetItem.tsx b/packages/app/src/systems/Asset/components/AssetItem/AssetItem.tsx index d710049d4..b5021e8be 100644 --- a/packages/app/src/systems/Asset/components/AssetItem/AssetItem.tsx +++ b/packages/app/src/systems/Asset/components/AssetItem/AssetItem.tsx @@ -1,6 +1,7 @@ import { cssObj } from '@fuel-ui/css'; import { Avatar, + Badge, Box, Button, CardList, @@ -126,7 +127,9 @@ export const AssetItem: AssetItemComponent = ({ delayDuration={0} open={visibility && tooltip ? undefined : false} > - + + This asset is flagged as suspicious, +
it may mimicking another asset. +
Proceed with caution. + + ); + return ( {icon ? ( @@ -161,20 +177,35 @@ export const AssetItem: AssetItemComponent = ({ )} - {name || ( - - Unknown + + {name || 'Unknown'} + {asset.suspicious ? ( + + + + ) : ( + '' + )} + {asset.isNft && ( + + NFT + + )} + {(!name || asset.indexed) && !asset.isNft && ( - - )} + )} + {symbol ? ( @@ -207,6 +238,11 @@ const styles = { textSize: 'sm', fontWeight: '$normal', }), + assetSuspicious: cssObj({ + marginLeft: 5, + marginRight: 5, + color: 'orange', + }), addAssetBtn: cssObj({ p: '0', ml: '$1', @@ -229,4 +265,9 @@ const styles = { color: '$intentsBase11 !important', }, }), + assetNft: cssObj({ + ml: '$2', + fontSize: '$sm', + lineHeight: 'normal', + }), }; diff --git a/packages/app/src/systems/Asset/components/AssetSelect/AssetSelect.tsx b/packages/app/src/systems/Asset/components/AssetSelect/AssetSelect.tsx index 754ab0014..eb8412b9d 100644 --- a/packages/app/src/systems/Asset/components/AssetSelect/AssetSelect.tsx +++ b/packages/app/src/systems/Asset/components/AssetSelect/AssetSelect.tsx @@ -1,6 +1,7 @@ import { cssObj, cx } from '@fuel-ui/css'; import { Avatar, + Badge, Box, Button, Dropdown, @@ -107,7 +108,7 @@ function AssetSelectBase({ items, selected, onSelect }: AssetSelectProps) { {(items || []).map((item) => { const assetId = item.assetId?.toString(); const itemAsset = items?.find((a) => a.assetId === assetId); - const { name, symbol, icon } = itemAsset || {}; + const { name, symbol, icon, isNft } = itemAsset || {}; return ( {name || 'Unknown'} + {isNft && ( + + NFT + + )} {symbol || shortAddress(assetId)} @@ -230,4 +240,9 @@ const styles = { dropdownRoot: cssObj({ zIndex: '1 !important', }), + assetNft: cssObj({ + ml: '$2', + fontSize: '$sm', + lineHeight: 'normal', + }), }; diff --git a/packages/app/src/systems/Asset/pages/UpsertAsset/UpsertAsset.tsx b/packages/app/src/systems/Asset/pages/UpsertAsset/UpsertAsset.tsx index a250083a9..af3a3fe60 100644 --- a/packages/app/src/systems/Asset/pages/UpsertAsset/UpsertAsset.tsx +++ b/packages/app/src/systems/Asset/pages/UpsertAsset/UpsertAsset.tsx @@ -1,7 +1,7 @@ import { cssObj } from '@fuel-ui/css'; import { Box, Button, Focus, Text } from '@fuel-ui/react'; import { isB256 } from 'fuels'; -import { useNavigate, useParams } from 'react-router-dom'; +import { useLocation, useNavigate, useParams } from 'react-router-dom'; import { Layout } from '~/systems/Core'; import { AssetItem } from '../../components'; @@ -13,6 +13,7 @@ import useFuelAsset from '../../hooks/useFuelAsset'; export function UpsertAsset() { const navigate = useNavigate(); + const { state } = useLocation(); const params = useParams<{ name: string; assetId: string }>(); const name = params.name; @@ -22,11 +23,11 @@ export function UpsertAsset() { const { handlers, isLoading } = useAssets(); const form = useAssetForm({ defaultValues: { - name: '', - symbol: '', + name: state?.name || '', + symbol: state?.symbol || '', icon: '', ...asset, - decimals: fuelAsset?.decimals || 0, + decimals: state?.decimals || fuelAsset?.decimals || 0, assetId: fuelAsset?.assetId || params.assetId || '', }, }); diff --git a/packages/app/src/systems/Asset/services/assets.ts b/packages/app/src/systems/Asset/services/assets.ts index 1e49e2853..7d8c8ebe7 100644 --- a/packages/app/src/systems/Asset/services/assets.ts +++ b/packages/app/src/systems/Asset/services/assets.ts @@ -227,6 +227,12 @@ export class AssetService { }); } + static async getAssetById(assetId: string) { + return db.transaction('r', db.assets, async () => { + return db.assets.get({ assetId }); + }); + } + static async clearAssets() { return db.transaction('rw', db.assets, async () => { return db.assets.clear(); diff --git a/packages/app/src/systems/Asset/utils/isNft.ts b/packages/app/src/systems/Asset/utils/isNft.ts new file mode 100644 index 000000000..4d8c91cff --- /dev/null +++ b/packages/app/src/systems/Asset/utils/isNft.ts @@ -0,0 +1,650 @@ +import { Contract, type Provider } from 'fuels'; + +export const isNft = async ({ + assetId, + contractId, + provider, +}: { assetId: string; contractId: string; provider: Provider }) => { + const contract = new Contract(contractId, SRC_20_ABI, provider); + + const result = await contract + .multiCall([ + contract.functions.total_supply({ bits: assetId }), + contract.functions.decimals({ bits: assetId }), + ]) + .dryRun(); + + const [total_supply, decimals] = result.value; + + /* + according to sway standards this is how you recognize an NFT: + https://docs.fuel.network/docs/sway-standards/src-20-native-asset/#non-fungible-asset-restrictions + */ + return total_supply.toNumber() === 1 && !decimals; +}; + +const SRC_20_ABI = { + programType: 'contract', + specVersion: '1', + encodingVersion: '1', + concreteTypes: [ + { + type: '()', + concreteTypeId: + '2e38e77b22c314a449e91fafed92a43826ac6aa403ae6a8acb6cf58239fbaf5d', + }, + { + type: 'b256', + concreteTypeId: + '7c5ee1cecf5f8eacd1284feb5f0bf2bdea533a51e2f0c9aabe9236d335989f3b', + }, + { + type: 'enum std::identity::Identity', + concreteTypeId: + 'ab7cd04e05be58e3fc15d424c2c4a57f824a2a2d97d67252440a3925ebdc1335', + metadataTypeId: 0, + }, + { + type: 'enum std::option::Option', + concreteTypeId: + '7c06d929390a9aeeb8ffccf8173ac0d101a9976d99dda01cce74541a81e75ac0', + metadataTypeId: 1, + typeArguments: [ + '9a7f1d3e963c10e0a4ea70a8e20a4813d1dc5682e28f74cb102ae50d32f7f98c', + ], + }, + { + type: 'enum std::option::Option', + concreteTypeId: + 'd852149004cc9ec0bbe7dc4e37bffea1d41469b759512b6136f2e865a4c06e7d', + metadataTypeId: 1, + typeArguments: [ + '1506e6f44c1d6291cdf46395a8e573276a4fa79e8ace3fc891e092ef32d1b0a0', + ], + }, + { + type: 'enum std::option::Option', + concreteTypeId: + '2da102c46c7263beeed95818cd7bee801716ba8303dddafdcd0f6c9efda4a0f1', + metadataTypeId: 1, + typeArguments: [ + 'c89951a24c6ca28c13fd1cfdc646b2b656d69e61a92b91023be7eb58eb914b6b', + ], + }, + { + type: 'str', + concreteTypeId: + '8c25cb3686462e9a86d2883c5688a22fe738b0bbc85f458d2d2b5f3f667c6d5a', + }, + { + type: 'str[11]', + concreteTypeId: + '48e8455800b58e79d9db5ac584872b19d307a74a81dcad1d1f9ca34da17e1b31', + }, + { + type: 'str[6]', + concreteTypeId: + 'ed705f920eb2c423c81df912430030def10f03218f0a064bfab81b68de71ae21', + }, + { + type: 'struct std::asset_id::AssetId', + concreteTypeId: + 'c0710b6731b1dd59799cf6bef33eee3b3b04a2e40e80a0724090215bbf2ca974', + metadataTypeId: 5, + }, + { + type: 'struct std::string::String', + concreteTypeId: + '9a7f1d3e963c10e0a4ea70a8e20a4813d1dc5682e28f74cb102ae50d32f7f98c', + metadataTypeId: 9, + }, + { + type: 'u64', + concreteTypeId: + '1506e6f44c1d6291cdf46395a8e573276a4fa79e8ace3fc891e092ef32d1b0a0', + }, + { + type: 'u8', + concreteTypeId: + 'c89951a24c6ca28c13fd1cfdc646b2b656d69e61a92b91023be7eb58eb914b6b', + }, + ], + metadataTypes: [ + { + type: 'enum std::identity::Identity', + metadataTypeId: 0, + components: [ + { + name: 'Address', + typeId: 4, + }, + { + name: 'ContractId', + typeId: 8, + }, + ], + }, + { + type: 'enum std::option::Option', + metadataTypeId: 1, + components: [ + { + name: 'None', + typeId: + '2e38e77b22c314a449e91fafed92a43826ac6aa403ae6a8acb6cf58239fbaf5d', + }, + { + name: 'Some', + typeId: 2, + }, + ], + typeParameters: [2], + }, + { + type: 'generic T', + metadataTypeId: 2, + }, + { + type: 'raw untyped ptr', + metadataTypeId: 3, + }, + { + type: 'struct std::address::Address', + metadataTypeId: 4, + components: [ + { + name: 'bits', + typeId: + '7c5ee1cecf5f8eacd1284feb5f0bf2bdea533a51e2f0c9aabe9236d335989f3b', + }, + ], + }, + { + type: 'struct std::asset_id::AssetId', + metadataTypeId: 5, + components: [ + { + name: 'bits', + typeId: + '7c5ee1cecf5f8eacd1284feb5f0bf2bdea533a51e2f0c9aabe9236d335989f3b', + }, + ], + }, + { + type: 'struct std::bytes::Bytes', + metadataTypeId: 6, + components: [ + { + name: 'buf', + typeId: 7, + }, + { + name: 'len', + typeId: + '1506e6f44c1d6291cdf46395a8e573276a4fa79e8ace3fc891e092ef32d1b0a0', + }, + ], + }, + { + type: 'struct std::bytes::RawBytes', + metadataTypeId: 7, + components: [ + { + name: 'ptr', + typeId: 3, + }, + { + name: 'cap', + typeId: + '1506e6f44c1d6291cdf46395a8e573276a4fa79e8ace3fc891e092ef32d1b0a0', + }, + ], + }, + { + type: 'struct std::contract_id::ContractId', + metadataTypeId: 8, + components: [ + { + name: 'bits', + typeId: + '7c5ee1cecf5f8eacd1284feb5f0bf2bdea533a51e2f0c9aabe9236d335989f3b', + }, + ], + }, + { + type: 'struct std::string::String', + metadataTypeId: 9, + components: [ + { + name: 'bytes', + typeId: 6, + }, + ], + }, + ], + functions: [ + { + inputs: [ + { + name: 'sub_id', + concreteTypeId: + '7c5ee1cecf5f8eacd1284feb5f0bf2bdea533a51e2f0c9aabe9236d335989f3b', + }, + { + name: 'amount', + concreteTypeId: + '1506e6f44c1d6291cdf46395a8e573276a4fa79e8ace3fc891e092ef32d1b0a0', + }, + ], + name: 'burn', + output: + '2e38e77b22c314a449e91fafed92a43826ac6aa403ae6a8acb6cf58239fbaf5d', + attributes: [ + { + name: 'doc-comment', + arguments: [ + ' Unconditionally burns assets sent with the default SubId.', + ], + }, + { + name: 'doc-comment', + arguments: [''], + }, + { + name: 'doc-comment', + arguments: [' # Arguments'], + }, + { + name: 'doc-comment', + arguments: [''], + }, + { + name: 'doc-comment', + arguments: [' * `sub_id`: [SubId] - The default SubId.'], + }, + { + name: 'doc-comment', + arguments: [' * `amount`: [u64] - The quantity of coins to burn.'], + }, + { + name: 'doc-comment', + arguments: [''], + }, + { + name: 'doc-comment', + arguments: [' # Number of Storage Accesses'], + }, + { + name: 'doc-comment', + arguments: [''], + }, + { + name: 'doc-comment', + arguments: [' * Reads: `1`'], + }, + { + name: 'doc-comment', + arguments: [' * Writes: `1`'], + }, + { + name: 'doc-comment', + arguments: [''], + }, + { + name: 'doc-comment', + arguments: [' # Reverts'], + }, + { + name: 'doc-comment', + arguments: [''], + }, + { + name: 'doc-comment', + arguments: [' * When the `sub_id` is not the default SubId.'], + }, + { + name: 'doc-comment', + arguments: [ + ' * When the transaction did not include at least `amount` coins.', + ], + }, + { + name: 'doc-comment', + arguments: [ + ' * When the transaction did not include the asset minted by this contract.', + ], + }, + { + name: 'doc-comment', + arguments: [''], + }, + { + name: 'doc-comment', + arguments: [' # Examples'], + }, + { + name: 'doc-comment', + arguments: [''], + }, + { + name: 'doc-comment', + arguments: [' ```sway'], + }, + { + name: 'doc-comment', + arguments: [' use src3::SRC3;'], + }, + { + name: 'doc-comment', + arguments: [' use std::constants::DEFAULT_SUB_ID;'], + }, + { + name: 'doc-comment', + arguments: [''], + }, + { + name: 'doc-comment', + arguments: [' fn foo(contract_id: ContractId, asset_id: AssetId) {'], + }, + { + name: 'doc-comment', + arguments: [' let contract_abi = abi(SRC3, contract_id);'], + }, + { + name: 'doc-comment', + arguments: [' contract_abi {'], + }, + { + name: 'doc-comment', + arguments: [' gas: 10000,'], + }, + { + name: 'doc-comment', + arguments: [' coins: 100,'], + }, + { + name: 'doc-comment', + arguments: [' asset_id: asset_id,'], + }, + { + name: 'doc-comment', + arguments: [' }.burn(DEFAULT_SUB_ID, 100);'], + }, + { + name: 'doc-comment', + arguments: [' }'], + }, + { + name: 'doc-comment', + arguments: [' ```'], + }, + { + name: 'payable', + arguments: [], + }, + { + name: 'storage', + arguments: ['read', 'write'], + }, + ], + }, + { + inputs: [ + { + name: 'recipient', + concreteTypeId: + 'ab7cd04e05be58e3fc15d424c2c4a57f824a2a2d97d67252440a3925ebdc1335', + }, + { + name: 'sub_id', + concreteTypeId: + '7c5ee1cecf5f8eacd1284feb5f0bf2bdea533a51e2f0c9aabe9236d335989f3b', + }, + { + name: 'amount', + concreteTypeId: + '1506e6f44c1d6291cdf46395a8e573276a4fa79e8ace3fc891e092ef32d1b0a0', + }, + ], + name: 'mint', + output: + '2e38e77b22c314a449e91fafed92a43826ac6aa403ae6a8acb6cf58239fbaf5d', + attributes: [ + { + name: 'doc-comment', + arguments: [ + ' Unconditionally mints new assets using the default SubId.', + ], + }, + { + name: 'doc-comment', + arguments: [''], + }, + { + name: 'doc-comment', + arguments: [' # Arguments'], + }, + { + name: 'doc-comment', + arguments: [''], + }, + { + name: 'doc-comment', + arguments: [ + ' * `recipient`: [Identity] - The user to which the newly minted asset is transferred to.', + ], + }, + { + name: 'doc-comment', + arguments: [' * `sub_id`: [SubId] - The default SubId.'], + }, + { + name: 'doc-comment', + arguments: [' * `amount`: [u64] - The quantity of coins to mint.'], + }, + { + name: 'doc-comment', + arguments: [''], + }, + { + name: 'doc-comment', + arguments: [' # Number of Storage Accesses'], + }, + { + name: 'doc-comment', + arguments: [''], + }, + { + name: 'doc-comment', + arguments: [' * Reads: `1`'], + }, + { + name: 'doc-comment', + arguments: [' * Writes: `1`'], + }, + { + name: 'doc-comment', + arguments: [''], + }, + { + name: 'doc-comment', + arguments: [' # Reverts'], + }, + { + name: 'doc-comment', + arguments: [''], + }, + { + name: 'doc-comment', + arguments: [' * When the `sub_id` is not the default SubId.'], + }, + { + name: 'doc-comment', + arguments: [''], + }, + { + name: 'doc-comment', + arguments: [' # Examples'], + }, + { + name: 'doc-comment', + arguments: [''], + }, + { + name: 'doc-comment', + arguments: [' ```sway'], + }, + { + name: 'doc-comment', + arguments: [' use src3::SRC3;'], + }, + { + name: 'doc-comment', + arguments: [' use std::constants::DEFAULT_SUB_ID;'], + }, + { + name: 'doc-comment', + arguments: [''], + }, + { + name: 'doc-comment', + arguments: [' fn foo(contract_id: ContractId) {'], + }, + { + name: 'doc-comment', + arguments: [' let contract_abi = abi(SRC3, contract);'], + }, + { + name: 'doc-comment', + arguments: [ + ' contract_abi.mint(Identity::ContractId(contract_id), DEFAULT_SUB_ID, 100);', + ], + }, + { + name: 'doc-comment', + arguments: [' }'], + }, + { + name: 'doc-comment', + arguments: [' ```'], + }, + { + name: 'storage', + arguments: ['read', 'write'], + }, + ], + }, + { + inputs: [ + { + name: 'asset', + concreteTypeId: + 'c0710b6731b1dd59799cf6bef33eee3b3b04a2e40e80a0724090215bbf2ca974', + }, + ], + name: 'decimals', + output: + '2da102c46c7263beeed95818cd7bee801716ba8303dddafdcd0f6c9efda4a0f1', + attributes: [ + { + name: 'storage', + arguments: ['read'], + }, + ], + }, + { + inputs: [ + { + name: 'asset', + concreteTypeId: + 'c0710b6731b1dd59799cf6bef33eee3b3b04a2e40e80a0724090215bbf2ca974', + }, + ], + name: 'name', + output: + '7c06d929390a9aeeb8ffccf8173ac0d101a9976d99dda01cce74541a81e75ac0', + attributes: [ + { + name: 'storage', + arguments: ['read'], + }, + ], + }, + { + inputs: [ + { + name: 'asset', + concreteTypeId: + 'c0710b6731b1dd59799cf6bef33eee3b3b04a2e40e80a0724090215bbf2ca974', + }, + ], + name: 'symbol', + output: + '7c06d929390a9aeeb8ffccf8173ac0d101a9976d99dda01cce74541a81e75ac0', + attributes: [ + { + name: 'storage', + arguments: ['read'], + }, + ], + }, + { + inputs: [], + name: 'total_assets', + output: + '1506e6f44c1d6291cdf46395a8e573276a4fa79e8ace3fc891e092ef32d1b0a0', + attributes: [ + { + name: 'storage', + arguments: ['read'], + }, + ], + }, + { + inputs: [ + { + name: 'asset', + concreteTypeId: + 'c0710b6731b1dd59799cf6bef33eee3b3b04a2e40e80a0724090215bbf2ca974', + }, + ], + name: 'total_supply', + output: + 'd852149004cc9ec0bbe7dc4e37bffea1d41469b759512b6136f2e865a4c06e7d', + attributes: [ + { + name: 'storage', + arguments: ['read'], + }, + ], + }, + ], + loggedTypes: [ + { + logId: '10098701174489624218', + concreteTypeId: + '8c25cb3686462e9a86d2883c5688a22fe738b0bbc85f458d2d2b5f3f667c6d5a', + }, + ], + messagesTypes: [], + configurables: [ + { + name: 'DECIMALS', + concreteTypeId: + 'c89951a24c6ca28c13fd1cfdc646b2b656d69e61a92b91023be7eb58eb914b6b', + offset: 15736, + }, + { + name: 'NAME', + concreteTypeId: + '48e8455800b58e79d9db5ac584872b19d307a74a81dcad1d1f9ca34da17e1b31', + offset: 15744, + }, + { + name: 'SYMBOL', + concreteTypeId: + 'ed705f920eb2c423c81df912430030def10f03218f0a064bfab81b68de71ae21', + offset: 15760, + }, + ], +}; diff --git a/packages/app/src/systems/CRX/background/services/BackgroundService.ts b/packages/app/src/systems/CRX/background/services/BackgroundService.ts index b519124ab..9c1a980c0 100644 --- a/packages/app/src/systems/CRX/background/services/BackgroundService.ts +++ b/packages/app/src/systems/CRX/background/services/BackgroundService.ts @@ -458,7 +458,7 @@ export class BackgroundService { ): Promise { await NetworkService.validateNetworkExists(input.network); const { isSelected, network } = await NetworkService.validateNetworkSelect({ - chainId: undefined, + chainId: input.network.chainId, url: input.network.url, }); if (isSelected) { diff --git a/packages/app/src/systems/CRX/background/services/types.ts b/packages/app/src/systems/CRX/background/services/types.ts index 3681212dd..3b09f45a3 100644 --- a/packages/app/src/systems/CRX/background/services/types.ts +++ b/packages/app/src/systems/CRX/background/services/types.ts @@ -40,10 +40,10 @@ export type MessageInputs = { contractId: string; }; selectNetwork: { - network: SelectNetworkArguments; + network: NetworkData; }; addNetwork: { - network: Pick; + network: NetworkData; }; }; diff --git a/packages/app/src/systems/Core/components/ControlledField/ControlledField.tsx b/packages/app/src/systems/Core/components/ControlledField/ControlledField.tsx index 0a9529a7c..8ef392b79 100644 --- a/packages/app/src/systems/Core/components/ControlledField/ControlledField.tsx +++ b/packages/app/src/systems/Core/components/ControlledField/ControlledField.tsx @@ -1,5 +1,5 @@ import type { ThemeUtilsCSS } from '@fuel-ui/css'; -import { Form } from '@fuel-ui/react'; +import { Form, Tooltip } from '@fuel-ui/react'; import { mergeRefs } from '@react-aria/utils'; import type { ReactElement, ReactNode } from 'react'; import { forwardRef, useId } from 'react'; @@ -30,6 +30,7 @@ export type ControlledFieldProps = Omit, 'render'> & { render: (props: RenderProps) => ReactElement; hideError?: boolean; warning?: string; + tooltipContent?: string; }; // biome-ignore lint/suspicious/noExplicitAny: @@ -48,6 +49,7 @@ export const ControlledField = forwardRef( isReadOnly, hideError, warning, + tooltipContent, }, ref ) => { @@ -68,7 +70,13 @@ export const ControlledField = forwardRef( return ( {label && labelSide === 'left' && ( - {label} + + {label} + )} {render({ ...props, @@ -80,7 +88,13 @@ export const ControlledField = forwardRef( }, })} {label && labelSide === 'right' && ( - {label} + + {label} + )} {!hideError && props.fieldState.error && ( diff --git a/packages/app/src/systems/Core/utils/database.ts b/packages/app/src/systems/Core/utils/database.ts index 089dfa8ff..848a8d399 100644 --- a/packages/app/src/systems/Core/utils/database.ts +++ b/packages/app/src/systems/Core/utils/database.ts @@ -24,6 +24,7 @@ export class FuelDB extends Dexie { connections!: Table; transactionsCursors!: Table; assets!: Table; + indexedAssets!: Table; abis!: Table; errors!: Table; integrityCheckInterval?: NodeJS.Timeout; @@ -91,6 +92,7 @@ export class FuelDB extends Dexie { this.connections.clear(), this.transactionsCursors.clear(), this.assets.clear(), + this.indexedAssets.clear(), this.abis.clear(), this.errors.clear(), ]); diff --git a/packages/app/src/systems/Core/utils/databaseVersioning.ts b/packages/app/src/systems/Core/utils/databaseVersioning.ts index 5dc153477..9389dbeec 100644 --- a/packages/app/src/systems/Core/utils/databaseVersioning.ts +++ b/packages/app/src/systems/Core/utils/databaseVersioning.ts @@ -187,6 +187,7 @@ export const applyDbVersioning = (db: Dexie) => { }); // DB VERSION 26 + // cleanup networks and add defaults db.version(26) .stores({ vaults: 'key', @@ -217,4 +218,18 @@ export const applyDbVersioning = (db: Dexie) => { }); } }); + + // DB VERSION 27 + // add indexedAssets table + db.version(27).stores({ + vaults: 'key', + accounts: '&address, &name', + networks: '&id, &url, &name, chainId', + connections: 'origin', + transactionsCursors: '++id, address, size, providerUrl, endCursor', + assets: '&name, &symbol', + indexedAssets: 'key', + abis: '&contractId', + errors: '&id', + }); }; diff --git a/packages/app/src/systems/FundWallet/hooks/useFundWallet.tsx b/packages/app/src/systems/FundWallet/hooks/useFundWallet.tsx index 7ebe412d6..02363748a 100644 --- a/packages/app/src/systems/FundWallet/hooks/useFundWallet.tsx +++ b/packages/app/src/systems/FundWallet/hooks/useFundWallet.tsx @@ -15,8 +15,8 @@ export function useFundWallet() { (n) => n.url === selectedNetwork?.url ); return { - bridgeUrl: network?.bridgeUrl ?? null, - faucetUrl: network?.faucetUrl ?? null, + bridgeUrl: (network?.bridgeUrl || selectedNetwork?.bridgeUrl) ?? null, + faucetUrl: (network?.faucetUrl || selectedNetwork?.faucetUrl) ?? null, }; }, [selectedNetwork]); diff --git a/packages/app/src/systems/Network/components/NetworkForm/NetworkForm.stories.tsx b/packages/app/src/systems/Network/components/NetworkForm/NetworkForm.stories.tsx index bfadf1ce9..19cae414d 100644 --- a/packages/app/src/systems/Network/components/NetworkForm/NetworkForm.stories.tsx +++ b/packages/app/src/systems/Network/components/NetworkForm/NetworkForm.stories.tsx @@ -11,7 +11,7 @@ export default { }; export const Usage = () => { - const form = useNetworkForm(); + const form = useNetworkForm({}); return ( diff --git a/packages/app/src/systems/Network/components/NetworkForm/NetworkForm.tsx b/packages/app/src/systems/Network/components/NetworkForm/NetworkForm.tsx index cefee853f..0eebab47d 100644 --- a/packages/app/src/systems/Network/components/NetworkForm/NetworkForm.tsx +++ b/packages/app/src/systems/Network/components/NetworkForm/NetworkForm.tsx @@ -1,10 +1,19 @@ import { cssObj } from '@fuel-ui/css'; -import { Box, Button, HelperIcon, Input, Spinner } from '@fuel-ui/react'; +import { + Box, + Button, + Checkbox, + Form, + HelperIcon, + Input, + Tooltip, +} from '@fuel-ui/react'; import { motion } from 'framer-motion'; import { ControlledField, animations } from '~/systems/Core'; import { NetworkReviewCard } from '~/systems/Network'; import { useEffect, useState } from 'react'; +import { useWatch } from 'react-hook-form'; import type { UseNetworkFormReturn } from '../../hooks'; const MotionInput = motion(Input); @@ -15,51 +24,44 @@ export type NetworkFormProps = { isEditing: boolean; isLoading?: boolean; onClickReview?: () => void; - isValidUrl?: boolean; + isValid?: boolean; + providerChainId?: number; + isReviewing?: boolean; + chainName?: string; }; export function NetworkForm({ form, isEditing, isLoading, - onClickReview, - isValidUrl, + isValid, + isReviewing, + chainName, }: NetworkFormProps) { - const [isFirstClickedReview, setIsFirstClickedReview] = useState(false); const [isFirstShownTestConnectionBtn, setIsFirstShownTestConnectionBtn] = useState(false); - const { control, formState, getValues } = form; + const { control, formState } = form; - const name = getValues('name'); - const url = getValues('url'); - const showReview = !isEditing && name; - - function onChangeUrl() { - form.setValue('name', '', { shouldValidate: true }); - } - - function onClickCheckNetwork() { - setIsFirstClickedReview(true); - onClickReview?.(); - } + const url = useWatch({ control, name: 'url' }); + const chainId = useWatch({ control, name: 'chainId' }); useEffect(() => { - if (isValidUrl) { + if (isValid && chainId) { setIsFirstShownTestConnectionBtn(true); } - }, [isValidUrl]); + }, [isValid, chainId]); return ( - {showReview && ( + {isReviewing && ( )} - {!showReview && ( + {!isReviewing && ( <> } - hideError={!isFirstClickedReview} render={({ field }) => ( + + )} + /> + + Chain ID + + } + render={({ field }) => ( + + )} /> + {!!formState.errors?.chainId && ( + + {formState.errors?.chainId?.message} + + )} {!isEditing && isFirstShownTestConnectionBtn && ( )} + {isEditing && ( <> ( - - - -); diff --git a/packages/app/src/systems/Network/components/NetworkList/NetworkList.test.tsx b/packages/app/src/systems/Network/components/NetworkList/NetworkList.test.tsx deleted file mode 100644 index c9f2b7e31..000000000 --- a/packages/app/src/systems/Network/components/NetworkList/NetworkList.test.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { render, screen } from '@fuel-ui/test-utils'; -import { uniqueId } from 'xstate/lib/utils'; -import { TestWrapper } from '~/systems/Core/components/TestWrapper'; - -import { MOCK_NETWORKS } from '../../__mocks__/networks'; - -import { NetworkList } from './NetworkList'; - -const NETWORKS = MOCK_NETWORKS.map((i) => ({ ...i, id: uniqueId() })); - -describe('NetworkList', () => { - it('should render a list of networks', () => { - render(, { wrapper: TestWrapper }); - expect(screen.getByText(NETWORKS[0].name)).toBeInTheDocument(); - expect(screen.getByText(NETWORKS[1].name)).toBeInTheDocument(); - }); -}); diff --git a/packages/app/src/systems/Network/components/NetworkList/NetworkList.tsx b/packages/app/src/systems/Network/components/NetworkList/NetworkList.tsx deleted file mode 100644 index 4f5d9a466..000000000 --- a/packages/app/src/systems/Network/components/NetworkList/NetworkList.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { CardList } from '@fuel-ui/react'; -import type { NetworkData } from '@fuel-wallet/types'; - -import type { NetworkItemProps } from '../NetworkItem'; -import { NetworkItem } from '../NetworkItem'; - -export type NetworkListProps = Omit & { - networks: NetworkData[]; -}; - -export function NetworkList({ networks = [], ...props }: NetworkListProps) { - return ( - - {networks.map((network) => ( - - ))} - - ); -} diff --git a/packages/app/src/systems/Network/components/NetworkList/index.tsx b/packages/app/src/systems/Network/components/NetworkList/index.tsx deleted file mode 100644 index cc5128656..000000000 --- a/packages/app/src/systems/Network/components/NetworkList/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export * from './NetworkList'; diff --git a/packages/app/src/systems/Network/components/NetworkReviewCard/NetworkReviewCard.tsx b/packages/app/src/systems/Network/components/NetworkReviewCard/NetworkReviewCard.tsx index 260cbcda2..c86e5af91 100644 --- a/packages/app/src/systems/Network/components/NetworkReviewCard/NetworkReviewCard.tsx +++ b/packages/app/src/systems/Network/components/NetworkReviewCard/NetworkReviewCard.tsx @@ -1,5 +1,5 @@ import { cssObj } from '@fuel-ui/css'; -import { Button, Card, Text } from '@fuel-ui/react'; +import { Card, Text } from '@fuel-ui/react'; import { motion } from 'framer-motion'; import { animations } from '~/systems/Core'; @@ -8,31 +8,31 @@ const MotionCard = motion(Card); export type NetworkReviewCardProps = { headerText: string; name: string; - onChangeUrl?: () => void; + chainId?: number | string; url: string; }; export function NetworkReviewCard({ headerText, name, - onChangeUrl, + chainId, url, }: NetworkReviewCardProps) { return ( {headerText} - {onChangeUrl && ( - - )} {name} {url} + {chainId && ( + + Chain ID: {chainId} + + )} ); diff --git a/packages/app/src/systems/Network/components/index.tsx b/packages/app/src/systems/Network/components/index.tsx index 4a152cb7a..e05c7a226 100644 --- a/packages/app/src/systems/Network/components/index.tsx +++ b/packages/app/src/systems/Network/components/index.tsx @@ -1,6 +1,5 @@ export * from './NetworkDropdown'; export * from './NetworkForm'; export * from './NetworkItem'; -export * from './NetworkList'; export * from './NetworkReviewCard'; export * from './NetworkSelector'; diff --git a/packages/app/src/systems/Network/events.tsx b/packages/app/src/systems/Network/events.tsx index f28e37713..43b3bf1aa 100644 --- a/packages/app/src/systems/Network/events.tsx +++ b/packages/app/src/systems/Network/events.tsx @@ -12,6 +12,12 @@ export function networkEvents(store: Store) { input, }); }, + validateAddNetwork(input: NetworkInputs['validateAddNetwork']) { + store.send(Services.networks, { + type: 'VALIDATE_ADD_NETWORK', + input, + }); + }, editNetwork(input: NetworkInputs['editNetwork']) { store.send(Services.networks, { type: 'EDIT_NETWORK', @@ -24,6 +30,9 @@ export function networkEvents(store: Store) { input, }); }, + clearChainInfo() { + store.send(Services.networks, { type: 'CLEAR_CHAIN_INFO' }); + }, removeNetwork(network: NetworkData) { store.send(Services.networks, { type: 'REMOVE_NETWORK', diff --git a/packages/app/src/systems/Network/hooks/useChainInfo.ts b/packages/app/src/systems/Network/hooks/useChainInfo.ts index 710849a05..1b06dddb8 100644 --- a/packages/app/src/systems/Network/hooks/useChainInfo.ts +++ b/packages/app/src/systems/Network/hooks/useChainInfo.ts @@ -25,6 +25,13 @@ export function useChainInfo(providerUrl?: string) { [] ); + const clearChainInfo = useCallback( + debounce(() => { + send('CLEAR_CHAIN_INFO'); + }, 750), + [] + ); + // biome-ignore lint/correctness/useExhaustiveDependencies: useEffect(() => { if (providerUrl) { @@ -34,5 +41,10 @@ export function useChainInfo(providerUrl?: string) { } }, [providerUrl]); - return { chainInfo, error, isLoading, handlers: { fetchChainInfo } }; + return { + chainInfo, + error, + isLoading, + handlers: { fetchChainInfo, clearChainInfo }, + }; } diff --git a/packages/app/src/systems/Network/hooks/useNetworkForm.ts b/packages/app/src/systems/Network/hooks/useNetworkForm.ts index 327d0490c..f380393b7 100644 --- a/packages/app/src/systems/Network/hooks/useNetworkForm.ts +++ b/packages/app/src/systems/Network/hooks/useNetworkForm.ts @@ -6,26 +6,53 @@ import type { Maybe } from '~/systems/Core'; import { isValidNetworkUrl } from '../utils'; -export type NetworkFormValues = { - name: string; - url: string; - explorerUrl?: string; -}; +export type NetworkFormValues = yup.InferType; const schema = yup .object({ - name: yup.string().required('Name is required'), + name: yup + .string() + .test('is-required', 'Name is required', function (value) { + return !this.options?.context?.isEditing || !!value; + }), url: yup .string() .test('is-url-valid', 'URL is not valid', isValidNetworkUrl) + .test('is-network-valid', 'Network is not valid', function (url) { + return ( + !url || this.options.context?.chainInfoError !== 'Invalid network URL' + ); + }) .required('URL is required'), explorerUrl: yup .string() - .test('is-url-valid', 'Explorer URL is not valid', (url) => { - if (!url) return true; - return isValidNetworkUrl(url); - }) + .test( + 'is-url-valid', + 'Explorer URL is not valid', + (url) => !url || isValidNetworkUrl(url) + ) .optional(), + chainId: yup + .mixed() + .transform((value) => + value != null && value !== '' ? Number(value) : undefined + ) + .required('Chain ID is required') + .test( + 'chainId-match', + 'Informed Chain ID does not match the network Chain ID.', + function (value) { + return ( + !value || + this.options.context?.chainInfoError !== `Chain ID doesn't match` + ); + } + ) + .test( + 'is-numbers-only', + 'Chain ID must contain only numbers', + (value) => value == null || Number.isInteger(value) + ), }) .required(); @@ -33,26 +60,37 @@ const DEFAULT_VALUES = { name: '', url: '', explorerUrl: '', + chainId: undefined, }; export type UseNetworkFormReturn = ReturnType; export type UseAddNetworkOpts = { defaultValues?: Maybe; + context?: { + providerChainId?: number; + isEditing?: boolean; + chainInfoError?: string; + }; }; -export function useNetworkForm(opts: UseAddNetworkOpts = {}) { +export function useNetworkForm({ defaultValues, context }: UseAddNetworkOpts) { const form = useForm({ - resolver: yupResolver(schema), + resolver: yupResolver(schema), reValidateMode: 'onChange', - mode: 'onChange', - defaultValues: opts.defaultValues || DEFAULT_VALUES, + mode: 'all', + resetOptions: { + keepValues: true, + }, + defaultValues: defaultValues || DEFAULT_VALUES, + context, }); - // biome-ignore lint/correctness/useExhaustiveDependencies: useEffect(() => { - opts.defaultValues && form.reset(opts.defaultValues); - }, [opts.defaultValues?.name, opts.defaultValues?.url]); + if (defaultValues) { + form.reset(defaultValues); + } + }, [defaultValues, form]); return form; } diff --git a/packages/app/src/systems/Network/hooks/useNetworks.ts b/packages/app/src/systems/Network/hooks/useNetworks.ts index 70cc584d0..d11d5e677 100644 --- a/packages/app/src/systems/Network/hooks/useNetworks.ts +++ b/packages/app/src/systems/Network/hooks/useNetworks.ts @@ -24,6 +24,18 @@ const selectors = { (id && state.context?.networks?.find((n) => n.id === id)) || undefined ); }, + reviewingAddNetwork: (state: NetworksMachineState) => { + return state.hasTag('reviewingAddNetwork'); + }, + chainInfoToAdd: (state: NetworksMachineState) => { + return state.context?.chainInfoToAdd; + }, + loadingChainInfo: (state: NetworksMachineState) => { + return state.hasTag('loadingChainInfo'); + }, + chainInfoError: (state: NetworksMachineState) => { + return state.context?.chainInfoError; + }, }; export function useNetworks() { @@ -33,6 +45,14 @@ export function useNetworks() { const networks = store.useSelector(Services.networks, selectors.networks); const network = store.useSelector(Services.networks, selectors.network); const isLoading = store.useSelector(Services.networks, selectors.isLoading); + const isLoadingChainInfo = store.useSelector( + Services.networks, + selectors.loadingChainInfo + ); + const isReviewingAddNetwork = store.useSelector( + Services.networks, + selectors.reviewingAddNetwork + ); const editingNetwork = store.useSelector( Services.networks, useMemo( @@ -44,6 +64,14 @@ export function useNetworks() { Services.networks, selectors.selectedNetwork ); + const chainInfoToAdd = store.useSelector( + Services.networks, + selectors.chainInfoToAdd + ); + const chainInfoError = store.useSelector( + Services.networks, + selectors.chainInfoError + ); const selectedNetwork = useMemo(() => { const networkFromDefault = DEFAULT_NETWORKS.find( @@ -89,17 +117,23 @@ export function useNetworks() { handlers: { closeDialog, goToUpdate, + validateAddNetwork: store.validateAddNetwork, addNetwork: store.addNetwork, openNetworks: store.openNetworksList, openNetworksAdd: store.openNetworksAdd, removeNetwork: store.removeNetwork, selectNetwork: store.selectNetwork, updateNetwork: store.updateNetwork, + clearChainInfo: store.clearChainInfo, }, isLoading, + isLoadingChainInfo, + isReviewingAddNetwork, + chainInfoToAdd, selectedNetwork, editingNetwork, network, networks, + chainInfoError, }; } diff --git a/packages/app/src/systems/Network/machines/chainInfoMachine.ts b/packages/app/src/systems/Network/machines/chainInfoMachine.ts index 7ff3acfd5..336ac490d 100644 --- a/packages/app/src/systems/Network/machines/chainInfoMachine.ts +++ b/packages/app/src/systems/Network/machines/chainInfoMachine.ts @@ -84,6 +84,7 @@ export const chainInfoMachine = createMachine( }), clearChainInfo: assign({ chainInfo: undefined, + error: undefined, }), assignError: assign({ error: (_, ev) => (ev.data.error as Error).message, diff --git a/packages/app/src/systems/Network/machines/networksMachine.test.ts b/packages/app/src/systems/Network/machines/networksMachine.test.ts deleted file mode 100644 index b72f89c2c..000000000 --- a/packages/app/src/systems/Network/machines/networksMachine.test.ts +++ /dev/null @@ -1,180 +0,0 @@ -import type { NetworkData } from '@fuel-wallet/types'; -import { interpret } from 'xstate'; -import { waitFor } from 'xstate/lib/waitFor'; -import { expectStateMatch } from '~/systems/Core/__tests__/utils'; - -import { MOCK_NETWORKS } from '../__mocks__/networks'; -import { NetworkService } from '../services'; - -import type { NetworksMachineService } from './networksMachine'; -import { networksMachine } from './networksMachine'; - -const NETWORK = MOCK_NETWORKS[0]; - -const machine = networksMachine - .withConfig({ - actions: { - redirectToList() {}, - redirectToHome() {}, - notifyUpdateAccounts() {}, - }, - delays: {}, - } as Parameters<(typeof networksMachine)['withConfig']>[0]) - .withContext({}); - -describe('networksMachine', () => { - let service: NetworksMachineService; - let state: ReturnType; - - beforeEach(async () => { - await NetworkService.clearNetworks(); - await NetworkService.addNetwork({ data: NETWORK }); - service = interpret(machine).start(); - state = service.getSnapshot(); - }); - - afterEach(() => { - service.stop(); - state = service.getSnapshot(); - }); - - it('should be on fetchingNetworks state by default', () => { - expect(state.context.networks).toBeUndefined(); - expect(state.value).toBe('fetchingNetworks'); - expect(state.hasTag('loading')).toBeTruthy(); - }); - - describe('list', () => { - it('should fetch list of networks', async () => { - state = await expectStateMatch(service, 'idle'); - expect(state.context.networks?.length).toBe(1); - }); - - it('should have one network selected in context', async () => { - state = await expectStateMatch(service, 'idle'); - expect(state.context.network).toBeDefined(); - }); - }); - - describe('add', () => { - // biome-ignore lint/suspicious/noExplicitAny: - const addEv: any = { - type: 'ADD_NETWORK', - input: { - data: MOCK_NETWORKS[1], - }, - }; - - it('should be able to remove a network', async () => { - await expectStateMatch(service, 'idle'); - - service.send(addEv); - state = await expectStateMatch(service, 'idle'); - const networks = state.context.networks || []; - - // biome-ignore lint/suspicious/noExplicitAny: - const removeEv: any = { - type: 'REMOVE_NETWORK', - input: { id: networks[1]?.id }, - }; - - const nextState = service.nextState(removeEv); - expect(nextState.value).toBe('removingNetwork'); - - service.send(removeEv); - state = await expectStateMatch(service, 'idle'); - expect(state.context.networks?.length).toBe(1); - }); - - it('should be able to add a new network', async () => { - state = await expectStateMatch(service, 'idle'); - - const nextState = service.nextState(addEv); - expect(nextState.value).toBe('addingNetwork'); - expect(nextState.hasTag('loading')).toBeTruthy(); - - service.send(addEv); - state = await expectStateMatch(service, 'idle'); - const networks = state.context.networks || []; - expect(networks?.length).toBe(2); - const networkId = networks?.[1].id; - await NetworkService.removeNetwork({ id: networkId as string }); - }); - - it('should be able to select a new network', async () => { - await expectStateMatch(service, 'idle'); - - service.send(addEv); - state = await expectStateMatch(service, 'idle'); - - let networks = state.context.networks || []; - const idx = networks.findIndex((n) => n.id === state.context.network?.id); - if (idx === -1) throw new Error('Network ID not found'); - const invertIdx = idx === 0 ? 1 : 0; - expect(networks[idx]?.isSelected).toBeTruthy(); - expect(networks[invertIdx]?.isSelected).toBeFalsy(); - // biome-ignore lint/suspicious/noExplicitAny: - const selectEv: any = { - type: 'SELECT_NETWORK', - input: { id: networks[invertIdx]?.id }, - }; - - const nextState = service.nextState(selectEv); - expect(nextState.value).toBe('selectingNetwork'); - - service.send(selectEv); - await expectStateMatch(service, 'selectingNetwork'); - state = await expectStateMatch(service, 'idle'); - networks = state.context.networks || []; - expect(networks[idx]?.isSelected).toBeFalsy(); - expect(networks[invertIdx]?.isSelected).toBeTruthy(); - }); - }); - - describe('update', () => { - let network: NetworkData | undefined; - // biome-ignore lint/suspicious/noExplicitAny: - let editEv: any; - - beforeEach(async () => { - editEv = { - type: 'EDIT_NETWORK', - input: { - // biome-ignore lint/suspicious/noExplicitAny: - id: network?.id as any, - }, - }; - }); - - it('should have networkId and network save on context', async () => { - service.send(editEv); - expect(state.context.network).toBeUndefined(); - - state = await waitFor(service, (state) => state.matches('idle')); - expect(state.context.network).toBeDefined(); - }); - - it('should be able to update a network', async () => { - state = await expectStateMatch(service, 'idle'); - const networks = state.context.networks || []; - - // biome-ignore lint/suspicious/noExplicitAny: - const updateEv: any = { - type: 'UPDATE_NETWORK', - input: { - id: networks[0].id, - data: { - name: 'Test', - }, - }, - }; - - const nextState = service.nextState(updateEv); - expect(nextState.value).toBe('updatingNetwork'); - - service.send(updateEv); - state = await waitFor(service, (state) => state.matches('idle')); - expect(state.context.networks?.[0].name).toBe('Test'); - }); - }); -}); diff --git a/packages/app/src/systems/Network/machines/networksMachine.ts b/packages/app/src/systems/Network/machines/networksMachine.ts index e26e68f0d..62f91cca7 100644 --- a/packages/app/src/systems/Network/machines/networksMachine.ts +++ b/packages/app/src/systems/Network/machines/networksMachine.ts @@ -9,19 +9,22 @@ import { store } from '~/store'; import type { FetchResponse, Maybe } from '~/systems/Core'; import { FetchMachine } from '~/systems/Core'; -import { createProvider } from '@fuel-wallet/connections'; +import type { ChainInfo } from 'fuels'; import { type NetworkInputs, NetworkService } from '../services'; type MachineContext = { networks?: NetworkData[]; network?: Maybe; error?: unknown; + chainInfoToAdd?: ChainInfo; + chainInfoError?: string; }; export type AddNetworkInput = { data: { name: string; url: string; + chainId: number; }; }; @@ -48,6 +51,8 @@ type MachineServices = { type MachineEvents = | { type: 'ADD_NETWORK'; input: AddNetworkInput } + | { type: 'VALIDATE_ADD_NETWORK'; input: NetworkInputs['validateAddNetwork'] } + | { type: 'CLEAR_CHAIN_INFO'; input?: null } | { type: 'EDIT_NETWORK'; input: NetworkInputs['editNetwork'] } | { type: 'UPDATE_NETWORK'; input: NetworkInputs['updateNetwork'] } | { type: 'REMOVE_NETWORK'; input: NetworkInputs['removeNetwork'] } @@ -84,8 +89,11 @@ export const networksMachine = createMachine( }, idle: { on: { - ADD_NETWORK: { - target: 'addingNetwork', + VALIDATE_ADD_NETWORK: { + target: 'validatingAddNetwork', + }, + CLEAR_CHAIN_INFO: { + actions: ['clearChainInfoToAdd', 'clearChainError'], }, EDIT_NETWORK: { target: 'fetchingNetworks', @@ -101,8 +109,36 @@ export const networksMachine = createMachine( }, }, }, + validatingAddNetwork: { + tags: ['loadingChainInfo'], + invoke: { + src: 'validateAddNetwork', + data: { + input: (_: MachineContext, ev: MachineEvents) => ev.input, + }, + onDone: [ + { + target: 'idle', + cond: FetchMachine.hasError, + actions: ['assignChainInfoError'], + }, + { + target: 'waitingAddNetwork', + actions: ['assignChainInfo'], + }, + ], + }, + }, + waitingAddNetwork: { + tags: ['reviewingAddNetwork'], + on: { + ADD_NETWORK: { + target: 'addingNetwork', + }, + }, + }, addingNetwork: { - tags: ['loading'], + tags: ['reviewingAddNetwork', 'loading'], invoke: { src: 'addNetwork', data: { @@ -285,6 +321,23 @@ export const networksMachine = createMachine( notifyUpdateAccounts: () => { store.updateAccounts(); }, + assignChainInfo: assign({ + chainInfoToAdd: (_, ev) => { + return ev.data as ChainInfo; + }, + }), + assignChainInfoError: assign({ + chainInfoError: (_, ev) => { + // biome-ignore lint/suspicious/noExplicitAny: + return (ev.data as any)?.error?.message || ''; + }, + }), + clearChainInfoToAdd: assign({ + chainInfoToAdd: undefined, + }), + clearChainError: assign({ + chainInfoError: undefined, + }), }, services: { fetchNetworks: FetchMachine.create({ @@ -294,6 +347,37 @@ export const networksMachine = createMachine( return networks; }, }), + validateAddNetwork: FetchMachine.create< + NetworkInputs['validateAddNetwork'], + ChainInfo + >({ + maxAttempts: 1, + showError: false, + async fetch({ input }) { + if (!input?.url) { + throw new Error('Inputs not provided'); + } + + let chainInfoToAdd: ChainInfo | undefined; + try { + chainInfoToAdd = await NetworkService.getChainInfo({ + providerUrl: input.url, + }); + } catch (_) { + throw new Error('Invalid network URL'); + } + + if ( + !chainInfoToAdd || + input.chainId !== + chainInfoToAdd.consensusParameters.chainId.toString(10) + ) { + throw new Error(`Chain ID doesn't match`); + } + + return chainInfoToAdd; + }, + }), addNetwork: FetchMachine.create({ showError: true, async fetch({ input }) { @@ -303,8 +387,7 @@ export const networksMachine = createMachine( await NetworkService.validateNetworkExists(input.data); await NetworkService.validateNetworkVersion(input.data); - const provider = await createProvider(input.data.url); - const chainId = provider.getChainId(); + const chainId = input.data?.chainId; const createdNetwork = await NetworkService.addNetwork({ data: { diff --git a/packages/app/src/systems/Network/pages/AddNetwork/AddNetwork.tsx b/packages/app/src/systems/Network/pages/AddNetwork/AddNetwork.tsx index 96224426a..b31643cf8 100644 --- a/packages/app/src/systems/Network/pages/AddNetwork/AddNetwork.tsx +++ b/packages/app/src/systems/Network/pages/AddNetwork/AddNetwork.tsx @@ -1,55 +1,75 @@ import { Box, Button, Dialog, Focus, Icon } from '@fuel-ui/react'; import { motion } from 'framer-motion'; -import { useEffect } from 'react'; +import { useEffect, useMemo } from 'react'; +import { useWatch } from 'react-hook-form'; import { animations, styles } from '~/systems/Core'; import type { NetworkFormValues } from '~/systems/Network'; -import { - NetworkForm, - useChainInfo, - useNetworkForm, - useNetworks, -} from '~/systems/Network'; +import { NetworkForm, useNetworkForm, useNetworks } from '~/systems/Network'; import { OverlayDialogTopbar } from '~/systems/Overlay'; -// biome-ignore lint/suspicious/noExplicitAny: -const MotionStack = motion(Box.Stack); +const MotionStack = motion(Box.Stack); export function AddNetwork() { - const form = useNetworkForm(); - const { isDirty, invalid } = form.getFieldState('url', form.formState); - const isValidUrl = isDirty && !invalid; - const { handlers, isLoading } = useNetworks(); + const isEditing = false; const { - chainInfo, - error: chainInfoError, - isLoading: isLoadingChainInfo, - handlers: chainInfoHandlers, - } = useChainInfo(); + handlers, + isLoading, + isReviewingAddNetwork, + chainInfoToAdd, + isLoadingChainInfo, + chainInfoError, + } = useNetworks(); + + const context = useMemo( + () => ({ + providerChainId: chainInfoToAdd?.consensusParameters?.chainId?.toNumber(), + chainInfoError, + }), + [chainInfoToAdd?.consensusParameters?.chainId, chainInfoError] + ); + + const form = useNetworkForm({ context }); + const url = useWatch({ control: form.control, name: 'url' }); + const chainId = useWatch({ control: form.control, name: 'chainId' }); + const isValid = + form.formState.isDirty && + form.formState.isValid && + !Object.keys(form.formState.errors ?? {}).length; - // biome-ignore lint/correctness/useExhaustiveDependencies: useEffect(() => { - if (isValidUrl && !isLoadingChainInfo && chainInfo) { - form.setValue('name', chainInfo.name, { shouldValidate: true }); + if (chainId != null && url) { + handlers.clearChainInfo(); } - }, [chainInfo, isLoadingChainInfo, isValidUrl]); + }, [url, chainId, handlers.clearChainInfo]); - // biome-ignore lint/correctness/useExhaustiveDependencies: useEffect(() => { if (chainInfoError) { - form.setError('url', { - type: 'manual', - message: 'Invalid network', - }); + form.trigger('url'); + form.trigger('chainId'); } - }, [chainInfoError]); + }, [chainInfoError, form]); function onSubmit(data: NetworkFormValues) { - handlers.addNetwork({ data }); + if (data.chainId == null || !data.url) { + throw new Error('Missing required fields'); + } + + if (!isValid) return; + handlers.validateAddNetwork({ + url: data.url, + chainId: data.chainId.toString(), + }); } - function onClickReview() { - if (!isValidUrl) return; - chainInfoHandlers.fetchChainInfo(form.getValues('url')); + function onAddNetwork() { + const name = chainInfoToAdd?.name || ''; + handlers.addNetwork({ + data: { + chainId: Number(chainId), + name, + url, + }, + }); } return ( @@ -58,7 +78,6 @@ export function AddNetwork() { as="form" gap="$4" onSubmit={form.handleSubmit(onSubmit)} - autoComplete="off" > Add Network @@ -67,10 +86,11 @@ export function AddNetwork() { @@ -79,11 +99,11 @@ export function AddNetwork() { Cancel