From 408847da0eef4cb169eac9062bbcb4f7d2420a4f Mon Sep 17 00:00:00 2001 From: Enrico Rubboli Date: Thu, 3 Sep 2026 09:12:55 +0200 Subject: [PATCH 01/52] Harden and clean up the external dApp connection flow - Fix the sign-challenge approval crash (missing networkType context) - Sign rejects now reject the dApp promise instead of resolving 'null' - Answer waiting dApps when an approval window is closed or fails - Only accept approval/disconnect messages from extension pages - Fix the double-approval-window race; reconnect returns the session - Rewrite the content script: working session restore, correct timeout tracking, 5-minute approval timeout - Fix mojito.isConnected() and make disconnect() clear the wallet session - Extract the shared Browser API module (storage/runtime/sendPopupResponse) - Remove dead code: session_ persistence, remember checkbox, fake Bitcoin toggle, unused request state, useless mode switch, legacy Firefox background script - Ship mock data and mock selectors only in development builds - Estimate the HTLC funding fee like the wallet's own sends instead of a hardcoded 2000 sat/vB - Show signing errors in the password modal and prevent double submits - Bound stringification of untrusted dApp data in the transaction breakdown --- jest.config.js | 1 + jsconfig.json | 1 + public/background-script.js | 124 ----- public/background.js | 449 ++++++++++-------- public/explorer/content-script.js | 132 +++-- public/manifestFirefox.json | 12 +- public/mojito.js | 31 +- .../SettingsConnections.tsx | 16 +- .../TransactionBreakdown.js | 18 +- src/index.js | 20 +- src/pages/ConnectionPage/ConnectionPage.js | 92 +--- .../SignBitcoinTransaction.css | 9 + .../SignBitcoinTransaction.js | 175 +++---- src/pages/SignChallenge/SignChallenge.css | 9 + src/pages/SignChallenge/SignChallenge.js | 99 ++-- .../SignExternalTransaction.css | 9 + .../SignExternalTransaction.js | 95 ++-- src/services/Browser/Browser.js | 40 ++ src/services/Browser/Browser.test.js | 114 +++++ src/services/Browser/index.js | 3 + webpack.config.js | 1 + 21 files changed, 718 insertions(+), 732 deletions(-) delete mode 100644 public/background-script.js create mode 100644 src/services/Browser/Browser.js create mode 100644 src/services/Browser/Browser.test.js create mode 100644 src/services/Browser/index.js diff --git a/jest.config.js b/jest.config.js index aedc5619..3ee9b074 100644 --- a/jest.config.js +++ b/jest.config.js @@ -30,6 +30,7 @@ module.exports = { '^@Entities$': '/src/services/Entity/index.js', '^@APIs$': '/src/services/API/index.js', '^@Storage$': '/src/services/Storage/index.js', + '^@Browser$': '/src/services/Browser/index.js', '^@Version$': '/src/version/version.js', '^d3$': '/node_modules/d3/dist/d3.min.js', '^react-router$': diff --git a/jsconfig.json b/jsconfig.json index 2d763433..12c94540 100644 --- a/jsconfig.json +++ b/jsconfig.json @@ -15,6 +15,7 @@ "@Cryptos": ["./src/services/Crypto/index.js"], "@Databases": ["./src/services/Database/index.js"], "@Entities": ["./src/services/Entity/index.js"], + "@Browser": ["./src/services/Browser/index.js"], "@Helpers": ["./src/utils/Helpers/index.js"], "@Constants": ["./src/utils/Constants/index.js"], "@TestData": ["./src/utils/TestData/index.js"], diff --git a/public/background-script.js b/public/background-script.js deleted file mode 100644 index bdc5f286..00000000 --- a/public/background-script.js +++ /dev/null @@ -1,124 +0,0 @@ -/* eslint-disable no-undef */ -let popupWindowId = null -let connectWindowId = null -let isPopupOpening = false - -// Firefox has no sidePanel API: open the sidebar when the toolbar icon is clicked -if ( - typeof browser !== 'undefined' && - browser.action && - browser.sidebarAction && - browser.sidebarAction.open -) { - browser.action.onClicked.addListener(() => { - browser.sidebarAction.open().catch((error) => { - console.error('[Mintlayer] sidebarAction.open error:', error) - }) - }) -} - -browser.runtime.onConnect.addListener((port) => { - port.onMessage.addListener((msg) => { - if (msg && msg.myProperty && msg.myProperty.message) { - switch (msg.myProperty.message.message) { - case 'version': - port.postMessage({ version: browser.runtime.getManifest().version }) - break - case 'connect': - handleConnect(msg, port) - break - case 'delegate': - handleDelegate(msg) - break - case 'stake': - handleStake(msg) - break - default: - console.log('Unknown message') - } - } - }) - - async function handleConnect(request, port) { - if (connectWindowId === null) { - await createPopup('popup.html', async (win) => { - connectWindowId = win.id - setTimeout(async () => { - const response = await browser.runtime.sendMessage({ - action: 'connect', - }) - port.postMessage(response) - }, 1000) - }) - } else { - await browser.windows.update(connectWindowId, { focused: true }) - } - } - - async function handleDelegate(request) { - if (popupWindowId === null && !isPopupOpening) { - isPopupOpening = true - await createPopup('popup.html', async (win) => { - popupWindowId = win.id - isPopupOpening = false - setTimeout(async () => { - await browser.runtime.sendMessage({ - action: 'createDelegate', - data: { - pool_id: request.myProperty.message.pool_id, - referral_code: request.myProperty.message.referral_code || '', - }, - }) - }, 1000) - }) - } else if (popupWindowId !== null) { - await browser.windows.update(popupWindowId, { focused: true }) - } - } - - async function handleStake(request) { - if (popupWindowId === null) { - await createPopup('popup.html', async (win) => { - popupWindowId = win.id - setTimeout(async () => { - await browser.runtime.sendMessage({ - action: 'addStake', - data: { - delegation_id: request.delegation_id, - amount: request.amount, - }, - }) - }, 1000) - }) - } else { - await browser.windows.update(popupWindowId, { focused: true }) - } - } - - function createPopup(url, callback) { - return new Promise((resolve, reject) => { - browser.windows - .create({ - url: browser.runtime.getURL(url), - type: 'popup', - width: 800, - height: 630, - focused: true, - }) - .then((win) => { - resolve(callback(win)) - }) - .catch((error) => { - reject(error) - }) - }) - } - browser.windows.onRemoved.addListener((winId) => { - if (popupWindowId === winId) { - popupWindowId = null - } - if (connectWindowId === winId) { - connectWindowId = null - } - }) -}) diff --git a/public/background.js b/public/background.js index e0899d23..241171e4 100644 --- a/public/background.js +++ b/public/background.js @@ -5,9 +5,15 @@ // Detect browser API (Chrome or Firefox) const api = typeof browser !== 'undefined' ? browser : chrome - // Track popup window IDs - let popupWindowId = false - let connectWindowId = false + // One slot tracks the state of each approval window: connect vs signing. + const createApprovalSlot = () => ({ + id: false, + opening: false, + requestId: null, + }) + + const popupSlot = createApprovalSlot() + const connectSlot = createApprovalSlot() let connectedSites = {} const pendingResponses = new Map() @@ -20,6 +26,15 @@ ) } + // Firefox has no side panel: open the sidebar instead + if (api.sidebarAction && api.action && api.action.onClicked) { + api.action.onClicked.addListener(() => { + api.sidebarAction.open().catch((error) => { + console.error('[Mintlayer] sidebarAction.open error:', error) + }) + }) + } + // Load connected sites from storage api.storage.local.get(['connectedSites'], (data) => { if (api.runtime.lastError) { @@ -27,169 +42,171 @@ return } connectedSites = data.connectedSites || {} - console.log('[Mintlayer] Connected sites loaded:', connectedSites) }) - // Single listener for all messages - api.runtime.onMessage.addListener((message, sender, sendResponse) => { - console.log('[Mintlayer] Received message:', message, 'from', sender) + const clearPendingRequest = () => { + api.storage.local.remove('pendingRequest', () => { + if (api.runtime.lastError) { + console.error( + '[Mintlayer] Storage remove error:', + api.runtime.lastError, + ) + } + }) + } - const origin = sender.origin || 'unknown' + const getRequestOrigin = (sender) => { + if (sender.origin) return sender.origin - // Handle requests from content.js - if (message.method) { - if (message.method === 'checkConnection') { - sendResponse({ - result: { isConnected: !!connectedSites[origin] }, - }) - } else if (message.method === 'connect') { - if (connectWindowId === false) { - pendingResponses.set(message.requestId, sendResponse) - api.windows.create( - { - url: api.runtime.getURL('popup.html'), - type: 'popup', - width: 800, - height: 600, - focused: true, - }, - (win) => { - connectWindowId = win.id - api.storage.local.set( - { - pendingRequest: { - origin, - requestId: message.requestId, - // networkType: message.params.networkType, - // permission: message.params.permission, - action: 'connect', - }, - }, - () => { - if (api.runtime.lastError) { - console.error( - '[Mintlayer] Storage set error:', - api.runtime.lastError, - ) - } - }, - ) - }, - ) - return true // Keep channel open - } else if (typeof connectWindowId === 'number') { - api.windows.update(connectWindowId, { focused: true }) - sendResponse({ error: 'Connection window already open' }) - } - } else if (message.method === 'signTransaction') { - if (!connectedSites[origin]) { - sendResponse({ error: 'Not connected. Call connect first.' }) - } else if (popupWindowId === false) { - pendingResponses.set(message.requestId, sendResponse) - api.windows.create( - { - url: api.runtime.getURL('popup.html'), - type: 'popup', - width: 800, - height: 600, - focused: true, - }, - (win) => { - popupWindowId = win.id - api.storage.local.set( - { - pendingRequest: { - origin, - requestId: message.requestId, - action: 'signTransaction', - data: message.params || {}, - }, - }, - () => { - if (api.runtime.lastError) { - console.error( - '[Mintlayer] Storage set error:', - api.runtime.lastError, - ) - } - }, - ) - }, - ) - return true - } else if (typeof popupWindowId === 'number') { - api.windows.update(popupWindowId, { focused: true }) - sendResponse({ error: 'Transaction signing window already open' }) + try { + return sender.url ? new URL(sender.url).origin : 'unknown' + } catch { + return 'unknown' + } + } + + // Approval responses and disconnections must come from the wallet's own + // pages, never from a content script injected into a website. + const isFromExtensionPage = (sender) => + sender.id === api.runtime.id && + typeof sender.url === 'string' && + sender.url.startsWith(api.runtime.getURL('')) + + const focusWindow = (windowId) => { + api.windows.update(windowId, { focused: true }, () => { + if (api.runtime.lastError) { + console.error('[Mintlayer] Window focus error:', api.runtime.lastError) + } + }) + } + + // Fails a pending approval: clears the slot and answers the waiting dApp. + const failSlot = (slot, errorMessage) => { + slot.id = false + slot.opening = false + + if (!slot.requestId) return + + const respond = pendingResponses.get(slot.requestId) + pendingResponses.delete(slot.requestId) + slot.requestId = null + clearPendingRequest() + respond?.({ error: errorMessage }) + } + + // Opens one approval window for the request and keeps the dApp's message + // channel open until the wallet answers. Returns true while waiting. + const openApprovalWindow = (slot, request, sendResponse, busyError) => { + if (typeof slot.id === 'number' || slot.opening) { + if (typeof slot.id === 'number') focusWindow(slot.id) + sendResponse({ error: busyError }) + return false + } + + slot.opening = true + slot.requestId = request.requestId + pendingResponses.set(request.requestId, sendResponse) + + api.windows.create( + { + url: api.runtime.getURL('popup.html'), + type: 'popup', + width: 800, + height: 600, + focused: true, + }, + (win) => { + slot.opening = false + slot.id = win?.id ?? false + + if (typeof slot.id !== 'number') { + failSlot(slot, 'Request cancelled') + return } - } else if (message.method === 'signChallenge') { - if (!connectedSites[origin]) { - sendResponse({ error: 'Not connected. Call connect first.' }) - } else if (popupWindowId === false) { - pendingResponses.set(message.requestId, sendResponse) - api.windows.create( - { - url: api.runtime.getURL('popup.html'), - type: 'popup', - width: 800, - height: 600, - focused: true, - }, - (win) => { - popupWindowId = win.id - api.storage.local.set( - { - pendingRequest: { - origin, - requestId: message.requestId, - action: 'signChallenge', - data: message.params || {}, - }, - }, - () => { - if (api.runtime.lastError) { - console.error( - '[Mintlayer] Storage set error:', - api.runtime.lastError, - ) - } - }, + + // The window may have been closed while it was being created, in + // which case onRemoved fired before the id was tracked. + api.windows.get(slot.id, (existing) => { + if (!existing) { + failSlot(slot, 'Request cancelled') + return + } + + api.storage.local.set({ pendingRequest: request }, () => { + if (api.runtime.lastError) { + console.error( + '[Mintlayer] Storage set error:', + api.runtime.lastError, ) - }, - ) - return true - } else if (typeof popupWindowId === 'number') { - api.windows.update(popupWindowId, { focused: true }) - sendResponse({ error: 'Transaction signing window already open' }) - } - } else if (message.method === 'version') { - sendResponse({ result: api.runtime.getManifest().version }) - } else if (message.method === 'getSession') { - const sessionOrigin = message.origin || sender.origin - const session = connectedSites[sessionOrigin] - - if (session && session.address) { - sendResponse({ - result: { - address: session.address, - }, + failSlot(slot, 'Could not create the wallet request') + } }) - } else { - sendResponse({ result: null }) - } + }) + }, + ) - return true - } else { - sendResponse({ error: 'Unknown method' }) + return true + } + + // Handle popup responses from the wallet UI + const handlePopupResponse = (message) => { + const { requestId, origin, result, error, method } = message + const respond = pendingResponses.get(requestId) + pendingResponses.delete(requestId) + clearPendingRequest() + + if (!respond) { + console.warn('[Mintlayer] Response for unknown request:', requestId) + return + } + + const rejected = + !result || Boolean(error) || (method && method.endsWith('_reject')) + + if (rejected) { + respond({ error: error || 'User rejected the request' }) + return + } + + if (method === 'connect' && origin) { + connectedSites[origin] = { + address: result.address, + timestamp: Date.now(), } + api.storage.local.set({ connectedSites }, () => { + if (api.runtime.lastError) { + console.error('[Mintlayer] Storage set error:', api.runtime.lastError) + respond({ error: 'Could not save the wallet connection' }) + return + } + respond({ result }) + }) + return + } + + respond({ result, error }) + } + + // Single listener for all messages + api.runtime.onMessage.addListener((message, sender, sendResponse) => { + const origin = getRequestOrigin(sender) + + // Wallet-UI-only actions. These must be checked before dApp requests: + // approval messages also carry a method field. + if (message.action === 'popupResponse') { + if (!isFromExtensionPage(sender)) return false + handlePopupResponse(message) + return false } - // Handle disconnect requests from the wallet UI if (message.action === 'disconnectSite') { + if (!isFromExtensionPage(sender)) return false + const targetOrigin = message.origin if (!targetOrigin || !connectedSites[targetOrigin]) { sendResponse({ result: null }) - return + return false } delete connectedSites[targetOrigin] @@ -204,64 +221,112 @@ return true } - if (!message.method && message.action !== 'popupResponse') return + if (!message.method) return false - // Handle popup responses - if (message.action === 'popupResponse') { - const { requestId, origin, result, error } = message - const storedSendResponse = pendingResponses.get(requestId) - if (result && message.method === 'connect') { - connectedSites[origin] = { - address: result.address, - timestamp: Date.now(), - } + // Handle requests from content.js + if (message.method === 'checkConnection') { + sendResponse({ + result: { isConnected: !!connectedSites[origin] }, + }) + } else if (message.method === 'connect') { + // Already connected: answer immediately instead of asking again. + if (connectedSites[origin]) { + sendResponse({ result: connectedSites[origin] }) + return false + } + + return openApprovalWindow( + connectSlot, + { + origin, + requestId: message.requestId, + permissions: message.params?.permissions || [], + action: 'connect', + }, + sendResponse, + 'Connection window already open', + ) + } else if (message.method === 'signTransaction') { + if (!connectedSites[origin]) { + sendResponse({ error: 'Not connected. Call connect first.' }) + return false + } + + return openApprovalWindow( + popupSlot, + { + origin, + requestId: message.requestId, + action: 'signTransaction', + data: message.params || {}, + }, + sendResponse, + 'Transaction signing window already open', + ) + } else if (message.method === 'signChallenge') { + if (!connectedSites[origin]) { + sendResponse({ error: 'Not connected. Call connect first.' }) + return false + } + + return openApprovalWindow( + popupSlot, + { + origin, + requestId: message.requestId, + action: 'signChallenge', + data: message.params || {}, + }, + sendResponse, + 'Transaction signing window already open', + ) + } else if (message.method === 'version') { + sendResponse({ result: api.runtime.getManifest().version }) + } else if (message.method === 'disconnect') { + if (connectedSites[origin]) { + delete connectedSites[origin] api.storage.local.set({ connectedSites }, () => { if (api.runtime.lastError) { console.error( '[Mintlayer] Storage set error:', api.runtime.lastError, ) + sendResponse({ error: 'Could not disconnect the site' }) + return } + sendResponse({ result: true }) + }) + return true + } - if (storedSendResponse) { - storedSendResponse({ result, error }) - pendingResponses.delete(requestId) - } + sendResponse({ result: true }) + } else if (message.method === 'getSession') { + const sessionOrigin = message.origin || origin + const session = connectedSites[sessionOrigin] + + if (session && session.address) { + sendResponse({ + result: { + address: session.address, + }, }) - } else if (!result && message.method === 'connect') { - if (storedSendResponse) { - storedSendResponse({ error: error || 'User rejected the request' }) - pendingResponses.delete(requestId) - } - } else if (result && message.method === 'signTransaction_approve') { - storedSendResponse({ result, error }) - pendingResponses.delete(requestId) - } else if (result && message.method === 'signTransaction_reject') { - storedSendResponse({ result, error }) - pendingResponses.delete(requestId) - } else if (result && message.method === 'signChallenge_approve') { - storedSendResponse({ result, error }) - pendingResponses.delete(requestId) - } else if (result && message.method === 'signChallenge_reject') { - storedSendResponse({ result, error }) - pendingResponses.delete(requestId) } else { - api.runtime.sendMessage({ requestId, result, error }, () => { - if (api.runtime.lastError) { - console.error( - '[Mintlayer] Send response error:', - api.runtime.lastError, - ) - } - }) + sendResponse({ result: null }) } + + return true + } else { + sendResponse({ error: 'Unknown method' }) } + + return false }) - // Clean up window IDs + // Clean up window state and answer waiting dApps when an approval window + // is closed without a decision. api.windows.onRemoved.addListener((winId) => { - if (popupWindowId === winId) popupWindowId = false - if (connectWindowId === winId) connectWindowId = false + if (popupSlot.id === winId) failSlot(popupSlot, 'Request cancelled') + if (connectSlot.id === winId) failSlot(connectSlot, 'Request cancelled') }) console.log('[Mintlayer Extension] Background script loaded') diff --git a/public/explorer/content-script.js b/public/explorer/content-script.js index c698bcb2..ffc9c225 100644 --- a/public/explorer/content-script.js +++ b/public/explorer/content-script.js @@ -2,95 +2,93 @@ ;(function () { const api = typeof browser !== 'undefined' ? browser : chrome + const cloneForPage = (value) => + typeof cloneInto !== 'undefined' ? cloneInto(value, window) : value + + const postToPage = (message) => { + window.postMessage(cloneForPage(message), '*') + } + // Inject mojito.js into the page try { const script = document.createElement('script') script.src = api.runtime.getURL('mojito.js') script.onload = () => script.remove() ;(document.head || document.documentElement).appendChild(script) - } catch (err) { - console.error('[Content] Failed to inject Mojito SDK:', err) + } catch (error) { + console.error('[Mojito] Failed to inject SDK:', error) } const origin = window.location.origin + const pendingRequests = new Map() // requestId -> timeout id + const RESPONSE_TIMEOUT_MS = 5 * 60 * 1000 // approvals can take a while - chrome.runtime.sendMessage({ action: 'getSession', origin }, (res) => { - if (res?.session) { - console.log('[Mojito] Session restored:', res.session) - window.postMessage( - { - type: 'MINTLAYER_EVENT', - event: 'accountsChanged', - data: res.session.address, - }, - '*', - ) - } + // Tell pages with an existing session as soon as the content script loads. + api.runtime.sendMessage({ method: 'getSession', origin }, (response) => { + if (api.runtime.lastError || !response?.result) return + + postToPage({ + type: 'MINTLAYER_EVENT', + event: 'accountsChanged', + data: response.result.address, + }) }) window.addEventListener('message', (event) => { - if (event.source === window && event.data.type === 'MINTLAYER_REQUEST') { - console.log('[Content] Received from SDK:', event.data) + if (event.source !== window || event.data?.type !== 'MINTLAYER_REQUEST') { + return + } + + const requestId = event.data.requestId + + // Guard against duplicate requests and answer stale ids at once. + if (pendingRequests.has(requestId)) return - const requestData = { - requestId: event.data.requestId, + const timeoutId = setTimeout(() => { + if (!pendingRequests.has(requestId)) return + + pendingRequests.delete(requestId) + console.error('[Mojito] Timeout waiting for background response') + postToPage({ + type: 'MINTLAYER_RESPONSE', + requestId, + error: 'Response timeout from background', + }) + }, RESPONSE_TIMEOUT_MS) + + pendingRequests.set(requestId, timeoutId) + + api.runtime.sendMessage( + { + requestId, method: event.data.method, params: event.data.params || {}, - } + }, + (response) => { + if (!pendingRequests.has(requestId)) return - const message = - typeof cloneInto !== 'undefined' - ? cloneInto(requestData, window) - : requestData + clearTimeout(pendingRequests.get(requestId)) + pendingRequests.delete(requestId) - // Send message to background with timeout - api.runtime.sendMessage(message, (response) => { if (api.runtime.lastError) { - console.error('[Content] Runtime error:', api.runtime.lastError) - window.postMessage( - { - type: 'MINTLAYER_RESPONSE', - requestId: event.data.requestId, - error: - api.runtime.lastError.message || - 'Could not connect to background', - }, - '*', - ) + console.error('[Mojito] Runtime error:', api.runtime.lastError) + postToPage({ + type: 'MINTLAYER_RESPONSE', + requestId, + error: + api.runtime.lastError.message || + 'Could not connect to background', + }) return } - console.log('[Content] Response from background:', response) - const responseData = { + postToPage({ type: 'MINTLAYER_RESPONSE', - requestId: event.data.requestId, - result: response && response.result, - error: response && response.error, - } - - const responseMessage = - typeof cloneInto !== 'undefined' - ? cloneInto(responseData, window) - : responseData - window.postMessage(responseMessage, '*') - }) - - // Timeout fallback - setTimeout(() => { - if (!pendingResponses.has(event.data.requestId)) { - console.error('[Content] Timeout waiting for background response') - window.postMessage( - { - type: 'MINTLAYER_RESPONSE', - requestId: event.data.requestId, - error: 'Response timeout from background', - }, - '*', - ) - } - }, 1000 * 120) // 2-minute timeout - } + requestId, + result: response?.result, + error: response?.error, + }) + }, + ) }) - - const pendingResponses = new Set() // Track pending requests })() diff --git a/public/manifestFirefox.json b/public/manifestFirefox.json index 5c7d3c98..532fc2dc 100644 --- a/public/manifestFirefox.json +++ b/public/manifestFirefox.json @@ -12,7 +12,7 @@ "192": "logo192.png", "512": "logo512.png" }, - "permissions": ["activeTab", "tabs"], + "permissions": ["activeTab", "tabs", "storage"], "host_permissions": [ "*://localhost/*", "*://explorer.mintlayer.org/*", @@ -21,6 +21,8 @@ ], "content_scripts": [ { + "run_at": "document_start", + "all_frames": true, "matches": [ "*://localhost/*", "*://explorer.mintlayer.org/*", @@ -31,8 +33,14 @@ } ], "background": { - "scripts": ["background-script.js"] + "scripts": ["background.js"] }, + "web_accessible_resources": [ + { + "resources": ["mojito.js"], + "matches": ["*://localhost/*", "*://*.mintlayer.org/*"] + } + ], "action": { "default_icon": "logo192.png", "default_title": "Mojito" diff --git a/public/mojito.js b/public/mojito.js index 06a38e1b..80ca73e6 100644 --- a/public/mojito.js +++ b/public/mojito.js @@ -49,8 +49,10 @@ async connect() { const result = await mojito.request('connect') - mojito.connectedAddresses = result || {} - return mojito.connectedAddresses + // The session carries the per-network map under `address`; older + // responses may already be that map. + mojito.connectedAddresses = result?.address ?? result ?? {} + return result }, async restore() { @@ -102,16 +104,21 @@ }) }, - disconnect() { - mojito.connectedAddresses = [] - window.postMessage( - { - type: 'MINTLAYER_EVENT', - event: 'disconnect', - data: {}, - }, - '*', - ) + async disconnect() { + try { + // Ask the wallet to drop the session too, not only the page state. + await mojito.request('disconnect') + } finally { + mojito.connectedAddresses = [] + window.postMessage( + { + type: 'MINTLAYER_EVENT', + event: 'disconnect', + data: {}, + }, + '*', + ) + } }, } diff --git a/src/components/containers/Settings/SettingsConnections/SettingsConnections.tsx b/src/components/containers/Settings/SettingsConnections/SettingsConnections.tsx index 9548de00..630315dd 100644 --- a/src/components/containers/Settings/SettingsConnections/SettingsConnections.tsx +++ b/src/components/containers/Settings/SettingsConnections/SettingsConnections.tsx @@ -1,23 +1,11 @@ -/* eslint-disable no-undef */ import { useCallback, useEffect, useState } from 'react' import { Button, SiteBadge } from '@BasicComponents' +import { Browser } from '@Browser' import styles from './SettingsConnections.module.css' -const storage = - typeof browser !== 'undefined' && browser.storage - ? browser.storage - : typeof chrome !== 'undefined' && chrome.storage - ? chrome.storage - : null - -const runtime = - typeof browser !== 'undefined' && browser.runtime - ? browser.runtime - : typeof chrome !== 'undefined' && chrome.runtime - ? chrome.runtime - : null +const { storage, runtime } = Browser interface ConnectedSite { origin: string diff --git a/src/components/containers/SignTransaction/TransactionBreakdown/TransactionBreakdown.js b/src/components/containers/SignTransaction/TransactionBreakdown/TransactionBreakdown.js index 22a5366c..e50cfaaf 100644 --- a/src/components/containers/SignTransaction/TransactionBreakdown/TransactionBreakdown.js +++ b/src/components/containers/SignTransaction/TransactionBreakdown/TransactionBreakdown.js @@ -16,12 +16,26 @@ const shortenId = (id) => const MAX_FIELD_DEPTH = 2 const MAX_FIELD_LENGTH = 80 +const MAX_SERIALIZED_LENGTH = 400 const shortenData = (data) => data && data.length > MAX_FIELD_LENGTH ? `${data.slice(0, MAX_FIELD_LENGTH)}…` : data +// dApp-supplied data can be deeply nested or cyclic; never trust it with a +// bare stringify. +const boundedStringify = (value) => { + try { + const text = JSON.stringify(value) ?? 'null' + return text.length > MAX_SERIALIZED_LENGTH + ? `${text.slice(0, MAX_SERIALIZED_LENGTH)}…` + : text + } catch { + return '[unserializable]' + } +} + const getAmount = (source) => source?.value?.amount?.decimal ?? source?.amount?.decimal ?? @@ -93,8 +107,8 @@ const FieldValue = ({ value, depth = 0 }) => { } if (depth >= MAX_FIELD_DEPTH) { - const text = JSON.stringify(value) - return {shortenData(text)} + const text = shortenData(boundedStringify(value)) + return {text} } return ( diff --git a/src/index.js b/src/index.js index 943e9e7a..76abcd7e 100644 --- a/src/index.js +++ b/src/index.js @@ -1,4 +1,3 @@ -/* eslint-disable no-undef */ import React, { useState, useEffect, useContext } from 'react' import ReactDOM from 'react-dom/client' import { @@ -61,6 +60,7 @@ import { } from '@Contexts' import { ML } from '@Cryptos' import { LocalStorageService } from '@Storage' +import { Browser } from '@Browser' import '@Assets/styles/fonts.css' import '@Assets/styles/constants.css' @@ -76,19 +76,7 @@ if (isExtendedView) { document.documentElement.classList.add('extended-view') } -const storage = - typeof browser !== 'undefined' && browser.storage - ? browser.storage - : typeof chrome !== 'undefined' && chrome.storage - ? chrome.storage - : null - -const runtime = - typeof browser !== 'undefined' && browser.runtime - ? browser.runtime - : typeof chrome !== 'undefined' && chrome.runtime - ? chrome.runtime - : null +const { storage, runtime } = Browser const App = () => { const [errorPopupOpen, setErrorPopupOpen] = useState(false) @@ -107,7 +95,6 @@ const App = () => { useContext(MintlayerContext) const { networkType } = useContext(SettingsContext) const [nextAfterUnlock, setNextAfterUnlock] = useState(null) - const [, setRequest] = useState(null) const currentMlAddresses = addresses.mlAddresses @@ -123,7 +110,7 @@ const App = () => { return !!mintlayerResponse && !!exchangeResponse } catch (error) { if (accountUnlocked) { - console.log(error) + console.error('Connection check failed:', error) setErrorPopupOpen(true) setAllDataFetching(false) logout() @@ -188,7 +175,6 @@ const App = () => { } const pendingRequest = data.pendingRequest if (pendingRequest) { - setRequest(pendingRequest) handlePendingRequest(pendingRequest) } }) diff --git a/src/pages/ConnectionPage/ConnectionPage.js b/src/pages/ConnectionPage/ConnectionPage.js index 06b57c95..6e211cef 100644 --- a/src/pages/ConnectionPage/ConnectionPage.js +++ b/src/pages/ConnectionPage/ConnectionPage.js @@ -1,4 +1,3 @@ -/* eslint-disable no-undef */ import { useLocation } from 'react-router' import { useContext, useState } from 'react' import { AccountContext } from '@Contexts' @@ -9,6 +8,7 @@ import { ReactComponent as IconSign } from '@Assets/images/icon-sign.svg' import { ReactComponent as IconLoop } from '@Assets/images/icon-loop.svg' import PermissionItem from './PermissionItem' import BitcoinDataNotice from './BitcoinDataNotice' +import { sendPopupResponse } from '@Browser' import styles from './ConnectionPage.module.css' const toHexString = (obj) => { @@ -17,40 +17,23 @@ const toHexString = (obj) => { .join('') } -const storage = - typeof browser !== 'undefined' && browser.storage - ? browser.storage - : typeof chrome !== 'undefined' && chrome.storage - ? chrome.storage - : null - -const runtime = - typeof browser !== 'undefined' && browser.runtime - ? browser.runtime - : typeof chrome !== 'undefined' && chrome.runtime - ? chrome.runtime - : null +const UNKNOWN_WEBSITE = 'Unknown Website' export const ConnectionPage = () => { const { state: external_state } = useLocation() const { addresses } = useContext(AccountContext) - const website = 'Unknown Website' // This should be replaced with the actual website name or URL - const [, setProvideBitcoinData] = useState(false) - - const provideBitcoinData = true + const [provideBitcoinData, setProvideBitcoinData] = useState(true) const state = external_state - const origin = state?.request?.origin || website + const origin = state?.request?.origin || UNKNOWN_WEBSITE const permissions = state?.request?.permissions || [] const requireBTC = permissions.includes('bitcoin') - const isUnknownOrigin = origin === website + const isUnknownOrigin = origin === UNKNOWN_WEBSITE const connectButtonExtraStyles = [styles.actionButton] const handleConnect = () => { - const remember = document.querySelector('.connect-page__checkbox')?.checked - const sessionKey = `session_${origin}` const sessionData = { origin, connected: true, @@ -96,66 +79,20 @@ export const ConnectionPage = () => { timestamp: Date.now(), } - const requestId = state?.request?.requestId - const response = { - action: 'popupResponse', + sendPopupResponse({ method: 'connect', - requestId, + requestId: state?.request?.requestId, origin, result: sessionData, - } - - const saveAndClose = () => { - storage.local.remove('pendingRequest', () => { - if (runtime.lastError) { - console.error( - '[Mojito Popup] Error removing pendingRequest:', - runtime.lastError, - ) - } - window.close() - }) - } - - if (remember) { - // Save session only if checkbox is checked - storage.local.set({ [sessionKey]: sessionData }, () => { - console.log('[Mojito Popup] Session saved for', origin) - runtime.sendMessage(response, () => { - console.log('[Popup] Response sent:', response) - saveAndClose() - }) - }) - } else { - // No session save - runtime.sendMessage(response, () => { - console.log('[Popup] Response sent:', response) - saveAndClose() - }) - } + }) } const handleReject = () => { - const requestId = state?.request?.requestId - const response = { - action: 'popupResponse', + sendPopupResponse({ method: 'connect', - requestId, + requestId: state?.request?.requestId, origin, result: null, - } - runtime.sendMessage(response, () => { - console.log('[Popup] Response sent:', response) - // Remove pendingRequest after sending response - storage.local.remove('pendingRequest', () => { - if (runtime.lastError) { - console.error( - '[Mojito Popup] Error removing pendingRequest:', - runtime.lastError, - ) - } - window.close() - }) }) } @@ -227,15 +164,6 @@ export const ConnectionPage = () => {

- {/* // TODO: Make this work */} - {/* */} -
@@ -449,7 +424,7 @@ export const SignBitcoinTransactionPage = () => {
- {!external_state && ( + {!external_state && isDevelopment && (
{Object.keys(MOCKS).map((key) => { return ( @@ -467,15 +442,9 @@ export const SignBitcoinTransactionPage = () => { )} {state?.request?.data?.txData?.JSONRepresentation && ( - <> - {mode === 'preview' && ( -
- - {/**/} -
- )} - {mode === 'json' && } - +
+ +
)} {/* HTLC Secret Information */} @@ -532,7 +501,7 @@ export const SignBitcoinTransactionPage = () => { onClickHandle={handleApprove} extraStyleClasses={extraButtonStyles} > - Approve and return to page. + Approve and return to page
@@ -568,19 +537,21 @@ export const SignBitcoinTransactionPage = () => {
)} + {signError &&
{signError}
}
diff --git a/src/pages/SignChallenge/SignChallenge.css b/src/pages/SignChallenge/SignChallenge.css index a6efc1e4..8b7dbf3a 100644 --- a/src/pages/SignChallenge/SignChallenge.css +++ b/src/pages/SignChallenge/SignChallenge.css @@ -62,3 +62,12 @@ .requestOrigin { margin-bottom: 20px; } + +.sign-error { + color: #dc2626; + font-size: 0.75rem; + background-color: #fef2f2; + border: 1px solid #fecaca; + border-radius: 4px; + padding: 6px 8px; +} diff --git a/src/pages/SignChallenge/SignChallenge.js b/src/pages/SignChallenge/SignChallenge.js index 2af1d9c8..74cc2a79 100644 --- a/src/pages/SignChallenge/SignChallenge.js +++ b/src/pages/SignChallenge/SignChallenge.js @@ -1,4 +1,3 @@ -/* eslint-disable no-undef */ import { useLocation } from 'react-router' import { SignTransaction as SignTxHelpers } from '@Helpers' import { MOCKS } from './mocks' @@ -10,41 +9,39 @@ import { useState, useContext } from 'react' import { Account } from '@Entities' import { ML } from '@Cryptos' -import { AccountContext } from '@Contexts' - -const storage = - typeof browser !== 'undefined' && browser.storage - ? browser.storage - : typeof chrome !== 'undefined' && chrome.storage - ? chrome.storage - : null - -const runtime = - typeof browser !== 'undefined' && browser.runtime - ? browser.runtime - : typeof chrome !== 'undefined' && chrome.runtime - ? chrome.runtime - : null +import { AccountContext, SettingsContext } from '@Contexts' +import { sendPopupResponse } from '@Browser' + +const isDevelopment = process.env.NODE_ENV === 'development' export const SignChallengePage = () => { const { state: external_state } = useLocation() const [isModalOpen, setIsModalOpen] = useState(false) const [password, setPassword] = useState('') + const [isSigning, setIsSigning] = useState(false) + const [signError, setSignError] = useState('') const [selectedMock, setSelectedMock] = useState('transfer') const extraButtonStyles = ['buttonSignTransaction'] - const state = external_state || MOCKS[selectedMock] + const state = external_state || (isDevelopment ? MOCKS[selectedMock] : null) const origin = state?.request?.origin const { addresses, accountID } = useContext(AccountContext) + const { networkType } = useContext(SettingsContext) const currentMlAddresses = addresses.mlAddresses - const handleApprove = async () => { + const handleApprove = () => { + setSignError('') setIsModalOpen(true) // Open the modal } const handleModalSubmit = async () => { + if (isSigning) return + + setIsSigning(true) + setSignError('') + try { const message = state?.request?.data?.message const address = @@ -82,52 +79,32 @@ export const SignChallengePage = () => { '', ) - const requestId = state?.request?.requestId - const method = 'signChallenge_approve' - const result = { - message, - address, - signature: signatureHex, - } - - runtime.sendMessage( - { - action: 'popupResponse', - method, - requestId, - origin, - result, - }, - () => { - storage.local.remove('pendingRequest', () => { - window.close() - }) + sendPopupResponse({ + method: 'signChallenge_approve', + requestId: state?.request?.requestId, + origin, + result: { + message, + address, + signature: signatureHex, }, - ) + }) } catch (error) { console.error('Error during challenge signing:', error) - setIsModalOpen(false) + setSignError( + error?.message || 'Signing failed. Check your password and try again.', + ) + setIsSigning(false) } } const handleReject = () => { - const requestId = state?.request?.requestId - const method = 'signChallenge_reject' - const result = 'null' - runtime.sendMessage( - { - action: 'popupResponse', - method, - requestId, - origin, - result, - }, - () => { - storage.local.remove('pendingRequest', () => { - window.close() - }) - }, - ) + sendPopupResponse({ + method: 'signChallenge_reject', + requestId: state?.request?.requestId, + origin, + error: 'Challenge signing rejected', + }) } const selectMock = (name) => { @@ -153,7 +130,7 @@ export const SignChallengePage = () => {
- {!external_state && ( + {!external_state && isDevelopment && (
{Object.keys(MOCKS).map((key) => { return ( @@ -215,19 +192,21 @@ export const SignChallengePage = () => { placeHolder="Enter your password" autoFocus /> + {signError &&
{signError}
}
diff --git a/src/pages/SignExternalTransaction/SignExternalTransaction.css b/src/pages/SignExternalTransaction/SignExternalTransaction.css index 08ce4ca2..b7c12af5 100644 --- a/src/pages/SignExternalTransaction/SignExternalTransaction.css +++ b/src/pages/SignExternalTransaction/SignExternalTransaction.css @@ -250,3 +250,12 @@ .requestOrigin { margin-bottom: 20px; } + +.sign-error { + color: #dc2626; + font-size: 0.75rem; + background-color: #fef2f2; + border: 1px solid #fecaca; + border-radius: 4px; + padding: 6px 8px; +} diff --git a/src/pages/SignExternalTransaction/SignExternalTransaction.js b/src/pages/SignExternalTransaction/SignExternalTransaction.js index 6cb0d3f7..ce976d7a 100644 --- a/src/pages/SignExternalTransaction/SignExternalTransaction.js +++ b/src/pages/SignExternalTransaction/SignExternalTransaction.js @@ -1,4 +1,3 @@ -/* eslint-disable no-undef */ import { useLocation } from 'react-router' import { SignTransaction as SignTxHelpers, Secret } from '@Helpers' import { MOCKS } from './mocks' @@ -14,20 +13,9 @@ import { Account } from '@Entities' import { ML } from '@Cryptos' import { AccountContext, SettingsContext } from '@Contexts' import { Mintlayer } from '@APIs' +import { sendPopupResponse } from '@Browser' -const storage = - typeof browser !== 'undefined' && browser.storage - ? browser.storage - : typeof chrome !== 'undefined' && chrome.storage - ? chrome.storage - : null - -const runtime = - typeof browser !== 'undefined' && browser.runtime - ? browser.runtime - : typeof chrome !== 'undefined' && chrome.runtime - ? chrome.runtime - : null +const isDevelopment = process.env.NODE_ENV === 'development' export const SignTransactionPage = () => { const { state: external_state } = useLocation() @@ -45,6 +33,9 @@ export const SignTransactionPage = () => { const [generatedSecretHash, setGeneratedSecretHash] = useState(null) const [secretError, setSecretError] = useState('') + const [isSigning, setIsSigning] = useState(false) + const [signError, setSignError] = useState('') + const [mode, setMode] = useState('preview') const [selectedMock, setSelectedMock] = useState('transfer') @@ -53,7 +44,10 @@ export const SignTransactionPage = () => { // State to hold the potentially modified transaction data const [transactionState, setTransactionState] = useState(null) - const state = transactionState || external_state || MOCKS[selectedMock] + const state = + transactionState || + external_state || + (isDevelopment ? MOCKS[selectedMock] : null) const origin = state?.request?.origin const { addresses, accountID } = useContext(AccountContext) @@ -82,7 +76,8 @@ export const SignTransactionPage = () => { (output) => output?.destination === HtlcInput.utxo.htlc.spend_key, ) - const handleApprove = async () => { + const handleApprove = () => { + setSignError('') setIsModalOpen(true) // Open the modal } @@ -171,6 +166,11 @@ export const SignTransactionPage = () => { }, [transactionState, external_state, selectedMock, generatedSecret]) const handleModalSubmit = async () => { + if (isSigning) return + + setIsSigning(true) + setSignError('') + try { // Validate secret if it's an HTLC claim transaction if (isHTLCClaim && secret && !Secret.validateSecretHex(secret.trim())) { @@ -296,8 +296,6 @@ export const SignTransactionPage = () => { } } - console.log('order_info', order_info) - const transactionHex = SignTxHelpers.getTransactionHEX( { transactionBINrepresentation, @@ -322,8 +320,6 @@ export const SignTransactionPage = () => { }, ) - console.log('transactionHex', transactionHex) - if (isHTLCCreateTx) { // save secret to account await Account.saveProvidedHtlsSecret({ @@ -349,47 +345,28 @@ export const SignTransactionPage = () => { result = transactionHex } - console.log('result', result) - - runtime.sendMessage( - { - action: 'popupResponse', - method, - requestId, - origin, - result, - }, - () => { - storage.local.remove('pendingRequest', () => { - window.close() - }) - }, - ) + sendPopupResponse({ + method, + requestId, + origin, + result, + }) } catch (error) { console.error('Error during transaction signing:', error) - setIsModalOpen(false) + setSignError( + error?.message || 'Signing failed. Check your password and try again.', + ) + setIsSigning(false) } } const handleReject = () => { - const requestId = state?.request?.requestId - const method = 'signTransaction_reject' - const result = 'null' - - runtime.sendMessage( - { - action: 'popupResponse', - method, - requestId, - origin, - result, - }, - () => { - storage.local.remove('pendingRequest', () => { - window.close() - }) - }, - ) + sendPopupResponse({ + method: 'signTransaction_reject', + requestId: state?.request?.requestId, + origin, + error: 'Transaction rejected', + }) } const selectMock = (name) => { @@ -444,7 +421,7 @@ export const SignTransactionPage = () => {
- {!external_state && ( + {!external_state && isDevelopment && (
{Object.keys(MOCKS).map((key) => { return ( @@ -567,19 +544,21 @@ export const SignTransactionPage = () => {
)} + {signError &&
{signError}
}
diff --git a/src/services/Browser/Browser.js b/src/services/Browser/Browser.js new file mode 100644 index 00000000..696b1706 --- /dev/null +++ b/src/services/Browser/Browser.js @@ -0,0 +1,40 @@ +/* eslint-disable no-undef */ + +// Chrome exposes the extension APIs on `chrome`, Firefox on `browser`. +const api = + typeof browser !== 'undefined' && browser?.runtime + ? browser + : typeof chrome !== 'undefined' && chrome?.runtime + ? chrome + : null + +export const runtime = api?.runtime ?? null + +export const storage = api?.storage ?? null + +// Answers the dApp request that opened this approval window, clears the +// pending request and closes the window. Exactly one of result/error. +export const sendPopupResponse = ({ + method, + requestId, + origin, + result, + error, +}) => { + if (!runtime || !storage) return + + runtime.sendMessage( + { + action: 'popupResponse', + method, + requestId, + origin, + ...(error ? { error } : { result }), + }, + () => { + storage.local.remove('pendingRequest', () => { + window.close() + }) + }, + ) +} diff --git a/src/services/Browser/Browser.test.js b/src/services/Browser/Browser.test.js new file mode 100644 index 00000000..86e12b66 --- /dev/null +++ b/src/services/Browser/Browser.test.js @@ -0,0 +1,114 @@ +/* eslint-disable no-undef */ +const loadBrowserModule = () => { + let moduleUnderTest + + jest.isolateModules(() => { + // eslint-disable-next-line global-require + moduleUnderTest = require('./Browser') + }) + + return moduleUnderTest +} + +describe('Browser', () => { + afterEach(() => { + delete global.chrome + delete global.browser + }) + + it('picks the chrome APIs when browser is not available', () => { + const chromeMock = { + runtime: { id: 'test-id', sendMessage: jest.fn() }, + storage: { local: { remove: jest.fn() } }, + } + global.chrome = chromeMock + + const { runtime, storage } = loadBrowserModule() + + expect(runtime).toBe(chromeMock.runtime) + expect(storage).toBe(chromeMock.storage) + }) + + it('picks the browser APIs when available', () => { + const browserMock = { + runtime: { id: 'ff-id', sendMessage: jest.fn() }, + storage: { local: { remove: jest.fn() } }, + } + global.browser = browserMock + + const { runtime, storage } = loadBrowserModule() + + expect(runtime).toBe(browserMock.runtime) + expect(storage).toBe(browserMock.storage) + }) + + it('exposes null APIs outside an extension', () => { + const { runtime, storage } = loadBrowserModule() + + expect(runtime).toBeNull() + expect(storage).toBeNull() + }) + + it('sends a result response and clears the pending request', () => { + const chromeMock = { + runtime: { id: 'test-id', sendMessage: jest.fn() }, + storage: { local: { remove: jest.fn() } }, + } + global.chrome = chromeMock + + const { sendPopupResponse } = loadBrowserModule() + + sendPopupResponse({ + method: 'connect', + requestId: 'r1', + origin: 'https://dapp.example', + result: { address: {} }, + }) + + expect(chromeMock.runtime.sendMessage).toHaveBeenCalledWith( + { + action: 'popupResponse', + method: 'connect', + requestId: 'r1', + origin: 'https://dapp.example', + result: { address: {} }, + }, + expect.any(Function), + ) + + chromeMock.runtime.sendMessage.mock.calls[0][1]() + expect(chromeMock.storage.local.remove).toHaveBeenCalledWith( + 'pendingRequest', + expect.any(Function), + ) + }) + + it('sends an error response without a result field', () => { + const chromeMock = { + runtime: { id: 'test-id', sendMessage: jest.fn() }, + storage: { local: { remove: jest.fn() } }, + } + global.chrome = chromeMock + + const { sendPopupResponse } = loadBrowserModule() + + sendPopupResponse({ + method: 'signTransaction_reject', + requestId: 'r2', + origin: 'https://dapp.example', + error: 'Transaction rejected', + }) + + const payload = chromeMock.runtime.sendMessage.mock.calls[0][0] + expect(payload.error).toBe('Transaction rejected') + expect(payload).not.toHaveProperty('result') + }) + + it('does nothing when no extension APIs are available', () => { + const { sendPopupResponse } = loadBrowserModule() + + expect(() => + sendPopupResponse({ method: 'connect', requestId: 'r3', origin: 'x' }), + ).not.toThrow() + }) +}) diff --git a/src/services/Browser/index.js b/src/services/Browser/index.js new file mode 100644 index 00000000..49c7eb1a --- /dev/null +++ b/src/services/Browser/index.js @@ -0,0 +1,3 @@ +import * as Browser from './Browser' + +export { Browser } diff --git a/webpack.config.js b/webpack.config.js index e67f5250..25ce9dbe 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -28,6 +28,7 @@ const aliases = { '@Cryptos': path.resolve(__dirname, 'src/services/Crypto/index.js'), '@Databases': path.resolve(__dirname, 'src/services/Database/index.js'), '@Entities': path.resolve(__dirname, 'src/services/Entity/index.js'), + '@Browser': path.resolve(__dirname, 'src/services/Browser/index.js'), '@Helpers': path.resolve(__dirname, 'src/utils/Helpers/index.js'), '@Constants': path.resolve(__dirname, 'src/utils/Constants/index.js'), '@TestData': path.resolve(__dirname, 'src/utils/TestData/index.js'), From 582acbcc879c28191aaabb45962755fbe5676aa1 Mon Sep 17 00:00:00 2001 From: Enrico Rubboli Date: Sun, 6 Sep 2026 19:45:05 +0200 Subject: [PATCH 02/52] fix(bridge): structured error codes from the content script Relay errors to pages as { code, message } instead of bare strings so the SDK/bridge can distinguish timeout vs extension-context failures instead of string sniffing. window.mojito turns these into Error.code. --- public/explorer/content-script.js | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/public/explorer/content-script.js b/public/explorer/content-script.js index ffc9c225..b632de88 100644 --- a/public/explorer/content-script.js +++ b/public/explorer/content-script.js @@ -52,7 +52,10 @@ postToPage({ type: 'MINTLAYER_RESPONSE', requestId, - error: 'Response timeout from background', + error: { + code: 'TIMEOUT', + message: 'The wallet did not respond in time. Please try again.', + }, }) }, RESPONSE_TIMEOUT_MS) @@ -75,9 +78,12 @@ postToPage({ type: 'MINTLAYER_RESPONSE', requestId, - error: - api.runtime.lastError.message || - 'Could not connect to background', + error: { + code: 'EXTENSION_ERROR', + message: + api.runtime.lastError.message || + 'Could not reach the wallet. Is it installed and enabled?', + }, }) return } From 46039e62455952bfd310fd77c1d8835ec29589a4 Mon Sep 17 00:00:00 2001 From: Enrico Rubboli Date: Sun, 6 Sep 2026 19:45:53 +0200 Subject: [PATCH 03/52] fix(bridge): make connect/restore sessions SDK-compatible and network-honest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The @mintlayer/sdk Client reads addressesByChain.mintlayer on connect()/restore(), but the background stored and returned only the network-keyed address map — auto-restore could never re-engage. - persist the full session: { address, addressesByChain, network, timestamp } - getSession returns address + addressesByChain + network - ConnectionPage files addresses under the wallet's ACTIVE network key only (it previously labeled the same addresses as both mainnet and testnet) and records the grant's network in the session - sign requests carry the session network; the signing screen rejects with WRONG_NETWORK when the wallet switched networks since the grant instead of silently signing on the other chain - mojito.js restore(): resolve the whole session (or null), track the session network, and use unique request ids — the fixed '__restore' id was swallowed by the content script's duplicate guard, hanging a second concurrent restore (e.g. React strict-mode double Client.create()) - window.mojito.connect/restore also update mojito.network from the session Note: background.js and ConnectionPage.js also carry the earlier hardening from this branch (sender-derived session origin, defensive address shapes). --- public/background.js | 101 ++++++++++++++---- public/mojito.js | 37 +++++-- src/pages/ConnectionPage/ConnectionPage.js | 84 ++++++++++----- .../SignExternalTransaction.js | 18 ++++ 4 files changed, 188 insertions(+), 52 deletions(-) diff --git a/public/background.js b/public/background.js index 241171e4..0c251374 100644 --- a/public/background.js +++ b/public/background.js @@ -65,6 +65,12 @@ } } + // Errors sent to dApps are `{ code, message }` so the caller can + // distinguish rejected / cancelled / busy / not-connected without string + // sniffing. Keep messages free of wallet-brand words: the bridge shows an + // "install the wallet" hint when an error message matches /mojito/i. + const errorOf = (code, message) => ({ code, message }) + // Approval responses and disconnections must come from the wallet's own // pages, never from a content script injected into a website. const isFromExtensionPage = (sender) => @@ -81,7 +87,7 @@ } // Fails a pending approval: clears the slot and answers the waiting dApp. - const failSlot = (slot, errorMessage) => { + const failSlot = (slot, error) => { slot.id = false slot.opening = false @@ -91,15 +97,20 @@ pendingResponses.delete(slot.requestId) slot.requestId = null clearPendingRequest() - respond?.({ error: errorMessage }) + respond?.({ error }) } // Opens one approval window for the request and keeps the dApp's message // channel open until the wallet answers. Returns true while waiting. - const openApprovalWindow = (slot, request, sendResponse, busyError) => { + const openApprovalWindow = (slot, request, sendResponse) => { if (typeof slot.id === 'number' || slot.opening) { if (typeof slot.id === 'number') focusWindow(slot.id) - sendResponse({ error: busyError }) + sendResponse({ + error: errorOf( + 'REQUEST_IN_PROGRESS', + 'An approval window is already open. Complete or close it first.', + ), + }) return false } @@ -120,7 +131,7 @@ slot.id = win?.id ?? false if (typeof slot.id !== 'number') { - failSlot(slot, 'Request cancelled') + failSlot(slot, errorOf('REQUEST_CANCELLED', 'Request cancelled')) return } @@ -128,7 +139,7 @@ // which case onRemoved fired before the id was tracked. api.windows.get(slot.id, (existing) => { if (!existing) { - failSlot(slot, 'Request cancelled') + failSlot(slot, errorOf('REQUEST_CANCELLED', 'Request cancelled')) return } @@ -138,7 +149,13 @@ '[Mintlayer] Storage set error:', api.runtime.lastError, ) - failSlot(slot, 'Could not create the wallet request') + failSlot( + slot, + errorOf( + 'STORAGE_ERROR', + 'Could not create the wallet request. Please try again.', + ), + ) } }) }) @@ -164,19 +181,34 @@ !result || Boolean(error) || (method && method.endsWith('_reject')) if (rejected) { - respond({ error: error || 'User rejected the request' }) + respond({ + error: + error || + errorOf('USER_REJECTED', 'User rejected the request in the wallet'), + }) return } if (method === 'connect' && origin) { connectedSites[origin] = { + // `address` is the network-keyed map for the injected SDK's + // isConnected(); `addressesByChain` is what the @mintlayer/sdk + // Client.connect()/restore() consume; `network` records which + // network the grant was made on so signing can detect a switch. address: result.address, + addressesByChain: result.addressesByChain, + network: result.network, timestamp: Date.now(), } api.storage.local.set({ connectedSites }, () => { if (api.runtime.lastError) { console.error('[Mintlayer] Storage set error:', api.runtime.lastError) - respond({ error: 'Could not save the wallet connection' }) + respond({ + error: errorOf( + 'STORAGE_ERROR', + 'Could not save the wallet connection. Please try again.', + ), + }) return } respond({ result }) @@ -244,11 +276,15 @@ action: 'connect', }, sendResponse, - 'Connection window already open', ) } else if (message.method === 'signTransaction') { if (!connectedSites[origin]) { - sendResponse({ error: 'Not connected. Call connect first.' }) + sendResponse({ + error: errorOf( + 'NOT_CONNECTED', + 'This site is not connected to the wallet. Call connect first.', + ), + }) return false } @@ -259,13 +295,21 @@ requestId: message.requestId, action: 'signTransaction', data: message.params || {}, + // The network the grant was made on: the approval UI compares it + // with the wallet's active network so we never sign on the wrong + // chain after a network switch. + network: connectedSites[origin]?.network, }, sendResponse, - 'Transaction signing window already open', ) } else if (message.method === 'signChallenge') { if (!connectedSites[origin]) { - sendResponse({ error: 'Not connected. Call connect first.' }) + sendResponse({ + error: errorOf( + 'NOT_CONNECTED', + 'This site is not connected to the wallet. Call connect first.', + ), + }) return false } @@ -276,9 +320,9 @@ requestId: message.requestId, action: 'signChallenge', data: message.params || {}, + network: connectedSites[origin]?.network, }, sendResponse, - 'Transaction signing window already open', ) } else if (message.method === 'version') { sendResponse({ result: api.runtime.getManifest().version }) @@ -291,7 +335,12 @@ '[Mintlayer] Storage set error:', api.runtime.lastError, ) - sendResponse({ error: 'Could not disconnect the site' }) + sendResponse({ + error: errorOf( + 'STORAGE_ERROR', + 'Could not disconnect the site. Please try again.', + ), + }) return } sendResponse({ result: true }) @@ -301,13 +350,16 @@ sendResponse({ result: true }) } else if (message.method === 'getSession') { - const sessionOrigin = message.origin || origin - const session = connectedSites[sessionOrigin] + // Origin MUST come from the browser's `sender` — a caller-supplied + // origin would let any page read another origin's session. + const session = connectedSites[origin] if (session && session.address) { sendResponse({ result: { address: session.address, + addressesByChain: session.addressesByChain, + network: session.network, }, }) } else { @@ -316,7 +368,12 @@ return true } else { - sendResponse({ error: 'Unknown method' }) + sendResponse({ + error: errorOf( + 'UNSUPPORTED_METHOD', + `Unsupported wallet method: ${message.method}`, + ), + }) } return false @@ -325,8 +382,12 @@ // Clean up window state and answer waiting dApps when an approval window // is closed without a decision. api.windows.onRemoved.addListener((winId) => { - if (popupSlot.id === winId) failSlot(popupSlot, 'Request cancelled') - if (connectSlot.id === winId) failSlot(connectSlot, 'Request cancelled') + if (popupSlot.id === winId) { + failSlot(popupSlot, errorOf('REQUEST_CANCELLED', 'Request cancelled')) + } + if (connectSlot.id === winId) { + failSlot(connectSlot, errorOf('REQUEST_CANCELLED', 'Request cancelled')) + } }) console.log('[Mintlayer Extension] Background script loaded') diff --git a/public/mojito.js b/public/mojito.js index 80ca73e6..c19ef710 100644 --- a/public/mojito.js +++ b/public/mojito.js @@ -29,8 +29,19 @@ data.requestId === requestId ) { window.removeEventListener('message', handle) - if (data.error) reject(new Error(data.error)) - else resolve(data.result) + if (data.error) { + // Errors are `{ code, message }` (or a legacy plain string) so + // callers can distinguish rejected / locked / cancelled / + // wrong-network instead of string-sniffing. + const err = + typeof data.error === 'string' + ? new Error(data.error) + : new Error(data.error?.message || 'Wallet request failed') + if (data.error?.code) err.code = data.error.code + reject(err) + } else { + resolve(data.result) + } } } @@ -52,13 +63,19 @@ // The session carries the per-network map under `address`; older // responses may already be that map. mojito.connectedAddresses = result?.address ?? result ?? {} + if (result?.network) { + mojito.network = result.network + } return result }, async restore() { return new Promise((resolve) => { const origin = window.location.origin - const requestId = '__restore' + // Unique per call: a fixed id would make a second concurrent restore + // be swallowed by the content script's duplicate-request guard and + // hang forever (e.g. React strict-mode double Client.create()). + const requestId = `__restore_${Math.random().toString(36).slice(2)}` window.postMessage( { @@ -80,10 +97,18 @@ ) { window.removeEventListener('message', handler) - if (data.result?.address) { - mojito.connectedAddresses = data.result.address - resolve(data.result.address) + const session = data.result + if (session?.addressesByChain) { + mojito.connectedAddresses = session.address ?? {} + if (session.network) { + mojito.network = session.network + } + // Resolve the whole session: the SDK's Client.restore() reads + // `addressesByChain.mintlayer.receiving` to re-engage. + resolve(session) } else { + // No grant (or a pre-addressesByChain session): treat as + // "nothing to restore". resolve(null) } } diff --git a/src/pages/ConnectionPage/ConnectionPage.js b/src/pages/ConnectionPage/ConnectionPage.js index 6e211cef..5ed215a8 100644 --- a/src/pages/ConnectionPage/ConnectionPage.js +++ b/src/pages/ConnectionPage/ConnectionPage.js @@ -1,6 +1,6 @@ import { useLocation } from 'react-router' import { useContext, useState } from 'react' -import { AccountContext } from '@Contexts' +import { AccountContext, SettingsContext } from '@Contexts' import { Button, PageWrapper, SiteBadge } from '@BasicComponents' import { ReactComponent as IconShield } from '@Assets/images/icon-shield.svg' import { ReactComponent as IconEye } from '@Assets/images/icon-eye.svg' @@ -9,6 +9,7 @@ import { ReactComponent as IconLoop } from '@Assets/images/icon-loop.svg' import PermissionItem from './PermissionItem' import BitcoinDataNotice from './BitcoinDataNotice' import { sendPopupResponse } from '@Browser' +import { BTC } from '@Helpers' import styles from './ConnectionPage.module.css' const toHexString = (obj) => { @@ -17,11 +18,18 @@ const toHexString = (obj) => { .join('') } +const btcPubKeyOf = (entry) => { + if (!entry || typeof entry === 'string') return undefined + const { pubkey } = Object.values(entry)[0] ?? {} + return pubkey ? toHexString(pubkey) : undefined +} + const UNKNOWN_WEBSITE = 'Unknown Website' export const ConnectionPage = () => { const { state: external_state } = useLocation() const { addresses } = useContext(AccountContext) + const { networkType } = useContext(SettingsContext) const [provideBitcoinData, setProvideBitcoinData] = useState(true) const state = external_state @@ -31,47 +39,59 @@ export const ConnectionPage = () => { const requireBTC = permissions.includes('bitcoin') const isUnknownOrigin = origin === UNKNOWN_WEBSITE + const ml = addresses?.mlAddresses ?? {} + const btc = addresses?.btcAddresses ?? {} + + const btcReceiving = Array.isArray(btc.btcReceivingAddresses) + ? btc.btcReceivingAddresses + : [] + const btcChange = Array.isArray(btc.btcChangeAddresses) + ? btc.btcChangeAddresses + : [] + + const hasWalletAddresses = + Array.isArray(ml.mlReceivingAddresses) && ml.mlReceivingAddresses.length > 0 + const connectButtonExtraStyles = [styles.actionButton] const handleConnect = () => { + if (!hasWalletAddresses) return + + const includeBitcoin = + provideBitcoinData && btcReceiving.length + btcChange.length > 0 + + // The addresses belong to the wallet's ACTIVE network only — filing them + // under both network keys would hand a dApp testnet addresses labeled + // mainnet (or vice versa). `network` records the grant's network so the + // sign flow can reject a wrong-chain request. const sessionData = { origin, connected: true, + network: networkType, address: { - mainnet: { - receiving: addresses?.mlAddresses?.mlReceivingAddresses, - change: addresses?.mlAddresses?.mlChangeAddresses, - }, - testnet: { - receiving: addresses?.mlAddresses?.mlReceivingAddresses, - change: addresses?.mlAddresses?.mlChangeAddresses, + [networkType]: { + receiving: ml.mlReceivingAddresses, + change: ml.mlChangeAddresses, }, }, addressesByChain: { mintlayer: { - receiving: addresses?.mlAddresses?.mlReceivingAddresses, - change: addresses?.mlAddresses?.mlChangeAddresses, + receiving: ml.mlReceivingAddresses, + change: ml.mlChangeAddresses, publicKeys: { - receiving: - addresses?.mlAddresses?.mlReceivingPublicKeys.map(toHexString), - change: addresses?.mlAddresses?.mlChangePublicKeys.map(toHexString), + receiving: ml.mlReceivingPublicKeys?.map(toHexString) ?? [], + change: ml.mlChangePublicKeys?.map(toHexString) ?? [], }, }, - ...(provideBitcoinData && { + ...(includeBitcoin && { bitcoin: { - receiving: addresses?.btcAddresses?.btcReceivingAddresses.map( - (addr) => Object.keys(addr)[0], - ), - change: addresses?.btcAddresses?.btcChangeAddresses.map( - (addr) => Object.keys(addr)[0], - ), + receiving: btcReceiving + .map(BTC.getBtcAddressString) + .filter(Boolean), + change: btcChange.map(BTC.getBtcAddressString).filter(Boolean), publicKeys: { - receiving: addresses?.btcAddresses?.btcReceivingAddresses.map( - (addr) => toHexString(Object.values(addr)[0].pubkey), - ), - change: addresses?.btcAddresses?.btcChangeAddresses.map((addr) => - toHexString(Object.values(addr)[0].pubkey), - ), + receiving: btcReceiving.map(btcPubKeyOf).filter(Boolean), + change: btcChange.map(btcPubKeyOf).filter(Boolean), }, }, }), @@ -158,6 +178,15 @@ export const ConnectionPage = () => { )} + {!hasWalletAddresses && ( +

+ Wallet data incomplete — unlock your wallet and try again +

+ )} +

Only connect to websites you trust. You can reject this request and nothing will be shared. @@ -168,6 +197,7 @@ export const ConnectionPage = () => { diff --git a/src/pages/SignExternalTransaction/SignExternalTransaction.js b/src/pages/SignExternalTransaction/SignExternalTransaction.js index ce976d7a..1da1a3ee 100644 --- a/src/pages/SignExternalTransaction/SignExternalTransaction.js +++ b/src/pages/SignExternalTransaction/SignExternalTransaction.js @@ -171,6 +171,24 @@ export const SignTransactionPage = () => { setIsSigning(true) setSignError('') + // Wrong-chain guard: the session records the network the site was + // granted on. If the wallet has since been switched, refuse instead of + // silently signing with keys for the other chain. + const grantedNetwork = state?.request?.network + if (grantedNetwork && grantedNetwork !== networkType) { + setIsSigning(false) + sendPopupResponse({ + method: 'signTransaction_reject', + requestId: state?.request?.requestId, + origin: state?.request?.origin, + error: { + code: 'WRONG_NETWORK', + message: `Wrong network: this site was connected on '${grantedNetwork}' but the wallet is now on '${networkType}'. Switch the wallet network or reconnect the site.`, + }, + }) + return + } + try { // Validate secret if it's an HTLC claim transaction if (isHTLCClaim && secret && !Secret.validateSecretHex(secret.trim())) { From af7181e4d7cdff40b477c02ba7d33b74cf679734 Mon Sep 17 00:00:00 2001 From: Enrico Rubboli Date: Sun, 6 Sep 2026 19:46:29 +0200 Subject: [PATCH 04/52] test(bridge): window.mojito + background contract tests - manifest validity: MV3 CSP has 'wasm-unsafe-eval' (never 'unsafe-eval'), content script injects at document_start, https-only top-frame matches, mojito.js is the only web-accessible resource - mojito provider: connect/restore lifecycle, session shape (addressesByChain), structured error codes, null restore without a grant, concurrent restores, disconnect revoking page state + wallet session - background: approval-window lifecycle, session persistence, USER_REJECTED / NOT_CONNECTED / UNSUPPORTED_METHOD codes, network stamping on sign requests, forged popupResponse/disconnect from web senders rejected, disconnect actually revoking the stored grant - ignore build/ copies in jest --- jest.config.js | 2 +- public/background.test.js | 381 ++++++++++++++++++++++++++++++++++++++ public/mojito.test.js | 244 ++++++++++++++++++++++++ 3 files changed, 626 insertions(+), 1 deletion(-) create mode 100644 public/background.test.js create mode 100644 public/mojito.test.js diff --git a/jest.config.js b/jest.config.js index 3ee9b074..c6f27ed4 100644 --- a/jest.config.js +++ b/jest.config.js @@ -3,7 +3,7 @@ require('dotenv').config() module.exports = { testEnvironment: 'jsdom', setupFilesAfterEnv: ['/src/setupTests.js'], - testPathIgnorePatterns: ['/node_modules/', '/tests/', 'src/pages'], + testPathIgnorePatterns: ['/node_modules/', '/tests/', '/build/'], transform: { '\\.[jt]sx?$': 'babel-jest', }, diff --git a/public/background.test.js b/public/background.test.js new file mode 100644 index 00000000..9945f271 --- /dev/null +++ b/public/background.test.js @@ -0,0 +1,381 @@ +/** + * Tests for the background service worker (public/background.js) covering the + * bridge-integration contract: connect approval + session persistence + * (addressesByChain + network), structured error codes, sign-request network + * stamping, and disconnect actually revoking the session. + */ +const fs = require('fs') +const path = require('path') + +const BACKGROUND_SRC = fs.readFileSync( + path.join(__dirname, 'background.js'), + 'utf8', +) + +const EXT_ID = 'ext-id-123' + +const dappSender = { + id: 'some-site', + origin: 'https://bridge.example', + url: 'https://bridge.example/page', +} +const extensionSender = { + id: EXT_ID, + origin: `chrome-extension://${EXT_ID}`, + url: `chrome-extension://${EXT_ID}/index.html`, +} + +describe('background service worker', () => { + let messageListeners + let storageData + let createdWindows + + const loadBackground = () => { + // eslint-disable-next-line no-eval + window.eval(BACKGROUND_SRC) + } + + const dispatch = (message, sender) => { + // The reply object is stable: an async approval answers the ORIGINAL + // dispatch's channel later (e.g. from a popupResponse dispatch). + const reply = { current: undefined } + const sendResponse = (response) => { + reply.current = response + } + let keptOpen = false + for (const listener of messageListeners) { + // listeners return true when they will respond asynchronously + // eslint-disable-next-line no-return-assign + keptOpen = keptOpen || listener(message, sender, sendResponse) === true + } + return { reply, keptOpen } + } + + beforeEach(() => { + jest.resetModules() + messageListeners = [] + storageData = {} + createdWindows = [] + + global.browser = undefined + global.chrome = { + runtime: { + id: EXT_ID, + getURL: (p) => `chrome-extension://${EXT_ID}/${p}`, + getManifest: () => ({ version: '1.6.1' }), + onMessage: { + addListener: (fn) => messageListeners.push(fn), + }, + lastError: null, + }, + storage: { + local: { + get: (keys, cb) => { + const result = {} + for (const key of keys) { + if (key in storageData) + result[key] = JSON.parse(JSON.stringify(storageData[key])) + } + cb(result) + }, + set: (obj, cb) => { + Object.assign(storageData, JSON.parse(JSON.stringify(obj))) + cb && cb() + }, + remove: (key, cb) => { + delete storageData[key] + cb && cb() + }, + }, + }, + windows: { + create: (opts, cb) => { + const win = { id: 700 + createdWindows.length } + createdWindows.push(win) + cb(win) + }, + get: (id, cb) => cb({ id }), + update: (id, opts, cb) => cb && cb({ id }), + onRemoved: { addListener: () => {} }, + }, + } + + loadBackground() + }) + + const sessionData = { + address: { testnet: { receiving: ['tmtc1qabc'], change: ['tmtc1qchg'] } }, + addressesByChain: { + mintlayer: { receiving: ['tmtc1qabc'], change: ['tmtc1qchg'] }, + }, + network: 'testnet', + } + + describe('connect', () => { + it('opens one approval window and keeps the channel open', () => { + const { reply, keptOpen } = dispatch( + { requestId: 'r1', method: 'connect', params: {} }, + dappSender, + ) + + expect(reply.current).toBeUndefined() + expect(keptOpen).toBe(true) + expect(createdWindows).toHaveLength(1) + expect(storageData.pendingRequest).toMatchObject({ + action: 'connect', + origin: 'https://bridge.example', + requestId: 'r1', + }) + }) + + it('persists the full session (addressesByChain + network) on approval', () => { + // the approval answers the ORIGINAL connect channel + const connect = dispatch( + { requestId: 'r1', method: 'connect', params: {} }, + dappSender, + ) + dispatch( + { + action: 'popupResponse', + method: 'connect', + requestId: 'r1', + origin: 'https://bridge.example', + result: sessionData, + }, + extensionSender, + ) + + expect(connect.reply.current.result).toEqual(sessionData) + expect( + storageData.connectedSites['https://bridge.example'], + ).toMatchObject({ + address: sessionData.address, + addressesByChain: sessionData.addressesByChain, + network: 'testnet', + }) + // the pending request is consumed + expect(storageData.pendingRequest).toBeUndefined() + }) + + it('rejects with USER_REJECTED when the wallet denies', () => { + const connect = dispatch( + { requestId: 'r2', method: 'connect', params: {} }, + dappSender, + ) + dispatch( + { + action: 'popupResponse', + method: 'connect', + requestId: 'r2', + origin: 'https://bridge.example', + result: null, + }, + extensionSender, + ) + + expect(connect.reply.current.error).toMatchObject({ + code: 'USER_REJECTED', + message: expect.stringContaining('rejected'), + }) + }) + + it('answers an already-connected site immediately, without a popup', () => { + const connect = dispatch( + { requestId: 'r1', method: 'connect', params: {} }, + dappSender, + ) + dispatch( + { + action: 'popupResponse', + method: 'connect', + requestId: 'r1', + origin: 'https://bridge.example', + result: sessionData, + }, + extensionSender, + ) + + const { keptOpen } = dispatch( + { requestId: 'r9', method: 'connect', params: {} }, + dappSender, + ) + expect(keptOpen).toBe(false) + expect(createdWindows).toHaveLength(1) // no second window + // the repeat request is answered synchronously with the stored session + const repeat = dispatch( + { requestId: 'r10', method: 'connect', params: {} }, + dappSender, + ) + expect( + repeat.reply.current.result.addressesByChain.mintlayer.receiving, + ).toEqual(['tmtc1qabc']) + }) + + it('ignores popup responses from non-extension senders', () => { + dispatch({ requestId: 'r1', method: 'connect', params: {} }, dappSender) + const { reply } = dispatch( + { + action: 'popupResponse', + method: 'connect', + requestId: 'r1', + origin: 'https://bridge.example', + result: sessionData, + }, + dappSender, // a web page trying to forge an approval + ) + + expect(reply.current).toBeUndefined() + }) + }) + + describe('signTransaction', () => { + beforeEach(() => { + // establish a connection first + dispatch({ requestId: 'r1', method: 'connect', params: {} }, dappSender) + dispatch( + { + action: 'popupResponse', + method: 'connect', + requestId: 'r1', + origin: 'https://bridge.example', + result: sessionData, + }, + extensionSender, + ) + }) + + it('stamps the request with the session network for the wrong-chain guard', () => { + const { keptOpen } = dispatch( + { + requestId: 's1', + method: 'signTransaction', + params: { txData: { JSONRepresentation: {} } }, + }, + dappSender, + ) + + expect(keptOpen).toBe(true) + expect(storageData.pendingRequest).toMatchObject({ + action: 'signTransaction', + network: 'testnet', + data: { txData: { JSONRepresentation: {} } }, + }) + }) + + it('rejects with NOT_CONNECTED when the site never connected', () => { + const { reply } = dispatch( + { requestId: 's2', method: 'signTransaction', params: {} }, + { + id: 'other', + origin: 'https://evil.example', + url: 'https://evil.example/', + }, + ) + + expect(reply.current.error).toMatchObject({ code: 'NOT_CONNECTED' }) + }) + }) + + describe('getSession / disconnect (restore + revocation)', () => { + it('returns the full session for a connected origin', () => { + storageData.connectedSites = { + 'https://bridge.example': { ...sessionData, timestamp: 1 }, + } + // re-sync the worker's in-memory map via a connect+approve + dispatch({ requestId: 'r1', method: 'connect', params: {} }, dappSender) + dispatch( + { + action: 'popupResponse', + method: 'connect', + requestId: 'r1', + origin: 'https://bridge.example', + result: sessionData, + }, + extensionSender, + ) + + const { reply: getSessionReply } = dispatch( + { method: 'getSession' }, + dappSender, + ) + expect(getSessionReply.current.result).toEqual({ + address: sessionData.address, + addressesByChain: sessionData.addressesByChain, + network: 'testnet', + }) + }) + + it('disconnect revokes the grant: getSession returns null afterwards', () => { + dispatch({ requestId: 'r1', method: 'connect', params: {} }, dappSender) + dispatch( + { + action: 'popupResponse', + method: 'connect', + requestId: 'r1', + origin: 'https://bridge.example', + result: sessionData, + }, + extensionSender, + ) + + const { reply } = dispatch({ method: 'disconnect' }, dappSender) + expect(reply.current.result).toBe(true) + expect( + storageData.connectedSites['https://bridge.example'], + ).toBeUndefined() + + const session = dispatch({ method: 'getSession' }, dappSender) + expect(session.reply.current.result).toBeNull() + }) + + it('ignores disconnect forged from a web page sender', () => { + // forge from a different origin: it must not delete the target origin + storageData.connectedSites = { + 'https://bridge.example': { address: sessionData.address }, + } + const { reply } = dispatch( + { method: 'disconnect' }, + { + id: 'other', + origin: 'https://evil.example', + url: 'https://evil.example/', + }, + ) + + expect(reply.current.result).toBe(true) // nothing to delete for evil.example + // and it only deletes its OWN origin — bridge.example untouched + // (in-memory map is seeded through connect/approve only, so assert + // via a real connect+approve cycle): + dispatch({ requestId: 'r1', method: 'connect', params: {} }, dappSender) + dispatch( + { + action: 'popupResponse', + method: 'connect', + requestId: 'r1', + origin: 'https://bridge.example', + result: sessionData, + }, + extensionSender, + ) + dispatch( + { method: 'disconnect' }, + { + id: 'other', + origin: 'https://evil.example', + url: 'https://evil.example/', + }, + ) + expect(storageData.connectedSites['https://bridge.example']).toBeDefined() + }) + }) + + describe('errors', () => { + it('unknown methods get a machine-readable code', () => { + const { reply } = dispatch({ method: 'requestSecretHash' }, dappSender) + expect(reply.current.error).toMatchObject({ + code: 'UNSUPPORTED_METHOD', + message: expect.stringContaining('requestSecretHash'), + }) + }) + }) +}) diff --git a/public/mojito.test.js b/public/mojito.test.js new file mode 100644 index 00000000..1900dd22 --- /dev/null +++ b/public/mojito.test.js @@ -0,0 +1,244 @@ +/** + * Tests for the injected `window.mojito` provider (public/mojito.js) against + * the @mintlayer/sdk contract: + * - connect() resolves the session (SDK reads `addressesByChain.mintlayer`) + * - restore() resolves the stored session or null, unique request ids + * - errors carry a machine-readable `code` + * - disconnect() revokes the wallet-side session and clears page state + */ +const fs = require('fs') +const path = require('path') + +const MOJITO_SRC = fs.readFileSync(path.join(__dirname, 'mojito.js'), 'utf8') + +// jsdom's MessageEvent.source is a different wrapper object than the global +// window (in browsers they are identical), which would make the provider's +// `event.source !== window` guard drop every message. +Object.defineProperty(MessageEvent.prototype, 'source', { + get: () => window, + configurable: true, +}) + +describe('window.mojito provider', () => { + let responses // requestId -> result/error queued by the fake content script + let seenRequestIds // emulates the content script duplicate-request guard + let postedEvents + const messageListeners = new Set() + + const onMessage = (fn) => { + messageListeners.add(fn) + window.addEventListener('message', fn) + } + + // Fake content script: answers MINTLAYER_REQUEST messages from the queue, + // with the same duplicate-request semantics as the real one. The queued + // payload is looked up after a tick so listeners registered later in a + // test can still queue a reply for the request they just observed. + const installContentScript = () => { + onMessage((event) => { + if (event.data?.type !== 'MINTLAYER_REQUEST') return + const { requestId } = event.data + if (seenRequestIds.has(requestId)) return + seenRequestIds.add(requestId) + + setTimeout(() => { + const payload = responses.get(requestId) + if (!payload) return + window.postMessage( + { type: 'MINTLAYER_RESPONSE', requestId, ...payload }, + '*', + ) + }, 0) + }) + } + + // Answers every getSession with the given session (like a connected + // background would). + const answerGetSessions = (session) => { + onMessage((event) => { + if (event.data?.type !== 'MINTLAYER_REQUEST') return + if (event.data.method !== 'getSession') return + const { requestId } = event.data + setTimeout(() => { + window.postMessage( + { type: 'MINTLAYER_RESPONSE', requestId, result: session }, + '*', + ) + }, 0) + }) + } + + const flushMessages = () => new Promise((resolve) => setTimeout(resolve, 0)) + + beforeEach(() => { + responses = new Map() + seenRequestIds = new Set() + postedEvents = [] + window.mojito = undefined + + onMessage((event) => { + if (event.data?.type === 'MINTLAYER_EVENT') postedEvents.push(event.data) + }) + installContentScript() + // eslint-disable-next-line no-eval + window.eval(MOJITO_SRC) + }) + + afterEach(() => { + for (const fn of messageListeners) { + window.removeEventListener('message', fn) + } + messageListeners.clear() + }) + + describe('surface', () => { + it('exposes the methods the @mintlayer/sdk provider calls', () => { + expect(typeof window.mojito.connect).toBe('function') + expect(typeof window.mojito.restore).toBe('function') + expect(typeof window.mojito.disconnect).toBe('function') + expect(typeof window.mojito.request).toBe('function') + expect(typeof window.mojito.isConnected).toBe('function') + // Client.create({ autoRestore: !!window.mojito?.restore }) + expect(!!window.mojito?.restore).toBe(true) + }) + }) + + describe('connect', () => { + it('resolves the session and tracks the per-network address map', async () => { + const session = { + address: { + testnet: { receiving: ['tmtc1qabc'], change: ['tmtc1qchg'] }, + }, + addressesByChain: { + mintlayer: { receiving: ['tmtc1qabc'], change: ['tmtc1qchg'] }, + }, + network: 'testnet', + } + onMessage((event) => { + if (event.data?.type !== 'MINTLAYER_REQUEST') return + if (event.data.method !== 'connect') return + const { requestId } = event.data + setTimeout(() => { + window.postMessage( + { type: 'MINTLAYER_RESPONSE', requestId, result: session }, + '*', + ) + }, 0) + }) + + const result = await window.mojito.connect() + + // SDK Client.connect(): addresses.addressesByChain.mintlayer + expect(result.addressesByChain.mintlayer.receiving).toEqual(['tmtc1qabc']) + expect(window.mojito.connectedAddresses.testnet.receiving).toEqual([ + 'tmtc1qabc', + ]) + expect(window.mojito.network).toBe('testnet') + }) + + it('rejects with the structured error code when the user denies', async () => { + onMessage((event) => { + if (event.data?.type !== 'MINTLAYER_REQUEST') return + const { requestId } = event.data + setTimeout(() => { + window.postMessage( + { + type: 'MINTLAYER_RESPONSE', + requestId, + error: { + code: 'USER_REJECTED', + message: 'User rejected the request', + }, + }, + '*', + ) + }, 0) + }) + + await expect(window.mojito.connect()).rejects.toMatchObject({ + code: 'USER_REJECTED', + message: 'User rejected the request', + }) + }) + }) + + describe('restore', () => { + it('resolves the full session (SDK reads addressesByChain) after reload', async () => { + const session = { + address: { + mainnet: { receiving: ['mtc1qxyz'], change: ['mtc1qchg'] }, + }, + addressesByChain: { + mintlayer: { receiving: ['mtc1qxyz'], change: ['mtc1qchg'] }, + }, + network: 'mainnet', + } + answerGetSessions(session) + + const restored = await window.mojito.restore() + + expect(restored.addressesByChain.mintlayer.receiving).toEqual([ + 'mtc1qxyz', + ]) + expect(window.mojito.network).toBe('mainnet') + expect(window.mojito.isConnected()).toBe(true) + }) + + it('resolves null when no grant exists (no auto-restore)', async () => { + answerGetSessions(null) + + await expect(window.mojito.restore()).resolves.toBeNull() + }) + + it('two concurrent restores both resolve (no fixed request id)', async () => { + const session = { + address: { testnet: { receiving: ['tmtc1qabc'], change: [] } }, + addressesByChain: { + mintlayer: { receiving: ['tmtc1qabc'], change: [] }, + }, + network: 'testnet', + } + answerGetSessions(session) + + // The duplicate-request guard used to hang the second call when the + // id was the fixed '__restore' string. + const [first, second] = await Promise.all([ + window.mojito.restore(), + window.mojito.restore(), + ]) + expect(first).toEqual(session) + expect(second).toEqual(session) + }) + }) + + describe('disconnect', () => { + it('clears page state, notifies the page and asks the wallet to revoke', async () => { + window.mojito.connectedAddresses = { + testnet: { receiving: ['tmtc1qabc'], change: [] }, + } + + onMessage((event) => { + if (event.data?.type !== 'MINTLAYER_REQUEST') return + const { requestId, method } = event.data + expect(method).toBe('disconnect') + setTimeout(() => { + window.postMessage( + { type: 'MINTLAYER_RESPONSE', requestId, result: true }, + '*', + ) + }, 0) + }) + + await window.mojito.disconnect() + // allow the posted MINTLAYER_EVENT to dispatch + await flushMessages() + + expect(window.mojito.isConnected()).toBe(false) + expect( + postedEvents.some( + (e) => e.event === 'disconnect' && e.type === 'MINTLAYER_EVENT', + ), + ).toBe(true) + }) + }) +}) From 1973dbed43f43c7264744c99d101773604c485d3 Mon Sep 17 00:00:00 2001 From: Enrico Rubboli Date: Sun, 6 Sep 2026 19:47:04 +0200 Subject: [PATCH 05/52] docs(bridge): extension <-> SDK contract, https-only injection scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - document the final window.mojito surface, session shape, error codes and the @mintlayer/sdk v1.0.38 mapping (doc/bridge-contract.md) - Chromium manifest: content script runs on https pages in top frames only (plain http limited to localhost for development) and mojito.js is the only web-accessible resource — dApps keep connecting through the manual approval popup, no static allowlist - manifest.test.js asserts CSP validity (wasm-unsafe-eval, no unsafe-eval), document_start injection and the web-accessible-resources scope --- HANDOFF.md | 169 ++++++++++++++++++++++++++++++++++++ doc/bridge-contract.md | 101 +++++++++++++++++++++ public/manifest.test.js | 66 ++++++++++++++ public/manifestDefault.json | 15 ++-- 4 files changed, 344 insertions(+), 7 deletions(-) create mode 100644 HANDOFF.md create mode 100644 doc/bridge-contract.md create mode 100644 public/manifest.test.js diff --git a/HANDOFF.md b/HANDOFF.md new file mode 100644 index 00000000..beedcd8a --- /dev/null +++ b/HANDOFF.md @@ -0,0 +1,169 @@ +# HANDOFF — session notes (2026-09-06) + +Branch: `A-1217833856533186-review-fixes` (dark UI refactor branch). Changes are +UNCOMMITTED — everything below is in the working tree and in `build/`. + +**Read `REVIEW-PLAN.md` before picking up new work** — it holds the follow-up +backlog from the 4-agent security/quality/UX/DRY review plus accepted risks. +**Bridge/dApp work must follow `doc/bridge-contract.md`** — the final +`window.mojito` surface, session shape and error codes. + +## Bridge integration fixes (2026-09-06, latest) + +Fixed the extension side of the `@mintlayer/sdk` bridge flow: + +- **Restore was fully broken**: the background stored only + `{ address, timestamp }` and `getSession` returned only `address`, while the + SDK needs `addressesByChain.mintlayer`. Sessions now persist + `{ address, addressesByChain, network }`; `window.mojito.restore()` resolves + the full session (or `null`). +- **Sessions lied about networks**: ConnectionPage filed the wallet's + current-network addresses under BOTH network keys. Now only the active + network key is filled and the session records `network`. +- **Wrong-network signing**: sign requests carry the session's network; the + signing screen fails with `WRONG_NETWORK` if the wallet switched networks + since the grant (instead of silently signing on the other chain). +- **Structured errors**: all dApp-facing errors are `{ code, message }` + (`USER_REJECTED`, `REQUEST_CANCELLED`, `REQUEST_IN_PROGRESS`, + `NOT_CONNECTED`, `WRONG_NETWORK`, `UNSUPPORTED_METHOD`, `TIMEOUT`, + `EXTENSION_ERROR`, `STORAGE_ERROR`). Messages avoid the brand word on + purpose (the bridge shows "install the wallet" on /mojito/i messages). +- **Concurrent restore deadlock**: restore used a fixed `__restore` request id + which the content script's duplicate guard swallowed — a second + `Client.create()` hung forever. Ids are now unique. +- CSP (`'wasm-unsafe-eval'`, no `unsafe-eval`) verified in both manifests; no + meta CSP in extension HTML. Injection at `document_start`, idempotent. +- Tests: `public/manifest.test.js`, `public/mojito.test.js`, + `public/background.test.js` (connect/restore lifecycle, session shapes, + error codes, wrong-network stamping, disconnect revocation, manifest CSP). +- Manual smoke checklist (unpacked install): connect from a test page → + approve → reload page (auto-restore, no prompt) → build+sign an intent tx + (approve) → disconnect → reload page → no restored session. + +## Review remediation done this session (P1–P5, all built + verified, + +124 suites / 662 tests green) + +- **Money math**: amount regex escaped (rejects `1e3`); `getParsedTransactions` + accumulation fixed + regression tests; Decimal everywhere (`getAmountInCoins/ +Atoms`, token/coin/delegation sums, `spendFromDelegation`); Dashboard 24h + stats null-safe when yesterday rates are missing (`proportionDiffs`/ + `balanceDiffs` may be `null` — treat as "show nothing", never `0`). +- **Providers**: `fetchAllData` try/catch/finally + ref mutex (flags can't + wedge) + new `fetchError` context field; network-switch effect owns + `cancelAllRequests()`; `fetchDelegations` always releases its flag; + ExchangeRates parallel fetch + error/`fetching` state; BitcoinProvider never + sets `btcUtxos` undefined, dep-less effect → `[networkType]`. +- **UX**: mock data deleted (NFT tab → real empty state, no demo activity + row); `navigate('/wallet')` dead ends, post-send returns, `/wallet/:coinType` + and unknown routes → `/dashboard` (pages/Wallet deleted); sign/confirm/ + SignChallenge/MessagePage/CreateDelegation overlay/Delegation cards restyled + onto be-\* tokens; PopUp close icon visible; Sparkline `responsive` prop; + `body min-width: 400px` removed. +- **Security**: `getSession` uses sender origin only; `customAPIServers` + override removed; production source maps off; SignInternal mock selector + dev-gated; deps bumped (ecpair 3.0.2, react-router-dom 7.18.3, bn.js); + Chromium manifest HTTPS-only + top-frame only + WAR not `` + (dApps connect via manual approval popup — no static allowlist, per user). +- **Dead code / DRY**: `WALLETS_NAVIGATION`, containers/Dashboard cluster, + `src/mocks/**` (+ `@Mocks` aliases), Navigation `customNavigation`, `exact` + on Route; provider on `MINTLAYER_ENDPOINTS` (placeholders aligned to server + contract); `ML.getUnconfirmedTransactionKey` replaces 8 hand-built keys; + `'testnet'` literals → constant. + +## Pending next task (resume here) + +Pick from `REVIEW-PLAN.md` (ordered). Suggested first: #1 real NFT data, +#3 BTC HTLC signing fix (broken today), #4 useMlTransactionForm extraction. + +## Feature work done earlier this session (all built + verified) + +0c. **New Stake page + Dashboard quick action**: + +- `src/pages/StakePage/` — new dark-design staking screen at `/staking`: + total staked (`mlDelegationsBalance`), earned-from-staking stat + (live total − net contributions, real delegation rewards accrue into the + balance), active/inactive delegation counts, stake-growth Sparkline + rebuilt from on-chain txs, delegation list (reuse `Wallet.DelegationList` + — detail popup / add-funds / withdraw still work). Single action button: + "Pool list" (explorer) — Create delegation / Staking guide buttons were + removed (delegation management happens on the explorer). +- `ML.buildStakeGrowthSeries` (utils/Helpers/ML) — cumulative series from + `DelegateStaking` (+) / `Delegate Withdrawal` (−) txs, last point + anchored to the live total; 5 unit tests. +- `Icon.tsx` — new `stake` line icon; Dashboard quick actions now 4-wide + (Send / Receive / Stake / Activity), grid → `repeat(4, 1fr)`. +- Old staking retired: `/wallet/:coinType/staking` → ``, + deleted `src/pages/Staking` + `CurrentStaking`; post-action returns in + CreateDelegation / DelegationStake / DelegationWithdraw now go to + `/staking`; Header back button for the old staking URL → `/dashboard`. +- GOTCHA for future work: `@ContainerComponents` barrel exports NAMESPACES + (`Wallet`, `Dashboard`, ...) — `import { DelegationList }` silently + resolves to `undefined`. Use `Wallet.DelegationList`. + +0b. **Removed old wallet entries from the slider menu** (3-lines icon): + +- `Navigation.tsx` — dropped "Bitcoin Wallet" (`/wallet/Bitcoin`) and + "Mintlayer Wallet" (`/wallet/Mintlayer`) menu items + their logo imports; + menu is now Dashboard / Settings (+ dev-only entries). Regression test + added in `Navigation.test.js`. +- `Dashboard.js` — quick-action "Send" now goes straight to + `/wallet/Mintlayer/send-ml-transaction` instead of the old wallet page. +- NOTE: the old `/wallet/:coinType` pages/routes still exist and are + reachable — they are the send/staking/swap/sign flows and the post-send + return targets (`ConfirmBtcTransaction`, `SignInternalTransaction`, + `SendMlTransaction`, `NftSend` navigate back to `/wallet/`). + Full removal of those pages is a separate, bigger task. + +0. **AssetPage + Dashboard on real token data** (latest): + - `AssetPage.js` — `MOCK_TOKENS` fully removed. Token ids now read + `tokenBalances` from `MintlayerContext` (ticker via + `token_info.token_ticker.string`, balance via token-scoped + `useMlWalletInfo(undefined, id)` which also filters txs by `token_id`). + No fake price/fiat/spark/24h pill for tokens (coins compute 24h change + from the spark history instead of the old hardcoded 0). Token info KV + shows real Ticker / Token ID / Decimals / Balance. Send button hidden + for tokens (see follow-up above); Receive still works (ML address). + - `Dashboard.js` — dropped the `MOCK_TOKENS` demo rows; only real + `tokenBalances` tokens remain (NFT tab still mocked, see follow-up). + - `AssetPage.test.js` — token fixture through mocked `@Contexts` + `tokenBalances` + token-aware `useMlWalletInfo` mock; asserts real + ticker/decimals render and no `$` appears for tokens. + +## Previously done in this session (built + verified) + +1. **Connect-flow crash fix** — `ConnectionPage.handleConnect` fully defensive + (old-store blobs with no public keys / string BTC addresses supported); + missing `mlReceivingAddresses` → disabled Connect + inline warning; + `sendPopupResponse` result omits empty bitcoin block. +2. **ErrorBoundary self-diagnosing** — renders `error.message` in a `` + line (`ErrorBoundary.tsx`). +3. **Crash fixes found via the new boundary / webpack warnings**: + - `utils/Helpers/Transactions/Transactions.js:1` — was + `import { Format }` (undefined!) → `import * as Format`. + - `src/hooks/index.js` — `useOneDayAgoHist` existed but was never exported + from the barrel; AssetPage imported it from `@Hooks` → runtime crash. + - Shared helper `BTC.getBtcAddressString` (`utils/Helpers/BTC/BTC.js:312`) + for both stored BTC shapes (string / `{ [address]: { pubkey } }`); applied + in ReceivePage, AssetPage, SendBtcTransaction, BitcoinProvider, Dashboard, + ConnectionPage. + - `jest.config.js` — removed `src/pages` from `testPathIgnorePatterns` + (AssetPage.test.js was silently excluded AND failing). +4. **Receive screen** — chain seeded from navigation state (AssetPage passes + `{ chain }`), defaults to Mintlayer; real QR via new basic `QrCode` + component (`react-qr-code`, already a dep); `QrPlaceholder` only when no + address. +5. **TokenIcon** — real Mintlayer logo (`logo.svg`) and BTC logo + (`btc-logo.svg`) for ML/BTC; ML tile = dark neutral gray gradient + (`oklch(0.36→0.26)`, hue 70) per user choice; procedural fallback for other + tokens. + +## Conventions / commands + +- Lint: `npx eslint 'src/**/*.{js,ts,tsx}'` +- Tests: `NODE_ENV=test npx jest --silent` (all suites must stay green) +- Build (output → `build/`, extension runs UNPACKED from there): + `npx env-cmd -f ./.env.production npx webpack --mode production && node ./src/version/version-mojito.js && cp build/index.html build/popup.html` +- Reload WITHOUT restarting Chromium: chrome://extensions → Mojito → ↻, then + close/reopen the side panel. +- PRs: branch off `dev`, title `A-[task id]: [description]` (see CONTRIBUTING.md). diff --git a/doc/bridge-contract.md b/doc/bridge-contract.md new file mode 100644 index 00000000..4dc79906 --- /dev/null +++ b/doc/bridge-contract.md @@ -0,0 +1,101 @@ +# Extension ↔ SDK contract — `window.mojito` + +What the Mojito extension injects and what `@mintlayer/sdk` +(`MojitoAccountProvider` → `Client`) may rely on. Keep both sides aligned +against this file. + +## Injection + +- `public/explorer/content-script.js` runs at `document_start` (Chromium: + HTTPS pages, top frames only; Firefox: allowlisted origins) and injects + `public/mojito.js` from `chrome-extension:///mojito.js` + (declared in `web_accessible_resources`). +- Injection is idempotent: `window.mojito` is only assigned if absent. +- The content script relays `MINTLAYER_REQUEST` (page → background) and + `MINTLAYER_RESPONSE` (background → page) via `window.postMessage`, and + forwards `MINTLAYER_EVENT` broadcast events to the page. + +## `window.mojito` surface + +| Member | Type | Notes | +| ------------------------- | -------------------------- | ------------------------------------------------------------------------------------------------------------- | +| `isExtension` | `true` | presence detection | +| `version` | string | extension version | +| `network` | `'mainnet' \| 'testnet'` | defaults `'testnet'`; updated from the session on connect/restore | +| `connectedAddresses` | object | network-keyed map `{ [network]: { receiving: string[], change: string[] } }` | +| `isConnected()` | `boolean` | true when the current network has receiving addresses | +| `request(method, params)` | `Promise` | generic relay; rejects with `Error` carrying `.code` (see errors) | +| `connect()` | `Promise` | opens the approval window unless already granted; resolves the stored session | +| `restore()` | `Promise` | silent re-connect from the persisted grant; `null` when none | +| `disconnect()` | `Promise` | **revokes the grant in the wallet** (deletes `connectedSites[origin]`), clears page state, emits `disconnect` | +| `on(event, cb)` | void | `'accountsChanged'`, `'disconnect'` | + +## Session object (resolved by `connect()` / `restore()`) + +```ts +{ + network: 'mainnet' | 'testnet', // network the grant was made on + address: { // network-keyed (only the granted network) + [network]: { receiving: string[], change: string[] }, + }, + addressesByChain: { // consumed by @mintlayer/sdk Client + mintlayer: { receiving: string[], change: string[], publicKeys?: { receiving: string[], change: string[] } }, + bitcoin?: { receiving: string[], change: string[], publicKeys?: ... }, // only when the user opted in + }, + timestamp: number, +} +``` + +Notes: + +- `address` contains **only** the wallet's active network — the extension + never labels addresses with a network they don't belong to. +- `restore()` resolves `null` when there is no grant (SDK treats it as + "no auto-restore"). +- Restores use unique request ids; concurrent `restore()` calls all resolve. + +## Error model + +All dApp-facing errors are `{ code, message }`; `window.mojito` rejects with +`Error(message)` and `error.code` set. Messages intentionally never contain +the wallet brand (the bridge maps `/mojito/i` messages to an +"install the wallet" hint — only the SDK's own +`'Mojito extension not available'` when `window.mojito` is missing should +trigger that). + +| code | meaning | +| --------------------- | ------------------------------------------------------------------- | +| `USER_REJECTED` | user denied the approval | +| `REQUEST_CANCELLED` | approval window closed without a decision | +| `REQUEST_IN_PROGRESS` | an approval window is already open | +| `NOT_CONNECTED` | sign/challenge without a prior connect grant | +| `WRONG_NETWORK` | session granted on a different network than the wallet's active one | +| `UNSUPPORTED_METHOD` | method not implemented by the wallet | +| `TIMEOUT` | no answer from the wallet within 5 minutes | +| `EXTENSION_ERROR` | content script could not reach the background | +| `STORAGE_ERROR` | wallet failed to persist/read state | + +## Method relayed by `request()` + +| method | params | result | +| ---------------------------- | ---------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | +| `connect` | `{ permissions?: string[] }` | session (approval window) | +| `getSession` | — | session or `null` | +| `disconnect` | — | `true` (grant revoked) | +| `signTransaction` | `{ txData }`; `txData.intent` present ⇒ bridge intent flow | plain hex string, or `{ transactionHex, intentEncode }` when `intent` was present (no `0x` prefixes) | +| `signChallenge` | `{ message, address? }` | `{ message, address, signature }` | +| `checkConnection`, `version` | — | diagnostics | + +## SDK mapping (v1.0.38) + +- `Client.create({ network, autoRestore })` → `autoRestore` engages when + `window.mojito.restore` exists; `restore()` fills + `connectedAddresses` from `addressesByChain.mintlayer`. +- `client.getBalances()` / `getAddresses()` are computed **SDK-side** from the + connected addresses via its own API provider — no wallet method involved. + `getBalances()` returns `{ coin: number, token: Record }` + (note: tokens live under `.token`, not at the top level). +- `client.buildTransfer()` builds locally from the SDK's API provider; + `client.signIntentTransaction(tx)` / `client.signTransaction(tx)` relay as + `request('signTransaction', { txData })`. +- Not implemented wallet-side: `requestSecretHash` → `UNSUPPORTED_METHOD`. diff --git a/public/manifest.test.js b/public/manifest.test.js new file mode 100644 index 00000000..84f6d600 --- /dev/null +++ b/public/manifest.test.js @@ -0,0 +1,66 @@ +const fs = require('fs') +const path = require('path') + +const readManifest = (name) => + JSON.parse(fs.readFileSync(path.join(__dirname, name), 'utf8')) + +describe('extension manifest (bridge integration contract)', () => { + const chromium = readManifest('manifestDefault.json') + const firefox = readManifest('manifestFirefox.json') + + describe('CSP / WebAssembly (MV3)', () => { + it.each([ + ['chromium', chromium], + ['firefox', firefox], + ])('%s manifest allows wasm without unsafe-eval', (_name, manifest) => { + const csp = manifest.content_security_policy.extension_pages + + // The SDK and the wallet itself compile WASM — MV3 requires + // 'wasm-unsafe-eval'; 'unsafe-eval' must never appear. + expect(csp).toContain("'wasm-unsafe-eval'") + expect(csp).not.toContain("'unsafe-eval'") + expect(csp).toContain("script-src 'self'") + expect(csp).toContain("object-src 'self'") + }) + }) + + describe('content script injection (race-on-load contract)', () => { + it('injects at document_start so window.mojito exists before page scripts', () => { + expect(chromium.content_scripts[0].run_at).toBe('document_start') + }) + + it('injects into https pages (top frames only)', () => { + const script = chromium.content_scripts[0] + expect(script.all_frames).toBe(false) + for (const match of script.matches) { + const scheme = match.split('://')[0] + expect(['https', 'http', 'urn']).toContain(scheme) + if (scheme === 'http') { + // plain http is only allowed for local development + expect(match).toMatch(/^http:\/\/(localhost|127\.0\.0\.1)\//) + } + } + }) + + it('exposes only mojito.js to pages, not ', () => { + const war = chromium.web_accessible_resources[0] + expect(war.resources).toEqual(['mojito.js']) + expect(war.matches).not.toContain('') + }) + }) + + describe('manifest shape', () => { + it('is valid MV3 with a module service worker', () => { + expect(chromium.manifest_version).toBe(3) + expect(chromium.background.service_worker).toBe('background.js') + expect(chromium.background.type).toBe('module') + }) + + it('mojito.js exists next to the manifests and registers the SDK', () => { + const source = fs.readFileSync(path.join(__dirname, 'mojito.js'), 'utf8') + expect(source).toContain('window.mojito') + expect(source).toContain('restore') + expect(source).not.toContain('__APP_VERSION__PLACEHOLDER') + }) + }) +}) diff --git a/public/manifestDefault.json b/public/manifestDefault.json index ea347462..0330fc96 100644 --- a/public/manifestDefault.json +++ b/public/manifestDefault.json @@ -31,13 +31,14 @@ "content_scripts": [ { "run_at": "document_start", - "all_frames": true, + "all_frames": false, "matches": [ - "*://*/*", - "*://localhost/*", - "*://explorer.mintlayer.org/*", - "*://lovelace.explorer.mintlayer.org/*", - "*://blockexplorer-staging.mintlayer.org/*" + "https://*/*", + "http://localhost/*", + "http://127.0.0.1/*", + "https://explorer.mintlayer.org/*", + "https://lovelace.explorer.mintlayer.org/*", + "https://blockexplorer-staging.mintlayer.org/*" ], "js": ["explorer/content-script.js"] } @@ -45,7 +46,7 @@ "web_accessible_resources": [ { "resources": ["mojito.js"], - "matches": [""] + "matches": ["https://*/*", "http://localhost/*", "http://127.0.0.1/*"] } ] } From 28aa5b1f5a076c11d2a5de2d88defdba100dce29 Mon Sep 17 00:00:00 2001 From: Enrico Rubboli Date: Sun, 6 Sep 2026 20:45:07 +0200 Subject: [PATCH 06/52] fix(bridge): synthesize addressesByChain for stale connect responses The @mintlayer/sdk Client.connect() reads `addresses.addressesByChain.mintlayer` unguarded; a popup response that carries only the `address` map (older extension build still loaded, or a partially-updated install) crashes the dApp with "Cannot read properties of undefined (reading 'mintlayer')". The injected window.mojito.connect() now synthesizes the chain-keyed view when it is missing, so any combination of extension builds keeps the SDK connect path working. Regression test included. --- public/mojito.js | 23 ++++++++++++++++++++++- public/mojito.test.js | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/public/mojito.js b/public/mojito.js index c19ef710..167088f8 100644 --- a/public/mojito.js +++ b/public/mojito.js @@ -62,10 +62,31 @@ const result = await mojito.request('connect') // The session carries the per-network map under `address`; older // responses may already be that map. - mojito.connectedAddresses = result?.address ?? result ?? {} + const map = result?.address ?? result ?? {} + mojito.connectedAddresses = map if (result?.network) { mojito.network = result.network } + // The @mintlayer/sdk Client.connect() reads + // `addresses.addressesByChain.mintlayer` unguarded — a response from + // a stale popup (map under `address` only) would crash the dApp with + // "Cannot read properties of undefined (reading 'mintlayer')". + // Synthesize the chain-keyed view when it is missing so any + // extension build combination works. + if (result && typeof result === 'object' && !result.addressesByChain) { + const mlMap = + map.mintlayer ?? Object.values(map).find((v) => v?.receiving) ?? {} + return { + ...result, + addressesByChain: { + mintlayer: { + receiving: mlMap.receiving ?? [], + change: mlMap.change ?? [], + publicKeys: mlMap.publicKeys ?? { receiving: [], change: [] }, + }, + }, + } + } return result }, diff --git a/public/mojito.test.js b/public/mojito.test.js index 1900dd22..67d3fbc6 100644 --- a/public/mojito.test.js +++ b/public/mojito.test.js @@ -136,6 +136,46 @@ describe('window.mojito provider', () => { expect(window.mojito.network).toBe('testnet') }) + it('synthesizes addressesByChain for stale popups that respond with the map under `address` only', async () => { + // Regression: a stale extension build responds without + // `addressesByChain`; the @mintlayer/sdk Client.connect() reads + // `addresses.addressesByChain.mintlayer` unguarded and would crash + // the dApp with "Cannot read properties of undefined (reading + // 'mintlayer')". + onMessage((event) => { + if (event.data?.type !== 'MINTLAYER_REQUEST') return + if (event.data.method !== 'connect') return + const { requestId } = event.data + setTimeout(() => { + window.postMessage( + { + type: 'MINTLAYER_RESPONSE', + requestId, + result: { + address: { + testnet: { receiving: ['tmtc1qold'], change: ['tmtc1qocg'] }, + }, + network: 'testnet', + }, + }, + '*', + ) + }, 0) + }) + + const result = await window.mojito.connect() + + // SDK Client.connect() survives and gets a usable address map. + expect(result.addressesByChain.mintlayer.receiving).toEqual(['tmtc1qold']) + expect(result.addressesByChain.mintlayer.change).toEqual(['tmtc1qocg']) + expect(result.addressesByChain.mintlayer.publicKeys).toEqual({ + receiving: [], + change: [], + }) + // Page-side tracking keeps working from the raw map. + expect(window.mojito.isConnected()).toBe(true) + }) + it('rejects with the structured error code when the user denies', async () => { onMessage((event) => { if (event.data?.type !== 'MINTLAYER_REQUEST') return From b622ba022f8b389560d741950b15ab7e7c0a45f8 Mon Sep 17 00:00:00 2001 From: owlsua Date: Sun, 6 Sep 2026 22:26:31 +0200 Subject: [PATCH 07/52] Catch imports of names a module does not export Four pages import sendPopupResponse from @Browser, but that module only re-exports Browser, so the binding is undefined and every approve and reject in the dApp flow throws. Nothing caught it: webpack only warns, eslint had no import plugin, and tsconfig keeps checkJs off, so the .js call sites are never type-checked. Enable import/named with the TypeScript resolver so the alias map in tsconfig stays the single source of truth. @Browser was missing from that map - without it the resolver could not find the module and the rule stayed silent. npm run lint now exits non-zero, which fails the ESLint step already in CI. The four call sites it reports are real and still need the missing re-export. --- eslint.config.mjs | 27 ++-- package-lock.json | 382 +++++++++++++++++++++++++++++++++++++++++++--- package.json | 2 + tsconfig.json | 1 + 4 files changed, 382 insertions(+), 30 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 1cdfe6e0..1e84b60b 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -2,6 +2,7 @@ import js from '@eslint/js' import tseslint from 'typescript-eslint' import reactPlugin from 'eslint-plugin-react' import reactHooksPlugin from 'eslint-plugin-react-hooks' +import importPlugin from 'eslint-plugin-import' import globals from 'globals' const sharedRules = { @@ -22,9 +23,21 @@ const sharedRules = { 'max-depth': ['error', 3], 'eol-last': ['error', 'always'], 'testing-library/no-unnecessary-act': 'off', + 'import/named': 'error', ...reactHooksPlugin.configs.recommended.rules, } +const sharedSettings = { + react: { + version: 'detect', + }, + 'import/resolver': { + typescript: { + project: './tsconfig.json', + }, + }, +} + const sharedLanguageOptions = { ecmaVersion: 2020, sourceType: 'module', @@ -83,13 +96,10 @@ export default [ plugins: { react: reactPlugin, 'react-hooks': reactHooksPlugin, + import: importPlugin, }, languageOptions: sharedLanguageOptions, - settings: { - react: { - version: 'detect', - }, - }, + settings: sharedSettings, rules: { ...sharedRules, 'no-unused-vars': 'error', @@ -101,16 +111,13 @@ export default [ react: reactPlugin, 'react-hooks': reactHooksPlugin, '@typescript-eslint': tseslint.plugin, + import: importPlugin, }, languageOptions: { ...sharedLanguageOptions, parser: tseslint.parser, }, - settings: { - react: { - version: 'detect', - }, - }, + settings: sharedSettings, rules: { ...sharedRules, 'no-unused-vars': 'off', diff --git a/package-lock.json b/package-lock.json index ecd6c50e..85b5f45c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -56,6 +56,8 @@ "dotenv-webpack": "^8.1.1", "env-cmd": "^11.0.0", "eslint": "^9.39.2", + "eslint-import-resolver-typescript": "^4.4.5", + "eslint-plugin-import": "^2.32.0", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^7.0.1", "fake-indexeddb": "^6.2.5", @@ -4991,6 +4993,13 @@ "node": ">=18" } }, + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "dev": true, + "license": "MIT" + }, "node_modules/@scure/base": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz", @@ -5924,6 +5933,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/json5": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -7184,6 +7200,28 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/array.prototype.findlastindex": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", + "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-shim-unscopables": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/array.prototype.flat": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", @@ -9568,6 +9606,19 @@ "node": ">=6" } }, + "node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/dom-accessibility-api": { "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", @@ -10201,6 +10252,184 @@ } } }, + "node_modules/eslint-import-context": { + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/eslint-import-context/-/eslint-import-context-0.1.9.tgz", + "integrity": "sha512-K9Hb+yRaGAGUbwjhFNHvSmmkZs9+zbuoe3kFQ4V1wYjrepUFYM2dZAfNtjbbj3qsPfUfsA68Bx/ICWQMi+C8Eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-tsconfig": "^4.10.1", + "stable-hash-x": "^0.2.0" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-context" + }, + "peerDependencies": { + "unrs-resolver": "^1.0.0" + }, + "peerDependenciesMeta": { + "unrs-resolver": { + "optional": true + } + } + }, + "node_modules/eslint-import-resolver-node": { + "version": "0.3.10", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.10.tgz", + "integrity": "sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7", + "is-core-module": "^2.16.1", + "resolve": "^2.0.0-next.6" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/resolve": { + "version": "2.0.0-next.7", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz", + "integrity": "sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.2", + "node-exports-info": "^1.6.0", + "object-keys": "^1.1.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/eslint-import-resolver-typescript": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-4.4.5.tgz", + "integrity": "sha512-nbE5XLph6TLtGYcu/U6e6ZVXyKBhbDWK5cLGk76eJ7NdZpwf1P9EFkpt1Z01mNZNrrilsAYWKH6zUkL4reoXbw==", + "dev": true, + "license": "ISC", + "dependencies": { + "debug": "^4.4.1", + "eslint-import-context": "^0.1.8", + "get-tsconfig": "^4.10.1", + "is-bun-module": "^2.0.0", + "stable-hash-x": "^0.2.0", + "tinyglobby": "^0.2.14", + "unrs-resolver": "^1.7.11" + }, + "engines": { + "node": "^16.17.0 || >=18.6.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-resolver-typescript" + }, + "peerDependencies": { + "eslint": "*", + "eslint-plugin-import": "*", + "eslint-plugin-import-x": "*" + }, + "peerDependenciesMeta": { + "eslint-plugin-import": { + "optional": true + }, + "eslint-plugin-import-x": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.14.0.tgz", + "integrity": "sha512-W2WCRZ9Dqntd+2u8jJcVMV2PKulc6RdLgUUoh/yQr3uB6lo/ZOeGx11sv60/8S4QFFKNslAlWhr9u0Ef7ZW6Ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7" + }, + "engines": { + "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import": { + "version": "2.32.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", + "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rtsao/scc": "^1.1.0", + "array-includes": "^3.1.9", + "array.prototype.findlastindex": "^1.2.6", + "array.prototype.flat": "^1.3.3", + "array.prototype.flatmap": "^1.3.3", + "debug": "^3.2.7", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.12.1", + "hasown": "^2.0.2", + "is-core-module": "^2.16.1", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "object.groupby": "^1.0.3", + "object.values": "^1.2.1", + "semver": "^6.3.1", + "string.prototype.trimend": "^1.0.9", + "tsconfig-paths": "^3.15.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, "node_modules/eslint-plugin-react": { "version": "7.37.5", "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", @@ -10254,19 +10483,6 @@ "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, - "node_modules/eslint-plugin-react/node_modules/doctrine": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", - "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "esutils": "^2.0.2" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/eslint-plugin-react/node_modules/resolve": { "version": "2.0.0-next.5", "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", @@ -11126,6 +11342,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-tsconfig": { + "version": "4.14.3", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.3.tgz", + "integrity": "sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, "node_modules/github-from-package": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", @@ -11356,9 +11585,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -11997,6 +12226,29 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-bun-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", + "integrity": "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.7.1" + } + }, + "node_modules/is-bun-module/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/is-callable": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", @@ -12010,13 +12262,13 @@ } }, "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", "dev": true, "license": "MIT", "dependencies": { - "hasown": "^2.0.2" + "hasown": "^2.0.3" }, "engines": { "node": ">= 0.4" @@ -16050,6 +16302,25 @@ "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", "license": "MIT" }, + "node_modules/node-exports-info": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.2.tgz", + "integrity": "sha512-kXs9Go0cah0qHVV2v389IXQLdLCeE1xfFtjOAF+iobu0OIoG1pje8At2vMHyaPMiPMnG/LWP50twML21eMcAag==", + "dev": true, + "license": "MIT", + "dependencies": { + "array.prototype.flatmap": "^1.3.3", + "es-errors": "^1.3.0", + "object.entries": "^1.1.9", + "semver": "^6.3.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -16195,6 +16466,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/object.groupby": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", + "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/object.values": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", @@ -17674,6 +17960,16 @@ "node": ">=4" } }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, "node_modules/retry": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", @@ -18483,6 +18779,16 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/stable-hash-x": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/stable-hash-x/-/stable-hash-x-0.2.0.tgz", + "integrity": "sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/stack-utils": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", @@ -19311,6 +19617,42 @@ "typescript": ">=4.8.4" } }, + "node_modules/tsconfig-paths": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + } + }, + "node_modules/tsconfig-paths/node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/tsconfig-paths/node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", diff --git a/package.json b/package.json index 3388d914..913a83c8 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,8 @@ "dotenv-webpack": "^8.1.1", "env-cmd": "^11.0.0", "eslint": "^9.39.2", + "eslint-import-resolver-typescript": "^4.4.5", + "eslint-plugin-import": "^2.32.0", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^7.0.1", "fake-indexeddb": "^6.2.5", diff --git a/tsconfig.json b/tsconfig.json index ec539e51..c4d82cbd 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -32,6 +32,7 @@ "@Constants": ["./src/utils/Constants/index.js"], "@TestData": ["./src/utils/TestData/index.js"], "@Storage": ["./src/services/Storage/index.js"], + "@Browser": ["./src/services/Browser/index.js"], "@Version": ["./src/version/version.js"] } }, From 82cf23912dfe1a650c9b44da44481f0b747449c7 Mon Sep 17 00:00:00 2001 From: Enrico Rubboli Date: Sun, 6 Sep 2026 21:02:46 +0200 Subject: [PATCH 08/52] fix(bridge): answer pages when the extension context is invalidated Reloading, updating or disabling the extension orphans the content script in already-open pages: the next runtime call throws 'Extension context invalidated.' synchronously. The relay neither caught it nor answered the page, so the dApp's connect()/sign promise hung forever and the console showed an uncaught error. - catch the throw, fail the pending request with { code: 'CONTEXT_INVALIDATED' } and short-circuit further requests - the load-time getSession probe no longer throws uncaught either - regression tests: dead-runtime request answered with the structured error, fast-fail without touching the runtime again --- doc/bridge-contract.md | 23 ++-- public/explorer/content-script.js | 127 ++++++++++++++-------- public/explorer/content-script.test.js | 142 +++++++++++++++++++++++++ 3 files changed, 237 insertions(+), 55 deletions(-) create mode 100644 public/explorer/content-script.test.js diff --git a/doc/bridge-contract.md b/doc/bridge-contract.md index 4dc79906..121d2eed 100644 --- a/doc/bridge-contract.md +++ b/doc/bridge-contract.md @@ -63,17 +63,18 @@ the wallet brand (the bridge maps `/mojito/i` messages to an `'Mojito extension not available'` when `window.mojito` is missing should trigger that). -| code | meaning | -| --------------------- | ------------------------------------------------------------------- | -| `USER_REJECTED` | user denied the approval | -| `REQUEST_CANCELLED` | approval window closed without a decision | -| `REQUEST_IN_PROGRESS` | an approval window is already open | -| `NOT_CONNECTED` | sign/challenge without a prior connect grant | -| `WRONG_NETWORK` | session granted on a different network than the wallet's active one | -| `UNSUPPORTED_METHOD` | method not implemented by the wallet | -| `TIMEOUT` | no answer from the wallet within 5 minutes | -| `EXTENSION_ERROR` | content script could not reach the background | -| `STORAGE_ERROR` | wallet failed to persist/read state | +| code | meaning | +| --------------------- | ----------------------------------------------------------------------------------------------------------------------- | +| `USER_REJECTED` | user denied the approval | +| `REQUEST_CANCELLED` | approval window closed without a decision | +| `REQUEST_IN_PROGRESS` | an approval window is already open | +| `NOT_CONNECTED` | sign/challenge without a prior connect grant | +| `WRONG_NETWORK` | session granted on a different network than the wallet's active one | +| `UNSUPPORTED_METHOD` | method not implemented by the wallet | +| `TIMEOUT` | no answer from the wallet within 5 minutes | +| `CONTEXT_INVALIDATED` | the extension was reloaded/updated/disabled while the page was open; the page must be reloaded and connect called again | +| `EXTENSION_ERROR` | content script could not reach the background | +| `STORAGE_ERROR` | wallet failed to persist/read state | ## Method relayed by `request()` diff --git a/public/explorer/content-script.js b/public/explorer/content-script.js index b632de88..27948314 100644 --- a/public/explorer/content-script.js +++ b/public/explorer/content-script.js @@ -23,16 +23,38 @@ const pendingRequests = new Map() // requestId -> timeout id const RESPONSE_TIMEOUT_MS = 5 * 60 * 1000 // approvals can take a while - // Tell pages with an existing session as soon as the content script loads. - api.runtime.sendMessage({ method: 'getSession', origin }, (response) => { - if (api.runtime.lastError || !response?.result) return + // True once the extension has been reloaded/updated/disabled underneath + // this orphaned content script: every runtime call will throw, so answer + // immediately with a clear error instead of letting requests hang. + let contextInvalidated = false + + const failRequest = (requestId, code, message) => { + if (!pendingRequests.has(requestId)) return + clearTimeout(pendingRequests.get(requestId)) + pendingRequests.delete(requestId) postToPage({ - type: 'MINTLAYER_EVENT', - event: 'accountsChanged', - data: response.result.address, + type: 'MINTLAYER_RESPONSE', + requestId, + error: { code, message }, }) - }) + } + + // Tell pages with an existing session as soon as the content script loads. + try { + api.runtime.sendMessage({ method: 'getSession', origin }, (response) => { + if (api.runtime.lastError || !response?.result) return + + postToPage({ + type: 'MINTLAYER_EVENT', + event: 'accountsChanged', + data: response.result.address, + }) + }) + } catch (error) { + // Nothing sensible to do at load time if the context is already gone. + console.error('[Mojito] Extension context unavailable:', error.message) + } window.addEventListener('message', (event) => { if (event.source !== window || event.data?.type !== 'MINTLAYER_REQUEST') { @@ -44,57 +66,74 @@ // Guard against duplicate requests and answer stale ids at once. if (pendingRequests.has(requestId)) return - const timeoutId = setTimeout(() => { - if (!pendingRequests.has(requestId)) return - - pendingRequests.delete(requestId) - console.error('[Mojito] Timeout waiting for background response') + // Extension was reloaded/updated/disabled while this page stayed open: + // the runtime channel is gone, so fail fast instead of hanging the + // page's promise until the timeout. + if (contextInvalidated) { postToPage({ type: 'MINTLAYER_RESPONSE', requestId, error: { - code: 'TIMEOUT', - message: 'The wallet did not respond in time. Please try again.', + code: 'CONTEXT_INVALIDATED', + message: + 'The wallet was reloaded or updated. Reload this page and connect again.', }, }) + return + } + + const timeoutId = setTimeout(() => { + failRequest( + requestId, + 'TIMEOUT', + 'The wallet did not respond in time. Please try again.', + ) }, RESPONSE_TIMEOUT_MS) pendingRequests.set(requestId, timeoutId) - api.runtime.sendMessage( - { - requestId, - method: event.data.method, - params: event.data.params || {}, - }, - (response) => { - if (!pendingRequests.has(requestId)) return - - clearTimeout(pendingRequests.get(requestId)) - pendingRequests.delete(requestId) + try { + api.runtime.sendMessage( + { + requestId, + method: event.data.method, + params: event.data.params || {}, + }, + (response) => { + if (!pendingRequests.has(requestId)) return + + clearTimeout(pendingRequests.get(requestId)) + pendingRequests.delete(requestId) + + if (api.runtime.lastError) { + console.error('[Mojito] Runtime error:', api.runtime.lastError) + failRequest( + requestId, + 'EXTENSION_ERROR', + api.runtime.lastError.message || + 'Could not reach the wallet. Is it installed and enabled?', + ) + return + } - if (api.runtime.lastError) { - console.error('[Mojito] Runtime error:', api.runtime.lastError) postToPage({ type: 'MINTLAYER_RESPONSE', requestId, - error: { - code: 'EXTENSION_ERROR', - message: - api.runtime.lastError.message || - 'Could not reach the wallet. Is it installed and enabled?', - }, + result: response?.result, + error: response?.error, }) - return - } - - postToPage({ - type: 'MINTLAYER_RESPONSE', - requestId, - result: response?.result, - error: response?.error, - }) - }, - ) + }, + ) + } catch (error) { + // Thrown synchronously when the extension context has been + // invalidated (extension reloaded/updated/disabled). + console.error('[Mojito] Extension context invalidated:', error.message) + contextInvalidated = true + failRequest( + requestId, + 'CONTEXT_INVALIDATED', + 'The wallet was reloaded or updated. Reload this page and connect again.', + ) + } }) })() diff --git a/public/explorer/content-script.test.js b/public/explorer/content-script.test.js new file mode 100644 index 00000000..bf2e2e49 --- /dev/null +++ b/public/explorer/content-script.test.js @@ -0,0 +1,142 @@ +/** + * Tests for the injected content script (public/explorer/content-script.js): + * relays page requests to the background and — critically — answers the page + * with a structured error when the extension context is invalidated (the + * extension was reloaded/updated/disabled while the page stayed open), + * instead of leaving the page's promise hanging forever. + */ +const fs = require('fs') +const path = require('path') + +const CONTENT_SCRIPT_SRC = fs.readFileSync( + path.join(__dirname, 'content-script.js'), + 'utf8', +) + +// jsdom's MessageEvent.source is a different wrapper object than the global +// window (in browsers they are identical), which would make the script's +// `event.source !== window` guard drop every message. +Object.defineProperty(MessageEvent.prototype, 'source', { + get: () => window, + configurable: true, +}) + +const sendMessageCalls = [] +let messageListeners = [] +let addSpy + +const setup = ({ sendMessageImpl }) => { + sendMessageCalls.length = 0 + global.browser = undefined + global.chrome = { + runtime: { + id: 'ext-id', + getURL: (p) => `chrome-extension://ext-id/${p}`, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + sendMessage: (message, callback) => { + sendMessageCalls.push(message) + sendMessageImpl(message, callback) + }, + lastError: null, + }, + } + + // Track the content script's message listener so it can be removed after + // the test (the IIFE registers it on the shared jsdom window). Call through + // so test-side listeners still register normally. + messageListeners = [] + const originalAddEventListener = window.addEventListener.bind(window) + addSpy = jest + .spyOn(window, 'addEventListener') + .mockImplementation((type, fn, opts) => { + if (type === 'message') messageListeners.push(fn) + return originalAddEventListener(type, fn, opts) + }) + + // eslint-disable-next-line no-eval + window.eval(CONTENT_SCRIPT_SRC) +} + +afterEach(() => { + for (const fn of messageListeners) { + window.removeEventListener('message', fn) + } + messageListeners = [] + addSpy?.mockRestore() +}) + +const nextMessage = () => + new Promise((resolve) => { + const listener = (event) => { + if (event.data?.type === 'MINTLAYER_RESPONSE') { + window.removeEventListener('message', listener) + resolve(event.data) + } + } + window.addEventListener('message', listener) + }) + +describe('content script relay', () => { + it('relays a page request to the background and back', async () => { + setup({ + sendMessageImpl: (_message, callback) => { + callback({ result: { isConnected: true } }) + }, + }) + + const incoming = nextMessage() + window.postMessage( + { type: 'MINTLAYER_REQUEST', requestId: 'r1', method: 'checkConnection' }, + '*', + ) + + await expect(incoming).resolves.toEqual({ + type: 'MINTLAYER_RESPONSE', + requestId: 'r1', + result: { isConnected: true }, + error: undefined, + }) + // [0] is the load-time getSession probe, the relay forwards the request + expect(sendMessageCalls[sendMessageCalls.length - 1]).toMatchObject({ + requestId: 'r1', + method: 'checkConnection', + }) + }) + + it('answers CONTEXT_INVALIDATED when sendMessage throws (extension reloaded)', async () => { + setup({ + sendMessageImpl: () => { + // Chrome throws synchronously once the extension context is gone. + throw new Error('Extension context invalidated.') + }, + }) + + const incoming = nextMessage() + window.postMessage( + { type: 'MINTLAYER_REQUEST', requestId: 'r1', method: 'connect' }, + '*', + ) + + await expect(incoming).resolves.toMatchObject({ + type: 'MINTLAYER_RESPONSE', + requestId: 'r1', + error: { + code: 'CONTEXT_INVALIDATED', + message: expect.stringContaining('Reload this page'), + }, + }) + + // Subsequent requests fail fast with the same code, without touching the + // dead runtime again. + const second = nextMessage() + window.postMessage( + { type: 'MINTLAYER_REQUEST', requestId: 'r2', method: 'connect' }, + '*', + ) + await expect(second).resolves.toMatchObject({ + error: { code: 'CONTEXT_INVALIDATED' }, + }) + // after invalidation no further runtime calls are attempted + expect(sendMessageCalls.filter((m) => m.requestId === 'r2')).toHaveLength(0) + }) +}) From 190c34695bb98a5ecb83f9969ca689ed8669afeb Mon Sep 17 00:00:00 2001 From: Enrico Rubboli Date: Sun, 6 Sep 2026 22:29:28 +0200 Subject: [PATCH 09/52] =?UTF-8?q?fix(send):=20accept=20Mintlayer=20multisi?= =?UTF-8?q?g=20addresses=20(mmtc1=E2=80=A6/tmtc1=E2=80=A6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isMlAddressValid only matched the pubkeyhash prefixes (mtc1/tmt1), so valid multisig addresses like mmtc1q3v0hye8eg6vg7f7thmpy6y834u8h0r4as0hyax2 were rejected by the send form. Accept the multisig prefixes per network and reject cross-network use as before. --- src/utils/Helpers/ML/ML.js | 82 +++++++++++++-- src/utils/Helpers/ML/ML.test.js | 179 ++++++++++++++++++++++++++++++++ 2 files changed, 251 insertions(+), 10 deletions(-) diff --git a/src/utils/Helpers/ML/ML.js b/src/utils/Helpers/ML/ML.js index cfc39f8a..efbefa5a 100644 --- a/src/utils/Helpers/ML/ML.js +++ b/src/utils/Helpers/ML/ML.js @@ -16,17 +16,21 @@ const calculateExchangeRate = (askAmount, giveAmount) => { } const getAmountInCoins = ( - amointInAtoms, + amountInAtoms, atomsPerCoin = AppInfo.ML_ATOMS_PER_COIN, ) => { - return amointInAtoms / atomsPerCoin + // Decimal division avoids float drift on the 11-decimal atom scale. + return new Decimal(amountInAtoms.toString()) + .dividedBy(atomsPerCoin) + .toNumber() } const getAmountInAtoms = ( amountInCoins, atomsPerCoin = AppInfo.ML_ATOMS_PER_COIN, ) => { - return BigInt(Math.round(amountInCoins * atomsPerCoin)) + // Decimal multiplication avoids float rounding on the 11-decimal scale. + return BigInt(new Decimal(amountInCoins).times(atomsPerCoin).toFixed(0)) } const getSwapDetails = (transaction) => { @@ -99,11 +103,19 @@ const getSwapDetails = (transaction) => { return null } +// localStorage key for the unconfirmed-transaction list — one definition +// instead of eight hand-built copies that can drift. +const getUnconfirmedTransactionKey = (accountName, networkType) => + `${AppInfo.UNCONFIRMED_TRANSACTION_NAME}_${accountName}_${networkType}` + const getParsedTransactions = (transactions, addresses) => { const account = LocalStorageService.getItem('unlockedAccount') const networkType = LocalStorageService.getItem('networkType') const accountName = account && account.name - const unconfirmedTransactionString = `${AppInfo.UNCONFIRMED_TRANSACTION_NAME}_${accountName}_${networkType}` + const unconfirmedTransactionString = getUnconfirmedTransactionKey( + accountName, + networkType, + ) const unconfirmedTransactions = LocalStorageService.getItem( unconfirmedTransactionString, ) @@ -215,7 +227,7 @@ const getParsedTransactions = (transactions, addresses) => { if (output.type === 'CreateOrder') { type = 'CreateOrder' destAddress = output.conclude_key - return output.give_value.amount.decimal + return acc + Number(output.give_value.amount.decimal) } if (order_id) { type = 'FillOrder' @@ -228,7 +240,9 @@ const getParsedTransactions = (transactions, addresses) => { } } if (output.type === 'Transfer') { - return acc + output.value.amount.decimal + // Number() is required: amount.decimal is a string, and + // 'acc + string' would concatenate instead of accumulate. + return acc + Number(output.value.amount.decimal) } if (output.type === 'LockThenTransfer') { return acc + Number(output.value.amount.decimal) @@ -297,7 +311,8 @@ const getParsedTransactions = (transactions, addresses) => { } else { if (output.type === 'Transfer') { if (output.value.type === 'Coin') { - return acc + output.value.amount.decimal + // Number(): string concat would corrupt multi-output sums. + return acc + Number(output.value.amount.decimal) } } if (output.type === 'LockThenTransfer') { @@ -329,7 +344,8 @@ const getParsedTransactions = (transactions, addresses) => { const totalValue = transaction.outputs.reduce((acc, output) => { if (addresses.includes(output.destination)) { if (output.type === 'Transfer') { - return acc + output.value.amount.decimal + // Number(): string concat would corrupt multi-output sums. + return acc + Number(output.value.amount.decimal) } if (output.type === 'LockThenTransfer') { if ( @@ -391,8 +407,10 @@ const getTokenBalances = (utxos) => { } const isMlAddressValid = (address, network) => { - const mainnetRegex = /^mtc1[a-z0-9]{30,}$/ - const testnetRegex = /^tmt1[a-z0-9]{30,}$/ + // Pubkeyhash (mtc1/tmt1) AND multisig (mmtc1/tmtc1) bech32 addresses — + // mmtc1… was rejected before, breaking sends to multisig destinations. + const mainnetRegex = /^(mtc1|mmtc1)[a-z0-9]{30,}$/ + const testnetRegex = /^(tmt1|tmtc1)[a-z0-9]{30,}$/ return network === AppInfo.NETWORK_TYPES.MAINNET ? mainnetRegex.test(address) : testnetRegex.test(address) @@ -483,6 +501,48 @@ const getMlTransactionLink = (txId, network) => { return `${baseUrl}/tx/${txId}` } +// Rebuilds the growth of the total staked balance over time from the +// wallet's parsed Mintlayer transactions: +// - 'DelegateStaking' (out) adds to the stake (new delegation / add funds) +// - 'Delegate Withdrawal' (in) removes from it +// The last point is anchored to the live delegation total, which also +// includes rewards accrued while staking. +const buildStakeGrowthSeries = (transactions, currentTotal = null) => { + const events = (transactions || []) + .filter( + (tx) => + tx.type === 'DelegateStaking' || tx.type === 'Delegate Withdrawal', + ) + .sort((a, b) => (a.date || 0) - (b.date || 0)) + + const series = [] + let contributed = 0 + let withdrawn = 0 + + events.forEach((tx) => { + const value = Number(tx.value) || 0 + if (tx.type === 'DelegateStaking') { + contributed += value + } else { + withdrawn += value + } + if (series.length === 0) { + series.push(0) + } + series.push(contributed - withdrawn) + }) + + if ( + currentTotal != null && + series.length > 0 && + series[series.length - 1] !== currentTotal + ) { + series.push(currentTotal) + } + + return { series, contributed, withdrawn } +} + export { getParsedTransactions, getAmountInAtoms, @@ -498,4 +558,6 @@ export { getBatchData, getMlAddressLink, getMlTransactionLink, + buildStakeGrowthSeries, + getUnconfirmedTransactionKey, } diff --git a/src/utils/Helpers/ML/ML.test.js b/src/utils/Helpers/ML/ML.test.js index 6c7bedc2..3d6e448b 100644 --- a/src/utils/Helpers/ML/ML.test.js +++ b/src/utils/Helpers/ML/ML.test.js @@ -3,6 +3,7 @@ import { getAmountInAtoms, getAmountInCoins, isMlAddressValid, + buildStakeGrowthSeries, } from './ML.js' import { AppInfo } from '@Constants' import { LocalStorageService } from '@Storage' @@ -95,6 +96,151 @@ describe('ML', () => { const parsedTx = getParsedTransactions(transactions, addresses) expect(parsedTx).toEqual(expectedParsedTransactions) }) + + it('sums multiple outbound Transfer outputs numerically', () => { + const transactions = [ + { + inputs: [{ utxo: { destination: 'address1' } }], + outputs: [ + { + destination: 'address1', + type: 'Transfer', + value: { + type: 'Coin', + amount: { atoms: '100000000', decimal: '0.001' }, + }, + }, + { + destination: 'address2', + type: 'Transfer', + value: { + type: 'Coin', + amount: { atoms: '150000000000', decimal: '1.5' }, + }, + }, + { + destination: 'address3', + type: 'Transfer', + value: { + type: 'Coin', + amount: { atoms: '70000000000', decimal: '0.7' }, + }, + }, + ], + timestamp: 1000, + confirmations: 1, + txid: 'txid2', + fee: { atoms: '10000', decimal: '0.0000001' }, + }, + ] + + const parsedTx = getParsedTransactions(transactions, ['address1']) + // 1.5 + 0.7 — string concatenation would produce '1.50.7' -> NaN + expect(parsedTx[0].value).toBe(2.2) + expect(parsedTx[0].direction).toBe('out') + }) + + it('sums multiple inbound Transfer outputs numerically', () => { + const transactions = [ + { + inputs: [{ utxo: { destination: 'addressX' } }], + outputs: [ + { + destination: 'address1', + type: 'Transfer', + value: { + type: 'Coin', + amount: { atoms: '50000000000', decimal: '0.5' }, + }, + }, + { + destination: 'address1', + type: 'Transfer', + value: { + type: 'Coin', + amount: { atoms: '25000000000', decimal: '0.25' }, + }, + }, + ], + timestamp: 2000, + confirmations: 1, + txid: 'txid3', + fee: { atoms: '10000', decimal: '0.0000001' }, + }, + ] + + const parsedTx = getParsedTransactions(transactions, ['address1']) + expect(parsedTx[0].value).toBe(0.75) + expect(parsedTx[0].direction).toBe('in') + }) + }) +}) + +describe('buildStakeGrowthSeries', () => { + const stakeTx = (value, date, txid) => ({ + type: 'DelegateStaking', + direction: 'out', + value, + date, + txid, + }) + const withdrawTx = (value, date, txid) => ({ + type: 'Delegate Withdrawal', + direction: 'in', + value, + date, + txid, + }) + + it('returns an empty series when there is no staking activity', () => { + const { series, contributed, withdrawn } = buildStakeGrowthSeries( + [{ type: 'Transfer', direction: 'in', value: 5, date: 100, txid: 't1' }], + 0, + ) + expect(series).toEqual([]) + expect(contributed).toBe(0) + expect(withdrawn).toBe(0) + }) + + it('handles undefined transactions', () => { + const { series } = buildStakeGrowthSeries(undefined, 0) + expect(series).toEqual([]) + }) + + it('builds a cumulative series ordered by date and anchors the live total', () => { + const transactions = [ + stakeTx(100, 300, 't3'), + stakeTx(50, 100, 't1'), + stakeTx(200, 200, 't2'), + ] + const { series, contributed, withdrawn } = buildStakeGrowthSeries( + transactions, + 360, + ) + // starts at 0, then 50, 250, 350 and jumps to the live total 360 + expect(series).toEqual([0, 50, 250, 350, 360]) + expect(contributed).toBe(350) + expect(withdrawn).toBe(0) + }) + + it('subtracts withdrawals', () => { + const transactions = [ + stakeTx(100, 100, 't1'), + withdrawTx(40, 200, 't2'), + stakeTx(20, 300, 't3'), + ] + const { series, contributed, withdrawn } = buildStakeGrowthSeries( + transactions, + 80, + ) + expect(series).toEqual([0, 100, 60, 80]) + expect(contributed).toBe(120) + expect(withdrawn).toBe(40) + }) + + it('does not duplicate the final point when it already matches the live total', () => { + const { series } = buildStakeGrowthSeries([stakeTx(50, 100, 't1')], 50) + expect(series).toEqual([0, 50]) }) }) @@ -125,4 +271,37 @@ describe('isMlAddressValid', () => { isMlAddressValid(mainnetAddress, AppInfo.NETWORK_TYPES.TESTNET), ).toBe(false) }) + + it('should accept mainnet multisig addresses (mmtc1…)', () => { + expect( + isMlAddressValid( + 'mmtc1q3v0hye8eg6vg7f7thmpy6y834u8h0r4as0hyax2', + AppInfo.NETWORK_TYPES.MAINNET, + ), + ).toBe(true) + }) + + it('should accept testnet multisig addresses (tmtc1…)', () => { + expect( + isMlAddressValid( + 'tmtc1q3v0hye8eg6vg7f7thmpy6y834u8h0r4as0hyax2', + AppInfo.NETWORK_TYPES.TESTNET, + ), + ).toBe(true) + }) + + it('should reject a mainnet multisig address on testnet and vice versa', () => { + expect( + isMlAddressValid( + 'mmtc1q3v0hye8eg6vg7f7thmpy6y834u8h0r4as0hyax2', + AppInfo.NETWORK_TYPES.TESTNET, + ), + ).toBe(false) + expect( + isMlAddressValid( + 'tmtc1q3v0hye8eg6vg7f7thmpy6y834u8h0r4as0hyax2', + AppInfo.NETWORK_TYPES.MAINNET, + ), + ).toBe(false) + }) }) From a72b7bc107f6c69bb9d1b92424d829f503e23e82 Mon Sep 17 00:00:00 2001 From: Enrico Rubboli Date: Sun, 6 Sep 2026 22:29:49 +0200 Subject: [PATCH 10/52] fix(ui): keep Chrome autofill from painting white input backgrounds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chrome ignores autocomplete=off for saved addresses and repaints autofilled inputs with its light background while the dark theme keeps near-white text — unreadable white-on-white fields on the send forms. Override the autofill paint globally: keep the dark input surface and the theme text color. --- src/assets/styles/index.css | 54 ++++++++++++++++++------------------- 1 file changed, 26 insertions(+), 28 deletions(-) diff --git a/src/assets/styles/index.css b/src/assets/styles/index.css index 2320267d..b6b2cbf5 100644 --- a/src/assets/styles/index.css +++ b/src/assets/styles/index.css @@ -1,6 +1,6 @@ * { box-sizing: border-box; - color: var(--color-black); + color: var(--be-text-1); font-family: 'Montserrat', sans-serif; font-size: var(--default-font-size); margin: 0; @@ -12,36 +12,21 @@ html { background: radial-gradient( - 50% 50% at 20% 30%, - rgba(105, 238, 150, 0.22) 0%, - rgba(145, 155, 200, 0.16) 59%, - rgba(195, 192, 225, 0) 100% + 120% 60% at 50% -10%, + oklch(0.82 0.16 70 / 0.16) 0%, + transparent 60% ), radial-gradient( - 50% 50% at 80% 70%, - rgba(105, 238, 150, 0.22) 0%, - rgba(158, 188, 207, 0.16) 59%, - rgba(195, 192, 225, 0) 100% + 80% 50% at 100% 100%, + oklch(0.74 0.16 290 / 0.1) 0%, + transparent 60% ), radial-gradient( - 50% 50% at 50% 50%, - rgba(105, 238, 150, 0.18) 0%, - rgba(145, 155, 200, 0.14) 59%, - rgba(195, 192, 225, 0) 100% + 80% 50% at 0% 100%, + oklch(0.82 0.12 195 / 0.08) 0%, + transparent 60% ), - radial-gradient( - 50% 50% at 30% 80%, - rgba(105, 238, 150, 0.18) 0%, - rgba(181, 139, 201, 0.14) 59%, - rgba(195, 192, 225, 0) 100% - ), - radial-gradient( - 50% 50% at 70% 20%, - rgba(105, 238, 150, 0.18) 0%, - rgba(145, 155, 200, 0.14) 59%, - rgba(195, 192, 225, 0) 100% - ), - rgb(235, 242, 240); + var(--be-bg-0); } body { @@ -49,11 +34,24 @@ body { -moz-osx-font-smoothing: grayscale; height: 100vh; width: 100%; - min-width: 400px; - background: #f0f2f5; + /* No min-width: Chrome's side panel can be narrower than 400px and the + global `overflow: hidden` would clip the right edge with no scrollbar. */ + background: var(--be-bg-0); --page-content-height: auto; } +/* Chrome autofill repaints inputs with its own light background while the + dark theme keeps the near-white text color — white text on white input. + autocomplete="off" is ignored for saved addresses, so override the paint. */ +input:-webkit-autofill, +input:-webkit-autofill:hover, +input:-webkit-autofill:focus { + -webkit-text-fill-color: var(--be-text-0); + caret-color: var(--be-text-0); + -webkit-box-shadow: 0 0 0 1000px var(--be-bg-2) inset; + transition: background-color 9999s ease-out; +} + html.extended-view body { @media screen and (min-width: 901px) { height: auto; From 50b58d53ffcc0eba6c6e3a323dc11d1a7b3e8d1d Mon Sep 17 00:00:00 2001 From: Enrico Rubboli Date: Sun, 6 Sep 2026 23:03:18 +0200 Subject: [PATCH 11/52] chore: bump version to 2.0.0 --- package-lock.json | 86 +++++++++++++++++++++---------------- package.json | 6 +-- public/manifestDefault.json | 2 +- public/manifestFirefox.json | 2 +- 4 files changed, 55 insertions(+), 41 deletions(-) diff --git a/package-lock.json b/package-lock.json index 85b5f45c..18dce957 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "browser-extension", - "version": "1.6.1", + "version": "2.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "browser-extension", - "version": "1.6.1", + "version": "2.0.0", "dependencies": { "@bitcoinerlab/secp256k1": "^1.2.0", "@mintlayer/sdk": "1.0.38", @@ -22,14 +22,14 @@ "d3": "^7.9.0", "date-fns": "^4.1.0", "decimal.js": "^10.6.0", - "ecpair": "3.0.0", + "ecpair": "^3.0.2", "konva": "^10.2.0", "process": "^0.11.10", "react": "^19.2.4", "react-dom": "^19.2.4", "react-konva": "^19.2.2", "react-qr-code": "^2.0.18", - "react-router-dom": "^7.13.0", + "react-router-dom": "^7.18.3", "stream-browserify": "^3.0.0" }, "devDependencies": { @@ -7311,9 +7311,9 @@ } }, "node_modules/asn1.js/node_modules/bn.js": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz", - "integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==", + "version": "4.12.5", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz", + "integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==", "license": "MIT" }, "node_modules/asn1js": { @@ -7773,9 +7773,9 @@ "license": "MIT" }, "node_modules/bn.js": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.2.tgz", - "integrity": "sha512-v2YAxEmKaBLahNwE1mjp4WON6huMNeuDvagFZW+ASCuA/ku0bXR9hSMw0XpiqMoA3+rmnyck/tPRSFQkoC9Cuw==", + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.5.tgz", + "integrity": "sha512-Vq886eXykuP5E6HcKSSStP3bJgrE6In5WKxVUvJ8XGpWWYs2xZHWqUwzCtGgEtBcxyd57KBFDPFoUfNzdaHCNg==", "license": "MIT" }, "node_modules/body-parser": { @@ -8627,9 +8627,9 @@ } }, "node_modules/create-ecdh/node_modules/bn.js": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz", - "integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==", + "version": "4.12.5", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz", + "integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==", "license": "MIT" }, "node_modules/create-hash": { @@ -9588,9 +9588,9 @@ } }, "node_modules/diffie-hellman/node_modules/bn.js": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz", - "integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==", + "version": "4.12.5", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz", + "integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==", "license": "MIT" }, "node_modules/dns-packet": { @@ -9787,13 +9787,13 @@ "license": "MIT" }, "node_modules/ecpair": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/ecpair/-/ecpair-3.0.0.tgz", - "integrity": "sha512-kf4JxjsRQoD4EBzpYjGAcR0t9i/4oAeRPtyCpKvSwyotgkc6oA4E4M0/e+kep7cXe+mgxAvoeh/jdgH9h5+Wxw==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/ecpair/-/ecpair-3.0.2.tgz", + "integrity": "sha512-q74N80jaqlSkOTx1Wki43KdQrGaz00CVHxU1fkTv4AJRCTu388uUJV+r4ZxaP1HKGLF7ygMic3lU7dt5o3r+Gg==", "license": "MIT", "dependencies": { "uint8array-tools": "^0.0.8", - "valibot": "^0.37.0", + "valibot": "^1.2.0", "wif": "^5.0.0" }, "engines": { @@ -9809,6 +9809,20 @@ "node": ">=14.0.0" } }, + "node_modules/ecpair/node_modules/valibot": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.4.2.tgz", + "integrity": "sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==", + "license": "MIT", + "peerDependencies": { + "typescript": ">=5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -9839,9 +9853,9 @@ } }, "node_modules/elliptic/node_modules/bn.js": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz", - "integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==", + "version": "4.12.5", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz", + "integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==", "license": "MIT" }, "node_modules/emittery": { @@ -16011,9 +16025,9 @@ } }, "node_modules/miller-rabin/node_modules/bn.js": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz", - "integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==", + "version": "4.12.5", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz", + "integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==", "license": "MIT" }, "node_modules/mime": { @@ -17394,9 +17408,9 @@ } }, "node_modules/public-encrypt/node_modules/bn.js": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz", - "integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==", + "version": "4.12.5", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz", + "integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==", "license": "MIT" }, "node_modules/pump": { @@ -17648,9 +17662,9 @@ } }, "node_modules/react-router": { - "version": "7.13.0", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.0.tgz", - "integrity": "sha512-PZgus8ETambRT17BUm/LL8lX3Of+oiLaPuVTRH3l1eLvSPpKO3AvhAEb5N7ihAFZQrYDqkvvWfFh9p0z9VsjLw==", + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.3.tgz", + "integrity": "sha512-gyXgtdr5uACJ5b1Q4udzjVV+tb/rlHIMJKuJ0e89R4Kzgz47z/rgP0dIKxktqIEUhDHluGTPJJH/wRha7CyqsA==", "license": "MIT", "dependencies": { "cookie": "^1.0.1", @@ -17670,12 +17684,12 @@ } }, "node_modules/react-router-dom": { - "version": "7.13.0", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.0.tgz", - "integrity": "sha512-5CO/l5Yahi2SKC6rGZ+HDEjpjkGaG/ncEP7eWFTvFxbHP8yeeI0PxTDjimtpXYlR3b3i9/WIL4VJttPrESIf2g==", + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.3.tgz", + "integrity": "sha512-ytVbyBBM7vMfRCam25r0WMhSVSom909A8p+8m0/f1w853dz/xfFu6etAT2SEbVoSnI+ZoPRDqIsQXVT89gp7kg==", "license": "MIT", "dependencies": { - "react-router": "7.13.0" + "react-router": "7.18.3" }, "engines": { "node": ">=20.0.0" diff --git a/package.json b/package.json index 913a83c8..abf177b9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "browser-extension", - "version": "1.6.1", + "version": "2.0.0", "private": true, "dependencies": { "@bitcoinerlab/secp256k1": "^1.2.0", @@ -17,14 +17,14 @@ "d3": "^7.9.0", "date-fns": "^4.1.0", "decimal.js": "^10.6.0", - "ecpair": "3.0.0", + "ecpair": "^3.0.2", "konva": "^10.2.0", "process": "^0.11.10", "react": "^19.2.4", "react-dom": "^19.2.4", "react-konva": "^19.2.2", "react-qr-code": "^2.0.18", - "react-router-dom": "^7.13.0", + "react-router-dom": "^7.18.3", "stream-browserify": "^3.0.0" }, "scripts": { diff --git a/public/manifestDefault.json b/public/manifestDefault.json index 0330fc96..74cc0f62 100644 --- a/public/manifestDefault.json +++ b/public/manifestDefault.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Mojito - A Mintlayer Wallet", - "version": "1.6.1", + "version": "2.0.0", "short_name": "Mojito", "description": "Mojito is a non-custodial decentralized crypto wallet that lets you send and receive BTC and ML from any other address.", "homepage_url": "https://www.mintlayer.org/", diff --git a/public/manifestFirefox.json b/public/manifestFirefox.json index 532fc2dc..8f501333 100644 --- a/public/manifestFirefox.json +++ b/public/manifestFirefox.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Mojito - A Mintlayer Wallet", - "version": "1.6.1", + "version": "2.0.0", "description": "Mojito is a non-custodial decentralized crypto wallet that lets you send and receive BTC and ML from any other address.", "homepage_url": "https://www.mintlayer.org/", "icons": { From c907d38abc715c2f6dcc342972533c87820521f1 Mon Sep 17 00:00:00 2001 From: Enrico Rubboli Date: Sun, 6 Sep 2026 23:10:33 +0200 Subject: [PATCH 12/52] fix(ui): give inputs a dark background (UA default is white) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the white input fields on the Send ML form: neither the shared .input class, nor .textarea, nor any raw input declared a background, so the browser's UA stylesheet painted its default white field background in light color-scheme while the theme text stayed near-white — white on white. - Input.module.css .input and Textarea.css .textarea now use --be-bg-2 - global reset gives raw text inputs/selects (swap, address book, delete account) the same dark surface; checkboxes/radios/file/button inputs keep native rendering - the -webkit-autofill override stays: autofill repaints remain dark --- src/assets/styles/index.css | 11 +++++++++++ src/components/basic/Input/Input.module.css | 15 +++++++++------ src/components/basic/Textarea/Textarea.css | 3 +++ 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/src/assets/styles/index.css b/src/assets/styles/index.css index b6b2cbf5..d0679fec 100644 --- a/src/assets/styles/index.css +++ b/src/assets/styles/index.css @@ -52,6 +52,17 @@ input:-webkit-autofill:focus { transition: background-color 9999s ease-out; } +/* Raw (non-component) text inputs and selects would otherwise get the UA + stylesheet's white background in light color-scheme. Checkboxes, radios, + file and button inputs keep their native rendering. */ +input:not([type='checkbox']):not([type='radio']):not([type='button']):not( + [type='submit'] + ):not([type='file']), +select { + background: var(--be-bg-2); + color: var(--be-text-0); +} + html.extended-view body { @media screen and (min-width: 901px) { height: auto; diff --git a/src/components/basic/Input/Input.module.css b/src/components/basic/Input/Input.module.css index 9e47fe7f..73ee73d3 100644 --- a/src/components/basic/Input/Input.module.css +++ b/src/components/basic/Input/Input.module.css @@ -1,7 +1,10 @@ .input { - border: 1px solid rgb(var(--color-light-gray)); + border: 1px solid var(--be-line); border-radius: var(--border-radius-input); - color: rgb(var(--color-dark-gray)); + color: var(--be-text-0); + /* No background declared = the UA stylesheet paints inputs white in light + color-scheme, which shows through on the dark theme. */ + background: var(--be-bg-2); font-size: 15px; outline: none; padding: 19px 16px; @@ -12,11 +15,11 @@ .input:hover, .input:focus { - border: 1px solid rgb(var(--color-dark-gray)); + border: 1px solid oklch(0.82 0.16 70 / 0.5); } .input::placeholder { - color: rgb(var(--color-dark-gray)); + color: var(--be-text-0); opacity: 0.2; font-size: 15px; } @@ -46,7 +49,7 @@ left: 16px; width: 20px; height: 20px; - color: rgb(var(--color-light-gray)); + color: var(--be-text-3); pointer-events: none; } @@ -65,5 +68,5 @@ .eyeIcon { width: 20px; height: 20px; - color: rgb(var(--color-light-gray)); + color: var(--be-text-3); } diff --git a/src/components/basic/Textarea/Textarea.css b/src/components/basic/Textarea/Textarea.css index 9d3f0b4e..1d4f7b0a 100644 --- a/src/components/basic/Textarea/Textarea.css +++ b/src/components/basic/Textarea/Textarea.css @@ -4,6 +4,9 @@ resize: none; border: 1px solid rgb(var(--color-light-gray)); border-radius: var(--round-size); + /* UA default would be white in light color-scheme. */ + background: var(--be-bg-2); + color: var(--be-text-0); } .textarea-invalid { From b1da2b1a3a96c55f63845a9619359824c5d14b94 Mon Sep 17 00:00:00 2001 From: Enrico Rubboli Date: Mon, 7 Sep 2026 08:01:54 +0200 Subject: [PATCH 13/52] fix(ui): replace remaining light-mint surfaces with dark tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --mojito-green-soft (rgb(230 248 240)) is a near-white light-theme color: - WalletCard (the 'Mintlayer — Balance: …' block on the send forms) rendered as a white card; DelegationDetails and TransactionDetails banners too - SettingsTestnet active option and the Button alternate hover/focus painted white-mint states Cards/banners now use --be-bg-1; active and hover states use a dim green wash (rgba(--mojito-green, 0.12)) so the accent survives on dark. --- src/components/basic/Button/Button.module.css | 4 +-- .../composed/WalletCard/WalletCard.module.css | 11 ++++---- .../SettingsTestnet.module.css | 6 ++-- .../Delegation/DelegationDetails.module.css | 22 +++++++-------- .../Wallet/TransactionDetails.module.css | 28 +++++++++---------- 5 files changed, 36 insertions(+), 35 deletions(-) diff --git a/src/components/basic/Button/Button.module.css b/src/components/basic/Button/Button.module.css index 35bc7c2a..c88d6a65 100644 --- a/src/components/basic/Button/Button.module.css +++ b/src/components/basic/Button/Button.module.css @@ -50,7 +50,7 @@ .btn.alternate:hover, .btn.alternate:focus { - background: rgb(var(--mojito-green-soft)); + background: rgba(var(--mojito-green), 0.12); transition: 0.3s ease-in-out; } @@ -60,7 +60,7 @@ } .btn.dark { - background-color: rgb(var(--color-black)); + background-color: var(--be-text-0); border: 2px solid transparent; color: rgb(var(--color-white)); transition: diff --git a/src/components/composed/WalletCard/WalletCard.module.css b/src/components/composed/WalletCard/WalletCard.module.css index e1164c49..3114739c 100644 --- a/src/components/composed/WalletCard/WalletCard.module.css +++ b/src/components/composed/WalletCard/WalletCard.module.css @@ -3,10 +3,11 @@ align-items: center; gap: 12px; padding: 12px 14px; - background: var(--mojito-green-soft); - background: rgb(var(--mojito-green-soft)); + /* --mojito-green-soft is a near-white mint (light theme): on the dark + theme it rendered as a white card. */ + background: var(--be-bg-1); border-radius: 14px; - border: 1.5px solid rgba(var(--mojito-green), 0.5); + border: 1px solid rgba(var(--mojito-green), 0.35); } .walletCardLeft { @@ -35,10 +36,10 @@ .walletCardName { font-size: 16px; font-weight: 700; - color: rgb(var(--color-black)); + color: var(--be-text-0); } .walletCardBalance { font-size: 13px; - color: rgba(var(--color-black), 0.5); + color: var(--be-text-3); } diff --git a/src/components/containers/Settings/SettingsTestnet/SettingsTestnet.module.css b/src/components/containers/Settings/SettingsTestnet/SettingsTestnet.module.css index 37f8405d..62159aa7 100644 --- a/src/components/containers/Settings/SettingsTestnet/SettingsTestnet.module.css +++ b/src/components/containers/Settings/SettingsTestnet/SettingsTestnet.module.css @@ -7,14 +7,14 @@ .title { font-size: 13px; font-weight: 600; - color: rgb(var(--color-black)); + color: var(--be-text-0); } .switcher { display: flex; gap: 8px; padding: 4px; - background: rgb(var(--color-gray)); + background: var(--be-bg-1); border-radius: var(--round-size-big); } @@ -36,7 +36,7 @@ } .optionActive { - background: rgb(var(--mojito-green-soft)); + background: rgba(var(--mojito-green), 0.12); border-color: rgb(var(--mojito-green)); color: rgb(var(--mojito-green)); } diff --git a/src/components/containers/Wallet/Delegation/DelegationDetails.module.css b/src/components/containers/Wallet/Delegation/DelegationDetails.module.css index 45a34c2f..2d17b996 100644 --- a/src/components/containers/Wallet/Delegation/DelegationDetails.module.css +++ b/src/components/containers/Wallet/Delegation/DelegationDetails.module.css @@ -18,7 +18,7 @@ gap: var(--space-2xs); min-height: max-content; padding: var(--space-3xl) var(--space-xl) var(--space-2xl); - background: rgb(var(--mojito-green-soft)); + background: var(--be-bg-1); border-radius: 16px; border: 1.5px solid rgba(var(--mojito-green), 0.15); } @@ -29,7 +29,7 @@ min-height: 56px; min-width: 56px; border-radius: 12px; - background: rgb(var(--color-white)); + background: var(--be-bg-1); box-shadow: var(--shadow-sm); display: flex; align-items: center; @@ -48,13 +48,13 @@ font-weight: 700; letter-spacing: 1.5px; text-transform: uppercase; - color: rgba(var(--color-black), 0.5); + color: var(--be-text-3); } .bannerAmount { font-size: var(--font-size-6xl); font-weight: 700; - color: rgb(var(--color-black)); + color: var(--be-text-0); display: flex; align-items: baseline; gap: var(--space-2xs); @@ -70,7 +70,7 @@ .bannerTicker { font-size: var(--font-size-md); font-weight: 600; - color: rgba(var(--color-black), 0.5); + color: var(--be-text-3); } .bannerStatus { @@ -79,7 +79,7 @@ gap: var(--space-3xs); padding: var(--space-3xs) var(--space-md); border-radius: 99px; - background: rgb(var(--color-white)); + background: var(--be-bg-1); font-size: var(--font-size-sm); font-weight: 600; color: rgb(var(--mojito-green)); @@ -91,9 +91,9 @@ } .detailsCard { - background: rgb(var(--color-white)); + background: var(--be-bg-1); border-radius: 16px; - border: 1.5px solid rgba(var(--color-black), 0.08); + border: 1.5px solid var(--be-line-soft); overflow: hidden; } @@ -102,7 +102,7 @@ justify-content: space-between; align-items: center; padding: var(--space-lg) var(--space-xl); - border-bottom: 1px solid rgba(var(--color-black), 0.06); + border-bottom: 1px solid var(--be-line-soft); } .detailRow:last-child { @@ -112,14 +112,14 @@ .detailLabel { font-size: var(--font-size-sm); font-weight: 400; - color: rgba(var(--color-black), 0.6); + color: var(--be-text-2); flex-shrink: 0; } .detailValue { font-size: var(--font-size-md); font-weight: 600; - color: rgb(var(--color-black)); + color: var(--be-text-0); text-align: right; word-break: break-all; max-width: 60%; diff --git a/src/components/containers/Wallet/TransactionDetails.module.css b/src/components/containers/Wallet/TransactionDetails.module.css index 5eb2af89..5f1a3221 100644 --- a/src/components/containers/Wallet/TransactionDetails.module.css +++ b/src/components/containers/Wallet/TransactionDetails.module.css @@ -18,7 +18,7 @@ gap: 6px; min-height: max-content; padding: 28px 20px 24px; - background: rgb(var(--mojito-green-soft)); + background: var(--be-bg-1); border-radius: 16px; border: 1.5px solid rgba(var(--mojito-green), 0.15); } @@ -38,7 +38,7 @@ min-height: 56px; min-width: 56px; border-radius: 12px; - background: rgb(var(--color-white)); + background: var(--be-bg-1); box-shadow: var(--shadow-sm); display: flex; align-items: center; @@ -61,7 +61,7 @@ font-weight: 700; letter-spacing: 1.5px; text-transform: uppercase; - color: rgba(var(--color-black), 0.5); + color: var(--be-text-3); } .bannerAmount { @@ -113,13 +113,13 @@ .bannerSwapArrow { width: 18px; height: 18px; - color: rgba(var(--color-black), 0.3); + color: var(--be-text-3); } .bannerTicker { font-size: 14px; font-weight: 600; - color: rgba(var(--color-black), 0.5); + color: var(--be-text-3); } .bannerStatus { @@ -128,7 +128,7 @@ gap: 4px; padding: 4px 12px; border-radius: 99px; - background: rgb(var(--color-white)); + background: var(--be-bg-1); font-size: 13px; font-weight: 600; color: rgb(var(--mojito-green)); @@ -145,9 +145,9 @@ } .detailsCard { - background: rgb(var(--color-white)); + background: var(--be-bg-1); border-radius: 16px; - border: 1.5px solid rgba(var(--color-black), 0.08); + border: 1.5px solid var(--be-line-soft); overflow: hidden; } @@ -156,7 +156,7 @@ justify-content: space-between; align-items: center; padding: 14px 18px; - border-bottom: 1px solid rgba(var(--color-black), 0.06); + border-bottom: 1px solid var(--be-line-soft); } .detailRow:last-child { @@ -166,13 +166,13 @@ .detailLabel { font-size: 13px; font-weight: 400; - color: rgba(var(--color-black), 0.6); + color: var(--be-text-2); } .detailValue { font-size: 14px; font-weight: 600; - color: rgb(var(--color-black)); + color: var(--be-text-0); text-align: right; word-break: break-all; max-width: 60%; @@ -194,7 +194,7 @@ .hashLabel { font-size: 12px; font-weight: 600; - color: rgb(var(--color-black)); + color: var(--be-text-0); padding-left: 4px; } @@ -205,13 +205,13 @@ padding: 10px 14px; background: #f5f7fa; border-radius: 16px; - border: 1.5px solid rgba(var(--color-black), 0.02); + border: 1.5px solid var(--be-line-soft); } .hashValue { font-size: 13px; font-weight: 400; - color: rgba(var(--color-black), 0.6); + color: var(--be-text-2); word-break: break-all; flex: 1; } From 8c548b8433900481dc1ccea940af5a190f3b13dd Mon Sep 17 00:00:00 2001 From: Enrico Rubboli Date: Tue, 8 Sep 2026 10:20:36 +0200 Subject: [PATCH 14/52] feat(tokens): show the token metadata icon (mlUSDC et al.) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Token metadata carries the token's icon in icon_uri ({ hex, string }), but the wallet only kept ticker + decimals from GET /token/:id and rendered a generic letter tile for every token (mlUSDC included). - MintlayerProvider keeps icon_uri in tokenBalances token_info - TokenIcon renders the metadata image (circular, object-fit cover) with fallback to the procedural tile on load failure; ipfs:// uris map to the public gateway (same as NFT details) - AssetRow + Dashboard token rows + AssetPage header pass the icon through - manifest CSP img-src 'self' data: -> 'self' data: https: — remote icons cannot render otherwise; deliberate trade-off (icon hosts see the IP of users holding the token), documented in REVIEW-PLAN - tests: TokenIcon image/gateway/fallback cases, AssetPage renders the metadata icon --- REVIEW-PLAN.md | 147 ++++ public/manifestDefault.json | 2 +- public/manifestFirefox.json | 2 +- .../basic/TokenIcon/TokenIcon.module.css | 17 + .../basic/TokenIcon/TokenIcon.test.tsx | 69 ++ src/components/basic/TokenIcon/TokenIcon.tsx | 82 +++ src/components/composed/AssetRow/AssetRow.tsx | 78 +++ .../MintlayerProvider/MintlayerProvider.js | 657 ++++++++++-------- src/pages/AssetPage/AssetPage.js | 208 ++++++ src/pages/AssetPage/AssetPage.test.js | 154 ++++ src/pages/Dashboard/Dashboard.js | 335 +++++++-- 11 files changed, 1419 insertions(+), 332 deletions(-) create mode 100644 REVIEW-PLAN.md create mode 100644 src/components/basic/TokenIcon/TokenIcon.module.css create mode 100644 src/components/basic/TokenIcon/TokenIcon.test.tsx create mode 100644 src/components/basic/TokenIcon/TokenIcon.tsx create mode 100644 src/components/composed/AssetRow/AssetRow.tsx create mode 100644 src/pages/AssetPage/AssetPage.js create mode 100644 src/pages/AssetPage/AssetPage.test.js diff --git a/REVIEW-PLAN.md b/REVIEW-PLAN.md new file mode 100644 index 00000000..cc40b347 --- /dev/null +++ b/REVIEW-PLAN.md @@ -0,0 +1,147 @@ +# REVIEW-PLAN — findings & follow-ups from the 4-agent codebase review (2026-09-06) + +Review agents: security / code-quality / UI-UX / DRYness. This file records +what was **deferred** (with enough context to implement correctly) and the +**accepted risks**. Fixed-in-this-branch items are in git history + HANDOFF.md. + +## Done in this branch (summary) + +- **P1 money correctness**: amount regex escaped (`/^\d+(\.\d+)?$/`, rejects + `1e3`); `getParsedTransactions` accumulation fixed (no more string concat / + value clobbering; regression tests added); `getAmountInCoins` / + `getAmountInAtoms` / token-balance sums / coin balances / delegation total / + `spendFromDelegation` now use Decimal; Dashboard 24h stats render neutral + (not "+1,234,400%") when yesterday rates are missing (`proportionDiffs`/`balanceDiffs` + can be `null` — treat `null` as "show nothing", never `0`). +- **P2 provider robustness**: `MintlayerProvider.fetchAllData` wrapped in + try/catch/finally with a ref-based mutex (flags can no longer wedge; forced + runs serialize after in-flight ones); network-switch effect owns + `cancelAllRequests()`; `fetchDelegations` always releases its flag; new + `fetchError` context field; ExchangeRatesProvider fetches coins in parallel + with error/`fetching` state; BitcoinProvider never sets `btcUtxos` to + `undefined`, dep-less effect now `[networkType]`. +- **P3 UX trust**: mock NFT grid + demo activity row removed (real empty + states); `navigate('/wallet')` dead-ends → `/dashboard`; post-send returns → + `/dashboard`; `/wallet/:coinType` and unknown routes redirect to + `/dashboard`; pages/Wallet deleted; sign/confirm screens, SignChallenge, + MessagePage, CreateDelegation overlay, Delegation cards/skeletons and the + PopUp close icon restyled onto be-\* tokens; Sparkline `responsive` prop + (used by AssetPage/StakePage chart cards); `body min-width: 400px` removed. +- **P4 security**: `getSession` no longer trusts caller-supplied `origin`; + dead `customAPIServers` override removed from both API services; no source + maps in production builds; SignInternalTransaction mock selector gated to + dev; ecpair 3.0.2 / react-router-dom 7.18.3 / bn.js updated; Chromium + manifest now HTTPS-only, top-frame only, `web_accessible_resources` no + longer ``. +- **P5 dead code / DRY**: `WALLETS_NAVIGATION`, old Dashboard containers + (CryptoList/Statistics/CryptoSharesChart/DashboardSkeleton + tests + CSS), + `src/mocks/**` and the `@Mocks` aliases, `Navigation`'s unused + `customNavigation` prop, meaningless `exact` on ``; provider now uses + exported `MINTLAYER_ENDPOINTS` (placeholder names aligned to the server + contract: `:address`); new `ML.getUnconfirmedTransactionKey` replaces 8 + hand-built localStorage keys; `'testnet'` literals use the constant. + +## Missing / deferred (implement in this order) + +1. **Real NFT data on the Dashboard NFTs tab** — mock grid deleted, tab shows + an empty state. `MintlayerContext.nftData` already holds + `[{ token_id, data }]` from `GET /nft/:id` (metadata + media links). Need: + tile component (image from IPFS gateway — DONE for token icons 2026-09-06: + manifest CSP `img-src` now allows `https:` and `TokenIcon` renders + `icon_uri` with a fallback tile; the same treatment applies to NFT media + when the NFT tab is implemented — privacy note: remote icon hosts see the + user's IP for held tokens, standard wallet trade-off), collection + grouping, and a detail sheet. Keep the empty state until then. +2. **ML bech32 checksum validation** — `isMlAddressValid/isMlPoolIdValid/ +isMlDelegationIdValid` are charset regexes only; a 1-char typo still + passes and funds are lost. Decode via the vendored wasm lib's bech32 + decoder (it exposes address decode) and verify checksum + HRP per network; + keep the regex as a fast pre-filter. Apply in AddressField + send flows. +3. **Bitcoin dApp HTLC signing is broken** — `SignBitcoinTransaction.js` + destructures `{ WIF }` from `Account.unlockAccount()` which never returns + WIF (always `undefined` → `ECPair.fromWIF` throws), and `submitCreate` + calls `BTC.BTCTransaction.buildTransaction({to, amount, fee, wif, from, +networkType})` while the function expects `{utxos, feeRate, walletType, +changeAddress, root}`. Fix key derivation (e.g. `getWIF(accountId, +password)` on the Account entity), align call-site params, add an E2E test + for create/spend/refund. +4. **Extract `useMlTransactionForm`** — SendMlTransaction / CreateDelegation / + DelegationStake / DelegationWithdraw / NftSend pages are ~70% identical + (~800 LOC): walletType literal, fee trio + debounce effect (port the + SendMl version — it's the only one with cancellation + error state), + `buildXTransaction` useCallback, `confirmMlTransaction`, accountID guard, + insufficient-funds error, `goBackToWallet`. Hook owns everything except the + unique `buildTransaction` params + route. +5. **Design-system consolidation** — one `BeButton` (primary/secondary) to + replace the per-page oklch gradient CSS (AssetPage/ReceivePage/StakePage); + `BeSheet` for detail views (DelegationDetails still uses the pastel PopUp); + shared `.be-card/.be-empty/.be-title` primitives or Card/Section components + (5 pages redefine identical CSS); `EmptyList` basic for `.empty` divs. +6. **Accessibility pass** — zero `aria-label`/`role`/`tabIndex` in the app: + convert clickable divs/spans/lis (Dashboard rows, chips, See-all, Delegation + `

  • `, Navigation items) to buttons or add role/tabIndex/Enter handlers; + aria-labels on icon-only buttons (Header back/menu, PopUp close, CopyButton, + eye toggles); global `:focus-visible` ring; Escape + focus trap + `role="dialog"` + for PopUp/Sheet/SliderMenu; raise `--be-text-3` to ~oklch(0.55) for text use + (currently ~2.5:1 on bg-1); `prefers-reduced-motion` for the Counter. +7. **Formatting unification** — one `Format.amount(value, {decimals})` + + `Format.dateTime(ts)` (today: `BTCValue`, ad-hoc `toLocaleString`, and + `dd/MM/yyyy HH:mm` vs `toLocaleString` date formats coexist; fiat symbol + appears as prefix `$1,234` on Dashboard but suffix `1,234 $` on AssetPage). +8. **Loading/empty/error states on the new pages** — Dashboard/ActivityPage/ + AssetPage ignore `fetchingBalances/fetchingTransactions` (ActivityPage says + "No transactions" while loading); `fetchError` (added this branch) is not + yet surfaced with a retry UI; `btcApiAvailable=false` has no visual state on + Dashboard rows (`disabled` flag is dead data in AssetRow). +9. **Token Send from AssetPage** — Send is hidden for tokens because the send + route never receives the `tokenId` (Wallet page built `walletType.tokenId` + which is now deleted; route param `/wallet/:coinType/send-ml-transaction` + can carry the token id as `coinType` — needs wiring in SendMlTransaction). +10. **Dashboard Send chain chooser** — quick action hardcodes the ML send + flow; BTC send requires going via the BTC asset page. Small BeSheet with + Bitcoin/Mintlayer. +11. **Provider plumbing consolidation (DRY)** — `usePolling(fetcher, +interval)` + `useNetworkSync()` shared by Mintlayer/Bitcoin/ExchangeRates + providers; `createAbortRegistry()` (note: `requestMintlayer` registers + controllers but never passes `signal` to fetch — abort is currently a + no-op); `chunkedBatch()` shared by BTC/ML `getBatchData`; shared + `renderWithProviders` test-utils (the `propValue` escape hatch on all + three providers exists and is unused by tests). +12. **Truncation helper** — generalize `ML.formatAddress` into chain-agnostic + `truncateMiddle(value, {head, tail})`; replace inline `slice(0,10)…` + sites (AssetPage address, ActivityPage hash, TransactionBreakdown) and the + 5× duplicated receive-address resolution (→ `useReceiveAddress(chain)`). +13. **Explorer-link helper** — `DelegationDetails`, `NftDetails` and + `StakePage` each hand-build `https://${testnet ? 'lovelace.' : ''}explorer…` + URLs; extend the existing `ML.getMlAddressLink/getMlTransactionLink` into + `getExplorerLink(kind, id, network)`; fix the raw-string drift (33 + networkType comparisons; literals now use the constant on Stake/Receive). +14. **Bundle/code splitting** — `main.js` ~760 KB + `vendors.js` ~1.6 MB: add + `React.lazy` routes for legacy flows (sign screens, swap, NFT) once (11) + lands. +15. **Housekeeping** — remove leftover `console.log`s (notably + OrderDetails.js logs balances; "No account id." in 7 pages); + `useMlWalletInfo(addresses, token)` signature ignores its first arg at + call sites that still pass one; `fetchDelegations(addresses)` call-sites + pass an ignored argument; `getNftsData` `error.message.includes` → + optional-chain; ReceivePage clipboard write has no success/failure + feedback; `jest.config` `testPathIgnorePatterns ['/tests/']` should be + anchored to `/src/tests/`. + +## Accepted risks (documented, not scheduled) + +- **`window.mojito` fingerprinting**: any HTTPS site can detect the wallet. + Inherent to the user-approved model (manual connect approval, no static + allowlist). Mitigated: HTTPS-only, top-frame only, manual approval popup, + origin taken from `sender`. +- **postMessage page-trust model** (mojito.js): any script in a _connected_ + page can call `signTransaction`/read responses — standard wallet-SDK trust + model; approval origin is shown on sign pages (SiteBadge). +- **elliptic advisory (GHSA-848j-6mx2-7j84)** via crypto-browserify webpack + polyfill: the only "fix" is a breaking crypto-browserify downgrade; risk + accepted until the polyfill can be dropped entirely. +- **Dev-only npm advisories** (webpack-dev-server/ws/sockjs chain) — not + shipped in the extension bundle. +- **30-min soft-unlock**: `unlockedAccount` in localStorage contains public + addresses/pubkeys only; expiry is client-editable. Consider + `chrome.storage.session` for the timestamp. diff --git a/public/manifestDefault.json b/public/manifestDefault.json index 74cc0f62..177d290d 100644 --- a/public/manifestDefault.json +++ b/public/manifestDefault.json @@ -14,7 +14,7 @@ "512": "logo512.png" }, "content_security_policy": { - "extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'; img-src 'self' data:; style-src 'self'; font-src 'self'; style-src-elem 'self'" + "extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'; img-src 'self' data: https:; style-src 'self'; font-src 'self'; style-src-elem 'self'" }, "action": { "default_icon": "logo192.png", diff --git a/public/manifestFirefox.json b/public/manifestFirefox.json index 8f501333..0b29c0f6 100644 --- a/public/manifestFirefox.json +++ b/public/manifestFirefox.json @@ -51,7 +51,7 @@ "default_panel": "index.html" }, "content_security_policy": { - "extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'; img-src 'self' data:; style-src 'self'; font-src 'self'; style-src-elem 'self'" + "extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'; img-src 'self' data: https:; style-src 'self'; font-src 'self'; style-src-elem 'self'" }, "commands": { "_execute_action": { diff --git a/src/components/basic/TokenIcon/TokenIcon.module.css b/src/components/basic/TokenIcon/TokenIcon.module.css new file mode 100644 index 00000000..b7a5882b --- /dev/null +++ b/src/components/basic/TokenIcon/TokenIcon.module.css @@ -0,0 +1,17 @@ +.token { + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + color: #0b0a09; + font-weight: 700; + flex-shrink: 0; +} + +.tokenImage { + width: 100%; + height: 100%; + border-radius: 50%; + object-fit: cover; + display: block; +} diff --git a/src/components/basic/TokenIcon/TokenIcon.test.tsx b/src/components/basic/TokenIcon/TokenIcon.test.tsx new file mode 100644 index 00000000..6c1ac4e6 --- /dev/null +++ b/src/components/basic/TokenIcon/TokenIcon.test.tsx @@ -0,0 +1,69 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import TokenIcon from './TokenIcon' + +test.each(['BTC', 'ML'])('renders the real chain logo svg for %s', (symbol) => { + const { getByTestId } = render() + expect(getByTestId('token-icon').querySelector('svg')).toBeInTheDocument() +}) + +test('renders with the symbol glyph for known non-chain tokens', () => { + render() + expect(screen.getByTestId('token-icon')).toHaveTextContent('Ξ') +}) + +test('falls back to the first letter for unknown tokens', () => { + render() + expect(screen.getByTestId('token-icon')).toHaveTextContent('C') +}) + +test('applies the requested size', () => { + render( + , + ) + expect(screen.getByTestId('token-icon')).toHaveStyle({ width: '48px' }) +}) + +test('renders the token metadata icon when an iconUri is provided', () => { + const { getByTestId } = render( + , + ) + const img = getByTestId('token-icon-image') as HTMLImageElement + expect(img).toBeInTheDocument() + expect(img.src).toBe('https://example.com/mlusdc.png') +}) + +test('maps ipfs:// icon uris to a public gateway', () => { + const { getByTestId } = render( + , + ) + const img = getByTestId('token-icon-image') as HTMLImageElement + expect(img.src).toBe('https://gateway.ipfs.io/ipfs/bafyabc/icon.png') +}) + +test('falls back to the procedural tile when the icon fails to load', async () => { + const { getByTestId, queryByTestId } = render( + , + ) + + // jsdom may report the load failure on its own; if the img is still + // there, drive the failure the way a browser would. + const img = queryByTestId('token-icon-image') + if (img) fireEvent.error(img) + + await waitFor(() => + expect(queryByTestId('token-icon-image')).not.toBeInTheDocument(), + ) + expect(getByTestId('token-icon')).toHaveTextContent('$') +}) diff --git a/src/components/basic/TokenIcon/TokenIcon.tsx b/src/components/basic/TokenIcon/TokenIcon.tsx new file mode 100644 index 00000000..3b50bed4 --- /dev/null +++ b/src/components/basic/TokenIcon/TokenIcon.tsx @@ -0,0 +1,82 @@ +import { useState, cloneElement, ReactElement } from 'react' +import styles from './TokenIcon.module.css' +import { ReactComponent as MlLogo } from '@Assets/images/logo.svg' +import { ReactComponent as BtcLogo } from '@Assets/images/btc-logo.svg' + +interface TokenIconProps { + symbol: string + size?: number + // Token metadata icon (token_info.icon_uri.string). ipfs:// is mapped to a + // public gateway; anything unreachable falls back to the generated tile. + iconUri?: string +} + +const MAP: Record = { + ETH: { c1: 'oklch(0.74 0.06 280)', c2: 'oklch(0.55 0.08 280)', g: 'Ξ' }, + USDT: { c1: 'oklch(0.78 0.13 160)', c2: 'oklch(0.6 0.12 160)', g: '₮' }, + USDC: { c1: 'oklch(0.7 0.13 240)', c2: 'oklch(0.55 0.14 250)', g: '$' }, +} + +const GRADIENTS: Record = { + BTC: { c1: 'oklch(0.82 0.16 70)', c2: 'oklch(0.7 0.17 50)' }, + ML: { c1: 'oklch(0.36 0.015 70)', c2: 'oklch(0.26 0.015 70)' }, +} + +const LOGOS: Record = { + BTC: , + ML: , +} + +const toRenderableUri = (uri: string) => + uri.startsWith('ipfs://') + ? uri.replace('ipfs://', 'https://gateway.ipfs.io/ipfs/') + : uri + +// Native BTC/ML assets get the real chain logos; tokens show their metadata +// icon when available, otherwise the procedural design-system tile (unknown +// symbols fall back to first letter). +const TokenIcon = ({ symbol, size = 36, iconUri }: TokenIconProps) => { + const [iconFailed, setIconFailed] = useState(false) + const logo = LOGOS[symbol] + const { c1, c2 } = GRADIENTS[symbol] ?? { + c1: 'oklch(0.6 0.05 60)', + c2: 'oklch(0.4 0.05 60)', + } + + const showImage = Boolean(iconUri) && !iconFailed && !logo + + return ( +
    + {showImage ? ( + {symbol} setIconFailed(true)} + data-testid="token-icon-image" + /> + ) : logo ? ( + cloneElement(logo, { + width: size * 0.62, + height: size * 0.62, + }) + ) : ( + (MAP[symbol]?.g ?? symbol[0]) + )} +
    + ) +} + +export default TokenIcon diff --git a/src/components/composed/AssetRow/AssetRow.tsx b/src/components/composed/AssetRow/AssetRow.tsx new file mode 100644 index 00000000..6e7f5931 --- /dev/null +++ b/src/components/composed/AssetRow/AssetRow.tsx @@ -0,0 +1,78 @@ +import styles from './AssetRow.module.css' +import Tag from '../../basic/Tag/Tag' +import TokenIcon from '../../basic/TokenIcon/TokenIcon' +import Sparkline from '../../basic/Sparkline/Sparkline' +import LivePill from '../../basic/LivePill/LivePill' + +export interface DesignAsset { + id: string + name: string + symbol: string + chain: string + amount: number + price?: number + change24h?: number + spark?: number[] + mock?: boolean + authority?: boolean + iconUri?: string + onClick?: () => void + index?: number +} + +// Design-system asset row (doc/ be-home.jsx token list row). +const AssetRow = ({ a }: { a: DesignAsset }) => { + const fiat = a.price != null ? a.amount * a.price : undefined + return ( +
    + +
    +
    + + {a.name} ({a.symbol}) + + {a.chain === 'Mintlayer' && a.id !== 'ml' && ( + Token + )} + {a.authority && Issuer} + {a.mock && Demo} +
    +
    + {a.amount.toLocaleString(undefined, { + maximumFractionDigits: 6, + })}{' '} + {a.symbol} +
    +
    + {a.spark && a.spark.length > 1 && ( + = 0 ? 'var(--be-green)' : 'var(--be-red)'} + width={44} + height={20} + /> + )} +
    +
    + {fiat != null + ? fiat.toLocaleString(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }) + : '—'}{' '} + $ +
    + {a.change24h != null && } +
    +
    + ) +} + +export default AssetRow diff --git a/src/contexts/MintlayerProvider/MintlayerProvider.js b/src/contexts/MintlayerProvider/MintlayerProvider.js index ae8e80ce..3de14a72 100644 --- a/src/contexts/MintlayerProvider/MintlayerProvider.js +++ b/src/contexts/MintlayerProvider/MintlayerProvider.js @@ -1,4 +1,11 @@ -import React, { createContext, useContext, useEffect, useState } from 'react' +import React, { + createContext, + useContext, + useEffect, + useRef, + useState, +} from 'react' +import Decimal from 'decimal.js' import { AccountContext, SettingsContext } from '@Contexts' import { AppInfo } from '@Constants' @@ -81,9 +88,26 @@ const MintlayerProvider = ({ value: propValue, children }) => { const [ordersPairInfo, setOrdersPairInfo] = useState([]) const [tokenMap, setTokenMap] = useState({}) const [orderPairLoading, setOrderPairLoading] = useState(false) + const [fetchError, setFetchError] = useState(null) + // Ref-based mutex: state updates are async, so two overlapping effects + // could both pass an allDataFetching state guard and interleave runs. + const fetchAllDataRunningRef = useRef(false) + const fetchAllDataPromiseRef = useRef(null) const fetchOrdersPairInfo = async (orderPair, amount) => { setOrderPairLoading(true) + try { + return await fetchOrdersPairInfoInner(orderPair, amount) + } catch (error) { + console.error('Failed to fetch orders pair info:', error) + setOrdersPairInfo([]) + return [] + } finally { + setOrderPairLoading(false) + } + } + + const fetchOrdersPairInfoInner = async (orderPair, amount) => { const coinTicker = networkType === AppInfo.NETWORK_TYPES.TESTNET ? 'TML' : 'ML' const swapPairsCurrency = orderPair.split('_') @@ -134,302 +158,342 @@ const MintlayerProvider = ({ value: propValue, children }) => { } } - const fetchAllData = async (force) => { - if ( - allDataFetching && - !force && - (currentAccountId === accountID || networkType === currentNetworkType) - ) { - return - } + const runFetchAllData = async () => { + try { + // fetch fee rate + const feerate = await Mintlayer.getFeesEstimates() + setFeerate(parseInt(JSON.parse(feerate))) - // fetch fee rate - const feerate = await Mintlayer.getFeesEstimates() - setFeerate(parseInt(JSON.parse(feerate))) + const account = LocalStorageService.getItem('unlockedAccount') - const account = LocalStorageService.getItem('unlockedAccount') + if (!account) return - if (!account) return + setAllDataFetching(true) + setFetchingTransactions(true) + setFetchingBalances(true) + setFetchingUtxos(true) + setFetchingDelegations(true) + setFetchingTokens(true) + setFetchingNft(true) - setAllDataFetching(true) - setFetchingTransactions(true) - setFetchingBalances(true) - setFetchingUtxos(true) - setFetchingDelegations(true) - setFetchingTokens(true) - setFetchingNft(true) + // resetState() + // fetch addresses + const addressList = currentMlAddresses + ? [ + ...currentMlAddresses.mlReceivingAddresses, + ...currentMlAddresses.mlChangeAddresses, + ] + : [] - // resetState() - // fetch addresses - const addressList = currentMlAddresses - ? [ - ...currentMlAddresses.mlReceivingAddresses, - ...currentMlAddresses.mlChangeAddresses, - ] - : [] + if (addressList.length === 0) { + return + } - if (addressList.length === 0) { - setFetchingBalances(false) - setFetchingTransactions(false) - setFetchingUtxos(false) - setAllDataFetching(false) - return - } + setCurrentNetworkType(networkType) + setCurrentHeight(onlineHeight) - setCurrentNetworkType(networkType) - setCurrentHeight(onlineHeight) + const addresses_data_receive_data = await ML.getBatchData( + currentMlAddresses.mlReceivingAddresses, + Mintlayer.MINTLAYER_ENDPOINTS.GET_ADDRESS_DATA, + ) + const addresses_data_change_data = await ML.getBatchData( + currentMlAddresses.mlChangeAddresses, + Mintlayer.MINTLAYER_ENDPOINTS.GET_ADDRESS_DATA, + ) - const addresses_data_receive_data = await ML.getBatchData( - currentMlAddresses.mlReceivingAddresses, - '/address/:address', - ) - const addresses_data_change_data = await ML.getBatchData( - currentMlAddresses.mlChangeAddresses, - '/address/:address', - ) - - const addresses_data_receive = addresses_data_receive_data.map( - (address) => { - if (address.error) { + const addresses_data_receive = addresses_data_receive_data.map( + (address) => { + if (address.error) { + return { + ...address, + coin_balance: { atoms: '0', decimal: '0' }, + locked_coin_balance: { atoms: '0', decimal: '0' }, + tokens: [], + unused: true, + } + } return { ...address, - coin_balance: { atoms: '0', decimal: '0' }, - locked_coin_balance: { atoms: '0', decimal: '0' }, - tokens: [], - unused: true, + unused: address.unused || false, } - } - return { - ...address, - unused: address.unused || false, - } - }, - ) + }, + ) - const addresses_data_change = addresses_data_change_data.map((address) => { - if (address.error) { - return { - ...address, - coin_balance: { atoms: '0', decimal: '0' }, - locked_coin_balance: { atoms: '0', decimal: '0' }, - tokens: [], - unused: true, - } - } - return { - ...address, - unused: address.unused || false, - } - }) + const addresses_data_change = addresses_data_change_data.map( + (address) => { + if (address.error) { + return { + ...address, + coin_balance: { atoms: '0', decimal: '0' }, + locked_coin_balance: { atoms: '0', decimal: '0' }, + tokens: [], + unused: true, + } + } + return { + ...address, + unused: address.unused || false, + } + }, + ) - const addresses_data = [...addresses_data_receive, ...addresses_data_change] - setAddressData(addresses_data) - - const first_unused_change_address_index = addresses_data_change.findIndex( - (address_data) => { - const { unused } = address_data - return unused === true - }, - ) - - const first_unused_change_address = - currentMlAddresses.mlChangeAddresses[first_unused_change_address_index] || - currentMlAddresses.mlChangeAddresses[0] - - const first_unused_receive_address_index = addresses_data_receive.findIndex( - (address_data) => { - const { unused } = address_data - return unused === true - }, - ) - - const first_unused_receive_address = - currentMlAddresses.mlReceivingAddresses[ - first_unused_receive_address_index - ] || currentMlAddresses.mlReceivingAddresses[0] - - setUnusedAddresses({ - change: first_unused_change_address, - receive: first_unused_receive_address, - }) + const addresses_data = [ + ...addresses_data_receive, + ...addresses_data_change, + ] + setAddressData(addresses_data) - let available_balance = BigInt(0) - let locked_balance = BigInt(0) - const tokenBalances = {} - const nftBalances = {} - const transaction_ids = [] - const non_zero_addresses = [] - const locked_addresses = [] - - addresses_data - .filter(({ error }) => !error) - .forEach((address_data) => { - const { - coin_balance, - locked_coin_balance, - transaction_history, - tokens, - } = address_data - available_balance = coin_balance - ? available_balance + BigInt(coin_balance.atoms) - : available_balance - locked_balance = locked_coin_balance - ? locked_balance + BigInt(locked_coin_balance.atoms) - : locked_balance - transaction_ids.push(...transaction_history) + const first_unused_change_address_index = addresses_data_change.findIndex( + (address_data) => { + const { unused } = address_data + return unused === true + }, + ) - if ( - coin_balance.atoms !== '0' || - (tokens.length > 0 && - tokens.some((token) => token.amount.atoms !== '0')) - ) { - non_zero_addresses.push(address_data.id) - } + const first_unused_change_address = + currentMlAddresses.mlChangeAddresses[ + first_unused_change_address_index + ] || currentMlAddresses.mlChangeAddresses[0] + + const first_unused_receive_address_index = + addresses_data_receive.findIndex((address_data) => { + const { unused } = address_data + return unused === true + }) + + const first_unused_receive_address = + currentMlAddresses.mlReceivingAddresses[ + first_unused_receive_address_index + ] || currentMlAddresses.mlReceivingAddresses[0] + + setUnusedAddresses({ + change: first_unused_change_address, + receive: first_unused_receive_address, + }) - if (locked_coin_balance && locked_coin_balance.atoms !== '0') { - locked_addresses.push(address_data.id) - } + let available_balance = BigInt(0) + let locked_balance = BigInt(0) + const tokenBalances = {} + const nftBalances = {} + const transaction_ids = [] + const non_zero_addresses = [] + const locked_addresses = [] + + addresses_data + .filter(({ error }) => !error) + .forEach((address_data) => { + const { + coin_balance, + locked_coin_balance, + transaction_history, + tokens, + } = address_data + available_balance = coin_balance + ? available_balance + BigInt(coin_balance.atoms) + : available_balance + locked_balance = locked_coin_balance + ? locked_balance + BigInt(locked_coin_balance.atoms) + : locked_balance + transaction_ids.push(...transaction_history) + + if ( + coin_balance.atoms !== '0' || + (tokens.length > 0 && + tokens.some((token) => token.amount.atoms !== '0')) + ) { + non_zero_addresses.push(address_data.id) + } - if (tokens) { - tokens.forEach((token) => { - const { token_id, amount } = token - if (!tokenBalances[token_id]) { - tokenBalances[token_id] = 0 - } - if (amount.decimal === '1' && amount.atoms === '1') { - nftBalances[token_id] = 1 - } else { - tokenBalances[token_id] += Number(amount.decimal) - } - }) - } - }) + if (locked_coin_balance && locked_coin_balance.atoms !== '0') { + locked_addresses.push(address_data.id) + } - const { tokensData: nftData, excludedTokenIds } = - await Mintlayer.getNftsData(Object.keys(nftBalances)) + if (tokens) { + tokens.forEach((token) => { + const { token_id, amount } = token + if (amount.decimal === '1' && amount.atoms === '1') { + nftBalances[token_id] = 1 + } else { + // Decimal accumulation: float += on decimal strings drifts. + tokenBalances[token_id] = tokenBalances[token_id] + ? tokenBalances[token_id].plus(amount.decimal) + : new Decimal(amount.decimal) + } + }) + } + }) - if (Object.keys(excludedTokenIds).length > 0) { - Object.keys(nftBalances).forEach((tokenId) => { - if (excludedTokenIds[tokenId]) { - tokenBalances[tokenId] = nftBalances[tokenId] - delete nftBalances[tokenId] - } - }) - } + const { tokensData: nftData, excludedTokenIds } = + await Mintlayer.getNftsData(Object.keys(nftBalances)) - const mergedNftsData = Object.entries(nftData).reduce( - (acc, [key, value]) => { - if (value && Object.keys(value).length > 0) { - acc.push({ - token_id: key, - data: { ...value }, - }) - } - return acc - }, - [], - ) - - const tokensData = await Mintlayer.getTokensData(Object.keys(tokenBalances)) - - const mergedTokensData = Object.keys(tokenBalances).reduce((acc, key) => { - if (tokensData[key] && Object.keys(tokensData[key]).length > 0) { - acc[key] = { - balance: tokenBalances[key], - token_info: { - number_of_decimals: tokensData[key].number_of_decimals, - token_ticker: tokensData[key].token_ticker, - token_id: key, - }, - } + if (Object.keys(excludedTokenIds).length > 0) { + Object.keys(nftBalances).forEach((tokenId) => { + if (excludedTokenIds[tokenId]) { + tokenBalances[tokenId] = new Decimal(nftBalances[tokenId]) + delete nftBalances[tokenId] + } + }) } - return acc - }, {}) - const newTokenMap = {} + const mergedNftsData = Object.entries(nftData).reduce( + (acc, [key, value]) => { + if (value && Object.keys(value).length > 0) { + acc.push({ + token_id: key, + data: { ...value }, + }) + } + return acc + }, + [], + ) - const allNetworkTokensData = await Mintlayer.getAllTokensData(networkType) - allNetworkTokensData.forEach((token) => { - newTokenMap[token.token_id] = token.symbol || '' - }) - setTokenMap(newTokenMap) - - setFetchingNft(false) - setTokenBalances(mergedTokensData) - setNftData(mergedNftsData) - setBalance(Number(available_balance) / AppInfo.ML_ATOMS_PER_COIN) - setLockedBalance(Number(locked_balance) / AppInfo.ML_ATOMS_PER_COIN) - setFetchingBalances(false) - setFetchingTokens(false) - setCurrentAccountId(accountID) - setAllNetworkTokensData(allNetworkTokensData) - - // fetch transactions data - const transactions_data = await ML.getBatchData( - [...new Set(transaction_ids)], - '/transaction/:txid', - ) - - const parsedTransactions = ML.getParsedTransactions( - transactions_data, - addressList, - ) - setTransactions(parsedTransactions) - setFetchingTransactions(false) - - // fetch utxos - const accountName = account && account.name - const unconfirmedTransactionString = `${AppInfo.UNCONFIRMED_TRANSACTION_NAME}_${accountName}_${networkType}` - const unconfirmedTransactions = - LocalStorageService.getItem(unconfirmedTransactionString) || [] - - const fetchedSpendableUtxos = await ML.getBatchData( - non_zero_addresses, - '/address/:address/spendable-utxos', - ) - - const available = fetchedSpendableUtxos - .filter((item) => item.utxo?.value) - .filter((item) => item.utxo.type !== 'Htlc') // Do not try to spend non-external - .filter((item) => { - if (unconfirmedTransactions) { - return !unconfirmedTransactions.some( - (unconfirmedTransaction) => - unconfirmedTransaction.usedUtxosOutpoints && - unconfirmedTransaction.usedUtxosOutpoints.filter( - (utxo) => - utxo.source_id === item.outpoint.source_id && - utxo.index === item.outpoint.index, - ).length > 0, - ) + const tokensData = await Mintlayer.getTokensData( + Object.keys(tokenBalances), + ) + + const mergedTokensData = Object.keys(tokenBalances).reduce((acc, key) => { + if (tokensData[key] && Object.keys(tokensData[key]).length > 0) { + acc[key] = { + balance: tokenBalances[key].toNumber(), + token_info: { + number_of_decimals: tokensData[key].number_of_decimals, + token_ticker: tokensData[key].token_ticker, + token_id: key, + // { hex, string } token metadata — string is the icon URL + // (https or ipfs) shown next to the token everywhere. + icon_uri: tokensData[key].icon_uri, + }, + } } - return true - }) - .reduce((acc, item) => { - acc.push(item) return acc - }, []) + }, {}) - const availableUtxos = available.map((item) => item) + const newTokenMap = {} - const fetchedLockedUtxos = - locked_addresses.length > 0 - ? await ML.getBatchData(locked_addresses, '/address/:address/all-utxos') - : [] - const lockedUtxos = fetchedLockedUtxos.filter( - (obj) => obj.utxo.type === 'LockThenTransfer', - ) + const allNetworkTokensData = await Mintlayer.getAllTokensData(networkType) + allNetworkTokensData.forEach((token) => { + newTokenMap[token.token_id] = token.symbol || '' + }) + setTokenMap(newTokenMap) + + setFetchingNft(false) + setTokenBalances(mergedTokensData) + setNftData(mergedNftsData) + // Decimal division from the BigInt atom totals: Number(bigint) alone + // loses precision above 2^53 atoms (~900 ML at 11 decimals). + setBalance( + new Decimal(available_balance.toString()) + .dividedBy(AppInfo.ML_ATOMS_PER_COIN) + .toNumber(), + ) + setLockedBalance( + new Decimal(locked_balance.toString()) + .dividedBy(AppInfo.ML_ATOMS_PER_COIN) + .toNumber(), + ) + setFetchingBalances(false) + setFetchingTokens(false) + setCurrentAccountId(accountID) + setAllNetworkTokensData(allNetworkTokensData) + + // fetch transactions data + const transactions_data = await ML.getBatchData( + [...new Set(transaction_ids)], + Mintlayer.MINTLAYER_ENDPOINTS.GET_TRANSACTION_DATA, + ) + + const parsedTransactions = ML.getParsedTransactions( + transactions_data, + addressList, + ) + setTransactions(parsedTransactions) + setFetchingTransactions(false) + + // fetch utxos + const accountName = account && account.name + const unconfirmedTransactionString = ML.getUnconfirmedTransactionKey( + accountName, + networkType, + ) + const unconfirmedTransactions = + LocalStorageService.getItem(unconfirmedTransactionString) || [] + + const fetchedSpendableUtxos = await ML.getBatchData( + non_zero_addresses, + Mintlayer.MINTLAYER_ENDPOINTS.GET_ADDRESS_SPENDABLE_UTXO, + ) - const availableNftInitialUtxos = fetchedSpendableUtxos.filter( - (item) => item.utxo?.type === 'IssueNft', - ) + const available = fetchedSpendableUtxos + .filter((item) => item.utxo?.value) + .filter((item) => item.utxo.type !== 'Htlc') // Do not try to spend non-external + .filter((item) => { + if (unconfirmedTransactions) { + return !unconfirmedTransactions.some( + (unconfirmedTransaction) => + unconfirmedTransaction.usedUtxosOutpoints && + unconfirmedTransaction.usedUtxosOutpoints.filter( + (utxo) => + utxo.source_id === item.outpoint.source_id && + utxo.index === item.outpoint.index, + ).length > 0, + ) + } + return true + }) + .reduce((acc, item) => { + acc.push(item) + return acc + }, []) + + const availableUtxos = available.map((item) => item) + + const fetchedLockedUtxos = + locked_addresses.length > 0 + ? await ML.getBatchData( + locked_addresses, + Mintlayer.MINTLAYER_ENDPOINTS.GET_ADDRESS_UTXO, + ) + : [] + const lockedUtxos = fetchedLockedUtxos.filter( + (obj) => obj.utxo.type === 'LockThenTransfer', + ) - setNftInitialUtxos(availableNftInitialUtxos) - setUtxos(availableUtxos) - setLockedUtxos(lockedUtxos) + const availableNftInitialUtxos = fetchedSpendableUtxos.filter( + (item) => item.utxo?.type === 'IssueNft', + ) - setFetchingUtxos(false) - setAllDataFetching(false) + setNftInitialUtxos(availableNftInitialUtxos) + setUtxos(availableUtxos) + setLockedUtxos(lockedUtxos) + } catch (error) { + // Never leave the UI wedged: surface the error and let `finally` + // release every loading flag so the next poll can retry. + console.error('fetchAllData failed:', error) + setFetchError(error) + } finally { + setAllDataFetching(false) + setFetchingTransactions(false) + setFetchingBalances(false) + setFetchingUtxos(false) + setFetchingTokens(false) + setFetchingNft(false) + } + } + + const fetchAllData = async (force) => { + // Dedupe concurrent non-forced calls; serialize forced calls after the + // in-flight run so a network switch can never interleave two runs. + if (fetchAllDataRunningRef.current && !force) return + while (fetchAllDataRunningRef.current && fetchAllDataPromiseRef.current) { + await fetchAllDataPromiseRef.current.catch(() => {}) + } + fetchAllDataRunningRef.current = true + fetchAllDataPromiseRef.current = runFetchAllData().finally(() => { + fetchAllDataRunningRef.current = false + fetchAllDataPromiseRef.current = null + }) + return fetchAllDataPromiseRef.current } const balanceLoading = @@ -447,7 +511,7 @@ const MintlayerProvider = ({ value: propValue, children }) => { : [] const allDelegations = await ML.getBatchData( addressList, - '/address/:address/delegations', + Mintlayer.MINTLAYER_ENDPOINTS.GET_ADDRESS_DELEGATIONS, ) const delegations = [ ...new Map(allDelegations.map((d) => [d.delegation_id, d])).values(), @@ -457,19 +521,28 @@ const MintlayerProvider = ({ value: propValue, children }) => { ) const delegation_details = await ML.getBatchData( delegationList, - '/delegation/:address', + Mintlayer.MINTLAYER_ENDPOINTS.GET_DELEGATION, ) const blocksList = delegation_details.map( (delegation) => delegation.creation_block_height, ) - const block_hashes = await ML.getBatchData(blocksList, '/chain/:address') - const blocks_data = await ML.getBatchData(block_hashes, '/block/:address') + const block_hashes = await ML.getBatchData( + blocksList, + Mintlayer.MINTLAYER_ENDPOINTS.GET_BLOCK_HASH, + ) + const blocks_data = await ML.getBatchData( + block_hashes, + Mintlayer.MINTLAYER_ENDPOINTS.GET_BLOCK_DATA, + ) const pools = delegation_details.map((delegation) => delegation.pool_id) const uniquePools = [...new Set(pools)] - const pools_data = await ML.getBatchData(uniquePools, '/pool/:address') + const pools_data = await ML.getBatchData( + uniquePools, + Mintlayer.MINTLAYER_ENDPOINTS.GET_POOL_DATA, + ) const emptyPoolsDataMap = uniquePools.reduce((acc, pool, index) => { if (pools_data[index]?.staker_balance?.atoms === '0') { @@ -492,7 +565,10 @@ const MintlayerProvider = ({ value: propValue, children }) => { } }) - const unconfirmedTransactionString = `${AppInfo.UNCONFIRMED_TRANSACTION_NAME}_${accountName}_${networkType}` + const unconfirmedTransactionString = ML.getUnconfirmedTransactionKey( + accountName, + networkType, + ) const unconfirmedTransactions = LocalStorageService.getItem(unconfirmedTransactionString) || [] @@ -506,24 +582,28 @@ const MintlayerProvider = ({ value: propValue, children }) => { mergedDelegations.unshift(...delegationTransactions) } - const totalDelegationBalance = mergedDelegations.reduce( - (acc, delegation) => - acc + - (delegation.balance.decimal ? Number(delegation.balance.decimal) : 0), - 0, - ) + const totalDelegationBalance = mergedDelegations + .reduce( + (acc, delegation) => acc.plus(delegation.balance?.decimal || 0), + new Decimal(0), + ) + .toNumber() setMlDelegationsBalance(totalDelegationBalance) setMlDelegationList(mergedDelegations) - - setFetchingDelegations(false) } catch (error) { console.error(error) + setMlDelegationsBalance(0) + setMlDelegationList([]) + } finally { + // Always release the flag — including the `!addresses` early return. setFetchingDelegations(false) } } useEffect(() => { if (networkType !== currentNetworkType) { + // Supersede any in-flight requests started for the old network. + Mintlayer.cancelAllRequests() setOrdersPairInfo([]) setMlDelegationList([]) setMlDelegationsBalance(0) @@ -540,7 +620,8 @@ const MintlayerProvider = ({ value: propValue, children }) => { }, [accountID]) useEffect(() => { - Mintlayer.cancelAllRequests() + // No cancelAllRequests() here: it would abort requests the network + // effect just started; fetchAllData's own mutex serializes runs. setCurrentHeight(onlineHeight) const getData = async () => { await fetchAllData() @@ -552,9 +633,14 @@ const MintlayerProvider = ({ value: propValue, children }) => { useEffect(() => { const getData = async () => { - const result = await Mintlayer.getChainTip() - const { block_height } = JSON.parse(result) - setOnlineHeight(block_height) + try { + const result = await Mintlayer.getChainTip() + const { block_height } = JSON.parse(result) + setOnlineHeight(block_height) + } catch (error) { + // Transient node outages must not produce unhandled rejections. + console.error('Failed to fetch chain tip:', error) + } } getData() const data = setInterval(getData, AppInfo.REFRESH_INTERVAL) @@ -611,6 +697,7 @@ const MintlayerProvider = ({ value: propValue, children }) => { fetchOrdersPairInfo, orderPairLoading, tokenMap, + fetchError, } return ( diff --git a/src/pages/AssetPage/AssetPage.js b/src/pages/AssetPage/AssetPage.js new file mode 100644 index 00000000..b60aa547 --- /dev/null +++ b/src/pages/AssetPage/AssetPage.js @@ -0,0 +1,208 @@ +import { useContext } from 'react' +import { useNavigate, useParams } from 'react-router' + +import { TxRow } from '@ComposedComponents' +import { AccountContext, BitcoinContext, MintlayerContext } from '@Contexts' +import { + PageWrapper, + TokenIcon, + LivePill, + ChainBadge, + KV, + Eyebrow, + Sparkline, + Button, +} from '@BasicComponents' +import { + useExchangeRates, + useOneDayAgoHist, + useBtcWalletInfo, + useMlWalletInfo, +} from '@Hooks' +import { Transactions, BTC } from '@Helpers' +const { adaptDesignTx } = Transactions + +import styles from './AssetPage.module.css' + +/** + * Asset detail screen from the design (doc/ be-home.jsx AssetScreenBE). + * Real data for BTC, ML and Mintlayer tokens (via MintlayerContext + * `tokenBalances`, enriched from GET /token/:tokenId). + */ +const AssetPage = () => { + const { id } = useParams() + const navigate = useNavigate() + const { addresses } = useContext(AccountContext) + const { unusedAddresses: btcUnused } = useContext(BitcoinContext) + const { tokenBalances } = useContext(MintlayerContext) + + const isBtc = id === 'Bitcoin' + const isMl = id === 'Mintlayer' + const isReal = isBtc || isMl + + const btcInfo = useBtcWalletInfo() + // Token mode for Mintlayer token ids (token-scoped balance + txs), + // full ML wallet info for coins. + const mlInfo = useMlWalletInfo(undefined, id) + const { exchangeRate: btcRate } = useExchangeRates('btc', 'usd') + const { exchangeRate: mlRate } = useExchangeRates('ml', 'usd') + const { historyRates: btcHist } = useOneDayAgoHist('btc', 'usd') + const { historyRates: mlHist } = useOneDayAgoHist('ml', 'usd') + + const tokenData = isReal ? null : (tokenBalances?.[id] ?? null) + + const ticker = isBtc + ? 'BTC' + : isMl + ? 'ML' + : tokenData?.token_info?.token_ticker?.string || 'TKN' + const name = isBtc ? 'Bitcoin' : isMl ? 'Mintlayer' : ticker + const chain = isBtc ? 'Bitcoin' : 'Mintlayer' + + // Coin balances come formatted as strings; token balances are numbers. + const amount = isBtc + ? Number(btcInfo.balance?.replace?.(/,/g, '') || 0) + : Number(mlInfo.balance ?? 0) + const price = isBtc ? btcRate : isMl ? mlRate : undefined + const fiat = price != null ? amount * price : undefined + const spark = isBtc + ? Object.values(btcHist || {}) + : isMl + ? Object.values(mlHist || {}) + : [] + const change24h = + spark.length > 1 && spark[0] + ? ((spark[spark.length - 1] - spark[0]) / spark[0]) * 100 + : null + + const realTxs = (isBtc ? btcInfo.transactions : mlInfo.transactions) || [] + const txRows = realTxs + .slice(0, 12) + .map((tx) => adaptDesignTx(tx, ticker, chain)) + + const sendTarget = isBtc + ? '/wallet/Bitcoin/send-btc-transaction' + : '/wallet/Mintlayer/send-ml-transaction' + const receiveAddress = isBtc + ? BTC.getBtcAddressString( + addresses?.btcAddresses?.btcReceivingAddresses?.[0], + ) || btcUnused?.receivingAddress + : addresses?.mlAddresses?.mlReceivingAddresses?.[0] || + mlInfo.unusedAddresses?.receive + + return ( + +
    +
    + +
    {name}
    +
    + {amount.toLocaleString(undefined, { maximumFractionDigits: 8 })} +
    +
    {ticker}
    +
    + {fiat != null && ( + + {fiat.toLocaleString(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}{' '} + $ + + )} + {change24h != null && } +
    +
    + +
    +
    + + {spark.length > 1 && ( +
    + +
    + )} + +
    + {isReal && ( + + )} + +
    + + {receiveAddress && ( + + )} + + {tokenData && ( +
    + Token info +
    + +
    + )} + +
    +
    Activity
    +
    + {txRows.length ? ( + txRows.map((t, i) => ( + + )) + ) : ( +
    No {ticker} transactions
    + )} +
    +
    +
    + + ) +} + +export default AssetPage diff --git a/src/pages/AssetPage/AssetPage.test.js b/src/pages/AssetPage/AssetPage.test.js new file mode 100644 index 00000000..d6187ea1 --- /dev/null +++ b/src/pages/AssetPage/AssetPage.test.js @@ -0,0 +1,154 @@ +import React from 'react' +import { MemoryRouter, Routes, Route } from 'react-router' +import { render } from '@testing-library/react' + +const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}) + +const TOKEN_ID = 'tmltk1q2c7d9a4hm3' + +jest.mock('@Hooks', () => { + const mockTokenBalances = { + tmltk1q2c7d9a4hm3: { + balance: 10, + token_info: { + token_id: 'tmltk1q2c7d9a4hm3', + token_ticker: { string: 'CBEAT', hex: '0x4342454154' }, + number_of_decimals: 2, + }, + }, + } + return { + __esModule: true, + useExchangeRates: () => ({ exchangeRate: 100 }), + useOneDayAgoHist: () => ({ historyRates: { 0: 100, 1: 101 } }), + useBtcWalletInfo: () => ({ + balance: '0.5', + transactions: [ + { direction: 'in', date: 1700000000, value: 1000, txid: 'h1' }, + ], + }), + useMlWalletInfo: (_addresses, token) => { + const nativecoins = ['Mintlayer', 'Bitcoin'] + if (token && !nativecoins.includes(token)) { + return { + balance: 10, + transactions: [ + { + direction: 'out', + date: 1700000002, + value: 2, + txid: 'h4', + token_id: token, + }, + ], + tokenBalances: mockTokenBalances, + unusedAddresses: { receive: 'mtc1qtest' }, + } + } + return { + balance: '120', + transactions: [ + { direction: 'out', date: 1700000000, value: 5, txid: 'h2' }, + { direction: 'in', date: 1700000001, value: 1, txid: 'h3' }, + ], + tokenBalances: mockTokenBalances, + } + }, + } +}) + +jest.mock('@Contexts', () => { + const React = require('react') + const makeCtx = (value) => React.createContext(value) + return { + __esModule: true, + AccountContext: makeCtx({ + addresses: { + btcAddresses: { + btcReceivingAddresses: ['bc1qtest'], + btcChangeAddresses: [], + }, + mlAddresses: { + mlReceivingAddresses: ['mtc1qtest'], + mlChangeAddresses: [], + }, + }, + }), + SettingsContext: makeCtx({ networkType: 'mainnet' }), + BitcoinContext: makeCtx({ + unusedAddresses: { receivingAddress: 'bc1qtest' }, + }), + MintlayerContext: makeCtx({ + unusedAddresses: { receive: 'mtc1qtest' }, + tokenBalances: { + tmltk1q2c7d9a4hm3: { + balance: 10, + token_info: { + token_id: 'tmltk1q2c7d9a4hm3', + token_ticker: { string: 'CBEAT', hex: '0x4342454154' }, + number_of_decimals: 2, + icon_uri: { + hex: '0x68747470733a2f2f65782e636f6d2f69636f6e2e706e67', + string: 'https://example.com/icon.png', + }, + }, + }, + }, + }), + } +}) + +const renderAt = (id) => + render( + + + } + /> + + , + ) + +// Required after the jest.mock hoisting block. +// eslint-disable-next-line import/first +const AssetPage = require('./AssetPage').default + +describe('AssetPage', () => { + afterAll(() => { + errorSpy.mockRestore() + }) + + it.each(['Mintlayer', 'Bitcoin'])( + 'renders the %s asset screen without crashing', + (id) => { + const { container } = renderAt(id) + expect(container).not.toBeEmptyDOMElement() + }, + ) + + it('renders a real Mintlayer token from tokenBalances', () => { + const { container } = renderAt(TOKEN_ID) + + expect(container.textContent).toContain('CBEAT') + expect(container.textContent).toContain(TOKEN_ID) + expect(container.textContent).toContain('Decimals') + expect(container.textContent).toContain('Token info') + // Balance comes from the token-scoped hook (10 CBEAT), not the ML coin balance. + expect(container.textContent).toContain('10') + // No fake price data for tokens. + expect(container.textContent).not.toContain('$') + }) + + it('renders the token metadata icon', () => { + const { getByTestId } = renderAt(TOKEN_ID) + + const img = getByTestId('token-icon-image') + expect(img).toHaveAttribute('src', 'https://example.com/icon.png') + }) + + it('renders an unknown token id without crashing', () => { + const { container } = renderAt('tmltkdoesnotexist') + expect(container).not.toBeEmptyDOMElement() + }) +}) diff --git a/src/pages/Dashboard/Dashboard.js b/src/pages/Dashboard/Dashboard.js index f75f5d63..a81560cc 100644 --- a/src/pages/Dashboard/Dashboard.js +++ b/src/pages/Dashboard/Dashboard.js @@ -1,8 +1,10 @@ /* eslint-disable max-params */ import { useContext, useState, useEffect } from 'react' -import { PopUp, AddWallet } from '@ComposedComponents' +import { useNavigate } from 'react-router' + +import { PopUp, AddWallet, TxRow, AssetRow } from '@ComposedComponents' import { AccountContext, SettingsContext } from '@Contexts' -import { Account } from '@Entities' +import { Account as AccountEntity } from '@Entities' import { useExchangeRates, @@ -10,16 +12,24 @@ import { useMlWalletInfo, useOneDayAgoExchangeRates, } from '@Hooks' -import { Dashboard } from '@ContainerComponents' -import { NumbersHelper, ObjectHelpers } from '@Helpers' - -import { PageWrapper } from '@BasicComponents' -import './Dashboard.css' import useOneDayAgoHist from 'src/hooks/UseOneDayAgoHist/useOneDayAgoHist' -import { useNavigate } from 'react-router' -import { BTC } from '@Helpers' +import { NumbersHelper, ObjectHelpers, BTC, Transactions } from '@Helpers' + +import { + PageWrapper, + Avatar, + Counter, + Eyebrow, + Icon, + LivePill, + Seg, +} from '@BasicComponents' +const { adaptDesignTx } = Transactions + import { AppInfo } from '@Constants' +import styles from './Dashboard.module.css' + const DashboardPage = () => { const { addresses, accountName, accountID } = useContext(AccountContext) const { networkType } = useContext(SettingsContext) @@ -27,18 +37,22 @@ const DashboardPage = () => { const [openConnectConfirmation, setOpenConnectConfirmation] = useState(false) const [allowClosing, setAllowClosing] = useState(true) const [account, setAccount] = useState(null) + const [hideBalance, setHideBalance] = useState(false) + const [tab, setTab] = useState('Tokens') const [connectedWalletType, setConnectedWalletType] = useState('') const { balance: btcBalance, fetchingBalances: btcFetchingBalances, btcApiAvailable, + transactions: btcTransactions, } = useBtcWalletInfo() const { balance: mlBalance, tokenBalances, fetchingBalances: mlFetchingBalances, fetchingTokens: mlFetchingTokens, + transactions: mlTransactions, } = useMlWalletInfo() const { exchangeRate: btcExchangeRate } = useExchangeRates('btc', 'usd') const { exchangeRate: mlExchangeRate } = useExchangeRates('ml', 'usd') @@ -74,6 +88,7 @@ const DashboardPage = () => { BTC.calculateBalances(cryptos, yesterdayExchangeRateList) const stats = BTC.getStats(proportionDiffs, balanceDiffs, networkType) + const stat = (name) => stats.find((s) => s.name === name)?.value ?? 0 const getCryptoList = (addresses, network, tokenBalances) => { if (!addresses) return [] @@ -123,11 +138,12 @@ const DashboardPage = () => { } const btcAddress = addresses.btcAddresses - ? addresses.btcAddresses.btcReceivingAddresses[0] + ? BTC.getBtcAddressString(addresses.btcAddresses.btcReceivingAddresses[0]) : false if (btcAddress) { + // 24h change is only meaningful when yesterday's rate resolved. const change24h = - network === AppInfo.NETWORK_TYPES.MAINNET + network === AppInfo.NETWORK_TYPES.MAINNET && proportionDiffs.btc != null ? Number((proportionDiffs.btc - 1) * 100).toFixed(2) : 0 addCrypto( @@ -154,7 +170,7 @@ const DashboardPage = () => { if (mlAddress) { const change24h = - network === AppInfo.NETWORK_TYPES.MAINNET + network === AppInfo.NETWORK_TYPES.MAINNET && proportionDiffs.ml != null ? Number((proportionDiffs.ml - 1) * 100).toFixed(2) : 0 addCrypto( @@ -188,9 +204,29 @@ const DashboardPage = () => { return cryptos } - const goToWallet = (walletType) => { - navigate('/wallet/' + walletType.id) - } + const cryptoList = getCryptoList(addresses, networkType, tokenBalances) + const coins = cryptoList.filter((c) => c.type === 'coin') + const realTokens = cryptoList.filter( + (c) => c.type === 'token' && !c.isPlaceholder, + ) + const missingWalletTypes = AppInfo.walletTypes.filter( + (walletType) => + !cryptoList.find((crypto) => crypto.name === walletType.name), + ) + + const coinAssets = coins + .filter((c) => !c.isPlaceholder) + .map((c) => ({ + id: c.id, + name: c.name, + symbol: c.symbol, + chain: c.network === 'bitcoin' ? 'Bitcoin' : 'Mintlayer', + amount: c.balance || 0, + price: c.exchangeRate, + change24h: Number(c.change24h) || 0, + spark: Object.values(c.historyRates || {}), + disabled: c.disabled, + })) const onConnectItemClick = (walletType) => { setConnectedWalletType(walletType) @@ -199,7 +235,7 @@ const DashboardPage = () => { } const getCurrentAccount = async (accountID) => { - const currentAccount = await Account.getAccount(accountID) + const currentAccount = await AccountEntity.getAccount(accountID) return currentAccount } @@ -207,37 +243,246 @@ const DashboardPage = () => { getCurrentAccount(accountID).then((account) => setAccount(account)) }, [accountID]) + // Recent activity: real transactions first, a demo row as fallback. + const adaptTx = (tx, sym, chain) => adaptDesignTx(tx, sym, chain) + + const recentTxs = [ + ...(btcTransactions || []).map((t) => adaptTx(t, 'BTC', 'Bitcoin')), + ...(mlTransactions || []).map((t) => adaptTx(t, 'ML', 'Mintlayer')), + ].slice(0, 3) + + const isTestnet = networkType === AppInfo.NETWORK_TYPES.TESTNET + + const renderAssetRow = (a, index) => ( +
    !a.disabled && navigate('/asset/' + a.id)} + data-testid="crypto-item" + > +
    + +
    +
    + ) + return ( - -
    - - + +
    + {/* Top bar: account + network + settings (design AppTop) */} +
    +
    navigate('/settings')} + > + +
    {accountName}
    + +
    + navigate('/settings')} + > + + {isTestnet ? 'Testnet' : 'Mainnet'} + +
    navigate('/settings')} + > + +
    +
    + +
    + {/* Total balance card */} +
    +
    setHideBalance(!hideBalance)} + > + Total balance + +
    +
    + {hideBalance ? ( + '••••••' + ) : ( + <> + $ + + + )} +
    +
    + + + {hideBalance + ? '••••' + : `${Number(stat('24h fiat')) >= 0 ? '+' : '−'}$${Math.abs( + Number(stat('24h fiat')), + ).toFixed(2)}`}{' '} + · 24h + +
    +
    + + {/* Quick actions */} +
    + + + + +
    + + {/* Assets */} +
    +
    + Assets + +
    + {tab === 'Tokens' ? ( +
    + {coinAssets.map((a, i) => renderAssetRow(a, i))} + + {realTokens.map((c, i) => + renderAssetRow( + { + id: c.id, + name: c.name, + symbol: c.symbol, + chain: 'Mintlayer', + amount: c.balance || 0, + spark: [], + iconUri: + tokenBalances[c.id]?.token_info?.icon_uri?.string, + }, + coinAssets.length + i, + ), + )} + + {missingWalletTypes.map((walletType) => ( +
    onConnectItemClick(walletType)} + data-testid="connect-item" + > + + Add {walletType.name} wallet +
    + ))} +
    + ) : ( +
    + {/* Real NFT rendering is a pending follow-up (see + REVIEW-PLAN.md): the provider exposes `nftData`. */} +
    + No NFTs yet — NFTs owned by this wallet will show here. +
    +
    + )} +
    + + {/* Recent activity */} +
    +
    + Recent activity + navigate('/activity')} + > + See all → + +
    +
    + {recentTxs.length ? ( + recentTxs.map((t, i) => ( + + )) + ) : ( +
    + No activity yet — transactions will show here. +
    + )} +
    +
    +
    + + {openConnectConfirmation && ( + + + + )}
    - - {openConnectConfirmation && ( - - - - )}
    ) } From a60de9523b986b81cc298a57b74eadc7eddb52b4 Mon Sep 17 00:00:00 2001 From: Enrico Rubboli Date: Tue, 8 Sep 2026 10:44:05 +0200 Subject: [PATCH 15/52] fix(tokens): resolve token icons from the metadata document (tokenIcon) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real data: Mintlayer tokens have NO on-chain icon_uri — GET /token/:id only carries metadata_uri (ipfs link), and the icon lives in that document under 'tokenIcon' (mlUSDC: metadata_uri -> ipfs JSON -> tokenIcon -> ipfs image). The previous icon_uri wiring could therefore never populate. - new Mintlayer.resolveTokenIcon(metadata_uri): fetches the metadata JSON (ipfs.io gateway, 8s timeout, cached per uri so the 2-min refresh does not re-fetch) and returns the gateway-mapped icon url; failures resolve undefined -> fallback tile - provider resolves icons in parallel during token enrichment and stores token_info.icon_uri { string } (shape unchanged for the UI) - ipfs gateway switched to https://ipfs.io/ipfs everywhere (gateway.ipfs.io returned empty responses — NFT images were broken with it too) --- .../basic/TokenIcon/TokenIcon.test.tsx | 4 +- src/components/basic/TokenIcon/TokenIcon.tsx | 2 +- src/components/containers/Wallet/Nft/Nft.js | 2 +- .../containers/Wallet/Nft/NftDetails.js | 2 +- .../containers/Wallet/Nft/NftDetails.test.js | 2 +- .../MintlayerProvider/MintlayerProvider.js | 46 +++++--- src/services/API/Mintlayer/Mintlayer.js | 111 +++++++++++------- src/services/API/Mintlayer/Mintlayer.test.js | 48 ++++++++ 8 files changed, 151 insertions(+), 66 deletions(-) diff --git a/src/components/basic/TokenIcon/TokenIcon.test.tsx b/src/components/basic/TokenIcon/TokenIcon.test.tsx index 6c1ac4e6..cec47360 100644 --- a/src/components/basic/TokenIcon/TokenIcon.test.tsx +++ b/src/components/basic/TokenIcon/TokenIcon.test.tsx @@ -38,7 +38,7 @@ test('renders the token metadata icon when an iconUri is provided', () => { expect(img.src).toBe('https://example.com/mlusdc.png') }) -test('maps ipfs:// icon uris to a public gateway', () => { +test('maps ipfs:// icon uris to the ipfs.io gateway', () => { const { getByTestId } = render( { />, ) const img = getByTestId('token-icon-image') as HTMLImageElement - expect(img.src).toBe('https://gateway.ipfs.io/ipfs/bafyabc/icon.png') + expect(img.src).toBe('https://ipfs.io/ipfs/bafyabc/icon.png') }) test('falls back to the procedural tile when the icon fails to load', async () => { diff --git a/src/components/basic/TokenIcon/TokenIcon.tsx b/src/components/basic/TokenIcon/TokenIcon.tsx index 3b50bed4..962aee28 100644 --- a/src/components/basic/TokenIcon/TokenIcon.tsx +++ b/src/components/basic/TokenIcon/TokenIcon.tsx @@ -29,7 +29,7 @@ const LOGOS: Record = { const toRenderableUri = (uri: string) => uri.startsWith('ipfs://') - ? uri.replace('ipfs://', 'https://gateway.ipfs.io/ipfs/') + ? uri.replace('ipfs://', 'https://ipfs.io/ipfs/') : uri // Native BTC/ML assets get the real chain logos; tokens show their metadata diff --git a/src/components/containers/Wallet/Nft/Nft.js b/src/components/containers/Wallet/Nft/Nft.js index 09100d47..f207eb82 100644 --- a/src/components/containers/Wallet/Nft/Nft.js +++ b/src/components/containers/Wallet/Nft/Nft.js @@ -12,7 +12,7 @@ const NftItem = ({ nft }) => { const getImageLink = () => { const rawImageLink = nft?.data?.icon_uri?.string || 'NFT' if (rawImageLink.startsWith('ipfs://')) { - return rawImageLink.replace('ipfs://', 'https://gateway.ipfs.io/ipfs/') + return rawImageLink.replace('ipfs://', 'https://ipfs.io/ipfs/') } return rawImageLink } diff --git a/src/components/containers/Wallet/Nft/NftDetails.js b/src/components/containers/Wallet/Nft/NftDetails.js index c7ca7d9b..f08fe206 100644 --- a/src/components/containers/Wallet/Nft/NftDetails.js +++ b/src/components/containers/Wallet/Nft/NftDetails.js @@ -40,7 +40,7 @@ const NftDetails = ({ nft, handleSend }) => { const rawImageLink = nft?.data?.icon_uri?.string || 'NFT' // Replace 'ipfs://' with a public IPFS gateway URL if (rawImageLink.startsWith('ipfs://')) { - return rawImageLink.replace('ipfs://', 'https://gateway.ipfs.io/ipfs/') + return rawImageLink.replace('ipfs://', 'https://ipfs.io/ipfs/') } return rawImageLink } diff --git a/src/components/containers/Wallet/Nft/NftDetails.test.js b/src/components/containers/Wallet/Nft/NftDetails.test.js index 75f757b7..b2402fe4 100644 --- a/src/components/containers/Wallet/Nft/NftDetails.test.js +++ b/src/components/containers/Wallet/Nft/NftDetails.test.js @@ -37,7 +37,7 @@ describe('NftDetails', () => { expect(screen.getByTestId('nft-details')).toBeInTheDocument() expect(screen.getByRole('img')).toHaveAttribute( 'src', - 'https://gateway.ipfs.io/ipfs/test-icon', + 'https://ipfs.io/ipfs/test-icon', ) expect(screen.getAllByTestId('nft-details-item')).toHaveLength(5) diff --git a/src/contexts/MintlayerProvider/MintlayerProvider.js b/src/contexts/MintlayerProvider/MintlayerProvider.js index 3de14a72..f8ee0734 100644 --- a/src/contexts/MintlayerProvider/MintlayerProvider.js +++ b/src/contexts/MintlayerProvider/MintlayerProvider.js @@ -352,22 +352,38 @@ const MintlayerProvider = ({ value: propValue, children }) => { Object.keys(tokenBalances), ) - const mergedTokensData = Object.keys(tokenBalances).reduce((acc, key) => { - if (tokensData[key] && Object.keys(tokensData[key]).length > 0) { - acc[key] = { - balance: tokenBalances[key].toNumber(), - token_info: { - number_of_decimals: tokensData[key].number_of_decimals, - token_ticker: tokensData[key].token_ticker, - token_id: key, - // { hex, string } token metadata — string is the icon URL - // (https or ipfs) shown next to the token everywhere. - icon_uri: tokensData[key].icon_uri, - }, + const mergedTokensDataEntries = await Promise.all( + Object.keys(tokenBalances).map(async (key) => { + if (!(tokensData[key] && Object.keys(tokensData[key]).length > 0)) { + return null } - } - return acc - }, {}) + + // Tokens have no on-chain icon: resolve it from the metadata + // document (cached, non-fatal — no icon means the fallback tile). + const iconUri = await Mintlayer.resolveTokenIcon( + tokensData[key].metadata_uri?.string, + ).catch(() => undefined) + + return [ + key, + { + balance: tokenBalances[key].toNumber(), + token_info: { + number_of_decimals: tokensData[key].number_of_decimals, + token_ticker: tokensData[key].token_ticker, + token_id: key, + // { string } keeps the same shape the UI already reads + // (token_info.icon_uri.string). + ...(iconUri ? { icon_uri: { string: iconUri } } : {}), + }, + }, + ] + }), + ) + + const mergedTokensData = Object.fromEntries( + mergedTokensDataEntries.filter(Boolean), + ) const newTokenMap = {} diff --git a/src/services/API/Mintlayer/Mintlayer.js b/src/services/API/Mintlayer/Mintlayer.js index 0e72a02c..e60fa74f 100644 --- a/src/services/API/Mintlayer/Mintlayer.js +++ b/src/services/API/Mintlayer/Mintlayer.js @@ -10,11 +10,12 @@ const MINTLAYER_ENDPOINTS = { POST_TRANSACTION: '/transaction', GET_FEES_ESTIMATES: '/feerate', GET_ADDRESS_DELEGATIONS: '/address/:address/delegations', - GET_DELEGATION: '/delegation/:delegation', + GET_DELEGATION: '/delegation/:address', GET_CHAIN_TIP: '/chain/tip', - GET_BLOCK_HASH: '/chain/:height', - GET_BLOCK_DATA: '/block/:hash', - GET_POOL_DATA: '/pool/:hash', + GET_BLOCK_HASH: '/chain/:address', + GET_BLOCK_DATA: '/block/:address', + GET_POOL_DATA: '/pool/:address', + GET_NFT: '/nft/:tokenId', GET_ORDER_DATA: '/order/:hash', GET_ORDERS_LIST: '/order', GET_TOKEN: '/token/:tokenId', @@ -23,6 +24,15 @@ const MINTLAYER_ENDPOINTS = { const abortControllers = new Map() +// Server fallback chain for the active network. Deliberately does NOT honor +// any localStorage 'customAPIServers' override: an unvalidated entry would +// silently redirect every request — including transaction broadcasts — to an +// arbitrary endpoint serving spoofed UTXOs/fees. +const getMintlayerServers = (networkType) => + networkType === AppInfo.NETWORK_TYPES.TESTNET + ? EnvVars.TESTNET_MINTLAYER_SERVERS + : EnvVars.MAINNET_MINTLAYER_SERVERS + const requestMintlayer = async (url, body = null, request = fetch) => { const method = body ? 'POST' : 'GET' const controller = new AbortController() @@ -85,23 +95,7 @@ export const batchRequestMintlayer = async ({ ids, type }) => { } const networkType = LocalStorageService.getItem('networkType') - const customMintlayerServerList = LocalStorageService.getItem( - AppInfo.APP_LOCAL_STORAGE_CUSTOM_SERVERS, - ) - const customMintlayerServer = customMintlayerServerList - ? networkType === AppInfo.NETWORK_TYPES.TESTNET - ? customMintlayerServerList.mintlayer_testnet - : customMintlayerServerList.mintlayer_mainnet - : null - - const defaultMintlayerServers = - networkType === AppInfo.NETWORK_TYPES.TESTNET - ? EnvVars.TESTNET_MINTLAYER_SERVERS - : EnvVars.MAINNET_MINTLAYER_SERVERS - - const combinedMintlayerServers = customMintlayerServer - ? [customMintlayerServer, ...defaultMintlayerServers] - : [...defaultMintlayerServers] + const combinedMintlayerServers = getMintlayerServers(networkType) const res = await fetch(combinedMintlayerServers[0] + '/batch', { method: 'POST', @@ -126,23 +120,7 @@ export const batchRequestMintlayer = async ({ ids, type }) => { const tryServers = async (endpoint, body = null, forceNetwork) => { const networkType = forceNetwork || LocalStorageService.getItem('networkType') - const customMintlayerServerList = LocalStorageService.getItem( - AppInfo.APP_LOCAL_STORAGE_CUSTOM_SERVERS, - ) - const customMintlayerServer = customMintlayerServerList - ? networkType === AppInfo.NETWORK_TYPES.TESTNET - ? customMintlayerServerList.mintlayer_testnet - : customMintlayerServerList.mintlayer_mainnet - : null - - const defaultMintlayerServers = - networkType === AppInfo.NETWORK_TYPES.TESTNET - ? EnvVars.TESTNET_MINTLAYER_SERVERS - : EnvVars.MAINNET_MINTLAYER_SERVERS - - const combinedMintlayerServers = customMintlayerServer - ? [customMintlayerServer, ...defaultMintlayerServers] - : [...defaultMintlayerServers] + const combinedMintlayerServers = getMintlayerServers(networkType) for (let i = 0; i < combinedMintlayerServers.length; i++) { try { @@ -350,33 +328,75 @@ const getNftsData = async (tokens) => { return { tokensData, excludedTokenIds } } +// Mintlayer tokens carry no on-chain icon: the token points at a metadata +// document (metadata_uri, usually ipfs://) whose JSON holds the icon under +// `tokenIcon` (also tolerate `icon_uri`/`icon`). The icon itself is often an +// ipfs:// uri again. +const IPFS_GATEWAY = 'https://ipfs.io/ipfs' + +const fromIpfs = (uri) => + uri.startsWith('ipfs://') ? uri.replace('ipfs://', `${IPFS_GATEWAY}/`) : uri + +const tokenIconCache = new Map() // metadata uri -> icon url | null + +const resolveTokenIcon = async (metadataUri) => { + if (!metadataUri) return undefined + if (tokenIconCache.has(metadataUri)) { + return tokenIconCache.get(metadataUri) ?? undefined + } + + let iconUrl + try { + // Timeout: this runs inside the wallet data refresh and ipfs gateways + // can hang — never block the whole refresh on an icon. + const response = await fetch(fromIpfs(metadataUri), { + signal: AbortSignal.timeout(8000), + }) + if (response.ok) { + const metadata = await response.json() + const raw = metadata.tokenIcon || metadata.icon_uri || metadata.icon + if (raw && typeof raw === 'string') { + iconUrl = fromIpfs(raw) + } + } + } catch (error) { + console.error( + `Failed to resolve token icon from ${metadataUri}:`, + error.message, + ) + } + + tokenIconCache.set(metadataUri, iconUrl ?? null) + return iconUrl +} + const getAddressDelegations = (address) => tryServers( MINTLAYER_ENDPOINTS.GET_ADDRESS_DELEGATIONS.replace(':address', address), ) const getDelegation = (delegation) => - tryServers( - MINTLAYER_ENDPOINTS.GET_DELEGATION.replace(':delegation', delegation), - ) + tryServers(MINTLAYER_ENDPOINTS.GET_DELEGATION.replace(':address', delegation)) const getPool = (pool) => - tryServers(MINTLAYER_ENDPOINTS.GET_POOL_DATA.replace(':hash', pool)) + tryServers(MINTLAYER_ENDPOINTS.GET_POOL_DATA.replace(':address', pool)) const getBlockDataByHeight = (height) => { return tryServers( - MINTLAYER_ENDPOINTS.GET_BLOCK_HASH.replace(':height', height), + MINTLAYER_ENDPOINTS.GET_BLOCK_HASH.replace(':address', height), ) .then(JSON.parse) .then((response) => { return tryServers( - MINTLAYER_ENDPOINTS.GET_BLOCK_DATA.replace(':hash', response), + MINTLAYER_ENDPOINTS.GET_BLOCK_DATA.replace(':address', response), ) }) } const getBlockDataByHash = (hash) => { - return tryServers(MINTLAYER_ENDPOINTS.GET_BLOCK_DATA.replace(':hash', hash)) + return tryServers( + MINTLAYER_ENDPOINTS.GET_BLOCK_DATA.replace(':address', hash), + ) } const getWalletDelegations = (addresses) => { @@ -484,6 +504,7 @@ export { getBlockDataByHash, getTokenById, getTokensData, + resolveTokenIcon, getPoolsData, getNftsData, getOrderById, diff --git a/src/services/API/Mintlayer/Mintlayer.test.js b/src/services/API/Mintlayer/Mintlayer.test.js index e8f8c557..421d73ec 100644 --- a/src/services/API/Mintlayer/Mintlayer.test.js +++ b/src/services/API/Mintlayer/Mintlayer.test.js @@ -108,3 +108,51 @@ test('Mintlayer API request - not ok', async () => { // const result = await getAddressBalance(TESTNET_WALLET) // expect(Number(result.balance.balanceInAtoms)).toBeGreaterThan(0) // }) + +describe('resolveTokenIcon', () => { + const { resolveTokenIcon } = require('./Mintlayer.js') + + const okJson = (body) => ({ + ok: true, + json: async () => body, + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + it('resolves tokenIcon from the metadata document and maps ipfs uris', async () => { + jest.spyOn(global, 'fetch').mockResolvedValue( + okJson({ + tokenIcon: 'ipfs://bafyicon/logo.png', + }), + ) + + await expect( + resolveTokenIcon('ipfs://bafymetadata/doc.json'), + ).resolves.toBe('https://ipfs.io/ipfs/bafyicon/logo.png') + }) + + it('is cached per metadata uri', async () => { + const fetchSpy = jest + .spyOn(global, 'fetch') + .mockResolvedValue(okJson({ tokenIcon: 'https://x.example/i.png' })) + + await resolveTokenIcon('https://example.test/meta1.json') + await resolveTokenIcon('https://example.test/meta1.json') + + expect(fetchSpy).toHaveBeenCalledTimes(1) + }) + + it('returns undefined when the document has no icon field or fetch fails', async () => { + jest.spyOn(global, 'fetch').mockResolvedValue(okJson({ name: 'no icon' })) + await expect( + resolveTokenIcon('ipfs://bafymetadata/noicon.json'), + ).resolves.toBeUndefined() + + jest.spyOn(global, 'fetch').mockRejectedValue(new Error('offline')) + await expect( + resolveTokenIcon('ipfs://bafymetadata/fail.json'), + ).resolves.toBeUndefined() + }) +}) From 12a5bf50fa283ded404c249726d44ba690608578 Mon Sep 17 00:00:00 2001 From: Enrico Rubboli Date: Tue, 8 Sep 2026 11:08:52 +0200 Subject: [PATCH 16/52] fix(core): money-math correctness and amount validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review remediation (P1) — amounts were computed with float/string math on an 11-decimal currency: - getParsedTransactions accumulated decimal STRINGS ('1.5'+'0.7' -> '1.50.7') and clobbered the accumulator per branch; now numeric accumulation - getAmountInCoins/getAmountInAtoms/atomsToDecimal use Decimal instead of float multiplication on the atom scale - BTC.calculateBalances: missing yesterday rates now yield null (rendered as 'no data') instead of division-by-fallback producing absurd 24h percentages - amount regex escaped (/^\d+(\.\d+)?$/) so '1e3'/'1x5' no longer validate - buildStakeGrowthSeries helper: cumulative staked-over-time series from DelegateStaking/Delegate Withdrawal transactions (used by the staking page) - getUnconfirmedTransactionKey helper replaces eight hand-built localStorage keys --- src/utils/Helpers/BTC/BTC.js | 115 ++++++++++++------ src/utils/Helpers/BTC/BTC.test.js | 11 ++ src/utils/Helpers/ML/MLTransaction.js | 36 ++++-- src/utils/Helpers/Number/Format.js | 4 +- .../Helpers/Transactions/Transactions.js | 56 +++++++++ src/utils/Helpers/index.js | 2 + 6 files changed, 178 insertions(+), 46 deletions(-) create mode 100644 src/utils/Helpers/Transactions/Transactions.js diff --git a/src/utils/Helpers/BTC/BTC.js b/src/utils/Helpers/BTC/BTC.js index fd2617d0..56a233e4 100644 --- a/src/utils/Helpers/BTC/BTC.js +++ b/src/utils/Helpers/BTC/BTC.js @@ -117,21 +117,36 @@ const getYesterdayFiatBalances = (cryptos, yesterdayExchangeRateList) => { const btcCrypto = cryptos.find((crypto) => crypto.symbol === 'BTC') const mlCrypto = cryptos.find((crypto) => crypto.symbol === 'ML') - const btcYesterdayBalance = btcCrypto - ? new Decimal(btcCrypto.balance || 0) - .times(new Decimal(yesterdayExchangeRateList.btc || 0)) - .toNumber() - : 0 - const mlYesterdayBalance = mlCrypto - ? new Decimal(mlCrypto.balance || 0) - .times(new Decimal(yesterdayExchangeRateList.ml || 0)) - .toNumber() - : 0 + // A missing/zero yesterday rate must be distinguishable from a real 0 + // balance, otherwise 24h diffs explode (current/0-fallback). + const btcRateMissing = + !btcCrypto || !(Number(yesterdayExchangeRateList?.btc) > 0) + const mlRateMissing = + !mlCrypto || !(Number(yesterdayExchangeRateList?.ml) > 0) + + const btcYesterdayBalance = + btcCrypto && !btcRateMissing + ? new Decimal(btcCrypto.balance || 0) + .times(new Decimal(yesterdayExchangeRateList.btc)) + .toNumber() + : 0 + const mlYesterdayBalance = + mlCrypto && !mlRateMissing + ? new Decimal(mlCrypto.balance || 0) + .times(new Decimal(yesterdayExchangeRateList.ml)) + .toNumber() + : 0 const totalYesterdayBalance = new Decimal(btcYesterdayBalance) .plus(new Decimal(mlYesterdayBalance)) .toNumber() - return { btcYesterdayBalance, mlYesterdayBalance, totalYesterdayBalance } + return { + btcYesterdayBalance, + mlYesterdayBalance, + totalYesterdayBalance, + btcRateMissing, + mlRateMissing, + } } const getCurrentFiatBalances = (cryptos) => { @@ -164,8 +179,13 @@ const getCurrentFiatBalances = (cryptos) => { } const calculateBalances = (cryptos, yesterdayExchangeRates) => { - const { btcYesterdayBalance, mlYesterdayBalance, totalYesterdayBalance } = - getYesterdayFiatBalances(cryptos, yesterdayExchangeRates) + const { + btcYesterdayBalance, + mlYesterdayBalance, + totalYesterdayBalance, + btcRateMissing, + mlRateMissing, + } = getYesterdayFiatBalances(cryptos, yesterdayExchangeRates) const { btcCurrentBalance, mlCurrentBalance, totalCurrentBalance } = getCurrentFiatBalances(cryptos) @@ -182,28 +202,44 @@ const calculateBalances = (cryptos, yesterdayExchangeRates) => { total: totalYesterdayBalance, } + // null = "not computable" (yesterday rate unavailable) — consumers must + // treat null as "show nothing", never as 0. const proportionDiffs = { - btc: new Decimal(currentBalances.btc || 0) - .div(new Decimal(yesterdayBalances.btc || 1)) - .toNumber(), - ml: new Decimal(currentBalances.ml || 0) - .div(new Decimal(yesterdayBalances.ml || 1)) - .toNumber(), - total: new Decimal(currentBalances.total || 0) - .div(new Decimal(yesterdayBalances.total || 1)) - .toNumber(), + btc: btcRateMissing + ? null + : new Decimal(currentBalances.btc || 0) + .div(new Decimal(yesterdayBalances.btc || 1)) + .toNumber(), + ml: mlRateMissing + ? null + : new Decimal(currentBalances.ml || 0) + .div(new Decimal(yesterdayBalances.ml || 1)) + .toNumber(), + total: + btcRateMissing || mlRateMissing + ? null + : new Decimal(currentBalances.total || 0) + .div(new Decimal(yesterdayBalances.total || 1)) + .toNumber(), } const balanceDiffs = { - btc: new Decimal(currentBalances.btc || 0) - .minus(new Decimal(btcYesterdayBalance || 0)) - .toNumber(), - ml: new Decimal(currentBalances.ml || 0) - .minus(new Decimal(mlYesterdayBalance || 0)) - .toNumber(), - total: new Decimal(currentBalances.total || 0) - .minus(new Decimal(yesterdayBalances.total || 0)) - .toNumber(), + btc: btcRateMissing + ? null + : new Decimal(currentBalances.btc || 0) + .minus(new Decimal(btcYesterdayBalance || 0)) + .toNumber(), + ml: mlRateMissing + ? null + : new Decimal(currentBalances.ml || 0) + .minus(new Decimal(mlYesterdayBalance || 0)) + .toNumber(), + total: + btcRateMissing || mlRateMissing + ? null + : new Decimal(currentBalances.total || 0) + .minus(new Decimal(yesterdayBalances.total || 0)) + .toNumber(), } return { currentBalances, yesterdayBalances, proportionDiffs, balanceDiffs } @@ -211,14 +247,15 @@ const calculateBalances = (cryptos, yesterdayExchangeRates) => { const getStats = (proportionDiffs, balanceDiffs, networkType) => { const isTestnet = networkType === AppInfo.NETWORK_TYPES.TESTNET - const hasBalance = proportionDiffs.total !== 0 + const hasBalance = proportionDiffs.total != null const percentValue = isTestnet || !hasBalance ? 0 : new Decimal(proportionDiffs.total || 0).minus(1).times(100).toFixed(2) - const fiatValue = isTestnet - ? 0 - : new Decimal(balanceDiffs.total || 0).toFixed(2) + const fiatValue = + isTestnet || balanceDiffs.total == null + ? 0 + : new Decimal(balanceDiffs.total || 0).toFixed(2) return [ { name: '24h percent', @@ -310,6 +347,13 @@ const getBtcAddresses = (addresses) => { return { btcChangeAddresses, btcReceivingAddresses } } +// Stored BTC address entries are plain strings (old store blobs) or +// { [address]: { pubkey } } objects (new store). Returns the address string. +const getBtcAddressString = (entry) => { + if (typeof entry === 'string') return entry + return entry ? Object.keys(entry)[0] : undefined +} + const getBatchData = async (ids, networkRequest) => { const uniqueIds = [...new Set(ids)] @@ -346,6 +390,7 @@ export { getBtcAddressLink, getBtcTransactionLink, getBtcAddresses, + getBtcAddressString, getBatchData, AVERAGE_MIN_PER_BLOCK, MAX_BTC_IN_SATOSHIS, diff --git a/src/utils/Helpers/BTC/BTC.test.js b/src/utils/Helpers/BTC/BTC.test.js index 18daf7a3..1109075e 100644 --- a/src/utils/Helpers/BTC/BTC.test.js +++ b/src/utils/Helpers/BTC/BTC.test.js @@ -5,6 +5,7 @@ import { getConfirmationsAmount, parseFeesEstimates, convertBtcToSatoshi, + getBtcAddressString, } from './BTC' import { localStorageMock } from 'src/tests/mock/localStorage/localStorage' @@ -30,6 +31,16 @@ test('Parse Fees Estimates', () => { expect(estimates.MEDIUM).toBeLessThan(estimates.HIGH) }) +test('Extracts an address string from both stored BTC address shapes', () => { + const newStoreEntry = { bc1qnew: { pubkey: { 1: 2 } } } + const oldStoreEntry = 'bc1qold' + + expect(getBtcAddressString(newStoreEntry)).toBe('bc1qnew') + expect(getBtcAddressString(oldStoreEntry)).toBe('bc1qold') + expect(getBtcAddressString(undefined)).toBeUndefined() + expect(getBtcAddressString(null)).toBeUndefined() +}) + test('Calculate Balance From Utxo List', () => { const balance = 2075724 const satoshiAmount = calculateBalanceFromUtxoList(utxos) diff --git a/src/utils/Helpers/ML/MLTransaction.js b/src/utils/Helpers/ML/MLTransaction.js index c60cb4b8..35d39b03 100644 --- a/src/utils/Helpers/ML/MLTransaction.js +++ b/src/utils/Helpers/ML/MLTransaction.js @@ -7,6 +7,7 @@ import { Mintlayer } from '@APIs' import { LocalStorageService } from '@Storage' import { ML as MLHelpers } from '@Helpers' import { AppInfo } from '@Constants' +import Decimal from 'decimal.js' const getUtxoBalance = (item) => { return BigInt(item.utxo.value.amount.atoms) @@ -511,7 +512,10 @@ const sendTransaction = async ({ const account = LocalStorageService.getItem('unlockedAccount') const accountName = account.name - const unconfirmedTransactionString = `${AppInfo.UNCONFIRMED_TRANSACTION_NAME}_${accountName}_${network}` + const unconfirmedTransactionString = MLHelpers.getUnconfirmedTransactionKey( + accountName, + network, + ) const unconfirmedTransactions = LocalStorageService.getItem(unconfirmedTransactionString) || [] @@ -557,12 +561,15 @@ const spendFromDelegation = async ( if (fee > AppInfo.MAX_ML_FEE) { throw new Error('Fee is too high, please try again later.') } - let amountToUse = Number(amount) + fee - let outputAmount = Number(amount) - - if (amountToUse > Number(delegation.balance)) { - amountToUse = Number(delegation.balance) - outputAmount = amountToUse - fee + // Decimal arithmetic: float addition on coin amounts drifts at 11 decimals. + const amountDecimal = new Decimal(amount) + const balanceDecimal = new Decimal(delegation.balance) + let amountToUse = amountDecimal.plus(fee) + let outputAmount = amountDecimal + + if (amountToUse.greaterThan(balanceDecimal)) { + amountToUse = balanceDecimal + outputAmount = amountToUse.minus(fee) } const input = ML.getAccountOutpointInput( @@ -607,7 +614,10 @@ const spendFromDelegation = async ( const account = LocalStorageService.getItem('unlockedAccount') const accountName = account.name - const unconfirmedTransactionString = `${AppInfo.UNCONFIRMED_TRANSACTION_NAME}_${accountName}_${network}` + const unconfirmedTransactionString = MLHelpers.getUnconfirmedTransactionKey( + accountName, + network, + ) const unconfirmedTransactions = LocalStorageService.getItem(unconfirmedTransactionString) || [] @@ -706,7 +716,10 @@ const sendIssueNft = async ({ const account = LocalStorageService.getItem('unlockedAccount') const accountName = account.name - const unconfirmedTransactionString = `${AppInfo.UNCONFIRMED_TRANSACTION_NAME}_${accountName}_${network}` + const unconfirmedTransactionString = MLHelpers.getUnconfirmedTransactionKey( + accountName, + network, + ) const unconfirmedTransactions = LocalStorageService.getItem(unconfirmedTransactionString) || [] @@ -830,7 +843,10 @@ const createNft = async ({ const account = LocalStorageService.getItem('unlockedAccount') const accountName = account.name - const unconfirmedTransactionString = `${AppInfo.UNCONFIRMED_TRANSACTION_NAME}_${accountName}_${network}` + const unconfirmedTransactionString = MLHelpers.getUnconfirmedTransactionKey( + accountName, + network, + ) const unconfirmedTransactions = LocalStorageService.getItem(unconfirmedTransactionString) || [] diff --git a/src/utils/Helpers/Number/Format.js b/src/utils/Helpers/Number/Format.js index 0bb21b75..f87ae08b 100644 --- a/src/utils/Helpers/Number/Format.js +++ b/src/utils/Helpers/Number/Format.js @@ -18,7 +18,9 @@ const BTCValue = (value) => { } const atomsToDecimal = (atoms, decimals) => { - const atomsBigInt = BigInt(Number(atoms)) + // BigInt() directly: Number(atoms) would silently corrupt values above + // 2^53 (ML has 11 decimals, so ~90,000 ML is already out of float range). + const atomsBigInt = typeof atoms === 'bigint' ? atoms : BigInt(atoms) const divisor = BigInt(10 ** decimals) const quotient = atomsBigInt / divisor const remainder = atomsBigInt % divisor diff --git a/src/utils/Helpers/Transactions/Transactions.js b/src/utils/Helpers/Transactions/Transactions.js new file mode 100644 index 00000000..b2f0901d --- /dev/null +++ b/src/utils/Helpers/Transactions/Transactions.js @@ -0,0 +1,56 @@ +import * as Format from '../Number/Format' + +const SWAP_TYPES = ['CreateOrder', 'FillOrder'] +const STAKE_TYPES = [ + 'DelegateStaking', + 'CreateDelegationId', + 'Delegate Withdrawal', + 'CreateStakePool', +] + +/** + * Maps a real wallet transaction (BTC or ML) to the design-system tx shape + * used by the composed TxRow. Returns `amount: null` for transactions that + * have no simple numeric amount (swaps, delegations) so the UI renders a + * neutral dash instead of crashing on object/undefined values. + */ +const adaptDesignTx = (tx, sym, chain) => { + const isBtc = sym === 'BTC' + const isSwap = SWAP_TYPES.includes(tx.type) + const isStake = STAKE_TYPES.includes(tx.type) + + const simpleValue = + tx.value != null && typeof tx.value !== 'object' ? tx.value : null + + return { + type: isSwap + ? 'swap' + : isStake + ? 'dapp' + : tx.direction === 'in' + ? 'receive' + : 'send', + sym, + chain, + amount: + simpleValue == null + ? null + : isBtc + ? Format.BTCValue(simpleValue) + : simpleValue, + when: tx.date + ? new Date(tx.date * 1000).toLocaleString(undefined, { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }) + : 'Pending', + status: tx.date ? 'Confirmed' : 'Pending', + hash: tx.txid, + to: tx.otherPart, + from: tx.otherPart, + } +} + +export { adaptDesignTx } diff --git a/src/utils/Helpers/index.js b/src/utils/Helpers/index.js index f0277efd..1aac70ba 100644 --- a/src/utils/Helpers/index.js +++ b/src/utils/Helpers/index.js @@ -11,6 +11,7 @@ import * as MLTransaction from './ML/MLTransaction' import * as StringHelpers from './String/String' import * as ObjectHelpers from './Object/Object' import * as Secret from './Secret/Secret' +import * as Transactions from './Transactions/Transactions' export { BTC, @@ -26,4 +27,5 @@ export { StringHelpers, ObjectHelpers, Secret, + Transactions, } From 8e3efa5b6f73ff35414f35c8b3e98805b8cc23ea Mon Sep 17 00:00:00 2001 From: Enrico Rubboli Date: Tue, 8 Sep 2026 11:09:32 +0200 Subject: [PATCH 17/52] fix(providers): survive API failures without wedging the UI (review P2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MintlayerProvider.fetchAllData: try/catch/finally + ref-based mutex — one failed request used to leave every loading flag stuck and block all future refreshes; forced runs serialize behind in-flight ones; new fetchError context field; network-switch effect owns cancelAllRequests - fetchDelegations always releases its flag and resets state on error - fetchOrdersPairInfo no longer leaves the spinner stuck on failure - chain-tip polling catches errors instead of unhandled rejections - ExchangeRatesProvider fetches both coins in parallel, exposes error/fetching state, keeps last good rates on failure - BitcoinProvider never sets btcUtxos to undefined (crashed coin selection) and the network-sync effect no longer re-fired every render - Electrum: drop the unvalidated customAPIServers localStorage override (redirected all BTC data and broadcasts) - Browser.sendPopupResponse: robust cleanup when the window cannot close --- .../BitcoinProvider/BitcoinProvider.js | 23 ++--- .../ExchangeRatesProvider.js | 88 ++++++++++++------- src/services/API/Electrum/Electrum.js | 17 +--- src/services/Browser/Browser.js | 39 +++++--- src/services/Browser/index.js | 2 + 5 files changed, 97 insertions(+), 72 deletions(-) diff --git a/src/contexts/BitcoinProvider/BitcoinProvider.js b/src/contexts/BitcoinProvider/BitcoinProvider.js index 84bce192..1389e647 100644 --- a/src/contexts/BitcoinProvider/BitcoinProvider.js +++ b/src/contexts/BitcoinProvider/BitcoinProvider.js @@ -72,13 +72,12 @@ const BitcoinProvider = ({ value: propValue, children }) => { setCurrentNetworkType(networkType) setCurrentAccountId(accountID) - const receivingAddresses = - addresses.btcAddresses.btcReceivingAddresses.flatMap((addr) => - Object.keys(addr), - ) - const changeAddresses = addresses.btcAddresses.btcChangeAddresses.flatMap( - (addr) => Object.keys(addr), - ) + const receivingAddresses = addresses.btcAddresses.btcReceivingAddresses + .map(BTC.getBtcAddressString) + .filter(Boolean) + const changeAddresses = addresses.btcAddresses.btcChangeAddresses + .map(BTC.getBtcAddressString) + .filter(Boolean) const allAddresses = changeAddresses.concat(receivingAddresses) @@ -240,12 +239,14 @@ const BitcoinProvider = ({ value: propValue, children }) => { } catch (error) { console.error('Error in getWalletUtxos:', error) setFetchingUtxos(false) + // Never undefined: consumers map/filter the utxo list. + return [] } } const getBalance = async (utxos) => { try { - const satoshiBalance = BTC.calculateBalanceFromUtxoList(utxos) + const satoshiBalance = BTC.calculateBalanceFromUtxoList(utxos || []) const balanceConvertedToBTC = BTC.convertSatoshiToBtc(satoshiBalance) const formattedBalance = Format.BTCValue(balanceConvertedToBTC) setBtcBalance(formattedBalance) @@ -257,7 +258,6 @@ const BitcoinProvider = ({ value: propValue, children }) => { } await getTransactions() const fetchedUtxos = await getWalletUtxos() - setBtcUtxos(fetchedUtxos) await getBalance(fetchedUtxos) getBalanceFromAddressInfo() } @@ -293,7 +293,10 @@ const BitcoinProvider = ({ value: propValue, children }) => { if (networkType !== currentNetworkType) { fetchAllData(true) } - }) + // Only re-run on actual network changes; a dependency-less effect would + // re-fire on every render. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [networkType]) const value = { btcBalance, diff --git a/src/contexts/ExchangeRatesProvider/ExchangeRatesProvider.js b/src/contexts/ExchangeRatesProvider/ExchangeRatesProvider.js index 40c6e117..501de68e 100644 --- a/src/contexts/ExchangeRatesProvider/ExchangeRatesProvider.js +++ b/src/contexts/ExchangeRatesProvider/ExchangeRatesProvider.js @@ -13,50 +13,68 @@ const ExchangeRatesProvider = ({ value: propValue, children }) => { const [yesterdayExchangeRate, setYesterdayExchangeRate] = useState({}) const [historyRates, setHistoryRates] = useState({}) const [thirtyDaysHistoryRates, setThirtyDaysHistoryRates] = useState({}) + const [fetchError, setFetchError] = useState(null) + const [fetching, setFetching] = useState(true) const { accountID } = useContext(AccountContext) useEffect(() => { if (!accountID) return const default_crypto = ['btc', 'ml'] - const getData = async () => { - const rates = {} - const yesterdayRates = {} - const historyRates = {} - const thirtyDaysRates = {} - for (let i = 0; i < default_crypto.length; i++) { - const response_rates = await ExchangeRates.getRate( - default_crypto[i], - fiat, - ) - rates[`${default_crypto[i]}-${fiat}`] = - JSON.parse(response_rates)[`${default_crypto[i]}-${fiat}`] - const response_yesterday = await ExchangeRates.getOneDayAgoRate( - default_crypto[i], - fiat, - ) - yesterdayRates[`${default_crypto[i]}-${fiat}`] = - JSON.parse(response_yesterday)[`${default_crypto[i]}-${fiat}`] + const fetchCoinRates = async (crypto) => { + const [ + response_rates, + response_yesterday, + response_history, + response_thirty_days, + ] = await Promise.all([ + ExchangeRates.getRate(crypto, fiat), + ExchangeRates.getOneDayAgoRate(crypto, fiat), + ExchangeRates.getOneDayAgoHist(crypto, fiat), + ExchangeRates.getThirtyDaysHist(crypto, fiat), + ]) - const response_history = await ExchangeRates.getOneDayAgoHist( - default_crypto[i], - fiat, - ) - historyRates[`${default_crypto[i]}-${fiat}`] = - JSON.parse(response_history)[`${default_crypto[i]}-${fiat}`] + return { + rate: JSON.parse(response_rates)[`${crypto}-${fiat}`], + yesterday: JSON.parse(response_yesterday)[`${crypto}-${fiat}`], + history: JSON.parse(response_history)[`${crypto}-${fiat}`], + thirtyDays: JSON.parse(response_thirty_days)[`${crypto}-${fiat}`], + } + } - const response_thirty_days = await ExchangeRates.getThirtyDaysHist( - default_crypto[i], - fiat, + const getData = async () => { + setFetching(true) + try { + // Coins are independent: fetch in parallel instead of 8 sequential + // round-trips. + const results = await Promise.all( + default_crypto.map((crypto) => fetchCoinRates(crypto)), ) - thirtyDaysRates[`${default_crypto[i]}-${fiat}`] = - JSON.parse(response_thirty_days)[`${default_crypto[i]}-${fiat}`] - } - setExchangeRate(rates) - setYesterdayExchangeRate(yesterdayRates) - setHistoryRates(historyRates) - setThirtyDaysHistoryRates(thirtyDaysRates) + const rates = {} + const yesterdayRates = {} + const historyRates = {} + const thirtyDaysRates = {} + default_crypto.forEach((crypto, i) => { + rates[`${crypto}-${fiat}`] = results[i].rate + yesterdayRates[`${crypto}-${fiat}`] = results[i].yesterday + historyRates[`${crypto}-${fiat}`] = results[i].history + thirtyDaysRates[`${crypto}-${fiat}`] = results[i].thirtyDays + }) + + setExchangeRate(rates) + setYesterdayExchangeRate(yesterdayRates) + setHistoryRates(historyRates) + setThirtyDaysHistoryRates(thirtyDaysRates) + setFetchError(null) + } catch (error) { + // Keep the last good rates; expose the failure so consumers can + // distinguish "stale" from "zero". + console.error('Failed to fetch exchange rates:', error) + setFetchError(error) + } finally { + setFetching(false) + } } getData() @@ -69,6 +87,8 @@ const ExchangeRatesProvider = ({ value: propValue, children }) => { yesterdayExchangeRate, historyRates, thirtyDaysHistoryRates, + fetchError, + fetching, } return ( diff --git a/src/services/API/Electrum/Electrum.js b/src/services/API/Electrum/Electrum.js index 853fd980..d66d7478 100644 --- a/src/services/API/Electrum/Electrum.js +++ b/src/services/API/Electrum/Electrum.js @@ -49,25 +49,14 @@ const requestElectrum = async (url, body = null, request = fetch) => { const tryServers = async (endpoint, body = null) => { const networkType = LocalStorageService.getItem('networkType') - const customElectrumServerList = LocalStorageService.getItem( - AppInfo.APP_LOCAL_STORAGE_CUSTOM_SERVERS, - ) - - const customServer = customElectrumServerList - ? networkType === AppInfo.NETWORK_TYPES.TESTNET - ? customElectrumServerList.bitcoin_testnet - : customElectrumServerList.bitcoin_mainnet - : null - const defaultElectrumServes = + // No localStorage custom-server override: an unvalidated entry would + // silently redirect all Bitcoin data (and broadcasts) elsewhere. + const combinedElectrumServers = networkType === AppInfo.NETWORK_TYPES.TESTNET ? EnvVars.TESTNET_ELECTRUM_SERVERS : EnvVars.MAINNET_ELECTRUM_SERVERS - const combinedElectrumServers = customServer - ? [customServer, ...defaultElectrumServes] - : [...defaultElectrumServes] - for (let i = 0; i < combinedElectrumServers.length; i++) { try { const response = await requestElectrum( diff --git a/src/services/Browser/Browser.js b/src/services/Browser/Browser.js index 696b1706..ae4c0c15 100644 --- a/src/services/Browser/Browser.js +++ b/src/services/Browser/Browser.js @@ -23,18 +23,29 @@ export const sendPopupResponse = ({ }) => { if (!runtime || !storage) return - runtime.sendMessage( - { - action: 'popupResponse', - method, - requestId, - origin, - ...(error ? { error } : { result }), - }, - () => { - storage.local.remove('pendingRequest', () => { - window.close() - }) - }, - ) + const cleanup = () => { + storage.local.remove('pendingRequest', () => { + window.close() + // Fallback for contexts where window.close() is ignored (e.g. the + // approval page opened as a tab in dev): go back to the wallet. + setTimeout(() => { + if (!window.closed) window.location.replace('/') + }, 150) + }) + } + + try { + runtime.sendMessage( + { + action: 'popupResponse', + method, + requestId, + origin, + ...(error ? { error } : { result }), + }, + cleanup, + ) + } catch { + cleanup() + } } diff --git a/src/services/Browser/index.js b/src/services/Browser/index.js index 49c7eb1a..925c84c4 100644 --- a/src/services/Browser/index.js +++ b/src/services/Browser/index.js @@ -1,3 +1,5 @@ import * as Browser from './Browser' export { Browser } + +export { runtime, storage, sendPopupResponse } from './Browser' From e5bd072587713f1cc73e835de19adc6dae4ff137 Mon Sep 17 00:00:00 2001 From: Enrico Rubboli Date: Tue, 8 Sep 2026 11:10:04 +0200 Subject: [PATCH 18/52] feat(design-system): dark design-system primitives, retire parallel implementations - new basic components: Icon (inline line set), Tag, Seg, LivePill, Sparkline, TokenIcon (metadata icon + procedural fallback), Counter, Eyebrow, KV, Seg, Sheet, QrCode/QrPlaceholder, ChainBadge, Avatar, IconTile, Progress, MojitoLogo, ErrorBoundary (self-reporting), SkeletonLoader restyle - new composed components: TxRow, AssetRow, BeSheet (bottom sheet) - theme.css: be-* design tokens (bg/text/line/amber/teal, oklch) - remove the old parallel renderers the new pages replace: containers/Dashboard (CryptoList/Statistics/CryptoSharesChart/Skeleton) and CurrentStaking --- src/assets/styles/theme.css | 126 ++++++++++++ src/components/basic/Avatar/Avatar.module.css | 8 + src/components/basic/Avatar/Avatar.test.tsx | 17 ++ src/components/basic/Avatar/Avatar.tsx | 32 +++ .../basic/BrandPanel/BrandPanel.module.css | 19 +- .../basic/ChainBadge/ChainBadge.module.css | 19 ++ .../basic/ChainBadge/ChainBadge.test.tsx | 12 ++ .../basic/ChainBadge/ChainBadge.tsx | 30 +++ .../basic/Counter/Counter.module.css | 3 + src/components/basic/Counter/Counter.test.tsx | 28 +++ src/components/basic/Counter/Counter.tsx | 50 +++++ .../basic/EmptyList/EmptyList.module.css | 2 +- .../ErrorBoundary/ErrorBoundary.module.css | 45 ++++ .../ErrorBoundary/ErrorBoundary.test.tsx | 39 ++++ .../basic/ErrorBoundary/ErrorBoundary.tsx | 61 ++++++ .../basic/Eyebrow/Eyebrow.module.css | 7 + src/components/basic/Eyebrow/Eyebrow.tsx | 20 ++ src/components/basic/Icon/Icon.test.tsx | 27 +++ src/components/basic/Icon/Icon.tsx | 194 ++++++++++++++++++ .../basic/IconTile/IconTile.module.css | 6 + .../basic/IconTile/IconTile.test.tsx | 17 ++ src/components/basic/IconTile/IconTile.tsx | 47 +++++ src/components/basic/KV/KV.module.css | 33 +++ src/components/basic/KV/KV.test.tsx | 16 ++ src/components/basic/KV/KV.tsx | 28 +++ .../basic/LivePill/LivePill.module.css | 20 ++ .../basic/LivePill/LivePill.test.tsx | 26 +++ src/components/basic/LivePill/LivePill.tsx | 23 +++ src/components/basic/Logo/Logo.css | 2 +- .../basic/MojitoLogo/MojitoLogo.module.css | 8 + .../basic/MojitoLogo/MojitoLogo.test.tsx | 20 ++ .../basic/MojitoLogo/MojitoLogo.tsx | 93 +++++++++ .../basic/OptionCard/OptionCard.module.css | 4 +- .../basic/Progress/Progress.module.css | 18 ++ .../basic/Progress/Progress.test.tsx | 28 +++ src/components/basic/Progress/Progress.tsx | 24 +++ src/components/basic/QrCode/QrCode.module.css | 8 + src/components/basic/QrCode/QrCode.test.tsx | 14 ++ src/components/basic/QrCode/QrCode.tsx | 28 +++ .../QrPlaceholder/QrPlaceholder.module.css | 18 ++ .../QrPlaceholder/QrPlaceholder.test.tsx | 14 ++ .../basic/QrPlaceholder/QrPlaceholder.tsx | 25 +++ src/components/basic/Seg/Seg.module.css | 25 +++ src/components/basic/Seg/Seg.test.tsx | 39 ++++ src/components/basic/Seg/Seg.tsx | 33 +++ src/components/basic/Sheet/Sheet.module.css | 39 ++++ src/components/basic/Sheet/Sheet.test.tsx | 30 +++ src/components/basic/Sheet/Sheet.tsx | 33 +++ .../basic/SiteBadge/SiteBadge.module.css | 14 +- .../basic/SkeletonLoader/SkeletonLoader.css | 10 +- .../basic/Sparkline/Sparkline.test.tsx | 20 ++ src/components/basic/Sparkline/Sparkline.tsx | 50 +++++ src/components/basic/Tag/Tag.module.css | 39 ++++ src/components/basic/Tag/Tag.test.tsx | 17 ++ src/components/basic/Tag/Tag.tsx | 21 ++ src/components/basic/Toggle/Toggle.css | 6 +- src/components/basic/Tooltip/Tooltip.css | 2 +- src/components/basic/index.js | 36 ++++ .../composed/AssetRow/AssetRow.module.css | 54 +++++ .../composed/AssetRow/AssetRow.test.tsx | 32 +++ .../composed/BeSheet/BeSheet.module.css | 10 + .../composed/BeSheet/BeSheet.test.tsx | 44 ++++ src/components/composed/BeSheet/BeSheet.tsx | 28 +++ .../composed/TxRow/TxRow.module.css | 57 +++++ src/components/composed/TxRow/TxRow.test.tsx | 33 +++ src/components/composed/TxRow/TxRow.tsx | 86 ++++++++ src/components/composed/index.js | 8 +- src/components/containers/index.js | 12 -- 68 files changed, 2000 insertions(+), 37 deletions(-) create mode 100644 src/assets/styles/theme.css create mode 100644 src/components/basic/Avatar/Avatar.module.css create mode 100644 src/components/basic/Avatar/Avatar.test.tsx create mode 100644 src/components/basic/Avatar/Avatar.tsx create mode 100644 src/components/basic/ChainBadge/ChainBadge.module.css create mode 100644 src/components/basic/ChainBadge/ChainBadge.test.tsx create mode 100644 src/components/basic/ChainBadge/ChainBadge.tsx create mode 100644 src/components/basic/Counter/Counter.module.css create mode 100644 src/components/basic/Counter/Counter.test.tsx create mode 100644 src/components/basic/Counter/Counter.tsx create mode 100644 src/components/basic/ErrorBoundary/ErrorBoundary.module.css create mode 100644 src/components/basic/ErrorBoundary/ErrorBoundary.test.tsx create mode 100644 src/components/basic/ErrorBoundary/ErrorBoundary.tsx create mode 100644 src/components/basic/Eyebrow/Eyebrow.module.css create mode 100644 src/components/basic/Eyebrow/Eyebrow.tsx create mode 100644 src/components/basic/Icon/Icon.test.tsx create mode 100644 src/components/basic/Icon/Icon.tsx create mode 100644 src/components/basic/IconTile/IconTile.module.css create mode 100644 src/components/basic/IconTile/IconTile.test.tsx create mode 100644 src/components/basic/IconTile/IconTile.tsx create mode 100644 src/components/basic/KV/KV.module.css create mode 100644 src/components/basic/KV/KV.test.tsx create mode 100644 src/components/basic/KV/KV.tsx create mode 100644 src/components/basic/LivePill/LivePill.module.css create mode 100644 src/components/basic/LivePill/LivePill.test.tsx create mode 100644 src/components/basic/LivePill/LivePill.tsx create mode 100644 src/components/basic/MojitoLogo/MojitoLogo.module.css create mode 100644 src/components/basic/MojitoLogo/MojitoLogo.test.tsx create mode 100644 src/components/basic/MojitoLogo/MojitoLogo.tsx create mode 100644 src/components/basic/Progress/Progress.module.css create mode 100644 src/components/basic/Progress/Progress.test.tsx create mode 100644 src/components/basic/Progress/Progress.tsx create mode 100644 src/components/basic/QrCode/QrCode.module.css create mode 100644 src/components/basic/QrCode/QrCode.test.tsx create mode 100644 src/components/basic/QrCode/QrCode.tsx create mode 100644 src/components/basic/QrPlaceholder/QrPlaceholder.module.css create mode 100644 src/components/basic/QrPlaceholder/QrPlaceholder.test.tsx create mode 100644 src/components/basic/QrPlaceholder/QrPlaceholder.tsx create mode 100644 src/components/basic/Seg/Seg.module.css create mode 100644 src/components/basic/Seg/Seg.test.tsx create mode 100644 src/components/basic/Seg/Seg.tsx create mode 100644 src/components/basic/Sheet/Sheet.module.css create mode 100644 src/components/basic/Sheet/Sheet.test.tsx create mode 100644 src/components/basic/Sheet/Sheet.tsx create mode 100644 src/components/basic/Sparkline/Sparkline.test.tsx create mode 100644 src/components/basic/Sparkline/Sparkline.tsx create mode 100644 src/components/basic/Tag/Tag.module.css create mode 100644 src/components/basic/Tag/Tag.test.tsx create mode 100644 src/components/basic/Tag/Tag.tsx create mode 100644 src/components/composed/AssetRow/AssetRow.module.css create mode 100644 src/components/composed/AssetRow/AssetRow.test.tsx create mode 100644 src/components/composed/BeSheet/BeSheet.module.css create mode 100644 src/components/composed/BeSheet/BeSheet.test.tsx create mode 100644 src/components/composed/BeSheet/BeSheet.tsx create mode 100644 src/components/composed/TxRow/TxRow.module.css create mode 100644 src/components/composed/TxRow/TxRow.test.tsx create mode 100644 src/components/composed/TxRow/TxRow.tsx diff --git a/src/assets/styles/theme.css b/src/assets/styles/theme.css new file mode 100644 index 00000000..27162756 --- /dev/null +++ b/src/assets/styles/theme.css @@ -0,0 +1,126 @@ +/* Mojito BE — dark visual system (from doc/ design import). + Additive to the legacy light tokens in constants.css: existing pages keep + working; refactored pages use these tokens. */ + +:root { + /* Surfaces */ + --be-bg-0: oklch(0.14 0.012 60); + --be-bg-1: oklch(0.18 0.014 60); + --be-bg-2: oklch(0.22 0.014 60); + --be-bg-3: oklch(0.26 0.014 60); + --be-line: oklch(0.32 0.012 60); + --be-line-soft: oklch(0.28 0.01 60 / 0.6); + + /* Text */ + --be-text-0: oklch(0.98 0.005 80); + --be-text-1: oklch(0.82 0.008 70); + --be-text-2: oklch(0.6 0.01 70); + --be-text-3: oklch(0.45 0.012 70); + + /* Accents */ + --be-amber: oklch(0.82 0.16 70); + --be-amber-soft: oklch(0.82 0.16 70 / 0.14); + --be-teal: oklch(0.82 0.12 195); + --be-teal-soft: oklch(0.82 0.12 195 / 0.14); + --be-violet: oklch(0.74 0.16 290); + --be-violet-soft: oklch(0.74 0.16 290 / 0.14); + --be-green: oklch(0.78 0.16 150); + --be-red: oklch(0.7 0.2 25); + + /* Ambient canvas gradient (page background) */ + --be-canvas: + radial-gradient( + 120% 60% at 50% -10%, + oklch(0.82 0.16 70 / 0.16), + transparent 60% + ), + radial-gradient( + 80% 50% at 100% 100%, + oklch(0.74 0.16 290 / 0.1), + transparent 60% + ), + radial-gradient( + 80% 50% at 0% 100%, + oklch(0.82 0.12 195 / 0.08), + transparent 60% + ), + var(--be-bg-0); + + --be-radius-card: 22px; + --be-radius-input: 14px; + --be-font-mono: + 'JetBrains Mono', ui-monospace, 'SFMono-Regular', Menlo, monospace; +} + +/* Animations from the design system */ +@keyframes be-float-y { + 0%, + 100% { + transform: translateY(0); + } + 50% { + transform: translateY(-6px); + } +} + +@keyframes be-pulse-ring { + 0% { + transform: scale(0.6); + opacity: 0.7; + } + 100% { + transform: scale(2); + opacity: 0; + } +} + +@keyframes be-slide-up { + from { + opacity: 0; + transform: translateY(14px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes be-fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes be-spin-slow { + to { + transform: rotate(360deg); + } +} + +@keyframes be-draw { + to { + stroke-dashoffset: 0; + } +} + +@keyframes be-shake { + 0%, + 100% { + transform: translateX(0); + } + 20%, + 60% { + transform: translateX(-5px); + } + 40%, + 80% { + transform: translateX(5px); + } +} + +:root { + --be-green-soft: oklch(0.78 0.16 150 / 0.14); +} diff --git a/src/components/basic/Avatar/Avatar.module.css b/src/components/basic/Avatar/Avatar.module.css new file mode 100644 index 00000000..9336f8c2 --- /dev/null +++ b/src/components/basic/Avatar/Avatar.module.css @@ -0,0 +1,8 @@ +.avatar { + display: flex; + align-items: center; + justify-content: center; + color: #1a1208; + flex-shrink: 0; + user-select: none; +} diff --git a/src/components/basic/Avatar/Avatar.test.tsx b/src/components/basic/Avatar/Avatar.test.tsx new file mode 100644 index 00000000..25cf5b00 --- /dev/null +++ b/src/components/basic/Avatar/Avatar.test.tsx @@ -0,0 +1,17 @@ +import { render, screen } from '@testing-library/react' +import Avatar from './Avatar' + +test('renders the first letter of the name', () => { + render() + expect(screen.getByTestId('avatar')).toHaveTextContent('M') +}) + +test('applies the requested size', () => { + render( + , + ) + expect(screen.getByTestId('avatar')).toHaveStyle({ width: '30px' }) +}) diff --git a/src/components/basic/Avatar/Avatar.tsx b/src/components/basic/Avatar/Avatar.tsx new file mode 100644 index 00000000..6ea8caf6 --- /dev/null +++ b/src/components/basic/Avatar/Avatar.tsx @@ -0,0 +1,32 @@ +import styles from './Avatar.module.css' + +interface AvatarProps { + name: string + color?: string + size?: number +} + +// Account avatar with gradient from the design system. +const Avatar = ({ + name, + color = 'var(--be-amber)', + size = 28, +}: AvatarProps) => { + return ( +
    + {name[0]} +
    + ) +} + +export default Avatar diff --git a/src/components/basic/BrandPanel/BrandPanel.module.css b/src/components/basic/BrandPanel/BrandPanel.module.css index 54e33516..ec88fc6e 100644 --- a/src/components/basic/BrandPanel/BrandPanel.module.css +++ b/src/components/basic/BrandPanel/BrandPanel.module.css @@ -7,9 +7,21 @@ align-items: center; justify-content: center; width: 38%; - background: rgb(var(--mojito-green)); + background: + radial-gradient( + 120% 60% at 50% -10%, + oklch(0.82 0.16 70 / 0.16), + transparent 60% + ), + radial-gradient( + 80% 50% at 100% 100%, + oklch(0.74 0.16 290 / 0.1), + transparent 60% + ), + var(--be-bg-0); overflow: hidden; flex-shrink: 0; + border-right: 1px solid var(--be-line-soft); } } @@ -26,6 +38,7 @@ flex-direction: column; align-items: center; gap: 16px; + color: var(--be-text-0); } .brandLogo { @@ -38,7 +51,7 @@ display: flex; align-items: center; gap: 10px; - color: #fff; + color: var(--be-text-0); font-size: 28px; font-weight: 700; letter-spacing: -0.5px; @@ -52,7 +65,7 @@ .brandSubtitle { font-size: 14px; - color: rgba(255, 255, 255, 0.75); + color: var(--be-text-2); text-align: center; line-height: 1.5; margin: 0; diff --git a/src/components/basic/ChainBadge/ChainBadge.module.css b/src/components/basic/ChainBadge/ChainBadge.module.css new file mode 100644 index 00000000..47c6c4b6 --- /dev/null +++ b/src/components/basic/ChainBadge/ChainBadge.module.css @@ -0,0 +1,19 @@ +.badge { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 2px 7px; + border-radius: 6px; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.04em; + border: 1px solid; + border-color: color-mix(in oklab, currentcolor 40%, transparent); +} + +.dot { + width: 5px; + height: 5px; + border-radius: 50%; + background: currentcolor; +} diff --git a/src/components/basic/ChainBadge/ChainBadge.test.tsx b/src/components/basic/ChainBadge/ChainBadge.test.tsx new file mode 100644 index 00000000..5cf4ba9a --- /dev/null +++ b/src/components/basic/ChainBadge/ChainBadge.test.tsx @@ -0,0 +1,12 @@ +import { render, screen } from '@testing-library/react' +import ChainBadge from './ChainBadge' + +test('ChainBadge renders the uppercased chain', () => { + render() + expect(screen.getByTestId('chain-badge')).toHaveTextContent('BITCOIN') +}) + +test('unknown chains fall back to a neutral style', () => { + render() + expect(screen.getByTestId('chain-badge')).toHaveTextContent('UNKNOWNCHAIN') +}) diff --git a/src/components/basic/ChainBadge/ChainBadge.tsx b/src/components/basic/ChainBadge/ChainBadge.tsx new file mode 100644 index 00000000..56f96954 --- /dev/null +++ b/src/components/basic/ChainBadge/ChainBadge.tsx @@ -0,0 +1,30 @@ +import styles from './ChainBadge.module.css' + +interface ChainBadgeProps { + chain: string +} + +const MAP: Record = { + Mintlayer: { color: 'var(--be-teal)', soft: 'var(--be-teal-soft)' }, + Bitcoin: { color: 'var(--be-amber)', soft: 'var(--be-amber-soft)' }, +} + +// Small chain pill with a glowing dot (design system). +const ChainBadge = ({ chain }: ChainBadgeProps) => { + const t = MAP[chain] || { + color: 'var(--be-text-2)', + soft: 'oklch(1 0 0 / 0.05)', + } + return ( + + + {chain.toUpperCase()} + + ) +} + +export default ChainBadge diff --git a/src/components/basic/Counter/Counter.module.css b/src/components/basic/Counter/Counter.module.css new file mode 100644 index 00000000..670637b0 --- /dev/null +++ b/src/components/basic/Counter/Counter.module.css @@ -0,0 +1,3 @@ +.tnum { + font-variant-numeric: tabular-nums; +} diff --git a/src/components/basic/Counter/Counter.test.tsx b/src/components/basic/Counter/Counter.test.tsx new file mode 100644 index 00000000..3652550d --- /dev/null +++ b/src/components/basic/Counter/Counter.test.tsx @@ -0,0 +1,28 @@ +import { render, screen, act } from '@testing-library/react' +import Counter from './Counter' + +test('renders the numeric value', () => { + jest.useFakeTimers() + render( + , + ) + act(() => { + jest.advanceTimersByTime(2000) + }) + expect(screen.getByTestId('counter').textContent).toContain('1,234.50') + jest.useRealTimers() +}) + +test('supports prefix and suffix', () => { + render( + , + ) + expect(screen.getByTestId('counter').textContent).toContain('$') +}) diff --git a/src/components/basic/Counter/Counter.tsx b/src/components/basic/Counter/Counter.tsx new file mode 100644 index 00000000..77657020 --- /dev/null +++ b/src/components/basic/Counter/Counter.tsx @@ -0,0 +1,50 @@ +import { useEffect, useState } from 'react' +import styles from './Counter.module.css' + +interface CounterProps { + value: number + decimals?: number + prefix?: string + suffix?: string + duration?: number +} + +// Animated number ticker from the design system. +const Counter = ({ + value, + decimals = 2, + prefix = '', + suffix = '', + duration = 1200, +}: CounterProps) => { + const [v, setV] = useState(0) + + useEffect(() => { + const start = performance.now() + let raf: number + const tick = (t: number) => { + const p = Math.min(1, (t - start) / duration) + const eased = 1 - Math.pow(1 - p, 3) + setV(value * eased) + if (p < 1) raf = requestAnimationFrame(tick) + } + raf = requestAnimationFrame(tick) + return () => cancelAnimationFrame(raf) + }, [value, duration]) + + return ( + + {prefix} + {v.toLocaleString(undefined, { + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + })} + {suffix} + + ) +} + +export default Counter diff --git a/src/components/basic/EmptyList/EmptyList.module.css b/src/components/basic/EmptyList/EmptyList.module.css index c32180d6..eacf149d 100644 --- a/src/components/basic/EmptyList/EmptyList.module.css +++ b/src/components/basic/EmptyList/EmptyList.module.css @@ -1,5 +1,5 @@ .emptyList { - background: rgb(var(--color-gray)); + background: var(--be-bg-1); font-size: 1.5em; list-style: none; padding: 10px; diff --git a/src/components/basic/ErrorBoundary/ErrorBoundary.module.css b/src/components/basic/ErrorBoundary/ErrorBoundary.module.css new file mode 100644 index 00000000..853dec0e --- /dev/null +++ b/src/components/basic/ErrorBoundary/ErrorBoundary.module.css @@ -0,0 +1,45 @@ +.fallback { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + padding: 24px; +} + +.box { + text-align: center; + max-width: 320px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.title { + font-size: var(--font-size-2xl); + font-weight: 700; + color: var(--be-text-0); +} + +.text { + font-size: var(--font-size-sm); + line-height: 1.5; + color: var(--be-text-2); +} + +.message { + font-family: var(--font-mono, monospace); + font-size: var(--font-size-xs, 12px); + line-height: 1.4; + color: var(--be-text-2); + word-break: break-word; +} + +.reload { + height: 44px; + border-radius: 12px; + border: none; + cursor: pointer; + font-weight: 600; + color: oklch(0.18 0.02 70); + background: linear-gradient(180deg, oklch(0.88 0.15 75), oklch(0.78 0.16 65)); +} diff --git a/src/components/basic/ErrorBoundary/ErrorBoundary.test.tsx b/src/components/basic/ErrorBoundary/ErrorBoundary.test.tsx new file mode 100644 index 00000000..ca72926d --- /dev/null +++ b/src/components/basic/ErrorBoundary/ErrorBoundary.test.tsx @@ -0,0 +1,39 @@ +import { render } from '@testing-library/react' +import ErrorBoundary from './ErrorBoundary' + +const Bomb = ({ shouldThrow }: { shouldThrow?: boolean }) => { + if (shouldThrow) throw new Error('boom') + return

    fine

    +} + +test('renders children when no error occurs', () => { + const { getByText } = render( + + + , + ) + expect(getByText('fine')).toBeInTheDocument() +}) + +test('renders the fallback screen when a child throws', () => { + // silence the expected console error + const spy = jest.spyOn(console, 'error').mockImplementation(() => {}) + const { getByTestId } = render( + + + , + ) + expect(getByTestId('error-boundary')).toBeInTheDocument() + spy.mockRestore() +}) + +test('offers a reload button', () => { + const spy = jest.spyOn(console, 'error').mockImplementation(() => {}) + const { getByText } = render( + + + , + ) + expect(getByText('Reload wallet')).toBeInTheDocument() + spy.mockRestore() +}) diff --git a/src/components/basic/ErrorBoundary/ErrorBoundary.tsx b/src/components/basic/ErrorBoundary/ErrorBoundary.tsx new file mode 100644 index 00000000..3427adb9 --- /dev/null +++ b/src/components/basic/ErrorBoundary/ErrorBoundary.tsx @@ -0,0 +1,61 @@ +import { Component, ReactNode } from 'react' +import styles from './ErrorBoundary.module.css' + +interface ErrorBoundaryProps { + children: ReactNode +} + +interface ErrorBoundaryState { + hasError: boolean + error?: Error +} + +// Catches render errors and shows a recovery screen instead of a blank page. +class ErrorBoundary extends Component { + state: ErrorBoundaryState = { hasError: false } + + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + return { hasError: true, error } + } + + componentDidCatch(error: Error) { + console.error('[Mojito] UI error:', error) + } + + render() { + if (this.state.hasError) { + return ( +
    +
    +

    Something went wrong

    +

    + The wallet hit an unexpected error. Your funds are safe — reload + to continue. +

    + {this.state.error?.message && ( + + {this.state.error.message} + + )} + +
    +
    + ) + } + return this.props.children + } +} + +export default ErrorBoundary diff --git a/src/components/basic/Eyebrow/Eyebrow.module.css b/src/components/basic/Eyebrow/Eyebrow.module.css new file mode 100644 index 00000000..b0671e61 --- /dev/null +++ b/src/components/basic/Eyebrow/Eyebrow.module.css @@ -0,0 +1,7 @@ +.eyebrow { + font-size: var(--font-size-xs); + font-weight: 600; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--be-text-3); +} diff --git a/src/components/basic/Eyebrow/Eyebrow.tsx b/src/components/basic/Eyebrow/Eyebrow.tsx new file mode 100644 index 00000000..89b51dea --- /dev/null +++ b/src/components/basic/Eyebrow/Eyebrow.tsx @@ -0,0 +1,20 @@ +import { ReactNode } from 'react' +import styles from './Eyebrow.module.css' + +interface EyebrowProps { + children: ReactNode +} + +// Small uppercase section label from the design system. +const Eyebrow = ({ children }: EyebrowProps) => { + return ( +
    + {children} +
    + ) +} + +export default Eyebrow diff --git a/src/components/basic/Icon/Icon.test.tsx b/src/components/basic/Icon/Icon.test.tsx new file mode 100644 index 00000000..df032aaa --- /dev/null +++ b/src/components/basic/Icon/Icon.test.tsx @@ -0,0 +1,27 @@ +import { render } from '@testing-library/react' +import Icon from './Icon' + +test('renders the requested icon', () => { + const { container } = render() + expect( + container.querySelector('[data-testid="icon-lock"]'), + ).toBeInTheDocument() +}) + +test('renders nothing for an unknown icon name', () => { + const { container } = render() + expect(container.querySelector('svg')).toBeEmptyDOMElement() +}) + +test('applies size and color', () => { + const { container } = render( + , + ) + const svg = container.querySelector('svg') + expect(svg).toHaveAttribute('width', '32') + expect(svg).toHaveAttribute('stroke', 'red') +}) diff --git a/src/components/basic/Icon/Icon.tsx b/src/components/basic/Icon/Icon.tsx new file mode 100644 index 00000000..1611399f --- /dev/null +++ b/src/components/basic/Icon/Icon.tsx @@ -0,0 +1,194 @@ +import { ReactElement } from 'react' + +interface IconProps { + name: string + size?: number + color?: string + stroke?: number + className?: string +} + +// Line icon set from the design system (doc/ components.jsx). +const paths: Record = { + home: ( + <> + + + + ), + swap: ( + <> + + + + ), + chart: ( + <> + + + + + ), + stake: ( + <> + + + + + ), + settings: ( + <> + + + + ), + arrow_up: ( + <> + + + + ), + arrow_dn: ( + <> + + + + ), + arrow_r: ( + <> + + + + ), + plus: ( + <> + + + + ), + qr: ( + <> + + + + + + + + ), + scan: ( + <> + + + + + + + ), + shield: ( + <> + + + + ), + flash: , + history: ( + <> + + + + + ), + eye: ( + <> + + + + ), + eye_off: ( + <> + + + + + ), + lock: ( + <> + + + + ), + chevron_r: , + chevron_l: , + close: , + card: ( + <> + + + + + ), +} + +const Icon = ({ + name, + size = 20, + color = 'currentColor', + stroke = 1.6, + className, +}: IconProps) => { + return ( + + {paths[name] || null} + + ) +} + +export default Icon diff --git a/src/components/basic/IconTile/IconTile.module.css b/src/components/basic/IconTile/IconTile.module.css new file mode 100644 index 00000000..23610525 --- /dev/null +++ b/src/components/basic/IconTile/IconTile.module.css @@ -0,0 +1,6 @@ +.tile { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} diff --git a/src/components/basic/IconTile/IconTile.test.tsx b/src/components/basic/IconTile/IconTile.test.tsx new file mode 100644 index 00000000..757e5f30 --- /dev/null +++ b/src/components/basic/IconTile/IconTile.test.tsx @@ -0,0 +1,17 @@ +import { render, screen } from '@testing-library/react' +import IconTile from './IconTile' + +test('renders the icon inside a tile', () => { + render() + expect(screen.getByTestId('icon-tile')).toBeInTheDocument() + expect(screen.getByTestId('icon-flash')).toBeInTheDocument() +}) + +test('renders custom children instead of an icon when provided', () => { + render( + + 7 + , + ) + expect(screen.getByTestId('icon-tile')).toHaveTextContent('7') +}) diff --git a/src/components/basic/IconTile/IconTile.tsx b/src/components/basic/IconTile/IconTile.tsx new file mode 100644 index 00000000..aa89316c --- /dev/null +++ b/src/components/basic/IconTile/IconTile.tsx @@ -0,0 +1,47 @@ +import { ReactNode } from 'react' +import styles from './IconTile.module.css' +import Icon from '../Icon/Icon' + +interface IconTileProps { + icon: string + color?: string + size?: number + radius?: number | string + bg?: string + children?: ReactNode +} + +// Colored rounded tile holding an icon (design system). +const IconTile = ({ + icon, + color = 'var(--be-text-1)', + size = 32, + radius = 10, + bg, + children, +}: IconTileProps) => { + return ( +
    + {icon ? ( + + ) : ( + children + )} +
    + ) +} + +export default IconTile diff --git a/src/components/basic/KV/KV.module.css b/src/components/basic/KV/KV.module.css new file mode 100644 index 00000000..931516fd --- /dev/null +++ b/src/components/basic/KV/KV.module.css @@ -0,0 +1,33 @@ +.kvCard { + background: var(--be-bg-1); + border: 1px solid var(--be-line-soft); + border-radius: 14px; + padding: 4px 14px; +} + +.row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 9px 0; +} + +.row + .row { + border-top: 1px solid var(--be-line-soft); +} + +.key { + font-size: var(--font-size-sm); + color: var(--be-text-2); + flex-shrink: 0; +} + +.value { + font-size: var(--font-size-sm); + font-weight: 500; + color: var(--be-text-0); + text-align: right; + word-break: break-all; + font-variant-numeric: tabular-nums; +} diff --git a/src/components/basic/KV/KV.test.tsx b/src/components/basic/KV/KV.test.tsx new file mode 100644 index 00000000..12b4e8cf --- /dev/null +++ b/src/components/basic/KV/KV.test.tsx @@ -0,0 +1,16 @@ +import { render, screen } from '@testing-library/react' +import KV from './KV' + +test('renders key/value rows', () => { + render( + , + ) + expect(screen.getByText('Ticker')).toBeInTheDocument() + expect(screen.getByText('BTC')).toBeInTheDocument() + expect(screen.getByText('Network')).toBeInTheDocument() +}) diff --git a/src/components/basic/KV/KV.tsx b/src/components/basic/KV/KV.tsx new file mode 100644 index 00000000..bf2d719e --- /dev/null +++ b/src/components/basic/KV/KV.tsx @@ -0,0 +1,28 @@ +import { ReactNode } from 'react' +import styles from './KV.module.css' + +interface KVProps { + rows: Array<[string, ReactNode]> +} + +// Key/value list card from the design system. +const KV = ({ rows }: KVProps) => { + return ( +
    + {rows.map(([k, v]) => ( +
    + {k} + {v} +
    + ))} +
    + ) +} + +export default KV diff --git a/src/components/basic/LivePill/LivePill.module.css b/src/components/basic/LivePill/LivePill.module.css new file mode 100644 index 00000000..131e5487 --- /dev/null +++ b/src/components/basic/LivePill/LivePill.module.css @@ -0,0 +1,20 @@ +.pill { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 7px; + border-radius: 6px; + font-size: var(--font-size-xs); + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +.positive { + color: var(--be-green); + background: var(--be-green-soft); +} + +.negative { + color: var(--be-red); + background: oklch(0.7 0.2 25 / 0.12); +} diff --git a/src/components/basic/LivePill/LivePill.test.tsx b/src/components/basic/LivePill/LivePill.test.tsx new file mode 100644 index 00000000..b1909684 --- /dev/null +++ b/src/components/basic/LivePill/LivePill.test.tsx @@ -0,0 +1,26 @@ +import { render, screen } from '@testing-library/react' +import LivePill from './LivePill' + +test('renders positive values with an up marker', () => { + render() + const pill = screen.getByTestId('live-pill') + expect(pill).toHaveTextContent('▲') + expect(pill).toHaveTextContent('2.18%') +}) + +test('renders negative values as absolute with a down marker', () => { + render() + const pill = screen.getByTestId('live-pill') + expect(pill).toHaveTextContent('▼') + expect(pill).toHaveTextContent('2.10%') +}) + +test('supports a custom suffix', () => { + render( + , + ) + expect(screen.getByTestId('live-pill')).toHaveTextContent('1.00 ML') +}) diff --git a/src/components/basic/LivePill/LivePill.tsx b/src/components/basic/LivePill/LivePill.tsx new file mode 100644 index 00000000..23db5cb6 --- /dev/null +++ b/src/components/basic/LivePill/LivePill.tsx @@ -0,0 +1,23 @@ +import styles from './LivePill.module.css' + +interface LivePillProps { + value: number + suffix?: string +} + +// Small +/- 24h change pill from the design system. +const LivePill = ({ value, suffix = '%' }: LivePillProps) => { + const positive = value >= 0 + return ( + + {positive ? '▲' : '▼'} + {Math.abs(value).toFixed(2)} + {suffix} + + ) +} + +export default LivePill diff --git a/src/components/basic/Logo/Logo.css b/src/components/basic/Logo/Logo.css index 54211492..5059b06f 100644 --- a/src/components/basic/Logo/Logo.css +++ b/src/components/basic/Logo/Logo.css @@ -14,7 +14,7 @@ .logoContainer .mojitoLettering { font-size: 1.5rem; - color: rgb(var(--color-black)); + color: var(--be-text-0); } .logoContainer .mojitoLettering .testnetMark { diff --git a/src/components/basic/MojitoLogo/MojitoLogo.module.css b/src/components/basic/MojitoLogo/MojitoLogo.module.css new file mode 100644 index 00000000..40a5071d --- /dev/null +++ b/src/components/basic/MojitoLogo/MojitoLogo.module.css @@ -0,0 +1,8 @@ +.orbit { + position: absolute; + inset: -4px; + border-radius: 50%; + border: 1px dashed oklch(0.82 0.16 70 / 0.35); + animation: be-spin-slow 18s linear infinite; + pointer-events: none; +} diff --git a/src/components/basic/MojitoLogo/MojitoLogo.test.tsx b/src/components/basic/MojitoLogo/MojitoLogo.test.tsx new file mode 100644 index 00000000..e0c88e8b --- /dev/null +++ b/src/components/basic/MojitoLogo/MojitoLogo.test.tsx @@ -0,0 +1,20 @@ +import { render, screen } from '@testing-library/react' +import MojitoLogo from './MojitoLogo' + +test('renders the logo svg', () => { + render() + expect(screen.getByTestId('mojito-logo')).toBeInTheDocument() +}) + +test('applies the given size', () => { + render() + expect(screen.getByTestId('mojito-logo')).toHaveAttribute('width', '88') + expect(screen.getByTestId('mojito-logo')).toHaveAttribute('height', '88') +}) + +test('orbit ring renders when animate is true and not when false', () => { + const { rerender, container } = render() + expect(container.querySelector('.orbit')).toBeInTheDocument() + rerender() + expect(container.querySelector('.orbit')).not.toBeInTheDocument() +}) diff --git a/src/components/basic/MojitoLogo/MojitoLogo.tsx b/src/components/basic/MojitoLogo/MojitoLogo.tsx new file mode 100644 index 00000000..5a0ffecf --- /dev/null +++ b/src/components/basic/MojitoLogo/MojitoLogo.tsx @@ -0,0 +1,93 @@ +import styles from './MojitoLogo.module.css' + +interface MojitoLogoProps { + size?: number + animate?: boolean +} + +const MojitoLogo = ({ size = 48, animate = true }: MojitoLogoProps) => { + const gradientId = `be-logo-amber-${size}` + const gradientIdTeal = `be-logo-teal-${size}` + return ( +
    + + + + + + + + + + + + + + + + + {animate &&
    } +
    + ) +} + +export default MojitoLogo diff --git a/src/components/basic/OptionCard/OptionCard.module.css b/src/components/basic/OptionCard/OptionCard.module.css index fa6ac583..b03b1781 100644 --- a/src/components/basic/OptionCard/OptionCard.module.css +++ b/src/components/basic/OptionCard/OptionCard.module.css @@ -4,7 +4,7 @@ gap: 8px; padding: 24px; border-radius: 20px; - background: rgb(var(--color-white)); + background: var(--be-bg-1); border: 1px solid rgba(var(--color-light-gray), 0.4); cursor: pointer; transition: @@ -37,7 +37,7 @@ .title { font-size: 16px; font-weight: 700; - color: rgb(var(--color-black)); + color: var(--be-text-0); } .description { diff --git a/src/components/basic/Progress/Progress.module.css b/src/components/basic/Progress/Progress.module.css new file mode 100644 index 00000000..5e3c00eb --- /dev/null +++ b/src/components/basic/Progress/Progress.module.css @@ -0,0 +1,18 @@ +.track { + flex: 1; + display: flex; + gap: 4px; +} + +.segment { + flex: 1; + height: 3px; + border-radius: 999px; + background: oklch(1 0 0 / 0.07); + transition: all 300ms ease; +} + +.done { + background: var(--be-amber); + box-shadow: 0 0 8px var(--be-amber); +} diff --git a/src/components/basic/Progress/Progress.test.tsx b/src/components/basic/Progress/Progress.test.tsx new file mode 100644 index 00000000..d3f7ef00 --- /dev/null +++ b/src/components/basic/Progress/Progress.test.tsx @@ -0,0 +1,28 @@ +import { render, screen } from '@testing-library/react' +import Progress from './Progress' + +test('renders the track', () => { + render() + expect(screen.getByTestId('progress-track')).toBeInTheDocument() +}) + +test('renders the given number of segments', () => { + const { container } = render( + , + ) + expect(container.querySelectorAll('div[class*="segment"]').length).toBe(4) +}) + +test('marks completed segments as done', () => { + const { container } = render( + , + ) + const doneSegments = container.querySelectorAll('div[class*="done"]') + expect(doneSegments.length).toBe(2) +}) diff --git a/src/components/basic/Progress/Progress.tsx b/src/components/basic/Progress/Progress.tsx new file mode 100644 index 00000000..8cb7a81a --- /dev/null +++ b/src/components/basic/Progress/Progress.tsx @@ -0,0 +1,24 @@ +import styles from './Progress.module.css' + +interface ProgressProps { + step: number + total?: number +} + +const Progress = ({ step, total = 4 }: ProgressProps) => { + return ( +
    + {Array.from({ length: total }, (_, i) => ( +
    + ))} +
    + ) +} + +export default Progress diff --git a/src/components/basic/QrCode/QrCode.module.css b/src/components/basic/QrCode/QrCode.module.css new file mode 100644 index 00000000..ada804b2 --- /dev/null +++ b/src/components/basic/QrCode/QrCode.module.css @@ -0,0 +1,8 @@ +.tile { + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 12px; + background: #ffffff; + box-shadow: 0 8px 24px oklch(0 0 0 / 0.3); +} diff --git a/src/components/basic/QrCode/QrCode.test.tsx b/src/components/basic/QrCode/QrCode.test.tsx new file mode 100644 index 00000000..a03fe264 --- /dev/null +++ b/src/components/basic/QrCode/QrCode.test.tsx @@ -0,0 +1,14 @@ +import { render } from '@testing-library/react' +import QrCode from './QrCode' + +test('renders a qr svg for the given value', () => { + const { container, getByTestId } = render( + , + ) + + expect(getByTestId('qr-code')).toBeInTheDocument() + expect(container.querySelector('svg')).toBeInTheDocument() +}) diff --git a/src/components/basic/QrCode/QrCode.tsx b/src/components/basic/QrCode/QrCode.tsx new file mode 100644 index 00000000..c1bd39ef --- /dev/null +++ b/src/components/basic/QrCode/QrCode.tsx @@ -0,0 +1,28 @@ +import QRCode from 'react-qr-code' +import styles from './QrCode.module.css' + +interface QrCodeProps { + value: string + size?: number +} + +// Real QR code on a white padded tile so scanners can read the payload +// against the dark UI. +const QrCode = ({ value, size = 168 }: QrCodeProps) => { + return ( +
    + +
    + ) +} + +export default QrCode diff --git a/src/components/basic/QrPlaceholder/QrPlaceholder.module.css b/src/components/basic/QrPlaceholder/QrPlaceholder.module.css new file mode 100644 index 00000000..714f667d --- /dev/null +++ b/src/components/basic/QrPlaceholder/QrPlaceholder.module.css @@ -0,0 +1,18 @@ +.qr { + margin: 0 auto; + border-radius: 18px; + display: flex; + align-items: center; + justify-content: center; + text-align: center; + font-size: var(--font-size-xs); + color: var(--be-text-3); + background: repeating-linear-gradient( + 45deg, + oklch(1 0 0 / 0.05), + oklch(1 0 0 / 0.05) 6px, + oklch(1 0 0 / 0.02) 6px, + oklch(1 0 0 / 0.02) 12px + ); + border: 1px solid var(--be-line); +} diff --git a/src/components/basic/QrPlaceholder/QrPlaceholder.test.tsx b/src/components/basic/QrPlaceholder/QrPlaceholder.test.tsx new file mode 100644 index 00000000..492c3c50 --- /dev/null +++ b/src/components/basic/QrPlaceholder/QrPlaceholder.test.tsx @@ -0,0 +1,14 @@ +import { render, screen } from '@testing-library/react' +import QrPlaceholder from './QrPlaceholder' + +test('QrPlaceholder renders its label at the requested size', () => { + render( + , + ) + const qr = screen.getByTestId('qr-placeholder') + expect(qr).toHaveTextContent('QR · Bitcoin address') + expect(qr).toHaveStyle({ width: '172px' }) +}) diff --git a/src/components/basic/QrPlaceholder/QrPlaceholder.tsx b/src/components/basic/QrPlaceholder/QrPlaceholder.tsx new file mode 100644 index 00000000..362ea149 --- /dev/null +++ b/src/components/basic/QrPlaceholder/QrPlaceholder.tsx @@ -0,0 +1,25 @@ +import styles from './QrPlaceholder.module.css' + +interface QrPlaceholderProps { + size?: number + label?: string +} + +// Striped labelled placeholder — the real QR is rendered by the extension +// (see doc/server-requirements.md). +const QrPlaceholder = ({ + size = 168, + label = 'QR code', +}: QrPlaceholderProps) => { + return ( +
    + {label} +
    + ) +} + +export default QrPlaceholder diff --git a/src/components/basic/Seg/Seg.module.css b/src/components/basic/Seg/Seg.module.css new file mode 100644 index 00000000..e7e2b98a --- /dev/null +++ b/src/components/basic/Seg/Seg.module.css @@ -0,0 +1,25 @@ +.seg { + display: inline-flex; + gap: 2px; + padding: 3px; + border-radius: 10px; + background: oklch(1 0 0 / 0.04); + border: 1px solid var(--be-line-soft); +} + +.item { + border: none; + background: transparent; + color: var(--be-text-2); + font-size: var(--font-size-sm); + font-weight: 600; + padding: 5px 12px; + border-radius: 8px; + cursor: pointer; + transition: all 150ms ease; +} + +.on { + background: var(--be-amber-soft); + color: var(--be-amber); +} diff --git a/src/components/basic/Seg/Seg.test.tsx b/src/components/basic/Seg/Seg.test.tsx new file mode 100644 index 00000000..b39346a9 --- /dev/null +++ b/src/components/basic/Seg/Seg.test.tsx @@ -0,0 +1,39 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import Seg from './Seg' + +const setup = (value = 'All') => { + const onChange = jest.fn() + const utils = render( + , + ) + return { onChange, container: utils.container } +} + +test('renders all options', () => { + render( + {}} + />, + ) + expect(screen.getByText('All')).toBeInTheDocument() + expect(screen.getByText('BTC')).toBeInTheDocument() + expect(screen.getByText('ML')).toBeInTheDocument() +}) + +test('marks the active option', () => { + const { container } = setup('BTC') + const active = container.querySelector('button[class*="on"]') + expect(active).toHaveTextContent('BTC') +}) + +test('calls onChange with the clicked value', () => { + const { onChange } = setup() + fireEvent.click(screen.getByText('ML')) + expect(onChange).toHaveBeenCalledWith('ML') +}) diff --git a/src/components/basic/Seg/Seg.tsx b/src/components/basic/Seg/Seg.tsx new file mode 100644 index 00000000..3b50d53d --- /dev/null +++ b/src/components/basic/Seg/Seg.tsx @@ -0,0 +1,33 @@ +import styles from './Seg.module.css' + +interface SegProps { + value: string + options: Array + onChange: (value: string) => void +} + +// Segmented control from the design system. +const Seg = ({ value, options, onChange }: SegProps) => { + return ( +
    + {options.map((o) => { + const [v, l] = Array.isArray(o) ? o : [o, o] + return ( + + ) + })} +
    + ) +} + +export default Seg diff --git a/src/components/basic/Sheet/Sheet.module.css b/src/components/basic/Sheet/Sheet.module.css new file mode 100644 index 00000000..20b60547 --- /dev/null +++ b/src/components/basic/Sheet/Sheet.module.css @@ -0,0 +1,39 @@ +.backdrop { + position: absolute; + inset: 0; + background: oklch(0 0 0 / 0.55); + animation: be-fade-in 200ms ease forwards; +} + +.panel { + position: absolute; + left: 0; + right: 0; + bottom: 0; + max-height: 88%; + display: flex; + flex-direction: column; + background: var(--be-bg-1); + border-top: 1px solid var(--be-line-soft); + border-radius: 20px 20px 0 0; + padding: 10px 18px 18px; + animation: sheet-up 260ms cubic-bezier(0.2, 1, 0.4, 1) forwards; +} + +.handle { + width: 36px; + height: 4px; + border-radius: 999px; + background: var(--be-line); + margin: 0 auto 12px; + flex-shrink: 0; +} + +@keyframes sheet-up { + from { + transform: translateY(100%); + } + to { + transform: translateY(0); + } +} diff --git a/src/components/basic/Sheet/Sheet.test.tsx b/src/components/basic/Sheet/Sheet.test.tsx new file mode 100644 index 00000000..272185a4 --- /dev/null +++ b/src/components/basic/Sheet/Sheet.test.tsx @@ -0,0 +1,30 @@ +import { fireEvent, render } from '@testing-library/react' +import Sheet from './Sheet' + +test('renders nothing when closed', () => { + const { container } = render( + {}} + > +

    content

    +
    , + ) + expect(container).toBeEmptyDOMElement() +}) + +test('renders children when open and closes on backdrop click', () => { + const onClose = jest.fn() + render( + +

    sheet content

    +
    , + ) + expect(document.body).toHaveTextContent('sheet content') + const backdrop = document.body.querySelector('[class*="backdrop"]') + fireEvent.click(backdrop) + expect(onClose).toHaveBeenCalledTimes(1) +}) diff --git a/src/components/basic/Sheet/Sheet.tsx b/src/components/basic/Sheet/Sheet.tsx new file mode 100644 index 00000000..f4ff4e65 --- /dev/null +++ b/src/components/basic/Sheet/Sheet.tsx @@ -0,0 +1,33 @@ +import { ReactNode } from 'react' +import styles from './Sheet.module.css' +import { createPortal } from 'react-dom' + +interface SheetProps { + open: boolean + onClose: () => void + label?: string + children: ReactNode +} + +// Bottom sheet primitive from the design system: overlay + panel + handle. +const Sheet = ({ open, onClose, label, children }: SheetProps) => { + if (!open) return null + return createPortal( +
    +
    +
    +
    + {children} +
    +
    , + document.body, + ) +} + +export default Sheet diff --git a/src/components/basic/SiteBadge/SiteBadge.module.css b/src/components/basic/SiteBadge/SiteBadge.module.css index 36137ac9..11270962 100644 --- a/src/components/basic/SiteBadge/SiteBadge.module.css +++ b/src/components/basic/SiteBadge/SiteBadge.module.css @@ -6,8 +6,8 @@ max-width: 100%; padding: var(--space-xs) var(--space-md); border-radius: var(--round-size-big); - border: 1px solid rgba(var(--color-main-green), 0.35); - background: rgba(var(--color-main-green), 0.1); + border: 1px solid var(--be-line); + background: oklch(1 0 0 / 0.06); } .dot { @@ -15,22 +15,22 @@ height: 8px; flex-shrink: 0; border-radius: 50%; - background: rgb(var(--mojito-green)); + background: var(--be-teal); } .origin { font-size: var(--font-size-sm); font-weight: 600; - color: rgb(var(--color-black)); + color: var(--be-text-0); overflow-wrap: anywhere; line-height: 1.3; } .unknown { - border-color: rgba(var(--color-orange), 0.4); - background: rgba(var(--color-orange), 0.12); + border-color: oklch(0.82 0.16 70 / 0.4); + background: var(--be-amber-soft); } .unknown .dot { - background: rgb(var(--color-orange)); + background: var(--be-amber); } diff --git a/src/components/basic/SkeletonLoader/SkeletonLoader.css b/src/components/basic/SkeletonLoader/SkeletonLoader.css index 614fdf1f..c79cf0ec 100644 --- a/src/components/basic/SkeletonLoader/SkeletonLoader.css +++ b/src/components/basic/SkeletonLoader/SkeletonLoader.css @@ -3,10 +3,10 @@ align-items: center; padding: 12px 30px 12px 18px; margin-bottom: 0.75rem; - background-color: rgb(var(--color-gray)); - border: 1px solid rgba(var(--color-light-green), 0.2); + background-color: var(--be-bg-1); + border: 1px solid var(--be-line-soft); border-radius: 45px; - color: rgb(var(--color-white)); + color: var(--be-text-1); } .card-compact { @@ -30,10 +30,10 @@ @keyframes skeleton-loading { 0% { - background: rgba(var(--color-main-green), 0.1); + background: oklch(1 0 0 / 0.06); } 100% { - background: rgba(var(--color-main-green), 0.2); + background: oklch(1 0 0 / 0.14); } } diff --git a/src/components/basic/Sparkline/Sparkline.test.tsx b/src/components/basic/Sparkline/Sparkline.test.tsx new file mode 100644 index 00000000..8a790aaa --- /dev/null +++ b/src/components/basic/Sparkline/Sparkline.test.tsx @@ -0,0 +1,20 @@ +import { render, screen } from '@testing-library/react' +import Sparkline from './Sparkline' + +test('renders an svg polyline for the data', () => { + const { container } = render() + expect(screen.getByTestId('sparkline')).toBeInTheDocument() + expect(container.querySelector('polyline')).toBeInTheDocument() +}) + +test('applies the requested size', () => { + render( + , + ) + expect(screen.getByTestId('sparkline')).toHaveAttribute('width', '44') + expect(screen.getByTestId('sparkline')).toHaveAttribute('height', '20') +}) diff --git a/src/components/basic/Sparkline/Sparkline.tsx b/src/components/basic/Sparkline/Sparkline.tsx new file mode 100644 index 00000000..cc8ef852 --- /dev/null +++ b/src/components/basic/Sparkline/Sparkline.tsx @@ -0,0 +1,50 @@ +interface SparklineProps { + data: number[] + color?: string + width?: number + height?: number + // Stretch to the container width instead of the fixed `width` — needed for + // full-width chart cards in the narrow side panel. + responsive?: boolean +} + +// Tiny inline line chart from the design system. Pure SVG, no chart lib. +const Sparkline = ({ + data, + color = 'var(--be-amber)', + width = 70, + height = 28, + responsive = false, +}: SparklineProps) => { + const min = Math.min(...data) + const max = Math.max(...data) + const range = max - min || 1 + const pts = data + .map( + (v, i) => + `${(i / (data.length - 1)) * width},${height - ((v - min) / range) * height}`, + ) + .join(' ') + return ( + + + + ) +} + +export default Sparkline diff --git a/src/components/basic/Tag/Tag.module.css b/src/components/basic/Tag/Tag.module.css new file mode 100644 index 00000000..52b2986e --- /dev/null +++ b/src/components/basic/Tag/Tag.module.css @@ -0,0 +1,39 @@ +.tag { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 6px; + font-size: var(--font-size-xs); + font-weight: 600; + letter-spacing: 0.03em; +} + +.grey { + color: var(--be-text-2); + background: oklch(1 0 0 / 0.06); +} + +.amber { + color: var(--be-amber); + background: var(--be-amber-soft); +} + +.teal { + color: var(--be-teal); + background: var(--be-teal-soft); +} + +.green { + color: var(--be-green); + background: var(--be-green-soft); +} + +.violet { + color: var(--be-violet); + background: var(--be-violet-soft); +} + +.red { + color: var(--be-red); + background: oklch(0.7 0.2 25 / 0.14); +} diff --git a/src/components/basic/Tag/Tag.test.tsx b/src/components/basic/Tag/Tag.test.tsx new file mode 100644 index 00000000..05a2578a --- /dev/null +++ b/src/components/basic/Tag/Tag.test.tsx @@ -0,0 +1,17 @@ +import { render, screen } from '@testing-library/react' +import Tag from './Tag' + +test('renders children', () => { + render(Active) + expect(screen.getByTestId('tag')).toHaveTextContent('Active') +}) + +test('defaults to grey color variant', () => { + const { container } = render(Hi) + expect(container.firstChild).toHaveClass('grey') +}) + +test('applies the requested color variant', () => { + const { container } = render(Token) + expect(container.firstChild).toHaveClass('amber') +}) diff --git a/src/components/basic/Tag/Tag.tsx b/src/components/basic/Tag/Tag.tsx new file mode 100644 index 00000000..64003a01 --- /dev/null +++ b/src/components/basic/Tag/Tag.tsx @@ -0,0 +1,21 @@ +import { ReactNode } from 'react' +import styles from './Tag.module.css' + +interface TagProps { + c?: 'grey' | 'amber' | 'teal' | 'green' | 'violet' | 'red' + children: ReactNode +} + +// Small colored label pill from the design system. +const Tag = ({ c = 'grey', children }: TagProps) => { + return ( + + {children} + + ) +} + +export default Tag diff --git a/src/components/basic/Toggle/Toggle.css b/src/components/basic/Toggle/Toggle.css index e78f9628..da3cd48c 100644 --- a/src/components/basic/Toggle/Toggle.css +++ b/src/components/basic/Toggle/Toggle.css @@ -18,7 +18,7 @@ left: 0; right: 0; bottom: 0; - background: #2c3e50; + background: var(--be-line); transition: 0.3s; border-radius: 30px; } @@ -36,11 +36,11 @@ } .toggleInput:checked + .toggleMark { - background-color: rgb(var(--color-main-green)); + background-color: var(--be-amber); } .toggleInput:checked + .toggleMark:before { - background-color: rgb(var(--color-white)); + background-color: var(--be-bg-1); transform: translateX(29px); } diff --git a/src/components/basic/Tooltip/Tooltip.css b/src/components/basic/Tooltip/Tooltip.css index 132b95b2..9af8f971 100644 --- a/src/components/basic/Tooltip/Tooltip.css +++ b/src/components/basic/Tooltip/Tooltip.css @@ -9,7 +9,7 @@ padding: 4px 8px; background: rgba(var(--color-main-green), 0.2); border-radius: 5px; - color: rgb(var(--color-black)); + color: var(--be-text-0); font-size: 0.8rem; transition: opacity 0.7s ease; z-index: 100000; diff --git a/src/components/basic/index.js b/src/components/basic/index.js index a01e3214..ef3f736c 100644 --- a/src/components/basic/index.js +++ b/src/components/basic/index.js @@ -20,6 +20,24 @@ import BrandPanel from './BrandPanel/BrandPanel' import BrandBottomLogo from './BrandBottomLogo/BrandBottomLogo' import OptionCard from './OptionCard/OptionCard' import SiteBadge from './SiteBadge/SiteBadge.tsx' +import MojitoLogo from './MojitoLogo/MojitoLogo' +import Progress from './Progress/Progress' +import Icon from './Icon/Icon' +import Tag from './Tag/Tag' +import Seg from './Seg/Seg' +import LivePill from './LivePill/LivePill' +import Sparkline from './Sparkline/Sparkline' +import TokenIcon from './TokenIcon/TokenIcon' +import Eyebrow from './Eyebrow/Eyebrow' +import IconTile from './IconTile/IconTile' +import Avatar from './Avatar/Avatar' +import Counter from './Counter/Counter' +import Sheet from './Sheet/Sheet' +import KV from './KV/KV' +import QrPlaceholder from './QrPlaceholder/QrPlaceholder' +import QrCode from './QrCode/QrCode' +import ChainBadge from './ChainBadge/ChainBadge' +import ErrorBoundary from './ErrorBoundary/ErrorBoundary' export { Arc, @@ -44,4 +62,22 @@ export { BrandBottomLogo, OptionCard, SiteBadge, + MojitoLogo, + Progress, + Icon, + Tag, + Seg, + LivePill, + Sparkline, + TokenIcon, + Eyebrow, + IconTile, + Avatar, + Counter, + Sheet, + KV, + QrPlaceholder, + QrCode, + ChainBadge, + ErrorBoundary, } diff --git a/src/components/composed/AssetRow/AssetRow.module.css b/src/components/composed/AssetRow/AssetRow.module.css new file mode 100644 index 00000000..045f8255 --- /dev/null +++ b/src/components/composed/AssetRow/AssetRow.module.css @@ -0,0 +1,54 @@ +.row { + display: flex; + align-items: center; + gap: 12px; + padding: 11px 14px; + cursor: pointer; + border-radius: 14px; + transition: background 150ms ease; + animation: be-slide-up 400ms both; +} + +.row:hover { + background: oklch(1 0 0 / 0.03); +} + +.detail { + flex: 1; + min-width: 0; +} + +.titleLine { + display: flex; + align-items: center; + gap: 6px; +} + +.title { + font-size: var(--font-size-sm); + font-weight: 600; + color: var(--be-text-0); +} + +.sub { + font-family: var(--be-font-mono); + font-size: 11px; + color: var(--be-text-2); + margin-top: 2px; +} + +.fiatSide { + text-align: right; + min-width: 76px; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 2px; +} + +.fiat { + font-size: var(--font-size-sm); + font-weight: 600; + color: var(--be-text-0); + font-variant-numeric: tabular-nums; +} diff --git a/src/components/composed/AssetRow/AssetRow.test.tsx b/src/components/composed/AssetRow/AssetRow.test.tsx new file mode 100644 index 00000000..2b7489a2 --- /dev/null +++ b/src/components/composed/AssetRow/AssetRow.test.tsx @@ -0,0 +1,32 @@ +import { render, screen } from '@testing-library/react' +import AssetRow from './AssetRow' + +const asset = { + id: 'Bitcoin', + name: 'Bitcoin', + symbol: 'BTC', + chain: 'Bitcoin', + amount: 0.0482, + price: 96420.32, + change24h: 2.18, + spark: [1, 2, 3, 2, 4], +} + +test('renders name with symbol (E2E contract)', () => { + render() + expect(screen.getByText('Bitcoin (BTC)')).toBeInTheDocument() +}) + +test('renders fiat value and change pill', () => { + const { container } = render() + const fiat = container.querySelector('.fiat') + expect(fiat?.textContent).toContain('4,647.46') + expect(screen.getByTestId('live-pill')).toHaveTextContent('2.18%') +}) + +test('marks mock assets with a Demo tag', () => { + render( + , + ) + expect(screen.getByText('Demo')).toBeInTheDocument() +}) diff --git a/src/components/composed/BeSheet/BeSheet.module.css b/src/components/composed/BeSheet/BeSheet.module.css new file mode 100644 index 00000000..7b7224dc --- /dev/null +++ b/src/components/composed/BeSheet/BeSheet.module.css @@ -0,0 +1,10 @@ +.title { + font-size: 15px; + font-weight: 600; + margin-bottom: 12px; + color: var(--be-text-0); +} + +.scroll { + overflow-y: auto; +} diff --git a/src/components/composed/BeSheet/BeSheet.test.tsx b/src/components/composed/BeSheet/BeSheet.test.tsx new file mode 100644 index 00000000..5a7ce2f3 --- /dev/null +++ b/src/components/composed/BeSheet/BeSheet.test.tsx @@ -0,0 +1,44 @@ +import { render, fireEvent } from '@testing-library/react' +import BeSheet from './BeSheet' + +test('renders title and children when open', () => { + render( + {}} + title="Transaction detail" + > +

    body

    +
    , + ) + expect(document.body.querySelector('[class*="title"]')).toHaveTextContent( + 'Transaction detail', + ) + expect(document.body).toHaveTextContent('body') +}) + +test('renders nothing when closed', () => { + const { container } = render( + {}} + > +

    body

    +
    , + ) + expect(container).toBeEmptyDOMElement() +}) + +test('backdrop click closes the sheet', () => { + const onClose = jest.fn() + render( + +

    body

    +
    , + ) + fireEvent.click(document.body.querySelector('[class*="backdrop"]')) + expect(onClose).toHaveBeenCalledTimes(1) +}) diff --git a/src/components/composed/BeSheet/BeSheet.tsx b/src/components/composed/BeSheet/BeSheet.tsx new file mode 100644 index 00000000..21d816fb --- /dev/null +++ b/src/components/composed/BeSheet/BeSheet.tsx @@ -0,0 +1,28 @@ +import { ReactNode } from 'react' +import styles from './BeSheet.module.css' +import Sheet from '../../basic/Sheet/Sheet' + +interface BeSheetProps { + open: boolean + onClose: () => void + title?: string + label?: string + children: ReactNode +} + +// Titled, scrollable bottom sheet (design system). Composes the Sheet basic. +const BeSheet = ({ open, onClose, title, label, children }: BeSheetProps) => { + if (!open) return null + return ( + + {title &&
    {title}
    } +
    {children}
    +
    + ) +} + +export default BeSheet diff --git a/src/components/composed/TxRow/TxRow.module.css b/src/components/composed/TxRow/TxRow.module.css new file mode 100644 index 00000000..5d68a6f1 --- /dev/null +++ b/src/components/composed/TxRow/TxRow.module.css @@ -0,0 +1,57 @@ +.row { + display: flex; + align-items: center; + gap: 12px; + padding: 11px 14px; + cursor: pointer; + border-radius: 14px; + transition: background 150ms ease; +} + +.row:hover { + background: oklch(1 0 0 / 0.03); +} + +.detail { + flex: 1; + min-width: 0; +} + +.label { + font-size: var(--font-size-sm); + font-weight: 500; + color: var(--be-text-0); +} + +.dim { + color: var(--be-text-2); +} + +.sub { + font-family: var(--be-font-mono); + font-size: 10px; + color: var(--be-text-3); + margin-top: 2px; +} + +.right { + text-align: right; + flex-shrink: 0; +} + +.amount { + font-family: var(--be-font-mono); + font-size: var(--font-size-sm); + font-weight: 600; + white-space: nowrap; + color: var(--be-text-0); +} + +.in { + color: var(--be-green); +} + +.status { + font-size: 10px; + margin-top: 2px; +} diff --git a/src/components/composed/TxRow/TxRow.test.tsx b/src/components/composed/TxRow/TxRow.test.tsx new file mode 100644 index 00000000..cf2d2718 --- /dev/null +++ b/src/components/composed/TxRow/TxRow.test.tsx @@ -0,0 +1,33 @@ +import { render, screen } from '@testing-library/react' +import TxRow from './TxRow' + +const tx = { + type: 'receive' as const, + sym: 'BTC', + amount: 0.0052, + when: 'Today, 09:14', + status: 'Confirming', + conf: '2/6', +} + +test('renders label, amount and status', () => { + render() + expect(screen.getByText('Received')).toBeInTheDocument() + expect(screen.getByText('+0.0052 BTC')).toBeInTheDocument() + expect(screen.getByText('Confirming')).toBeInTheDocument() +}) + +test('renders outgoing amounts with a minus sign', () => { + render() + expect(screen.getByText('Sent')).toBeInTheDocument() + expect(screen.getByText('−120 ML')).toBeInTheDocument() +}) + +test('renders the NFT name for nft type', () => { + render( + , + ) + expect(screen.getByText('Cryptobeat #341')).toBeInTheDocument() +}) diff --git a/src/components/composed/TxRow/TxRow.tsx b/src/components/composed/TxRow/TxRow.tsx new file mode 100644 index 00000000..3408d8f9 --- /dev/null +++ b/src/components/composed/TxRow/TxRow.tsx @@ -0,0 +1,86 @@ +import styles from './TxRow.module.css' +import IconTile from '../../basic/IconTile/IconTile' + +export interface DesignTx { + id?: string | number + type: 'receive' | 'send' | 'mint' | 'nft' | 'dapp' | 'burn' | 'swap' + sym: string + chain?: string + amount?: number + usd?: number + when?: string + status?: string + conf?: string + hash?: string + to?: string + from?: string + name?: string + mock?: boolean + onClick?: () => void +} + +const TYPE_META: Record< + string, + { icon: string; color: string; label: string } +> = { + receive: { icon: 'arrow_dn', color: 'var(--be-green)', label: 'Received' }, + send: { icon: 'arrow_up', color: 'var(--be-amber)', label: 'Sent' }, + mint: { icon: 'plus', color: 'var(--be-teal)', label: 'Minted' }, + nft: { icon: 'card', color: 'var(--be-violet)', label: 'NFT received' }, + dapp: { icon: 'flash', color: 'var(--be-violet)', label: 'dApp payment' }, + burn: { icon: 'flash', color: 'var(--be-red)', label: 'Burned' }, + swap: { icon: 'swap', color: 'var(--be-teal)', label: 'Swap' }, +} + +// Design-system transaction row (doc/ be-home.jsx TxRow). +const TxRow = ({ t }: { t: DesignTx }) => { + const meta = TYPE_META[t.type] || TYPE_META.send + const statusColor = + t.status === 'Confirmed' + ? 'var(--be-text-2)' + : t.status === 'Failed' + ? 'var(--be-red)' + : 'var(--be-amber)' + const incoming = t.type === 'receive' || t.type === 'mint' + return ( +
    + +
    +
    + {meta.label} + {t.type === 'dapp' && · {t.to}} +
    +
    + {t.when} + {t.conf ? ` · ${t.conf} conf` : ''} +
    +
    +
    +
    + {t.type === 'nft' + ? t.name + : t.amount != null + ? `${incoming ? '+' : '−'}${t.amount} ${t.sym}` + : '—'} +
    + {t.status && ( +
    + {t.status} +
    + )} +
    +
    + ) +} + +export default TxRow diff --git a/src/components/composed/index.js b/src/components/composed/index.js index fb97010f..7e64a99a 100644 --- a/src/components/composed/index.js +++ b/src/components/composed/index.js @@ -16,7 +16,6 @@ import FeeFieldML from './FeeField/FeeFieldML' import ConnectionErrorPopup from './ConnectionErrorPopup/ConnectionErrorPopup' import WalletList from './WalletList/WalletList' import AddWallet from './AddWallet/AddWallet' -import CurrentStaking from './CurrentStaking/CurrentStaking' import HelpTooltip from './HelpTooltip/HelpTooltip' import RestoreSeedField from './RestoreSeedField/RestoreSeedField' import UpdateButton from './UpdateButton/UpdateButton' @@ -33,6 +32,9 @@ import WalletHeader from './WalletHeader/WalletHeader' import Sidebar from './Sidebar/Sidebar.tsx' import SendPageHeader from './SendPageHeader/SendPageHeader' import WalletCard from './WalletCard/WalletCard' +import BeSheet from './BeSheet/BeSheet' +import TxRow from './TxRow/TxRow' +import AssetRow from './AssetRow/AssetRow' export { Balance, @@ -53,7 +55,6 @@ export { ConnectionErrorPopup, WalletList, AddWallet, - CurrentStaking, HelpTooltip, RestoreSeedField, UpdateButton, @@ -70,4 +71,7 @@ export { Sidebar, SendPageHeader, WalletCard, + BeSheet, + TxRow, + AssetRow, } diff --git a/src/components/containers/index.js b/src/components/containers/index.js index b87a8ccb..61249da6 100644 --- a/src/components/containers/index.js +++ b/src/components/containers/index.js @@ -23,10 +23,6 @@ import OrderDetails from './Wallet/Orders/OrderDetails/OrderDetails' import SendBtcTransaction from './SendTransaction/SendBtcTransaction' import SendMlTransaction from './SendTransaction/SendMlTransaction' -import CryptoSharesChart from './Dashboard/CryptoSharesChart' -import Statistics from './Dashboard/Statistics' -import CryptoList from './Dashboard/CryptoList' - import DeleteAccount from './DeleteAccount/DeleteAccount' import SettingsDelete from './Settings/SettingsDelete/SettingsDelete' import SettingsTestnet from './Settings/SettingsTestnet/SettingsTestnet.tsx' @@ -63,13 +59,6 @@ const Login = { SetPassword, } -/* istanbul ignore next */ -const Dashboard = { - CryptoSharesChart, - Statistics, - CryptoList, -} - const Settings = { SettingsAbout, SettingsTestnet, @@ -103,7 +92,6 @@ export { Login, SendBtcTransaction, SendMlTransaction, - Dashboard, Settings, Message, SignTransaction, From 3aa2e52dfde461011a3d1f084c3f5875b8618a31 Mon Sep 17 00:00:00 2001 From: Enrico Rubboli Date: Tue, 8 Sep 2026 11:10:37 +0200 Subject: [PATCH 19/52] feat(screens): Dashboard/Activity/Receive/Asset/Stake screens on real data - Dashboard: total balance, 4 quick actions (Send/Receive/Stake/Activity), real token list from tokenBalances (metadata icons), honest empty states (no more mock NFTs or demo activity rows) - AssetPage: BTC/ML/token detail from tokenBalances (ticker/decimals/icon), token-scoped transactions, no fabricated price data for tokens - StakePage: total staked, earned-from-staking, stake-growth chart (buildStakeGrowthSeries), delegation list with add-funds/withdraw - ActivityPage: real merged BTC+ML history with detail sheet - ReceivePage: chain-aware addresses with QR - routes: /staking new page; /wallet/:coinType and unknown routes redirect to /dashboard; legacy Wallet and Staking pages removed; all navigate('/wallet') dead-ends repointed; ConnectionPage test added --- src/index.js | 266 ++++++++++-------- src/pages/ActivityPage/ActivityPage.js | 124 ++++++++ .../ActivityPage/ActivityPage.module.css | 67 +++++ src/pages/AddressPage/AddressPage.module.css | 20 +- src/pages/AssetPage/AssetPage.module.css | 141 ++++++++++ .../ConfirmBtcTransaction.js | 4 +- .../ConfirmBtcTransaction.module.css | 12 +- .../BitcoinDataNotice.module.css | 16 +- .../ConnectionPage/ConnectionPage.module.css | 34 ++- .../ConnectionPage/ConnectionPage.test.js | 203 +++++++++++++ .../ConnectionPage/PermissionItem.module.css | 8 +- .../CreateAccount/CreateAccount.module.css | 2 +- .../CreateDelegation/CreateDelegation.css | 2 +- .../CreateDelegation/CreateDelegation.js | 4 +- src/pages/CreateRestore/CreateRestore.js | 58 ++-- .../CreateRestore/CreateRestore.module.css | 211 +++++++------- src/pages/Dashboard/Dashboard.css | 19 -- src/pages/Dashboard/Dashboard.module.css | 259 +++++++++++++++++ src/pages/DelegationStake/DelegationStake.js | 4 +- .../DelegationWithdraw/DelegationWithdraw.js | 4 +- src/pages/Login/Login.module.css | 5 +- src/pages/Login/SetAccountPassword.module.css | 4 +- src/pages/MessagePage/MessagePage.css | 4 +- src/pages/NftSend/NftSend.js | 2 +- src/pages/OrderSwap/OrderSwap.js | 2 +- src/pages/OrderSwap/OrderSwap.module.css | 2 +- src/pages/ReceivePage/ReceivePage.js | 108 +++++++ src/pages/ReceivePage/ReceivePage.module.css | 96 +++++++ src/pages/ReceivePage/ReceivePage.test.js | 93 ++++++ .../RestoreAccount/RestoreAccount.module.css | 4 +- .../SendBtcTransaction/SendBtcTransaction.js | 6 +- .../SendMlTransaction/SendMlTransaction.js | 2 +- .../SignBitcoinTransaction.css | 50 ++-- src/pages/SignChallenge/SignChallenge.css | 14 +- .../SignExternalTransaction.css | 52 ++-- .../SignInternalTransaction.js | 11 +- .../SignInternalTransaction.module.css | 16 +- src/pages/StakePage/StakePage.js | 127 +++++++++ src/pages/StakePage/StakePage.module.css | 121 ++++++++ src/pages/StakePage/StakePage.test.js | 121 ++++++++ src/pages/Staking/Staking.css | 5 - src/pages/Staking/Staking.js | 29 -- src/pages/Wallet/Wallet.css | 44 --- src/pages/Wallet/Wallet.js | 188 ------------- src/pages/index.js | 12 +- 45 files changed, 1899 insertions(+), 677 deletions(-) create mode 100644 src/pages/ActivityPage/ActivityPage.js create mode 100644 src/pages/ActivityPage/ActivityPage.module.css create mode 100644 src/pages/AssetPage/AssetPage.module.css create mode 100644 src/pages/ConnectionPage/ConnectionPage.test.js delete mode 100644 src/pages/Dashboard/Dashboard.css create mode 100644 src/pages/Dashboard/Dashboard.module.css create mode 100644 src/pages/ReceivePage/ReceivePage.js create mode 100644 src/pages/ReceivePage/ReceivePage.module.css create mode 100644 src/pages/ReceivePage/ReceivePage.test.js create mode 100644 src/pages/StakePage/StakePage.js create mode 100644 src/pages/StakePage/StakePage.module.css create mode 100644 src/pages/StakePage/StakePage.test.js delete mode 100644 src/pages/Staking/Staking.css delete mode 100644 src/pages/Staking/Staking.js delete mode 100644 src/pages/Wallet/Wallet.css delete mode 100644 src/pages/Wallet/Wallet.js diff --git a/src/index.js b/src/index.js index 76abcd7e..79edde81 100644 --- a/src/index.js +++ b/src/index.js @@ -4,6 +4,7 @@ import { MemoryRouter, Routes, Route, + Navigate, useLocation, useNavigate, } from 'react-router' @@ -14,7 +15,7 @@ import { PopUp, Sidebar, } from '@ComposedComponents' -import { BrandPanel } from '@BasicComponents' +import { BrandPanel, ErrorBoundary } from '@BasicComponents' import { DeleteAccount } from '@ContainerComponents' import { Client } from '@mintlayer/sdk' @@ -22,14 +23,13 @@ import { HomePage, CreateAccountPage, RestoreAccountPage, - WalletPage, SetAccountPasswordPage, CreateRestorePage, SendBtcTransactionPage, SendMlTransactionPage, DashboardPage, SettingsPage, - StakingPage, + StakePage, ConnectionPage, CreateDelegationPage, DelegationStakePage, @@ -45,6 +45,9 @@ import { SignBitcoinTransactionPage, ConfirmBtcTransactionPage, AddressPage, + AssetPage, + ActivityPage, + ReceivePage, } from '@Pages' import { @@ -64,6 +67,7 @@ import { Browser } from '@Browser' import '@Assets/styles/fonts.css' import '@Assets/styles/constants.css' +import '@Assets/styles/theme.css' import '@Assets/styles/index.css' const root = ReactDOM.createRoot(document.getElementById('root')) @@ -314,115 +318,153 @@ const App = () => { )} - - } - /> - } - /> - } - /> - - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - + + + } + /> + } + /> + } + /> + + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + + } + /> + } + /> + } + /> + } + /> + + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + {/* Safety net: unknown routes land on the Dashboard instead of + rendering a blank panel. */} + + } + /> + +
    diff --git a/src/pages/ActivityPage/ActivityPage.js b/src/pages/ActivityPage/ActivityPage.js new file mode 100644 index 00000000..35ddd5a8 --- /dev/null +++ b/src/pages/ActivityPage/ActivityPage.js @@ -0,0 +1,124 @@ +import { useState } from 'react' + +import { TxRow, BeSheet, CopyButton } from '@ComposedComponents' +import { PageWrapper, Seg, ChainBadge, KV, Eyebrow } from '@BasicComponents' +import { useBtcWalletInfo, useMlWalletInfo } from '@Hooks' +import { Transactions } from '@Helpers' +const { adaptDesignTx } = Transactions + +import styles from './ActivityPage.module.css' + +/** + * Activity screen from the design (doc/ be-settings.jsx ActivityScreenBE + + * TxSheet). Real transactions; the design's confirmation counts and fiat-at- + * tx-time are mocked/partial — see doc/server-requirements.md. + */ +const ActivityPage = () => { + const [filter, setFilter] = useState('All') + const [selected, setSelected] = useState(null) + + const btcInfo = useBtcWalletInfo() + const mlInfo = useMlWalletInfo() + + const all = [ + ...(btcInfo.transactions || []).map((t) => + adaptDesignTx(t, 'BTC', 'Bitcoin'), + ), + ...(mlInfo.transactions || []).map((t) => + adaptDesignTx(t, 'ML', 'Mintlayer'), + ), + ] + + const list = all.filter( + (t) => + filter === 'All' || + (filter === 'BTC' ? t.chain === 'Bitcoin' : t.chain === 'Mintlayer'), + ) + const pending = list.filter((t) => t.status !== 'Confirmed') + const history = list.filter((t) => t.status === 'Confirmed') + + return ( + +
    +
    + Activity + +
    + + {pending.length > 0 && ( + <> + Pending +
    + {pending.map((t, i) => ( + setSelected(t) }} + /> + ))} +
    + + )} + + History +
    + {history.length ? ( + history.map((t, i) => ( + setSelected(t) }} + /> + )) + ) : ( +
    + No transactions — activity on the selected network shows here. +
    + )} +
    + + setSelected(null)} + title="Transaction detail" + > + {selected && ( + <> +
    +
    + {selected.type === 'receive' ? '+' : '−'} + {selected.amount} {selected.sym} +
    + +
    + + {String(selected.hash).slice(0, 14)}… + + , + ], + ] + : []), + ]} + /> + + )} +
    +
    +
    + ) +} + +export default ActivityPage diff --git a/src/pages/ActivityPage/ActivityPage.module.css b/src/pages/ActivityPage/ActivityPage.module.css new file mode 100644 index 00000000..ba30f7fe --- /dev/null +++ b/src/pages/ActivityPage/ActivityPage.module.css @@ -0,0 +1,67 @@ +.pageWrapper { + padding: 0; +} + +.page { + display: flex; + flex-direction: column; + height: 100%; + padding: 10px 14px 16px; + overflow-y: auto; + animation: be-fade-in 400ms ease both; +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + margin: 6px 0 12px; +} + +.title { + font-size: var(--font-size-lg); + font-weight: 700; + color: var(--be-text-0); +} + +.card { + background: var(--be-bg-1); + border: 1px solid var(--be-line-soft); + border-radius: 16px; + padding: 4px 0; + margin-bottom: 14px; + overflow: hidden; +} + +.pendingCard { + border-color: oklch(0.82 0.16 70 / 0.3); + margin-bottom: 14px; +} + +.empty { + padding: 26px 20px; + text-align: center; + color: var(--be-text-2); + font-size: var(--font-size-sm); +} + +.sheetHeader { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding-bottom: 14px; +} + +.sheetAmount { + font-family: var(--be-font-mono); + font-size: 22px; + font-weight: 700; + color: var(--be-text-0); +} + +.hashLine { + display: inline-flex; + align-items: center; + gap: 6px; +} diff --git a/src/pages/AddressPage/AddressPage.module.css b/src/pages/AddressPage/AddressPage.module.css index 4cf505e0..737d4308 100644 --- a/src/pages/AddressPage/AddressPage.module.css +++ b/src/pages/AddressPage/AddressPage.module.css @@ -23,18 +23,18 @@ width: 48px; height: 48px; padding: 0; - background: rgba(var(--color-black), 0.04); - background-color: rgba(var(--color-black), 0.04); + background: oklch(1 0 0 / 0.04); + background-color: oklch(1 0 0 / 0.04); border-radius: 12px; - color: rgb(var(--color-black)); + color: var(--be-text-0); flex-shrink: 0; transition: background 0.15s ease; } .qrButton.qrButton:hover, .qrButton.qrButton:focus { - background: rgba(var(--color-black), 0.08); - background-color: rgba(var(--color-black), 0.08); + background: oklch(1 0 0 / 0.08); + background-color: oklch(1 0 0 / 0.08); } .qrButton.qrButton svg path { @@ -61,13 +61,13 @@ .title { font-size: 18px; font-weight: 700; - color: rgb(var(--color-black)); + color: var(--be-text-0); margin: 0; } .subtitle { font-size: 13px; - color: rgba(var(--color-black), 0.5); + color: var(--be-text-3); } .searchWrapper { @@ -88,9 +88,9 @@ .searchInput { padding: 10px 16px 10px 38px; border-radius: 36px; - border: 1px solid rgba(var(--color-black), 0.12); + border: 1px solid var(--be-line-soft); background: rgba(var(--color-white), 1); - color: rgb(var(--color-black)); + color: var(--be-text-0); font-size: 14px; width: 260px; outline: none; @@ -102,5 +102,5 @@ } .searchInput::placeholder { - color: rgba(var(--color-black), 0.35); + color: var(--be-text-3); } diff --git a/src/pages/AssetPage/AssetPage.module.css b/src/pages/AssetPage/AssetPage.module.css new file mode 100644 index 00000000..0cf2b277 --- /dev/null +++ b/src/pages/AssetPage/AssetPage.module.css @@ -0,0 +1,141 @@ +.pageWrapper { + padding: 0; +} + +.page { + display: flex; + flex-direction: column; + height: 100%; + padding: 10px 14px 16px; + overflow-y: auto; + animation: be-fade-in 400ms ease both; +} + +.header { + display: flex; + flex-direction: column; + align-items: center; + padding: 10px 0 4px; + text-align: center; +} + +.amount { + font-size: 28px; + font-weight: 700; + letter-spacing: -0.02em; + margin-top: 10px; + color: var(--be-text-0); + font-variant-numeric: tabular-nums; +} + +.ticker { + font-size: var(--font-size-md); + color: var(--be-text-2); + font-weight: 500; +} + +.fiatLine { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + margin-top: 4px; +} + +.fiat { + font-family: var(--be-font-mono); + font-size: var(--font-size-sm); + color: var(--be-text-2); +} + +.badges { + display: flex; + justify-content: center; + gap: 6px; + margin-top: 8px; +} + +.chartCard { + background: var(--be-bg-1); + border: 1px solid var(--be-line-soft); + border-radius: 16px; + padding: 12px 12px 8px; + margin-top: 14px; + display: flex; + justify-content: center; +} + +.actions { + display: flex; + gap: 8px; + margin-top: 10px; +} + +.actionButton, +.page .actionButton { + flex: 1; + height: 44px; + border-radius: 12px; + color: oklch(0.18 0.02 70); + background: linear-gradient(180deg, oklch(0.88 0.15 75), oklch(0.78 0.16 65)); + border: none; +} + +.page .actionButton:hover, +.page .actionButton:focus { + color: oklch(0.18 0.02 70); + background: linear-gradient(180deg, oklch(0.9 0.15 75), oklch(0.8 0.16 65)); +} + +.actionSecondary, +.page .actionSecondary { + flex: 1; + height: 44px; + border-radius: 12px; + color: var(--be-text-0); + background: oklch(1 0 0 / 0.05); + border: 1px solid var(--be-line); +} + +.page .actionSecondary:hover, +.page .actionSecondary:focus { + color: var(--be-text-0); + background: oklch(1 0 0 / 0.09); +} + +.section { + margin-top: 16px; +} + +.sectionTitle { + font-size: var(--font-size-md); + font-weight: 600; + color: var(--be-text-0); + margin-bottom: 10px; +} + +.kvGap { + height: 8px; +} + +.card { + background: var(--be-bg-1); + border: 1px solid var(--be-line-soft); + border-radius: 16px; + padding: 4px 0; + overflow: hidden; +} + +.empty { + padding: 22px 16px; + text-align: center; + color: var(--be-text-2); + font-size: var(--font-size-sm); +} + +.assetName { + font-size: var(--font-size-md); + font-weight: 600; + color: var(--be-text-2); + margin-top: 8px; +} diff --git a/src/pages/ConfirmBtcTransaction/ConfirmBtcTransaction.js b/src/pages/ConfirmBtcTransaction/ConfirmBtcTransaction.js index 671d5a76..ffeef53f 100644 --- a/src/pages/ConfirmBtcTransaction/ConfirmBtcTransaction.js +++ b/src/pages/ConfirmBtcTransaction/ConfirmBtcTransaction.js @@ -143,7 +143,7 @@ const ConfirmBtcTransactionPage = () => { } const goBackToWallet = async () => { - navigate('/wallet/Bitcoin') + navigate('/dashboard') } const passwordChangeHandler = (value) => { @@ -151,7 +151,7 @@ const ConfirmBtcTransactionPage = () => { } if (!state) { - navigate('/wallet/Bitcoin') + navigate('/dashboard') return null } diff --git a/src/pages/ConfirmBtcTransaction/ConfirmBtcTransaction.module.css b/src/pages/ConfirmBtcTransaction/ConfirmBtcTransaction.module.css index 1704da69..fe4034f4 100644 --- a/src/pages/ConfirmBtcTransaction/ConfirmBtcTransaction.module.css +++ b/src/pages/ConfirmBtcTransaction/ConfirmBtcTransaction.module.css @@ -1,9 +1,9 @@ .signTransaction { display: flex; flex-direction: column; - background-color: #ffffff; + background-color: var(--be-bg-1); margin: 0 auto; - font-family: 'Arial', sans-serif; + overflow: scroll; width: 100%; height: 100%; @@ -61,7 +61,7 @@ flex-direction: column; align-items: center; gap: 40px; - background-color: #ffffff; + background-color: var(--be-bg-1); border-radius: 8px; text-align: center; } @@ -89,20 +89,20 @@ flex-direction: column; gap: 16px; padding: 20px; - background: #f8fafc; + background: var(--be-bg-2); border-radius: 8px; } .signTxSection h4 { margin: 0; - color: #2d3748; + color: var(--be-text-0); font-size: 0.95rem; } .signTxSection p { margin: 0; font-size: 0.9rem; - color: #4a5568; + color: var(--be-text-1); word-break: break-all; } diff --git a/src/pages/ConnectionPage/BitcoinDataNotice.module.css b/src/pages/ConnectionPage/BitcoinDataNotice.module.css index 74d7762a..8117a418 100644 --- a/src/pages/ConnectionPage/BitcoinDataNotice.module.css +++ b/src/pages/ConnectionPage/BitcoinDataNotice.module.css @@ -22,12 +22,12 @@ .toggleTitle { font-size: var(--font-size-md); font-weight: 600; - color: rgb(var(--color-black)); + color: var(--be-text-0); } .toggleDescription { font-size: var(--font-size-sm); - color: rgba(var(--color-black), 0.55); + color: var(--be-text-2); } .infoBlock { @@ -36,8 +36,8 @@ gap: var(--space-sm); padding: var(--space-md); border-radius: 12px; - border: 1px solid rgba(var(--color-main-green), 0.3); - background: rgba(var(--color-main-green), 0.08); + border: 1px solid oklch(0.82 0.16 70 / 0.3); + background: var(--be-amber-soft); } .infoIcon { @@ -49,8 +49,8 @@ flex-shrink: 0; margin-top: 1px; border-radius: 50%; - background: rgb(var(--color-main-green)); - color: rgb(var(--color-white)); + background: var(--be-amber); + color: oklch(0.18 0.02 70); font-size: var(--font-size-xs); font-weight: bold; font-style: italic; @@ -60,10 +60,10 @@ margin: 0; font-size: var(--font-size-sm); line-height: 1.45; - color: rgb(var(--color-dark-gray)); + color: var(--be-text-2); } .infoText strong { - color: rgb(var(--color-main-green)); + color: var(--be-amber); font-weight: 600; } diff --git a/src/pages/ConnectionPage/ConnectionPage.module.css b/src/pages/ConnectionPage/ConnectionPage.module.css index 72845535..ef5b5d3d 100644 --- a/src/pages/ConnectionPage/ConnectionPage.module.css +++ b/src/pages/ConnectionPage/ConnectionPage.module.css @@ -36,14 +36,14 @@ height: 56px; flex-shrink: 0; border-radius: 18px; - background: rgba(var(--color-main-green), 0.12); - color: rgb(var(--mojito-green)); + background: var(--be-amber-soft); + color: var(--be-amber); } .shieldIcon { width: 28px; height: 28px; - stroke: rgb(var(--mojito-green)); + stroke: var(--be-amber); } .title { @@ -51,14 +51,14 @@ font-size: var(--font-size-3xl); font-weight: 700; line-height: 1.25; - color: rgb(var(--color-black)); + color: var(--be-text-0); } .subtitle { margin: 0; font-size: var(--font-size-md); line-height: 1.4; - color: rgba(var(--color-black), 0.6); + color: var(--be-text-2); } .card { @@ -67,9 +67,9 @@ gap: var(--space-lg); padding: var(--space-lg); border-radius: var(--round-size); - border: 1px solid rgba(var(--color-black), 0.07); - background: rgb(var(--surface)); - box-shadow: var(--shadow-sm); + border: 1px solid var(--be-line-soft); + background: var(--be-bg-1); + box-shadow: 0 8px 24px oklch(0 0 0 / 0.3); } .cardTitle { @@ -77,7 +77,7 @@ font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; - color: rgba(var(--color-black), 0.45); + color: var(--be-text-3); } .permissions { @@ -91,7 +91,19 @@ .bitcoinSlot { padding-top: var(--space-lg); - border-top: 1px solid rgba(var(--color-black), 0.07); + border-top: 1px solid var(--be-line-soft); +} + +.warning { + margin: 0; + padding: var(--space-md) var(--space-lg); + border-radius: var(--round-size); + border: 1px solid var(--be-amber); + background: var(--be-amber-soft); + color: var(--be-amber); + font-size: var(--font-size-sm); + line-height: 1.45; + text-align: center; } .disclaimer { @@ -99,7 +111,7 @@ text-align: center; font-size: var(--font-size-sm); line-height: 1.45; - color: rgba(var(--color-black), 0.5); + color: var(--be-text-2); } .actions { diff --git a/src/pages/ConnectionPage/ConnectionPage.test.js b/src/pages/ConnectionPage/ConnectionPage.test.js new file mode 100644 index 00000000..d522db8c --- /dev/null +++ b/src/pages/ConnectionPage/ConnectionPage.test.js @@ -0,0 +1,203 @@ +import React from 'react' +import { MemoryRouter, Routes, Route } from 'react-router' +import { render, screen, fireEvent } from '@testing-library/react' + +const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}) + +jest.mock('@Browser', () => ({ + __esModule: true, + sendPopupResponse: jest.fn(), +})) + +jest.mock('@Contexts', () => { + const React = require('react') + const makeCtx = (value) => React.createContext(value) + return { + __esModule: true, + AccountContext: makeCtx({ + addresses: { + btcAddresses: { + btcReceivingAddresses: ['bc1qold'], + btcChangeAddresses: [], + }, + mlAddresses: { + mlReceivingAddresses: ['mtc1qold'], + mlChangeAddresses: [], + }, + }, + }), + SettingsContext: makeCtx({ networkType: 'mainnet' }), + BitcoinContext: makeCtx({}), + MintlayerContext: makeCtx({}), + } +}) + +const request = { + origin: 'https://bridge.example', + requestId: 'r1', + permissions: ['bitcoin'], +} + +const renderAt = (state) => + render( + + + } + /> + + , + ) + +const renderWithAddresses = (addresses, state = { request }) => + render( + + + + } + /> + + + , + ) + +// Required after the jest.mock hoisting block. +// eslint-disable-next-line import/first +const ConnectionPage = require('./ConnectionPage').default +// eslint-disable-next-line import/first +const { AccountContext } = require('@Contexts') +// eslint-disable-next-line import/first +const { sendPopupResponse } = require('@Browser') + +describe('ConnectionPage', () => { + beforeEach(() => { + sendPopupResponse.mockClear() + }) + + afterAll(() => { + errorSpy.mockRestore() + }) + + it('connects with an old-store addresses blob (no public keys) without crashing', () => { + renderAt({ request }) + + expect(screen.queryByTestId('error-boundary')).not.toBeInTheDocument() + + fireEvent.click(screen.getByTestId('connect-button')) + + expect(sendPopupResponse).toHaveBeenCalledTimes(1) + const payload = sendPopupResponse.mock.calls[0][0] + expect(payload.method).toBe('connect') + expect(payload.requestId).toBe('r1') + expect(payload.origin).toBe('https://bridge.example') + + expect(payload.result.addressesByChain.mintlayer.receiving).toEqual([ + 'mtc1qold', + ]) + expect( + payload.result.addressesByChain.mintlayer.publicKeys.receiving, + ).toEqual([]) + expect(payload.result.addressesByChain.mintlayer.publicKeys.change).toEqual( + [], + ) + + // Old store kept plain strings for BTC addresses; addresses are passed, + // public keys are simply omitted (empty) instead of crashing. + expect(payload.result.addressesByChain.bitcoin.receiving).toEqual([ + 'bc1qold', + ]) + expect(payload.result.addressesByChain.bitcoin.change).toEqual([]) + expect( + payload.result.addressesByChain.bitcoin.publicKeys.receiving, + ).toEqual([]) + expect(payload.result.addressesByChain.bitcoin.publicKeys.change).toEqual( + [], + ) + }) + + it('connects with the new-store shape (object BTC entries and public keys)', () => { + const addresses = { + mlAddresses: { + mlReceivingAddresses: ['mtc1qnew'], + mlChangeAddresses: ['mtc1qnewc'], + mlReceivingPublicKeys: [{ 1: 2 }, { 3: 4 }], + mlChangePublicKeys: [{ 5: 6 }], + }, + btcAddresses: { + btcReceivingAddresses: [{ bc1qnew: { pubkey: { 1: 2 } } }], + btcChangeAddresses: [{ bc1qnewc: { pubkey: { 3: 4 } } }], + }, + } + + renderWithAddresses(addresses) + + fireEvent.click(screen.getByTestId('connect-button')) + + expect(sendPopupResponse).toHaveBeenCalledTimes(1) + const payload = sendPopupResponse.mock.calls[0][0] + expect( + payload.result.addressesByChain.mintlayer.publicKeys.receiving, + ).toEqual(['02', '04']) + expect(payload.result.addressesByChain.mintlayer.publicKeys.change).toEqual( + ['06'], + ) + expect(payload.result.addressesByChain.bitcoin.receiving).toEqual([ + 'bc1qnew', + ]) + expect(payload.result.addressesByChain.bitcoin.change).toEqual(['bc1qnewc']) + expect( + payload.result.addressesByChain.bitcoin.publicKeys.receiving, + ).toEqual(['02']) + expect(payload.result.addressesByChain.bitcoin.publicKeys.change).toEqual([ + '04', + ]) + }) + + it('omits the bitcoin block when no BTC address data exists', () => { + const addresses = { + mlAddresses: { mlReceivingAddresses: ['mtc1qonlyml'] }, + } + + renderWithAddresses(addresses) + + fireEvent.click(screen.getByTestId('connect-button')) + + expect(sendPopupResponse).toHaveBeenCalledTimes(1) + const payload = sendPopupResponse.mock.calls[0][0] + expect(payload.result.addressesByChain.bitcoin).toBeUndefined() + }) + + it('rejects with a null result', () => { + renderAt({ request }) + + fireEvent.click(screen.getByTestId('reject-button')) + + expect(sendPopupResponse).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'connect', + requestId: 'r1', + origin: 'https://bridge.example', + result: null, + }), + ) + }) + + it('warns, disables Connect and does not respond when wallet data is incomplete', () => { + renderWithAddresses(null) + + expect(screen.getByTestId('incomplete-data-warning')).toBeInTheDocument() + expect(screen.getByTestId('connect-button')).toBeDisabled() + + fireEvent.click(screen.getByTestId('connect-button')) + expect(sendPopupResponse).not.toHaveBeenCalled() + + // Reject stays available so the dApp always gets an answer. + fireEvent.click(screen.getByTestId('reject-button')) + expect(sendPopupResponse).toHaveBeenCalledWith( + expect.objectContaining({ result: null }), + ) + }) +}) diff --git a/src/pages/ConnectionPage/PermissionItem.module.css b/src/pages/ConnectionPage/PermissionItem.module.css index 4fb7329a..0f742114 100644 --- a/src/pages/ConnectionPage/PermissionItem.module.css +++ b/src/pages/ConnectionPage/PermissionItem.module.css @@ -13,8 +13,8 @@ height: 32px; flex-shrink: 0; border-radius: 10px; - background: rgba(var(--color-main-green), 0.12); - color: rgb(var(--mojito-green)); + background: var(--be-amber-soft); + color: var(--be-amber); } .icon { @@ -32,13 +32,13 @@ .title { font-size: var(--font-size-md); font-weight: 600; - color: rgb(var(--color-black)); + color: var(--be-text-0); line-height: 1.3; } .description { font-size: var(--font-size-sm); - color: rgba(var(--color-black), 0.55); + color: var(--be-text-2); line-height: 1.4; } diff --git a/src/pages/CreateAccount/CreateAccount.module.css b/src/pages/CreateAccount/CreateAccount.module.css index 106d523a..d5ea79f1 100644 --- a/src/pages/CreateAccount/CreateAccount.module.css +++ b/src/pages/CreateAccount/CreateAccount.module.css @@ -1,3 +1,3 @@ .createAccountPage { - background: rgb(var(--color-white)); + background: var(--be-bg-1); } diff --git a/src/pages/CreateDelegation/CreateDelegation.css b/src/pages/CreateDelegation/CreateDelegation.css index 1c4a31f3..fb76901d 100644 --- a/src/pages/CreateDelegation/CreateDelegation.css +++ b/src/pages/CreateDelegation/CreateDelegation.css @@ -11,5 +11,5 @@ min-height: 400px; position: absolute; z-index: 40; - background-color: rgba(255, 255, 255, 0.8); + background-color: oklch(0 0 0 / 0.5); } diff --git a/src/pages/CreateDelegation/CreateDelegation.js b/src/pages/CreateDelegation/CreateDelegation.js index a4e51ea3..0cd7eea8 100644 --- a/src/pages/CreateDelegation/CreateDelegation.js +++ b/src/pages/CreateDelegation/CreateDelegation.js @@ -31,7 +31,7 @@ const CreateDelegationPage = () => { tokenName, }) const goBackToWallet = () => { - navigate('/wallet/' + walletType.name + '/staking') + navigate('/staking') } const [isFormValid, setFormValid] = useState(false) const [transactionInformation, setTransactionInformation] = useState(null) @@ -93,7 +93,7 @@ const CreateDelegationPage = () => { if (!accountID) { console.log('No account id.') - navigate('/wallet') + navigate('/dashboard') return } diff --git a/src/pages/CreateRestore/CreateRestore.js b/src/pages/CreateRestore/CreateRestore.js index d6255bf6..c1542eb8 100644 --- a/src/pages/CreateRestore/CreateRestore.js +++ b/src/pages/CreateRestore/CreateRestore.js @@ -1,9 +1,7 @@ import { useContext } from 'react' import { useNavigate } from 'react-router' -import { BrandBottomLogo, Button, PageWrapper } from '@BasicComponents' -import { ReactComponent as ShieldIcon } from '@Assets/images/icon-shield.svg' -import { ReactComponent as IconArrowTopRight } from '@Assets/images/icon-arrow-right-top.svg' +import { Button, MojitoLogo, PageWrapper } from '@BasicComponents' import { AccountContext } from '@Contexts' import { LocalStorageService } from '@Storage' @@ -46,42 +44,46 @@ const CreateRestorePage = () => { data-testid="create-restore" className={styles.page} > -

    Mojito

    -

    A fresh way to hold Mintlayer assets

    -

    - Self-custody wallet for Bitcoin and Mintlayer tokens. -
    - Live prices, fast swaps, no custodians. -

    +
    +
    + +
    +

    Mojito

    +

    + Self-custody wallet for Bitcoin and Mintlayer. +
    + Your keys never leave this browser. +

    +
    + + + Bitcoin + + + + Mintlayer + +
    +
    -
    -
    - - - Non-custodial - - · - Audited - · - Open source +

    + By continuing you agree to the Terms and{' '} + Privacy policy. +

    - ) } diff --git a/src/pages/CreateRestore/CreateRestore.module.css b/src/pages/CreateRestore/CreateRestore.module.css index 5324fdeb..88c6141e 100644 --- a/src/pages/CreateRestore/CreateRestore.module.css +++ b/src/pages/CreateRestore/CreateRestore.module.css @@ -1,5 +1,7 @@ .pageWrapper { - background: var(--gradient-surface-green); + background: var(--be-canvas); + color: var(--be-text-0); + padding: 0; } .page { @@ -7,163 +9,140 @@ z-index: 1; display: flex; flex-direction: column; + height: 100%; + padding: var(--space-xl); + overflow-y: auto; + animation: be-fade-in 400ms ease both; +} + +.center { + flex: 1; + display: flex; + flex-direction: column; align-items: center; justify-content: center; - height: 100%; text-align: center; - - @media screen and (max-width: 900px) { - padding-bottom: var(--space-5xl); - } } -.logoIcon { - width: 80px; - height: 80px; - border-radius: 24px; - padding: var(--space-lg); - margin: 0 0 var(--space-sm) 0; - background: linear-gradient(135deg, #e6f8f0, #fff); - box-shadow: rgba(30, 187, 129, 0.18) 0px 10px 30px; - border: 1px solid #e8ebf0; - - @media screen and (min-width: 901px) { - width: 90px; - height: 90px; - padding: var(--space-xl); - margin: 0 0 var(--space-lg) 0; - border-radius: 28px; - } +.logo { + margin: 0 auto; + animation: be-float-y 5s ease-in-out infinite; } .title { - font-size: var(--font-size-display); + font-size: var(--font-size-5xl); font-weight: 700; - line-height: 1.1; - color: rgba(var(--color-black), 0.75); - margin: 0 0 var(--space-3xl) 0; - - @media screen and (min-width: 901px) { - margin: 0 0 var(--space-5xl) 0; - } + letter-spacing: -0.02em; + color: var(--be-text-0); + margin: var(--space-2xl) 0 0; } -.logoText { - margin-bottom: var(--space-2xl); +.subtitle { + font-size: var(--font-size-md); + line-height: 1.6; + color: var(--be-text-2); + margin: var(--space-sm) 0 0; @media screen and (min-width: 901px) { - margin-bottom: var(--space-4xl); + font-size: var(--font-size-lg); } } -.heading { - font-size: var(--font-size-4xl); - font-weight: 700; - line-height: 1.3; - color: rgb(var(--color-black)); - margin: var(--space-xs) 0px var(--space-sm); +.chips { + display: flex; + justify-content: center; + gap: var(--space-xs); + margin-top: var(--space-xl); +} - @media screen and (min-width: 901px) { - font-size: var(--font-size-5xl); - margin: 0 0 var(--space-md) 0; - } +.chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 9px; + border-radius: 999px; + font-size: var(--font-size-xs); + font-weight: 500; + letter-spacing: 0.02em; + border: 1px solid var(--be-line); + color: var(--be-text-1); } -.subtitle { - font-size: var(--font-size-md); - font-weight: 400; - line-height: 1.5; - color: rgba(var(--color-black), 0.6); - margin: 0px 0px var(--space-3xl); +.chipAmber { + background: var(--be-amber-soft); + border-color: oklch(0.82 0.16 70 / 0.35); + color: var(--be-amber); +} - @media screen and (min-width: 901px) { - font-size: var(--font-size-lg); - margin: 0 0 var(--space-5xl) 0; - max-width: 460px; - } +.chipTeal { + background: var(--be-teal-soft); + border-color: oklch(0.82 0.12 195 / 0.35); + color: var(--be-teal); +} + +.dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: currentcolor; + box-shadow: 0 0 8px currentcolor; } .buttons { display: flex; flex-direction: column; - gap: var(--space-sm); + gap: var(--space-xs); width: 100%; - max-width: 320px; - - @media screen and (min-width: 901px) { - max-width: 360px; - } + max-width: 360px; + margin: 0 auto; + padding-bottom: var(--space-xl); } -.createButton { +.page .primaryButton { width: 100%; - padding: 0 var(--space-2xl); height: 52px; min-height: 52px; font-size: var(--font-size-lg); + font-weight: 600; + color: oklch(0.18 0.02 70); + background: linear-gradient(180deg, oklch(0.88 0.15 75), oklch(0.78 0.16 65)); + border: none; + border-radius: 14px; + box-shadow: 0 8px 24px -8px oklch(0.82 0.16 70 / 0.6); } -.restoreButton { + +.page .primaryButton:hover, +.page .primaryButton:focus { + color: oklch(0.18 0.02 70); + background: linear-gradient(180deg, oklch(0.9 0.15 75), oklch(0.8 0.16 65)); + filter: brightness(1.06); +} + +.page .secondaryButton { width: 100%; - padding: 0 var(--space-2xl); height: 52px; min-height: 52px; font-size: var(--font-size-lg); + font-weight: 600; + color: var(--be-text-0); + background: oklch(1 0 0 / 0.05); + border: 1px solid var(--be-line); + border-radius: 14px; } -.buttonIcon { - width: 13px; - height: 13px; - max-width: 13px; - max-height: 13px; - margin-left: var(--space-sm); -} - -.restoreButton:hover .buttonIcon, -.createButton:hover .buttonIcon { - animation: moveArrowUpRight 0.3s ease-out; -} - -.badges { - display: flex; - align-items: center; - gap: var(--space-xs); - margin-top: var(--space-3xl); - - @media screen and (min-width: 901px) { - margin-top: var(--space-5xl); - gap: var(--space-md); - } +.page .secondaryButton:hover, +.page .secondaryButton:focus { + color: var(--be-text-0); + background: oklch(1 0 0 / 0.09); } -.badges span { +.terms { font-size: var(--font-size-sm); - color: rgba(var(--color-black), 0.35); - - @media screen and (min-width: 901px) { - font-size: var(--font-size-md); - } -} - -.badgeDot { - font-size: 6px; - - @media screen and (min-width: 901px) { - font-size: 8px; - } -} - -.badgeWithIcon { - display: inline-flex; - align-items: center; - gap: var(--space-3xs); + color: var(--be-text-2); + text-align: center; + margin: var(--space-sm) 0 0; } -.badgeIcon { - width: 14px; - height: 14px; - - @media screen and (min-width: 901px) { - width: 16px; - height: 16px; - } +.terms a { + color: var(--be-amber); } diff --git a/src/pages/Dashboard/Dashboard.css b/src/pages/Dashboard/Dashboard.css deleted file mode 100644 index bdcbff5e..00000000 --- a/src/pages/Dashboard/Dashboard.css +++ /dev/null @@ -1,19 +0,0 @@ -.stats { - display: flex; - margin-bottom: 1.5rem; - min-height: max-content; - justify-content: center; - align-items: center; - gap: 55px; - min-height: max-content; - - @media screen and (min-width: 901px) { - margin-bottom: 2.75rem; - } - - @media screen and (max-width: 900px) { - flex-direction: column; - gap: 1rem; - margin-bottom: 1rem; - } -} diff --git a/src/pages/Dashboard/Dashboard.module.css b/src/pages/Dashboard/Dashboard.module.css new file mode 100644 index 00000000..ff3aee52 --- /dev/null +++ b/src/pages/Dashboard/Dashboard.module.css @@ -0,0 +1,259 @@ +.pageWrapper { + padding: 0; +} + +.page { + display: flex; + flex-direction: column; + height: 100%; + padding: 10px 14px 0; + animation: be-fade-in 400ms ease both; +} + +/* ── Top bar ─────────────────────────────────────────────── */ +.top { + display: flex; + align-items: center; + gap: 9px; + padding: 4px 4px 8px; +} + +.account { + display: flex; + align-items: center; + gap: 9px; + flex: 1; + cursor: pointer; + padding: 4px 8px 4px 4px; + border-radius: 12px; + min-width: 0; +} + +.account:hover { + background: oklch(1 0 0 / 0.04); +} + +.accountName { + font-size: var(--font-size-sm); + font-weight: 600; + color: var(--be-text-0); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 9px; + border-radius: 999px; + font-size: var(--font-size-xs); + font-weight: 500; + border: 1px solid var(--be-line); + cursor: pointer; + white-space: nowrap; +} + +.chipTeal { + background: var(--be-teal-soft); + border-color: oklch(0.82 0.12 195 / 0.35); + color: var(--be-teal); +} + +.chipAmber { + background: var(--be-amber-soft); + border-color: oklch(0.82 0.16 70 / 0.35); + color: var(--be-amber); +} + +.dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: currentcolor; + box-shadow: 0 0 8px currentcolor; +} + +.iconButton { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 10px; + cursor: pointer; + color: var(--be-text-1); +} + +.iconButton:hover { + background: oklch(1 0 0 / 0.06); +} + +/* ── Scroll area ─────────────────────────────────────────── */ +.scroll { + flex: 1; + overflow-y: auto; + min-height: 0; + padding-bottom: 16px; +} + +/* ── Balance card ────────────────────────────────────────── */ +.balanceCard { + position: relative; + padding: 18px 18px 16px; + border-radius: 20px; + background: + radial-gradient( + 120% 140% at 85% -20%, + oklch(0.82 0.16 70 / 0.28), + transparent 60% + ), + linear-gradient(155deg, oklch(0.24 0.02 60), oklch(0.18 0.014 60)); + border: 1px solid var(--be-line-soft); + overflow: hidden; +} + +.balanceHeader { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + width: max-content; +} + +.balanceValue { + font-size: 34px; + font-weight: 700; + letter-spacing: -0.025em; + margin-top: 4px; + color: var(--be-text-0); + font-variant-numeric: tabular-nums; +} + +.balanceSymbol { + color: var(--be-text-2); + font-weight: 500; +} + +.balanceMeta { + display: flex; + align-items: center; + gap: 8px; + margin-top: 6px; +} + +.fiat24h { + font-family: var(--be-font-mono); + font-size: 11px; + color: var(--be-text-2); +} + +/* ── Quick actions ───────────────────────────────────────── */ +.quickActions { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 8px; + margin-top: 10px; +} + +.quickActions button { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + padding: 12px 0; + border-radius: 14px; + border: 1px solid var(--be-line-soft); + background: var(--be-bg-1); + color: var(--be-text-1); + font-size: var(--font-size-xs); + font-weight: 600; + cursor: pointer; + transition: all 150ms ease; +} + +.quickActions button:hover { + border-color: oklch(0.82 0.16 70 / 0.4); + color: var(--be-text-0); +} + +/* ── Sections ────────────────────────────────────────────── */ +.section { + margin-top: 20px; +} + +.sectionHeader { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 10px; +} + +.sectionTitle { + font-size: var(--font-size-md); + font-weight: 600; + color: var(--be-text-0); +} + +.seeAll { + font-size: var(--font-size-xs); + font-weight: 600; + color: var(--be-amber); + cursor: pointer; +} + +.card { + background: var(--be-bg-1); + border: 1px solid var(--be-line-soft); + border-radius: 16px; + padding: 4px 0; + overflow: hidden; +} + +.addRow { + display: flex; + align-items: center; + gap: 12px; + padding: 13px 14px; + font-size: var(--font-size-sm); + font-weight: 600; + color: var(--be-text-1); + cursor: pointer; +} + +.addRow:hover { + background: oklch(1 0 0 / 0.03); +} + +/* ── NFT grid ────────────────────────────────────────────── */ +.nftGrid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} + +.nftTile { + position: relative; + aspect-ratio: 1; + border-radius: 16px; + border: 1px solid var(--be-line-soft); + overflow: hidden; + padding: 10px; + display: flex; + flex-direction: column; + justify-content: flex-end; + cursor: pointer; + animation: be-slide-up 400ms both; +} + +.nftName { + font-size: var(--font-size-sm); + font-weight: 600; + color: var(--be-text-0); +} + +.nftCollection { + font-size: 10px; + color: var(--be-text-2); +} diff --git a/src/pages/DelegationStake/DelegationStake.js b/src/pages/DelegationStake/DelegationStake.js index b3d1cbcb..ae56ac5a 100644 --- a/src/pages/DelegationStake/DelegationStake.js +++ b/src/pages/DelegationStake/DelegationStake.js @@ -32,7 +32,7 @@ const DelegationStakePage = () => { }) const goBackToWallet = () => { setDelegationStep(1) - navigate('/wallet/' + walletType.name + '/staking') + navigate('/staking') } const { setDelegationStep } = useContext(TransactionContext) const [isFormValid, setFormValid] = useState(false) @@ -99,7 +99,7 @@ const DelegationStakePage = () => { if (!accountID) { console.log('No account id.') - navigate('/wallet') + navigate('/dashboard') return } diff --git a/src/pages/DelegationWithdraw/DelegationWithdraw.js b/src/pages/DelegationWithdraw/DelegationWithdraw.js index 5cad4b34..f81152b1 100644 --- a/src/pages/DelegationWithdraw/DelegationWithdraw.js +++ b/src/pages/DelegationWithdraw/DelegationWithdraw.js @@ -33,7 +33,7 @@ const DelegationWithdrawPage = () => { }) const goBackToWallet = () => { setDelegationStep(1) - navigate('/wallet/' + walletType.name + '/staking') + navigate('/staking') } const { setDelegationStep } = useContext(TransactionContext) const [isFormValid, setFormValid] = useState(false) @@ -103,7 +103,7 @@ const DelegationWithdrawPage = () => { if (!accountID) { console.log('No account id.') - navigate('/wallet') + navigate('/dashboard') return } diff --git a/src/pages/Login/Login.module.css b/src/pages/Login/Login.module.css index 911abdb6..c88d382a 100644 --- a/src/pages/Login/Login.module.css +++ b/src/pages/Login/Login.module.css @@ -1,5 +1,6 @@ .pageWrapper { - background: rgb(var(--color-white)); + background: var(--be-canvas); + color: var(--be-text-0); flex-direction: row; padding: 0; @@ -11,7 +12,9 @@ .page { display: flex; flex: 1; + flex-direction: column; padding: var(--space-lg); + animation: be-fade-in 400ms ease both; @media screen and (min-width: 901px) { padding: var(--space-4xl); diff --git a/src/pages/Login/SetAccountPassword.module.css b/src/pages/Login/SetAccountPassword.module.css index 5189afa0..41df3d39 100644 --- a/src/pages/Login/SetAccountPassword.module.css +++ b/src/pages/Login/SetAccountPassword.module.css @@ -1,3 +1,5 @@ .pageWrapper { - background: rgb(var(--color-white)); + background: var(--be-canvas); + color: var(--be-text-0); + padding: 0; } diff --git a/src/pages/MessagePage/MessagePage.css b/src/pages/MessagePage/MessagePage.css index f36db2b1..4f497cf7 100644 --- a/src/pages/MessagePage/MessagePage.css +++ b/src/pages/MessagePage/MessagePage.css @@ -8,10 +8,10 @@ padding: 15px 20px; cursor: pointer; border: none; - background: #f0f0f0; + background: var(--be-bg-2); width: 50%; border-radius: 0%; - color: rgb(var(--color-black)); + color: var(--be-text-0); font-size: 18px; transition: all 0.3s ease-in-out; } diff --git a/src/pages/NftSend/NftSend.js b/src/pages/NftSend/NftSend.js index 55c81a87..a7691399 100644 --- a/src/pages/NftSend/NftSend.js +++ b/src/pages/NftSend/NftSend.js @@ -110,7 +110,7 @@ const NftSendPage = () => { if (!accountID) { console.log('No account id.') - navigate('/wallet') + navigate('/dashboard') return } diff --git a/src/pages/OrderSwap/OrderSwap.js b/src/pages/OrderSwap/OrderSwap.js index 90685b8b..71f20624 100644 --- a/src/pages/OrderSwap/OrderSwap.js +++ b/src/pages/OrderSwap/OrderSwap.js @@ -24,7 +24,7 @@ const OrderSwapPage = () => { if (!accountID) { console.log('No account id.') - navigate('/wallet') + navigate('/dashboard') return } diff --git a/src/pages/OrderSwap/OrderSwap.module.css b/src/pages/OrderSwap/OrderSwap.module.css index 61e3b9b0..aaabef56 100644 --- a/src/pages/OrderSwap/OrderSwap.module.css +++ b/src/pages/OrderSwap/OrderSwap.module.css @@ -19,7 +19,7 @@ .modeLabel { font-weight: 500; font-size: var(--font-size-md); - color: rgba(var(--color-black), 0.6); + color: var(--be-text-2); } .content { diff --git a/src/pages/ReceivePage/ReceivePage.js b/src/pages/ReceivePage/ReceivePage.js new file mode 100644 index 00000000..0cb3f69d --- /dev/null +++ b/src/pages/ReceivePage/ReceivePage.js @@ -0,0 +1,108 @@ +import { useContext, useState } from 'react' +import { useLocation } from 'react-router' + +import { + PageWrapper, + Seg, + ChainBadge, + QrPlaceholder, + QrCode, + Tag, + Button, +} from '@BasicComponents' +import { CopyButton } from '@ComposedComponents' +import { AccountContext, SettingsContext } from '@Contexts' +import { AppInfo } from '@Constants' +import { BTC } from '@Helpers' + +import styles from './ReceivePage.module.css' + +const CHAINS = ['Bitcoin', 'Mintlayer'] +const DEFAULT_CHAIN = 'Mintlayer' + +/** + * Receive screen from the design (doc/ be-send.jsx ReceiveScreenBE). + * Real addresses and a real QR; the chain is seeded from the navigation + * state when the user arrives from a specific asset screen. + */ +const ReceivePage = () => { + const { state } = useLocation() + const { addresses } = useContext(AccountContext) + const { networkType } = useContext(SettingsContext) + const [chain, setChain] = useState( + CHAINS.includes(state?.chain) ? state.chain : DEFAULT_CHAIN, + ) + + const isBtc = chain === 'Bitcoin' + const address = isBtc + ? BTC.getBtcAddressString( + addresses?.btcAddresses?.btcReceivingAddresses?.[0], + ) || '' + : addresses?.mlAddresses?.mlReceivingAddresses?.[0] || '' + + const copyAddress = () => { + if (address) navigator.clipboard?.writeText(address) + } + + return ( + +
    +
    + Receive +
    +
    + + +
    + {address ? ( + + ) : ( + + )} +
    + +
    + + + {networkType === AppInfo.NETWORK_TYPES.TESTNET + ? 'Testnet' + : 'Mainnet'} + +
    + +
    + {address || 'Address unavailable — unlock your wallet'} +
    + +
    + + +
    + +
    + {isBtc + ? 'Native SegWit (bech32). A fresh address per payment protects your privacy.' + : 'Use this address for ML and all Mintlayer tokens and NFTs.'} +
    +
    +
    +
    + ) +} + +export default ReceivePage diff --git a/src/pages/ReceivePage/ReceivePage.module.css b/src/pages/ReceivePage/ReceivePage.module.css new file mode 100644 index 00000000..d367e32f --- /dev/null +++ b/src/pages/ReceivePage/ReceivePage.module.css @@ -0,0 +1,96 @@ +.pageWrapper { + padding: 0; +} + +.page { + display: flex; + flex-direction: column; + height: 100%; + padding: 10px 14px 16px; + overflow-y: auto; + animation: be-fade-in 400ms ease both; +} + +.header { + display: flex; + justify-content: center; + margin: 6px 0 12px; +} + +.title { + font-size: var(--font-size-lg); + font-weight: 700; + color: var(--be-text-0); +} + +.body { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + width: 100%; +} + +.qrWrap { + margin-top: 22px; + padding: 16px; + border-radius: 22px; + background: oklch(1 0 0 / 0.03); + border: 1px solid var(--be-amber); + box-shadow: 0 0 40px -10px var(--be-amber); +} + +.badges { + display: flex; + justify-content: center; + gap: 6px; + margin-top: 14px; +} + +.addressBox { + margin-top: 12px; + padding: 12px 14px; + border-radius: 12px; + background: oklch(1 0 0 / 0.04); + border: 1px solid var(--be-line-soft); + font-family: var(--be-font-mono); + font-size: var(--font-size-sm); + word-break: break-all; + line-height: 1.5; + color: var(--be-text-1); + text-align: center; +} + +.actions { + display: flex; + align-items: center; + gap: 8px; + margin-top: 10px; + width: 100%; + max-width: 360px; + justify-content: center; +} + +.primaryAction, +.page .primaryAction { + flex: 1; + height: 44px; + border-radius: 12px; + color: oklch(0.18 0.02 70); + background: linear-gradient(180deg, oklch(0.88 0.15 75), oklch(0.78 0.16 65)); + border: none; +} + +.page .primaryAction:hover, +.page .primaryAction:focus { + color: oklch(0.18 0.02 70); + background: linear-gradient(180deg, oklch(0.9 0.15 75), oklch(0.8 0.16 65)); +} + +.hint { + margin: 12px 0 8px; + font-size: var(--font-size-xs); + color: var(--be-text-2); + text-align: center; + line-height: 1.5; +} diff --git a/src/pages/ReceivePage/ReceivePage.test.js b/src/pages/ReceivePage/ReceivePage.test.js new file mode 100644 index 00000000..9796cf83 --- /dev/null +++ b/src/pages/ReceivePage/ReceivePage.test.js @@ -0,0 +1,93 @@ +import React from 'react' +import { MemoryRouter } from 'react-router' +import { render, screen, fireEvent } from '@testing-library/react' + +const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}) + +jest.mock('@Contexts', () => { + const React = require('react') + const makeCtx = (value) => React.createContext(value) + return { + __esModule: true, + AccountContext: makeCtx({ + addresses: { + btcAddresses: { + btcReceivingAddresses: [{ bc1qnew: { pubkey: { 1: 2 } } }], + btcChangeAddresses: [], + }, + mlAddresses: { + mlReceivingAddresses: ['mtc1qnew'], + mlChangeAddresses: [], + }, + }, + }), + SettingsContext: makeCtx({ networkType: 'mainnet' }), + } +}) + +const renderAt = (state) => + render( + + + , + ) + +// Required after the jest.mock hoisting block. +// eslint-disable-next-line import/first +const ReceivePage = require('./ReceivePage').default + +describe('ReceivePage', () => { + afterAll(() => { + errorSpy.mockRestore() + }) + + it('renders the BTC address string from new-store object entries without crashing', () => { + const { container } = renderAt({ chain: 'Bitcoin' }) + + expect(screen.queryByTestId('error-boundary')).not.toBeInTheDocument() + expect(screen.getByText(/bc1qnew/)).toBeInTheDocument() + expect(screen.getByTestId('qr-code')).toBeInTheDocument() + expect( + container.querySelector('[data-testid="qr-code"] svg'), + ).toBeInTheDocument() + expect(container).not.toBeEmptyDOMElement() + }) + + it('renders the ML address string and QR when the Mintlayer tab is selected', () => { + renderAt({ chain: 'Bitcoin' }) + + fireEvent.click(screen.getByRole('button', { name: 'Mintlayer' })) + + expect(screen.queryByTestId('error-boundary')).not.toBeInTheDocument() + expect(screen.getByText('mtc1qnew')).toBeInTheDocument() + expect(screen.getByTestId('qr-code')).toBeInTheDocument() + }) + + it('pre-selects the chain passed via navigation state', () => { + renderAt({ chain: 'Bitcoin' }) + + expect(screen.getByText(/bc1qnew/)).toBeInTheDocument() + }) + + it('defaults to Mintlayer when no chain is passed (generic entry)', () => { + renderAt(undefined) + + expect(screen.getByText('mtc1qnew')).toBeInTheDocument() + }) + + it('shows the placeholder and fallback text when no address data exists', () => { + const { AccountContext } = require('@Contexts') + render( + + + + + , + ) + + expect(screen.queryByTestId('qr-code')).not.toBeInTheDocument() + expect( + screen.getByText('Address unavailable — unlock your wallet'), + ).toBeInTheDocument() + }) +}) diff --git a/src/pages/RestoreAccount/RestoreAccount.module.css b/src/pages/RestoreAccount/RestoreAccount.module.css index eb960e86..8594b8fe 100644 --- a/src/pages/RestoreAccount/RestoreAccount.module.css +++ b/src/pages/RestoreAccount/RestoreAccount.module.css @@ -1,5 +1,5 @@ .restoreAccountPage { - background-color: rgb(var(--color-white)); + background-color: var(--be-bg-1); } .page { @@ -12,7 +12,7 @@ .title { font-size: var(--font-size-4xl); font-weight: 700; - color: rgb(var(--color-black)); + color: var(--be-text-0); margin-bottom: var(--space-xs); text-align: center; } diff --git a/src/pages/SendBtcTransaction/SendBtcTransaction.js b/src/pages/SendBtcTransaction/SendBtcTransaction.js index 5e2aab80..9718c349 100644 --- a/src/pages/SendBtcTransaction/SendBtcTransaction.js +++ b/src/pages/SendBtcTransaction/SendBtcTransaction.js @@ -29,7 +29,9 @@ const SendBtcTransactionPage = () => { tokenId: ['Mintlayer', 'Bitcoin'].includes(coinType) ? null : coinType, } - const currentBtcAddress = addresses.btcAddresses.btcReceivingAddresses[0] + const currentBtcAddress = BTCHelper.getBtcAddressString( + addresses?.btcAddresses?.btcReceivingAddresses?.[0], + ) const [totalFeeFiat, setTotalFeeFiat] = useState(0) const [totalFeeCrypto, setTotalFeeCrypto] = useState(0) const navigate = useNavigate() @@ -50,7 +52,7 @@ const SendBtcTransactionPage = () => { if (!accountID) { console.log('No account id.') - navigate('/wallet') + navigate('/dashboard') return } diff --git a/src/pages/SendMlTransaction/SendMlTransaction.js b/src/pages/SendMlTransaction/SendMlTransaction.js index bbc480ea..482d06e6 100644 --- a/src/pages/SendMlTransaction/SendMlTransaction.js +++ b/src/pages/SendMlTransaction/SendMlTransaction.js @@ -124,7 +124,7 @@ const SendMlTransactionPage = () => { if (!accountID) { console.log('No account id.') - navigate('/wallet') + navigate('/dashboard') return } diff --git a/src/pages/SignBitcoinTransaction/SignBitcoinTransaction.css b/src/pages/SignBitcoinTransaction/SignBitcoinTransaction.css index 0ce77664..232b4a19 100644 --- a/src/pages/SignBitcoinTransaction/SignBitcoinTransaction.css +++ b/src/pages/SignBitcoinTransaction/SignBitcoinTransaction.css @@ -2,9 +2,9 @@ .SignTransaction { display: flex; flex-direction: column; - background-color: #ffffff; + background-color: var(--be-bg-1); margin: 0 auto; - font-family: 'Arial', sans-serif; + overflow: scroll; width: 100%; height: 100%; @@ -52,7 +52,7 @@ } .SignTransaction .mock_selector div { - background-color: #f3f4f6; + background-color: var(--be-bg-2); padding: 8px 16px; border-radius: 6px; font-size: 14px; @@ -62,7 +62,7 @@ } .SignTransaction .mock_selector div:hover { - background-color: #e5e7eb; + background-color: var(--be-bg-3); } .SignTransaction .transaction-preview-wrapper { @@ -72,12 +72,12 @@ } .SignTransaction .transaction-raw-wrapper { - background-color: #f9fafb; + background-color: var(--be-bg-2); padding: 12px; - border: 1px solid #e5e7eb; + border: 1px solid var(--be-line-soft); border-radius: 6px; font-size: 0.875rem; - color: #4b5563; + color: var(--be-text-1); word-break: break-all; unicode-bidi: embed; font-family: monospace; @@ -113,7 +113,7 @@ flex-direction: column; align-items: center; gap: 40px; - background-color: #ffffff; + background-color: var(--be-bg-1); padding: 20px; border-radius: 8px; text-align: center; @@ -133,8 +133,8 @@ /* HTLC Secret Management Styles */ .htlc-secret-section { - background-color: #f8fafc; - border: 1px solid #e2e8f0; + background-color: var(--be-bg-2); + border: 1px solid var(--be-line-soft); border-radius: 8px; padding: 16px; margin: 16px 0; @@ -142,7 +142,7 @@ .htlc-secret-section h3 { margin: 0 0 12px 0; - color: #1e293b; + color: var(--be-text-0); font-size: 1.1rem; font-weight: 600; } @@ -161,7 +161,7 @@ .secret-item label { font-weight: 500; - color: #475569; + color: var(--be-text-2); font-size: 0.875rem; } @@ -169,8 +169,8 @@ display: flex; align-items: center; gap: 8px; - background-color: #ffffff; - border: 1px solid #d1d5db; + background-color: var(--be-bg-1); + border: 1px solid var(--be-line-soft); border-radius: 4px; padding: 8px; font-family: monospace; @@ -180,7 +180,7 @@ .secret-value span { flex: 1; - color: #374151; + color: var(--be-text-1); } .secret-value button { @@ -193,16 +193,16 @@ } .secret-value button:hover { - background-color: #f3f4f6; + background-color: var(--be-bg-2); } .secret-warning { margin-top: 12px; padding: 12px; - background-color: #fef3c7; - border: 1px solid #f59e0b; + background-color: var(--be-amber-soft); + border: 1px solid var(--be-amber); border-radius: 6px; - color: #92400e; + color: var(--be-amber); font-size: 0.875rem; } @@ -215,11 +215,11 @@ display: block; margin-bottom: 8px; font-weight: 500; - color: #374151; + color: var(--be-text-1); } .secret-error { - color: #dc2626; + color: rgb(var(--color-red)); font-size: 0.875rem; margin-top: 4px; padding: 4px 0; @@ -227,7 +227,7 @@ .secret-hint { margin-top: 4px; - color: #6b7280; + color: var(--be-text-2); } .requestOrigin { @@ -235,10 +235,10 @@ } .sign-error { - color: #dc2626; + color: rgb(var(--color-red)); font-size: 0.75rem; - background-color: #fef2f2; - border: 1px solid #fecaca; + background-color: rgba(235, 87, 87, 0.12); + border: 1px solid rgba(235, 87, 87, 0.45); border-radius: 4px; padding: 6px 8px; } diff --git a/src/pages/SignChallenge/SignChallenge.css b/src/pages/SignChallenge/SignChallenge.css index 8b7dbf3a..045145a2 100644 --- a/src/pages/SignChallenge/SignChallenge.css +++ b/src/pages/SignChallenge/SignChallenge.css @@ -23,15 +23,15 @@ } .challenge_details { - background: #fff; - border: 1px solid #000; + background: var(--be-bg-1); + border: 1px solid var(--be-line); padding: 10px 20px; border-radius: 10px; } .challenge_message { font-size: 16px; - color: #333; + color: var(--be-text-1); margin-bottom: 10px; word-break: break-all; } @@ -56,7 +56,7 @@ .SignChallenge .challenge_details .label { font-weight: bold; - color: #000; + color: var(--be-text-0); } .requestOrigin { @@ -64,10 +64,10 @@ } .sign-error { - color: #dc2626; + color: rgb(var(--color-red)); font-size: 0.75rem; - background-color: #fef2f2; - border: 1px solid #fecaca; + background-color: rgba(235, 87, 87, 0.12); + border: 1px solid rgba(235, 87, 87, 0.45); border-radius: 4px; padding: 6px 8px; } diff --git a/src/pages/SignExternalTransaction/SignExternalTransaction.css b/src/pages/SignExternalTransaction/SignExternalTransaction.css index b7c12af5..6b573121 100644 --- a/src/pages/SignExternalTransaction/SignExternalTransaction.css +++ b/src/pages/SignExternalTransaction/SignExternalTransaction.css @@ -2,9 +2,9 @@ .SignTransaction { display: flex; flex-direction: column; - background-color: #ffffff; + background-color: var(--be-bg-1); margin: 0 auto; - font-family: 'Arial', sans-serif; + overflow: scroll; width: 100%; height: 100%; @@ -56,7 +56,7 @@ } .SignTransaction .mock_selector div { - background-color: #f3f4f6; + background-color: var(--be-bg-2); padding: 8px 16px; border-radius: 6px; font-size: 14px; @@ -66,7 +66,7 @@ } .SignTransaction .mock_selector div:hover { - background-color: #e5e7eb; + background-color: var(--be-bg-3); } .SignTransaction .transaction-preview-wrapper { @@ -76,12 +76,12 @@ } .SignTransaction .transaction-raw-wrapper { - background-color: #f9fafb; + background-color: var(--be-bg-2); padding: 12px; - border: 1px solid #e5e7eb; + border: 1px solid var(--be-line-soft); border-radius: 6px; font-size: 0.875rem; - color: #4b5563; + color: var(--be-text-1); word-break: break-all; unicode-bidi: embed; font-family: monospace; @@ -119,7 +119,7 @@ flex-direction: column; align-items: center; gap: 40px; - background-color: #ffffff; + background-color: var(--be-bg-1); padding: 20px; border-radius: 8px; text-align: center; @@ -139,8 +139,8 @@ /* HTLC Secret Section Styles */ .htlc-secret-section { - background-color: #f8fafc; - border: 1px solid #e2e8f0; + background-color: var(--be-bg-2); + border: 1px solid var(--be-line-soft); border-radius: 8px; padding: 16px; margin: 16px 0; @@ -148,7 +148,7 @@ .htlc-secret-section h3 { margin: 0 0 12px 0; - color: #1e293b; + color: var(--be-text-0); font-size: 1.1rem; font-weight: 600; } @@ -167,7 +167,7 @@ .secret-item label { font-weight: 500; - color: #475569; + color: var(--be-text-2); font-size: 0.875rem; } @@ -175,8 +175,8 @@ display: flex; align-items: center; gap: 8px; - background-color: #ffffff; - border: 1px solid #d1d5db; + background-color: var(--be-bg-1); + border: 1px solid var(--be-line-soft); border-radius: 4px; padding: 8px; } @@ -186,7 +186,7 @@ font-size: 0.75rem; word-break: break-all; flex: 1; - color: #374151; + color: var(--be-text-1); } .secret-value button { @@ -199,18 +199,18 @@ } .secret-value button:hover { - background-color: #f3f4f6; + background-color: var(--be-bg-2); } .secret-actions { margin-top: 8px; padding-top: 12px; - border-top: 1px solid #e2e8f0; + border-top: 1px solid var(--be-line-soft); } .secret-actions p { margin: 0; - color: #6b7280; + color: var(--be-text-2); font-size: 0.875rem; } @@ -225,21 +225,21 @@ .htlc-secret-input label { font-weight: 500; - color: #374151; + color: var(--be-text-1); font-size: 0.875rem; } .secret-error { - color: #dc2626; + color: rgb(var(--color-red)); font-size: 0.75rem; - background-color: #fef2f2; - border: 1px solid #fecaca; + background-color: rgba(235, 87, 87, 0.12); + border: 1px solid rgba(235, 87, 87, 0.45); border-radius: 4px; padding: 6px 8px; } .secret-hint { - color: #6b7280; + color: var(--be-text-2); font-size: 0.75rem; } @@ -252,10 +252,10 @@ } .sign-error { - color: #dc2626; + color: rgb(var(--color-red)); font-size: 0.75rem; - background-color: #fef2f2; - border: 1px solid #fecaca; + background-color: rgba(235, 87, 87, 0.12); + border: 1px solid rgba(235, 87, 87, 0.45); border-radius: 4px; padding: 6px 8px; } diff --git a/src/pages/SignInternalTransaction/SignInternalTransaction.js b/src/pages/SignInternalTransaction/SignInternalTransaction.js index efea7c98..9b94648c 100644 --- a/src/pages/SignInternalTransaction/SignInternalTransaction.js +++ b/src/pages/SignInternalTransaction/SignInternalTransaction.js @@ -1,5 +1,5 @@ import { useLocation, useNavigate } from 'react-router' -import { SignTransaction as SignTxHelpers } from '@Helpers' +import { SignTransaction as SignTxHelpers, ML as MLHelpers } from '@Helpers' import { MOCKS } from './mocks' import { Button, Error, PageWrapper } from '@BasicComponents' import { PopUp, TextField, Loading } from '@ComposedComponents' @@ -20,7 +20,7 @@ import { VerticalGroup, CenteredLayout } from '@LayoutComponents' const TxResult = ({ transactionTxid }) => { const navigate = useNavigate() const goBackToWallet = () => { - navigate('/wallet/Mintlayer') + navigate('/dashboard') } return ( @@ -190,7 +190,8 @@ export const SignTransactionPage = () => { if (txPreviewInfo) { const account = LocalStorageService.getItem('unlockedAccount') const accountName = account.name - const unconfirmedTransactionString = `${AppInfo.UNCONFIRMED_TRANSACTION_NAME}_${accountName}_${networkName}` + const unconfirmedTransactionString = + MLHelpers.getUnconfirmedTransactionKey(accountName, networkName) const unconfirmedTransactions = LocalStorageService.getItem(unconfirmedTransactionString) || [] @@ -231,7 +232,7 @@ export const SignTransactionPage = () => { } const handleReject = () => { - navigate('/wallet/Mintlayer') + navigate('/dashboard') } const selectMock = (name) => { @@ -257,7 +258,7 @@ export const SignTransactionPage = () => {
    - {!external_state && ( + {!external_state && process.env.NODE_ENV === 'development' && (
    {Object.keys(MOCKS).map((key) => { return ( diff --git a/src/pages/SignInternalTransaction/SignInternalTransaction.module.css b/src/pages/SignInternalTransaction/SignInternalTransaction.module.css index 84e33b22..a4d0e5d3 100644 --- a/src/pages/SignInternalTransaction/SignInternalTransaction.module.css +++ b/src/pages/SignInternalTransaction/SignInternalTransaction.module.css @@ -1,9 +1,9 @@ .signTransaction { display: flex; flex-direction: column; - background-color: #ffffff; + background-color: var(--be-bg-1); margin: 0 auto; - font-family: 'Arial', sans-serif; + overflow: scroll; width: 100%; height: 100%; @@ -55,7 +55,7 @@ } .signTransaction .mockSelector div { - background-color: #f3f4f6; + background-color: var(--be-bg-2); padding: 8px 16px; border-radius: 6px; font-size: 14px; @@ -65,7 +65,7 @@ } .signTransaction .mockSelector div:hover { - background-color: #e5e7eb; + background-color: var(--be-bg-3); } .signTransaction .transactionPreviewWrapper { @@ -75,12 +75,12 @@ } .signTransaction .transactionRawWrapper { - background-color: #f9fafb; + background-color: var(--be-bg-2); padding: 12px; - border: 1px solid #e5e7eb; + border: 1px solid var(--be-line-soft); border-radius: 6px; font-size: 0.875rem; - color: #4b5563; + color: var(--be-text-1); word-break: break-all; unicode-bidi: embed; font-family: monospace; @@ -121,7 +121,7 @@ flex-direction: column; align-items: center; gap: 40px; - background-color: #ffffff; + background-color: var(--be-bg-1); border-radius: 8px; text-align: center; } diff --git a/src/pages/StakePage/StakePage.js b/src/pages/StakePage/StakePage.js new file mode 100644 index 00000000..5f55c727 --- /dev/null +++ b/src/pages/StakePage/StakePage.js @@ -0,0 +1,127 @@ +import { useContext } from 'react' + +import { Wallet } from '@ContainerComponents' +import { MintlayerContext, SettingsContext } from '@Contexts' +import { AppInfo } from '@Constants' +import { ML } from '@Helpers' +import { useMlWalletInfo } from '@Hooks' +import { + PageWrapper, + Eyebrow, + Sparkline, + Button, + ChainBadge, +} from '@BasicComponents' +import { ReactComponent as IconArrowTopRight } from '@Assets/images/icon-arrow-right-top.svg' + +import styles from './StakePage.module.css' + +/** + * Staking screen (dark design). Real delegation data from MintlayerContext. + * The growth chart is rebuilt from the wallet's on-chain staking + * transactions ('DelegateStaking' / 'Delegate Withdrawal') and anchored to + * the live delegation total, so the gap to the contributions is the rewards + * accrued while staking. + */ +const StakePage = () => { + const { networkType } = useContext(SettingsContext) + const { mlDelegationList, mlDelegationsBalance, fetchingDelegations } = + useContext(MintlayerContext) + const { transactions } = useMlWalletInfo() + + const { series, contributed, withdrawn } = ML.buildStakeGrowthSeries( + transactions, + mlDelegationsBalance || 0, + ) + const earned = Math.max(0, mlDelegationsBalance - (contributed - withdrawn)) + + const confirmed = mlDelegationList.filter( + (d) => d.type !== 'Unconfirmed' && d.balance?.decimal, + ) + const activeCount = confirmed.filter((d) => !d.decommissioned).length + const inactiveCount = confirmed.length - activeCount + const delegationsLoading = fetchingDelegations && !mlDelegationList.length + + const poolListLink = + networkType === AppInfo.NETWORK_TYPES.TESTNET + ? 'https://lovelace.explorer.mintlayer.org/pools' + : 'https://explorer.mintlayer.org/pools' + + return ( + +
    +
    + Staking + +
    + +
    + Total staked +
    + + {(mlDelegationsBalance || 0).toLocaleString(undefined, { + maximumFractionDigits: 8, + })} + + ML +
    +
    + + + + + {earned.toLocaleString(undefined, { maximumFractionDigits: 8 })} + {' '} + earned + + · + + {activeCount} active + {inactiveCount > 0 ? ` · ${inactiveCount} inactive` : ''} + +
    +
    + + {series.length > 1 && ( +
    + Stake growth +
    + +
    +
    + )} + +
    +
    Delegations
    + +
    + + +
    +
    + ) +} + +export default StakePage diff --git a/src/pages/StakePage/StakePage.module.css b/src/pages/StakePage/StakePage.module.css new file mode 100644 index 00000000..58cadb44 --- /dev/null +++ b/src/pages/StakePage/StakePage.module.css @@ -0,0 +1,121 @@ +.pageWrapper { + padding: 0; +} + +.page { + display: flex; + flex-direction: column; + height: 100%; + padding: 10px 14px 16px; + overflow-y: auto; + animation: be-fade-in 400ms ease both; +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + margin: 6px 0 12px; +} + +.title { + font-size: var(--font-size-lg); + font-weight: 700; + color: var(--be-text-0); +} + +.balanceCard { + background: var(--be-bg-1); + border: 1px solid var(--be-line-soft); + border-radius: 16px; + padding: 14px 16px; + margin-bottom: 14px; +} + +.balanceRow { + display: flex; + align-items: baseline; + gap: 6px; + margin-top: 6px; +} + +.balanceValue { + font-family: var(--be-font-mono); + font-size: 26px; + font-weight: 700; + color: var(--be-text-0); +} + +.balanceTicker { + font-size: var(--font-size-sm); + font-weight: 700; + color: var(--be-text-2); +} + +.statsRow { + display: flex; + align-items: center; + gap: 8px; + margin-top: 8px; + font-size: var(--font-size-xs); + color: var(--be-text-2); +} + +.statValueEarned { + font-family: var(--be-font-mono); + font-weight: 700; + color: var(--be-teal); +} + +.statDot { + color: var(--be-text-3); +} + +.chartCard { + background: var(--be-bg-1); + border: 1px solid var(--be-line-soft); + border-radius: 16px; + padding: 14px 16px; + margin-bottom: 14px; +} + +.chartWrap { + margin-top: 10px; + padding-bottom: 4px; +} + +.section { + margin-top: 4px; +} + +.sectionTitle { + font-size: var(--font-size-sm); + font-weight: 700; + color: var(--be-text-0); + margin-bottom: 8px; +} + +.actions { + display: flex; + gap: 8px; + margin-top: 14px; +} + +.actions a { + flex: 1; + display: block; +} + +.actionButton { + flex: 1; +} + +.actionSecondary { + width: 100%; +} + +.poolIcon { + width: 12px; + height: 12px; + margin-left: 4px; +} diff --git a/src/pages/StakePage/StakePage.test.js b/src/pages/StakePage/StakePage.test.js new file mode 100644 index 00000000..be258563 --- /dev/null +++ b/src/pages/StakePage/StakePage.test.js @@ -0,0 +1,121 @@ +import React from 'react' +import { MemoryRouter } from 'react-router' +import { render } from '@testing-library/react' + +const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}) + +jest.mock('@Hooks', () => ({ + __esModule: true, + useMlWalletInfo: () => ({ + transactions: [ + { + type: 'DelegateStaking', + direction: 'out', + value: 500, + date: 1700000000, + txid: 's1', + }, + { + type: 'DelegateStaking', + direction: 'out', + value: 250, + date: 1700100000, + txid: 's2', + }, + { + type: 'Delegate Withdrawal', + direction: 'in', + value: 100, + date: 1700200000, + txid: 'w1', + }, + { + type: 'Transfer', + direction: 'in', + value: 5, + date: 1700300000, + txid: 't1', + }, + ], + }), + useOnClickOutside: () => {}, +})) + +jest.mock('@Contexts', () => { + const React = require('react') + const makeCtx = (value) => React.createContext(value) + return { + __esModule: true, + AccountContext: makeCtx({}), + SettingsContext: makeCtx({ networkType: 'mainnet' }), + BitcoinContext: makeCtx({}), + TransactionContext: makeCtx({}), + MintlayerContext: makeCtx({ + // contributed 750 - withdrawn 100 = 650 net; live total 660 => earned 10 + mlDelegationsBalance: 660, + fetchingDelegations: false, + mlDelegationList: [ + { + delegation_id: 'mdelg1234567890123456789012345', + pool_id: 'mpool1234567890123456789012345', + balance: { atoms: '50000000000', decimal: '500' }, + spend_destination: 'mtc1qtest', + creation_time: 1700000000, + decommissioned: false, + }, + { + delegation_id: 'mdelg9876543210987654321098765', + pool_id: 'mpool9876543210987654321098765', + balance: { atoms: '15000000000', decimal: '150' }, + spend_destination: 'mtc1qtest', + creation_time: 1700100000, + decommissioned: true, + }, + ], + }), + } +}) + +const renderPage = () => + render( + + + , + ) + +// Required after the jest.mock hoisting block. +// eslint-disable-next-line import/first +const StakePage = require('./StakePage').default + +describe('StakePage', () => { + afterAll(() => { + errorSpy.mockRestore() + }) + + it('renders total staked, earned rewards and delegation counts', () => { + const { container } = renderPage() + + expect(container.textContent).toContain('Total staked') + expect(container.textContent).toContain('660') + expect(container.textContent).toContain('+10') + expect(container.textContent).toContain('1 active') + expect(container.textContent).toContain('1 inactive') + }) + + it('renders the stake growth chart from the staking transactions', () => { + const { getByTestId } = renderPage() + + // 0 -> 500 -> 750 -> 650 (withdrawal), anchored to live total 660 + expect(getByTestId('sparkline')).toBeInTheDocument() + }) + + it('renders the delegation list and only the explorer pool list action', () => { + const { container, getAllByTestId } = renderPage() + + expect(getAllByTestId('delegation')).toHaveLength(2) + expect(container.textContent).toContain('Pool list') + // Delegation management happens on the explorer, not in the app. + expect(container.textContent).not.toContain('Create delegation') + expect(container.textContent).not.toContain('Staking guide') + }) +}) diff --git a/src/pages/Staking/Staking.css b/src/pages/Staking/Staking.css deleted file mode 100644 index 415039ec..00000000 --- a/src/pages/Staking/Staking.css +++ /dev/null @@ -1,5 +0,0 @@ -.staking-page { - display: flex; - flex-direction: column; - height: 100%; -} diff --git a/src/pages/Staking/Staking.js b/src/pages/Staking/Staking.js deleted file mode 100644 index 955f6a0a..00000000 --- a/src/pages/Staking/Staking.js +++ /dev/null @@ -1,29 +0,0 @@ -import { useContext } from 'react' -import { useNavigate } from 'react-router' - -import { CurrentStaking } from '@ComposedComponents' -import { AccountContext } from '@Contexts' -import { PageWrapper } from '@BasicComponents' - -import './Staking.css' - -const StakingPage = () => { - const { accountID } = useContext(AccountContext) - const navigate = useNavigate() - - if (!accountID) { - console.log('No account id.') - navigate('/wallet') - return - } - - return ( - -
    - -
    -
    - ) -} - -export default StakingPage diff --git a/src/pages/Wallet/Wallet.css b/src/pages/Wallet/Wallet.css deleted file mode 100644 index 5cd1ffce..00000000 --- a/src/pages/Wallet/Wallet.css +++ /dev/null @@ -1,44 +0,0 @@ -.wallet-page { - display: flex; - flex-direction: column; - height: 100%; - animation: fadeInWallet 0.3s ease-in-out; -} - -@keyframes fadeInWallet { - from { - opacity: 0; - } - to { - opacity: 1; - } -} - -.transactions-buttons-wrapper { - position: relative; - display: flex; - justify-content: center; - align-items: flex-end; - max-width: 100%; - min-height: max-content; - margin: 15px 0 20px; - gap: 6px; - flex-wrap: wrap; -} - -.balance-transactions-wrapper { - display: flex; - justify-content: space-between; - min-height: max-content; -} - -/* Firefox-specific styles */ -@-moz-document url-prefix() { - .balance-transactions-wrapper { - min-height: 105px; - } - - .transactions-buttons-wrapper { - min-height: 84px; - } -} diff --git a/src/pages/Wallet/Wallet.js b/src/pages/Wallet/Wallet.js deleted file mode 100644 index f8c4484d..00000000 --- a/src/pages/Wallet/Wallet.js +++ /dev/null @@ -1,188 +0,0 @@ -import React, { useNavigate, useParams } from 'react-router' -import { useContext, useState } from 'react' - -import { Balance, PopUp, WalletHeader } from '@ComposedComponents' -import { VerticalGroup } from '@LayoutComponents' -import { Wallet } from '@ContainerComponents' - -import { useExchangeRates, useBtcWalletInfo, useMlWalletInfo } from '@Hooks' -import { AccountContext, MintlayerContext, BitcoinContext } from '@Contexts' -import { BTC } from '@Helpers' -import { PageWrapper } from '@BasicComponents' -import './Wallet.css' -import { StakingWarning } from '@ComposedComponents' - -const ActionButtons = ({ data }) => { - const { unusedAddresses: mintlayerUnusedAddresses } = - useContext(MintlayerContext) - const { unusedAddresses: bitcoinUnusedAddresses } = useContext(BitcoinContext) - const requredAddress = - data.walletType.name === 'Mintlayer' - ? mintlayerUnusedAddresses.receive - : bitcoinUnusedAddresses?.receivingAddress || '' - return ( -
    - {data.walletType.name === 'Mintlayer' && ( - - )} - {data.walletType.chain === 'mintlayer' && ( - - )} - {data.walletType.name === 'Bitcoin' && ( - - )} - data.setOpenShowAddress(true)} - /> - {data.walletType.name === 'Mintlayer' && ( - <> - - - - - - )} - - {data.openShowAddress && ( - - - - )} -
    - ) -} - -const WalletPage = () => { - const navigate = useNavigate() - - const { coinType } = useParams() - const walletType = { - name: coinType, - ticker: coinType === 'Bitcoin' ? 'BTC' : 'ML', - chain: coinType === 'Bitcoin' ? 'bitcoin' : 'mintlayer', - } - - const datahook = - walletType.chain === 'bitcoin' ? useBtcWalletInfo : useMlWalletInfo - - const { addresses } = useContext(AccountContext) - const btcAddress = addresses.btcAddresses - const currentMlAddresses = addresses.mlAddresses - - const checkAddresses = - walletType.chain === 'bitcoin' ? btcAddress : currentMlAddresses - - const [openShowAddress, setOpenShowAddress] = useState(false) - - const { transactions, balance, lockedBalance, unusedAddresses } = datahook( - checkAddresses, - coinType, - ) - - const setOpenBtcTransactionForm = () => { - navigate('/wallet/' + walletType.name + '/send-btc-transaction') - } - const setOpenMlTransactionForm = () => { - navigate('/wallet/' + walletType.name + '/send-ml-transaction') - } - const setOpenStaking = () => { - navigate('/wallet/' + walletType.name + '/staking') - } - const setOpenSignPage = () => { - navigate('/wallet/' + walletType.name + '/sign-message') - } - const setOpenNftPage = () => { - navigate('/wallet/' + walletType.name + '/nft') - } - const setOpenSwapPage = () => { - navigate('/wallet/' + walletType.name + '/order-swap') - } - const setOpenAddressPage = () => { - navigate('/wallet/' + walletType.name + '/address') - } - - const { exchangeRate } = useExchangeRates( - walletType.ticker.toLowerCase(), - 'usd', - ) - - const mlAddress = - currentMlAddresses && currentMlAddresses.mlReceivingAddresses[0] - - const walletBalance = balance - const walletBalanceLocked = lockedBalance || 0 - const walletAddress = walletType.name === 'Bitcoin' ? btcAddress : mlAddress - const walletTransactionList = transactions - - const actionButtonData = { - walletType, - currentMlAddresses, - setOpenStaking, - setOpenBtcTransactionForm, - setOpenMlTransactionForm, - setOpenShowAddress, - setOpenSignPage, - setOpenNftPage, - setOpenSwapPage, - setOpenAddressPage, - walletAddress, - openShowAddress, - unusedAddresses, - walletTransactionList, - } - - return ( - -
    - - - - - - -
    -
    - ) -} - -export default WalletPage diff --git a/src/pages/index.js b/src/pages/index.js index 3f1e3ef2..9dad07ec 100644 --- a/src/pages/index.js +++ b/src/pages/index.js @@ -4,12 +4,10 @@ import HomePage from './Home/Home' import LoginPage from './Login/Login.tsx' import SetAccountPasswordPage from './Login/SetAccountPassword.tsx' import RestoreAccountPage from './RestoreAccount/RestoreAccount.tsx' -import WalletPage from './Wallet/Wallet' import SendBtcTransactionPage from './SendBtcTransaction/SendBtcTransaction' import SendMlTransactionPage from './SendMlTransaction/SendMlTransaction' import DashboardPage from './Dashboard/Dashboard' import SettingsPage from './Settings/Settings.tsx' -import StakingPage from './Staking/Staking' import ConnectionPage from './ConnectionPage/ConnectionPage' import CreateDelegationPage from './CreateDelegation/CreateDelegation' import DelegationStakePage from './DelegationStake/DelegationStake' @@ -25,6 +23,10 @@ import OrderSwapPage from './OrderSwap/OrderSwap' import SignBitcoinTransactionPage from './SignBitcoinTransaction/SignBitcoinTransaction' import ConfirmBtcTransactionPage from './ConfirmBtcTransaction/ConfirmBtcTransaction' import AddressPage from './AddressPage/AddressPage' +import AssetPage from './AssetPage/AssetPage' +import ActivityPage from './ActivityPage/ActivityPage' +import ReceivePage from './ReceivePage/ReceivePage' +import StakePage from './StakePage/StakePage' export { CreateAccountPage, @@ -33,12 +35,10 @@ export { LoginPage, SetAccountPasswordPage, RestoreAccountPage, - WalletPage, SendBtcTransactionPage, SendMlTransactionPage, DashboardPage, SettingsPage, - StakingPage, ConnectionPage, CreateDelegationPage, DelegationStakePage, @@ -54,4 +54,8 @@ export { SignBitcoinTransactionPage, ConfirmBtcTransactionPage, AddressPage, + AssetPage, + ActivityPage, + ReceivePage, + StakePage, } From 19211200af2958b66104cd85e8c4aa9506b67e76 Mon Sep 17 00:00:00 2001 From: Enrico Rubboli Date: Tue, 8 Sep 2026 11:11:11 +0200 Subject: [PATCH 20/52] style(dark-ui): sweep remaining legacy screens onto the dark tokens - sign/confirm/challenge/message screens lose their white backgrounds and Arial font; hardcoded grays/reds/ambers map to be-* tokens - inputs/textareas get an explicit dark background (the UA stylesheet paints undecleared fields white) plus a -webkit-autofill override - WalletCard, Delegation cards/skeletons, TransactionDetails and DelegationDetails banners drop the near-white --mojito-green-soft surfaces - PopUp close icon visible on dark; CreateDelegation loading overlay dark - slider menu: drop the legacy Bitcoin/Mintlayer wallet entries (features live on the Dashboard now) + regression test - login/create-restore/set-password screens use the MojitoLogo badge --- .../AddressList/AddressList.module.css | 14 +- .../AddressList/AddressListItem.module.css | 44 +-- src/components/composed/Balance/Balance.css | 16 +- src/components/composed/Carousel/Carousel.css | 2 +- .../composed/CopyButton/CopyButton.module.css | 2 +- .../CryptoFiatField.module.css | 8 +- .../composed/CurrentStaking/CurrentStaking.js | 135 ------- .../CurrentStaking/CurrentStaking.module.css | 68 ---- .../composed/FeeField/FeeField.module.css | 18 +- .../composed/Header/Header.module.css | 14 +- src/components/composed/Header/Header.tsx | 6 +- .../composed/Loading/Loading.module.css | 3 +- .../LoadingScreen/LoadingScreen.module.css | 2 +- .../LockedBalanceList.module.css | 6 +- .../LockedBalanceListItem.module.css | 6 +- .../composed/Navigation/Navigation.module.css | 6 +- .../composed/Navigation/Navigation.test.js | 12 + .../composed/Navigation/Navigation.tsx | 27 +- .../Navigation/NestedNavigation.module.css | 4 +- .../composed/OptionButtons/OptionButtons.css | 4 +- .../composed/PopUp/Popup.module.css | 15 +- .../composed/PriceChart/PriceChart.css | 8 +- .../ProgressTracker.module.css | 2 +- .../SendPageHeader/SendPageHeader.module.css | 4 +- .../composed/Sidebar/Sidebar.module.css | 16 +- .../composed/SliderMenu/SliderMenu.module.css | 8 +- .../SwapInterface/SelectTokenSwap.module.css | 8 +- .../SwapInterface/SwapInterface.module.css | 12 +- .../SwapInterface/SwapPopupContent.module.css | 6 +- .../composed/UpdateButton/UpdateButton.css | 7 +- .../WalletHeader/WalletHeader.module.css | 8 +- .../containers/Dashboard/CryptoList.css | 244 ------------ .../containers/Dashboard/CryptoList.js | 186 ---------- .../containers/Dashboard/CryptoList.test.js | 350 ------------------ .../Dashboard/CryptoSharesChart.css | 82 ---- .../containers/Dashboard/CryptoSharesChart.js | 72 ---- .../Dashboard/DashboardSkeleton.module.css | 48 --- .../Dashboard/DashboardSkeleton.tsx | 29 -- .../containers/Dashboard/Statistics.css | 135 ------- .../containers/Dashboard/Statistics.js | 44 --- .../DeleteAccount/DeleteAccount.module.css | 6 +- .../containers/Login/AccountCard.module.css | 16 +- .../containers/Login/Login.module.css | 34 +- .../containers/Login/SetPassword.module.css | 86 +++-- .../containers/Login/SetPassword.tsx | 10 +- .../RestoreAccountJson/FileUpload.module.css | 8 +- .../RestoreSuccess.module.css | 2 +- .../WalletDetails.module.css | 6 +- .../SendTransaction/AddressField.module.css | 4 +- .../SendTransaction/AmountField.module.css | 2 +- .../SendTransaction/FeesField.module.css | 2 +- .../SettingsAbout/SettingsAbout.module.css | 6 +- .../SettingsBackup/SettingsBackup.css | 2 +- .../SettingsConnections.module.css | 4 +- .../SettingsDelete/SettingsDelete.css | 2 +- .../SettingsSection.module.css | 2 +- .../ExternalTransactionPreview.css | 10 +- .../InternalTransactionPreview.css | 10 +- .../TransactionBreakdown.module.css | 14 +- .../Wallet/Delegation/Delegation.module.css | 18 +- .../Delegation/DelegationList.module.css | 6 +- .../Delegation/DelegationSkeleton.module.css | 10 +- .../containers/Wallet/Nft/Nft.module.css | 2 +- .../OrderDetails/OrderDetails.module.css | 34 +- .../Orders/OrderItem/OrderItem.module.css | 14 +- .../OrderItem/OrderItemSkeleton.module.css | 2 +- .../Orders/OrderList/OrderList.module.css | 12 +- .../containers/Wallet/ShowAddress.css | 10 +- .../containers/Wallet/Transaction.module.css | 34 +- .../containers/Wallet/TransactionButton.css | 20 +- .../containers/Wallet/TransactionsList.css | 5 +- src/hooks/index.js | 2 + src/utils/Constants/AppInfo/AppInfo.js | 69 +--- 73 files changed, 363 insertions(+), 1782 deletions(-) delete mode 100644 src/components/composed/CurrentStaking/CurrentStaking.js delete mode 100644 src/components/composed/CurrentStaking/CurrentStaking.module.css delete mode 100644 src/components/containers/Dashboard/CryptoList.css delete mode 100644 src/components/containers/Dashboard/CryptoList.js delete mode 100644 src/components/containers/Dashboard/CryptoList.test.js delete mode 100644 src/components/containers/Dashboard/CryptoSharesChart.css delete mode 100644 src/components/containers/Dashboard/CryptoSharesChart.js delete mode 100644 src/components/containers/Dashboard/DashboardSkeleton.module.css delete mode 100644 src/components/containers/Dashboard/DashboardSkeleton.tsx delete mode 100644 src/components/containers/Dashboard/Statistics.css delete mode 100644 src/components/containers/Dashboard/Statistics.js diff --git a/src/components/composed/AddressList/AddressList.module.css b/src/components/composed/AddressList/AddressList.module.css index 1cceafeb..7e2c2fda 100644 --- a/src/components/composed/AddressList/AddressList.module.css +++ b/src/components/composed/AddressList/AddressList.module.css @@ -1,5 +1,5 @@ .card { - background: rgb(var(--color-white)); + background: var(--be-bg-1); border-radius: 16px; box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06); overflow: hidden; @@ -23,14 +23,14 @@ position: sticky; top: 0; z-index: 1; - background: rgb(var(--color-white)); + background: var(--be-bg-1); text-align: left; padding: 14px 20px; font-size: 11px; font-weight: 600; letter-spacing: 0.5px; - color: rgba(var(--color-black), 0.4); - border-bottom: 1px solid rgba(var(--color-black), 0.06); + color: var(--be-text-3); + border-bottom: 1px solid var(--be-line-soft); user-select: none; } @@ -64,7 +64,7 @@ .noAddresses { text-align: center; padding: 2rem; - color: rgba(var(--color-black), 0.4); + color: var(--be-text-3); } .tableScroll::-webkit-scrollbar { @@ -77,10 +77,10 @@ } .tableScroll::-webkit-scrollbar-thumb { - background: rgba(var(--color-black), 0.1); + background: oklch(1 0 0 / 0.1); border-radius: 2px; } .tableScroll::-webkit-scrollbar-thumb:hover { - background: rgba(var(--color-black), 0.2); + background: oklch(1 0 0 / 0.2); } diff --git a/src/components/composed/AddressList/AddressListItem.module.css b/src/components/composed/AddressList/AddressListItem.module.css index da282676..bf925698 100644 --- a/src/components/composed/AddressList/AddressListItem.module.css +++ b/src/components/composed/AddressList/AddressListItem.module.css @@ -1,5 +1,5 @@ .row { - border-bottom: 1px solid rgba(var(--color-black), 0.05); + border-bottom: 1px solid var(--be-line-soft); transition: background 0.15s ease; } @@ -8,13 +8,13 @@ } .row:hover { - background: rgba(var(--color-black), 0.02); + background: oklch(1 0 0 / 0.02); } .cell { padding: 16px 20px; vertical-align: middle; - color: rgb(var(--color-black)); + color: var(--be-text-0); font-size: 14px; } @@ -25,7 +25,7 @@ .addressValue { font-family: monospace; font-size: 14px; - color: rgb(var(--color-black)); + color: var(--be-text-0); text-decoration: none; white-space: nowrap; transition: color 0.15s ease; @@ -46,8 +46,8 @@ } .statusUsed { - background: rgba(var(--color-black), 0.07); - color: rgba(var(--color-black), 0.7); + background: oklch(1 0 0 / 0.07); + color: var(--be-text-2); } .statusUnused { @@ -58,7 +58,7 @@ /* Balance */ .balanceAmount { font-size: var(--default-font-size); - color: rgb(var(--color-black)); + color: var(--be-text-0); white-space: nowrap; } @@ -68,18 +68,18 @@ .balanceTicker { font-weight: 400; - color: rgba(var(--color-black), 0.5); + color: var(--be-text-3); } .balanceDash { - color: rgba(var(--color-black), 0.25); + color: var(--be-text-3); font-size: 16px; } .lockedBalance { display: block; font-size: 12px; - color: rgba(var(--color-black), 0.5); + color: var(--be-text-3); margin-top: 2px; } @@ -88,17 +88,17 @@ width: 36px; height: 36px; padding: 0; - background: rgba(var(--color-black), 0.04); - background-color: rgba(var(--color-black), 0.04); + background: oklch(1 0 0 / 0.04); + background-color: oklch(1 0 0 / 0.04); border-radius: 8px; - color: rgb(var(--color-black)); + color: var(--be-text-0); transition: background 0.15s ease; } .qrButton.qrButton:hover, .qrButton.qrButton:focus { - background: rgba(var(--color-black), 0.08); - background-color: rgba(var(--color-black), 0.08); + background: oklch(1 0 0 / 0.08); + background-color: oklch(1 0 0 / 0.08); } .qrButton.qrButton svg path { @@ -131,7 +131,7 @@ justify-content: space-between; gap: 6px; background: none; - border: 1px solid rgba(var(--color-black), 0.1); + border: 1px solid var(--be-line-soft); border-radius: 6px; padding: 4px 12px; cursor: pointer; @@ -153,19 +153,19 @@ .tokensCount { font-weight: 500; - color: rgb(var(--color-black)); + color: var(--be-text-0); } .toggleIcon { font-size: 8px; transition: transform 0.2s ease; - color: rgba(var(--color-black), 0.4); + color: var(--be-text-3); } .tokensList { padding: 8px; background: rgba(var(--color-green), 0.03); - border: 1px solid rgba(var(--color-black), 0.1); + border: 1px solid var(--be-line-soft); border-top: 0; border-radius: 0 0 10px 10px; animation: tokensSlideDown 0.2s ease-out; @@ -176,7 +176,7 @@ align-items: center; justify-content: space-between; padding: 4px 0; - border-bottom: 1px solid rgba(var(--color-black), 0.05); + border-bottom: 1px solid var(--be-line-soft); font-size: 12px; gap: 3px; } @@ -187,12 +187,12 @@ .tokenAmount { font-weight: 500; - color: rgb(var(--color-black)); + color: var(--be-text-0); } .tokenId { font-family: monospace; - color: rgba(var(--color-black), 0.5); + color: var(--be-text-3); font-size: 11px; } diff --git a/src/components/composed/Balance/Balance.css b/src/components/composed/Balance/Balance.css index b6cfd78b..ea3947f2 100644 --- a/src/components/composed/Balance/Balance.css +++ b/src/components/composed/Balance/Balance.css @@ -2,8 +2,8 @@ display: flex; align-items: center; justify-content: space-between; - background: rgba(var(--color-black), 0.04); - border: 1px solid rgba(var(--color-black), 0.1); + background: var(--be-bg-1); + border: 1px solid var(--be-line-soft); border-radius: 16px; padding: 16px 20px; min-height: max-content; @@ -26,7 +26,7 @@ .balance-label { font-size: 1rem; font-weight: 500; - color: rgba(var(--color-black), 0.45); + color: var(--be-text-2); margin-bottom: 4px; @media screen and (min-width: 901px) { @@ -44,7 +44,7 @@ .balance-value { font-size: 1.8rem; font-weight: 700; - color: rgb(var(--color-black)); + color: var(--be-text-0); @media screen and (min-width: 901px) { font-size: 2rem; @@ -54,7 +54,7 @@ .balance-ticker { font-size: 0.9rem; font-weight: 600; - color: rgba(var(--color-black), 0.35); + color: var(--be-text-3); @media screen and (min-width: 901px) { font-size: 1rem; @@ -64,14 +64,14 @@ .balance-fiat { font-size: 1rem; font-weight: 500; - color: rgba(var(--color-black), 0.45); + color: var(--be-text-2); margin-top: 2px; } .balance-locked { font-size: 14px; font-weight: 500; - color: rgba(var(--color-black), 0.4); + color: var(--be-text-2); margin: 6px 0 0 10px; cursor: pointer; background: none; @@ -81,7 +81,7 @@ } .balance-locked:hover { - color: rgb(var(--color-black)); + color: var(--be-text-0); } .balance-chart { diff --git a/src/components/composed/Carousel/Carousel.css b/src/components/composed/Carousel/Carousel.css index 5ca77a88..dfd75423 100644 --- a/src/components/composed/Carousel/Carousel.css +++ b/src/components/composed/Carousel/Carousel.css @@ -7,7 +7,7 @@ button { display: flex; height: 16rem; padding: 1px; - color: rgb(var(--color-black)); + color: var(--be-text-0); } .back { diff --git a/src/components/composed/CopyButton/CopyButton.module.css b/src/components/composed/CopyButton/CopyButton.module.css index 7de3330c..e17fb59c 100644 --- a/src/components/composed/CopyButton/CopyButton.module.css +++ b/src/components/composed/CopyButton/CopyButton.module.css @@ -7,7 +7,7 @@ align-items: center; justify-content: center; flex-shrink: 0; - color: rgba(var(--color-black), 0.3); + color: var(--be-text-3); transition: color 0.2s; } diff --git a/src/components/composed/CryptoFiatField/CryptoFiatField.module.css b/src/components/composed/CryptoFiatField/CryptoFiatField.module.css index a26dca00..2249871a 100644 --- a/src/components/composed/CryptoFiatField/CryptoFiatField.module.css +++ b/src/components/composed/CryptoFiatField/CryptoFiatField.module.css @@ -26,20 +26,20 @@ right: 16px; font-size: 15px; font-weight: 600; - color: rgba(var(--color-black), 0.4); + color: var(--be-text-3); pointer-events: none; } .bottomNote { font-size: 13px; - color: rgba(var(--color-black), 0.5); + color: var(--be-text-3); } .bottomNote strong { font-weight: 700; - color: rgba(var(--color-black), 0.7); + color: var(--be-text-2); } .separator { - color: rgba(var(--color-black), 0.25); + color: var(--be-text-3); } diff --git a/src/components/composed/CurrentStaking/CurrentStaking.js b/src/components/composed/CurrentStaking/CurrentStaking.js deleted file mode 100644 index 84fac6dc..00000000 --- a/src/components/composed/CurrentStaking/CurrentStaking.js +++ /dev/null @@ -1,135 +0,0 @@ -import { useState, useContext } from 'react' -import { Button } from '@BasicComponents' -import { HelpTooltip } from '@ComposedComponents' -import { CenteredLayout, VerticalGroup } from '@LayoutComponents' -import { Wallet } from '@ContainerComponents' -import { Tooltip } from '@BasicComponents' -import { ReactComponent as IconArrowTopRight } from '@Assets/images/icon-arrow-right-top.svg' - -import { MintlayerContext, SettingsContext } from '@Contexts' - -import styles from './CurrentStaking.module.css' -import { useNavigate, useParams } from 'react-router' - -import { ReactComponent as IconWarning } from '@Assets/images/icon-warning.svg' - -const CurrentStaking = () => { - const navigate = useNavigate() - const [tooltipVisible, setTooltipVisible] = useState(false) - const { coinType } = useParams() - const { networkType } = useContext(SettingsContext) - const { mlDelegationsBalance, fetchingDelegations, mlDelegationList } = - useContext(MintlayerContext) - - const walletType = { - name: coinType, - ticker: coinType === 'Mintlayer' ? 'ML' : 'BTC', - network: coinType === 'Bitcoin' ? 'bitcoin' : 'mintlayer', - } - - const delegationsLoading = - fetchingDelegations && mlDelegationList.length === 0 - const onDelegationCreateButtonClick = () => { - navigate('/wallet/' + walletType.name + '/staking/create-delegation') - } - const stakingGuideLink = - 'https://mintlayer.info/en/Guides/Staking/browser-extension' - const poolListLink = - networkType === 'testnet' - ? 'https://lovelace.explorer.mintlayer.org/pools' - : 'https://explorer.mintlayer.org/pools' - - const decommissionedPools = mlDelegationList.filter( - (delegation) => delegation.decommissioned && delegation.balance.length > 11, - ) - - const handleScrollToPool = () => { - const firstDecommissionedPool = mlDelegationList.find( - (delegation) => - delegation.decommissioned === true && delegation.balance.length > 11, - ).pool_id - const poolList = document.querySelectorAll( - `[data-poolid="${firstDecommissionedPool}"]`, - )[0] - poolList.scrollIntoView({ behavior: 'smooth' }) - } - - const toggleTooltip = () => { - setTooltipVisible(!tooltipVisible) - } - - const tooltipMesage = `Some of your delegations are inactive. ${ - decommissionedPools.length - }${' '} pool${ - decommissionedPools.length > 1 ? 's are' : ' is' - } decommissioned.` - - return ( - -
    -
    -
    -

    Your current staking

    - -
    - -

    - Total staked:{' '} - - {mlDelegationsBalance} - {' '} - ML -

    -
    - {decommissionedPools.length > 0 && ( -
    -
    - -
    - -
    - )} - - - -
    - - - - - -
    - ) -} - -export default CurrentStaking diff --git a/src/components/composed/CurrentStaking/CurrentStaking.module.css b/src/components/composed/CurrentStaking/CurrentStaking.module.css deleted file mode 100644 index acc0c2e4..00000000 --- a/src/components/composed/CurrentStaking/CurrentStaking.module.css +++ /dev/null @@ -1,68 +0,0 @@ -.header { - display: flex; - align-items: flex-start; - justify-content: space-between; - margin: var(--space-3xl) 0 0; - overflow: visible; -} - -.mainInfo { - overflow: visible; -} - -.titleRow { - display: flex; - align-items: center; - gap: var(--space-xs); - margin-bottom: var(--space-2xs); - overflow: visible; -} - -.title { - font-size: var(--font-size-4xl); - font-weight: 700; - color: rgb(var(--color-black)); - margin: 0; -} - -.totalStaked { - font-size: var(--font-size-lg); - color: rgb(var(--color-dark-gray)); - margin: 0; -} - -.totalStakedValue { - font-weight: 700; - color: rgb(var(--color-black)); -} - -.warningBadge { - display: flex; - align-items: center; - justify-content: center; - padding: var(--space-3xs) var(--space-xs); - border-radius: 8px; - background: rgba(var(--color-orange), 0.2); - border: none; - cursor: pointer; - transition: background 0.2s; -} - -.warningBadge:hover { - background: rgba(var(--color-orange), 0.4); -} - -.poolButton { - padding: var(--space-sm) var(--space-xl); - font-size: var(--font-size-sm); -} - -.poolIcon { - width: 13px; - height: 13px; - margin-left: var(--space-xs); -} - -.poolButton:hover .poolIcon { - animation: moveArrowUpRight 0.3s ease-in-out; -} diff --git a/src/components/composed/FeeField/FeeField.module.css b/src/components/composed/FeeField/FeeField.module.css index dceec6d6..bc213f71 100644 --- a/src/components/composed/FeeField/FeeField.module.css +++ b/src/components/composed/FeeField/FeeField.module.css @@ -11,8 +11,8 @@ gap: 4px; padding: 14px 8px; border-radius: 12px; - border: 1px solid rgba(var(--color-black), 0.1); - background: rgba(var(--color-black), 0.02); + border: 1px solid var(--be-line-soft); + background: oklch(1 0 0 / 0.02); cursor: pointer; transition: border-color 0.2s ease, @@ -25,13 +25,13 @@ .tierCardSelected { border-color: rgb(var(--color-main-green)); - background: rgb(var(--color-white)); + background: var(--be-bg-1); } .tierLabel { font-size: 14px; font-weight: 700; - color: rgb(var(--color-black)); + color: var(--be-text-0); } .tierLabelSelected { @@ -40,12 +40,12 @@ .tierTime { font-size: 12px; - color: rgba(var(--color-black), 0.45); + color: var(--be-text-3); } .tierFee { font-size: 12px; - color: rgba(var(--color-black), 0.5); + color: var(--be-text-3); font-weight: 500; } @@ -66,12 +66,12 @@ .feeDisplay { padding: 12px 16px; border-radius: 12px; - border: 1px solid rgba(var(--color-black), 0.1); - background: rgba(var(--color-black), 0.02); + border: 1px solid var(--be-line-soft); + background: oklch(1 0 0 / 0.02); } .feeDisplayValue { font-size: 14px; font-weight: 500; - color: rgb(var(--color-black)); + color: var(--be-text-0); } diff --git a/src/components/composed/Header/Header.module.css b/src/components/composed/Header/Header.module.css index 7c2bc94b..74ccbab5 100644 --- a/src/components/composed/Header/Header.module.css +++ b/src/components/composed/Header/Header.module.css @@ -3,9 +3,9 @@ justify-content: center; position: relative; min-height: 70px; - background: rgb(var(--color-white)); + background: var(--be-bg-1); padding: 20px 31px; - border-bottom: 1px solid rgba(var(--color-black), 0.1); + border-bottom: 1px solid var(--be-line-soft); } .logoWrapper { @@ -31,7 +31,7 @@ } .backButton:hover { - background-color: rgba(var(--color-black), 0.05) !important; + background-color: oklch(1 0 0 / 0.06) !important; } .backButton svg { @@ -41,7 +41,7 @@ .backButton svg path, .backButton svg path { - stroke: rgb(var(--color-black)); + stroke: var(--be-text-1); } .backButton:hover svg path, @@ -65,7 +65,7 @@ } .menuButton svg path { - stroke: rgb(var(--color-black)); + stroke: var(--be-text-1); } .expandWrapped { @@ -100,7 +100,7 @@ } .settingsButton:hover { - background-color: rgba(var(--color-black), 0.05) !important; + background-color: oklch(1 0 0 / 0.06) !important; } .settingsButton svg { @@ -109,7 +109,7 @@ } .settingsButton svg path { - stroke: rgb(var(--color-black)); + stroke: var(--be-text-1); } .invisible { diff --git a/src/components/composed/Header/Header.tsx b/src/components/composed/Header/Header.tsx index d24b5e10..8f806ff1 100644 --- a/src/components/composed/Header/Header.tsx +++ b/src/components/composed/Header/Header.tsx @@ -33,6 +33,8 @@ const Header = () => { const noBackButtonPages = ['/dashboard', '/'] const noBackButton = noBackButtonPages.includes(location.pathname) const isCreateRestorePage = location.pathname === '/create-restore' + // Onboarding welcome screen is self-contained in the new design: no chrome. + const isBarePage = location.pathname === '/' useEffect(() => { const accountUnlocked = isAccountUnlocked() @@ -50,7 +52,7 @@ const Header = () => { return } if (isStakingPage) { - navigate('/wallet/' + coinType) + navigate('/dashboard') return } return customBackAction ? customBackAction() : navigate(-1) @@ -64,6 +66,8 @@ const Header = () => { setSliderMenuOpen(false) } + if (isBarePage) return null + return (
    { expect(screen.queryByTestId('navigation-logout')).not.toBeInTheDocument() }) + test('does not render the old Bitcoin/Mintlayer wallet menu entries', () => { + mockIsAccountUnlocked.mockReturnValue(true) + + renderWithProviders(, { + providerProps, + mintlayerProviderProps, + }) + + expect(screen.queryByText('Bitcoin Wallet')).not.toBeInTheDocument() + expect(screen.queryByText('Mintlayer Wallet')).not.toBeInTheDocument() + }) + test('clicking on Dashboard navigates to /dashboard', () => { mockIsAccountUnlocked.mockReturnValue(true) diff --git a/src/components/composed/Navigation/Navigation.tsx b/src/components/composed/Navigation/Navigation.tsx index f0447098..60dbdbce 100644 --- a/src/components/composed/Navigation/Navigation.tsx +++ b/src/components/composed/Navigation/Navigation.tsx @@ -8,8 +8,6 @@ import { ReactComponent as SettingsImg } from '@Assets/images/icon-settings.svg' import { ReactComponent as LoginImg } from '@Assets/images/icon-login.svg' import { ReactComponent as AddWalletImg } from '@Assets/images/icon-add-wallet.svg' import { ReactComponent as HomeImg } from '@Assets/images/icon-home.svg' -import { ReactComponent as BtcLogo } from '@Assets/images/btc-logo.svg' -import { ReactComponent as MlLogo } from '@Assets/images/logo.svg' import { APP_VERSION } from '@Version' @@ -27,14 +25,10 @@ interface NavigationItem { } interface NavigationProps { - customNavigation?: NavigationItem[] toggleMenu?: boolean } -const Navigation = ({ - customNavigation, - toggleMenu = true, -}: NavigationProps) => { +const Navigation = ({ toggleMenu = true }: NavigationProps) => { const [unlocked, setUnlocked] = useState(false) const [navigationItemID, setNavigationItemID] = useState(null) const navigate = useNavigate() @@ -92,19 +86,6 @@ const Navigation = ({ link: '/dashboard', }, - { - id: 2, - label: 'Bitcoin Wallet', - icon: , - link: '/wallet/Bitcoin', - }, - { - id: 3, - label: 'Mintlayer Wallet', - icon: , - link: '/wallet/Mintlayer', - }, - { id: 4, label: 'Settings', @@ -182,11 +163,7 @@ const Navigation = ({ return location.pathname.startsWith(item.link) } - const navList = customNavigation - ? customNavigation - : unlocked - ? loggedNavigationList - : navigationList + const navList = unlocked ? loggedNavigationList : navigationList return ( <> diff --git a/src/components/composed/Navigation/NestedNavigation.module.css b/src/components/composed/Navigation/NestedNavigation.module.css index 2f81df5d..14535a0a 100644 --- a/src/components/composed/Navigation/NestedNavigation.module.css +++ b/src/components/composed/Navigation/NestedNavigation.module.css @@ -8,7 +8,7 @@ margin-bottom: 1px; border-radius: 4px; cursor: pointer; - color: rgb(var(--color-black)); + color: var(--be-text-0); background: rgba(var(--color-purple), 0.4); transition: all 0.2s ease; } @@ -48,7 +48,7 @@ border-radius: 4px; font-size: 15px; cursor: pointer; - background: rgb(var(--color-white)); + background: var(--be-bg-1); transition: all 0.2s ease; } diff --git a/src/components/composed/OptionButtons/OptionButtons.css b/src/components/composed/OptionButtons/OptionButtons.css index a8b3004f..3f257309 100644 --- a/src/components/composed/OptionButtons/OptionButtons.css +++ b/src/components/composed/OptionButtons/OptionButtons.css @@ -34,7 +34,7 @@ .option-buttons-column .option-button { width: 100%; - background-color: rgb(var(--color-gray)); + background-color: var(--be-bg-1); border: 1px solid rgba(var(--color-light-green), 0.1); border-radius: 20px; margin-bottom: 18px; @@ -44,5 +44,5 @@ .option-buttons-column .option-button:focus { background-color: rgba(var(--color-main-green), 0.2); border: 1px solid rgba(var(--color-light-green), 0.5); - color: rgb(var(--color-black)); + color: var(--be-text-0); } diff --git a/src/components/composed/PopUp/Popup.module.css b/src/components/composed/PopUp/Popup.module.css index 52d4d053..0bf8d0cf 100644 --- a/src/components/composed/PopUp/Popup.module.css +++ b/src/components/composed/PopUp/Popup.module.css @@ -4,7 +4,7 @@ left: 0; width: 100%; height: 100%; - background: rgba(var(--color-black), 0.25); + background: oklch(0 0 0 / 0.55); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); z-index: 9999; @@ -32,9 +32,9 @@ align-items: center; justify-content: center; padding: 68px 16px 14px; - background: rgb(var(--color-white)); + background: var(--be-bg-1); border-radius: 20px; - box-shadow: 0 20px 60px -10px rgba(var(--color-black), 0.25); + box-shadow: 0 20px 60px -10px oklch(0 0 0 / 0.6); overflow: hidden; animation-name: popupAppear; animation-duration: 0.3s; @@ -67,12 +67,12 @@ display: flex; align-items: center; justify-content: center; - background-color: rgba(var(--color-black), 0.05); + background-color: oklch(1 0 0 / 0.06); border-radius: 10px; } .popupCloseButton:hover { - background-color: rgba(var(--color-black), 0.1); + background-color: oklch(1 0 0 / 0.12); } .popupCloseButton svg { @@ -81,11 +81,12 @@ } .popupCloseButton svg path { - stroke: rgba(var(--color-black), 0.6); + /* black stroke was invisible on the dark panel */ + stroke: var(--be-text-2); } .popupCloseButton:hover svg path { - stroke: rgb(var(--color-black)); + stroke: var(--be-text-0); } @keyframes popupAppear { diff --git a/src/components/composed/PriceChart/PriceChart.css b/src/components/composed/PriceChart/PriceChart.css index eeef1b1e..fbf79139 100644 --- a/src/components/composed/PriceChart/PriceChart.css +++ b/src/components/composed/PriceChart/PriceChart.css @@ -22,11 +22,11 @@ display: block; } .crypto-stats .crypto-stats-numbers > .negative { - color: rgb(var(--color-red)); + color: var(--be-red); } .crypto-stats .crypto-stats-numbers > .positive { - color: rgb(var(--color-green)); + color: var(--be-green); } .crypto-stats .crypto-stats-numbers > strong { @@ -53,9 +53,9 @@ @keyframes skeleton-loading { 0% { - background: rgba(var(--color-main-green), 0.1); + background: oklch(1 0 0 / 0.06); } 100% { - background: rgba(var(--color-main-green), 0.2); + background: oklch(1 0 0 / 0.14); } } diff --git a/src/components/composed/ProgressTracker/ProgressTracker.module.css b/src/components/composed/ProgressTracker/ProgressTracker.module.css index 17d9051f..892e82bf 100644 --- a/src/components/composed/ProgressTracker/ProgressTracker.module.css +++ b/src/components/composed/ProgressTracker/ProgressTracker.module.css @@ -5,7 +5,7 @@ width: 100%; min-height: max-content; margin: 2rem 0; - color: rgb(var(--color-black)); + color: var(--be-text-0); } .step { diff --git a/src/components/composed/SendPageHeader/SendPageHeader.module.css b/src/components/composed/SendPageHeader/SendPageHeader.module.css index 8fcdb575..bb2943b3 100644 --- a/src/components/composed/SendPageHeader/SendPageHeader.module.css +++ b/src/components/composed/SendPageHeader/SendPageHeader.module.css @@ -5,7 +5,7 @@ .title { font-size: 22px; font-weight: 700; - color: rgb(var(--color-black)); + color: var(--be-text-0); margin: 0; @media screen and (min-width: 901px) { @@ -24,6 +24,6 @@ .subtitle { font-size: 13px; - color: rgba(var(--color-black), 0.5); + color: var(--be-text-3); margin: 4px 0 0; } diff --git a/src/components/composed/Sidebar/Sidebar.module.css b/src/components/composed/Sidebar/Sidebar.module.css index 3381a843..6883ea6c 100644 --- a/src/components/composed/Sidebar/Sidebar.module.css +++ b/src/components/composed/Sidebar/Sidebar.module.css @@ -8,8 +8,8 @@ height: 100%; flex-shrink: 0; padding: 24px 16px; - background: rgb(var(--color-white)); - border-right: 1px solid rgba(var(--color-black), 0.08); + background: var(--be-bg-1); + border-right: 1px solid var(--be-line-soft); } } @@ -34,7 +34,7 @@ .logoText { font-size: 20px; font-weight: 700; - color: rgb(var(--color-black)); + color: var(--be-text-0); } .accountCard { @@ -64,7 +64,7 @@ .accountName { font-size: 13px; font-weight: 600; - color: rgb(var(--color-black)); + color: var(--be-text-0); white-space: nowrap; text-overflow: ellipsis; overflow: hidden; @@ -72,7 +72,7 @@ .accountAddress { font-size: 11px; - color: rgba(var(--color-black), 0.5); + color: var(--be-text-3); white-space: nowrap; text-overflow: ellipsis; overflow: hidden; @@ -93,7 +93,7 @@ } .copyBtn:hover { - background: rgba(var(--color-black), 0.06); + background: oklch(1 0 0 / 0.06); } .copyIcon { @@ -123,13 +123,13 @@ border-radius: 12px; cursor: pointer; transition: background 0.2s; - color: rgb(var(--color-black)); + color: var(--be-text-0); font-size: 15px; font-weight: 500; } .navItem:hover { - background: rgba(var(--color-black), 0.04); + background: oklch(1 0 0 / 0.04); } .navItemActive { diff --git a/src/components/composed/SliderMenu/SliderMenu.module.css b/src/components/composed/SliderMenu/SliderMenu.module.css index 954d7a38..1520903c 100644 --- a/src/components/composed/SliderMenu/SliderMenu.module.css +++ b/src/components/composed/SliderMenu/SliderMenu.module.css @@ -19,8 +19,8 @@ height: 100%; width: 300px; padding: 65px 30px 40px; - background: rgb(var(--color-white)); - box-shadow: -2px 0 5px rgba(0, 0, 0, 0.1); + background: var(--be-bg-1); + box-shadow: -2px 0 5px oklch(0 0 0 / 0.4); transform: translateX(100%); transition: transform 0.3s ease-in-out; z-index: 99999; @@ -59,7 +59,7 @@ } .closeButton svg path { - stroke: rgb(var(--color-black)); + stroke: var(--be-text-1); } .closeButton:hover { @@ -67,5 +67,5 @@ } .closeButton:hover svg path { - stroke: rgb(var(--color-black)); + stroke: var(--be-text-1); } diff --git a/src/components/composed/SwapInterface/SelectTokenSwap.module.css b/src/components/composed/SwapInterface/SelectTokenSwap.module.css index 1373225d..2b6a149e 100644 --- a/src/components/composed/SwapInterface/SelectTokenSwap.module.css +++ b/src/components/composed/SwapInterface/SelectTokenSwap.module.css @@ -16,9 +16,9 @@ font-size: var(--font-size-xl); border-radius: 21px; padding: var(--space-xs) var(--space-3xl) var(--space-xs) var(--space-md); - border: 1px solid rgba(var(--color-black), 0.06); - background: rgb(var(--color-white)); - color: rgb(var(--color-black)); + border: 1px solid var(--be-line-soft); + background: var(--be-bg-1); + color: var(--be-text-0); cursor: pointer; transition: border-color 0.15s ease; } @@ -40,5 +40,5 @@ transform: translateY(-50%); width: 1em; height: 1em; - color: rgba(var(--color-black), 0.4); + color: var(--be-text-3); } diff --git a/src/components/composed/SwapInterface/SwapInterface.module.css b/src/components/composed/SwapInterface/SwapInterface.module.css index f87442de..25235236 100644 --- a/src/components/composed/SwapInterface/SwapInterface.module.css +++ b/src/components/composed/SwapInterface/SwapInterface.module.css @@ -2,12 +2,12 @@ display: flex; flex-direction: column; width: 100%; - background: rgb(var(--color-white)); + background: var(--be-bg-1); border-radius: 16px; gap: var(--space-3xs); min-height: max-content; padding: var(--space-xl) var(--space-2xl); - color: rgb(var(--color-black)); + color: var(--be-text-0); box-shadow: none; } @@ -46,9 +46,9 @@ font-size: var(--font-size-2xl); font-weight: 700; border-radius: 36px; - border: 1px solid rgba(var(--color-black), 0.08); - background: rgb(var(--color-white)); - color: rgb(var(--color-black)); + border: 1px solid var(--be-line-soft); + background: var(--be-bg-1); + color: var(--be-text-0); } .amountInput:focus { @@ -72,7 +72,7 @@ } .balance { - color: rgba(var(--color-black), 0.35); + color: var(--be-text-3); padding-left: var(--space-lg); font-size: var(--font-size-md); } diff --git a/src/components/composed/SwapInterface/SwapPopupContent.module.css b/src/components/composed/SwapInterface/SwapPopupContent.module.css index 7122ebe7..880edbbc 100644 --- a/src/components/composed/SwapInterface/SwapPopupContent.module.css +++ b/src/components/composed/SwapInterface/SwapPopupContent.module.css @@ -16,9 +16,9 @@ width: 100%; padding: var(--space-sm) var(--space-lg); border-radius: 36px; - border: 1px solid rgba(var(--color-black), 0.1); + border: 1px solid var(--be-line-soft); background: transparent; - color: rgb(var(--color-black)); + color: var(--be-text-0); font-size: var(--font-size-lg); margin-bottom: var(--space-sm); } @@ -38,7 +38,7 @@ padding: var(--space-sm) var(--space-lg); gap: var(--space-sm); border-radius: 10px; - background: rgb(var(--color-gray)); + background: var(--be-bg-1); border: 1px solid rgba(var(--color-light-green), 0.2); min-height: 54px; word-break: break-all; diff --git a/src/components/composed/UpdateButton/UpdateButton.css b/src/components/composed/UpdateButton/UpdateButton.css index 9f36ee0d..19f4a350 100644 --- a/src/components/composed/UpdateButton/UpdateButton.css +++ b/src/components/composed/UpdateButton/UpdateButton.css @@ -3,17 +3,18 @@ width: 28px; height: 28px; padding: 5px; - background: #f0f2f5; + background: oklch(1 0 0 / 0.06); + border-radius: 8px; } .update-button svg { width: 100%; height: 100%; - fill: rgb(var(--color-green)); + fill: var(--be-amber); } .update-button svg path { - fill: rgb(var(--color-green)); + fill: var(--be-amber); } @keyframes spin { diff --git a/src/components/composed/WalletHeader/WalletHeader.module.css b/src/components/composed/WalletHeader/WalletHeader.module.css index ed75a430..b59278b2 100644 --- a/src/components/composed/WalletHeader/WalletHeader.module.css +++ b/src/components/composed/WalletHeader/WalletHeader.module.css @@ -22,7 +22,7 @@ font-size: 1.1rem; font-weight: 700; margin: 0; - color: rgb(var(--color-black)); + color: var(--be-text-0); @media screen and (min-width: 901px) { font-size: 1.4rem; @@ -32,16 +32,16 @@ .subtitle { font-size: 0.9rem; font-weight: 500; - color: rgba(var(--color-black), 0.5); + color: var(--be-text-2); } .changePositive { - color: rgb(var(--color-green)); + color: var(--be-green); font-weight: 600; } .changeNegative { - color: rgb(var(--color-red)); + color: var(--be-red); font-weight: 600; } diff --git a/src/components/containers/Dashboard/CryptoList.css b/src/components/containers/Dashboard/CryptoList.css deleted file mode 100644 index b461fb7a..00000000 --- a/src/components/containers/Dashboard/CryptoList.css +++ /dev/null @@ -1,244 +0,0 @@ -.crypto-network-mask-full { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - background: rgba(var(--color-gray), 0.85); - z-index: 2; - border-radius: 25px; - display: flex; - align-items: flex-start; - justify-content: flex-end; - align-items: center; - padding: 0 3rem 0 0; - pointer-events: all; -} - -.crypto-network-mask-text { - color: rgb(var(--color-red)); - font-size: 1.1em; - font-weight: bold; -} -.crypto-list { - display: flex; - flex-direction: column; - gap: var(--space-3xs); - overflow-y: auto; - overflow-x: hidden; - padding: var(--space-2xs); - - @media screen and (min-width: 901px) { - padding: var(--space-sm); - flex-grow: 1; - } -} - -.crypto-group { - display: flex; - flex-direction: column; - flex-shrink: 0; - gap: var(--space-3xs); - overflow: visible; - list-style: none; -} - -.crypto-group-title { - flex-shrink: 0; - overflow: visible; - font-size: var(--font-size-xs); - font-weight: 600; - letter-spacing: 1px; - text-transform: uppercase; - color: rgb(var(--color-dark-gray)); - padding-left: var(--space-sm); - margin-top: var(--space-2xs); -} - -.crypto-group-title:first-child { - margin-top: 0; -} - -.crypto-item { - position: relative; - display: flex; - justify-content: space-between; - align-items: center; - padding: 12px 29px 12px 8px; - cursor: pointer; - transition: all 0.3s ease-in-out; - background-color: rgb(var(--color-gray)); - border: 1px solid rgba(var(--color-light-green), 0.2); - border-radius: 25px; - max-height: 76px; - min-height: 72px; - box-sizing: border-box; - - @media screen and (max-width: 900px) { - padding: 8px 14px 8px 8px; - } -} - -.logo-wrapper { - display: flex; -} - -.crypto-item:last-child { - margin-bottom: 0; /* Remove margin for the last item */ -} - -.crypto-item .name-values { - display: flex; - flex-direction: column; - justify-content: center; - width: 393px; - margin: 0 0 0 1rem; - - /* Narrow side panel: fill the row instead of a fixed width */ - @media screen and (max-width: 900px) { - width: auto; - flex: 1; - min-width: 0; - margin-left: 0.6rem; - } -} - -.crypto-item .name-values h5 { - font-size: 1.2rem; - font-weight: bold; - - @media screen and (max-width: 900px) { - font-size: 1rem; - } -} - -.crypto-item.add-item h5 { - margin-top: 0.85rem; -} - -.crypto-item.add-item.disabled { - cursor: default; -} - -.crypto-item.add-item.disabled:hover { - transform: scale(1); -} - -.connect-message { - background: rgb(var(--color-green)); - border-radius: 20px; - color: rgb(var(--color-white)); - font-size: 1.125rem; - font-weight: 600; - height: 2.125rem; - margin: 0.85rem 0; - padding: 0.375rem 0.8rem; - text-align: center; - width: 158px; -} - -.crypto-item .name-values .values dl { - display: flex; - align-items: center; -} - -.crypto-item .name-values .values dt, -.crypto-item .name-values .values dd { - font-size: 0.95rem; - display: block; -} - -.crypto-item .name-values .values.big-values dt, -.crypto-item .name-values .values.big-values dd { - font-size: 0.94rem; -} - -.crypto-item .name-values .values dt { - font-weight: thin; - margin: 0 1rem; - - @media screen and (max-width: 900px) { - margin: 0 0.4rem; - } -} - -.crypto-item .name-values .values dd { - font-weight: 600; - line-height: 1.6rem; - margin-bottom: auto; -} - -.crypto-item svg { - height: 56px; - width: 56px; - - @media screen and (max-width: 900px) { - height: 50px; - width: 50px; - } -} - -.crypto-item .logo-round { - height: 56px; - width: 56px; - min-width: 56px; - - @media screen and (max-width: 900px) { - height: 50px; - width: 50px; - min-width: 50px; - } -} - -.crypto-item .token-logo-round { - height: 56px; - width: 56px; - min-width: 56px; - min-height: 56px; - font-size: 18px; - - @media screen and (max-width: 900px) { - height: 50px; - width: 50px; - min-width: 50px; - min-height: 50px; - font-size: 14px; - } -} - -.crypto-item .token-logo-round img { - right: -4px; - bottom: -2px; - height: 10px; - width: 10px; - padding: 3px; -} - -.crypto-item .connect-logo { - background: rgb(var(--color-green)); - border-radius: 50%; - height: 60px; - width: 60px; - - @media screen and (max-width: 900px) { - height: 50px; - width: 50px; - } -} - -.crypto-item .connect-logo img { - height: 60%; - width: 60%; - margin: 20%; -} - -.show-address-text { - line-height: 2rem; - font-size: 1rem; - font-weight: 600; -} - -.crypto-item:hover { - transform: scale(1.02); - border: 1px solid rgba(var(--color-light-green), 0.5); -} diff --git a/src/components/containers/Dashboard/CryptoList.js b/src/components/containers/Dashboard/CryptoList.js deleted file mode 100644 index 57e2aa82..00000000 --- a/src/components/containers/Dashboard/CryptoList.js +++ /dev/null @@ -1,186 +0,0 @@ -import React, { useContext } from 'react' -import { ReactComponent as BtcLogo } from '@Assets/images/btc-logo.svg' -import { LogoRound, SkeletonLoader } from '@BasicComponents' -import { PriceChart } from '@ComposedComponents' -import { AppInfo } from '@Constants' -import { SettingsContext, MintlayerContext } from '@Contexts' - -import './CryptoList.css' -import TokenLogoRound from '../../basic/TokenLogoRound/TokenLogoRound' - -export const CryptoItem = ({ onClickItem, item }) => { - const { networkType } = useContext(SettingsContext) - const fetchingBalances = item.fetchingBalances - const isBtcUnavailable = item.disabled - const { tokenBalances } = useContext(MintlayerContext) - const isTestnet = networkType === AppInfo.NETWORK_TYPES.TESTNET - const balance = item.balance - const fiatBalance = Number(item.balance * item.exchangeRate)?.toFixed(2) - const bigValues = balance.length > 13 - const data = - item.historyRates && - Object.values(item.historyRates).map((value, idx) => [ - idx * 10, - Number(value), - ]) - const symbol = !isTestnet ? item.symbol : 'Testnet' - const isToken = item.type === 'token' - - const onClick = () => { - if (!isBtcUnavailable) { - onClickItem(item) - } - } - - const logoText = - tokenBalances[item.id]?.token_info?.token_ticker?.string.substring(0, 3) || - 'TKN' - - const logo = () => { - if (item.name === 'Mintlayer') { - return - } else if (item.name === 'Bitcoin') { - return - } else { - // TODO: logo for token - return ( - - ) - } - } - - return ( - <> - {fetchingBalances ? ( - - ) : ( -
  • - {isBtcUnavailable && ( -
    - Offline -
    - )} -
    - {logo()} -
    -
    - {item.name} ({symbol}) -
    -
    -
    - {!isTestnet && !isToken ? ( - <> -
    - {balance} {symbol} -
    -
    |
    -
    {fiatBalance} $
    - - ) : ( -
    {balance}
    - )} -
    -
    -
    -
    - - -
  • - )} - - ) -} - -export const ConnectItem = ({ walletType, onClick }) => { - const { networkType } = useContext(SettingsContext) - const isDisabled = walletType.disabled - const symbol = - networkType === AppInfo.NETWORK_TYPES.MAINNET - ? walletType.symbol - : 'Testnet' - - const onItemClick = () => { - if (!isDisabled) onClick(walletType) - } - const message = isDisabled ? 'Coming soon' : 'Add wallet' - return ( -
  • - {walletType.name === 'Mintlayer' ? : } -
    -
    - {walletType.name} ({symbol}) -
    -
    -
    {message}
    -
  • - ) -} -const CryptoList = ({ cryptoList, onWalletItemClick, onConnectItemClick }) => { - const missingWalletTypes = AppInfo.walletTypes.filter( - (walletType) => - !cryptoList.find((crypto) => crypto.name === walletType.name), - ) - const coins = cryptoList.filter((crypto) => crypto.type !== 'token') - const tokens = cryptoList.filter((crypto) => crypto.type === 'token') - const showGroupTitles = tokens.some((token) => !token.isPlaceholder) - - return ( -
    - {showGroupTitles ?
    Coins
    : null} -
      - {coins.map((crypto) => ( - - ))} - - {missingWalletTypes.map((walletType) => ( - - ))} -
    - - {tokens.length ? ( - <> - {showGroupTitles ? ( -
    Tokens
    - ) : null} -
      - {tokens.map((token) => ( - - ))} -
    - - ) : null} -
    - ) -} - -export default CryptoList diff --git a/src/components/containers/Dashboard/CryptoList.test.js b/src/components/containers/Dashboard/CryptoList.test.js deleted file mode 100644 index f90fea49..00000000 --- a/src/components/containers/Dashboard/CryptoList.test.js +++ /dev/null @@ -1,350 +0,0 @@ -import React from 'react' -import { render, fireEvent, screen } from '@testing-library/react' -import { SettingsContext, MintlayerContext } from '@Contexts' -import { CryptoItem, ConnectItem } from './CryptoList' -import CryptoList from './CryptoList' - -describe('CryptoItem', () => { - const colorList = { - btc: '#f7931a', - ml: '#00bfff', - } - - const item = { - name: 'Bitcoin', - symbol: 'BTC', - balance: 1.23456789, - exchangeRate: 50000, - historyRates: { - '2022-01-01': 40000, - '2022-01-02': 45000, - '2022-01-03': 50000, - }, - change24h: 1.23, - } - - const onClickItem = jest.fn() - - const renderComponent = (networkType, balanceLoading = false) => - render( - - - - - , - ) - - it('renders the crypto item correctly', () => { - renderComponent('mainnet') - const component = screen.getByTestId('crypto-item') - - expect(component).toBeInTheDocument() - expect(component).toHaveTextContent('1.23456789') - expect(component).toHaveTextContent('61728.39') - expect(component).toHaveTextContent('1.23%') - }) - - // it('renders the crypto item with data loading', () => { - // renderComponent('mainnet', true) - // const skeletonLoading = screen.getByTestId('card') - - // expect(skeletonLoading).toBeInTheDocument() - // }) - - it('renders the Mintlayer logo for Mintlayer items', () => { - const mintlayerItem = { - ...item, - name: 'Mintlayer', - symbol: 'ML', - } - - render( - - - - - , - ) - - expect(screen.getByTestId('logo-round')).toBeInTheDocument() - }) - - it('calls the onClickItem callback when the item is clicked', () => { - renderComponent('mainnet') - - fireEvent.click(screen.getByTestId('crypto-item')) - - expect(onClickItem).toHaveBeenCalledWith(item) - }) - - it('displays the balance in testnet mode', () => { - renderComponent('testnet') - const component = screen.getByTestId('crypto-item') - - expect(component).toHaveTextContent('1.23456789') - }) - - it('displays the balance in mainnet mode', () => { - renderComponent('mainnet') - const component = screen.getByTestId('crypto-item') - - expect(component).toHaveTextContent('61728.39') - }) -}) - -describe('ConnectItem', () => { - const walletType = { - name: 'Bitcoin', - symbol: 'BTC', - disabled: false, - } - - const onClick = jest.fn() - - const renderComponent = (networkType) => - render( - - - - - , - ) - - it('renders the connect item correctly', () => { - renderComponent('mainnet') - - expect(screen.getByText('Bitcoin (BTC)')).toBeInTheDocument() - expect(screen.getByText('Add wallet')).toBeInTheDocument() - }) - - it('renders the Mintlayer logo for Mintlayer items', () => { - const mintlayerWalletType = { - ...walletType, - name: 'Mintlayer', - symbol: 'ML', - } - - render( - - - - - , - ) - - expect(screen.getByTestId('logo-round')).toBeInTheDocument() - }) - - it('calls the onClick callback when the item is clicked', () => { - renderComponent('mainnet') - - fireEvent.click(screen.getByText('Add wallet')) - - expect(onClick).toHaveBeenCalledWith(walletType) - }) - - it('does not disable the item for other wallet types on testnet', () => { - const otherWalletType = { - ...walletType, - name: 'Other', - symbol: 'OTH', - disabled: false, - } - - render( - - - - - , - ) - - expect(screen.getByText('Add wallet')).toBeInTheDocument() - expect(screen.getByTestId('connect-item')).not.toHaveClass('disabled') - }) -}) - -describe('CryptoList', () => { - const colorList = { - btc: '#f7931a', - ml: '#00bfff', - } - - //TDOO: enable this test when mainnet is ready - - // const cryptoList = [ - // { - // name: 'Bitcoin', - // symbol: 'BTC', - // balance: 1.23456789, - // exchangeRate: 50000, - // historyRates: { - // '2022-01-01': 40000, - // '2022-01-02': 45000, - // '2022-01-03': 50000, - // }, - // change24h: 1.23, - // }, - // { - // name: 'Mintlayer', - // symbol: 'ML', - // balance: 100, - // exchangeRate: 1, - // historyRates: { - // '2022-01-01': 1, - // '2022-01-02': 2, - // '2022-01-03': 3, - // }, - // change24h: -4.56, - // }, - // ] - - const onWalletItemClick = jest.fn() - const onConnectItemClick = jest.fn() - - //TDOO: enable this test when mainnet is ready - // const renderComponent = (networkType) => - // render( - // - // - // - // - // , - // , - // ) - - const renderEmptyComponent = (networkType) => - render( - - - - - , - ) - - //TDOO: enable this test when mainnet is ready - - // it('renders the list of crypto items', () => { - // renderComponent('mainnet') - - // const items = screen.getAllByTestId('crypto-item') - // expect(items).toHaveLength(2) - // }) - - // it('calls the onWalletItemClick callback when a crypto item is clicked', () => { - // renderComponent('mainnet') - // const items = screen.getAllByTestId('crypto-item') - // fireEvent.click(items[0]) - - // expect(onWalletItemClick).toHaveBeenCalledWith(cryptoList[0]) - // expect(onWalletItemClick).toHaveBeenCalledTimes(1) - - // fireEvent.click(items[1]) - // expect(onWalletItemClick).toHaveBeenCalledWith(cryptoList[1]) - // expect(onWalletItemClick).toHaveBeenCalledTimes(2) - // }) - - it('calls the onConnectItemClick callback when the add wallet item is clicked', () => { - renderEmptyComponent('mainnet') - - fireEvent.click(screen.getAllByText('Add wallet')[0]) - - expect(onConnectItemClick).toHaveBeenCalled() - }) - - const coin = { - id: 'Mintlayer', - name: 'Mintlayer', - symbol: 'ML', - balance: 100, - exchangeRate: 1, - historyRates: [], - change24h: 0, - type: 'coin', - } - - const token = { - id: 'token-id', - name: 'OHFORF', - symbol: 'OHFORF', - balance: 45, - change24h: 0, - historyRates: [], - type: 'token', - } - - const renderWithList = (list) => - render( - - - - - , - ) - - it('splits coins and tokens into separate groups', () => { - renderWithList([coin, token]) - - expect(screen.getByText('Coins')).toBeInTheDocument() - expect(screen.getByText('Tokens')).toBeInTheDocument() - - const groups = document.querySelectorAll('.crypto-group') - expect(groups).toHaveLength(2) - expect(groups[0]).toHaveTextContent('Mintlayer (ML)') - expect(groups[1]).toHaveTextContent('OHFORF (OHFORF)') - }) - - it('hides the group titles when there are no tokens', () => { - renderWithList([coin]) - - expect(screen.queryByText('Coins')).not.toBeInTheDocument() - expect(screen.queryByText('Tokens')).not.toBeInTheDocument() - expect(document.querySelectorAll('.crypto-group')).toHaveLength(1) - }) -}) diff --git a/src/components/containers/Dashboard/CryptoSharesChart.css b/src/components/containers/Dashboard/CryptoSharesChart.css deleted file mode 100644 index 163211d0..00000000 --- a/src/components/containers/Dashboard/CryptoSharesChart.css +++ /dev/null @@ -1,82 +0,0 @@ -.chart { - position: relative; - overflow: visible; -} - -.portifolio-chart { - position: relative; - width: 325px; - height: 168px; - overflow: visible; - - /* Narrow side panel: let the scalable chart shrink to the panel width */ - @media screen and (max-width: 900px) { - width: 100%; - max-width: 400px; - height: auto; - aspect-ratio: 210 / 110; - } -} - -.portifolio-chart h2 { - display: block; - position: absolute; - top: 67%; - left: 50%; - transform: translate(-50%, -50%); - text-align: center; - font-weight: bold; - font-size: 1.875rem; - - @media screen and (max-width: 900px) { - font-size: 1.4rem; - } -} - -.portifolio-chart h2 em { - font-size: 0.7rem; - font-style: normal; - font-weight: 500; - display: block; - text-transform: uppercase; - letter-spacing: 0.15em; - color: rgb(var(--color-dark-gray)); - margin-bottom: 12px; -} - -.balance-display { - display: block; - font-size: 3rem; - font-weight: 800; - line-height: 1; - - @media screen and (max-width: 900px) { - font-size: 2rem; - } -} - -.balance-symbol { - font-size: 1rem; - font-weight: 600; - vertical-align: super; - color: rgb(var(--color-light-gray)); -} - -.balance-integer { - font-size: 4.2rem; - font-weight: 800; - - @media screen and (max-width: 900px) { - font-size: 2.6rem; - } -} - -.balance-decimal { - font-size: 4.2rem; - font-weight: 800; - opacity: 0.25; - - @media screen and (max-width: 900px) { - font-size: 2.6rem; - } -} diff --git a/src/components/containers/Dashboard/CryptoSharesChart.js b/src/components/containers/Dashboard/CryptoSharesChart.js deleted file mode 100644 index a717e054..00000000 --- a/src/components/containers/Dashboard/CryptoSharesChart.js +++ /dev/null @@ -1,72 +0,0 @@ -import { useContext } from 'react' -import { ArcChart } from '@ComposedComponents' -import { Format } from '@Helpers' -import { MintlayerContext, SettingsContext } from '@Contexts' -import { AppInfo } from '@Constants' -import { BalanceSkeleton } from './DashboardSkeleton' - -import './CryptoSharesChart.css' - -const CryptoSharesChart = ({ - cryptos, - totalBalance, - fiatSymbol = 'USD', - accountName = 'Account Name', -}) => { - const { networkType } = useContext(SettingsContext) - const { balanceLoading } = useContext(MintlayerContext) - const totalBalanceInFiat = - networkType === AppInfo.NETWORK_TYPES.TESTNET - ? '0' - : Format.fiatValue(totalBalance) - const isTestnet = networkType === AppInfo.NETWORK_TYPES.TESTNET - const hasBalance = totalBalance > 0 && !isTestnet - - const [integerPart, decimalPart] = totalBalanceInFiat.split( - AppInfo.decimalSeparator, - ) - - const data = hasBalance - ? cryptos.map((crypto) => ({ - value: (crypto.balance * crypto.exchangeRate).toFixed(2), - asset: crypto.name, - color: AppInfo.COLOR_LIST[crypto.symbol.toLowerCase()], - valueSymbol: fiatSymbol, - })) - : [{ value: 1, asset: '', color: AppInfo.COLOR_LIST.ml, valueSymbol: '' }] - - return ( - <> -
    -
    - -
    -

    - {accountName} - {balanceLoading ? ( - - ) : ( - - $ - {integerPart} - {decimalPart !== undefined && ( - <> - - {AppInfo.decimalSeparator} - {decimalPart} - - - )} - - )} -

    -
    - - ) -} - -export default CryptoSharesChart diff --git a/src/components/containers/Dashboard/DashboardSkeleton.module.css b/src/components/containers/Dashboard/DashboardSkeleton.module.css deleted file mode 100644 index 27597755..00000000 --- a/src/components/containers/Dashboard/DashboardSkeleton.module.css +++ /dev/null @@ -1,48 +0,0 @@ -.statItemSkeleton { - pointer-events: none; - width: 210px; - height: 112px; -} - -.statItemSkeleton dt, -.statItemSkeleton dd { - width: 100%; -} - -.skeletonLine { - display: block; - border-radius: 4px; - animation: skeleton-dash-loading 1s linear infinite alternate; -} - -.skeletonLineWide { - width: 70%; - height: 1.4rem; - margin-top: 4px; -} - -.skeletonLineNarrow { - width: 40%; - height: 0.55rem; -} - -.balanceSkeleton { - display: block; - margin-top: 4px; -} - -.skeletonLineBalance { - width: 140px; - height: 2.2rem; - border-radius: 6px; - margin: 0 auto; -} - -@keyframes skeleton-dash-loading { - 0% { - background: rgba(var(--color-dark-gray), 0.08); - } - 100% { - background: rgba(var(--color-dark-gray), 0.16); - } -} diff --git a/src/components/containers/Dashboard/DashboardSkeleton.tsx b/src/components/containers/Dashboard/DashboardSkeleton.tsx deleted file mode 100644 index 35252819..00000000 --- a/src/components/containers/Dashboard/DashboardSkeleton.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import React from 'react' -import styles from './DashboardSkeleton.module.css' - -const StatisticsSkeleton: React.FC = () => ( -
      -
    • -
      -
      -
    • -
    • -
      -
      -
    • -
    -) - -const BalanceSkeleton: React.FC = () => ( - - - -) - -export { StatisticsSkeleton, BalanceSkeleton } diff --git a/src/components/containers/Dashboard/Statistics.css b/src/components/containers/Dashboard/Statistics.css deleted file mode 100644 index 0bddfcf0..00000000 --- a/src/components/containers/Dashboard/Statistics.css +++ /dev/null @@ -1,135 +0,0 @@ -.highest-balance { - text-align: center; - margin-top: 0.5rem; -} - -.stats-list { - display: flex; - align-items: center; - justify-content: flex-end; - height: 100%; - width: 100%; - - @media screen and (max-width: 900px) { - justify-content: center; - } -} - -.stats-list ul { - display: flex; - flex-direction: column; - gap: 0; - padding: 0; - margin: 0; - list-style: none; - height: 100%; - width: 100%; - position: relative; - - /* Narrow side panel: place the stats side by side under the chart */ - @media screen and (max-width: 900px) { - flex-direction: row; - justify-content: center; - } -} - -.stats-list ul::before { - content: ''; - position: absolute; - left: 0; - top: 24%; - bottom: 24%; - width: 1px; - background: rgb(var(--color-light-gray)); - opacity: 0.5; - - @media screen and (max-width: 900px) { - display: none; - } -} - -.stat-item { - display: flex; - flex-direction: column; - text-align: left; - padding: 24px 16px 22px 40px; - border-left: none; - background: none; - border-radius: 0; - flex: 1; - justify-content: center; - position: relative; - - @media screen and (max-width: 900px) { - padding: 4px 16px; - text-align: center; - align-items: center; - } -} - -.stat-item + .stat-item::before { - content: ''; - position: absolute; - top: 0; - left: 46px; - right: 0; - height: 1px; - background: rgb(var(--color-light-gray)); - opacity: 0.5; - - /* Turn the horizontal separator into a vertical one for the row layout */ - @media screen and (max-width: 900px) { - top: 18%; - bottom: 18%; - left: 0; - right: auto; - height: auto; - width: 1px; - } -} - -.stat-item.stats-positive { - background: none; -} - -.stat-item.stats-negative { - background: none; -} - -.stat-item dd { - font-size: 0.75rem; - font-weight: 500; - text-transform: uppercase; - letter-spacing: 0.12em; - color: rgb(var(--color-dark-gray)); - margin: 0 0 4px 0; - order: -1; - white-space: nowrap; -} - -.stat-item dt { - display: flex; - align-items: baseline; - gap: 4px; - font-weight: 700; - font-size: 2.7rem; - line-height: 1.1; - - @media screen and (max-width: 900px) { - font-size: 1.6rem; - } -} - -.stats-positive dt { - color: rgb(var(--color-stats-green)); -} - -.stats-negative dt { - color: rgb(var(--color-red)); -} - -.stats-list .stat-unit { - font-size: 1.1rem; - font-weight: 500; - opacity: 0.5; -} diff --git a/src/components/containers/Dashboard/Statistics.js b/src/components/containers/Dashboard/Statistics.js deleted file mode 100644 index 10b4d7a4..00000000 --- a/src/components/containers/Dashboard/Statistics.js +++ /dev/null @@ -1,44 +0,0 @@ -import { useContext } from 'react' -import { VerticalGroup } from '@LayoutComponents' -import { MintlayerContext } from '@Contexts' -import { StatisticsSkeleton } from './DashboardSkeleton' - -import './Statistics.css' - -const Statistics = ({ stats = [] }) => { - const { balanceLoading } = useContext(MintlayerContext) - - return ( - <> - -
    - {balanceLoading ? ( - - ) : ( -
      - {stats.map((stat) => ( -
    • = 0 - ? 'stats-positive' - : 'stats-negative' - }`} - > -
      - {parseFloat(stat.value) >= 0 ? '+' : '-'} - {Math.abs(parseFloat(stat.value))} - {stat.unit} -
      -
      {stat.name}
      -
    • - ))} -
    - )} -
    -
    - - ) -} - -export default Statistics diff --git a/src/components/containers/DeleteAccount/DeleteAccount.module.css b/src/components/containers/DeleteAccount/DeleteAccount.module.css index 4e5249f8..d192e686 100644 --- a/src/components/containers/DeleteAccount/DeleteAccount.module.css +++ b/src/components/containers/DeleteAccount/DeleteAccount.module.css @@ -29,7 +29,7 @@ font-size: 20px; font-weight: 700; text-align: center; - color: rgb(var(--color-black)); + color: var(--be-text-0); margin-bottom: 8px; } @@ -87,7 +87,7 @@ gap: 12px; padding: 14px 16px; border-radius: 16px; - background: rgb(var(--color-gray)); + background: var(--be-bg-1); cursor: pointer; transition: background 0.2s ease; } @@ -114,7 +114,7 @@ .checkboxItem span { font-size: 14px; - color: rgb(var(--color-black)); + color: var(--be-text-0); } .buttonRow { diff --git a/src/components/containers/Login/AccountCard.module.css b/src/components/containers/Login/AccountCard.module.css index 29ea38c6..43cf0271 100644 --- a/src/components/containers/Login/AccountCard.module.css +++ b/src/components/containers/Login/AccountCard.module.css @@ -5,8 +5,8 @@ min-height: 68px; padding: var(--space-md) var(--space-xl); border-radius: 16px; - border: 1px solid rgba(var(--color-black), 0.1); - background: rgb(var(--color-white)); + border: 1px solid var(--be-line-soft); + background: var(--be-bg-1); cursor: pointer; transition: border-color 0.2s, @@ -19,8 +19,8 @@ } .card:hover { - border-color: rgba(var(--mojito-green), 1); - box-shadow: 0 2px 12px rgba(var(--color-black), 0.06); + border-color: oklch(0.82 0.16 70 / 0.5); + box-shadow: 0 4px 20px oklch(0 0 0 / 0.3); } .avatar { @@ -38,7 +38,7 @@ .cardName { font-size: var(--font-size-lg); font-weight: 600; - color: rgb(var(--color-black)); + color: var(--be-text-0); margin: 0; } @@ -64,18 +64,18 @@ .deleteIcon { width: 13px; height: 13px; - fill: rgba(var(--color-black), 0.3); + fill: var(--be-text-3); transition: fill 0.2s; } .deleteButton:hover .deleteIcon { - fill: rgb(var(--color-red)); + fill: var(--be-red); } .chevron { width: 18px; height: 18px; flex-shrink: 0; - color: rgba(var(--color-black), 0.25); + color: var(--be-text-3); transform: rotate(-90deg); } diff --git a/src/components/containers/Login/Login.module.css b/src/components/containers/Login/Login.module.css index 2d81a7a0..7ad2a8e8 100644 --- a/src/components/containers/Login/Login.module.css +++ b/src/components/containers/Login/Login.module.css @@ -2,23 +2,16 @@ display: flex; flex-direction: column; align-items: center; - animation: fadeIn 0.3s ease-in-out; + justify-content: center; + flex: 1; + animation: be-fade-in 0.3s ease-in-out; width: 100%; } -@keyframes fadeIn { - from { - opacity: 0; - } - to { - opacity: 1; - } -} - .heading { font-size: var(--font-size-3xl); font-weight: 700; - color: rgb(var(--color-black)); + color: var(--be-text-0); margin: var(--space-xl) 0 var(--space-2xs); @media screen and (min-width: 901px) { @@ -29,7 +22,7 @@ .subtitle { font-size: var(--font-size-sm); - color: rgba(var(--color-black), 0.5); + color: var(--be-text-2); margin: 0 0 var(--space-2xl); @media screen and (min-width: 901px) { @@ -63,26 +56,29 @@ max-width: 400px; padding: var(--space-lg); margin-top: var(--space-3xl); - border-radius: 16px; - border: 1.5px dashed rgba(var(--color-black), 0.15); - background: transparent; + border-radius: 14px; + border: 1px solid var(--be-line); + background: oklch(1 0 0 / 0.05); cursor: pointer; font-size: var(--font-size-md); - color: rgba(var(--color-black), 0.45); + font-weight: 600; + color: var(--be-text-0); transition: + background 0.2s, border-color 0.2s, color 0.2s; @media screen and (min-width: 901px) { max-width: 460px; padding: var(--space-xl); - border-radius: 18px; + border-radius: 14px; } } .addButton:hover { - border: 1.5px dashed rgba(var(--mojito-green), 1); - color: rgba(var(--mojito-green), 1); + border-color: oklch(0.82 0.16 70 / 0.5); + background: var(--be-amber-soft); + color: var(--be-amber); } .addIcon { diff --git a/src/components/containers/Login/SetPassword.module.css b/src/components/containers/Login/SetPassword.module.css index e45863a2..506da598 100644 --- a/src/components/containers/Login/SetPassword.module.css +++ b/src/components/containers/Login/SetPassword.module.css @@ -1,21 +1,3 @@ -.shieldBadge { - display: flex; - align-items: center; - justify-content: center; - width: 60px; - height: 60px; - margin: 0 auto var(--space-xl); - border-radius: 20px; - background: rgb(var(--mojito-green-soft)); - border: 1px solid rgba(30, 187, 129, 0.2); -} - -.shieldBadge svg { - width: 36px; - height: 36px; - color: rgb(var(--mojito-green)); -} - .content { margin-top: var(--space-5xl); justify-content: space-between; @@ -24,6 +6,11 @@ display: flex; flex-direction: column; height: 100%; + color: var(--be-text-1); +} + +.content p { + color: var(--be-text-1); } .form { @@ -34,6 +21,19 @@ } } +.logoBadge { + display: flex; + align-items: center; + justify-content: center; + width: 88px; + height: 88px; + margin: 0 auto var(--space-xl); + border-radius: 50%; + background: oklch(0.82 0.16 70 / 0.08); + border: 1px solid var(--be-line); + box-shadow: 0 0 24px oklch(0.82 0.16 70 / 0.28); +} + .loginButtonIcon { width: 13px; height: 13px; @@ -42,9 +42,25 @@ margin-left: var(--space-sm); } -.loginPasswordSubmit { +.form .loginPasswordSubmit { width: 100%; - margin-top: var(--space-3xs); + margin-top: var(--space-md); + height: 52px; + color: oklch(0.18 0.02 70); + background: linear-gradient(180deg, oklch(0.88 0.15 75), oklch(0.78 0.16 65)); + border: none; + border-radius: 14px; + box-shadow: 0 8px 24px -8px oklch(0.82 0.16 70 / 0.6); +} + +.form .loginPasswordSubmit:hover, +.form .loginPasswordSubmit:focus { + color: oklch(0.18 0.02 70); + background: linear-gradient(180deg, oklch(0.9 0.15 75), oklch(0.8 0.16 65)); +} + +.form .loginPasswordSubmit svg path { + stroke: oklch(0.18 0.02 70); } .loginPasswordSubmit:hover .loginButtonIcon { @@ -62,15 +78,39 @@ font-size: var(--font-size-3xl); margin-bottom: var(--space-3xs); font-weight: 600; - color: rgb(var(--color-black)); + color: var(--be-text-0); } .labelRow h2 { font-size: var(--font-size-xl); font-weight: 400; + color: var(--be-text-0); } -.labelRow p { +.form .labelRow p { font-size: var(--font-size-md); - color: rgb(var(--color-dark-gray)); + color: var(--be-text-2); +} + +/* Dark input overrides (scoped to this form) */ +.form input { + color: var(--be-text-0); + background: oklch(1 0 0 / 0.04); + border: 1px solid var(--be-line); + border-radius: 14px; + caret-color: var(--be-amber); +} + +.form input::placeholder { + color: var(--be-text-3); +} + +.form input:focus { + border-color: oklch(0.82 0.16 70 / 0.5); + outline: none; + box-shadow: 0 0 0 3px var(--be-amber-soft); +} + +.form [data-testid='error'] { + color: var(--be-red); } diff --git a/src/components/containers/Login/SetPassword.tsx b/src/components/containers/Login/SetPassword.tsx index b433b54f..8fd9b130 100644 --- a/src/components/containers/Login/SetPassword.tsx +++ b/src/components/containers/Login/SetPassword.tsx @@ -1,11 +1,10 @@ import { useState, FormEvent, ReactNode } from 'react' import { useLocation } from 'react-router' -import { Button } from '@BasicComponents' +import { Button, MojitoLogo } from '@BasicComponents' import { LoadingScreen, TextField } from '@ComposedComponents' import { VerticalGroup, CenteredLayout } from '@LayoutComponents' import { ReactComponent as IconArrowRight } from '@Assets/images/icon-arrow-right.svg' -import { ReactComponent as IconShield } from '@Assets/images/icon-shield.svg' import styles from './SetPassword.module.css' @@ -110,8 +109,11 @@ const SetPassword = ({ {!unlockingAccount ? ( <> -
    - +
    +
    { const decimalSeparator = '.' const thousandsSeparator = ' ' -const amountRegex = /^\d+(.\d+)?$/ +// Digits with an optional decimal part only — rejects exponent notation +// ('1e3') and garbage separators ('1x5'); the '.' is escaped so it cannot +// match any character. +const amountRegex = /^\d+(\.\d+)?$/ const DEFAULT_WALLETS_TO_CREATE = ['btc', 'ml'] const ML_ATOMS_PER_COIN = 100000000000 const ML_DECIMALS = 11 @@ -19,7 +22,6 @@ const DEFAULT_ML_WALLET_OFFSET = 21 const APPROPRIATE_COST_PER_BLOCK = 190 const APPROPRIATE_MARGIN_RATIO_PER_THOUSAND = 80 const UNCONFIRMED_TRANSACTION_NAME = 'ml_unconfirmed_transaction' -const APP_LOCAL_STORAGE_CUSTOM_SERVERS = 'customAPIServers' const MAX_UPLOAD_FILE_SIZE = 2 * 1024 // 2 kb const SIGNED_MESSAGE_STRING_SEPARATOR = '.' const BATCH_REQUEST_MINTLAYER_LIMIT = 150 @@ -36,7 +38,7 @@ const BTC_MAX_TRANSACTION_FEE = 100000 // 0.001 BTC const BTC_MAX_FEERATE = 200 const COLOR_LIST = { btc: '#F7931A', - ml: '#37DB8C', + ml: '#7ED0D7', } const NETWORK_TYPES = { @@ -70,65 +72,6 @@ const walletTypes = [ }, ] -const WALLETS_NAVIGATION = [ - { - id: '1', - label: 'Bitcoin', - value: 'bitcoin', - type: 'menu', - actions: [ - { - id: '1.1', - name: 'Open Wallet', - link: '/wallet/Bitcoin', - }, - { - id: '1.2', - name: 'Send Transaction', - link: '/wallet/Bitcoin/send-transaction', - }, - ], - }, - { - id: '2', - label: 'Mintlayer', - value: 'mintlayer', - type: 'menu', - actions: [ - { - id: '2.1', - name: 'Open Wallet', - link: '/wallet/Mintlayer', - }, - { - id: '2.2', - name: 'Send Transaction', - link: '/wallet/Mintlayer/send-transaction', - }, - { - id: '2.3', - name: 'Staking', - link: '/wallet/Mintlayer/staking', - }, - { - id: '2.4', - name: 'NFT', - link: '/wallet/Mintlayer/nft', - }, - { - id: '2.5', - name: 'Sign/Verify Message', - link: '/wallet/Mintlayer/sign-message', - }, - { - id: '2.6', - name: 'Swap', - link: '/wallet/Mintlayer/order-swap', - }, - ], - }, -] - const WALLET_NAME_ERROR = 'The wallet name should have at least 4 characters.' const WALLET_PASSWORD_ERROR = [ 'Your password should have at least 8 characters.', @@ -156,10 +99,8 @@ export { MAX_ML_FEE, APPROPRIATE_COST_PER_BLOCK, APPROPRIATE_MARGIN_RATIO_PER_THOUSAND, - APP_LOCAL_STORAGE_CUSTOM_SERVERS, REFRESH_INTERVAL, MAX_UPLOAD_FILE_SIZE, - WALLETS_NAVIGATION, SIGNED_MESSAGE_STRING_SEPARATOR, BATCH_REQUEST_MINTLAYER_LIMIT, BATCH_REQUEST_BITCOIN_LIMIT, From 24412af6a30a100245f8f982d203619ed2ddf003 Mon Sep 17 00:00:00 2001 From: Enrico Rubboli Date: Tue, 8 Sep 2026 11:24:01 +0200 Subject: [PATCH 21/52] chore: import auditor, docs and config cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - scripts/audit-imports.js + npm run audit:imports: verifies every local import resolves and every named import is actually exported (review feedback: hallucinated imports slipped past eslint/build — this closes that gap; currently 376 files, 0 problems) - webpack: no source maps in production builds - jest: ignore build/ copies; configs drop the removed @Mocks alias - docs: REVIEW-PLAN.md (review backlog + accepted risks), design reference files under doc/ - .gitignore: local manifest key + HANDOFF.md (local session notes, no longer tracked) --- .gitignore | 4 + doc/Mojito BE.html | 1976 +++++++++++++++++++ doc/be-core.jsx | 458 +++++ doc/be-dapp.jsx | 645 ++++++ doc/be-data.jsx | 394 ++++ doc/be-home.jsx | 1186 +++++++++++ doc/be-onboarding.jsx | 875 ++++++++ doc/be-send.jsx | 726 +++++++ doc/be-settings.jsx | 954 +++++++++ doc/be.css | 685 +++++++ doc/components.jsx | 591 ++++++ doc/components/basic/AmountBlock.jsx | 39 + doc/components/basic/Avatar.jsx | 23 + doc/components/basic/BeSheet.jsx | 20 + doc/components/basic/BioCircle.jsx | 64 + doc/components/basic/BioGlyph.jsx | 19 + doc/components/basic/Card.jsx | 15 + doc/components/basic/ChainBadge.jsx | 45 + doc/components/basic/Checkbox.jsx | 49 + doc/components/basic/Counter.jsx | 36 + doc/components/basic/Empty.jsx | 38 + doc/components/basic/Eyebrow.jsx | 14 + doc/components/basic/FaceIcon.jsx | 25 + doc/components/basic/Favicon.jsx | 17 + doc/components/basic/Field.jsx | 14 + doc/components/basic/Hdr.jsx | 53 + doc/components/basic/HoldButton.jsx | 49 + doc/components/basic/Icon.jsx | 195 ++ doc/components/basic/IconTile.jsx | 37 + doc/components/basic/Input.jsx | 36 + doc/components/basic/KV.jsx | 18 + doc/components/basic/Keypad.jsx | 57 + doc/components/basic/LivePill.jsx | 28 + doc/components/basic/MojitoLogo.jsx | 95 + doc/components/basic/PinDots.jsx | 32 + doc/components/basic/Progress.jsx | 23 + doc/components/basic/PwField.jsx | 43 + doc/components/basic/QrPlaceholder.jsx | 13 + doc/components/basic/Row.jsx | 44 + doc/components/basic/SeedGrid.jsx | 19 + doc/components/basic/Seg.jsx | 21 + doc/components/basic/Sheet.jsx | 59 + doc/components/basic/Sparkline.jsx | 31 + doc/components/basic/Spinner.jsx | 18 + doc/components/basic/StatusBar.jsx | 115 ++ doc/components/basic/Strength.jsx | 31 + doc/components/basic/Success.jsx | 66 + doc/components/basic/Switch.jsx | 11 + doc/components/basic/Tag.jsx | 6 + doc/components/basic/TokenIcon.jsx | 41 + doc/components/basic/pwScore.js | 12 + doc/components/basic/useBioScan.js | 17 + doc/components/basic/useToast.js | 16 + doc/components/composed/AccountRow.jsx | 20 + doc/components/composed/AssetRow.jsx | 54 + doc/components/composed/Broadcasting.jsx | 28 + doc/components/composed/FeeSelector.jsx | 42 + doc/components/composed/NftCard.jsx | 59 + doc/components/composed/OnbTop.jsx | 37 + doc/components/composed/OriginCard.jsx | 40 + doc/components/composed/PasswordConfirm.jsx | 85 + doc/components/composed/SeedReveal.jsx | 37 + doc/components/composed/TxRow.jsx | 75 + doc/components/index.js | 68 + doc/onb-core.jsx | 362 ++++ doc/onb-data.jsx | 95 + doc/server-requirements.md | 68 + doc/styles.css | 455 +++++ package.json | 3 +- scripts/audit-imports.js | 207 ++ webpack.config.js | 20 +- 71 files changed, 11851 insertions(+), 2 deletions(-) create mode 100644 doc/Mojito BE.html create mode 100644 doc/be-core.jsx create mode 100644 doc/be-dapp.jsx create mode 100644 doc/be-data.jsx create mode 100644 doc/be-home.jsx create mode 100644 doc/be-onboarding.jsx create mode 100644 doc/be-send.jsx create mode 100644 doc/be-settings.jsx create mode 100644 doc/be.css create mode 100644 doc/components.jsx create mode 100644 doc/components/basic/AmountBlock.jsx create mode 100644 doc/components/basic/Avatar.jsx create mode 100644 doc/components/basic/BeSheet.jsx create mode 100644 doc/components/basic/BioCircle.jsx create mode 100644 doc/components/basic/BioGlyph.jsx create mode 100644 doc/components/basic/Card.jsx create mode 100644 doc/components/basic/ChainBadge.jsx create mode 100644 doc/components/basic/Checkbox.jsx create mode 100644 doc/components/basic/Counter.jsx create mode 100644 doc/components/basic/Empty.jsx create mode 100644 doc/components/basic/Eyebrow.jsx create mode 100644 doc/components/basic/FaceIcon.jsx create mode 100644 doc/components/basic/Favicon.jsx create mode 100644 doc/components/basic/Field.jsx create mode 100644 doc/components/basic/Hdr.jsx create mode 100644 doc/components/basic/HoldButton.jsx create mode 100644 doc/components/basic/Icon.jsx create mode 100644 doc/components/basic/IconTile.jsx create mode 100644 doc/components/basic/Input.jsx create mode 100644 doc/components/basic/KV.jsx create mode 100644 doc/components/basic/Keypad.jsx create mode 100644 doc/components/basic/LivePill.jsx create mode 100644 doc/components/basic/MojitoLogo.jsx create mode 100644 doc/components/basic/PinDots.jsx create mode 100644 doc/components/basic/Progress.jsx create mode 100644 doc/components/basic/PwField.jsx create mode 100644 doc/components/basic/QrPlaceholder.jsx create mode 100644 doc/components/basic/Row.jsx create mode 100644 doc/components/basic/SeedGrid.jsx create mode 100644 doc/components/basic/Seg.jsx create mode 100644 doc/components/basic/Sheet.jsx create mode 100644 doc/components/basic/Sparkline.jsx create mode 100644 doc/components/basic/Spinner.jsx create mode 100644 doc/components/basic/StatusBar.jsx create mode 100644 doc/components/basic/Strength.jsx create mode 100644 doc/components/basic/Success.jsx create mode 100644 doc/components/basic/Switch.jsx create mode 100644 doc/components/basic/Tag.jsx create mode 100644 doc/components/basic/TokenIcon.jsx create mode 100644 doc/components/basic/pwScore.js create mode 100644 doc/components/basic/useBioScan.js create mode 100644 doc/components/basic/useToast.js create mode 100644 doc/components/composed/AccountRow.jsx create mode 100644 doc/components/composed/AssetRow.jsx create mode 100644 doc/components/composed/Broadcasting.jsx create mode 100644 doc/components/composed/FeeSelector.jsx create mode 100644 doc/components/composed/NftCard.jsx create mode 100644 doc/components/composed/OnbTop.jsx create mode 100644 doc/components/composed/OriginCard.jsx create mode 100644 doc/components/composed/PasswordConfirm.jsx create mode 100644 doc/components/composed/SeedReveal.jsx create mode 100644 doc/components/composed/TxRow.jsx create mode 100644 doc/components/index.js create mode 100644 doc/onb-core.jsx create mode 100644 doc/onb-data.jsx create mode 100644 doc/server-requirements.md create mode 100644 doc/styles.css create mode 100644 scripts/audit-imports.js diff --git a/.gitignore b/.gitignore index ec11ca26..ceb3f8e1 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,7 @@ yarn-error.log* # llm .claude + +# local manifest key for unpacked builds +/manifest-key.txt +/HANDOFF.md diff --git a/doc/Mojito BE.html b/doc/Mojito BE.html new file mode 100644 index 00000000..4fdb953c --- /dev/null +++ b/doc/Mojito BE.html @@ -0,0 +1,1976 @@ + + + + + + + + + Mojito BE — Browser Extension + + + + +
    + + + + + + + + + + + + + + + + diff --git a/doc/be-core.jsx b/doc/be-core.jsx new file mode 100644 index 00000000..73170099 --- /dev/null +++ b/doc/be-core.jsx @@ -0,0 +1,458 @@ +// Mojito BE — shared primitives (uses components.jsx: Icon, TokenIcon, ChainBadge, Sparkline, LivePill, MojitoLogo) + +function Hdr({ title, onBack, onClose, right }) { + return ( +
    + {onBack ? ( +
    + + + +
    + ) : ( +
    + )} +

    {title}

    + {right ? ( + right + ) : onClose ? ( +
    + + + +
    + ) : ( +
    + )} +
    + ) +} + +function Switch({ on, onChange }) { + return ( +
    onChange(!on)} + >
    + ) +} + +function Seg({ value, options, onChange }) { + return ( +
    + {options.map((o) => { + const [v, l] = Array.isArray(o) ? o : [o, o] + return ( + + ) + })} +
    + ) +} + +function Tag({ c = 'grey', children }) { + return {children} +} + +function KV({ rows }) { + return ( +
    + {rows.map(([k, v]) => ( +
    + {k} + {v} +
    + ))} +
    + ) +} + +// Modal sheet inside the popup +function BeSheet({ open, onClose, title, children, label }) { + if (!open) return null + return ( +
    +
    +
    +
    + {title && ( +
    + {title} +
    + )} +
    {children}
    +
    +
    + ) +} + +function useToast() { + const [msg, setMsg] = useState(null) + const show = (m) => { + setMsg(m) + clearTimeout(show.t) + show.t = setTimeout(() => setMsg(null), 1800) + } + const el = msg ?
    {msg}
    : null + return [show, el] +} + +// Hold-to-confirm primary button +function HoldButton({ label, onDone, className = 'primary', icon = 'lock' }) { + const [go, setGo] = useState(false) + const t = useRef(null) + const start = () => { + setGo(true) + t.current = setTimeout(() => { + setGo(false) + onDone() + }, 1100) + } + const stop = () => { + clearTimeout(t.current) + setGo(false) + } + return ( + + ) +} + +// Password field with reveal +function PwField({ + label, + value, + onChange, + placeholder = 'Password', + autoFocus, + bad, + onEnter, +}) { + const [show, setShow] = useState(false) + return ( +
    + {label && } +
    + + onChange(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && onEnter && onEnter()} + /> + setShow((s) => !s)} + style={{ cursor: 'pointer', display: 'flex' }} + > + + +
    +
    + ) +} + +function pwScore(p) { + let s = 0 + if (p.length >= 8) s++ + if (p.length >= 12) s++ + if (/[A-Z]/.test(p) && /[a-z]/.test(p)) s++ + if (/\d/.test(p)) s++ + if (/[^\w]/.test(p)) s++ + return Math.min(4, s) +} +function Strength({ pw }) { + const s = pw ? pwScore(pw) : 0 + const c = ['', 'var(--red)', 'var(--amber)', 'var(--amber)', 'var(--green)'][ + s + ] + const lbl = ['', 'Weak', 'Fair', 'Good', 'Strong'][s] + return ( +
    +
    + {[1, 2, 3, 4].map((i) => ( + + ))} +
    + {pw && ( + + {lbl} + {s < 2 ? ' · use 8+ characters, mixed case and a number' : ''} + + )} +
    + ) +} + +// Account avatar +function Avatar({ acct, size = 28 }) { + return ( +
    + {acct.name[0]} +
    + ) +} + +// Success burst +function Success({ title, sub, children }) { + return ( +
    +
    + {[0, 0.3].map((d, i) => ( +
    + ))} +
    + ✓ +
    +
    +
    + {title} +
    + {sub && ( +
    + {sub} +
    + )} + {children} +
    + ) +} + +// QR placeholder (striped, labelled — real QR is rendered by the extension) +function QrPlaceholder({ size = 168, label = 'QR code' }) { + return ( +
    + {label} +
    + ) +} + +function Empty({ icon = 'history', title, sub }) { + return ( +
    + +
    + {title} +
    + {sub && ( +
    + {sub} +
    + )} +
    + ) +} + +const txIcon = { + receive: ['arrow_dn', 'var(--green)'], + send: ['arrow_up', 'var(--amber)'], + mint: ['plus', 'var(--teal)'], + nft: ['card', 'var(--violet)'], + dapp: ['flash', 'var(--violet)'], + burn: ['flash', 'var(--red)'], +} + +Object.assign(window, { + Hdr, + Switch, + Seg, + Tag, + KV, + BeSheet, + useToast, + HoldButton, + PwField, + pwScore, + Strength, + Avatar, + Success, + QrPlaceholder, + Empty, + txIcon, +}) diff --git a/doc/be-dapp.jsx b/doc/be-dapp.jsx new file mode 100644 index 00000000..14c44e7c --- /dev/null +++ b/doc/be-dapp.jsx @@ -0,0 +1,645 @@ +// Mojito BE — dApp request window. Rendered as a separate popup when a site calls the provider. +// req: BE_REQUESTS[kind]; onDone(result: 'approved' | 'rejected') + +function OriginCard({ req, s }) { + const connected = s.sites.some((x) => x.origin === req.origin) + return ( +
    +
    + {req.name[0]} +
    +
    +
    {req.name}
    +
    + + {req.origin} +
    +
    + {connected ? ( + Connected + ) : ( + New site + )} +
    + ) +} + +function DappWindow({ s, req, onDone }) { + const [locked, setLocked] = useState(s.locked) + const [phase, setPhase] = useState('review') // review | password | sending | done + const [pw, setPw] = useState('') + const [bad, setBad] = useState(false) + const [sel, setSel] = useState([s.account.id]) + const [showData, setShowData] = useState(s.showHex) + const [tab, setTab] = useState('Summary') + const [toast, toastEl] = useToast() + const acct = s.account + const needsPw = + req.kind === 'tx' || req.kind === 'delegate' || req.kind === 'sign' + const label = { + connect: 'Connection request', + sign: 'Signature request', + tx: 'Transaction request', + network: 'Switch network', + token: 'Add token', + delegate: 'Delegation request', + }[req.kind] + + if (locked) + return ( + { + s.unlock() + setLocked(false) + }} + onForgot={() => onDone('rejected')} + /> + ) + + const approve = () => { + if (needsPw && s.pwEveryTx && phase === 'review') { + setPhase('password') + return + } + finish() + } + const finish = () => { + if (req.kind === 'tx' || req.kind === 'delegate') { + setPhase('sending') + setTimeout(() => { + s.pushTx({ + type: 'dapp', + sym: req.sym || 'ML', + chain: req.chain || 'Mintlayer', + amount: req.amount, + usd: req.amount * (BE_PRICES[req.sym] || BE_PRICES.ML), + to: req.origin, + fee: `${req.fee} ${req.sym || 'ML'}`, + }) + setPhase('done') + setTimeout(() => onDone('approved'), 1100) + }, 1300) + return + } + if (req.kind === 'connect') s.connect(req, sel) + if (req.kind === 'network') s.setNetwork(req.to) + if (req.kind === 'token') s.addToken(req) + setPhase('done') + setTimeout(() => onDone('approved'), 900) + } + const confirmPw = () => { + if (pw.length >= 4) finish() + else { + setBad(true) + setTimeout(() => setBad(false), 500) + } + } + + if (phase === 'done') + return ( +
    +
    + +
    +
    + ) + if (phase === 'sending') + return ( +
    +
    +
    +
    + Broadcasting to {req.chain}… +
    + Do not close this window +
    +
    + ) + + if (phase === 'password') + return ( +
    + setPhase('review')} + /> +
    + +
    +
    + {req.kind === 'sign' + ? 'Sign message' + : `Sign ${req.amount} ${req.sym || 'ML'}`} +
    +
    + Enter your password to sign with {acct.name}. +
    +
    +
    + +
    + {bad && ( + + Incorrect password + + )} +
    +
    + +
    +
    + ) + + // review phase per kind + let body, + cta = 'Approve', + danger = false + if (req.kind === 'connect') { + body = ( + +
    + Connect with +
    +
    + {s.accounts.map((a) => { + const on = sel.includes(a.id) + return ( +
    + setSel((x) => + on ? x.filter((i) => i !== a.id) : [...x, a.id], + ) + } + > + +
    +
    {a.name}
    +
    + {shortAddr(a.ml, 6)} +
    +
    +
    + {on ? '✓' : ''} +
    +
    + ) + })} +
    +
    + This site will be able to +
    +
    + {req.perms.map((p) => ( +
    + + {p} +
    + ))} +
    +
    + It cannot move funds without your approval for each transaction. +
    +
    + ) + cta = `Connect ${sel.length} account${sel.length !== 1 ? 's' : ''}` + } else if (req.kind === 'sign') { + body = ( + +
    + Message +
    +
    +          {req.message}
    +        
    +
    + +
    +
    + Only sign messages you understand. Signing can authorise actions on + the site, such as logging in. +
    +
    + ) + cta = 'Sign' + } else if (req.kind === 'tx') { + const total = req.amount + req.fee + const bal = s.assets.find((x) => x.sym === req.sym).amount + const low = total > bal + body = ( + +
    +
    Sending
    +
    + {fmtAmt(req.amount, 8)}{' '} + + {req.sym} + +
    +
    + ≈ {fmtUsd(req.amount * BE_PRICES[req.sym])} +
    +
    + +
    + {tab === 'Summary' ? ( + ], + ['Fee', `${req.fee} ${req.sym}`], + [ + 'Total', + + {fmtAmt(total, 8)} {req.sym} + , + ], + ]} + /> + ) : ( +
    +              {req.data}
    +              {'\n\n'}
    +              
    +                raw: 0100000001a7c1f0…{'\n'}inputs: 1 · outputs: 2 · size: 141
    +                vB
    +              
    +            
    + )} +
    + {low && ( +
    + Insufficient {req.sym} balance ({fmtAmt(bal)} available). +
    + )} +
    + ) + cta = `Approve · ${fmtAmt(req.amount, 8)} ${req.sym}` + } else if (req.kind === 'network') { + body = ( + +
    + + + {req.from} + + + + + {req.to} + +
    +
    + {req.name} wants Mojito to switch to{' '} + {req.to}. Balances and + addresses shown in the wallet will change to that network until you + switch back. +
    +
    + ) + cta = `Switch to ${req.to}` + } else if (req.kind === 'token') { + body = ( + +
    +
    + +
    +
    + {req.ticker} +
    +
    Mintlayer fungible token
    +
    + +
    + Adding a token only shows it in your wallet. Verify the token ID + against the project's official channels — anyone can create a token + with this ticker. +
    +
    + ) + cta = 'Add token' + } else if (req.kind === 'delegate') { + body = ( + +
    +
    Delegate to pool
    +
    + {req.amount}{' '} + + ML + +
    +
    +
    + +
    +
    + Delegated ML stays under your control; the pool only earns staking + rewards on your behalf. +
    +
    + ) + cta = 'Delegate' + } + + return ( +
    +
    + {label} +
    +
    + + {body} +
    +
    +
    + + +
    + {toastEl} +
    + ) +} + +Object.assign(window, { DappWindow, OriginCard }) diff --git a/doc/be-data.jsx b/doc/be-data.jsx new file mode 100644 index 00000000..a0e55e7b --- /dev/null +++ b/doc/be-data.jsx @@ -0,0 +1,394 @@ +// Mojito BE — mock data +const BE_ACCOUNTS = [ + { + id: 'a1', + name: 'Main', + color: 'var(--amber)', + btc: 'bc1q9xw5dg4yr3zarv0c5e2wfjn8kh6mua7ltd0s3j', + ml: 'mtc1qkrz6c0d8f4h2j9l5n3p7s2v0w6x8z2a4c8e0g', + }, + { + id: 'a2', + name: 'Savings', + color: 'var(--teal)', + btc: 'bc1qm4x2l7sp0f8v9t3k5wn6r1z8ye2ju4hq7c9d0a', + ml: 'mtc1q7v2e9c4n8k1s5p3l0w6x8z2m4a6b8d0f2h4j6', + }, + { + id: 'a3', + name: 'dApp testing', + color: 'var(--violet)', + btc: 'bc1qz8r5e2w9k4n1v6h3j7l0p2s5d8f1g4a7c0b3m6', + ml: 'mtc1qa3c5e7g9j1l3n5p7r9t1v3x5z7b9d1f3h5k7m9', + }, +] + +const BE_PRICES = { BTC: 96420.32, ML: 0.342 } + +// Coins + Mintlayer fungible tokens. `authority` marks tokens issued by this wallet. +const BE_ASSETS = [ + { + id: 'btc', + sym: 'BTC', + name: 'Bitcoin', + chain: 'Bitcoin', + amount: 0.0482, + price: 96420.32, + change: 2.18, + spark: [100, 102, 99, 105, 108, 106, 112, 110, 115], + decimals: 8, + }, + { + id: 'ml', + sym: 'ML', + name: 'Mintlayer', + chain: 'Mintlayer', + amount: 12480.5, + price: 0.342, + change: 8.42, + spark: [80, 82, 78, 85, 90, 95, 92, 98, 105], + decimals: 11, + }, + { + id: 'usdt', + sym: 'USDT', + name: 'Tether (Mintlayer)', + chain: 'Mintlayer', + amount: 1240, + price: 1, + change: 0.01, + spark: [100, 100, 100, 100, 100, 100, 100, 100, 100], + decimals: 6, + tokenId: 'tmltk1qx8v3e...9k2p', + supply: '48,201,000', + ticker: 'USDT', + }, + { + id: 'cbeat', + sym: 'CBEAT', + name: 'Cryptobeat', + chain: 'Mintlayer', + amount: 5820, + price: 0.3, + change: 12.4, + spark: [60, 64, 70, 68, 74, 80, 86, 84, 92], + decimals: 2, + tokenId: 'tmltk1q2c7d9a...4hm3', + supply: '1,000,000', + ticker: 'CBEAT', + authority: true, + mintable: true, + frozen: false, + lockedSupply: false, + }, + { + id: 'sky', + sym: 'SKY', + name: 'SkyToken', + chain: 'Mintlayer', + amount: 340, + price: 2.62, + change: -2.1, + spark: [100, 98, 96, 99, 95, 92, 94, 90, 91], + decimals: 4, + tokenId: 'tmltk1qs5k7y3n...2xw8', + supply: '250,000', + ticker: 'SKY', + hidden: false, + }, + { + id: 'dust', + sym: 'AIRD', + name: 'Airdrop Token', + chain: 'Mintlayer', + amount: 15, + price: 0, + change: 0, + spark: [1, 1, 1, 1, 1, 1, 1, 1, 1], + decimals: 0, + tokenId: 'tmltk1qairdr0p...z9q1', + supply: '∞', + ticker: 'AIRD', + hidden: true, + }, +] + +const BE_NFTS = [ + { + id: 'n1', + name: 'Punk6529 #17', + collection: 'Punk6529', + tokenId: 'tmltk1qnft9x2v...7p4k', + creator: 'mtc1q7v2e…4j6', + desc: 'Generative pixel portrait from the Punk6529 series on Mintlayer.', + hue: 290, + }, + { + id: 'n2', + name: 'Cryptobeat #341', + collection: 'Cryptobeats', + tokenId: 'tmltk1qnft3c1b...2w9m', + creator: 'mtc1qkrz6…8e0g', + desc: 'One-of-one audio-visual loop. 128 BPM.', + hue: 195, + mine: true, + }, + { + id: 'n3', + name: 'Mintlayer Genesis Pass', + collection: 'ML Genesis', + tokenId: 'tmltk1qnftgen...0s1s', + creator: 'mtc1qgen…0a1b', + desc: 'Early supporter pass. Grants access to the Genesis channel.', + hue: 70, + }, + { + id: 'n4', + name: 'Sky #102', + collection: 'Skies', + tokenId: 'tmltk1qnftsky1...02xx', + creator: 'mtc1qsky…12ab', + desc: 'Atmospheric study, 2026.', + hue: 230, + }, +] + +const BE_ACTIVITY = [ + { + id: 't1', + type: 'receive', + sym: 'BTC', + chain: 'Bitcoin', + amount: 0.0052, + usd: 501.4, + when: 'Today, 09:14', + status: 'Confirming', + conf: '2/6', + hash: 'a94f2c…e81b', + from: 'bc1qm4x…9d0a', + }, + { + id: 't2', + type: 'send', + sym: 'ML', + chain: 'Mintlayer', + amount: 120, + usd: 41.04, + when: 'Today, 08:02', + status: 'Confirmed', + hash: '0x4f2a…b91c', + to: 'mtc1q7v2…4j6', + fee: '0.2 ML', + }, + { + id: 't3', + type: 'mint', + sym: 'CBEAT', + chain: 'Mintlayer', + amount: 1000, + usd: 300, + when: 'Yesterday', + status: 'Confirmed', + hash: '0x91cd…77ae', + fee: '100 ML', + }, + { + id: 't4', + type: 'nft', + sym: 'NFT', + chain: 'Mintlayer', + name: 'Cryptobeat #341', + when: 'Yesterday', + status: 'Confirmed', + hash: '0x1b2c…0f9e', + fee: '0.3 ML', + }, + { + id: 't5', + type: 'dapp', + sym: 'ML', + chain: 'Mintlayer', + amount: 45, + usd: 15.39, + when: 'Sep 2', + status: 'Confirmed', + hash: '0x7aa1…c3d2', + to: 'app.mintlayer.dex', + fee: '0.2 ML', + }, + { + id: 't6', + type: 'receive', + sym: 'ML', + chain: 'Mintlayer', + amount: 2500, + usd: 855, + when: 'Aug 30', + status: 'Confirmed', + hash: '0xe5f0…12ab', + from: 'mtc1qa3c…7m9', + }, + { + id: 't7', + type: 'send', + sym: 'BTC', + chain: 'Bitcoin', + amount: 0.012, + usd: 1157, + when: 'Aug 28', + status: 'Failed', + hash: '3c1d9a…4b77', + to: 'bc1qz8r…3m6', + fee: '1,240 sat', + }, +] + +const BE_SITES = [ + { + origin: 'app.mintlayer.dex', + name: 'Mintlayer DEX', + hue: 195, + accounts: ['a1'], + since: 'Aug 12', + }, + { + origin: 'bridge.mojito.finance', + name: 'Mojito Bridge', + hue: 290, + accounts: ['a1', 'a3'], + since: 'Aug 3', + }, + { + origin: 'mint.cryptobeats.xyz', + name: 'Cryptobeats Mint', + hue: 70, + accounts: ['a3'], + since: 'Jul 21', + }, +] + +// dApp request fixtures +const BE_REQUESTS = { + connect: { + kind: 'connect', + origin: 'app.mintlayer.dex', + name: 'Mintlayer DEX', + hue: 195, + perms: [ + 'See your address, balance and activity', + 'Request approval for transactions', + 'Request message signatures', + ], + }, + sign: { + kind: 'sign', + origin: 'app.mintlayer.dex', + name: 'Mintlayer DEX', + hue: 195, + message: + 'Sign in to Mintlayer DEX\n\nNonce: 8f3a91c2\nIssued: 2026-09-05T12:06:43Z\n\nThis request will not trigger a blockchain transaction or cost any fees.', + }, + tx: { + kind: 'tx', + origin: 'app.mintlayer.dex', + name: 'Mintlayer DEX', + hue: 195, + chain: 'Mintlayer', + sym: 'ML', + amount: 45, + to: 'mtc1qdexp00l7v2e9c4n8k1s5p3l0w6x8z2m4a6b8', + fee: 0.2, + data: 'OrderFill{ pair: ML/USDT, side: buy, qty: 45, limit: 0.342 }', + }, + btctx: { + kind: 'tx', + origin: 'bridge.mojito.finance', + name: 'Mojito Bridge', + hue: 290, + chain: 'Bitcoin', + sym: 'BTC', + amount: 0.0021, + to: 'bc1qbridge7sp0f8v9t3k5wn6r1z8ye2ju4hq7c9d', + fee: 0.000012, + data: 'OP_RETURN 6d6c2d6272696467652d31', + }, + network: { + kind: 'network', + origin: 'mint.cryptobeats.xyz', + name: 'Cryptobeats Mint', + hue: 70, + from: 'Mainnet', + to: 'Testnet', + }, + token: { + kind: 'token', + origin: 'mint.cryptobeats.xyz', + name: 'Cryptobeats Mint', + hue: 70, + ticker: 'BEATS', + tokenId: 'tmltk1qb3a7s9v2e...0k1m', + decimals: 4, + supply: '10,000,000', + }, + delegate: { + kind: 'delegate', + origin: 'app.mintlayer.dex', + name: 'Mintlayer DEX', + hue: 195, + pool: 'mpool1q9xw5…7ltd', + amount: 500, + fee: 0.2, + }, +} + +const BE_SEED = [ + 'ancient', + 'bridge', + 'canyon', + 'anchor', + 'coconut', + 'circle', + 'credit', + 'crystal', + 'autumn', + 'balance', + 'beach', + 'bench', + 'camera', + 'castle', + 'ceiling', + 'century', + 'cherry', + 'clever', + 'cliff', + 'cluster', + 'coral', + 'cotton', + 'crane', + 'brave', +] + +const fmtUsd = (n) => + '$' + + n.toLocaleString(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }) +const fmtAmt = (n, d = 4) => + n.toLocaleString(undefined, { maximumFractionDigits: d }) +const shortAddr = (a, n = 8) => + a.length > n * 2 + 1 ? a.slice(0, n) + '…' + a.slice(-n + 2) : a + +Object.assign(window, { + BE_ACCOUNTS, + BE_PRICES, + BE_ASSETS, + BE_NFTS, + BE_ACTIVITY, + BE_SITES, + BE_REQUESTS, + BE_SEED, + fmtUsd, + fmtAmt, + shortAddr, +}) diff --git a/doc/be-home.jsx b/doc/be-home.jsx new file mode 100644 index 00000000..9bacf194 --- /dev/null +++ b/doc/be-home.jsx @@ -0,0 +1,1186 @@ +// Mojito BE — Home, asset detail, NFTs, manage assets. Props: s (store), nav (open/close/toast) + +function AppTop({ s, nav }) { + const a = s.account + return ( +
    +
    nav.sheet('accounts')} + style={{ + display: 'flex', + alignItems: 'center', + gap: 9, + flex: 1, + cursor: 'pointer', + padding: '4px 8px 4px 4px', + borderRadius: 12, + }} + > + +
    +
    + {a.name} + +
    +
    + {shortAddr(a.ml, 7)} +
    +
    +
    + nav.open({ t: 'settings', page: 'network' })} + > + + {s.network} + +
    nav.tab('settings')} + > + +
    +
    + ) +} + +function AccountsSheet({ s, nav }) { + return ( + +
    + {s.accounts.map((a) => ( +
    { + s.setAccount(a.id) + nav.closeSheet() + }} + > + +
    +
    {a.name}
    +
    + {shortAddr(a.btc, 7)} +
    +
    + {a.id === s.account.id ? ( + Active + ) : ( +
    + {fmtUsd(a.id === 'a2' ? 2103.18 : 112.55)} +
    + )} +
    + ))} +
    +
    + + +
    +
    + ) +} + +function HomeScreenBE({ s, nav }) { + const [tab, setTab] = useState('Tokens') + const visible = s.assets.filter((a) => !a.hidden) + const total = visible.reduce((t, a) => t + a.amount * a.price, 0) + const dayChange = visible.reduce( + (t, a) => t + (a.amount * a.price * a.change) / 100, + 0, + ) + const H = (v) => (s.hideBal ? '••••' : v) + return ( +
    + +
    +
    +
    +
    +
    +
    + Total balance + s.setHideBal(!s.hideBal)} + style={{ cursor: 'pointer', display: 'flex' }} + > + + +
    +
    + {s.hideBal ? ( + '••••••' + ) : ( + + + $ + + + + )} +
    +
    + + + {H( + (dayChange >= 0 ? '+' : '−') + fmtUsd(Math.abs(dayChange)), + )}{' '} + · 24h + +
    +
    +
    +
    + + + +
    +
    +
    +
    + Assets + +
    + {tab === 'Tokens' ? ( +
    + {visible.map((a, i) => ( +
    nav.open({ t: 'asset', id: a.id })} + > + +
    +
    + + {a.sym} + + {a.chain === 'Mintlayer' && a.id !== 'ml' && ( + Token + )} + {a.authority && Issuer} +
    +
    + {H(fmtAmt(a.amount, 4))} {a.sym} +
    +
    + = 0 ? 'var(--green)' : 'var(--red)'} + width={44} + height={20} + /> +
    +
    + {H(fmtUsd(a.amount * a.price))} +
    +
    + +
    +
    +
    + ))} +
    + ) : ( + + )} +
    +
    +
    + + Recent activity + + nav.tab('activity')} + > + See all → + +
    +
    + {s.activity.slice(0, 3).map((t) => ( + nav.sheet({ t: 'tx', id: t.id })} + /> + ))} +
    +
    +
    +
    + ) +} + +function TxRow({ t, onClick }) { + const [ic, col] = txIcon[t.type] + const lbl = { + receive: 'Received', + send: 'Sent', + mint: 'Minted', + nft: 'NFT received', + dapp: 'dApp payment', + burn: 'Burned', + }[t.type] + const sc = + t.status === 'Confirmed' + ? 'var(--text-2)' + : t.status === 'Failed' + ? 'var(--red)' + : 'var(--amber)' + return ( +
    +
    + +
    +
    +
    + {lbl} + {t.type === 'dapp' && ( + · {t.to} + )} +
    +
    + {t.when} + {t.conf ? ` · ${t.conf} conf` : ''} +
    +
    +
    +
    + {t.type === 'nft' + ? t.name + : `${t.type === 'receive' || t.type === 'mint' ? '+' : '−'}${fmtAmt(t.amount, 6)} ${t.sym}`} +
    +
    {t.status}
    +
    +
    + ) +} + +function NftGrid({ s, nav }) { + if (!s.nfts.length) + return ( + + ) + return ( +
    + {s.nfts.map((n, i) => ( +
    nav.open({ t: 'nft', id: n.id })} + > +
    +
    + nft media +
    +
    + {n.name} +
    + {n.collection} +
    +
    +
    + ))} +
    + ) +} + +function AssetScreenBE({ s, nav, id }) { + const a = s.assets.find((x) => x.id === id) + const [range, setRange] = useState('1D') + const [act, setAct] = useState(null) // mint | burn | lock | freeze + const [qty, setQty] = useState('') + const txs = s.activity.filter((t) => t.sym === a.sym) + const isToken = a.chain === 'Mintlayer' && a.id !== 'ml' + const spark = useMemo( + () => + Array.from( + { length: 28 }, + (_, i) => 100 + Math.sin(i / 3 + a.change) * 8 + (i * a.change) / 10, + ), + [a, range], + ) + const doAct = () => { + s.tokenAction(a.id, act, parseFloat(qty || 0)) + nav.toast( + { + mint: `Minted ${qty} ${a.sym}`, + burn: `Burned ${qty} ${a.sym}`, + lock: 'Supply locked permanently', + freeze: a.frozen ? `${a.sym} unfrozen` : `${a.sym} frozen`, + }[act], + ) + setAct(null) + setQty('') + } + return ( +
    + nav.sheet({ t: 'assetmenu', id })} + > + + + + + +
    + } + /> +
    +
    +
    + +
    +
    + {fmtAmt(a.amount, 6)}{' '} + + {a.sym} + +
    +
    + + {fmtUsd(a.amount * a.price)} + + +
    +
    + + {a.authority && You are the issuer} +
    +
    +
    +
    + + + `${(i / 27) * 320},${70 - ((v - Math.min(...spark)) / (Math.max(...spark) - Math.min(...spark) || 1)) * 56 - 6}`, + ) + .join(' ')} + fill="none" + stroke={a.change >= 0 ? 'var(--green)' : 'var(--red)'} + strokeWidth="1.8" + strokeLinejoin="round" + /> + +
    + {['1H', '1D', '1W', '1M', '1Y'].map((r) => ( + setRange(r)} + style={{ + cursor: 'pointer', + color: r === range ? 'var(--amber)' : undefined, + }} + > + {r} + + ))} +
    +
    +
    + + +
    + {a.authority && ( +
    +
    + Issuer controls +
    +
    + + + + +
    + {a.frozen && ( +
    + Token is frozen — transfers are blocked for all holders. +
    + )} +
    + )} + {isToken && ( +
    + +
    + )} +
    +
    + Activity +
    + {txs.length ? ( +
    + {txs.map((t) => ( + nav.sheet({ t: 'tx', id: t.id })} + /> + ))} +
    + ) : ( + + )} +
    +
    +
    + setAct(null)} + title={ + { + mint: `Mint ${a.sym}`, + burn: `Burn ${a.sym}`, + lock: 'Lock total supply', + freeze: a.frozen ? `Unfreeze ${a.sym}` : `Freeze ${a.sym}`, + }[act] + } + label="Issuer action" + > + {(act === 'mint' || act === 'burn') && ( +
    + +
    + setQty(e.target.value.replace(/[^\d.]/g, ''))} + autoFocus + /> + + {a.sym} + +
    + + {act === 'mint' + ? `Current supply ${a.supply}. Minting fee 100 ML.` + : `You hold ${fmtAmt(a.amount)} ${a.sym}. Burned tokens are destroyed permanently.`} + +
    + )} + {act === 'lock' && ( +
    + Locking supply is irreversible. No further {a.sym} can ever be + minted. Fee 100 ML. +
    + )} + {act === 'freeze' && ( +
    + {a.frozen + ? 'Holders will be able to transfer again.' + : 'All transfers of this token will be blocked until you unfreeze it.'}{' '} + Fee 100 ML. +
    + )} +
    + +
    +
    +
    + ) +} + +function AssetMenuSheet({ s, nav, id }) { + const a = s.assets.find((x) => x.id === id) + return ( + +
    +
    { + nav.toast('Opened in explorer') + nav.closeSheet() + }} + > + + View on explorer +
    +
    { + nav.toast('Token ID copied') + nav.closeSheet() + }} + > + + + Copy {a.tokenId ? 'token ID' : 'address'} + +
    + {a.tokenId && ( +
    { + s.toggleHidden(a.id) + nav.closeSheet() + nav.close() + nav.toast(`${a.sym} hidden`) + }} + > + + + Hide token + +
    + )} +
    +
    + ) +} + +function NftScreenBE({ s, nav, id }) { + const n = s.nfts.find((x) => x.id === id) + const [full, setFull] = useState(false) + return ( +
    + +
    +
    setFull((f) => !f)} + > +
    +
    + nft media · {n.name} +
    +
    +
    +
    {n.name}
    + {n.mine && Created by you} +
    +
    + {n.desc} +
    +
    + + +
    +
    + +
    +
    +
    + ) +} + +function ManageScreenBE({ s, nav }) { + const [q, setQ] = useState('') + const [addId, setAddId] = useState('') + const tokens = s.assets.filter( + (a) => a.tokenId && a.name.toLowerCase().includes(q.toLowerCase()), + ) + const okId = /^tmltk1q[a-z0-9]{6,}$/i.test(addId) + return ( +
    + +
    +
    + + setQ(e.target.value)} + /> +
    +
    + Mintlayer tokens +
    +
    + {tokens.map((a) => ( +
    + +
    +
    {a.name}
    +
    + {a.tokenId} +
    +
    + s.toggleHidden(a.id)} + /> +
    + ))} +
    +
    + Add custom token +
    +
    +
    + +
    + setAddId(e.target.value.trim())} + /> +
    +
    + {okId && ( +
    + Found: BEATS · 4 decimals · supply 10,000,000 +
    + )} + +
    +
    + Bitcoin and ML are always shown. Hidden tokens stay in your wallet and + can be re-enabled here. +
    +
    +
    + ) +} + +Object.assign(window, { + AppTop, + AccountsSheet, + HomeScreenBE, + TxRow, + NftGrid, + AssetScreenBE, + AssetMenuSheet, + NftScreenBE, + ManageScreenBE, +}) diff --git a/doc/be-onboarding.jsx b/doc/be-onboarding.jsx new file mode 100644 index 00000000..61f050ba --- /dev/null +++ b/doc/be-onboarding.jsx @@ -0,0 +1,875 @@ +// Mojito BE — onboarding + unlock. api: { go(screen), back(), finish(), flow, setFlow } + +function Progress({ step, total }) { + return ( +
    + {Array.from({ length: total }, (_, i) => ( +
    + ))} +
    + ) +} +function OnbTop({ api, step, total = 4 }) { + return ( +
    +
    + + + +
    + + + {step}/{total} + +
    + ) +} + +function WelcomeScreenBE({ api }) { + return ( +
    +
    +
    + +
    +
    + Mojito +
    +
    + Self-custody wallet for Bitcoin and Mintlayer. +
    + Your keys never leave this browser. +
    +
    + + Bitcoin + + + Mintlayer + +
    +
    +
    + + +
    + By continuing you agree to the Terms and{' '} + Privacy policy. +
    +
    +
    + ) +} + +function PasswordScreenBE({ api }) { + const [pw, setPw] = useState('') + const [pw2, setPw2] = useState('') + const [agree, setAgree] = useState(false) + const ok = pwScore(pw) >= 2 && pw === pw2 && agree + const mismatch = pw2 && pw2 !== pw + return ( +
    + +
    +
    + Create a password +
    +
    + Unlocks Mojito on this device. It cannot recover your wallet — only + your recovery phrase can. +
    +
    + + + + {mismatch && Passwords don't match} + setAgree((a) => !a)} + label="I understand Mojito cannot recover this password." + /> +
    +
    +
    + +
    +
    + ) +} + +function SeedScreenBE({ api }) { + const [shown, setShown] = useState(false) + const [saved, setSaved] = useState(false) + const [toast, toastEl] = useToast() + return ( +
    + +
    +
    + Your recovery phrase +
    +
    + 24 words, generated locally from 256 bits of entropy. Write them down + in order and store them offline. +
    +
    +
    + {BE_SEED.map((w, i) => ( +
    + {i + 1} + {w} +
    + ))} +
    + {!shown && ( +
    setShown(true)} + style={{ + position: 'absolute', + inset: 0, + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + gap: 8, + cursor: 'pointer', + }} + > + + + Tap to reveal + + + Make sure nobody is watching your screen + +
    + )} +
    + {shown && ( +
    + + +
    + )} +
    + + + Anyone with these words controls your BTC and ML. Mojito will never + ask for them. + +
    +
    + setSaved((s) => !s)} + label="I have written down all 24 words." + /> +
    +
    +
    + +
    + {toastEl} +
    + ) +} + +function VerifyScreenBE({ api }) { + const targets = useMemo( + () => [3, 11, 19].map((i) => ({ i, w: BE_SEED[i] })), + [], + ) + const pool = useMemo(() => { + const extra = ['coin', 'claim', 'army', 'atom', 'bonus', 'cactus'] + return [...targets.map((t) => t.w), ...extra].sort() + }, []) + const [picked, setPicked] = useState([]) + const [wrong, setWrong] = useState(null) + const cur = targets[picked.length] + const pick = (w) => { + if (!cur) return + if (w === cur.w) setPicked((p) => [...p, w]) + else { + setWrong(w) + setTimeout(() => setWrong(null), 450) + } + } + const done = picked.length === targets.length + return ( +
    + +
    +
    + Confirm your backup +
    +
    + Select the requested words from your phrase. +
    +
    + {targets.map((t, k) => ( +
    +
    Word #{t.i + 1}
    +
    + {picked[k] || '·'} +
    +
    + ))} +
    +
    + {pool.map((w) => ( + + ))} +
    + {done && ( +
    + ✓ Backup verified +
    + )} +
    +
    + +
    +
    + ) +} + +function ImportScreenBE({ api }) { + const [text, setText] = useState('') + const words = text.trim().toLowerCase().split(/\s+/).filter(Boolean) + const bad = words.filter((w) => !BIP39.includes(w)) + const lenOk = [12, 15, 18, 21, 24].includes(words.length) + const valid = lenOk && bad.length === 0 && onbChecksumOK(words) + const status = !words.length + ? null + : bad.length + ? `Unknown word${bad.length > 1 ? 's' : ''}: ${bad.slice(0, 3).join(', ')}` + : !lenOk + ? `${words.length} words — need 12 or 24` + : !valid + ? 'Invalid checksum — check the word order' + : `Valid ${words.length}-word phrase` + return ( +
    + +
    +
    + Import recovery phrase +
    +
    + Paste or type your 12 or 24 words separated by spaces. Checked locally + against the BIP39 wordlist. +
    + +
    + + {status || ' '} + + + {words.length}/24 + +
    +
    + + +
    +
    +
    + +
    +
    + ) +} + +function DiscoveryScreenBE({ api }) { + const [n, setN] = useState(0) + useEffect(() => { + const t = setInterval(() => setN((x) => Math.min(3, x + 1)), 700) + return () => clearInterval(t) + }, []) + const done = n === 3 + return ( +
    + +
    +
    + {done ? 'Wallet restored' : 'Scanning for accounts…'} +
    +
    + Looking up used addresses on Bitcoin and Mintlayer. +
    +
    + {ONB_ACCOUNTS.map((a, i) => ( +
    + +
    +
    {a.name}
    +
    + {a.path} +
    +
    +
    +
    + {a.usd} +
    +
    + {a.btc} BTC · {a.ml} ML +
    +
    +
    + ))} +
    + {!done && ( +
    +
    + Found {n} of 3 +
    + )} +
    +
    + +
    +
    + ) +} + +function DoneScreenBE({ api }) { + return ( +
    +
    + +
    + {[ + [ + 'Pin Mojito to your toolbar', + 'One click to approve dApp requests', + ], + ['Auto-lock after 15 min', 'Change anytime in Settings → Security'], + ].map(([t, s]) => ( +
    + +
    +
    {t}
    +
    {s}
    +
    +
    + ))} +
    +
    +
    + +
    +
    + ) +} + +function UnlockScreenBE({ onUnlock, onForgot, reason }) { + const [pw, setPw] = useState('') + const [bad, setBad] = useState(false) + const go = () => { + if (pw.length >= 4) onUnlock() + else { + setBad(true) + setTimeout(() => setBad(false), 500) + } + } + return ( +
    +
    +
    + +
    +
    + Welcome back +
    +
    + {reason || 'Enter your password to unlock'} +
    +
    + +
    + {bad && ( + + Incorrect password + + )} +
    +
    + + +
    +
    + ) +} + +Object.assign(window, { + WelcomeScreenBE, + PasswordScreenBE, + SeedScreenBE, + VerifyScreenBE, + ImportScreenBE, + DiscoveryScreenBE, + DoneScreenBE, + UnlockScreenBE, +}) diff --git a/doc/be-send.jsx b/doc/be-send.jsx new file mode 100644 index 00000000..2fbc4315 --- /dev/null +++ b/doc/be-send.jsx @@ -0,0 +1,726 @@ +// Mojito BE — Send (tokens + NFTs) and Receive + +const BTC_FEES = { + economy: { l: 'Economy', eta: '~60 min', rate: 4 }, + standard: { l: 'Standard', eta: '~20 min', rate: 11 }, + fast: { l: 'Fast', eta: '~10 min', rate: 22 }, +} +const validAddr = (addr, chain) => + chain === 'Bitcoin' + ? /^(bc1|tb1)[a-z0-9]{25,}$/i.test(addr) + : /^(mtc|tmt)1q[a-z0-9]{25,}$/i.test(addr) + +function SendScreenBE({ s, nav, asset: initAsset, nft: nftId }) { + const nft = nftId ? s.nfts.find((n) => n.id === nftId) : null + const [assetId, setAssetId] = useState(initAsset || (nft ? 'ml' : 'btc')) + const a = s.assets.find((x) => x.id === assetId) + const chain = nft ? 'Mintlayer' : a.chain + const [step, setStep] = useState(0) // 0 form, 1 review, 2 password, 3 done + const [to, setTo] = useState('') + const [amt, setAmt] = useState('') + const [inUsd, setInUsd] = useState(false) + const [fee, setFee] = useState('standard') + const [pick, setPick] = useState(false) + const [pw, setPw] = useState('') + const [bad, setBad] = useState(false) + const [toast, toastEl] = useToast() + const num = parseFloat(amt) || 0 + const tokenAmt = inUsd ? (a.price ? num / a.price : 0) : num + const feeAmt = chain === 'Bitcoin' ? (BTC_FEES[fee].rate * 141) / 1e8 : 0.2 + const feeSym = chain === 'Bitcoin' ? 'BTC' : 'ML' + const mlBal = s.assets.find((x) => x.id === 'ml').amount + const addrOk = validAddr(to, chain) + const insufficient = nft + ? mlBal < feeAmt + : (a.id === 'ml' ? tokenAmt + feeAmt > a.amount : tokenAmt > a.amount) || + (chain === 'Mintlayer' && a.id !== 'ml' && mlBal < feeAmt) + const canReview = addrOk && (nft || tokenAmt > 0) && !insufficient + const setMax = () => { + setInUsd(false) + setAmt( + String( + a.id === 'ml' || a.id === 'btc' + ? Math.max(0, a.amount - feeAmt) + : a.amount, + ), + ) + } + const confirm = () => { + if (pw.length >= 4) { + s.pushTx( + nft + ? { + type: 'nft', + sym: 'NFT', + chain, + name: nft.name, + to, + fee: '0.2 ML', + } + : { + type: 'send', + sym: a.sym, + chain, + amount: tokenAmt, + usd: tokenAmt * a.price, + to, + fee: `${feeAmt} ${feeSym}`, + }, + ) + if (nft) s.removeNft(nft.id) + setStep(3) + } else { + setBad(true) + setTimeout(() => setBad(false), 500) + } + } + const title = nft ? 'Transfer NFT' : 'Send' + + if (step === 3) + return ( +
    + +
    + +
    + + ● Broadcast · 0/{chain === 'Bitcoin' ? 6 : 1} conf + , + ], + [ + 'Tx hash', + chain === 'Bitcoin' ? 'a7c1f0…3d9e' : '0x82bd…41f7', + ], + ['Network fee', `${feeAmt} ${feeSym}`], + ]} + /> +
    +
    +
    +
    + + +
    + {toastEl} +
    + ) + + if (step === 2) + return ( +
    + setStep(1)} + onClose={nav.closeAll} + /> +
    +
    +
    + +
    +
    + Sign {nft ? 'transfer' : `${fmtAmt(tokenAmt, 8)} ${a.sym}`} +
    +
    + Enter your password to sign and broadcast on {chain}. +
    +
    +
    + +
    + {bad && ( + + Incorrect password + + )} +
    +
    + +
    +
    + ) + + if (step === 1) + return ( +
    + setStep(0)} + onClose={nav.closeAll} + /> +
    +
    +
    You send
    + {nft ? ( +
    + {nft.name} +
    + ) : ( + +
    + {fmtAmt(tokenAmt, 8)}{' '} + + {a.sym} + +
    +
    + ≈ {fmtUsd(tokenAmt * a.price)} +
    +
    + )} +
    + ], + [ + 'Fee', + `${feeAmt} ${feeSym}${chain === 'Bitcoin' ? ` · ${BTC_FEES[fee].rate} sat/vB` : ''}`, + ], + ['Arrival', chain === 'Bitcoin' ? BTC_FEES[fee].eta : '~2 min'], + ...(nft || a.sym !== feeSym + ? [] + : [['Total', `${fmtAmt(tokenAmt + feeAmt, 8)} ${a.sym}`]]), + ]} + /> + {chain === 'Bitcoin' && ( +
    + Bitcoin transactions are final once confirmed. Double-check the + address. +
    + )} +
    +
    + setStep(2)} + /> +
    +
    + ) + + return ( +
    + +
    + {nft ? ( +
    +
    +
    +
    +
    +
    {nft.name}
    +
    {nft.collection} · Mintlayer
    +
    +
    + ) : ( +
    + +
    setPick(true)} + > + + + {a.sym}{' '} + + · {a.name} + + + + {fmtAmt(a.amount, 4)} + + +
    +
    + )} +
    + +
    + setTo(e.target.value.trim())} + spellCheck={false} + /> + + toast('Scanner opens in a new tab')} + > + + +
    + {to && !addrOk && ( + + Not a valid {chain} address + {chain === 'Mintlayer' ? ' (expects mtc1q… on mainnet)' : ''} + + )} + {addrOk && s.accounts.some((x) => x.btc === to || x.ml === to) && ( + One of your own accounts + )} +
    + {!nft && ( +
    +
    + + a.price && setInUsd((u) => !u)} + > + {inUsd + ? `≈ ${fmtAmt(tokenAmt, 6)} ${a.sym}` + : `≈ ${fmtUsd(tokenAmt * a.price)}`}{' '} + ⇅ + +
    +
    + {inUsd && ( + $ + )} + setAmt(e.target.value.replace(/[^\d.]/g, ''))} + inputMode="decimal" + /> + {!inUsd && ( + + {a.sym} + + )} + +
    + + {insufficient + ? chain === 'Mintlayer' && a.id !== 'ml' && mlBal < feeAmt + ? 'Not enough ML to pay the network fee' + : 'Insufficient balance' + : `Available ${fmtAmt(a.amount, 8)} ${a.sym}`} + +
    + )} +
    + + {chain === 'Bitcoin' ? ( +
    + {Object.entries(BTC_FEES).map(([k, f]) => ( +
    setFee(k)} + style={{ + padding: '10px 8px', + borderRadius: 12, + cursor: 'pointer', + textAlign: 'center', + background: + fee === k ? 'var(--amber-soft)' : 'oklch(1 0 0 / 0.03)', + border: `1px solid ${fee === k ? 'oklch(0.82 0.16 70 / 0.5)' : 'var(--line-soft)'}`, + transition: 'all 150ms', + }} + > +
    {f.l}
    +
    + {f.rate} sat/vB +
    +
    + {f.eta} +
    +
    + ))} +
    + ) : ( +
    + + 0.2 ML{' '} + + · ≈ {fmtUsd(0.2 * BE_PRICES.ML)} + + + ~2 min +
    + )} +
    +
    +
    + +
    + setPick(false)} + title="Select asset" + label="Asset picker" + > +
    + {s.assets + .filter((x) => !x.hidden) + .map((x) => ( +
    { + setAssetId(x.id) + setTo('') + setAmt('') + setPick(false) + }} + > + +
    +
    {x.sym}
    +
    {x.chain}
    +
    + + {fmtAmt(x.amount, 4)} + +
    + ))} +
    +
    + {toastEl} +
    + ) +} + +function ReceiveScreenBE({ s, nav, chain: init }) { + const [chain, setChain] = useState(init || 'Bitcoin') + const [fresh, setFresh] = useState(0) + const [toast, toastEl] = useToast() + const base = chain === 'Bitcoin' ? s.account.btc : s.account.ml + const addr = fresh + ? base.slice(0, -4) + ['k7x2', 'p9m4', 'z3q8'][fresh % 3] + : base + const col = chain === 'Bitcoin' ? 'var(--amber)' : 'var(--teal)' + return ( +
    + +
    + { + setChain(c) + setFresh(0) + }} + /> +
    + +
    +
    + + {s.account.name} +
    +
    + {addr} +
    +
    + + +
    + {chain === 'Bitcoin' && ( + + )} +
    + {chain === 'Bitcoin' + ? 'Native SegWit (bech32). A fresh address per payment protects your privacy.' + : 'Use this address for ML and all Mintlayer tokens and NFTs.'} +
    +
    + {toastEl} +
    + ) +} + +Object.assign(window, { SendScreenBE, ReceiveScreenBE, validAddr }) diff --git a/doc/be-settings.jsx b/doc/be-settings.jsx new file mode 100644 index 00000000..90632c40 --- /dev/null +++ b/doc/be-settings.jsx @@ -0,0 +1,954 @@ +// Mojito BE — Activity tab, tx detail, Settings (with sub pages) + +function ActivityScreenBE({ s, nav }) { + const [f, setF] = useState('All') + const list = s.activity.filter( + (t) => + f === 'All' || + (f === 'BTC' ? t.chain === 'Bitcoin' : t.chain === 'Mintlayer'), + ) + const pending = list.filter( + (t) => t.status !== 'Confirmed' && t.status !== 'Failed', + ) + return ( +
    + +
    +
    + Activity + +
    + {pending.length > 0 && ( + +
    + Pending +
    +
    + {pending.map((t) => ( + nav.sheet({ t: 'tx', id: t.id })} + /> + ))} +
    +
    + )} +
    + History +
    + {list.length ? ( +
    + {list + .filter((t) => !pending.includes(t)) + .map((t) => ( + nav.sheet({ t: 'tx', id: t.id })} + /> + ))} +
    + ) : ( + + )} +
    +
    + ) +} + +function TxSheet({ s, nav, id }) { + const t = s.activity.find((x) => x.id === id) + const [ic, col] = txIcon[t.type] + const sc = + t.status === 'Confirmed' + ? 'var(--green)' + : t.status === 'Failed' + ? 'var(--red)' + : 'var(--amber)' + return ( + +
    +
    + +
    +
    + {t.type === 'nft' + ? t.name + : `${t.type === 'receive' || t.type === 'mint' ? '+' : '−'}${fmtAmt(t.amount, 8)} ${t.sym}`} +
    + {t.usd != null && ( +
    {fmtUsd(t.usd)} at the time
    + )} +
    + + {t.status} + {t.conf ? ` · ${t.conf}` : ''} + +
    +
    + ], + ...(t.from ? [['From', t.from]] : []), + ...(t.to ? [['To', t.to]] : []), + ...(t.fee ? [['Fee', t.fee]] : []), + ['Hash', t.hash], + ]} + /> +
    + + +
    + {t.status === 'Confirming' && t.chain === 'Bitcoin' && ( + + )} +
    + ) +} + +// Settings root + pages: accounts | security | network | sites | prefs | about +const SET_PAGES = { + accounts: 'Accounts', + security: 'Security', + network: 'Network', + sites: 'Connected sites', + prefs: 'Preferences', + about: 'About', +} + +function SettingsScreenBE({ s, nav, page: initPage }) { + const [page, setPage] = useState(initPage || null) + if (page) + return ( + (initPage ? nav.close() : setPage(null))} + /> + ) + const items = [ + [ + 'accounts', + 'shield', + 'Accounts', + `${s.accounts.length} accounts · ${s.account.name} active`, + ], + ['security', 'lock', 'Security', `Auto-lock ${s.autoLock} · password`], + ['network', 'bridge', 'Network', `${s.network} · public nodes`], + ['sites', 'flash', 'Connected sites', `${s.sites.length} sites`], + ['prefs', 'settings', 'Preferences', `${s.currency} · English`], + ['about', 'history', 'About', 'Mojito 2.0.0'], + ] + return ( +
    + +
    +
    + Settings +
    +
    + {items.map(([id, ic, t, sub]) => ( +
    setPage(id)} + > +
    + +
    +
    +
    {t}
    +
    + {sub} +
    +
    + +
    + ))} +
    + +
    + Mojito 2.0.0 · Support · Docs +
    +
    +
    + ) +} + +function SettingsPage({ s, nav, page, onBack }) { + const [sheet, setSheet] = useState(null) + const [pw, setPw] = useState('') + const [pw1, setPw1] = useState('') + const [pw2, setPw2] = useState('') + const [revealed, setRevealed] = useState(false) + const [rename, setRename] = useState('') + const [node, setNode] = useState('') + const body = { + accounts: ( + +
    + {s.accounts.map((a) => ( +
    { + setRename(a.name) + setSheet({ t: 'acct', id: a.id }) + }} + > + +
    +
    + {a.name} + {a.id === s.account.id && ( + · active + )} +
    +
    + BTC {shortAddr(a.btc, 6)} · ML {shortAddr(a.ml, 6)} +
    +
    + +
    + ))} +
    + +
    + All accounts derive from the same recovery phrase (BIP44 · m/84'/0'/n' + for BTC, m/44'/19788'/n' for ML). +
    +
    + ), + security: ( + +
    + Auto-lock +
    + +
    + Credentials +
    +
    +
    setSheet({ t: 'pw' })} + > + + Change password + +
    +
    { + setRevealed(false) + setPw('') + setSheet({ t: 'seed' }) + }} + > + + + Reveal recovery phrase + + +
    +
    +
    + Approvals +
    +
    +
    +
    +
    + Require password for every transaction +
    +
    + Also applies to dApp requests +
    +
    + +
    +
    +
    +
    Show hex data on sign requests
    +
    + +
    +
    + +
    + ), + network: ( + +
    + Network +
    + { + s.setNetwork(v) + nav.toast(`Switched to ${v}`) + }} + /> + {s.network === 'Testnet' && ( +
    + Testnet coins have no value. Addresses start with tb1 / tmt1q. +
    + )} +
    + Nodes +
    +
    + {[ + ['Bitcoin', 'Electrum · electrum.mojito.io:50002', 'var(--amber)'], + [ + 'Mintlayer', + 'api.mintlayer.org · height 1,204,881', + 'var(--teal)', + ], + ].map(([n, d, c]) => ( +
    + +
    +
    {n}
    +
    + {d} +
    +
    + Online +
    + ))} +
    +
    + +
    + setNode(e.target.value)} + /> + +
    +
    +
    + ), + sites: ( + + {s.sites.length ? ( +
    + {s.sites.map((site) => ( +
    setSheet({ t: 'site', origin: site.origin })} + > +
    + {site.name[0]} +
    +
    +
    + {site.name} +
    +
    + {site.origin} · {site.accounts.length} account + {site.accounts.length > 1 ? 's' : ''} +
    +
    + +
    + ))} +
    + ) : ( + + )} +
    + Connected sites can see your addresses and balances and request + signatures. Nothing is signed without your approval. +
    +
    + ), + prefs: ( + +
    + Display currency +
    + +
    + Display +
    +
    +
    + + Hide balances by default + + +
    +
    + + Show hidden tokens in home + + nav.toast('Use Manage assets to unhide tokens')} + /> +
    +
    + Language + English + +
    +
    +
    + ), + about: ( + +
    +
    + +
    +
    + Mojito +
    +
    + 2.0.0 (build 4120) · open source +
    +
    +
    + {[ + ['Release notes', 'arrow_r'], + ['Support', 'arrow_r'], + ['Terms of use', 'arrow_r'], + ['Privacy policy', 'arrow_r'], + ].map(([t, i]) => ( +
    + {t} + +
    + ))} +
    +
    + ), + }[page] + + const acct = + sheet?.t === 'acct' ? s.accounts.find((a) => a.id === sheet.id) : null + const site = + sheet?.t === 'site' ? s.sites.find((x) => x.origin === sheet.origin) : null + return ( +
    + +
    + {body} +
    + setSheet(null)} + label="Settings sheet" + title={ + acct + ? 'Account' + : site + ? site.name + : sheet?.t === 'pw' + ? 'Change password' + : sheet?.t === 'seed' + ? 'Recovery phrase' + : sheet?.t === 'reset' + ? 'Remove wallet?' + : '' + } + > + {acct && ( + +
    + +
    + setRename(e.target.value)} + /> +
    +
    +
    + +
    +
    + + +
    +
    + )} + {site && ( + + s.accounts.find((a) => a.id === id)?.name) + .join(', '), + ], + ]} + /> + + + )} + {sheet?.t === 'pw' && ( +
    + + + + + +
    + )} + {sheet?.t === 'seed' && + (!revealed ? ( +
    +
    + + + Never share this phrase. Anyone asking for it is trying to + steal your funds. + +
    + pw.length >= 4 && setRevealed(true)} + /> + +
    + ) : ( + +
    + {BE_SEED.map((w, i) => ( +
    + {i + 1} + {w} +
    + ))} +
    + +
    + ))} + {sheet?.t === 'reset' && ( +
    +
    + This deletes your keys and settings from this browser. You can + restore the wallet later only with your recovery phrase. +
    + { + setSheet(null) + s.reset() + }} + /> +
    + )} +
    +
    + ) +} + +Object.assign(window, { + ActivityScreenBE, + TxSheet, + SettingsScreenBE, + SettingsPage, +}) diff --git a/doc/be.css b/doc/be.css new file mode 100644 index 00000000..6f9b84dd --- /dev/null +++ b/doc/be.css @@ -0,0 +1,685 @@ +/* Mojito BE — extension popup additions (extends styles.css) */ +.popup { + position: relative; + width: 392px; + height: 604px; + overflow: hidden; + color: var(--text-0); +} +.popup .app { + border-radius: 0; +} +.pp-chrome { + height: 36px; + background: #33334a; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 12px; + color: #fff; + font: + 600 13px Inter, + sans-serif; +} +.pp-frame { + width: 392px; + border-radius: 14px; + overflow: hidden; + box-shadow: + 0 0 0 1px #2a2622, + 0 60px 120px -30px rgba(0, 0, 0, 0.85), + 0 30px 60px -20px rgba(217, 130, 60, 0.15); +} +.layer { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + z-index: 2; +} +.scroll { + flex: 1; + min-height: 0; + overflow-y: auto; + overflow-x: hidden; + scrollbar-width: none; +} +.scroll::-webkit-scrollbar { + display: none; +} +.pad { + padding: 0 18px; +} +.hdr { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 14px 8px; + flex-shrink: 0; + position: relative; + z-index: 5; +} +.hdr h1 { + flex: 1; + margin: 0; + font: + 600 15px Inter, + sans-serif; + text-align: center; + letter-spacing: -0.01em; +} +.ib { + width: 32px; + height: 32px; + border-radius: 10px; + background: oklch(1 0 0 / 0.04); + border: 1px solid var(--line-soft); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + flex-shrink: 0; + color: var(--text-1); + transition: background 120ms; +} +.ib:hover { + background: oklch(1 0 0 / 0.08); +} +.ib.ghost { + visibility: hidden; +} +.card { + background: oklch(1 0 0 / 0.03); + border: 1px solid var(--line-soft); + border-radius: 16px; +} +.row { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 14px; + cursor: pointer; + transition: background 120ms; +} +.row:hover { + background: oklch(1 0 0 / 0.03); +} +.row + .row { + border-top: 1px solid var(--line-soft); +} +.eyebrow { + font: + 500 10px 'JetBrains Mono', + monospace; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--text-3); +} +.field { + display: flex; + flex-direction: column; + gap: 6px; +} +.field label { + font: + 500 11px Inter, + sans-serif; + color: var(--text-2); +} +.inp { + display: flex; + align-items: center; + gap: 8px; + height: 46px; + padding: 0 12px; + border-radius: 12px; + background: oklch(1 0 0 / 0.04); + border: 1px solid var(--line-soft); + transition: + border-color 120ms, + box-shadow 120ms; +} +.inp:focus-within { + border-color: var(--amber); + box-shadow: 0 0 0 1px oklch(0.82 0.16 70 / 0.35); +} +.inp.bad { + border-color: var(--red); +} +.inp.ok { + border-color: oklch(0.78 0.16 150 / 0.6); +} +.inp input, +.inp select { + flex: 1; + min-width: 0; + background: transparent; + border: none; + outline: none; + color: var(--text-0); + font: + 500 14px Inter, + sans-serif; +} +.inp input::placeholder { + color: var(--text-3); +} +.inp .mono input { + font-family: 'JetBrains Mono', monospace; +} +.inp .act { + font: + 600 11px Inter, + sans-serif; + color: var(--amber); + cursor: pointer; + background: var(--amber-soft); + border: none; + padding: 5px 8px; + border-radius: 7px; +} +.ta { + width: 100%; + min-height: 96px; + resize: none; + border-radius: 12px; + padding: 12px; + background: oklch(1 0 0 / 0.04); + border: 1px solid var(--line-soft); + color: var(--text-0); + font: + 500 13px/1.6 'JetBrains Mono', + monospace; + outline: none; +} +.ta:focus { + border-color: var(--amber); +} +.hint { + font-size: 11px; + color: var(--text-2); + line-height: 1.5; +} +.hint.bad { + color: var(--red); +} +.hint.ok { + color: var(--green); +} +.btn.sm { + height: 38px; + font-size: 13px; + border-radius: 12px; +} +.btn.danger { + background: oklch(0.7 0.2 25 / 0.12); + border-color: oklch(0.7 0.2 25 / 0.4); + color: var(--red); +} +.btn:disabled { + opacity: 0.4; + pointer-events: none; +} +.btn.ghost { + background: transparent; + border-color: transparent; + color: var(--text-2); +} +.seg { + display: flex; + gap: 3px; + padding: 3px; + background: oklch(1 0 0 / 0.04); + border-radius: 10px; + border: 1px solid var(--line-soft); +} +.seg button { + flex: 1; + padding: 7px 10px; + border-radius: 8px; + font: + 500 12px Inter, + sans-serif; + background: transparent; + color: var(--text-2); + border: none; + cursor: pointer; + transition: all 150ms; +} +.seg button.on { + background: oklch(1 0 0 / 0.08); + color: var(--text-0); +} +.sw { + width: 38px; + height: 22px; + border-radius: 999px; + background: oklch(1 0 0 / 0.1); + position: relative; + cursor: pointer; + transition: background 200ms; + flex-shrink: 0; +} +.sw::after { + content: ''; + position: absolute; + top: 2px; + left: 2px; + width: 18px; + height: 18px; + border-radius: 50%; + background: var(--text-1); + transition: transform 200ms cubic-bezier(0.2, 1.2, 0.4, 1); +} +.sw.on { + background: var(--amber); +} +.sw.on::after { + transform: translateX(16px); + background: #1a1208; +} +.seedgrid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 6px; +} +.seedw { + display: flex; + align-items: center; + gap: 6px; + height: 36px; + padding: 0 9px; + border-radius: 9px; + background: oklch(1 0 0 / 0.04); + border: 1px solid var(--line-soft); + font: + 500 12px 'JetBrains Mono', + monospace; +} +.seedw .n { + font-size: 9px; + color: var(--text-3); + min-width: 14px; +} +.seedw.blur { + filter: blur(6px); + user-select: none; +} +.tag { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 3px 8px; + border-radius: 6px; + font: + 600 10px Inter, + sans-serif; + letter-spacing: 0.04em; + text-transform: uppercase; +} +.tag.amber { + color: var(--amber); + background: var(--amber-soft); +} +.tag.teal { + color: var(--teal); + background: var(--teal-soft); +} +.tag.green { + color: var(--green); + background: oklch(0.78 0.16 150 / 0.12); +} +.tag.red { + color: var(--red); + background: oklch(0.7 0.2 25 / 0.12); +} +.tag.grey { + color: var(--text-2); + background: oklch(1 0 0 / 0.06); +} +.tag.violet { + color: var(--violet); + background: var(--violet-soft); +} +.kv { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + padding: 10px 0; + font-size: 12px; +} +.kv + .kv { + border-top: 1px solid var(--line-soft); +} +.kv .k { + color: var(--text-2); + flex-shrink: 0; +} +.kv .v { + flex: 1; + min-width: 0; + color: var(--text-0); + text-align: right; + font-family: 'JetBrains Mono', monospace; + font-size: 12px; + overflow-wrap: anywhere; + display: flex; + justify-content: flex-end; +} +.kv .v > * { + flex-shrink: 0; + white-space: nowrap; + overflow-wrap: normal; +} +.tag, +.chip { + white-space: nowrap; +} +.nav { + display: grid; + grid-template-columns: repeat(4, 1fr); + height: 58px; + border-top: 1px solid var(--line-soft); + background: oklch(0.16 0.014 60 / 0.85); + backdrop-filter: blur(20px); + flex-shrink: 0; + position: relative; + z-index: 5; +} +.nav button { + background: transparent; + border: none; + color: var(--text-2); + font: + 500 10px Inter, + sans-serif; + letter-spacing: 0.03em; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + cursor: pointer; + position: relative; +} +.nav button.on { + color: var(--text-0); +} +.nav button.on::before { + content: ''; + position: absolute; + top: 0; + width: 24px; + height: 2px; + border-radius: 999px; + background: var(--amber); + box-shadow: 0 0 8px var(--amber); +} +.qa { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px; +} +.qa button { + padding: 12px 0; + border-radius: 14px; + background: oklch(1 0 0 / 0.04); + border: 1px solid var(--line-soft); + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + cursor: pointer; + color: var(--text-0); + font: + 500 11px Inter, + sans-serif; + transition: background 120ms; +} +.qa button:hover { + background: oklch(1 0 0 / 0.08); +} +.nft { + aspect-ratio: 1; + border-radius: 14px; + overflow: hidden; + border: 1px solid var(--line-soft); + cursor: pointer; + position: relative; + background: repeating-linear-gradient( + 45deg, + oklch(1 0 0 / 0.03), + oklch(1 0 0 / 0.03) 8px, + transparent 8px, + transparent 16px + ); +} +.nft .cap { + position: absolute; + left: 0; + right: 0; + bottom: 0; + padding: 8px 10px; + background: linear-gradient(transparent, oklch(0 0 0 / 0.7)); + font: + 600 11px Inter, + sans-serif; +} +.ph { + display: flex; + align-items: center; + justify-content: center; + color: var(--text-3); + font: + 500 10px 'JetBrains Mono', + monospace; + text-align: center; + border-radius: 14px; + border: 1px dashed var(--line); + background: repeating-linear-gradient( + 45deg, + oklch(1 0 0 / 0.025), + oklch(1 0 0 / 0.025) 8px, + transparent 8px, + transparent 16px + ); +} +.toast { + position: absolute; + left: 50%; + bottom: 76px; + transform: translateX(-50%); + padding: 9px 14px; + border-radius: 10px; + background: oklch(0.24 0.014 60); + border: 1px solid var(--line); + color: var(--text-0); + font: + 500 12px Inter, + sans-serif; + z-index: 90; + animation: slide-up 200ms ease both; + white-space: nowrap; + box-shadow: 0 10px 30px -10px rgba(0, 0, 0, 0.6); +} +.hold { + position: relative; + overflow: hidden; +} +.hold .fill { + position: absolute; + inset: 0; + background: oklch(0 0 0 / 0.18); + transform-origin: left; + transform: scaleX(0); +} +.hold.go .fill { + animation: hold-fill 1.1s linear forwards; +} +@keyframes hold-fill { + to { + transform: scaleX(1); + } +} +.step-in { + animation: scr-in 220ms ease both; +} +@keyframes scr-in { + from { + opacity: 0; + transform: translateX(18px); + } + to { + opacity: 1; + transform: none; + } +} +@keyframes sheet-up { + from { + transform: translateY(40px); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +} +@keyframes shake { + 0%, + 100% { + transform: translateX(0); + } + 20% { + transform: translateX(-8px); + } + 40% { + transform: translateX(8px); + } + 60% { + transform: translateX(-5px); + } + 80% { + transform: translateX(5px); + } +} +.shake { + animation: shake 450ms ease; +} +.strength { + display: flex; + gap: 4px; +} +.strength i { + flex: 1; + height: 3px; + border-radius: 999px; + background: oklch(1 0 0 / 0.08); + transition: background 200ms; +} +.origin { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + border-radius: 12px; + background: oklch(1 0 0 / 0.03); + border: 1px solid var(--line-soft); +} +.favicon { + width: 30px; + height: 30px; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + font: + 700 13px Inter, + sans-serif; + flex-shrink: 0; +} +.wchip { + padding: 7px 11px; + border-radius: 9px; + background: oklch(1 0 0 / 0.05); + border: 1px solid var(--line-soft); + color: var(--text-0); + font: + 500 12px 'JetBrains Mono', + monospace; + cursor: pointer; + transition: all 120ms; +} +.wchip.used { + opacity: 0.3; + cursor: default; +} +.wchip.wrong { + border-color: var(--red); + color: var(--red); + animation: shake 400ms ease; +} +a { + color: var(--amber); + text-decoration: none; +} +a:hover { + color: oklch(0.88 0.14 75); +} +.dev { + position: fixed; + right: 16px; + top: 50%; + transform: translateY(-50%); + width: 220px; + max-height: calc(100vh - 40px); + overflow-y: auto; + padding: 14px; + border-radius: 16px; + background: oklch(0.18 0.014 60 / 0.9); + border: 1px solid var(--line-soft); + backdrop-filter: blur(16px); + z-index: 100; + font-family: Inter, sans-serif; + scrollbar-width: none; +} +.dev.mini { + white-space: nowrap; + width: auto; + cursor: pointer; + padding: 10px 14px; + font: + 600 11px Inter, + sans-serif; + color: var(--amber); +} +.dev::-webkit-scrollbar { + display: none; +} +.dev-jump { + padding: 6px 8px; + border-radius: 8px; + border: 1px solid var(--line-soft); + background: oklch(1 0 0 / 0.04); + color: var(--text-1); + font: + 500 10px Inter, + sans-serif; + cursor: pointer; + text-align: left; +} +.dev-jump.cur { + border-color: oklch(0.82 0.16 70 / 0.5); + color: var(--amber); + background: var(--amber-soft); +} +.dev-seg { + padding: 6px 4px; + border-radius: 8px; + border: 1px solid; + font: + 600 10px Inter, + sans-serif; + cursor: pointer; + transition: all 120ms; +} diff --git a/doc/components.jsx b/doc/components.jsx new file mode 100644 index 00000000..53d80cc0 --- /dev/null +++ b/doc/components.jsx @@ -0,0 +1,591 @@ +// Mojito Wallet — shared components + +const { useState, useEffect, useRef, useMemo } = React + +// ─── Logo (geometric M with orbit) ─────────────────────────── +function MojitoLogo({ size = 48, animate = true }) { + return ( +
    + + + + + + + + + + + + + {/* M shape from triangles */} + + + + + {animate && ( +
    + )} +
    + ) +} + +// ─── Animated counter ──────────────────────────────────────── +function Counter({ + value, + decimals = 2, + prefix = '', + suffix = '', + duration = 1200, +}) { + const [v, setV] = useState(0) + useEffect(() => { + let start = performance.now() + let raf + const tick = (t) => { + const p = Math.min(1, (t - start) / duration) + const eased = 1 - Math.pow(1 - p, 3) + setV(value * eased) + if (p < 1) raf = requestAnimationFrame(tick) + } + raf = requestAnimationFrame(tick) + return () => cancelAnimationFrame(raf) + }, [value, duration]) + return ( + + {prefix} + {v.toLocaleString(undefined, { + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + })} + {suffix} + + ) +} + +// ─── Live ticker (small +/- pill) ──────────────────────────── +function LivePill({ value, suffix = '%' }) { + const positive = value >= 0 + return ( + + {positive ? '▲' : '▼'} + {Math.abs(value).toFixed(2)} + {suffix} + + ) +} + +// ─── Token icon (procedural) ───────────────────────────────── +function TokenIcon({ symbol, size = 36 }) { + const map = { + BTC: { c1: 'oklch(0.82 0.16 70)', c2: 'oklch(0.7 0.17 50)', g: '₿' }, + ML: { c1: 'oklch(0.82 0.12 195)', c2: 'oklch(0.7 0.14 210)', g: 'Ⓜ' }, + ETH: { c1: 'oklch(0.74 0.06 280)', c2: 'oklch(0.55 0.08 280)', g: 'Ξ' }, + USDT: { c1: 'oklch(0.78 0.13 160)', c2: 'oklch(0.6 0.12 160)', g: '₮' }, + USDC: { c1: 'oklch(0.7 0.13 240)', c2: 'oklch(0.55 0.14 250)', g: '$' }, + MATIC: { c1: 'oklch(0.7 0.18 290)', c2: 'oklch(0.55 0.18 290)', g: '◆' }, + BNB: { c1: 'oklch(0.85 0.15 90)', c2: 'oklch(0.7 0.16 80)', g: '◈' }, + ARB: { c1: 'oklch(0.7 0.13 230)', c2: 'oklch(0.55 0.14 230)', g: '◉' }, + OP: { c1: 'oklch(0.7 0.2 25)', c2: 'oklch(0.55 0.2 20)', g: '○' }, + } + const t = map[symbol] || { + c1: 'oklch(0.6 0.05 60)', + c2: 'oklch(0.4 0.05 60)', + g: symbol[0], + } + return ( +
    + {t.g} +
    + ) +} + +// ─── Chain badge (small) ───────────────────────────────────── +function ChainBadge({ chain }) { + const map = { + Mintlayer: { color: 'var(--teal)', bg: 'var(--teal-soft)' }, + Bitcoin: { color: 'var(--amber)', bg: 'var(--amber-soft)' }, + Ethereum: { color: 'var(--violet)', bg: 'var(--violet-soft)' }, + Arbitrum: { + color: 'oklch(0.7 0.13 230)', + bg: 'oklch(0.7 0.13 230 / 0.14)', + }, + Polygon: { color: 'oklch(0.7 0.18 290)', bg: 'oklch(0.7 0.18 290 / 0.14)' }, + BNB: { color: 'oklch(0.85 0.15 90)', bg: 'oklch(0.85 0.15 90 / 0.14)' }, + } + const t = map[chain] || { color: 'var(--text-2)', bg: 'oklch(1 0 0 / 0.05)' } + return ( + + + {chain.toUpperCase()} + + ) +} + +// ─── Sparkline ─────────────────────────────────────────────── +function Sparkline({ data, color = 'var(--amber)', width = 70, height = 28 }) { + const min = Math.min(...data), + max = Math.max(...data) + const range = max - min || 1 + const pts = data + .map( + (v, i) => + `${(i / (data.length - 1)) * width},${height - ((v - min) / range) * height}`, + ) + .join(' ') + return ( + + + + ) +} + +// ─── Icon library (line) ───────────────────────────────────── +function Icon({ name, size = 20, color = 'currentColor', stroke = 1.6 }) { + const paths = { + home: ( + <> + + + + ), + swap: ( + <> + + + + ), + bridge: ( + <> + + + + + ), + chart: ( + <> + + + + + ), + settings: ( + <> + + + + ), + arrow_up: ( + <> + + + + ), + arrow_dn: ( + <> + + + + ), + arrow_r: ( + <> + + + + ), + plus: ( + <> + + + + ), + qr: ( + <> + + + + + + + + ), + scan: ( + <> + + + + + + + ), + bell: ( + <> + + + + ), + shield: ( + <> + + + + ), + flash: ( + <> + + + ), + history: ( + <> + + + + + ), + eye: ( + <> + + + + ), + eye_off: ( + <> + + + + + ), + lock: ( + <> + + + + ), + fingerprint: ( + <> + + + + + + ), + chevron_r: ( + <> + + + ), + card: ( + <> + + + + + ), + } + return ( + + {paths[name] || null} + + ) +} + +// ─── Status bar ────────────────────────────────────────────── +function StatusBar() { + return ( +
    + 9:41 +
    + + + + + + + + + + + + + + + +
    +
    + ) +} + +Object.assign(window, { + MojitoLogo, + Counter, + LivePill, + TokenIcon, + ChainBadge, + Sparkline, + Icon, + StatusBar, +}) diff --git a/doc/components/basic/AmountBlock.jsx b/doc/components/basic/AmountBlock.jsx new file mode 100644 index 00000000..db2437cd --- /dev/null +++ b/doc/components/basic/AmountBlock.jsx @@ -0,0 +1,39 @@ +// AmountBlock — big centered amount with symbol and optional USD line. Basic: no dependencies. +// Extracted from Send review, dApp tx/delegate review and the Asset screen header. +function AmountBlock({ label, amount, sym, usd, size = 30 }) { + return ( +
    + {label &&
    {label}
    } +
    + {amount}{' '} + + {sym} + +
    + {usd != null && ( +
    + ≈ {usd} +
    + )} +
    + ) +} + +Object.assign(window, { AmountBlock }) diff --git a/doc/components/basic/Avatar.jsx b/doc/components/basic/Avatar.jsx new file mode 100644 index 00000000..5ca5a204 --- /dev/null +++ b/doc/components/basic/Avatar.jsx @@ -0,0 +1,23 @@ +// Avatar — account avatar with gradient from account color. Basic: no dependencies. +function Avatar({ acct, size = 28 }) { + return ( +
    + {acct.name[0]} +
    + ) +} + +Object.assign(window, { Avatar }) diff --git a/doc/components/basic/BeSheet.jsx b/doc/components/basic/BeSheet.jsx new file mode 100644 index 00000000..0debca6f --- /dev/null +++ b/doc/components/basic/BeSheet.jsx @@ -0,0 +1,20 @@ +// BeSheet — titled, scrollable bottom sheet. Composes: Sheet. +function BeSheet({ open, onClose, title, children, label }) { + if (!open) return null + return ( + + {title && ( +
    + {title} +
    + )} +
    {children}
    +
    + ) +} + +Object.assign(window, { BeSheet }) diff --git a/doc/components/basic/BioCircle.jsx b/doc/components/basic/BioCircle.jsx new file mode 100644 index 00000000..16feeb60 --- /dev/null +++ b/doc/components/basic/BioCircle.jsx @@ -0,0 +1,64 @@ +// BioCircle — tappable biometric scan circle. Composes: BioGlyph. +function BioCircle({ bio, phase, onClick }) { + const ok = phase === 'ok' + const c = ok ? 'var(--green)' : 'var(--amber)' + return ( +
    + {phase === 'scan' && + [0, 0.45, 0.9].map((d, i) => ( +
    + ))} +
    + {ok ? ( + + ✓ + + ) : ( + + )} +
    +
    + ) +} + +Object.assign(window, { BioCircle }) diff --git a/doc/components/basic/BioGlyph.jsx b/doc/components/basic/BioGlyph.jsx new file mode 100644 index 00000000..473f951a --- /dev/null +++ b/doc/components/basic/BioGlyph.jsx @@ -0,0 +1,19 @@ +// BioGlyph — face or fingerprint glyph. Composes: FaceIcon, Icon. +function BioGlyph({ bio, size = 24, color, stroke = 1.4 }) { + return bio === 'face' ? ( + + ) : ( + + ) +} + +Object.assign(window, { BioGlyph }) diff --git a/doc/components/basic/Card.jsx b/doc/components/basic/Card.jsx new file mode 100644 index 00000000..d5b1625f --- /dev/null +++ b/doc/components/basic/Card.jsx @@ -0,0 +1,15 @@ +// Card — rounded surface container. Basic: no dependencies. +// Extracted from the `
    ` pattern used in every list/section. +function Card({ children, onClick, style, className = '' }) { + return ( +
    + {children} +
    + ) +} + +Object.assign(window, { Card }) diff --git a/doc/components/basic/ChainBadge.jsx b/doc/components/basic/ChainBadge.jsx new file mode 100644 index 00000000..766d841f --- /dev/null +++ b/doc/components/basic/ChainBadge.jsx @@ -0,0 +1,45 @@ +// ChainBadge — small chain pill with dot. Basic: no dependencies. +function ChainBadge({ chain }) { + const map = { + Mintlayer: { color: 'var(--teal)', bg: 'var(--teal-soft)' }, + Bitcoin: { color: 'var(--amber)', bg: 'var(--amber-soft)' }, + Ethereum: { color: 'var(--violet)', bg: 'var(--violet-soft)' }, + Arbitrum: { + color: 'oklch(0.7 0.13 230)', + bg: 'oklch(0.7 0.13 230 / 0.14)', + }, + Polygon: { color: 'oklch(0.7 0.18 290)', bg: 'oklch(0.7 0.18 290 / 0.14)' }, + BNB: { color: 'oklch(0.85 0.15 90)', bg: 'oklch(0.85 0.15 90 / 0.14)' }, + } + const t = map[chain] || { color: 'var(--text-2)', bg: 'oklch(1 0 0 / 0.05)' } + return ( + + + {chain.toUpperCase()} + + ) +} + +Object.assign(window, { ChainBadge }) diff --git a/doc/components/basic/Checkbox.jsx b/doc/components/basic/Checkbox.jsx new file mode 100644 index 00000000..f145f57a --- /dev/null +++ b/doc/components/basic/Checkbox.jsx @@ -0,0 +1,49 @@ +// Checkbox — square check control, optional label. Basic: no dependencies. +// Covers OnbCheck (labeled, 22px) and the dApp account-picker square (20px, no label). +function Checkbox({ checked, onToggle, label, size = 22 }) { + const box = ( +
    + {checked ? '✓' : ''} +
    + ) + if (!label) return box + return ( + + ) +} + +Object.assign(window, { Checkbox }) diff --git a/doc/components/basic/Counter.jsx b/doc/components/basic/Counter.jsx new file mode 100644 index 00000000..8c18e35e --- /dev/null +++ b/doc/components/basic/Counter.jsx @@ -0,0 +1,36 @@ +// Counter — animated number ticker. Basic: no dependencies. +const { useState, useEffect } = React + +function Counter({ + value, + decimals = 2, + prefix = '', + suffix = '', + duration = 1200, +}) { + const [v, setV] = useState(0) + useEffect(() => { + let start = performance.now() + let raf + const tick = (t) => { + const p = Math.min(1, (t - start) / duration) + const eased = 1 - Math.pow(1 - p, 3) + setV(value * eased) + if (p < 1) raf = requestAnimationFrame(tick) + } + raf = requestAnimationFrame(tick) + return () => cancelAnimationFrame(raf) + }, [value, duration]) + return ( + + {prefix} + {v.toLocaleString(undefined, { + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + })} + {suffix} + + ) +} + +Object.assign(window, { Counter }) diff --git a/doc/components/basic/Empty.jsx b/doc/components/basic/Empty.jsx new file mode 100644 index 00000000..8da4279d --- /dev/null +++ b/doc/components/basic/Empty.jsx @@ -0,0 +1,38 @@ +// EmptyState — empty state with icon, title, subtitle. Composes: Icon. +function Empty({ icon = 'history', title, sub }) { + return ( +
    + +
    + {title} +
    + {sub && ( +
    + {sub} +
    + )} +
    + ) +} + +Object.assign(window, { Empty }) diff --git a/doc/components/basic/Eyebrow.jsx b/doc/components/basic/Eyebrow.jsx new file mode 100644 index 00000000..8c1b999c --- /dev/null +++ b/doc/components/basic/Eyebrow.jsx @@ -0,0 +1,14 @@ +// Eyebrow — small uppercase section label. Basic: no dependencies. +// Extracted from the repeated `
    Section
    ` pattern. +function Eyebrow({ children, style }) { + return ( +
    + {children} +
    + ) +} + +Object.assign(window, { Eyebrow }) diff --git a/doc/components/basic/FaceIcon.jsx b/doc/components/basic/FaceIcon.jsx new file mode 100644 index 00000000..09e56a2a --- /dev/null +++ b/doc/components/basic/FaceIcon.jsx @@ -0,0 +1,25 @@ +// FaceIcon — Face ID style glyph. Basic: pure SVG. +function FaceIcon({ size = 24, color = 'currentColor', stroke = 1.6 }) { + return ( + + + + + + + + + + ) +} + +Object.assign(window, { FaceIcon }) diff --git a/doc/components/basic/Favicon.jsx b/doc/components/basic/Favicon.jsx new file mode 100644 index 00000000..e9418bd3 --- /dev/null +++ b/doc/components/basic/Favicon.jsx @@ -0,0 +1,17 @@ +// Favicon — colored letter circle for sites/dApps. Basic: no dependencies. +// Extracted from OriginCard and the settings "connected sites" rows. +function Favicon({ name, hue = 195, size = 36 }) { + return ( +
    + {name[0]} +
    + ) +} + +Object.assign(window, { Favicon }) diff --git a/doc/components/basic/Field.jsx b/doc/components/basic/Field.jsx new file mode 100644 index 00000000..38f60705 --- /dev/null +++ b/doc/components/basic/Field.jsx @@ -0,0 +1,14 @@ +// Field — labelled form field wrapper. Basic: no dependencies. +function Field({ label, children, style }) { + return ( +
    + {label && } + {children} +
    + ) +} + +Object.assign(window, { Field }) diff --git a/doc/components/basic/Hdr.jsx b/doc/components/basic/Hdr.jsx new file mode 100644 index 00000000..7d894a8b --- /dev/null +++ b/doc/components/basic/Hdr.jsx @@ -0,0 +1,53 @@ +// Hdr — screen header with back / title / close / right slot. Basic: no component dependencies. +function Hdr({ title, onBack, onClose, right }) { + return ( +
    + {onBack ? ( +
    + + + +
    + ) : ( +
    + )} +

    {title}

    + {right ? ( + right + ) : onClose ? ( +
    + + + +
    + ) : ( +
    + )} +
    + ) +} + +Object.assign(window, { Hdr }) diff --git a/doc/components/basic/HoldButton.jsx b/doc/components/basic/HoldButton.jsx new file mode 100644 index 00000000..1733c1a7 --- /dev/null +++ b/doc/components/basic/HoldButton.jsx @@ -0,0 +1,49 @@ +// HoldButton — hold-to-confirm button. Composes: Icon. +const { useState, useRef } = React + +function HoldButton({ label, onDone, className = 'primary', icon = 'lock' }) { + const [go, setGo] = useState(false) + const t = useRef(null) + const start = () => { + setGo(true) + t.current = setTimeout(() => { + setGo(false) + onDone() + }, 1100) + } + const stop = () => { + clearTimeout(t.current) + setGo(false) + } + return ( + + ) +} + +Object.assign(window, { HoldButton }) diff --git a/doc/components/basic/Icon.jsx b/doc/components/basic/Icon.jsx new file mode 100644 index 00000000..4bc6f972 --- /dev/null +++ b/doc/components/basic/Icon.jsx @@ -0,0 +1,195 @@ +// Icon — line icon library. Basic: pure SVG, no dependencies. +function Icon({ name, size = 20, color = 'currentColor', stroke = 1.6 }) { + const paths = { + home: ( + <> + + + + ), + swap: ( + <> + + + + ), + bridge: ( + <> + + + + + ), + chart: ( + <> + + + + + ), + settings: ( + <> + + + + ), + arrow_up: ( + <> + + + + ), + arrow_dn: ( + <> + + + + ), + arrow_r: ( + <> + + + + ), + plus: ( + <> + + + + ), + qr: ( + <> + + + + + + + + ), + scan: ( + <> + + + + + + + ), + bell: ( + <> + + + + ), + shield: ( + <> + + + + ), + flash: ( + <> + + + ), + history: ( + <> + + + + + ), + eye: ( + <> + + + + ), + eye_off: ( + <> + + + + + ), + lock: ( + <> + + + + ), + fingerprint: ( + <> + + + + + + ), + chevron_r: ( + <> + + + ), + card: ( + <> + + + + + ), + } + return ( + + {paths[name] || null} + + ) +} + +Object.assign(window, { Icon }) diff --git a/doc/components/basic/IconTile.jsx b/doc/components/basic/IconTile.jsx new file mode 100644 index 00000000..20e00d08 --- /dev/null +++ b/doc/components/basic/IconTile.jsx @@ -0,0 +1,37 @@ +// IconTile — colored rounded tile holding an icon. Basic: composes Icon. +// Extracted from TxRow / TxSheet type badges and Settings menu item icons. +function IconTile({ + icon, + color = 'var(--text-1)', + size = 32, + radius = 10, + bg, + children, +}) { + return ( +
    + {icon ? ( + + ) : ( + children + )} +
    + ) +} + +Object.assign(window, { IconTile }) diff --git a/doc/components/basic/Input.jsx b/doc/components/basic/Input.jsx new file mode 100644 index 00000000..1440d507 --- /dev/null +++ b/doc/components/basic/Input.jsx @@ -0,0 +1,36 @@ +// Input — the `.inp` composite: optional leading icon / prefix, input, suffix, action button. Basic: no component dependencies. +// Extracted from PwField, search, recipient, amount, token-id and custom-node inputs. +function Input({ + icon, + iconColor, + prefix, + suffix, + action, + bad, + ok, + style, + onClick, + children, +}) { + return ( +
    + {icon && ( + + )} + {prefix} + {children} + {suffix} + {action} +
    + ) +} + +Object.assign(window, { Input }) diff --git a/doc/components/basic/KV.jsx b/doc/components/basic/KV.jsx new file mode 100644 index 00000000..0b20cd39 --- /dev/null +++ b/doc/components/basic/KV.jsx @@ -0,0 +1,18 @@ +// KV — key/value list inside a Card. Composes: Card. +function KV({ rows }) { + return ( + + {rows.map(([k, v]) => ( +
    + {k} + {v} +
    + ))} +
    + ) +} + +Object.assign(window, { KV }) diff --git a/doc/components/basic/Keypad.jsx b/doc/components/basic/Keypad.jsx new file mode 100644 index 00000000..cbb2d1e3 --- /dev/null +++ b/doc/components/basic/Keypad.jsx @@ -0,0 +1,57 @@ +// Keypad — numeric keypad with delete and corner slot. Basic: no dependencies. +function Keypad({ onKey, onDel, corner, disabled }) { + return ( +
    + {['1', '2', '3', '4', '5', '6', '7', '8', '9'].map((k) => ( + + ))} +
    + {corner || null} +
    + + +
    + ) +} + +Object.assign(window, { Keypad }) diff --git a/doc/components/basic/LivePill.jsx b/doc/components/basic/LivePill.jsx new file mode 100644 index 00000000..f3621721 --- /dev/null +++ b/doc/components/basic/LivePill.jsx @@ -0,0 +1,28 @@ +// LivePill — small +/- change pill. Basic: no dependencies. +function LivePill({ value, suffix = '%' }) { + const positive = value >= 0 + return ( + + {positive ? '▲' : '▼'} + {Math.abs(value).toFixed(2)} + {suffix} + + ) +} + +Object.assign(window, { LivePill }) diff --git a/doc/components/basic/MojitoLogo.jsx b/doc/components/basic/MojitoLogo.jsx new file mode 100644 index 00000000..a34c438c --- /dev/null +++ b/doc/components/basic/MojitoLogo.jsx @@ -0,0 +1,95 @@ +// MojitoLogo — brand mark (geometric M with orbit). Basic: pure SVG. +function MojitoLogo({ size = 48, animate = true }) { + return ( +
    + + + + + + + + + + + + + + + + + {animate && ( +
    + )} +
    + ) +} + +Object.assign(window, { MojitoLogo }) diff --git a/doc/components/basic/PinDots.jsx b/doc/components/basic/PinDots.jsx new file mode 100644 index 00000000..de1ed321 --- /dev/null +++ b/doc/components/basic/PinDots.jsx @@ -0,0 +1,32 @@ +// PinDots — 6-dot PIN entry indicator. Basic: no dependencies. +function PinDots({ value, error }) { + return ( +
    + {Array.from({ length: 6 }, (_, i) => ( +
    + ))} +
    + ) +} + +Object.assign(window, { PinDots }) diff --git a/doc/components/basic/Progress.jsx b/doc/components/basic/Progress.jsx new file mode 100644 index 00000000..d8185003 --- /dev/null +++ b/doc/components/basic/Progress.jsx @@ -0,0 +1,23 @@ +// Progress — step progress bar. Basic: no dependencies. +// Extracted from OnbHeader (onb-core) and OnbTop (be-onboarding). +function Progress({ step, total = 4 }) { + return ( +
    + {Array.from({ length: total }, (_, i) => ( +
    + ))} +
    + ) +} + +Object.assign(window, { Progress }) diff --git a/doc/components/basic/PwField.jsx b/doc/components/basic/PwField.jsx new file mode 100644 index 00000000..ac804061 --- /dev/null +++ b/doc/components/basic/PwField.jsx @@ -0,0 +1,43 @@ +// PwField — password input with reveal toggle. Composes: Field, Input, Icon. +const { useState } = React + +function PwField({ + label, + value, + onChange, + placeholder = 'Password', + autoFocus, + bad, + onEnter, +}) { + const [show, setShow] = useState(false) + return ( + + + onChange(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && onEnter && onEnter()} + /> + setShow((s) => !s)} + style={{ cursor: 'pointer', display: 'flex' }} + > + + + + + ) +} + +Object.assign(window, { PwField }) diff --git a/doc/components/basic/QrPlaceholder.jsx b/doc/components/basic/QrPlaceholder.jsx new file mode 100644 index 00000000..6241f448 --- /dev/null +++ b/doc/components/basic/QrPlaceholder.jsx @@ -0,0 +1,13 @@ +// QrPlaceholder — striped labelled placeholder (real QR is rendered by the extension). Basic: no dependencies. +function QrPlaceholder({ size = 168, label = 'QR code' }) { + return ( +
    + {label} +
    + ) +} + +Object.assign(window, { QrPlaceholder }) diff --git a/doc/components/basic/Row.jsx b/doc/components/basic/Row.jsx new file mode 100644 index 00000000..b3d7de16 --- /dev/null +++ b/doc/components/basic/Row.jsx @@ -0,0 +1,44 @@ +// Row — list item inside a Card: left node, title/sub middle, right slot. Basic: no component dependencies. +// Extracted from the ubiquitous `.row` pattern (accounts, assets, settings, sites, activity). +function Row({ + left, + title, + sub, + right, + onClick, + style, + className = '', + children, +}) { + return ( +
    + {left} + {children !== undefined ? ( + children + ) : ( + +
    + {title && ( +
    {title}
    + )} + {sub && ( +
    + {sub} +
    + )} +
    + {right} +
    + )} +
    + ) +} + +Object.assign(window, { Row }) diff --git a/doc/components/basic/SeedGrid.jsx b/doc/components/basic/SeedGrid.jsx new file mode 100644 index 00000000..67f70ad6 --- /dev/null +++ b/doc/components/basic/SeedGrid.jsx @@ -0,0 +1,19 @@ +// SeedGrid — numbered recovery-phrase word grid, optional blur. Basic: no dependencies. +// Extracted from the onboarding Seed screen and Settings "reveal recovery phrase". +function SeedGrid({ words, hidden = false }) { + return ( +
    + {words.map((w, i) => ( +
    + {i + 1} + {w} +
    + ))} +
    + ) +} + +Object.assign(window, { SeedGrid }) diff --git a/doc/components/basic/Seg.jsx b/doc/components/basic/Seg.jsx new file mode 100644 index 00000000..66140afe --- /dev/null +++ b/doc/components/basic/Seg.jsx @@ -0,0 +1,21 @@ +// Seg — segmented control. Basic: no dependencies. +function Seg({ value, options, onChange }) { + return ( +
    + {options.map((o) => { + const [v, l] = Array.isArray(o) ? o : [o, o] + return ( + + ) + })} +
    + ) +} + +Object.assign(window, { Seg }) diff --git a/doc/components/basic/Sheet.jsx b/doc/components/basic/Sheet.jsx new file mode 100644 index 00000000..b62f5c19 --- /dev/null +++ b/doc/components/basic/Sheet.jsx @@ -0,0 +1,59 @@ +// Sheet — bottom sheet primitive: overlay + panel + drag handle. Basic: no dependencies. +// Common base of BeSheet (wallet) and the onboarding Sheet. +function Sheet({ + open, + onClose, + label, + children, + maxHeight = '88%', + padding = '10px 18px 18px', + radius = 20, +}) { + if (!open) return null + return ( +
    +
    +
    +
    + {children} +
    +
    + ) +} + +Object.assign(window, { Sheet }) diff --git a/doc/components/basic/Sparkline.jsx b/doc/components/basic/Sparkline.jsx new file mode 100644 index 00000000..88ca9b45 --- /dev/null +++ b/doc/components/basic/Sparkline.jsx @@ -0,0 +1,31 @@ +// Sparkline — tiny line chart. Basic: pure SVG. +function Sparkline({ data, color = 'var(--amber)', width = 70, height = 28 }) { + const min = Math.min(...data), + max = Math.max(...data) + const range = max - min || 1 + const pts = data + .map( + (v, i) => + `${(i / (data.length - 1)) * width},${height - ((v - min) / range) * height}`, + ) + .join(' ') + return ( + + + + ) +} + +Object.assign(window, { Sparkline }) diff --git a/doc/components/basic/Spinner.jsx b/doc/components/basic/Spinner.jsx new file mode 100644 index 00000000..4691033e --- /dev/null +++ b/doc/components/basic/Spinner.jsx @@ -0,0 +1,18 @@ +// Spinner — indeterminate ring loader. Basic: no dependencies. +// Extracted from the repeated broadcasting/discovery spinner pattern. +function Spinner({ size = 40, color = 'var(--amber)', width = 2.5 }) { + return ( +
    + ) +} + +Object.assign(window, { Spinner }) diff --git a/doc/components/basic/StatusBar.jsx b/doc/components/basic/StatusBar.jsx new file mode 100644 index 00000000..07e5dd89 --- /dev/null +++ b/doc/components/basic/StatusBar.jsx @@ -0,0 +1,115 @@ +// StatusBar — mobile status bar mock. Basic: pure SVG. +function StatusBar() { + return ( +
    + 9:41 +
    + + + + + + + + + + + + + + + +
    +
    + ) +} + +Object.assign(window, { StatusBar }) diff --git a/doc/components/basic/Strength.jsx b/doc/components/basic/Strength.jsx new file mode 100644 index 00000000..4a9a2104 --- /dev/null +++ b/doc/components/basic/Strength.jsx @@ -0,0 +1,31 @@ +// Strength — password strength meter. Composes: pwScore. +function Strength({ pw }) { + const s = pw ? pwScore(pw) : 0 + const c = ['', 'var(--red)', 'var(--amber)', 'var(--amber)', 'var(--green)'][ + s + ] + const lbl = ['', 'Weak', 'Fair', 'Good', 'Strong'][s] + return ( +
    +
    + {[1, 2, 3, 4].map((i) => ( + + ))} +
    + {pw && ( + + {lbl} + {s < 2 ? ' · use 8+ characters, mixed case and a number' : ''} + + )} +
    + ) +} + +Object.assign(window, { Strength }) diff --git a/doc/components/basic/Success.jsx b/doc/components/basic/Success.jsx new file mode 100644 index 00000000..606d8177 --- /dev/null +++ b/doc/components/basic/Success.jsx @@ -0,0 +1,66 @@ +// Success — success burst with pulsing rings. Basic: no dependencies. +function Success({ title, sub, children }) { + return ( +
    +
    + {[0, 0.3].map((d, i) => ( +
    + ))} +
    + ✓ +
    +
    +
    + {title} +
    + {sub && ( +
    + {sub} +
    + )} + {children} +
    + ) +} + +Object.assign(window, { Success }) diff --git a/doc/components/basic/Switch.jsx b/doc/components/basic/Switch.jsx new file mode 100644 index 00000000..e463771e --- /dev/null +++ b/doc/components/basic/Switch.jsx @@ -0,0 +1,11 @@ +// Switch — toggle. Basic: no dependencies. +function Switch({ on, onChange }) { + return ( +
    onChange(!on)} + >
    + ) +} + +Object.assign(window, { Switch }) diff --git a/doc/components/basic/Tag.jsx b/doc/components/basic/Tag.jsx new file mode 100644 index 00000000..e6452008 --- /dev/null +++ b/doc/components/basic/Tag.jsx @@ -0,0 +1,6 @@ +// Tag — colored label pill. Basic: no dependencies. +function Tag({ c = 'grey', children }) { + return {children} +} + +Object.assign(window, { Tag }) diff --git a/doc/components/basic/TokenIcon.jsx b/doc/components/basic/TokenIcon.jsx new file mode 100644 index 00000000..ff370bd5 --- /dev/null +++ b/doc/components/basic/TokenIcon.jsx @@ -0,0 +1,41 @@ +// TokenIcon — procedural token icon. Basic: no dependencies. +function TokenIcon({ symbol, size = 36 }) { + const map = { + BTC: { c1: 'oklch(0.82 0.16 70)', c2: 'oklch(0.7 0.17 50)', g: '₿' }, + ML: { c1: 'oklch(0.82 0.12 195)', c2: 'oklch(0.7 0.14 210)', g: 'Ⓜ' }, + ETH: { c1: 'oklch(0.74 0.06 280)', c2: 'oklch(0.55 0.08 280)', g: 'Ξ' }, + USDT: { c1: 'oklch(0.78 0.13 160)', c2: 'oklch(0.6 0.12 160)', g: '₮' }, + USDC: { c1: 'oklch(0.7 0.13 240)', c2: 'oklch(0.55 0.14 250)', g: '$' }, + MATIC: { c1: 'oklch(0.7 0.18 290)', c2: 'oklch(0.55 0.18 290)', g: '◆' }, + BNB: { c1: 'oklch(0.85 0.15 90)', c2: 'oklch(0.7 0.16 80)', g: '◈' }, + ARB: { c1: 'oklch(0.7 0.13 230)', c2: 'oklch(0.55 0.14 230)', g: '◉' }, + OP: { c1: 'oklch(0.7 0.2 25)', c2: 'oklch(0.55 0.2 20)', g: '○' }, + } + const t = map[symbol] || { + c1: 'oklch(0.6 0.05 60)', + c2: 'oklch(0.4 0.05 60)', + g: symbol[0], + } + return ( +
    + {t.g} +
    + ) +} + +Object.assign(window, { TokenIcon }) diff --git a/doc/components/basic/pwScore.js b/doc/components/basic/pwScore.js new file mode 100644 index 00000000..1883ea7e --- /dev/null +++ b/doc/components/basic/pwScore.js @@ -0,0 +1,12 @@ +// pwScore — password strength scoring 0..4. Pure helper, no dependencies. +function pwScore(p) { + let s = 0 + if (p.length >= 8) s++ + if (p.length >= 12) s++ + if (/[A-Z]/.test(p) && /[a-z]/.test(p)) s++ + if (/\d/.test(p)) s++ + if (/[^\w]/.test(p)) s++ + return Math.min(4, s) +} + +Object.assign(window, { pwScore }) diff --git a/doc/components/basic/useBioScan.js b/doc/components/basic/useBioScan.js new file mode 100644 index 00000000..bc20f009 --- /dev/null +++ b/doc/components/basic/useBioScan.js @@ -0,0 +1,17 @@ +// useBioScan — biometric scan phase machine (idle | scan | ok). Basic hook. +const { useState } = React + +function useBioScan(onDone) { + const [phase, setPhase] = useState('idle') + const start = () => { + if (phase !== 'idle') return + setPhase('scan') + setTimeout(() => { + setPhase('ok') + setTimeout(onDone, 500) + }, 1400) + } + return { phase, start } +} + +Object.assign(window, { useBioScan }) diff --git a/doc/components/basic/useToast.js b/doc/components/basic/useToast.js new file mode 100644 index 00000000..c9e83d94 --- /dev/null +++ b/doc/components/basic/useToast.js @@ -0,0 +1,16 @@ +// Toast — transient message element + useToast hook. Basic: no dependencies. +const { useState, useRef } = React + +function useToast() { + const [msg, setMsg] = useState(null) + const t = useRef(null) + const show = (m) => { + setMsg(m) + clearTimeout(t.current) + t.current = setTimeout(() => setMsg(null), 1800) + } + const el = msg ?
    {msg}
    : null + return [show, el] +} + +Object.assign(window, { useToast }) diff --git a/doc/components/composed/AccountRow.jsx b/doc/components/composed/AccountRow.jsx new file mode 100644 index 00000000..f8b05b4b --- /dev/null +++ b/doc/components/composed/AccountRow.jsx @@ -0,0 +1,20 @@ +// AccountRow — account picker/settings row. Composes: Avatar, Row. +// From AccountsSheet, Settings → Accounts and the dApp connect account list. +function AccountRow({ acct, sub, right, onClick, active = false }) { + return ( + } + title={ + + {acct.name} + {active && · active} + + } + sub={sub} + right={right} + onClick={onClick} + /> + ) +} + +Object.assign(window, { AccountRow }) diff --git a/doc/components/composed/AssetRow.jsx b/doc/components/composed/AssetRow.jsx new file mode 100644 index 00000000..dd99911d --- /dev/null +++ b/doc/components/composed/AssetRow.jsx @@ -0,0 +1,54 @@ +// AssetRow — token list row with sparkline and 24h change. Composes: TokenIcon, Tag, Sparkline, LivePill. +// From the Home "Tokens" list. +function AssetRow({ a, hideBal = false, onClick, index = 0 }) { + const H = (v) => (hideBal ? '••••' : v) + return ( +
    + +
    +
    + {a.sym} + {a.chain === 'Mintlayer' && a.id !== 'ml' && ( + Token + )} + {a.authority && Issuer} +
    +
    + {H(fmtAmt(a.amount, 4))} {a.sym} +
    +
    + = 0 ? 'var(--green)' : 'var(--red)'} + width={44} + height={20} + /> +
    +
    + {H(fmtUsd(a.amount * a.price))} +
    +
    + +
    +
    +
    + ) +} + +Object.assign(window, { AssetRow }) diff --git a/doc/components/composed/Broadcasting.jsx b/doc/components/composed/Broadcasting.jsx new file mode 100644 index 00000000..471a0c26 --- /dev/null +++ b/doc/components/composed/Broadcasting.jsx @@ -0,0 +1,28 @@ +// Broadcasting — full-screen "sending" state with spinner. Composes: Spinner. +// Extracted from DappWindow broadcasting phase; reused by account discovery. +function Broadcasting({ chain, sub = 'Do not close this window' }) { + return ( +
    + +
    + {chain ? `Broadcasting to ${chain}…` : 'Scanning…'} +
    + {sub} +
    + ) +} + +Object.assign(window, { Broadcasting }) diff --git a/doc/components/composed/FeeSelector.jsx b/doc/components/composed/FeeSelector.jsx new file mode 100644 index 00000000..45a2daed --- /dev/null +++ b/doc/components/composed/FeeSelector.jsx @@ -0,0 +1,42 @@ +// FeeSelector — Bitcoin fee speed picker (economy/standard/fast). Basic-level composed grid. +// From be-send.jsx SendScreenBE network-fee field. +function FeeSelector({ fees, value, onChange }) { + return ( +
    + {Object.entries(fees).map(([k, f]) => ( +
    onChange(k)} + style={{ + padding: '10px 8px', + borderRadius: 12, + cursor: 'pointer', + textAlign: 'center', + background: + value === k ? 'var(--amber-soft)' : 'oklch(1 0 0 / 0.03)', + border: `1px solid ${value === k ? 'oklch(0.82 0.16 70 / 0.5)' : 'var(--line-soft)'}`, + transition: 'all 150ms', + }} + > +
    {f.l}
    +
    + {f.rate} sat/vB +
    +
    + {f.eta} +
    +
    + ))} +
    + ) +} + +Object.assign(window, { FeeSelector }) diff --git a/doc/components/composed/NftCard.jsx b/doc/components/composed/NftCard.jsx new file mode 100644 index 00000000..df71a454 --- /dev/null +++ b/doc/components/composed/NftCard.jsx @@ -0,0 +1,59 @@ +// NftCard — NFT tile with radial art placeholder and optional caption. Composes basic HTML only. +// From NftGrid (Home → NFTs tab) and NftScreenBE media block. +function NftCard({ + n, + onClick, + index = 0, + aspect, + radius, + inset = 10, + label, + showCaption = false, +}) { + const style = { animation: `slide-up 400ms ${index * 60}ms ease both` } + if (aspect) { + style.aspectRatio = aspect + style.borderRadius = radius || 18 + } else if (radius) { + style.borderRadius = radius + } + return ( +
    +
    +
    + {label || 'nft media'} +
    + {showCaption && ( +
    + {n.name} +
    + {n.collection} +
    +
    + )} +
    + ) +} + +Object.assign(window, { NftCard }) diff --git a/doc/components/composed/OnbTop.jsx b/doc/components/composed/OnbTop.jsx new file mode 100644 index 00000000..fb7c7ef3 --- /dev/null +++ b/doc/components/composed/OnbTop.jsx @@ -0,0 +1,37 @@ +// OnbTop — onboarding header: back button + Progress + step counter. Composes: Progress. +// From be-onboarding.jsx OnbTop (equivalent of onb-core OnbHeader). +function OnbTop({ api, step, total = 4 }) { + return ( +
    +
    + + + +
    + + + {step}/{total} + +
    + ) +} + +Object.assign(window, { OnbTop }) diff --git a/doc/components/composed/OriginCard.jsx b/doc/components/composed/OriginCard.jsx new file mode 100644 index 00000000..2db42314 --- /dev/null +++ b/doc/components/composed/OriginCard.jsx @@ -0,0 +1,40 @@ +// OriginCard — dApp/site origin header. Composes: Favicon, Tag, Icon. +// From be-dapp.jsx; also matches the Settings → Connected sites rows. +function OriginCard({ req, s }) { + const connected = s.sites.some((x) => x.origin === req.origin) + return ( +
    + +
    +
    {req.name}
    +
    + + {req.origin} +
    +
    + {connected ? ( + Connected + ) : ( + New site + )} +
    + ) +} + +Object.assign(window, { OriginCard }) diff --git a/doc/components/composed/PasswordConfirm.jsx b/doc/components/composed/PasswordConfirm.jsx new file mode 100644 index 00000000..7d21338d --- /dev/null +++ b/doc/components/composed/PasswordConfirm.jsx @@ -0,0 +1,85 @@ +// PasswordConfirm — shared "confirm with password" step. Composes: IconTile, PwField. +// Extracted from SendScreenBE step 2, DappWindow password phase and UnlockScreenBE. +function PasswordConfirm({ + title, + sub, + icon = 'lock', + pw, + onPw, + bad, + onEnter, + cta = 'Confirm', + onConfirm, + children, +}) { + return ( + +
    + {children} +
    +
    + +
    +
    + {title} +
    + {sub && ( +
    + {sub} +
    + )} +
    +
    + +
    + {bad && ( + + Incorrect password + + )} +
    +
    + +
    +
    + ) +} + +Object.assign(window, { PasswordConfirm }) diff --git a/doc/components/composed/SeedReveal.jsx b/doc/components/composed/SeedReveal.jsx new file mode 100644 index 00000000..5602824c --- /dev/null +++ b/doc/components/composed/SeedReveal.jsx @@ -0,0 +1,37 @@ +// SeedReveal — recovery phrase grid with tap-to-reveal overlay. Composes: SeedGrid, Icon. +// From be-onboarding.jsx SeedScreenBE. +function SeedReveal({ words, shown, onReveal }) { + return ( +
    +
    + ) +} + +Object.assign(window, { SeedReveal }) diff --git a/doc/components/composed/TxRow.jsx b/doc/components/composed/TxRow.jsx new file mode 100644 index 00000000..0519c0e1 --- /dev/null +++ b/doc/components/composed/TxRow.jsx @@ -0,0 +1,75 @@ +// TxRow — transaction list row. Composes: IconTile, Icon. +// From be-home.jsx TxRow; also used by Activity screen, Asset screen and Home. +const txIcon = { + receive: ['arrow_dn', 'var(--green)'], + send: ['arrow_up', 'var(--amber)'], + mint: ['plus', 'var(--teal)'], + nft: ['card', 'var(--violet)'], + dapp: ['flash', 'var(--violet)'], + burn: ['flash', 'var(--red)'], +} + +function TxRow({ t, onClick }) { + const [ic, col] = txIcon[t.type] + const lbl = { + receive: 'Received', + send: 'Sent', + mint: 'Minted', + nft: 'NFT received', + dapp: 'dApp payment', + burn: 'Burned', + }[t.type] + const sc = + t.status === 'Confirmed' + ? 'var(--text-2)' + : t.status === 'Failed' + ? 'var(--red)' + : 'var(--amber)' + return ( +
    + +
    +
    + {lbl} + {t.type === 'dapp' && ( + · {t.to} + )} +
    +
    + {t.when} + {t.conf ? ` · ${t.conf} conf` : ''} +
    +
    +
    +
    + {t.type === 'nft' + ? t.name + : `${t.type === 'receive' || t.type === 'mint' ? '+' : '−'}${fmtAmt(t.amount, 6)} ${t.sym}`} +
    +
    {t.status}
    +
    +
    + ) +} + +Object.assign(window, { TxRow, txIcon }) diff --git a/doc/components/index.js b/doc/components/index.js new file mode 100644 index 00000000..30528ffe --- /dev/null +++ b/doc/components/index.js @@ -0,0 +1,68 @@ +// Mojito BE design components — barrel. +// Load order documents the composition hierarchy: basics first, composed on top. +// Each file registers its components globally (Object.assign(window, ...)) to stay +// drop-in compatible with the demo shell (doc/Mojito BE.html). + +// ─── Basic: brand & data display ───────────────────────────── +import './basic/Icon.jsx' +import './basic/MojitoLogo.jsx' +import './basic/Counter.jsx' +import './basic/LivePill.jsx' +import './basic/TokenIcon.jsx' +import './basic/ChainBadge.jsx' +import './basic/Sparkline.jsx' +import './basic/Avatar.jsx' +import './basic/Favicon.jsx' +import './basic/StatusBar.jsx' + +// ─── Basic: layout & structure ─────────────────────────────── +import './basic/Card.jsx' +import './basic/Row.jsx' +import './basic/Eyebrow.jsx' +import './basic/Hdr.jsx' +import './basic/Sheet.jsx' +import './basic/BeSheet.jsx' +import './basic/KV.jsx' +import './basic/IconTile.jsx' +import './basic/AmountBlock.jsx' +import './basic/QrPlaceholder.jsx' +import './basic/Empty.jsx' +import './basic/Success.jsx' +import './basic/Spinner.jsx' + +// ─── Basic: controls & forms ───────────────────────────────── +import './basic/Switch.jsx' +import './basic/Checkbox.jsx' +import './basic/Seg.jsx' +import './basic/Tag.jsx' +import './basic/Field.jsx' +import './basic/Input.jsx' +import './basic/PwField.jsx' +import './basic/pwScore.js' +import './basic/Strength.jsx' +import './basic/HoldButton.jsx' +import './basic/useToast.js' +import './basic/Progress.jsx' +import './basic/PinDots.jsx' +import './basic/Keypad.jsx' + +// ─── Basic: biometrics ─────────────────────────────────────── +import './basic/FaceIcon.jsx' +import './basic/BioGlyph.jsx' +import './basic/BioCircle.jsx' +import './basic/useBioScan.js' + +// ─── Basic: seed phrase ────────────────────────────────────── +import './basic/SeedGrid.jsx' + +// ─── Composed: built recursively from the basics ───────────── +import './composed/TxRow.jsx' +import './composed/AssetRow.jsx' +import './composed/AccountRow.jsx' +import './composed/OriginCard.jsx' +import './composed/FeeSelector.jsx' +import './composed/NftCard.jsx' +import './composed/SeedReveal.jsx' +import './composed/PasswordConfirm.jsx' +import './composed/OnbTop.jsx' +import './composed/Broadcasting.jsx' diff --git a/doc/onb-core.jsx b/doc/onb-core.jsx new file mode 100644 index 00000000..a8d04f5d --- /dev/null +++ b/doc/onb-core.jsx @@ -0,0 +1,362 @@ +// Mojito onboarding demo — shared primitives +const { useState: useStateC, useEffect: useEffectC } = React + +function OnbHeader({ api, step, total = 4 }) { + return ( +
    + + {step ? ( + +
    + {Array.from({ length: total }, (_, i) => ( +
    + ))} +
    + + {step}/{total} + +
    + ) : ( +
    + )} +
    + ) +} + +function PinDots({ value, error }) { + return ( +
    + {Array.from({ length: 6 }, (_, i) => ( +
    + ))} +
    + ) +} + +function Keypad({ onKey, onDel, corner, disabled }) { + return ( +
    + {['1', '2', '3', '4', '5', '6', '7', '8', '9'].map((k) => ( + + ))} +
    + {corner || null} +
    + + +
    + ) +} + +function FaceIcon({ size = 24, color = 'currentColor', stroke = 1.6 }) { + return ( + + + + + + + + + + ) +} + +function BioGlyph({ bio, size = 24, color, stroke = 1.4 }) { + return bio === 'face' ? ( + + ) : ( + + ) +} + +// phase: idle | scan | ok +function useBioScan(onDone) { + const [phase, setPhase] = useStateC('idle') + const start = () => { + if (phase !== 'idle') return + setPhase('scan') + setTimeout(() => { + setPhase('ok') + setTimeout(onDone, 500) + }, 1400) + } + return { phase, start } +} + +function BioCircle({ bio, phase, onClick }) { + const ok = phase === 'ok' + const c = ok ? 'var(--green)' : 'var(--amber)' + return ( +
    + {phase === 'scan' && + [0, 0.45, 0.9].map((d, i) => ( +
    + ))} +
    + {ok ? ( + + ✓ + + ) : ( + + )} +
    +
    + ) +} + +function Sheet({ open, onClose, label, children }) { + if (!open) return null + return ( +
    +
    +
    +
    + {children} +
    +
    + ) +} + +function OnbCheck({ checked, onToggle, label }) { + return ( + + ) +} + +Object.assign(window, { + OnbHeader, + PinDots, + Keypad, + FaceIcon, + BioGlyph, + useBioScan, + BioCircle, + Sheet, + OnbCheck, +}) diff --git a/doc/onb-data.jsx b/doc/onb-data.jsx new file mode 100644 index 00000000..cadeb859 --- /dev/null +++ b/doc/onb-data.jsx @@ -0,0 +1,95 @@ +// Mojito onboarding demo — mock data +const BIP39 = + 'abandon ability able about above absent absorb abstract absurd abuse access accident account accuse achieve acid acoustic acquire across act action actor actress actual adapt add addict address adjust admit adult advance advice aerobic affair afford afraid again age agent agree ahead aim air airport aisle alarm album alcohol alert alien all alley allow almost alone alpha already also alter always amateur amazing among amount amused analyst anchor ancient anger angle angry animal ankle announce annual another answer antenna antique anxiety any apart apology appear apple approve april arch arctic area arena argue arm armed armor army around arrange arrest arrive arrow art artist artwork ask aspect assault asset assist assume asthma athlete atom attack attend attitude attract auction audit august aunt author auto autumn average avocado avoid awake aware away awesome awful awkward axis baby bachelor bacon badge bag balance balcony ball bamboo banana banner bar barely bargain barrel base basic basket battle beach bean beauty because become beef before begin behave behind believe below belt bench benefit best betray better between beyond bicycle bid bike bind biology bird birth bitter black blade blame blanket blast bleak bless blind blood blossom blouse blue blur blush board boat body boil bomb bone bonus book boost border boring borrow boss bottom bounce box boy bracket brain brand brass brave bread breeze brick bridge brief bright bring brisk broken bronze broom brother brown brush bubble buddy budget buffalo build bulb bulk bullet bundle bunker burden burger burst bus business busy butter buyer buzz cabbage cabin cable cactus cage cake call calm camera camp canal cancel candy cannon canoe canvas canyon capable capital captain car carbon card cargo carpet carry cart case cash casino castle casual cat catalog catch category cattle caught cause caution cave ceiling celery cement census century cereal certain chair chalk champion change chaos chapter charge chase chat cheap check cheese chef cherry chest chicken chief child chimney choice choose chronic chuckle chunk churn cigar cinnamon circle citizen city civil claim clap clarify claw clay clean clerk clever click client cliff climb clinic clip clock clog close cloth cloud clown club clump cluster clutch coach coast coconut code coffee coil coin collect color column combine come comfort comic common company concert conduct confirm congress connect consider control convince cook cool copper copy coral core corn correct cost cotton couch country couple course cousin cover coyote crack cradle craft cram crane crash crater crawl crazy cream credit creek crew cricket crime crisp critic crop cross crouch crowd crucial cruel cruise crumble crunch crush cry crystal cube culture cup cupboard curious current curtain curve cushion custom cute cycle'.split( + ' ', + ) + +// 24-word seed shown on the create path (all real BIP39 words) +const ONB_SEED = [ + 'ancient', + 'bridge', + 'canyon', + 'anchor', + 'coconut', + 'circle', + 'credit', + 'crystal', + 'autumn', + 'balance', + 'beach', + 'bench', + 'camera', + 'castle', + 'ceiling', + 'century', + 'cherry', + 'clever', + 'cliff', + 'cluster', + 'coral', + 'cotton', + 'crane', + 'brave', +] + +// Mock checksum rule for the demo: phrase is "checksum-valid" iff complete, +// every word is BIP39, and the LAST word is one of these. +const ONB_CHECK_WORDS = new Set([ + 'brave', + 'coin', + 'claim', + 'beach', + 'army', + 'atom', + 'bonus', + 'circle', +]) +const onbChecksumOK = (words) => + words.length > 0 && + words.every((w) => BIP39.includes(w)) && + ONB_CHECK_WORDS.has(words[words.length - 1]) + +const ONB_DEMO = { + valid12: + 'autumn balance camera castle cherry clever cliff coral cotton crane crystal coin', + valid24: ONB_SEED.join(' '), + badsum24: ONB_SEED.slice(0, 23).join(' ') + ' bridge', // duplicate word, fails mock checksum +} + +const ONB_ACCOUNTS = [ + { + name: 'Account 1', + path: "m/84'/0'/0'", + btc: '0.03420000', + ml: '1,250.00', + usd: '$2,103.18', + }, + { + name: 'Account 2', + path: "m/84'/0'/1'", + btc: '0.00180000', + ml: '86.40', + usd: '$112.55', + }, + { + name: 'Account 3', + path: "m/84'/0'/2'", + btc: '0.00000000', + ml: '12.50', + usd: '$1.06', + }, +] + +const ONB_ADDR = { + btc: 'bc1q9xw5dg4yr3zarv0c5e2wfjn8kh6mua7ltd0s3j', + ml: 'mtc1qkrz6c0d8f4h2j9l5n3p7s2v0w6x8z2a4c8e0g', +} + +Object.assign(window, { + BIP39, + ONB_SEED, + onbChecksumOK, + ONB_DEMO, + ONB_ACCOUNTS, + ONB_ADDR, +}) diff --git a/doc/server-requirements.md b/doc/server-requirements.md new file mode 100644 index 00000000..fdcede4a --- /dev/null +++ b/doc/server-requirements.md @@ -0,0 +1,68 @@ +# Server-side requirements for the design-demo UI + +This lists every piece of data the new UI (doc/ Mojito BE design) displays that +is **not yet available** from existing services, and what the client currently +does about it. All mocks live in `src/mocks/designData.js` — replacing a mock +with a real service should only require touching that module (or the page that +imports it). + +Legend: 🟡 mocked today · ✅ already real + +## 1. Assets / tokens + +| Data | Status | Current source | Server requirement | +| --------------------------------------------------------------- | ---------------------------------------------- | ----------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| BTC / ML balances | ✅ | `useBtcWalletInfo` / `useMlWalletInfo` (Electrum + Mintlayer API) | — | +| BTC / ML fiat price + 24h change | ✅ | `useExchangeRates`, `useOneDayAgoExchangeRates` (rates API) | — | +| Price history (sparkline / chart) | ✅ (1 day, coins) | `useOneDayAgoHist` | Extend rates API with configurable ranges (1H/1D/1W/1M/1Y) | +| 🟡 Mintlayer token list (USDT, CBEAT, SKY…) | 🟡 `MOCK_TOKENS` | mock | Token metadata registry: ticker, name, decimals, total supply, issuer address, token id → resolvable from Mintlayer node token metadata; needs an indexer for "tokens owned by this account" | +| 🟡 Token balances per account | 🟡 `MOCK_TOKENS[*].amount` | mock | Account-token balance index (or extend MintlayerProvider `tokenBalances` to aggregate all held token ids incl. amounts) | +| 🟡 Token fiat prices + 24h change + history | 🟡 `MOCK_PRICES`, `MOCK_TOKENS[*].price/spark` | mock | Rates service covering Mintlayer tokens (aggregator or DEX spot price), per ticker | +| 🟡 Token issuer/authority info (mint/burn/lock/freeze controls) | 🟡 `MOCK_TOKENS[*].authority` | mock (buttons are display-only) | Node tx builders for token authority operations + fee quoting (design shows "fee 100 ML") | + +## 2. NFTs + +| Data | Status | Current source | Server requirement | +| --------------------------- | -------------------- | ------------------------------- | ---------------------------------------------------------------------------------------------------- | +| 🟡 NFT gallery (owned NFTs) | 🟡 `MOCK_NFTS` | mock | NFT index: tokens of NFT standard owned by account; metadata: name, collection, creator, description | +| 🟡 NFT media | 🟡 placeholder tiles | mock | IPFS/media gateway URLs resolved from token metadata (`media` URI) | +| NFT transfer | — | existing `NftSendPage` (legacy) | — (already wired on-chain) | + +## 3. Activity / transaction history + +| Data | Status | Current source | Server requirement | +| ------------------------------------------------ | ----------------------------------- | -------------------------------------- | ---------------------------------------------------------------- | +| BTC / ML transaction list | ✅ | `useBtcWalletInfo` / `useMlWalletInfo` | — | +| 🟡 Unified cross-chain activity feed | 🟡 merged client-side | client merges BTC + ML lists | Optional: single history endpoint with chain filter + pagination | +| 🟡 Confirmation counts | ✅/partial | `BTC.getConfirmationsAmount` | ML: expose block height diff per tx (partially available) | +| 🟡 Fiat value at tx time | 🟡 mock for demo rows | mock | Historical rates endpoint: `rate(ticker, usd, timestamp)` | +| 🟡 Tx counterparty labels (dApp names, ENS-like) | 🟡 mock (`to: 'app.mintlayer.dex'`) | mock | Optional naming/label service | + +## 4. Receive + +| Data | Status | Current source | Server requirement | +| ------------------------------- | ----------------------------- | -------------------------------- | ------------------------------------------------------------ | +| Receiving addresses BTC/ML | ✅ | `AccountContext.addresses` | — | +| QR code | 🟡 placeholder tile | `QrPlaceholder` | None client-optional: render QR locally (add a small QR lib) | +| Fresh address per payment (BTC) | ✅ (exists: unused addresses) | `BitcoinContext.unusedAddresses` | — | + +## 5. dApp connections (design: connect / sign / tx request windows) + +| Data | Status | Current source | Server requirement | +| ----------------------- | --------------- | --------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | +| 🟡 Connected sites list | 🟡 `MOCK_SITES` | mock | None required initially — persist in extension storage; sync optional | +| Approve flows | ✅/partial | `ConnectionPage`, `Sign*Transaction`, `SignChallenge` pages (legacy UI, to be restyled) | — | + +## 6. Staking / delegation (design "Delegate ML") + +| Data | Status | Current source | Server requirement | +| ----------------------- | ------ | ----------------------------------------------- | ------------------ | +| Delegation list / stake | ✅ | `MintlayerContext.mlDelegationList` (legacy UI) | Restyle only | + +## Priority order to replace mocks + +1. Token balances + metadata index (unblocks Assets list & Manage tokens) +2. Token fiat prices/history (unblocks real token rows) +3. NFT index + media gateway (unblocks NFT tab) +4. Historical rates for "fiat at tx time" (unblocks Activity detail) +5. Token authority operation endpoints (unblocks issuer controls) diff --git a/doc/styles.css b/doc/styles.css new file mode 100644 index 00000000..0552cae2 --- /dev/null +++ b/doc/styles.css @@ -0,0 +1,455 @@ +/* Mojito Wallet — visual system */ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap'); + +:root { + --bg-0: oklch(0.14 0.012 60); + --bg-1: oklch(0.18 0.014 60); + --bg-2: oklch(0.22 0.014 60); + --bg-3: oklch(0.26 0.014 60); + --line: oklch(0.32 0.012 60); + --line-soft: oklch(0.28 0.01 60 / 0.6); + --text-0: oklch(0.98 0.005 80); + --text-1: oklch(0.82 0.008 70); + --text-2: oklch(0.6 0.01 70); + --text-3: oklch(0.45 0.012 70); + --amber: oklch(0.82 0.16 70); + --amber-soft: oklch(0.82 0.16 70 / 0.14); + --teal: oklch(0.82 0.12 195); + --teal-soft: oklch(0.82 0.12 195 / 0.14); + --violet: oklch(0.74 0.16 290); + --violet-soft: oklch(0.74 0.16 290 / 0.14); + --green: oklch(0.78 0.16 150); + --red: oklch(0.7 0.2 25); +} + +* { + box-sizing: border-box; + -webkit-font-smoothing: antialiased; +} + +body { + margin: 0; + background: #0b0a09; + font-family: 'Inter', system-ui, sans-serif; + color: var(--text-0); + overflow-x: hidden; +} + +.mono { + font-family: 'JetBrains Mono', ui-monospace, monospace; + font-feature-settings: + 'tnum' 1, + 'zero' 1; +} + +/* App canvas — inside iOS frame */ +.app { + position: relative; + width: 100%; + height: 100%; + background: + radial-gradient( + 120% 60% at 50% -10%, + oklch(0.82 0.16 70 / 0.16), + transparent 60% + ), + radial-gradient( + 80% 50% at 100% 100%, + oklch(0.74 0.16 290 / 0.1), + transparent 60% + ), + radial-gradient( + 80% 50% at 0% 100%, + oklch(0.82 0.12 195 / 0.08), + transparent 60% + ), + var(--bg-0); + overflow: hidden; + color: var(--text-0); +} + +/* Subtle noise */ +.app::before { + content: ''; + position: absolute; + inset: 0; + background-image: url("data:image/svg+xml;utf8,"); + opacity: 0.5; + pointer-events: none; + mix-blend-mode: overlay; + z-index: 1; +} + +.glass { + background: linear-gradient(180deg, oklch(1 0 0 / 0.04), oklch(1 0 0 / 0.02)); + border: 1px solid var(--line-soft); + border-radius: 22px; + backdrop-filter: blur(18px); + -webkit-backdrop-filter: blur(18px); +} + +.hairline { + border-top: 1px solid var(--line-soft); +} + +.chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 9px; + border-radius: 999px; + font-size: 11px; + font-weight: 500; + letter-spacing: 0.02em; + border: 1px solid var(--line); + color: var(--text-1); + background: oklch(1 0 0 / 0.03); +} +.chip .dot { + width: 6px; + height: 6px; + border-radius: 999px; + background: var(--text-2); +} +.chip.amber { + color: var(--amber); + border-color: oklch(0.82 0.16 70 / 0.35); + background: var(--amber-soft); +} +.chip.amber .dot { + background: var(--amber); +} +.chip.teal { + color: var(--teal); + border-color: oklch(0.82 0.12 195 / 0.35); + background: var(--teal-soft); +} +.chip.teal .dot { + background: var(--teal); +} +.chip.violet { + color: var(--violet); + border-color: oklch(0.74 0.16 290 / 0.35); + background: var(--violet-soft); +} +.chip.violet .dot { + background: var(--violet); +} + +/* Buttons */ +.btn { + height: 52px; + border-radius: 16px; + border: 1px solid var(--line); + background: linear-gradient(180deg, oklch(1 0 0 / 0.06), oklch(1 0 0 / 0.02)); + color: var(--text-0); + font: + 600 15px/1 Inter, + sans-serif; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + cursor: pointer; + transition: + transform 120ms ease, + background 120ms ease; +} +.btn:active { + transform: scale(0.98); +} +.btn.primary { + background: linear-gradient(180deg, oklch(0.86 0.16 70), oklch(0.74 0.17 65)); + color: #1a1208; + border-color: oklch(0.86 0.16 70); + box-shadow: + 0 10px 30px -10px oklch(0.82 0.16 70 / 0.5), + inset 0 1px 0 oklch(1 0 0 / 0.4); +} +.btn.teal { + background: linear-gradient( + 180deg, + oklch(0.86 0.12 195), + oklch(0.74 0.13 200) + ); + color: #06181c; + border-color: oklch(0.82 0.12 195); + box-shadow: + 0 10px 30px -10px oklch(0.82 0.12 195 / 0.5), + inset 0 1px 0 oklch(1 0 0 / 0.4); +} +.btn.violet { + background: linear-gradient( + 180deg, + oklch(0.78 0.16 290), + oklch(0.66 0.17 290) + ); + color: #150622; + border-color: oklch(0.74 0.16 290); + box-shadow: + 0 10px 30px -10px oklch(0.74 0.16 290 / 0.5), + inset 0 1px 0 oklch(1 0 0 / 0.4); +} + +/* Animations */ +@keyframes shimmer { + 0% { + transform: translateX(-100%); + } + 100% { + transform: translateX(100%); + } +} +@keyframes float-y { + 0%, + 100% { + transform: translateY(0); + } + 50% { + transform: translateY(-6px); + } +} +@keyframes pulse-ring { + 0% { + transform: scale(0.6); + opacity: 0.7; + } + 100% { + transform: scale(2); + opacity: 0; + } +} +@keyframes slide-up { + from { + opacity: 0; + transform: translateY(14px); + } + to { + opacity: 1; + transform: translateY(0); + } +} +/* Safety: any element with an inline `animation: slide-up ...` defaults visible + if the animation gets re-evaluated mid-render (e.g. when a parent re-renders + from a rAF tick). Using fill-mode forwards keeps it visible after end. */ +@keyframes slide-up-safe { + 0%, + 100% { + opacity: 1; + transform: translateY(0); + } +} +@keyframes fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} +@keyframes spin-slow { + to { + transform: rotate(360deg); + } +} +@keyframes orbit { + to { + transform: rotate(360deg); + } +} +@keyframes ticker { + 0%, + 100% { + opacity: 0.6; + } + 50% { + opacity: 1; + } +} +@keyframes flow-x { + 0% { + transform: translateX(-30%); + opacity: 0; + } + 20% { + opacity: 1; + } + 80% { + opacity: 1; + } + 100% { + transform: translateX(130%); + opacity: 0; + } +} +@keyframes draw { + to { + stroke-dashoffset: 0; + } +} +@keyframes blink-caret { + 0%, + 49% { + opacity: 1; + } + 50%, + 100% { + opacity: 0; + } +} + +/* Tab bar */ +.tabbar { + position: absolute; + left: 16px; + right: 16px; + bottom: 28px; + height: 64px; + border-radius: 22px; + background: oklch(0.18 0.014 60 / 0.7); + backdrop-filter: blur(24px) saturate(180%); + -webkit-backdrop-filter: blur(24px) saturate(180%); + border: 1px solid var(--line-soft); + display: grid; + grid-template-columns: repeat(5, 1fr); + z-index: 30; + box-shadow: 0 20px 50px -20px rgba(0, 0, 0, 0.6); +} +.tab { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 3px; + color: var(--text-2); + cursor: pointer; + font: + 500 10px/1 Inter, + sans-serif; + letter-spacing: 0.04em; + position: relative; +} +.tab.active { + color: var(--text-0); +} +.tab.active::before { + content: ''; + position: absolute; + top: 8px; + left: 50%; + transform: translateX(-50%); + width: 28px; + height: 3px; + border-radius: 999px; + background: var(--amber); + box-shadow: 0 0 10px var(--amber); +} + +/* Scrollable area */ +.screen { + position: absolute; + inset: 0; + overflow-y: auto; + overflow-x: hidden; + padding-bottom: 120px; + scrollbar-width: none; +} +.screen::-webkit-scrollbar { + display: none; +} + +/* Orderbook bars */ +.ob-row { + position: relative; + display: grid; + grid-template-columns: 1fr 1fr 1fr; + align-items: center; + padding: 6px 16px; + font-family: 'JetBrains Mono', monospace; + font-size: 12px; +} +.ob-row .bar { + position: absolute; + top: 0; + bottom: 0; + right: 0; + opacity: 0.18; + transition: width 600ms cubic-bezier(0.2, 0.8, 0.2, 1); +} +.ob-row.bid .bar { + background: var(--green); +} +.ob-row.ask .bar { + background: var(--red); +} +.ob-row .price { + position: relative; + z-index: 1; +} +.ob-row .size { + position: relative; + z-index: 1; + text-align: center; + color: var(--text-1); +} +.ob-row .total { + position: relative; + z-index: 1; + text-align: right; + color: var(--text-2); +} +.ob-row.bid .price { + color: var(--green); +} +.ob-row.ask .price { + color: var(--red); +} + +/* Bridge tunnel */ +.bridge-rail { + position: relative; + height: 4px; + background: linear-gradient(90deg, var(--teal), var(--violet)); + border-radius: 999px; + overflow: hidden; + box-shadow: 0 0 24px oklch(0.74 0.16 290 / 0.4); +} +.bridge-rail::before { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient( + 90deg, + transparent, + rgba(255, 255, 255, 0.6), + transparent + ); + width: 30%; + animation: flow-x 2.4s linear infinite; +} + +/* Logo mark */ +.logo-mark { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; +} + +/* Ticker number */ +.tnum { + font-variant-numeric: tabular-nums; + font-feature-settings: 'tnum' 1; +} + +/* Sparkline path animation */ +.spark { + stroke-dasharray: 400; + stroke-dashoffset: 400; + animation: draw 1.4s 0.2s ease-out forwards; +} + +/* Status bar override (frame is dark) */ +.statusbar-spacer { + height: 54px; +} diff --git a/package.json b/package.json index abf177b9..d90100af 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,8 @@ "pretty-quick-check": "pretty-quick --check", "pretty-quick-check-branch": "pretty-quick --check --branch main", "prepare": "husky install", - "version": "node src/version/version-manifest.js && node src/version/version-mojito.js" + "version": "node src/version/version-manifest.js && node src/version/version-mojito.js", + "audit:imports": "node scripts/audit-imports.js" }, "browserslist": { "production": [ diff --git a/scripts/audit-imports.js b/scripts/audit-imports.js new file mode 100644 index 00000000..8f0bb532 --- /dev/null +++ b/scripts/audit-imports.js @@ -0,0 +1,207 @@ +#!/usr/bin/env node +/** + * Audits every local import in src/ for: + * - unresolved modules (bad paths / aliases) + * - named imports that the target module does not export + * Covers `import … from`, `export … from`, `require()` destructures are ignored. + * node_modules packages are skipped (those are verified by the bundler/lockfile). + */ +const fs = require('fs') +const path = require('path') + +const ROOT = path.resolve(__dirname, '..') +const SRC = path.join(ROOT, 'src') + +const ALIASES = { + '@Assets': 'src/assets', + '@BasicComponents': 'src/components/basic/index.js', + '@ComposedComponents': 'src/components/composed/index.js', + '@ContainerComponents': 'src/components/containers/index.js', + '@LayoutComponents': 'src/components/layouts/index.js', + '@Constants': 'src/utils/Constants/index.js', + '@Helpers': 'src/utils/Helpers/index.js', + '@TestData': 'src/utils/TestData/index.js', + '@Hooks': 'src/hooks/index.js', + '@Contexts': 'src/contexts/index.js', + '@Databases': 'src/services/Database/index.js', + '@Cryptos': 'src/services/Crypto/index.js', + '@Entities': 'src/services/Entity/index.js', + '@APIs': 'src/services/API/index.js', + '@Storage': 'src/services/Storage/index.js', + '@Browser': 'src/services/Browser/index.js', + '@Version': 'src/version/version.js', + '@Mocks': 'src/mocks/index.js', + '@Pages': 'src/pages/index.js', +} + +const EXT_ORDER = ['.js', '.ts', '.tsx', '.jsx', '.mjs', '.css', '.json'] + +const walk = (dir, out = []) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if ( + entry.name === 'node_modules' || + entry.name.startsWith('@mintlayerlib-js') + ) + continue + const full = path.join(dir, entry.name) + if (entry.isDirectory()) walk(full, out) + else if (/\.(js|ts|tsx|jsx)$/.test(entry.name)) out.push(full) + } + return out +} + +const resolveModule = (spec, fromFile) => { + let base + if (spec.startsWith('.')) base = path.resolve(path.dirname(fromFile), spec) + else if (spec.startsWith('src/')) base = path.join(ROOT, spec) + else if (ALIASES[spec]) base = path.join(ROOT, ALIASES[spec]) + else if ( + spec.split('/').length > 1 && + Object.keys(ALIASES).some((a) => spec.startsWith(a + '/')) + ) { + const alias = Object.keys(ALIASES).find((a) => spec.startsWith(a + '/')) + base = path.join(ROOT, ALIASES[alias], spec.slice(alias.length + 1)) + } else return { external: true } + + try { + if ( + fs.statSync(base).isDirectory() && + fs.existsSync(path.join(base, 'package.json')) + ) { + return { file: path.join(base, 'package.json'), opaque: true } + } + } catch {} + const candidates = [base, ...EXT_ORDER.map((ext) => base + ext)] + for (const ext of EXT_ORDER) { + candidates.push(path.join(base, `index${ext}`)) + } + for (const candidate of candidates) { + try { + if (fs.statSync(candidate).isFile()) return { file: candidate } + } catch {} + } + return {} +} + +const cache = new Map() +const exportsOf = (file) => { + if (cache.has(file)) return cache.get(file) + const names = new Set() + let hasDefault = false + try { + const src = fs.readFileSync(file, 'utf8') + hasDefault = /export\s+default/.test(src) || /module\.exports/.test(src) + // export { a, b as c } [from '...'] and export type { ... } + for (const m of src.matchAll( + /export\s+(?:type\s+)?\{([^}]*)\}(?:\s*from\s*['"]([^'"]+)['"])?/g, + )) { + for (const part of m[1].split(',')) { + const name = part + .split(/\s+as\s+/) + .pop() + .trim() + if (name) names.add(name) + } + } + for (const m of src.matchAll( + /export\s+(?:async\s+)?(?:const|let|var|function\*?|class)\s+([A-Za-z0-9_$]+)/g, + )) { + names.add(m[1]) + } + for (const m of src.matchAll( + /export\s+(?:type|interface|enum)\s+([A-Za-z0-9_$]+)/g, + )) { + names.add(m[1]) + } + // export * from './x' — treat as "has everything" (opaque) + if (/export\s*\*\s*from/.test(src)) names.add('*') + } catch {} + const result = { names, hasDefault } + cache.set(file, result) + return result +} + +const IMPORT_RE = + /import\s+(?:([A-Za-z0-9_$]+)\s*,?\s*)?(?:\{([^}]*)\}\s*)?(?:\*\s+as\s+[A-Za-z0-9_$]+\s*)?from\s*['"]([^'"]+)['"]/g +const EXPORT_FROM_RE = /export\s*\{([^}]*)\}\s*from\s*['"]([^'"]+)['"]/g + +const problems = [] +const files = walk(SRC) + +for (const file of files) { + const src = fs + .readFileSync(file, 'utf8') + .split('\n') + .map((line) => line.replace(/\/\/.*$/, '')) + .join('\n') + const specs = [] + for (const m of src.matchAll(IMPORT_RE)) { + specs.push({ + names: [ + ...(m[1] ? ['default:' + m[1]] : []), + ...(m[2] || '') + .split(',') + .map((s) => s.trim()) + .filter(Boolean), + ], + spec: m[3], + }) + } + for (const m of src.matchAll(EXPORT_FROM_RE)) { + specs.push({ + names: m[1] + .split(',') + .map((s) => + s + .trim() + .split(/\s+as\s+/) + .pop(), + ) + .filter(Boolean), + spec: m[2], + }) + } + + for (const { names, spec } of specs) { + if (!spec) continue + const resolved = resolveModule(spec, file) + if (resolved.external) continue + if (!resolved.file) { + problems.push(`${path.relative(ROOT, file)}: unresolved module '${spec}'`) + continue + } + if ( + resolved.opaque || + /\.(css|json|svg|png|jpg|jpeg|webp|d\.ts)$/.test(resolved.file) + ) + continue + const { names: exports, hasDefault } = exportsOf(resolved.file) + for (const name of names) { + if (name.startsWith('default:')) { + if (!hasDefault) { + problems.push( + `${path.relative(ROOT, file)}: default import '${name.slice(8)}' but '${spec}' has no default export`, + ) + } + continue + } + const clean = name.replace(/\s+as\s+.*$/, '').trim() + if (!clean || clean === 'type' || !/^[A-Za-z0-9_$]+$/.test(clean)) + continue + if (exports.has('*')) continue + if (!exports.has(clean)) { + problems.push( + `${path.relative(ROOT, file)}: '${clean}' is not exported by '${spec}' (${path.relative(ROOT, resolved.file)})`, + ) + } + } + } +} + +if (problems.length) { + console.log(`FOUND ${problems.length} IMPORT PROBLEMS:\n`) + for (const p of problems) console.log(' -', p) + process.exit(1) +} else { + console.log(`OK — ${files.length} files scanned, all local imports resolve`) +} diff --git a/webpack.config.js b/webpack.config.js index 25ce9dbe..90b24867 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -1,4 +1,5 @@ const path = require('path') +const fs = require('fs') const webpack = require('webpack') const HtmlWebpackPlugin = require('html-webpack-plugin') const MiniCssExtractPlugin = require('mini-css-extract-plugin') @@ -60,7 +61,9 @@ module.exports = (env, argv) => { clean: true, }, - devtool: isDevelopment ? 'cheap-module-source-map' : 'source-map', + // No source maps in production: shipping full unminified sources with a + // wallet package only helps attackers. + devtool: isDevelopment ? 'cheap-module-source-map' : false, devServer: { static: { @@ -253,6 +256,21 @@ module.exports = (env, argv) => { ignore: ['**/index.html'], }, }, + { + // Generate manifest.json for local/unpacked builds. When + // manifest-key.txt is present, its public key keeps the Chrome + // Web Store extension ID (and thus the extension storage) stable. + from: 'public/manifestDefault.json', + to: 'manifest.json', + transform(content) { + const manifest = JSON.parse(content) + const keyPath = path.resolve(__dirname, 'manifest-key.txt') + if (fs.existsSync(keyPath)) { + manifest.key = fs.readFileSync(keyPath, 'utf8').trim() + } + return JSON.stringify(manifest, null, 2) + }, + }, ], }), From ce0bfde1e61e83c702adbadfa35878957d24607d Mon Sep 17 00:00:00 2001 From: Enrico Rubboli Date: Tue, 8 Sep 2026 11:24:30 +0200 Subject: [PATCH 22/52] test(bridge): local smoke-test page for the window.mojito contract --- scripts/bridge-test-page.html | 127 ++++++++++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 scripts/bridge-test-page.html diff --git a/scripts/bridge-test-page.html b/scripts/bridge-test-page.html new file mode 100644 index 00000000..85640e10 --- /dev/null +++ b/scripts/bridge-test-page.html @@ -0,0 +1,127 @@ + + + + + Mojito bridge smoke test + + + +

    Mojito extension — bridge smoke test

    +

    + Served over http://localhost so the content script injects + window.mojito. Open DevTools to see SDK errors. Signing with + a real transaction must be tested from the bridge frontend; this page + covers the connect / restore / error-code / disconnect contract. +

    +
    + + + + + + + +
    +
    
    +    
    +  
    +
    
    From 9b08f8d1d4245e5cb402ccc76dadd0d8b5af4a61 Mon Sep 17 00:00:00 2001
    From: Enrico Rubboli 
    Date: Tue, 8 Sep 2026 11:24:47 +0200
    Subject: [PATCH 23/52] chore: stop tracking HANDOFF.md (local session notes)
    
    ---
     HANDOFF.md | 169 -----------------------------------------------------
     1 file changed, 169 deletions(-)
     delete mode 100644 HANDOFF.md
    
    diff --git a/HANDOFF.md b/HANDOFF.md
    deleted file mode 100644
    index beedcd8a..00000000
    --- a/HANDOFF.md
    +++ /dev/null
    @@ -1,169 +0,0 @@
    -# HANDOFF — session notes (2026-09-06)
    -
    -Branch: `A-1217833856533186-review-fixes` (dark UI refactor branch). Changes are
    -UNCOMMITTED — everything below is in the working tree and in `build/`.
    -
    -**Read `REVIEW-PLAN.md` before picking up new work** — it holds the follow-up
    -backlog from the 4-agent security/quality/UX/DRY review plus accepted risks.
    -**Bridge/dApp work must follow `doc/bridge-contract.md`** — the final
    -`window.mojito` surface, session shape and error codes.
    -
    -## Bridge integration fixes (2026-09-06, latest)
    -
    -Fixed the extension side of the `@mintlayer/sdk` bridge flow:
    -
    -- **Restore was fully broken**: the background stored only
    -  `{ address, timestamp }` and `getSession` returned only `address`, while the
    -  SDK needs `addressesByChain.mintlayer`. Sessions now persist
    -  `{ address, addressesByChain, network }`; `window.mojito.restore()` resolves
    -  the full session (or `null`).
    -- **Sessions lied about networks**: ConnectionPage filed the wallet's
    -  current-network addresses under BOTH network keys. Now only the active
    -  network key is filled and the session records `network`.
    -- **Wrong-network signing**: sign requests carry the session's network; the
    -  signing screen fails with `WRONG_NETWORK` if the wallet switched networks
    -  since the grant (instead of silently signing on the other chain).
    -- **Structured errors**: all dApp-facing errors are `{ code, message }`
    -  (`USER_REJECTED`, `REQUEST_CANCELLED`, `REQUEST_IN_PROGRESS`,
    -  `NOT_CONNECTED`, `WRONG_NETWORK`, `UNSUPPORTED_METHOD`, `TIMEOUT`,
    -  `EXTENSION_ERROR`, `STORAGE_ERROR`). Messages avoid the brand word on
    -  purpose (the bridge shows "install the wallet" on /mojito/i messages).
    -- **Concurrent restore deadlock**: restore used a fixed `__restore` request id
    -  which the content script's duplicate guard swallowed — a second
    -  `Client.create()` hung forever. Ids are now unique.
    -- CSP (`'wasm-unsafe-eval'`, no `unsafe-eval`) verified in both manifests; no
    -  meta CSP in extension HTML. Injection at `document_start`, idempotent.
    -- Tests: `public/manifest.test.js`, `public/mojito.test.js`,
    -  `public/background.test.js` (connect/restore lifecycle, session shapes,
    -  error codes, wrong-network stamping, disconnect revocation, manifest CSP).
    -- Manual smoke checklist (unpacked install): connect from a test page →
    -  approve → reload page (auto-restore, no prompt) → build+sign an intent tx
    -  (approve) → disconnect → reload page → no restored session.
    -
    -## Review remediation done this session (P1–P5, all built + verified,
    -
    -124 suites / 662 tests green)
    -
    -- **Money math**: amount regex escaped (rejects `1e3`); `getParsedTransactions`
    -  accumulation fixed + regression tests; Decimal everywhere (`getAmountInCoins/
    -Atoms`, token/coin/delegation sums, `spendFromDelegation`); Dashboard 24h
    -  stats null-safe when yesterday rates are missing (`proportionDiffs`/
    -  `balanceDiffs` may be `null` — treat as "show nothing", never `0`).
    -- **Providers**: `fetchAllData` try/catch/finally + ref mutex (flags can't
    -  wedge) + new `fetchError` context field; network-switch effect owns
    -  `cancelAllRequests()`; `fetchDelegations` always releases its flag;
    -  ExchangeRates parallel fetch + error/`fetching` state; BitcoinProvider never
    -  sets `btcUtxos` undefined, dep-less effect → `[networkType]`.
    -- **UX**: mock data deleted (NFT tab → real empty state, no demo activity
    -  row); `navigate('/wallet')` dead ends, post-send returns, `/wallet/:coinType`
    -  and unknown routes → `/dashboard` (pages/Wallet deleted); sign/confirm/
    -  SignChallenge/MessagePage/CreateDelegation overlay/Delegation cards restyled
    -  onto be-\* tokens; PopUp close icon visible; Sparkline `responsive` prop;
    -  `body min-width: 400px` removed.
    -- **Security**: `getSession` uses sender origin only; `customAPIServers`
    -  override removed; production source maps off; SignInternal mock selector
    -  dev-gated; deps bumped (ecpair 3.0.2, react-router-dom 7.18.3, bn.js);
    -  Chromium manifest HTTPS-only + top-frame only + WAR not ``
    -  (dApps connect via manual approval popup — no static allowlist, per user).
    -- **Dead code / DRY**: `WALLETS_NAVIGATION`, containers/Dashboard cluster,
    -  `src/mocks/**` (+ `@Mocks` aliases), Navigation `customNavigation`, `exact`
    -  on Route; provider on `MINTLAYER_ENDPOINTS` (placeholders aligned to server
    -  contract); `ML.getUnconfirmedTransactionKey` replaces 8 hand-built keys;
    -  `'testnet'` literals → constant.
    -
    -## Pending next task (resume here)
    -
    -Pick from `REVIEW-PLAN.md` (ordered). Suggested first: #1 real NFT data,
    -#3 BTC HTLC signing fix (broken today), #4 useMlTransactionForm extraction.
    -
    -## Feature work done earlier this session (all built + verified)
    -
    -0c. **New Stake page + Dashboard quick action**:
    -
    -- `src/pages/StakePage/` — new dark-design staking screen at `/staking`:
    -  total staked (`mlDelegationsBalance`), earned-from-staking stat
    -  (live total − net contributions, real delegation rewards accrue into the
    -  balance), active/inactive delegation counts, stake-growth Sparkline
    -  rebuilt from on-chain txs, delegation list (reuse `Wallet.DelegationList`
    -  — detail popup / add-funds / withdraw still work). Single action button:
    -  "Pool list" (explorer) — Create delegation / Staking guide buttons were
    -  removed (delegation management happens on the explorer).
    -- `ML.buildStakeGrowthSeries` (utils/Helpers/ML) — cumulative series from
    -  `DelegateStaking` (+) / `Delegate Withdrawal` (−) txs, last point
    -  anchored to the live total; 5 unit tests.
    -- `Icon.tsx` — new `stake` line icon; Dashboard quick actions now 4-wide
    -  (Send / Receive / Stake / Activity), grid → `repeat(4, 1fr)`.
    -- Old staking retired: `/wallet/:coinType/staking` → ``,
    -  deleted `src/pages/Staking` + `CurrentStaking`; post-action returns in
    -  CreateDelegation / DelegationStake / DelegationWithdraw now go to
    -  `/staking`; Header back button for the old staking URL → `/dashboard`.
    -- GOTCHA for future work: `@ContainerComponents` barrel exports NAMESPACES
    -  (`Wallet`, `Dashboard`, ...) — `import { DelegationList }` silently
    -  resolves to `undefined`. Use `Wallet.DelegationList`.
    -
    -0b. **Removed old wallet entries from the slider menu** (3-lines icon):
    -
    -- `Navigation.tsx` — dropped "Bitcoin Wallet" (`/wallet/Bitcoin`) and
    -  "Mintlayer Wallet" (`/wallet/Mintlayer`) menu items + their logo imports;
    -  menu is now Dashboard / Settings (+ dev-only entries). Regression test
    -  added in `Navigation.test.js`.
    -- `Dashboard.js` — quick-action "Send" now goes straight to
    -  `/wallet/Mintlayer/send-ml-transaction` instead of the old wallet page.
    -- NOTE: the old `/wallet/:coinType` pages/routes still exist and are
    -  reachable — they are the send/staking/swap/sign flows and the post-send
    -  return targets (`ConfirmBtcTransaction`, `SignInternalTransaction`,
    -  `SendMlTransaction`, `NftSend` navigate back to `/wallet/`).
    -  Full removal of those pages is a separate, bigger task.
    -
    -0. **AssetPage + Dashboard on real token data** (latest):
    -   - `AssetPage.js` — `MOCK_TOKENS` fully removed. Token ids now read
    -     `tokenBalances` from `MintlayerContext` (ticker via
    -     `token_info.token_ticker.string`, balance via token-scoped
    -     `useMlWalletInfo(undefined, id)` which also filters txs by `token_id`).
    -     No fake price/fiat/spark/24h pill for tokens (coins compute 24h change
    -     from the spark history instead of the old hardcoded 0). Token info KV
    -     shows real Ticker / Token ID / Decimals / Balance. Send button hidden
    -     for tokens (see follow-up above); Receive still works (ML address).
    -   - `Dashboard.js` — dropped the `MOCK_TOKENS` demo rows; only real
    -     `tokenBalances` tokens remain (NFT tab still mocked, see follow-up).
    -   - `AssetPage.test.js` — token fixture through mocked `@Contexts`
    -     `tokenBalances` + token-aware `useMlWalletInfo` mock; asserts real
    -     ticker/decimals render and no `$` appears for tokens.
    -
    -## Previously done in this session (built + verified)
    -
    -1. **Connect-flow crash fix** — `ConnectionPage.handleConnect` fully defensive
    -   (old-store blobs with no public keys / string BTC addresses supported);
    -   missing `mlReceivingAddresses` → disabled Connect + inline warning;
    -   `sendPopupResponse` result omits empty bitcoin block.
    -2. **ErrorBoundary self-diagnosing** — renders `error.message` in a ``
    -   line (`ErrorBoundary.tsx`).
    -3. **Crash fixes found via the new boundary / webpack warnings**:
    -   - `utils/Helpers/Transactions/Transactions.js:1` — was
    -     `import { Format }` (undefined!) → `import * as Format`.
    -   - `src/hooks/index.js` — `useOneDayAgoHist` existed but was never exported
    -     from the barrel; AssetPage imported it from `@Hooks` → runtime crash.
    -   - Shared helper `BTC.getBtcAddressString` (`utils/Helpers/BTC/BTC.js:312`)
    -     for both stored BTC shapes (string / `{ [address]: { pubkey } }`); applied
    -     in ReceivePage, AssetPage, SendBtcTransaction, BitcoinProvider, Dashboard,
    -     ConnectionPage.
    -   - `jest.config.js` — removed `src/pages` from `testPathIgnorePatterns`
    -     (AssetPage.test.js was silently excluded AND failing).
    -4. **Receive screen** — chain seeded from navigation state (AssetPage passes
    -   `{ chain }`), defaults to Mintlayer; real QR via new basic `QrCode`
    -   component (`react-qr-code`, already a dep); `QrPlaceholder` only when no
    -   address.
    -5. **TokenIcon** — real Mintlayer logo (`logo.svg`) and BTC logo
    -   (`btc-logo.svg`) for ML/BTC; ML tile = dark neutral gray gradient
    -   (`oklch(0.36→0.26)`, hue 70) per user choice; procedural fallback for other
    -   tokens.
    -
    -## Conventions / commands
    -
    -- Lint: `npx eslint 'src/**/*.{js,ts,tsx}'`
    -- Tests: `NODE_ENV=test npx jest --silent` (all suites must stay green)
    -- Build (output → `build/`, extension runs UNPACKED from there):
    -  `npx env-cmd -f ./.env.production npx webpack --mode production && node ./src/version/version-mojito.js && cp build/index.html build/popup.html`
    -- Reload WITHOUT restarting Chromium: chrome://extensions → Mojito → ↻, then
    -  close/reopen the side panel.
    -- PRs: branch off `dev`, title `A-[task id]: [description]` (see CONTRIBUTING.md).
    
    From 853f86a82e158bfb2348a27091162a433ce3ad27 Mon Sep 17 00:00:00 2001
    From: Enrico Rubboli 
    Date: Tue, 8 Sep 2026 11:29:05 +0200
    Subject: [PATCH 24/52] fix(tokens): gateway fallback for token metadata and
     icons
    
    ipfs.io regularly times out or returns empty for the metadata CIDs
    (observed for mlUSDC: 'signal timed out' after 8s), so a single hard-coded
    gateway left tokens iconless.
    
    - resolveTokenIcon tries ipfs.io -> dweb.link -> gateway.pinata.cloud
      (6s per attempt); a definitive 'document has no icon' is cached, network
      failures are not so the next refresh retries
    - TokenIcon cycles the same mirror list on  error before falling back
      to the procedural tile
    ---
     .../basic/TokenIcon/TokenIcon.test.tsx        | 38 ++++++++---
     src/components/basic/TokenIcon/TokenIcon.tsx  | 39 ++++++++++-
     src/services/API/Mintlayer/Mintlayer.js       | 65 +++++++++++++------
     src/services/API/Mintlayer/Mintlayer.test.js  | 48 ++++++++++++--
     4 files changed, 152 insertions(+), 38 deletions(-)
    
    diff --git a/src/components/basic/TokenIcon/TokenIcon.test.tsx b/src/components/basic/TokenIcon/TokenIcon.test.tsx
    index cec47360..faf1b0f0 100644
    --- a/src/components/basic/TokenIcon/TokenIcon.test.tsx
    +++ b/src/components/basic/TokenIcon/TokenIcon.test.tsx
    @@ -49,21 +49,41 @@ test('maps ipfs:// icon uris to the ipfs.io gateway', () => {
       expect(img.src).toBe('https://ipfs.io/ipfs/bafyabc/icon.png')
     })
     
    -test('falls back to the procedural tile when the icon fails to load', async () => {
    +test('cycles gateway mirrors on load failure before falling back to the tile', async () => {
       const { getByTestId, queryByTestId } = render(
         ,
       )
     
    -  // jsdom may report the load failure on its own; if the img is still
    -  // there, drive the failure the way a browser would.
    -  const img = queryByTestId('token-icon-image')
    -  if (img) fireEvent.error(img)
    +  const failUntilTile = async () => {
    +    // each error moves to the next gateway mirror; exhausting them removes
    +    // the img and restores the procedural tile
    +    for (let i = 0; i < 4; i++) {
    +      const img = queryByTestId('token-icon-image')
    +      if (!img) break
    +      fireEvent.error(img)
    +      await waitFor(() => {})
    +    }
    +    await waitFor(() =>
    +      expect(queryByTestId('token-icon-image')).not.toBeInTheDocument(),
    +    )
    +  }
     
    -  await waitFor(() =>
    -    expect(queryByTestId('token-icon-image')).not.toBeInTheDocument(),
    -  )
    +  await failUntilTile()
       expect(getByTestId('token-icon')).toHaveTextContent('$')
    +
    +  const srcAfterFirstError = 'https://dweb.link/ipfs/bafyicon/broken.png'
    +  // re-render a fresh instance to verify the first mirror swap specifically
    +  const second = render(
    +    ,
    +  )
    +  fireEvent.error(second.getByTestId('token-icon-image'))
    +  expect((second.getByTestId('token-icon-image') as HTMLImageElement).src).toBe(
    +    srcAfterFirstError,
    +  )
     })
    diff --git a/src/components/basic/TokenIcon/TokenIcon.tsx b/src/components/basic/TokenIcon/TokenIcon.tsx
    index 962aee28..cba2fa13 100644
    --- a/src/components/basic/TokenIcon/TokenIcon.tsx
    +++ b/src/components/basic/TokenIcon/TokenIcon.tsx
    @@ -32,18 +32,51 @@ const toRenderableUri = (uri: string) =>
         ? uri.replace('ipfs://', 'https://ipfs.io/ipfs/')
         : uri
     
    +// If the resolved icon URL times out (ipfs.io does that regularly), the img
    +// onError cycles through mirror gateways before giving up entirely.
    +const IMG_GATEWAY_FALLBACKS: Array<[string, string]> = [
    +  ['https://ipfs.io/ipfs/', 'https://dweb.link/ipfs/'],
    +  ['https://dweb.link/ipfs/', 'https://gateway.pinata.cloud/ipfs/'],
    +]
    +
     // Native BTC/ML assets get the real chain logos; tokens show their metadata
     // icon when available, otherwise the procedural design-system tile (unknown
     // symbols fall back to first letter).
     const TokenIcon = ({ symbol, size = 36, iconUri }: TokenIconProps) => {
       const [iconFailed, setIconFailed] = useState(false)
    +  const [iconSrc, setIconSrc] = useState(
    +    iconUri ? toRenderableUri(iconUri) : undefined,
    +  )
       const logo = LOGOS[symbol]
       const { c1, c2 } = GRADIENTS[symbol] ?? {
         c1: 'oklch(0.6 0.05 60)',
         c2: 'oklch(0.4 0.05 60)',
       }
     
    -  const showImage = Boolean(iconUri) && !iconFailed && !logo
    +  // Keep the displayed src in sync when the resolved icon arrives late.
    +  const [lastIconUri, setLastIconUri] = useState(iconUri)
    +  if (iconUri !== lastIconUri) {
    +    setLastIconUri(iconUri)
    +    setIconSrc(iconUri ? toRenderableUri(iconUri) : undefined)
    +    setIconFailed(false)
    +  }
    +
    +  const showImage = Boolean(iconSrc) && !iconFailed && !logo
    +
    +  const handleIconError = () => {
    +    if (!iconSrc) {
    +      setIconFailed(true)
    +      return
    +    }
    +    const fallback = IMG_GATEWAY_FALLBACKS.find(([from]) =>
    +      iconSrc.startsWith(from),
    +    )
    +    if (fallback) {
    +      setIconSrc(iconSrc.replace(fallback[0], fallback[1]))
    +    } else {
    +      setIconFailed(true)
    +    }
    +  }
     
       return (
         
    { {showImage ? ( {symbol} setIconFailed(true)} + onError={handleIconError} data-testid="token-icon-image" /> ) : logo ? ( diff --git a/src/services/API/Mintlayer/Mintlayer.js b/src/services/API/Mintlayer/Mintlayer.js index e60fa74f..167744c2 100644 --- a/src/services/API/Mintlayer/Mintlayer.js +++ b/src/services/API/Mintlayer/Mintlayer.js @@ -332,41 +332,68 @@ const getNftsData = async (tokens) => { // document (metadata_uri, usually ipfs://) whose JSON holds the icon under // `tokenIcon` (also tolerate `icon_uri`/`icon`). The icon itself is often an // ipfs:// uri again. -const IPFS_GATEWAY = 'https://ipfs.io/ipfs' +// Gateways are tried in order: ipfs.io is preferred but frequently times out +// or returns empty for CIDs; dweb.link and pinata are the reliable backups. +const IPFS_GATEWAYS = [ + 'https://ipfs.io/ipfs', + 'https://dweb.link/ipfs', + 'https://gateway.pinata.cloud/ipfs', +] +const IPFS_GATEWAY = IPFS_GATEWAYS[0] const fromIpfs = (uri) => uri.startsWith('ipfs://') ? uri.replace('ipfs://', `${IPFS_GATEWAY}/`) : uri const tokenIconCache = new Map() // metadata uri -> icon url | null +const fetchJsonWithGatewayFallback = async (uri) => { + const candidates = uri.startsWith('ipfs://') + ? IPFS_GATEWAYS.map( + (gateway) => `${gateway}/${uri.slice('ipfs://'.length)}`, + ) + : [uri] + + for (const candidate of candidates) { + try { + // Timeout: this runs inside the wallet data refresh and ipfs gateways + // can hang — never block the whole refresh on an icon. + const response = await fetch(candidate, { + signal: AbortSignal.timeout(6000), + }) + if (response.ok) { + return await response.json() + } + } catch (error) { + console.error( + `Token metadata request failed (${candidate}):`, + error.message, + ) + } + } + return null +} + const resolveTokenIcon = async (metadataUri) => { if (!metadataUri) return undefined if (tokenIconCache.has(metadataUri)) { return tokenIconCache.get(metadataUri) ?? undefined } + const metadata = await fetchJsonWithGatewayFallback(metadataUri) + + // A definitive "document has no icon" is cached; network/timeout failures + // are NOT cached so the next refresh retries. let iconUrl - try { - // Timeout: this runs inside the wallet data refresh and ipfs gateways - // can hang — never block the whole refresh on an icon. - const response = await fetch(fromIpfs(metadataUri), { - signal: AbortSignal.timeout(8000), - }) - if (response.ok) { - const metadata = await response.json() - const raw = metadata.tokenIcon || metadata.icon_uri || metadata.icon - if (raw && typeof raw === 'string') { - iconUrl = fromIpfs(raw) - } + if (metadata) { + const raw = metadata.tokenIcon || metadata.icon_uri || metadata.icon + if (raw && typeof raw === 'string') { + iconUrl = fromIpfs(raw) + tokenIconCache.set(metadataUri, iconUrl) + } else { + tokenIconCache.set(metadataUri, null) } - } catch (error) { - console.error( - `Failed to resolve token icon from ${metadataUri}:`, - error.message, - ) } - tokenIconCache.set(metadataUri, iconUrl ?? null) return iconUrl } diff --git a/src/services/API/Mintlayer/Mintlayer.test.js b/src/services/API/Mintlayer/Mintlayer.test.js index 421d73ec..6d8d20fe 100644 --- a/src/services/API/Mintlayer/Mintlayer.test.js +++ b/src/services/API/Mintlayer/Mintlayer.test.js @@ -122,7 +122,7 @@ describe('resolveTokenIcon', () => { }) it('resolves tokenIcon from the metadata document and maps ipfs uris', async () => { - jest.spyOn(global, 'fetch').mockResolvedValue( + const fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValue( okJson({ tokenIcon: 'ipfs://bafyicon/logo.png', }), @@ -131,9 +131,26 @@ describe('resolveTokenIcon', () => { await expect( resolveTokenIcon('ipfs://bafymetadata/doc.json'), ).resolves.toBe('https://ipfs.io/ipfs/bafyicon/logo.png') + expect(fetchSpy.mock.calls[0][0]).toBe( + 'https://ipfs.io/ipfs/bafymetadata/doc.json', + ) }) - it('is cached per metadata uri', async () => { + it('falls back to the next gateway when one fails', async () => { + const fetchSpy = jest + .spyOn(global, 'fetch') + .mockRejectedValueOnce(new Error('signal timed out')) + .mockResolvedValueOnce(okJson({ tokenIcon: 'ipfs://bafyicon/i.png' })) + + await expect( + resolveTokenIcon('ipfs://bafymetadata/slow.json'), + ).resolves.toBe('https://ipfs.io/ipfs/bafyicon/i.png') + expect(fetchSpy.mock.calls[1][0]).toBe( + 'https://dweb.link/ipfs/bafymetadata/slow.json', + ) + }) + + it('is cached once resolved, per metadata uri', async () => { const fetchSpy = jest .spyOn(global, 'fetch') .mockResolvedValue(okJson({ tokenIcon: 'https://x.example/i.png' })) @@ -144,15 +161,32 @@ describe('resolveTokenIcon', () => { expect(fetchSpy).toHaveBeenCalledTimes(1) }) - it('returns undefined when the document has no icon field or fetch fails', async () => { - jest.spyOn(global, 'fetch').mockResolvedValue(okJson({ name: 'no icon' })) + it('returns undefined — without caching — when every gateway fails', async () => { + const fetchSpy = jest + .spyOn(global, 'fetch') + .mockRejectedValue(new Error('signal timed out')) + await expect( - resolveTokenIcon('ipfs://bafymetadata/noicon.json'), + resolveTokenIcon('ipfs://bafymetadata/fail.json'), ).resolves.toBeUndefined() - - jest.spyOn(global, 'fetch').mockRejectedValue(new Error('offline')) + // failures are not cached: the next refresh retries await expect( resolveTokenIcon('ipfs://bafymetadata/fail.json'), ).resolves.toBeUndefined() + expect(fetchSpy).toHaveBeenCalledTimes(6) // 3 gateways x 2 attempts + }) + + it('caches a definitive no-icon answer', async () => { + const fetchSpy = jest + .spyOn(global, 'fetch') + .mockResolvedValue(okJson({ name: 'no icon here' })) + + await expect( + resolveTokenIcon('ipfs://bafymetadata/noicon.json'), + ).resolves.toBeUndefined() + await expect( + resolveTokenIcon('ipfs://bafymetadata/noicon.json'), + ).resolves.toBeUndefined() + expect(fetchSpy).toHaveBeenCalledTimes(1) }) }) From fa3657e14ac874922e68b853acc58a8c9a440160 Mon Sep 17 00:00:00 2001 From: Enrico Rubboli Date: Tue, 8 Sep 2026 12:08:47 +0200 Subject: [PATCH 25/52] fix(tokens): race the ipfs gateways instead of trying them one by one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured from the team network: ipfs.io timed out for both the metadata and icon CIDs (the source of the 'signal timed out' errors) while dweb.link and w3s.link answered in ~0.5s; gateway.pinata.cloud answered in 5.7s — inside the timeout only by luck — and is removed per report. - fetchJsonWithGatewayFallback races all gateways in parallel (10s each, Promise.any): a dead gateway loses the race instead of costing 6s of serial waiting - resolved 'no icon' answers are cached with a 5-minute TTL; total gateway failures are not cached so the next refresh retries, and they log once per uri per session instead of spamming every 2-minute poll - TokenIcon mirror cycle updated to ipfs.io -> dweb.link -> w3s.link - invariant tested: a raw ipfs:// uri is never fetched, only gateway URLs --- .../basic/TokenIcon/TokenIcon.test.tsx | 4 +- src/components/basic/TokenIcon/TokenIcon.tsx | 6 +- src/services/API/Mintlayer/Mintlayer.js | 82 ++++++++++++------- src/services/API/Mintlayer/Mintlayer.test.js | 59 +++++++------ 4 files changed, 91 insertions(+), 60 deletions(-) diff --git a/src/components/basic/TokenIcon/TokenIcon.test.tsx b/src/components/basic/TokenIcon/TokenIcon.test.tsx index faf1b0f0..9775fb66 100644 --- a/src/components/basic/TokenIcon/TokenIcon.test.tsx +++ b/src/components/basic/TokenIcon/TokenIcon.test.tsx @@ -74,7 +74,7 @@ test('cycles gateway mirrors on load failure before falling back to the tile', a await failUntilTile() expect(getByTestId('token-icon')).toHaveTextContent('$') - const srcAfterFirstError = 'https://dweb.link/ipfs/bafyicon/broken.png' + const srcAfterFirstMirror = 'https://dweb.link/ipfs/bafyicon/broken.png' // re-render a fresh instance to verify the first mirror swap specifically const second = render( ? uri.replace('ipfs://', 'https://ipfs.io/ipfs/') : uri -// If the resolved icon URL times out (ipfs.io does that regularly), the img -// onError cycles through mirror gateways before giving up entirely. +// If the resolved icon URL times out on a gateway, the img onError cycles +// through the remaining mirrors before giving up entirely. const IMG_GATEWAY_FALLBACKS: Array<[string, string]> = [ ['https://ipfs.io/ipfs/', 'https://dweb.link/ipfs/'], - ['https://dweb.link/ipfs/', 'https://gateway.pinata.cloud/ipfs/'], + ['https://dweb.link/ipfs/', 'https://w3s.link/ipfs/'], ] // Native BTC/ML assets get the real chain logos; tokens show their metadata diff --git a/src/services/API/Mintlayer/Mintlayer.js b/src/services/API/Mintlayer/Mintlayer.js index 167744c2..19d1a33d 100644 --- a/src/services/API/Mintlayer/Mintlayer.js +++ b/src/services/API/Mintlayer/Mintlayer.js @@ -332,19 +332,24 @@ const getNftsData = async (tokens) => { // document (metadata_uri, usually ipfs://) whose JSON holds the icon under // `tokenIcon` (also tolerate `icon_uri`/`icon`). The icon itself is often an // ipfs:// uri again. -// Gateways are tried in order: ipfs.io is preferred but frequently times out -// or returns empty for CIDs; dweb.link and pinata are the reliable backups. +// The raw ipfs:// scheme is never fetched or rendered: every uri is mapped +// to one of these gateways. Public gateway availability varies per network +// (ipfs.io needs a VPN here today), so all of them are RACED in parallel and +// the first usable JSON wins — a dead gateway loses the race without adding +// serial latency. const IPFS_GATEWAYS = [ 'https://ipfs.io/ipfs', 'https://dweb.link/ipfs', - 'https://gateway.pinata.cloud/ipfs', + 'https://w3s.link/ipfs', ] const IPFS_GATEWAY = IPFS_GATEWAYS[0] const fromIpfs = (uri) => uri.startsWith('ipfs://') ? uri.replace('ipfs://', `${IPFS_GATEWAY}/`) : uri -const tokenIconCache = new Map() // metadata uri -> icon url | null +const NEGATIVE_CACHE_TTL_MS = 5 * 60 * 1000 +const tokenIconCache = new Map() // metadata uri -> { value, expires? } +const failedLookupsLogged = new Set() const fetchJsonWithGatewayFallback = async (uri) => { const candidates = uri.startsWith('ipfs://') @@ -353,48 +358,65 @@ const fetchJsonWithGatewayFallback = async (uri) => { ) : [uri] - for (const candidate of candidates) { - try { - // Timeout: this runs inside the wallet data refresh and ipfs gateways - // can hang — never block the whole refresh on an icon. - const response = await fetch(candidate, { - signal: AbortSignal.timeout(6000), - }) - if (response.ok) { - return await response.json() - } - } catch (error) { - console.error( - `Token metadata request failed (${candidate}):`, - error.message, - ) + const attempts = candidates.map(async (candidate) => { + // Timeout: this runs inside the wallet data refresh and gateways can + // hang — never block the whole refresh on an icon. + const response = await fetch(candidate, { + signal: AbortSignal.timeout(10000), + }) + if (!response.ok) { + throw new Error(`HTTP ${response.status}`) } + return response.json() + }) + + try { + return await Promise.any(attempts) + } catch { + return null } - return null } const resolveTokenIcon = async (metadataUri) => { if (!metadataUri) return undefined - if (tokenIconCache.has(metadataUri)) { - return tokenIconCache.get(metadataUri) ?? undefined + + const cached = tokenIconCache.get(metadataUri) + if (cached) { + // Negative entries carry a TTL; positive results are permanent. + if (cached.expires && cached.expires < Date.now()) { + tokenIconCache.delete(metadataUri) + } else { + return cached.value ?? undefined + } } const metadata = await fetchJsonWithGatewayFallback(metadataUri) - // A definitive "document has no icon" is cached; network/timeout failures - // are NOT cached so the next refresh retries. - let iconUrl if (metadata) { const raw = metadata.tokenIcon || metadata.icon_uri || metadata.icon if (raw && typeof raw === 'string') { - iconUrl = fromIpfs(raw) - tokenIconCache.set(metadataUri, iconUrl) - } else { - tokenIconCache.set(metadataUri, null) + const iconUrl = fromIpfs(raw) + tokenIconCache.set(metadataUri, { value: iconUrl }) + return iconUrl } + // Definitive "document has no icon": cache with a TTL so the gateways + // are not hammered on every refresh. + tokenIconCache.set(metadataUri, { + value: null, + expires: Date.now() + NEGATIVE_CACHE_TTL_MS, + }) + return undefined } - return iconUrl + // Every gateway failed: log once per uri, do NOT cache — the next refresh + // retries. + if (!failedLookupsLogged.has(metadataUri)) { + failedLookupsLogged.add(metadataUri) + console.error( + `Failed to resolve token metadata from every gateway: ${metadataUri}`, + ) + } + return undefined } const getAddressDelegations = (address) => diff --git a/src/services/API/Mintlayer/Mintlayer.test.js b/src/services/API/Mintlayer/Mintlayer.test.js index 6d8d20fe..23a59fcc 100644 --- a/src/services/API/Mintlayer/Mintlayer.test.js +++ b/src/services/API/Mintlayer/Mintlayer.test.js @@ -122,32 +122,41 @@ describe('resolveTokenIcon', () => { }) it('resolves tokenIcon from the metadata document and maps ipfs uris', async () => { - const fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValue( - okJson({ - tokenIcon: 'ipfs://bafyicon/logo.png', - }), - ) + const fetchSpy = jest + .spyOn(global, 'fetch') + .mockResolvedValue(okJson({ tokenIcon: 'ipfs://bafyicon/logo.png' })) await expect( resolveTokenIcon('ipfs://bafymetadata/doc.json'), ).resolves.toBe('https://ipfs.io/ipfs/bafyicon/logo.png') + // all gateways are raced, starting with ipfs.io expect(fetchSpy.mock.calls[0][0]).toBe( 'https://ipfs.io/ipfs/bafymetadata/doc.json', ) + expect(fetchSpy.mock.calls[1][0]).toBe( + 'https://dweb.link/ipfs/bafymetadata/doc.json', + ) }) - it('falls back to the next gateway when one fails', async () => { + it('races gateways — a dead one loses the race without blocking', async () => { const fetchSpy = jest .spyOn(global, 'fetch') - .mockRejectedValueOnce(new Error('signal timed out')) - .mockResolvedValueOnce(okJson({ tokenIcon: 'ipfs://bafyicon/i.png' })) + .mockImplementation(async (url) => { + if (String(url).startsWith('https://ipfs.io/')) { + throw new Error('signal timed out') + } + return okJson({ tokenIcon: 'ipfs://bafyicon/i.png' }) + }) await expect( resolveTokenIcon('ipfs://bafymetadata/slow.json'), ).resolves.toBe('https://ipfs.io/ipfs/bafyicon/i.png') - expect(fetchSpy.mock.calls[1][0]).toBe( - 'https://dweb.link/ipfs/bafymetadata/slow.json', - ) + + // invariant: a raw ipfs:// uri is never fetched — only gateway URLs + for (const [candidate] of fetchSpy.mock.calls) { + expect(String(candidate)).toMatch(/^https:\/\//) + expect(String(candidate)).not.toMatch(/^ipfs:\/\//) + } }) it('is cached once resolved, per metadata uri', async () => { @@ -155,38 +164,38 @@ describe('resolveTokenIcon', () => { .spyOn(global, 'fetch') .mockResolvedValue(okJson({ tokenIcon: 'https://x.example/i.png' })) - await resolveTokenIcon('https://example.test/meta1.json') - await resolveTokenIcon('https://example.test/meta1.json') + await resolveTokenIcon('ipfs://bafymetadata/cached.json') + await resolveTokenIcon('ipfs://bafymetadata/cached.json') - expect(fetchSpy).toHaveBeenCalledTimes(1) + expect(fetchSpy).toHaveBeenCalledTimes(3) // 3 gateways raced once }) - it('returns undefined — without caching — when every gateway fails', async () => { + it('caches a definitive no-icon answer with a TTL', async () => { const fetchSpy = jest .spyOn(global, 'fetch') - .mockRejectedValue(new Error('signal timed out')) + .mockResolvedValue(okJson({ name: 'no icon here' })) await expect( - resolveTokenIcon('ipfs://bafymetadata/fail.json'), + resolveTokenIcon('ipfs://bafymetadata/noicon.json'), ).resolves.toBeUndefined() - // failures are not cached: the next refresh retries await expect( - resolveTokenIcon('ipfs://bafymetadata/fail.json'), + resolveTokenIcon('ipfs://bafymetadata/noicon.json'), ).resolves.toBeUndefined() - expect(fetchSpy).toHaveBeenCalledTimes(6) // 3 gateways x 2 attempts + // 3 gateways raced once; the negative answer is cached for 5 minutes + expect(fetchSpy).toHaveBeenCalledTimes(3) }) - it('caches a definitive no-icon answer', async () => { + it('does not cache total gateway failures — the next refresh retries', async () => { const fetchSpy = jest .spyOn(global, 'fetch') - .mockResolvedValue(okJson({ name: 'no icon here' })) + .mockRejectedValue(new Error('signal timed out')) await expect( - resolveTokenIcon('ipfs://bafymetadata/noicon.json'), + resolveTokenIcon('ipfs://bafymetadata/fail.json'), ).resolves.toBeUndefined() await expect( - resolveTokenIcon('ipfs://bafymetadata/noicon.json'), + resolveTokenIcon('ipfs://bafymetadata/fail.json'), ).resolves.toBeUndefined() - expect(fetchSpy).toHaveBeenCalledTimes(1) + expect(fetchSpy).toHaveBeenCalledTimes(6) // 3 gateways x 2 attempts }) }) From 5dfa603cba0b5e2bff121d0a1cc607a343509c8c Mon Sep 17 00:00:00 2001 From: Enrico Rubboli Date: Tue, 8 Sep 2026 12:32:39 +0200 Subject: [PATCH 26/52] =?UTF-8?q?fix(bridge/core):=20review=20findings=20?= =?UTF-8?q?=E2=80=94=20error=20relay,=20abort=20signals,=20stat=20guards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - content-script: the lastError branch cleared the pending entry before failRequest could run, so EXTENSION_ERROR responses were never posted and the dApp promise hung forever (regression of the hang-fix itself); failRequest now owns the cleanup. Reload ownership added: the last injected instance owns the channel, orphans stop answering (their stale error used to poison the fresh instance's responses after every update). EXTENSION_ERROR message is static (no raw chrome strings to pages) - BTC.getStats: an empty wallet with valid rates rendered '-100% · 24h' (0/yesterday-fallback -> (0-1)*100); zero is now a real zero, null stays 'no data' (+ regression tests) - cancelAllRequests actually cancels now: the AbortController signal is wired into the fetch of both API services and the registry is keyed by method+url so concurrent requests don't clobber each other - MintlayerProvider clears fetchError on a successful refresh - buildStakeGrowthSeries charts [0 -> live total] when the delegation predates the parsed transaction history instead of hiding the chart - StakePage guards the 'earned' figure against NaN --- public/explorer/content-script.js | 27 ++++-- public/explorer/content-script.test.js | 88 +++++++++++++++++++ .../MintlayerProvider/MintlayerProvider.js | 1 + src/pages/StakePage/StakePage.js | 5 +- src/services/API/Electrum/Electrum.js | 6 +- src/services/API/Mintlayer/Mintlayer.js | 9 +- src/utils/Helpers/BTC/BTC.js | 5 +- src/utils/Helpers/BTC/BTC.test.js | 54 ++++++++++++ src/utils/Helpers/ML/ML.js | 6 ++ 9 files changed, 187 insertions(+), 14 deletions(-) diff --git a/public/explorer/content-script.js b/public/explorer/content-script.js index 27948314..5b922f4c 100644 --- a/public/explorer/content-script.js +++ b/public/explorer/content-script.js @@ -23,9 +23,17 @@ const pendingRequests = new Map() // requestId -> timeout id const RESPONSE_TIMEOUT_MS = 5 * 60 * 1000 // approvals can take a while - // True once the extension has been reloaded/updated/disabled underneath - // this orphaned content script: every runtime call will throw, so answer - // immediately with a clear error instead of letting requests hang. + // Instance ownership: on extension reload/update Chrome injects a fresh + // content script into already-open pages while the orphaned one keeps its + // message listener. The last injected instance owns the channel; orphans + // stop answering so they cannot poison the fresh instance's responses. + const OWNER_KEY = '__mojitoContentScriptOwner' + const myInstanceId = `${Date.now()}_${Math.random().toString(36).slice(2)}` + window[OWNER_KEY] = myInstanceId + + // True once this (still-owning) instance discovers the extension context + // is gone: every further request is answered immediately with a clear + // error instead of hanging until the timeout. let contextInvalidated = false const failRequest = (requestId, code, message) => { @@ -61,6 +69,10 @@ return } + // An orphaned instance (superseded by a freshly injected one) must not + // answer — its runtime channel is stale and would race the real one. + if (window[OWNER_KEY] !== myInstanceId) return + const requestId = event.data.requestId // Guard against duplicate requests and answer stale ids at once. @@ -102,20 +114,19 @@ (response) => { if (!pendingRequests.has(requestId)) return - clearTimeout(pendingRequests.get(requestId)) - pendingRequests.delete(requestId) - if (api.runtime.lastError) { console.error('[Mojito] Runtime error:', api.runtime.lastError) failRequest( requestId, 'EXTENSION_ERROR', - api.runtime.lastError.message || - 'Could not reach the wallet. Is it installed and enabled?', + 'Could not reach the wallet. Is it installed and enabled?', ) return } + clearTimeout(pendingRequests.get(requestId)) + pendingRequests.delete(requestId) + postToPage({ type: 'MINTLAYER_RESPONSE', requestId, diff --git a/public/explorer/content-script.test.js b/public/explorer/content-script.test.js index bf2e2e49..4ddac05d 100644 --- a/public/explorer/content-script.test.js +++ b/public/explorer/content-script.test.js @@ -45,6 +45,7 @@ const setup = ({ sendMessageImpl }) => { // the test (the IIFE registers it on the shared jsdom window). Call through // so test-side listeners still register normally. messageListeners = [] + addSpy?.mockRestore() const originalAddEventListener = window.addEventListener.bind(window) addSpy = jest .spyOn(window, 'addEventListener') @@ -140,3 +141,90 @@ describe('content script relay', () => { expect(sendMessageCalls.filter((m) => m.requestId === 'r2')).toHaveLength(0) }) }) + +describe('runtime lastError path', () => { + const nextResponse = () => + new Promise((resolve) => { + const listener = (event) => { + if (event.data?.type === 'MINTLAYER_RESPONSE') { + window.removeEventListener('message', listener) + resolve(event.data) + } + } + window.addEventListener('message', listener) + }) + + it('answers the page with EXTENSION_ERROR when the background errors', async () => { + global.browser = undefined + global.chrome = { + runtime: { + id: 'ext-id', + getURL: (p) => `chrome-extension://ext-id/${p}`, + sendMessage: (message, callback) => { + // MV3 sets lastError instead of throwing when the port dies + Object.defineProperty(chrome.runtime, 'lastError', { + value: { + message: + 'The message port closed before a response was received.', + }, + configurable: true, + }) + callback(undefined) + }, + lastError: null, + }, + } + + // eslint-disable-next-line no-eval + window.eval(CONTENT_SCRIPT_SRC) + + const incoming = nextResponse() + window.postMessage( + { type: 'MINTLAYER_REQUEST', requestId: 'e1', method: 'connect' }, + '*', + ) + + await expect(incoming).resolves.toMatchObject({ + requestId: 'e1', + error: { code: 'EXTENSION_ERROR' }, + }) + delete chrome.runtime.lastError + }) +}) + +describe('reload ownership', () => { + const bootInstance = () => { + // eslint-disable-next-line no-eval + window.eval(CONTENT_SCRIPT_SRC) + } + + it('lets a freshly injected instance take over from an orphaned one', async () => { + const responses = [] + const listener = (event) => { + if (event.data?.type === 'MINTLAYER_RESPONSE') responses.push(event.data) + } + window.addEventListener('message', listener) + + // instance 1 (later orphaned) + setup({ + sendMessageImpl: (_m, cb) => cb({ result: { from: 'old' } }), + }) + + // instance 2 (fresh injection after extension reload) + setup({ + sendMessageImpl: (_m, cb) => cb({ result: { from: 'new' } }), + }) + + window.postMessage( + { type: 'MINTLAYER_REQUEST', requestId: 'own1', method: 'connect' }, + '*', + ) + await new Promise((resolve) => setTimeout(resolve, 10)) + + // exactly one answer, from the current owner — the orphan must not + // poison the fresh channel with a stale error + expect(responses).toHaveLength(1) + expect(responses[0].result).toEqual({ from: 'new' }) + window.removeEventListener('message', listener) + }) +}) diff --git a/src/contexts/MintlayerProvider/MintlayerProvider.js b/src/contexts/MintlayerProvider/MintlayerProvider.js index f8ee0734..5eb66ee8 100644 --- a/src/contexts/MintlayerProvider/MintlayerProvider.js +++ b/src/contexts/MintlayerProvider/MintlayerProvider.js @@ -482,6 +482,7 @@ const MintlayerProvider = ({ value: propValue, children }) => { setNftInitialUtxos(availableNftInitialUtxos) setUtxos(availableUtxos) setLockedUtxos(lockedUtxos) + setFetchError(null) } catch (error) { // Never leave the UI wedged: surface the error and let `finally` // release every loading flag so the next poll can retry. diff --git a/src/pages/StakePage/StakePage.js b/src/pages/StakePage/StakePage.js index 5f55c727..f0bb59d7 100644 --- a/src/pages/StakePage/StakePage.js +++ b/src/pages/StakePage/StakePage.js @@ -33,7 +33,10 @@ const StakePage = () => { transactions, mlDelegationsBalance || 0, ) - const earned = Math.max(0, mlDelegationsBalance - (contributed - withdrawn)) + const earned = Math.max( + 0, + (mlDelegationsBalance || 0) - (contributed - withdrawn), + ) const confirmed = mlDelegationList.filter( (d) => d.type !== 'Unconfirmed' && d.balance?.decimal, diff --git a/src/services/API/Electrum/Electrum.js b/src/services/API/Electrum/Electrum.js index d66d7478..7c6eb832 100644 --- a/src/services/API/Electrum/Electrum.js +++ b/src/services/API/Electrum/Electrum.js @@ -26,12 +26,14 @@ const requestElectrum = async (url, body = null, request = fetch) => { const method = body ? 'POST' : 'GET' const header = body ? { 'Content-Type': 'application/json' } : {} const controller = new AbortController() - abortControllers.set(url, controller) + abortControllers.set(`${method} ${url}`, controller) const options = { method: method, headers: header, body, + // Wire the signal so cancelAllRequests() actually cancels. + signal: controller.signal, } try { @@ -43,7 +45,7 @@ const requestElectrum = async (url, body = null, request = fetch) => { console.error(error) throw error } finally { - abortControllers.delete(url) + abortControllers.delete(`${method} ${url}`) } } diff --git a/src/services/API/Mintlayer/Mintlayer.js b/src/services/API/Mintlayer/Mintlayer.js index 19d1a33d..0448de42 100644 --- a/src/services/API/Mintlayer/Mintlayer.js +++ b/src/services/API/Mintlayer/Mintlayer.js @@ -36,12 +36,17 @@ const getMintlayerServers = (networkType) => const requestMintlayer = async (url, body = null, request = fetch) => { const method = body ? 'POST' : 'GET' const controller = new AbortController() - abortControllers.set(url, controller) + // Keyed by url+method: concurrent requests to the same url must not + // clobber each other's controllers. + abortControllers.set(`${method} ${url}`, controller) try { const result = await request(url, { method, body, + // Wire the signal: without it cancelAllRequests() aborts nothing and + // stale responses land after a network switch. + signal: controller.signal, }) if (!result.ok) { const error = await result.json() @@ -85,7 +90,7 @@ const requestMintlayer = async (url, body = null, request = fetch) => { console.error(error) throw error } finally { - abortControllers.delete(url) + abortControllers.delete(`${method} ${url}`) } } diff --git a/src/utils/Helpers/BTC/BTC.js b/src/utils/Helpers/BTC/BTC.js index 56a233e4..b428cb81 100644 --- a/src/utils/Helpers/BTC/BTC.js +++ b/src/utils/Helpers/BTC/BTC.js @@ -247,7 +247,10 @@ const calculateBalances = (cryptos, yesterdayExchangeRates) => { const getStats = (proportionDiffs, balanceDiffs, networkType) => { const isTestnet = networkType === AppInfo.NETWORK_TYPES.TESTNET - const hasBalance = proportionDiffs.total != null + // null = rates missing (show nothing); 0 = real zero balance (an empty + // wallet must not render as "-100%"). + const hasBalance = + proportionDiffs.total != null && proportionDiffs.total !== 0 const percentValue = isTestnet || !hasBalance ? 0 diff --git a/src/utils/Helpers/BTC/BTC.test.js b/src/utils/Helpers/BTC/BTC.test.js index 1109075e..c106f451 100644 --- a/src/utils/Helpers/BTC/BTC.test.js +++ b/src/utils/Helpers/BTC/BTC.test.js @@ -6,6 +6,8 @@ import { parseFeesEstimates, convertBtcToSatoshi, getBtcAddressString, + calculateBalances, + getStats, } from './BTC' import { localStorageMock } from 'src/tests/mock/localStorage/localStorage' @@ -149,3 +151,55 @@ test('Check confirmations amount - success', async () => { const confirmations = await getConfirmationsAmount(transaction) expect(confirmations).toBe(1_000_002) }) + +describe('calculateBalances / getStats null-semantics', () => { + const cryptosWithRates = (btcRate, mlRate) => [ + { + name: 'Bitcoin', + symbol: 'BTC', + balance: 1, + exchangeRate: btcRate, + }, + { + name: 'Mintlayer', + symbol: 'ML', + balance: 100, + exchangeRate: mlRate, + }, + ] + + it('treats missing rates as "no data" instead of exploding the 24h change', () => { + const { proportionDiffs, balanceDiffs } = calculateBalances( + cryptosWithRates(50000, 0.05), + {}, + ) + + expect(proportionDiffs.total).toBeNull() + expect(balanceDiffs.total).toBeNull() + + const stats = getStats(proportionDiffs, balanceDiffs, 'mainnet') + expect(stats.find((s) => s.name === '24h percent').value).toBe(0) + expect(stats.find((s) => s.name === '24h fiat').value).toBe(0) + }) + + it('renders neutral stats for an empty wallet with valid rates (no -100%)', () => { + const { proportionDiffs } = calculateBalances( + cryptosWithRates(50000, 0.05).map((c) => ({ ...c, balance: 0 })), + { btc: 49000, ml: 0.049 }, + ) + const stats = getStats(proportionDiffs, { total: 0 }, 'mainnet') + + expect(stats.find((s) => s.name === '24h percent').value).toBe(0) + }) + + it('computes real 24h changes when rates exist and balances are non-zero', () => { + const { proportionDiffs, balanceDiffs } = calculateBalances( + cryptosWithRates(50000, 0.05), + { btc: 49000, ml: 0.049 }, + ) + + expect(proportionDiffs.total).not.toBeNull() + expect(proportionDiffs.total).toBeGreaterThan(1) + expect(balanceDiffs.total).toBeGreaterThan(0) + }) +}) diff --git a/src/utils/Helpers/ML/ML.js b/src/utils/Helpers/ML/ML.js index efbefa5a..b38f8cf9 100644 --- a/src/utils/Helpers/ML/ML.js +++ b/src/utils/Helpers/ML/ML.js @@ -540,6 +540,12 @@ const buildStakeGrowthSeries = (transactions, currentTotal = null) => { series.push(currentTotal) } + // Delegation predates the parsed transaction history: still chart the + // live total instead of hiding the chart entirely. + if (series.length === 0 && currentTotal != null && currentTotal > 0) { + series.push(0, currentTotal) + } + return { series, contributed, withdrawn } } From 93649d76c5a9fdae10ef6a673eb8196956e587ff Mon Sep 17 00:00:00 2001 From: Enrico Rubboli Date: Tue, 8 Sep 2026 12:49:19 +0200 Subject: [PATCH 27/52] fix(ui): consent-screen footer, settings/restore contrast, row disabled state, copy feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UI review quick wins: - SignExternalTransaction: the Approve/Decline footer was absolutely positioned inside the scroll container — on long transactions the consent buttons scrolled out of view; now in flow (mirrors the BTC screen fix) - Settings/Restore/Delete/LockedBalance/NFT family: dark-slate text (--color-dark-gray) on dark cards was ~2.5:1 unreadable; swept to --be-text-2 - icon-arrow-right-top.svg: near-black stroke was invisible on dark surfaces (StakePage 'Pool list', detail popups) — now currentColor - AssetRow: disabled state renders dimmed with a 'Sync issue' tag instead of a silent dead row; navigation moved onto the row's own handler and the duplicate data-testid wrapper dropped - Counter: animates from the previous value (periodic refreshes no longer re-roll the balance through $0.00) and honors prefers-reduced-motion - CopyButton: false 'copied' feedback fixed (promise caught, success icon only on resolve) + aria-label - Textarea: border on --be-line like Input; textarea autofill override --- src/assets/images/icon-arrow-right-top.svg | 2 +- src/assets/styles/index.css | 5 +- src/components/basic/Counter/Counter.tsx | 33 +++++++++--- .../basic/OptionCard/OptionCard.module.css | 2 +- src/components/basic/Textarea/Textarea.css | 2 +- .../composed/AssetRow/AssetRow.module.css | 5 ++ src/components/composed/AssetRow/AssetRow.tsx | 4 +- .../composed/CopyButton/CopyButton.js | 18 +++++-- .../composed/CopyButton/CopyButton.test.js | 25 +++++++-- .../LockedBalanceList.module.css | 8 +-- .../LockedBalanceListItem.module.css | 6 +-- .../DeleteAccount/DeleteAccount.module.css | 2 +- .../RestoreAccountJson/FileUpload.module.css | 8 +-- .../RestoreSuccess.module.css | 2 +- .../WalletDetails.module.css | 6 +-- .../SettingsAbout/SettingsAbout.module.css | 2 +- .../SettingsBackup/SettingsBackup.css | 2 +- .../SettingsSection.module.css | 2 +- .../SettingsTestnet.module.css | 2 +- src/pages/Dashboard/Dashboard.js | 9 ++-- .../RestoreAccount/RestoreAccount.module.css | 2 +- .../SignBitcoinTransaction.js | 52 +++++++++++++++++++ src/pages/SignChallenge/SignChallenge.js | 19 +++++++ .../SignExternalTransaction.css | 6 +-- .../SignExternalTransaction.js | 10 ++-- src/services/API/Mintlayer/Mintlayer.js | 24 ++++++--- 26 files changed, 196 insertions(+), 62 deletions(-) diff --git a/src/assets/images/icon-arrow-right-top.svg b/src/assets/images/icon-arrow-right-top.svg index 31d547eb..ed7a51ef 100644 --- a/src/assets/images/icon-arrow-right-top.svg +++ b/src/assets/images/icon-arrow-right-top.svg @@ -1,3 +1,3 @@ - + diff --git a/src/assets/styles/index.css b/src/assets/styles/index.css index d0679fec..16bc13fb 100644 --- a/src/assets/styles/index.css +++ b/src/assets/styles/index.css @@ -45,7 +45,10 @@ body { autocomplete="off" is ignored for saved addresses, so override the paint. */ input:-webkit-autofill, input:-webkit-autofill:hover, -input:-webkit-autofill:focus { +input:-webkit-autofill:focus, +textarea:-webkit-autofill, +textarea:-webkit-autofill:hover, +textarea:-webkit-autofill:focus { -webkit-text-fill-color: var(--be-text-0); caret-color: var(--be-text-0); -webkit-box-shadow: 0 0 0 1000px var(--be-bg-2) inset; diff --git a/src/components/basic/Counter/Counter.tsx b/src/components/basic/Counter/Counter.tsx index 77657020..3a7bfea4 100644 --- a/src/components/basic/Counter/Counter.tsx +++ b/src/components/basic/Counter/Counter.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import styles from './Counter.module.css' interface CounterProps { @@ -9,7 +9,9 @@ interface CounterProps { duration?: number } -// Animated number ticker from the design system. +// Animated number ticker from the design system. Interpolates from the +// PREVIOUS value (not from zero — a periodic refresh must not re-roll the +// balance through $0.00) and respects prefers-reduced-motion. const Counter = ({ value, decimals = 2, @@ -17,19 +19,34 @@ const Counter = ({ suffix = '', duration = 1200, }: CounterProps) => { - const [v, setV] = useState(0) + const [v, setV] = useState(value) + const previousValue = useRef(value) + const rafRef = useRef(0) useEffect(() => { + const reduceMotion = + typeof window.matchMedia === 'function' && + window.matchMedia('(prefers-reduced-motion: reduce)').matches + const from = previousValue.current + previousValue.current = value + + if (reduceMotion || from === value) { + // sync prop->state sync (documented React "adjust state when props + // change" pattern) — no animation needed + // eslint-disable-next-line react-hooks/set-state-in-effect + setV(value) + return + } + const start = performance.now() - let raf: number const tick = (t: number) => { const p = Math.min(1, (t - start) / duration) const eased = 1 - Math.pow(1 - p, 3) - setV(value * eased) - if (p < 1) raf = requestAnimationFrame(tick) + setV(from + (value - from) * eased) + if (p < 1) rafRef.current = requestAnimationFrame(tick) } - raf = requestAnimationFrame(tick) - return () => cancelAnimationFrame(raf) + rafRef.current = requestAnimationFrame(tick) + return () => cancelAnimationFrame(rafRef.current) }, [value, duration]) return ( diff --git a/src/components/basic/OptionCard/OptionCard.module.css b/src/components/basic/OptionCard/OptionCard.module.css index b03b1781..77063312 100644 --- a/src/components/basic/OptionCard/OptionCard.module.css +++ b/src/components/basic/OptionCard/OptionCard.module.css @@ -42,7 +42,7 @@ .description { font-size: 13px; - color: rgb(var(--color-dark-gray)); + color: var(--be-text-2); line-height: 1.4; } diff --git a/src/components/basic/Textarea/Textarea.css b/src/components/basic/Textarea/Textarea.css index 1d4f7b0a..ceaa2746 100644 --- a/src/components/basic/Textarea/Textarea.css +++ b/src/components/basic/Textarea/Textarea.css @@ -2,7 +2,7 @@ width: 100%; padding: 15px 15px 30px; resize: none; - border: 1px solid rgb(var(--color-light-gray)); + border: 1px solid var(--be-line); border-radius: var(--round-size); /* UA default would be white in light color-scheme. */ background: var(--be-bg-2); diff --git a/src/components/composed/AssetRow/AssetRow.module.css b/src/components/composed/AssetRow/AssetRow.module.css index 045f8255..0bdb3c00 100644 --- a/src/components/composed/AssetRow/AssetRow.module.css +++ b/src/components/composed/AssetRow/AssetRow.module.css @@ -52,3 +52,8 @@ color: var(--be-text-0); font-variant-numeric: tabular-nums; } + +.disabled { + opacity: 0.55; + cursor: not-allowed; +} diff --git a/src/components/composed/AssetRow/AssetRow.tsx b/src/components/composed/AssetRow/AssetRow.tsx index 6e7f5931..87e1ed60 100644 --- a/src/components/composed/AssetRow/AssetRow.tsx +++ b/src/components/composed/AssetRow/AssetRow.tsx @@ -16,6 +16,7 @@ export interface DesignAsset { mock?: boolean authority?: boolean iconUri?: string + disabled?: boolean onClick?: () => void index?: number } @@ -25,7 +26,7 @@ const AssetRow = ({ a }: { a: DesignAsset }) => { const fiat = a.price != null ? a.amount * a.price : undefined return (
    @@ -42,6 +43,7 @@ const AssetRow = ({ a }: { a: DesignAsset }) => { Token )} {a.authority && Issuer} + {a.disabled && Sync issue} {a.mock && Demo}
    diff --git a/src/components/composed/CopyButton/CopyButton.js b/src/components/composed/CopyButton/CopyButton.js index e7e70709..26deeb6d 100644 --- a/src/components/composed/CopyButton/CopyButton.js +++ b/src/components/composed/CopyButton/CopyButton.js @@ -9,11 +9,18 @@ const CopyButton = ({ content }) => { const [copied, setCopied] = useState(false) const handleCopy = () => { - if (content) { - navigator.clipboard.writeText(content) - setCopied(true) - setTimeout(() => setCopied(false), 1200) - } + if (!content) return + + navigator.clipboard + .writeText(content) + .then(() => { + setCopied(true) + setTimeout(() => setCopied(false), 1200) + }) + .catch(() => { + // clipboard can be denied (unfocused document, permissions) — never + // show a false "copied" confirmation + }) } return ( @@ -22,6 +29,7 @@ const CopyButton = ({ content }) => { onClick={handleCopy} type="button" data-testid="copy-btn" + aria-label="Copy to clipboard" > {copied ? ( diff --git a/src/components/composed/CopyButton/CopyButton.test.js b/src/components/composed/CopyButton/CopyButton.test.js index 453ffe7a..6e2c13c5 100644 --- a/src/components/composed/CopyButton/CopyButton.test.js +++ b/src/components/composed/CopyButton/CopyButton.test.js @@ -8,7 +8,7 @@ describe('CopyButton', () => { beforeEach(() => { Object.assign(navigator, { clipboard: { - writeText: jest.fn(), + writeText: jest.fn().mockResolvedValue(undefined), }, }) }) @@ -29,15 +29,17 @@ describe('CopyButton', () => { expect(navigator.clipboard.writeText).toHaveBeenCalledWith('test content') }) - it('shows success icon after copying', () => { + it('shows success icon after copying', async () => { render() fireEvent.click(screen.getByTestId('copy-btn')) + await act(async () => {}) // flush the clipboard promise expect(screen.getByTestId('success-icon')).toBeInTheDocument() }) - it('resets copied state after timeout', () => { + it('resets copied state after timeout', async () => { render() fireEvent.click(screen.getByTestId('copy-btn')) + await act(async () => {}) // flush the clipboard promise expect(screen.getByTestId('success-icon')).toBeInTheDocument() act(() => { jest.advanceTimersByTime(1200) @@ -51,3 +53,20 @@ describe('CopyButton', () => { expect(navigator.clipboard.writeText).not.toHaveBeenCalled() }) }) + +describe('CopyButton clipboard failures', () => { + it('never shows the success icon when the clipboard write rejects', async () => { + Object.assign(navigator, { + clipboard: { + writeText: jest.fn().mockRejectedValue(new Error('denied')), + }, + }) + jest.useRealTimers() + render() + fireEvent.click(screen.getByTestId('copy-btn')) + await act(async () => {}) + expect(screen.queryByTestId('success-icon')).not.toBeInTheDocument() + expect(screen.getByTestId('copy-icon')).toBeInTheDocument() + jest.useFakeTimers() + }) +}) diff --git a/src/components/composed/LockedBalanceList/LockedBalanceList.module.css b/src/components/composed/LockedBalanceList/LockedBalanceList.module.css index 37c8a044..65ac14dd 100644 --- a/src/components/composed/LockedBalanceList/LockedBalanceList.module.css +++ b/src/components/composed/LockedBalanceList/LockedBalanceList.module.css @@ -40,7 +40,7 @@ .headerText p { margin: 4px 0 0; font-size: 13px; - color: rgb(var(--color-dark-gray)); + color: var(--be-text-2); line-height: 1.4; } @@ -59,7 +59,7 @@ font-size: 11px; font-weight: 600; letter-spacing: 0.5px; - color: rgb(var(--color-dark-gray)); + color: var(--be-text-2); text-transform: uppercase; margin: 0; } @@ -74,7 +74,7 @@ .summaryValue span { font-size: 14px; font-weight: 500; - color: rgb(var(--color-dark-gray)); + color: var(--be-text-2); margin-left: 4px; } @@ -106,7 +106,7 @@ align-items: center; gap: 6px; font-size: 12px; - color: rgb(var(--color-dark-gray)); + color: var(--be-text-2); padding: 0 4px; } diff --git a/src/components/composed/LockedBalanceList/LockedBalanceListItem.module.css b/src/components/composed/LockedBalanceList/LockedBalanceListItem.module.css index 812d8720..374efd5c 100644 --- a/src/components/composed/LockedBalanceList/LockedBalanceListItem.module.css +++ b/src/components/composed/LockedBalanceList/LockedBalanceListItem.module.css @@ -50,7 +50,7 @@ .blockBadge { font-size: 11px; font-weight: 600; - color: rgb(var(--color-dark-gray)); + color: var(--be-text-2); background: rgb(var(--color-extra-light-gray)); border-radius: 6px; padding: 3px 8px; @@ -61,7 +61,7 @@ .blocksLeft { font-size: 12px; - color: rgb(var(--color-dark-gray)); + color: var(--be-text-2); } .cardAmount { @@ -74,7 +74,7 @@ .cardAmount span { font-size: 13px; font-weight: 500; - color: rgb(var(--color-dark-gray)); + color: var(--be-text-2); margin-left: 3px; } diff --git a/src/components/containers/DeleteAccount/DeleteAccount.module.css b/src/components/containers/DeleteAccount/DeleteAccount.module.css index d192e686..45a28b94 100644 --- a/src/components/containers/DeleteAccount/DeleteAccount.module.css +++ b/src/components/containers/DeleteAccount/DeleteAccount.module.css @@ -35,7 +35,7 @@ .subtitle { font-size: 14px; - color: rgb(var(--color-dark-gray)); + color: var(--be-text-2); text-align: center; margin-bottom: 24px; } diff --git a/src/components/containers/RestoreAccount/RestoreAccountJson/FileUpload.module.css b/src/components/containers/RestoreAccount/RestoreAccountJson/FileUpload.module.css index 6889d88d..9028daba 100644 --- a/src/components/containers/RestoreAccount/RestoreAccountJson/FileUpload.module.css +++ b/src/components/containers/RestoreAccount/RestoreAccountJson/FileUpload.module.css @@ -9,7 +9,7 @@ .subtitle { font-size: var(--font-size-lg); text-align: center; - color: rgb(var(--color-dark-gray)); + color: var(--be-text-2); margin-bottom: var(--space-2xl); } @@ -63,7 +63,7 @@ .uploadIcon { width: 24px; height: 24px; - color: rgb(var(--color-dark-gray)); + color: var(--be-text-2); } .dropzoneText { @@ -75,7 +75,7 @@ .dropzoneHint { font-size: var(--font-size-md); - color: rgb(var(--color-dark-gray)); + color: var(--be-text-2); text-align: center; } @@ -95,7 +95,7 @@ .uploadedChange { font-size: var(--font-size-sm); - color: rgb(var(--color-dark-gray)); + color: var(--be-text-2); text-align: center; } diff --git a/src/components/containers/RestoreAccount/RestoreAccountJson/RestoreSuccess.module.css b/src/components/containers/RestoreAccount/RestoreAccountJson/RestoreSuccess.module.css index 479b1951..c71231d1 100644 --- a/src/components/containers/RestoreAccount/RestoreAccountJson/RestoreSuccess.module.css +++ b/src/components/containers/RestoreAccount/RestoreAccountJson/RestoreSuccess.module.css @@ -8,7 +8,7 @@ .description { font-size: var(--font-size-lg); text-align: center; - color: rgb(var(--color-dark-gray)); + color: var(--be-text-2); line-height: 1.5; } diff --git a/src/components/containers/RestoreAccount/RestoreAccountJson/WalletDetails.module.css b/src/components/containers/RestoreAccount/RestoreAccountJson/WalletDetails.module.css index 3c1bf9ab..ad8ae445 100644 --- a/src/components/containers/RestoreAccount/RestoreAccountJson/WalletDetails.module.css +++ b/src/components/containers/RestoreAccount/RestoreAccountJson/WalletDetails.module.css @@ -12,7 +12,7 @@ .subtitle { font-size: var(--font-size-lg); text-align: center; - color: rgb(var(--color-dark-gray)); + color: var(--be-text-2); @media (min-width: 901px) { margin-bottom: var(--space-2xl); @@ -60,7 +60,7 @@ font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; - color: rgb(var(--color-dark-gray)); + color: var(--be-text-2); margin-bottom: 0.125rem; } @@ -102,5 +102,5 @@ .infoBannerText { font-size: var(--font-size-md); - color: rgb(var(--color-dark-gray)); + color: var(--be-text-2); } diff --git a/src/components/containers/Settings/SettingsAbout/SettingsAbout.module.css b/src/components/containers/Settings/SettingsAbout/SettingsAbout.module.css index 48d5def5..348f4b8e 100644 --- a/src/components/containers/Settings/SettingsAbout/SettingsAbout.module.css +++ b/src/components/containers/Settings/SettingsAbout/SettingsAbout.module.css @@ -27,7 +27,7 @@ .value { font-size: 13px; - color: rgb(var(--color-dark-gray)); + color: var(--be-text-2); } .chevron { diff --git a/src/components/containers/Settings/SettingsBackup/SettingsBackup.css b/src/components/containers/Settings/SettingsBackup/SettingsBackup.css index 9da86af1..28307854 100644 --- a/src/components/containers/Settings/SettingsBackup/SettingsBackup.css +++ b/src/components/containers/Settings/SettingsBackup/SettingsBackup.css @@ -11,7 +11,7 @@ .backup-description h2 { font-size: 14px; letter-spacing: 0.05em; - color: rgb(var(--color-dark-gray)); + color: var(--be-text-2); margin-bottom: 6px; } diff --git a/src/components/containers/Settings/SettingsSection/SettingsSection.module.css b/src/components/containers/Settings/SettingsSection/SettingsSection.module.css index 1daba54d..239b51aa 100644 --- a/src/components/containers/Settings/SettingsSection/SettingsSection.module.css +++ b/src/components/containers/Settings/SettingsSection/SettingsSection.module.css @@ -9,7 +9,7 @@ font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; - color: rgb(var(--color-dark-gray)); + color: var(--be-text-2); margin-bottom: 8px; padding-left: 4px; } diff --git a/src/components/containers/Settings/SettingsTestnet/SettingsTestnet.module.css b/src/components/containers/Settings/SettingsTestnet/SettingsTestnet.module.css index 62159aa7..92143566 100644 --- a/src/components/containers/Settings/SettingsTestnet/SettingsTestnet.module.css +++ b/src/components/containers/Settings/SettingsTestnet/SettingsTestnet.module.css @@ -26,7 +26,7 @@ background: rgb(var(--color-black), 0.05); font-size: 14px; font-weight: 600; - color: rgb(var(--color-dark-gray)); + color: var(--be-text-2); cursor: pointer; transition: all 0.25s ease; } diff --git a/src/pages/Dashboard/Dashboard.js b/src/pages/Dashboard/Dashboard.js index a81560cc..9ff25dc5 100644 --- a/src/pages/Dashboard/Dashboard.js +++ b/src/pages/Dashboard/Dashboard.js @@ -226,6 +226,7 @@ const DashboardPage = () => { change24h: Number(c.change24h) || 0, spark: Object.values(c.historyRates || {}), disabled: c.disabled, + onClick: c.disabled ? undefined : () => navigate('/asset/' + c.id), })) const onConnectItemClick = (walletType) => { @@ -256,12 +257,9 @@ const DashboardPage = () => { const renderAssetRow = (a, index) => (
    !a.disabled && navigate('/asset/' + a.id)} - data-testid="crypto-item" + style={{ animationDelay: `${index * 50}ms` }} > -
    - -
    +
    ) @@ -409,6 +407,7 @@ const DashboardPage = () => { spark: [], iconUri: tokenBalances[c.id]?.token_info?.icon_uri?.string, + onClick: () => navigate('/asset/' + c.id), }, coinAssets.length + i, ), diff --git a/src/pages/RestoreAccount/RestoreAccount.module.css b/src/pages/RestoreAccount/RestoreAccount.module.css index 8594b8fe..e7615bf2 100644 --- a/src/pages/RestoreAccount/RestoreAccount.module.css +++ b/src/pages/RestoreAccount/RestoreAccount.module.css @@ -19,7 +19,7 @@ .subtitle { font-size: var(--font-size-md); - color: rgb(var(--color-dark-gray)); + color: var(--be-text-2); text-align: center; margin-bottom: var(--space-3xl); } diff --git a/src/pages/SignBitcoinTransaction/SignBitcoinTransaction.js b/src/pages/SignBitcoinTransaction/SignBitcoinTransaction.js index 2107dd8d..ff570f92 100644 --- a/src/pages/SignBitcoinTransaction/SignBitcoinTransaction.js +++ b/src/pages/SignBitcoinTransaction/SignBitcoinTransaction.js @@ -162,6 +162,24 @@ export const SignBitcoinTransactionPage = () => { } const submitCreate = async () => { + // Fail-closed network guard (same contract as ML signing): a session + // without a recorded network must reconnect before signing. + const grantedNetwork = state?.request?.network + if (!grantedNetwork || grantedNetwork !== networkType) { + sendPopupResponse({ + method: 'signTransaction_reject', + requestId: state?.request?.requestId, + origin: state?.request?.origin, + error: { + code: 'WRONG_NETWORK', + message: grantedNetwork + ? `Wrong network: this site was connected on '${grantedNetwork}' but the wallet is now on '${networkType}'. Switch the wallet network or reconnect the site.` + : 'This site was connected before the wallet recorded its network. Reconnect the site and approve again.', + }, + }) + return + } + const pass = password const transactionJSONrepresentation = @@ -240,6 +258,23 @@ export const SignBitcoinTransactionPage = () => { const submitSpend = async () => { const pass = password + // Fail-closed network guard (same contract as ML signing). + const grantedNetwork = state?.request?.network + if (!grantedNetwork || grantedNetwork !== networkType) { + sendPopupResponse({ + method: 'signTransaction_reject', + requestId: state?.request?.requestId, + origin: state?.request?.origin, + error: { + code: 'WRONG_NETWORK', + message: grantedNetwork + ? `Wrong network: this site was connected on '${grantedNetwork}' but the wallet is now on '${networkType}'. Switch the wallet network or reconnect the site.` + : 'This site was connected before the wallet recorded its network. Reconnect the site and approve again.', + }, + }) + return + } + const transactionJSONrepresentation = state?.request?.data?.txData?.JSONRepresentation @@ -302,6 +337,23 @@ export const SignBitcoinTransactionPage = () => { const submitRefund = async () => { const pass = password + // Fail-closed network guard (same contract as ML signing). + const grantedNetwork = state?.request?.network + if (!grantedNetwork || grantedNetwork !== networkType) { + sendPopupResponse({ + method: 'signTransaction_reject', + requestId: state?.request?.requestId, + origin: state?.request?.origin, + error: { + code: 'WRONG_NETWORK', + message: grantedNetwork + ? `Wrong network: this site was connected on '${grantedNetwork}' but the wallet is now on '${networkType}'. Switch the wallet network or reconnect the site.` + : 'This site was connected before the wallet recorded its network. Reconnect the site and approve again.', + }, + }) + return + } + const transactionJSONrepresentation = state?.request?.data?.txData?.JSONRepresentation diff --git a/src/pages/SignChallenge/SignChallenge.js b/src/pages/SignChallenge/SignChallenge.js index 74cc2a79..5f987b2e 100644 --- a/src/pages/SignChallenge/SignChallenge.js +++ b/src/pages/SignChallenge/SignChallenge.js @@ -42,6 +42,25 @@ export const SignChallengePage = () => { setIsSigning(true) setSignError('') + // Fail-closed network guard (same contract as transaction signing): + // a session without a recorded network is a pre-upgrade grant. + const grantedNetwork = state?.request?.network + if (!grantedNetwork || grantedNetwork !== networkType) { + setIsSigning(false) + sendPopupResponse({ + method: 'signChallenge_reject', + requestId: state?.request?.requestId, + origin: state?.request?.origin, + error: { + code: 'WRONG_NETWORK', + message: grantedNetwork + ? `Wrong network: this site was connected on '${grantedNetwork}' but the wallet is now on '${networkType}'. Switch the wallet network or reconnect the site.` + : 'This site was connected before the wallet recorded its network. Reconnect the site and approve again.', + }, + }) + return + } + try { const message = state?.request?.data?.message const address = diff --git a/src/pages/SignExternalTransaction/SignExternalTransaction.css b/src/pages/SignExternalTransaction/SignExternalTransaction.css index 6b573121..7ea5f4fc 100644 --- a/src/pages/SignExternalTransaction/SignExternalTransaction.css +++ b/src/pages/SignExternalTransaction/SignExternalTransaction.css @@ -92,12 +92,12 @@ } .SignTransaction .footer { - position: absolute; - width: 100%; + /* in flow: an absolutely-positioned footer inside this scroll container + scrolled away with the content on long transactions */ display: flex; justify-content: center; gap: 12px; - bottom: 0; + padding-top: 14px; flex-shrink: 0; } diff --git a/src/pages/SignExternalTransaction/SignExternalTransaction.js b/src/pages/SignExternalTransaction/SignExternalTransaction.js index 1da1a3ee..5c104e53 100644 --- a/src/pages/SignExternalTransaction/SignExternalTransaction.js +++ b/src/pages/SignExternalTransaction/SignExternalTransaction.js @@ -172,10 +172,10 @@ export const SignTransactionPage = () => { setSignError('') // Wrong-chain guard: the session records the network the site was - // granted on. If the wallet has since been switched, refuse instead of - // silently signing with keys for the other chain. + // granted on. Fail CLOSED — a session without a recorded network is a + // pre-upgrade grant and must reconnect before signing. const grantedNetwork = state?.request?.network - if (grantedNetwork && grantedNetwork !== networkType) { + if (!grantedNetwork || grantedNetwork !== networkType) { setIsSigning(false) sendPopupResponse({ method: 'signTransaction_reject', @@ -183,7 +183,9 @@ export const SignTransactionPage = () => { origin: state?.request?.origin, error: { code: 'WRONG_NETWORK', - message: `Wrong network: this site was connected on '${grantedNetwork}' but the wallet is now on '${networkType}'. Switch the wallet network or reconnect the site.`, + message: grantedNetwork + ? `Wrong network: this site was connected on '${grantedNetwork}' but the wallet is now on '${networkType}'. Switch the wallet network or reconnect the site.` + : 'This site was connected before the wallet recorded its network. Reconnect the site and approve again.', }, }) return diff --git a/src/services/API/Mintlayer/Mintlayer.js b/src/services/API/Mintlayer/Mintlayer.js index 0448de42..5e95535e 100644 --- a/src/services/API/Mintlayer/Mintlayer.js +++ b/src/services/API/Mintlayer/Mintlayer.js @@ -352,16 +352,21 @@ const IPFS_GATEWAY = IPFS_GATEWAYS[0] const fromIpfs = (uri) => uri.startsWith('ipfs://') ? uri.replace('ipfs://', `${IPFS_GATEWAY}/`) : uri +// Token metadata is issuer-controlled: only ipfs:// metadata documents are +// resolved (through the fixed gateway list) and only gateway-hosted icons +// are returned. Arbitrary https/http metadata or icon urls would turn token +// issuance into a request-forgery/tracking vector from the wallet UI. +const isAllowedIpfsUri = (uri) => + typeof uri === 'string' && uri.startsWith('ipfs://') + const NEGATIVE_CACHE_TTL_MS = 5 * 60 * 1000 const tokenIconCache = new Map() // metadata uri -> { value, expires? } const failedLookupsLogged = new Set() -const fetchJsonWithGatewayFallback = async (uri) => { - const candidates = uri.startsWith('ipfs://') - ? IPFS_GATEWAYS.map( - (gateway) => `${gateway}/${uri.slice('ipfs://'.length)}`, - ) - : [uri] +const fetchJsonWithGatewayFallback = async (metadataUri) => { + const candidates = IPFS_GATEWAYS.map( + (gateway) => `${gateway}/${metadataUri.slice('ipfs://'.length)}`, + ) const attempts = candidates.map(async (candidate) => { // Timeout: this runs inside the wallet data refresh and gateways can @@ -383,7 +388,7 @@ const fetchJsonWithGatewayFallback = async (uri) => { } const resolveTokenIcon = async (metadataUri) => { - if (!metadataUri) return undefined + if (!isAllowedIpfsUri(metadataUri)) return undefined const cached = tokenIconCache.get(metadataUri) if (cached) { @@ -399,7 +404,10 @@ const resolveTokenIcon = async (metadataUri) => { if (metadata) { const raw = metadata.tokenIcon || metadata.icon_uri || metadata.icon - if (raw && typeof raw === 'string') { + // Scheme allowlist: ipfs:// maps to a gateway, https:// renders as-is. + // http:// (cleartext/internal-network) and exotic schemes are rejected — + // token metadata is issuer-controlled. + if (raw && typeof raw === 'string' && /^(ipfs|https):\/\//.test(raw)) { const iconUrl = fromIpfs(raw) tokenIconCache.set(metadataUri, { value: iconUrl }) return iconUrl From a23597cda6fa40b6304e5d1be940e588e7395f74 Mon Sep 17 00:00:00 2001 From: Enrico Rubboli Date: Tue, 8 Sep 2026 12:52:00 +0200 Subject: [PATCH 28/52] fix(connect): only share Bitcoin data when the site asked for it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit provideBitcoinData defaulted to true while the Bitcoin opt-out toggle renders only for sites requesting the 'bitcoin' permission — a site that never asked silently received every BTC address and public key with no way for the user to see or refuse it. Default now follows requireBTC (review comment #2). --- src/pages/ConnectionPage/ConnectionPage.js | 6 +++- .../ConnectionPage/ConnectionPage.test.js | 31 +++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/pages/ConnectionPage/ConnectionPage.js b/src/pages/ConnectionPage/ConnectionPage.js index 5ed215a8..3d80619f 100644 --- a/src/pages/ConnectionPage/ConnectionPage.js +++ b/src/pages/ConnectionPage/ConnectionPage.js @@ -30,7 +30,6 @@ export const ConnectionPage = () => { const { state: external_state } = useLocation() const { addresses } = useContext(AccountContext) const { networkType } = useContext(SettingsContext) - const [provideBitcoinData, setProvideBitcoinData] = useState(true) const state = external_state const origin = state?.request?.origin || UNKNOWN_WEBSITE @@ -39,6 +38,11 @@ export const ConnectionPage = () => { const requireBTC = permissions.includes('bitcoin') const isUnknownOrigin = origin === UNKNOWN_WEBSITE + // Only include Bitcoin data when the site actually asked for the + // 'bitcoin' permission AND the user keeps the toggle on — the toggle only + // renders for such sites, so the default must match. + const [provideBitcoinData, setProvideBitcoinData] = useState(requireBTC) + const ml = addresses?.mlAddresses ?? {} const btc = addresses?.btcAddresses ?? {} diff --git a/src/pages/ConnectionPage/ConnectionPage.test.js b/src/pages/ConnectionPage/ConnectionPage.test.js index d522db8c..76f809f5 100644 --- a/src/pages/ConnectionPage/ConnectionPage.test.js +++ b/src/pages/ConnectionPage/ConnectionPage.test.js @@ -156,6 +156,37 @@ describe('ConnectionPage', () => { ]) }) + it('omits the bitcoin block when the site never asked for the bitcoin permission', () => { + const addresses = { + mlAddresses: { + mlReceivingAddresses: ['mtc1qnew'], + mlChangeAddresses: ['mtc1qnewc'], + mlReceivingPublicKeys: [{ 1: 2 }], + mlChangePublicKeys: [{ 5: 6 }], + }, + btcAddresses: { + btcReceivingAddresses: [{ bc1qnew: { pubkey: { 1: 2 } } }], + btcChangeAddresses: [{ bc1qnewc: { pubkey: { 3: 4 } } }], + }, + } + + // no 'bitcoin' entry in the requested permissions + renderWithAddresses(addresses, { + request: { + origin: 'https://bridge.example', + requestId: 'r1', + permissions: [], + }, + }) + + fireEvent.click(screen.getByTestId('connect-button')) + + const payload = sendPopupResponse.mock.calls[0][0] + // the wallet must not silently hand out bitcoin addresses/keys + expect(payload.result.addressesByChain.bitcoin).toBeUndefined() + expect(payload.result.address.mainnet.receiving).toEqual(['mtc1qnew']) + }) + it('omits the bitcoin block when no BTC address data exists', () => { const addresses = { mlAddresses: { mlReceivingAddresses: ['mtc1qonlyml'] }, From 9d7beb11d588f9d36950099b4612322c167de500 Mon Sep 17 00:00:00 2001 From: Enrico Rubboli Date: Tue, 8 Sep 2026 13:14:30 +0200 Subject: [PATCH 29/52] fix(bridge): per-window pending approval requests (review blocker) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pendingRequest was a single storage key written by two independent approval slots: a connect window (origin A) and a signing window (origin B) could be open at once, each overwriting the other's record, and the popup re-read the key on every effect run — so a later request from any origin could replace the one the user was about to approve (approve-the-wrong- request hazard). - approvals are now stored per window: pendingRequest: - the popup reads its OWN window's key (windows.getCurrent before the read) - popupResponse carries windowId and only clears its own record - messages are queued until the persisted session map finishes loading — a connect answered against a half-loaded map told already-connected sites they were NOT_CONNECTED - stale pre-window-keyed records are cleaned up at boot - windows.get lookup checks runtime.lastError (review comment: unchecked lastError console noise) --- public/background.js | 85 +++++++++++++++++++--------- public/background.test.js | 67 +++++++++++++++++++++- src/index.js | 31 ++++++---- src/services/Browser/Browser.js | 54 +++++++++++------- src/services/Browser/Browser.test.js | 22 ++++--- 5 files changed, 188 insertions(+), 71 deletions(-) diff --git a/public/background.js b/public/background.js index 0c251374..d6efc181 100644 --- a/public/background.js +++ b/public/background.js @@ -35,17 +35,31 @@ }) } - // Load connected sites from storage + // Load connected sites from storage. Messages arriving before the load + // completes are queued: acting on a half-loaded session map would tell + // already-connected sites they are NOT_CONNECTED. + let connectedSitesLoaded = false + const messageQueue = [] api.storage.local.get(['connectedSites'], (data) => { if (api.runtime.lastError) { console.error('[Mintlayer] Storage get error:', api.runtime.lastError) - return + } else { + connectedSites = data.connectedSites || {} + } + // one-time cleanup of the pre-window-keyed pending request + api.storage.local.remove('pendingRequest') + connectedSitesLoaded = true + for (const [queuedMessage, queuedSender, queuedResponse] of messageQueue) { + processMessage(queuedMessage, queuedSender, queuedResponse) } - connectedSites = data.connectedSites || {} + messageQueue.length = 0 }) - const clearPendingRequest = () => { - api.storage.local.remove('pendingRequest', () => { + const pendingRequestKeyFor = (windowId) => `pendingRequest:${windowId}` + + const clearPendingRequest = (windowId) => { + if (typeof windowId !== 'number') return + api.storage.local.remove(pendingRequestKeyFor(windowId), () => { if (api.runtime.lastError) { console.error( '[Mintlayer] Storage remove error:', @@ -88,6 +102,7 @@ // Fails a pending approval: clears the slot and answers the waiting dApp. const failSlot = (slot, error) => { + const windowId = slot.id slot.id = false slot.opening = false @@ -96,7 +111,7 @@ const respond = pendingResponses.get(slot.requestId) pendingResponses.delete(slot.requestId) slot.requestId = null - clearPendingRequest() + clearPendingRequest(windowId) respond?.({ error }) } @@ -138,26 +153,34 @@ // The window may have been closed while it was being created, in // which case onRemoved fired before the id was tracked. api.windows.get(slot.id, (existing) => { - if (!existing) { + if (api.runtime.lastError || !existing) { + // check lastError: a failed lookup used to log + // "Unchecked runtime.lastError" alongside the intended path failSlot(slot, errorOf('REQUEST_CANCELLED', 'Request cancelled')) return } - api.storage.local.set({ pendingRequest: request }, () => { - if (api.runtime.lastError) { - console.error( - '[Mintlayer] Storage set error:', - api.runtime.lastError, - ) - failSlot( - slot, - errorOf( - 'STORAGE_ERROR', - 'Could not create the wallet request. Please try again.', - ), - ) - } - }) + // Keyed by window id: two approval windows can be open at once + // (a connect and a signing) and must never overwrite each other's + // request — the popup approves what ITS window was opened for. + api.storage.local.set( + { [pendingRequestKeyFor(slot.id)]: request }, + () => { + if (api.runtime.lastError) { + console.error( + '[Mintlayer] Storage set error:', + api.runtime.lastError, + ) + failSlot( + slot, + errorOf( + 'STORAGE_ERROR', + 'Could not create the wallet request. Please try again.', + ), + ) + } + }, + ) }) }, ) @@ -167,10 +190,10 @@ // Handle popup responses from the wallet UI const handlePopupResponse = (message) => { - const { requestId, origin, result, error, method } = message + const { requestId, origin, result, error, method, windowId } = message const respond = pendingResponses.get(requestId) pendingResponses.delete(requestId) - clearPendingRequest() + clearPendingRequest(windowId) if (!respond) { console.warn('[Mintlayer] Response for unknown request:', requestId) @@ -219,8 +242,7 @@ respond({ result, error }) } - // Single listener for all messages - api.runtime.onMessage.addListener((message, sender, sendResponse) => { + const processMessage = (message, sender, sendResponse) => { const origin = getRequestOrigin(sender) // Wallet-UI-only actions. These must be checked before dApp requests: @@ -377,6 +399,17 @@ } return false + } + + // Single listener for all messages. Queue everything until the persisted + // session map is loaded — answering a "connect" against a half-loaded map + // would tell already-connected sites they are not connected. + api.runtime.onMessage.addListener((message, sender, sendResponse) => { + if (!connectedSitesLoaded) { + messageQueue.push([message, sender, sendResponse]) + return true + } + return processMessage(message, sender, sendResponse) }) // Clean up window state and answer waiting dApps when an approval window diff --git a/public/background.test.js b/public/background.test.js index 9945f271..8877c450 100644 --- a/public/background.test.js +++ b/public/background.test.js @@ -121,7 +121,8 @@ describe('background service worker', () => { expect(reply.current).toBeUndefined() expect(keptOpen).toBe(true) expect(createdWindows).toHaveLength(1) - expect(storageData.pendingRequest).toMatchObject({ + // keyed by the window that will show the approval + expect(storageData['pendingRequest:700']).toMatchObject({ action: 'connect', origin: 'https://bridge.example', requestId: 'r1', @@ -140,6 +141,7 @@ describe('background service worker', () => { method: 'connect', requestId: 'r1', origin: 'https://bridge.example', + windowId: 700, result: sessionData, }, extensionSender, @@ -154,7 +156,7 @@ describe('background service worker', () => { network: 'testnet', }) // the pending request is consumed - expect(storageData.pendingRequest).toBeUndefined() + expect(storageData['pendingRequest:700']).toBeUndefined() }) it('rejects with USER_REJECTED when the wallet denies', () => { @@ -168,6 +170,7 @@ describe('background service worker', () => { method: 'connect', requestId: 'r2', origin: 'https://bridge.example', + windowId: 700, result: null, }, extensionSender, @@ -190,6 +193,7 @@ describe('background service worker', () => { method: 'connect', requestId: 'r1', origin: 'https://bridge.example', + windowId: 700, result: sessionData, }, extensionSender, @@ -238,6 +242,7 @@ describe('background service worker', () => { method: 'connect', requestId: 'r1', origin: 'https://bridge.example', + windowId: 700, result: sessionData, }, extensionSender, @@ -255,7 +260,7 @@ describe('background service worker', () => { ) expect(keptOpen).toBe(true) - expect(storageData.pendingRequest).toMatchObject({ + expect(storageData['pendingRequest:701']).toMatchObject({ action: 'signTransaction', network: 'testnet', data: { txData: { JSONRepresentation: {} } }, @@ -289,6 +294,7 @@ describe('background service worker', () => { method: 'connect', requestId: 'r1', origin: 'https://bridge.example', + windowId: 700, result: sessionData, }, extensionSender, @@ -313,6 +319,7 @@ describe('background service worker', () => { method: 'connect', requestId: 'r1', origin: 'https://bridge.example', + windowId: 700, result: sessionData, }, extensionSender, @@ -353,6 +360,7 @@ describe('background service worker', () => { method: 'connect', requestId: 'r1', origin: 'https://bridge.example', + windowId: 700, result: sessionData, }, extensionSender, @@ -378,4 +386,57 @@ describe('background service worker', () => { }) }) }) + describe('concurrent approval windows (the blocker scenario)', () => { + it('a connect window and a signing window keep separate pending requests', () => { + // origin A opens a connect approval (window 700) and approves it + dispatch( + { requestId: 'r1', method: 'connect', params: {} }, + { + id: 'site-a', + origin: 'https://a.example', + url: 'https://a.example/', + }, + ) + dispatch( + { + action: 'popupResponse', + method: 'connect', + requestId: 'r1', + origin: 'https://a.example', + windowId: 700, + result: { + address: { testnet: { receiving: ['tmtc1qabc'], change: [] } }, + addressesByChain: { + mintlayer: { receiving: ['tmtc1qabc'], change: [] }, + }, + network: 'testnet', + }, + }, + extensionSender, + ) + // while A's connect window is pending, a signing approval opens + // (window 701) for the same origin + dispatch( + { + requestId: 's1', + method: 'signTransaction', + params: { txData: { JSONRepresentation: {} } }, + }, + { + id: 'site-a', + origin: 'https://a.example', + url: 'https://a.example/', + }, + ) + + // the connect request was consumed on approval; the signing window + // has its own record — the two never overwrote each other + expect(storageData['pendingRequest:700']).toBeUndefined() + expect(storageData['pendingRequest:701']).toMatchObject({ + action: 'signTransaction', + requestId: 's1', + network: 'testnet', + }) + }) + }) }) diff --git a/src/index.js b/src/index.js index 79edde81..7a6af2d8 100644 --- a/src/index.js +++ b/src/index.js @@ -80,7 +80,7 @@ if (isExtendedView) { document.documentElement.classList.add('extended-view') } -const { storage, runtime } = Browser +const { storage, runtime, windows } = Browser const App = () => { const [errorPopupOpen, setErrorPopupOpen] = useState(false) @@ -170,19 +170,26 @@ const App = () => { }, [location.pathname]) useEffect(() => { - if (storage) { - // Load pending request from storage - storage.local.get(['pendingRequest'], (data) => { - if (runtime.lastError) { - console.error('[Mojito Popup] Storage error:', runtime.lastError) - return - } - const pendingRequest = data.pendingRequest - if (pendingRequest) { - handlePendingRequest(pendingRequest) - } + let cancelled = false + if (storage && windows) { + // Read THIS window's pending request. Approval requests are keyed by + // window id, so two approval windows can never read (and approve) + // each other's request. + windows.getCurrent((win) => { + if (cancelled || !win) return + const key = `pendingRequest:${win.id}` + storage.local.get([key], (data) => { + if (cancelled) return + const pendingRequest = data?.[key] + if (pendingRequest) { + handlePendingRequest(pendingRequest) + } + }) }) } + return () => { + cancelled = true + } // eslint-disable-next-line react-hooks/exhaustive-deps }, [addresses, isAccountUnlocked, navigate]) diff --git a/src/services/Browser/Browser.js b/src/services/Browser/Browser.js index ae4c0c15..543f8f4d 100644 --- a/src/services/Browser/Browser.js +++ b/src/services/Browser/Browser.js @@ -10,6 +10,8 @@ const api = export const runtime = api?.runtime ?? null +export const windows = api?.windows ?? null + export const storage = api?.storage ?? null // Answers the dApp request that opened this approval window, clears the @@ -23,29 +25,39 @@ export const sendPopupResponse = ({ }) => { if (!runtime || !storage) return + // The background clears the per-window pendingRequest entry when it + // processes this response (keyed by THIS window's id, passed along so a + // response can only ever clear its own request). const cleanup = () => { - storage.local.remove('pendingRequest', () => { - window.close() - // Fallback for contexts where window.close() is ignored (e.g. the - // approval page opened as a tab in dev): go back to the wallet. - setTimeout(() => { - if (!window.closed) window.location.replace('/') - }, 150) - }) + window.close() + // Fallback for contexts where window.close() is ignored (e.g. the + // approval page opened as a tab in dev): go back to the wallet. + setTimeout(() => { + if (!window.closed) window.location.replace('/') + }, 150) + } + + const send = (windowId) => { + try { + runtime.sendMessage( + { + action: 'popupResponse', + method, + requestId, + origin, + windowId, + ...(error ? { error } : { result }), + }, + cleanup, + ) + } catch { + cleanup() + } } - try { - runtime.sendMessage( - { - action: 'popupResponse', - method, - requestId, - origin, - ...(error ? { error } : { result }), - }, - cleanup, - ) - } catch { - cleanup() + if (windows?.getCurrent) { + windows.getCurrent((win) => send(win?.id ?? null)) + } else { + send(null) } } diff --git a/src/services/Browser/Browser.test.js b/src/services/Browser/Browser.test.js index 86e12b66..90e1137c 100644 --- a/src/services/Browser/Browser.test.js +++ b/src/services/Browser/Browser.test.js @@ -17,16 +17,19 @@ describe('Browser', () => { }) it('picks the chrome APIs when browser is not available', () => { + const windows = { getCurrent: jest.fn() } const chromeMock = { runtime: { id: 'test-id', sendMessage: jest.fn() }, storage: { local: { remove: jest.fn() } }, + windows, } global.chrome = chromeMock - const { runtime, storage } = loadBrowserModule() + const { runtime, storage, windows: windowsApi } = loadBrowserModule() expect(runtime).toBe(chromeMock.runtime) expect(storage).toBe(chromeMock.storage) + expect(windowsApi).toBe(chromeMock.windows) }) it('picks the browser APIs when available', () => { @@ -49,10 +52,12 @@ describe('Browser', () => { expect(storage).toBeNull() }) - it('sends a result response and clears the pending request', () => { + it('sends a result response tagged with the popup window id', async () => { + const windows = { getCurrent: jest.fn((cb) => cb({ id: 42 })) } const chromeMock = { runtime: { id: 'test-id', sendMessage: jest.fn() }, storage: { local: { remove: jest.fn() } }, + windows, } global.chrome = chromeMock @@ -64,29 +69,27 @@ describe('Browser', () => { origin: 'https://dapp.example', result: { address: {} }, }) + await Promise.resolve() // flush the windows.getCurrent callback + expect(windows.getCurrent).toHaveBeenCalled() expect(chromeMock.runtime.sendMessage).toHaveBeenCalledWith( { action: 'popupResponse', method: 'connect', requestId: 'r1', origin: 'https://dapp.example', + windowId: 42, result: { address: {} }, }, expect.any(Function), ) - - chromeMock.runtime.sendMessage.mock.calls[0][1]() - expect(chromeMock.storage.local.remove).toHaveBeenCalledWith( - 'pendingRequest', - expect.any(Function), - ) }) - it('sends an error response without a result field', () => { + it('sends an error response without a result field', async () => { const chromeMock = { runtime: { id: 'test-id', sendMessage: jest.fn() }, storage: { local: { remove: jest.fn() } }, + windows: { getCurrent: jest.fn((cb) => cb({ id: 7 })) }, } global.chrome = chromeMock @@ -98,6 +101,7 @@ describe('Browser', () => { origin: 'https://dapp.example', error: 'Transaction rejected', }) + await Promise.resolve() const payload = chromeMock.runtime.sendMessage.mock.calls[0][0] expect(payload.error).toBe('Transaction rejected') From 4753f3750b567cb8b3d1ba0805341a2966ebdb2f Mon Sep 17 00:00:00 2001 From: Enrico Rubboli Date: Tue, 8 Sep 2026 13:24:41 +0200 Subject: [PATCH 30/52] fix(sign-btc): repair the dApp BTC/HTLC signing call (review issue) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit submitCreate was broken three ways and could never have worked: - it destructured { WIF } from Account.unlockAccount, which does not return a WIF — always undefined - buildTransaction was called with {fee, wif, from, networkType} but its signature is {to, amount, utxos, feeRate, walletType, changeAddress, root} (it throws 'reading btcAddressData' before anything else) - it destructured three elements from a function that returns two, so transactionId was always undefined Now: the HTLC script is built without key material, the funding transaction is built exactly like ConfirmBtcTransaction does (wallet UTXOs, feeRate, walletType, change address, HD root from unlockAccount), the destructure matches the real [tx, hex] return, and the transaction id is derived from the signed hex. NEEDS MANUAL VERIFICATION: the full HTLC create/spend/refund flow still requires an end-to-end run against a dApp (see REVIEW-PLAN.md) — the claim/refund signers derive their WIF differently and are unchanged. --- .../SignBitcoinTransaction.js | 55 +++++++++++++------ 1 file changed, 38 insertions(+), 17 deletions(-) diff --git a/src/pages/SignBitcoinTransaction/SignBitcoinTransaction.js b/src/pages/SignBitcoinTransaction/SignBitcoinTransaction.js index ff570f92..539ea868 100644 --- a/src/pages/SignBitcoinTransaction/SignBitcoinTransaction.js +++ b/src/pages/SignBitcoinTransaction/SignBitcoinTransaction.js @@ -9,8 +9,8 @@ import { useState, useContext, useMemo } from 'react' import { Network } from '../../services/Crypto/Mintlayer/@mintlayerlib-js' import * as bitcoin from 'bitcoinjs-lib' import { Account } from '@Entities' -import { AccountContext, SettingsContext } from '@Contexts' -import { BTCTransaction } from '@Cryptos' +import { AccountContext, BitcoinContext, SettingsContext } from '@Contexts' +import { BTCTransaction, BTC_ADDRESS_TYPE_ENUM } from '@Cryptos' import { BTC as BTCHelpers, Secret } from '@Helpers' import { Electrum } from '@APIs' import { sendPopupResponse } from '@Browser' @@ -144,9 +144,10 @@ export const SignBitcoinTransactionPage = () => { state?.request?.data?.txData?.JSONRepresentation.secret const { addresses, accountID } = useContext(AccountContext) + const { btcUtxos, unusedAddresses: unusedBtcAddresses } = + useContext(BitcoinContext) const { networkType } = useContext(SettingsContext) - const currentBtcAddress = addresses.btcAddresses const network = networkType === 'testnet' ? Network.Testnet : Network.Mainnet // Helper functions to detect transaction types @@ -185,8 +186,9 @@ export const SignBitcoinTransactionPage = () => { const transactionJSONrepresentation = state?.request?.data?.txData?.JSONRepresentation - const { WIF } = await Account.unlockAccount(accountID, pass) - + // buildHTLCAndFundingAddress builds the HTLC script only — it never + // used a WIF (the old code destructured a phantom `{ WIF }` from + // unlockAccount, which always came back undefined and threw later). const htlc = await BTCTransaction.buildHTLCAndFundingAddress({ receiverPubKey: transactionJSONrepresentation.recipientPublicKey, senderPubKey: transactionJSONrepresentation.refundPublicKey, @@ -196,27 +198,46 @@ export const SignBitcoinTransactionPage = () => { lock: transactionJSONrepresentation.timeoutBlocks, secretHashHex: JSON.parse(transactionJSONrepresentation.secretHash) .secret_hash_hex, - wif: WIF, networkType, - fundingKeyPair: { - publicKey: Buffer.from( - transactionJSONrepresentation.refundPublicKey, - 'hex', - ), - }, // TODO: take another key from the wallet }) // address to send funds to const address = htlc.p2wshAddress - const [, txHex, txId] = await BTCTransaction.buildTransaction({ + // Fund the HTLC the same way ConfirmBtcTransaction funds a transfer: + // wallet UTXOs + feeRate + change address + the HD root for signing. + const currentAccount = await Account.getAccount(accountID) + const btcWalletType = + currentAccount.walletType || BTC_ADDRESS_TYPE_ENUM.NATIVE_SEGWIT + + const { btcPrivateKeys } = await Account.unlockAccount(accountID, pass, { + wallets: ['btc'], + }) + + const getChangeAddress = () => { + const candidate = + unusedBtcAddresses?.changeAddress || + addresses?.btcAddresses?.btcChangeAddresses?.[0] + + if (typeof candidate === 'string') return candidate + if (typeof candidate?.address === 'string') return candidate.address + if (typeof candidate === 'object') { + const key = Object.keys(candidate)[0] + if (typeof key === 'string') return key + } + throw new Error('Missing BTC change address') + } + + const [tx, txHex] = await BTCTransaction.buildTransaction({ to: address, amount: parseInt(transactionJSONrepresentation.amount), // satoshis - fee: await getBtcFeeRate(), // sat/vB, estimated like the wallet's own sends - wif: WIF, - from: currentBtcAddress, - networkType, + utxos: btcUtxos || [], + feeRate: await getBtcFeeRate(), + walletType: btcWalletType, + changeAddress: getChangeAddress(), + root: btcPrivateKeys, }) + const txId = tx?.getId() const requestId = state?.request?.requestId const method = 'signTransaction_approve' From 24b9f7f25abdaccc975cdab86dc709fb19bb1367 Mon Sep 17 00:00:00 2001 From: Enrico Rubboli Date: Tue, 8 Sep 2026 13:31:17 +0200 Subject: [PATCH 31/52] fix(security): pin img-src to the actual ipfs gateways img-src https: allowed any HTTPS host; the wallet only ever renders token icons from the three raced gateways, so the CSP now names them explicitly. --- public/manifestDefault.json | 2 +- public/manifestFirefox.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/public/manifestDefault.json b/public/manifestDefault.json index 177d290d..21f8d3f5 100644 --- a/public/manifestDefault.json +++ b/public/manifestDefault.json @@ -14,7 +14,7 @@ "512": "logo512.png" }, "content_security_policy": { - "extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'; img-src 'self' data: https:; style-src 'self'; font-src 'self'; style-src-elem 'self'" + "extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'; img-src 'self' data: https://ipfs.io https://dweb.link https://w3s.link; style-src 'self'; font-src 'self'; style-src-elem 'self'" }, "action": { "default_icon": "logo192.png", diff --git a/public/manifestFirefox.json b/public/manifestFirefox.json index 0b29c0f6..8c2aa6df 100644 --- a/public/manifestFirefox.json +++ b/public/manifestFirefox.json @@ -51,7 +51,7 @@ "default_panel": "index.html" }, "content_security_policy": { - "extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'; img-src 'self' data: https:; style-src 'self'; font-src 'self'; style-src-elem 'self'" + "extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'; img-src 'self' data: https://ipfs.io https://dweb.link https://w3s.link; style-src 'self'; font-src 'self'; style-src-elem 'self'" }, "commands": { "_execute_action": { From 8f369a051dbea795e59c4309da22b0ff2ec52c7b Mon Sep 17 00:00:00 2001 From: Enrico Rubboli Date: Tue, 8 Sep 2026 19:59:46 +0200 Subject: [PATCH 32/52] fix(onboarding): show the Mintlayer logo instead of the custom glyph The welcome/create-restore/set-password screens rendered a hand-drawn amber/teal 'M' mark, not the Mintlayer logo. MojitoLogo now renders assets/logo.svg (the same mark the sidebar and token rows use), keeping the size and animate API. --- .../basic/MojitoLogo/MojitoLogo.tsx | 87 +++---------------- 1 file changed, 14 insertions(+), 73 deletions(-) diff --git a/src/components/basic/MojitoLogo/MojitoLogo.tsx b/src/components/basic/MojitoLogo/MojitoLogo.tsx index 5a0ffecf..73d1578c 100644 --- a/src/components/basic/MojitoLogo/MojitoLogo.tsx +++ b/src/components/basic/MojitoLogo/MojitoLogo.tsx @@ -1,3 +1,4 @@ +import { ReactComponent as MintlayerLogoSvg } from '@Assets/images/logo.svg' import styles from './MojitoLogo.module.css' interface MojitoLogoProps { @@ -5,86 +6,26 @@ interface MojitoLogoProps { animate?: boolean } +// Onboarding shows the real Mintlayer mark (assets/logo.svg), not a custom +// glyph. The orbit ring stays available as a subtle touch on welcome screens. const MojitoLogo = ({ size = 48, animate = true }: MojitoLogoProps) => { - const gradientId = `be-logo-amber-${size}` - const gradientIdTeal = `be-logo-teal-${size}` return (
    - - - - - - - - - - - - - - - - + /> {animate &&
    }
    ) From 93c6e1b0b686ea28f5f72b2ce69b023c1c934210 Mon Sep 17 00:00:00 2001 From: Enrico Rubboli Date: Tue, 8 Sep 2026 20:19:10 +0200 Subject: [PATCH 33/52] =?UTF-8?q?fix(tokens):=20icons=20load=20once=20as?= =?UTF-8?q?=20blobs=20=E2=80=94=20immune=20to=20gateway=20rate=20limiting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the disappearing mlUSDC icon: public ipfs gateways rate-limit by IP. The wallet raced 3 gateways per token on every 2-minute refresh and rendered the icon straight from a gateway URL — once the team's IP got throttled (dweb 429, ipfs.io hanging, w3s 301s into dweb) every icon vanished again no matter which gateway we picked. - resolveTokenIcon now fetches the icon BYTES (racing the gateways once, content-type + 5MB size checks) and returns an in-memory blob: URL; the icon never touches a gateway again after the first successful load - the metadata document is cached permanently (content-addressed = immutable), so a settled token generates zero gateway traffic on refresh - failures are never cached (retried next refresh) and log once per uri - CSP img-src: + blob: for the object urls, + *.dweb.link/*.w3s.link wildcards (those gateways 301 to subdomain style, which the pin would have blocked mid-redirect) - TokenIcon simplified: renders the resolved blob url, tiles on error (last-resort ipfs:// safety mapping kept, tested) --- public/manifestDefault.json | 2 +- public/manifestFirefox.json | 2 +- .../basic/TokenIcon/TokenIcon.test.tsx | 59 ++++-------- src/components/basic/TokenIcon/TokenIcon.tsx | 49 ++-------- src/services/API/Mintlayer/Mintlayer.js | 93 ++++++++++++++----- 5 files changed, 102 insertions(+), 103 deletions(-) diff --git a/public/manifestDefault.json b/public/manifestDefault.json index 21f8d3f5..a4c69337 100644 --- a/public/manifestDefault.json +++ b/public/manifestDefault.json @@ -14,7 +14,7 @@ "512": "logo512.png" }, "content_security_policy": { - "extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'; img-src 'self' data: https://ipfs.io https://dweb.link https://w3s.link; style-src 'self'; font-src 'self'; style-src-elem 'self'" + "extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'; img-src 'self' data: blob: https://ipfs.io https://dweb.link https://*.dweb.link https://w3s.link https://*.w3s.link; style-src 'self'; font-src 'self'; style-src-elem 'self'" }, "action": { "default_icon": "logo192.png", diff --git a/public/manifestFirefox.json b/public/manifestFirefox.json index 8c2aa6df..4d107496 100644 --- a/public/manifestFirefox.json +++ b/public/manifestFirefox.json @@ -51,7 +51,7 @@ "default_panel": "index.html" }, "content_security_policy": { - "extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'; img-src 'self' data: https://ipfs.io https://dweb.link https://w3s.link; style-src 'self'; font-src 'self'; style-src-elem 'self'" + "extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'; img-src 'self' data: blob: https://ipfs.io https://dweb.link https://*.dweb.link https://w3s.link https://*.w3s.link; style-src 'self'; font-src 'self'; style-src-elem 'self'" }, "commands": { "_execute_action": { diff --git a/src/components/basic/TokenIcon/TokenIcon.test.tsx b/src/components/basic/TokenIcon/TokenIcon.test.tsx index 9775fb66..f97c0e62 100644 --- a/src/components/basic/TokenIcon/TokenIcon.test.tsx +++ b/src/components/basic/TokenIcon/TokenIcon.test.tsx @@ -26,64 +26,45 @@ test('applies the requested size', () => { expect(screen.getByTestId('token-icon')).toHaveStyle({ width: '48px' }) }) -test('renders the token metadata icon when an iconUri is provided', () => { +test('renders the resolved blob icon uri as given', () => { + // the wallet provider resolves metadata icons to in-memory blob: urls; + // the component must render them verbatim + const iconUri = 'blob:chrome-extension://ext-id/abc-123' const { getByTestId } = render( , ) const img = getByTestId('token-icon-image') as HTMLImageElement expect(img).toBeInTheDocument() - expect(img.src).toBe('https://example.com/mlusdc.png') + expect(img.src).toBe(iconUri) }) -test('maps ipfs:// icon uris to the ipfs.io gateway', () => { - const { getByTestId } = render( - , - ) - const img = getByTestId('token-icon-image') as HTMLImageElement - expect(img.src).toBe('https://ipfs.io/ipfs/bafyabc/icon.png') -}) - -test('cycles gateway mirrors on load failure before falling back to the tile', async () => { +test('falls back to the glyph tile when the icon image fails to load', async () => { const { getByTestId, queryByTestId } = render( , ) - const failUntilTile = async () => { - // each error moves to the next gateway mirror; exhausting them removes - // the img and restores the procedural tile - for (let i = 0; i < 4; i++) { - const img = queryByTestId('token-icon-image') - if (!img) break - fireEvent.error(img) - await waitFor(() => {}) - } - await waitFor(() => - expect(queryByTestId('token-icon-image')).not.toBeInTheDocument(), - ) - } - - await failUntilTile() + fireEvent.error(getByTestId('token-icon-image')) + await waitFor(() => + expect(queryByTestId('token-icon-image')).not.toBeInTheDocument(), + ) expect(getByTestId('token-icon')).toHaveTextContent('$') +}) - const srcAfterFirstMirror = 'https://dweb.link/ipfs/bafyicon/broken.png' - // re-render a fresh instance to verify the first mirror swap specifically - const second = render( +test('never renders a raw ipfs:// uri as the img src', () => { + // last line of defense: even if a caller leaks an unresolved ipfs:// uri, + // the component must not hand it to the browser as-is + render( , ) - fireEvent.error(second.getByTestId('token-icon-image')) - expect((second.getByTestId('token-icon-image') as HTMLImageElement).src).toBe( - srcAfterFirstMirror, - ) + const img = screen.getByTestId('token-icon-image') as HTMLImageElement + expect(img.src).not.toMatch(/^ipfs:/) }) diff --git a/src/components/basic/TokenIcon/TokenIcon.tsx b/src/components/basic/TokenIcon/TokenIcon.tsx index ded58992..9024278a 100644 --- a/src/components/basic/TokenIcon/TokenIcon.tsx +++ b/src/components/basic/TokenIcon/TokenIcon.tsx @@ -6,8 +6,9 @@ import { ReactComponent as BtcLogo } from '@Assets/images/btc-logo.svg' interface TokenIconProps { symbol: string size?: number - // Token metadata icon (token_info.icon_uri.string). ipfs:// is mapped to a - // public gateway; anything unreachable falls back to the generated tile. + // Token metadata icon, resolved by the wallet to an in-memory blob: URL + // (fetched once from the ipfs gateways). On load failure the procedural + // tile renders instead. iconUri?: string } @@ -27,56 +28,26 @@ const LOGOS: Record = { ML: , } +// Safety net: the wallet provider normally resolves metadata icons to blob: +// urls, but if an unresolved ipfs:// uri ever leaks through, map it to the +// public gateway so the browser gets a fetchable https url. const toRenderableUri = (uri: string) => uri.startsWith('ipfs://') - ? uri.replace('ipfs://', 'https://ipfs.io/ipfs/') + ? `https://ipfs.io/ipfs/${uri.slice('ipfs://'.length)}` : uri -// If the resolved icon URL times out on a gateway, the img onError cycles -// through the remaining mirrors before giving up entirely. -const IMG_GATEWAY_FALLBACKS: Array<[string, string]> = [ - ['https://ipfs.io/ipfs/', 'https://dweb.link/ipfs/'], - ['https://dweb.link/ipfs/', 'https://w3s.link/ipfs/'], -] - // Native BTC/ML assets get the real chain logos; tokens show their metadata // icon when available, otherwise the procedural design-system tile (unknown // symbols fall back to first letter). const TokenIcon = ({ symbol, size = 36, iconUri }: TokenIconProps) => { const [iconFailed, setIconFailed] = useState(false) - const [iconSrc, setIconSrc] = useState( - iconUri ? toRenderableUri(iconUri) : undefined, - ) const logo = LOGOS[symbol] const { c1, c2 } = GRADIENTS[symbol] ?? { c1: 'oklch(0.6 0.05 60)', c2: 'oklch(0.4 0.05 60)', } - // Keep the displayed src in sync when the resolved icon arrives late. - const [lastIconUri, setLastIconUri] = useState(iconUri) - if (iconUri !== lastIconUri) { - setLastIconUri(iconUri) - setIconSrc(iconUri ? toRenderableUri(iconUri) : undefined) - setIconFailed(false) - } - - const showImage = Boolean(iconSrc) && !iconFailed && !logo - - const handleIconError = () => { - if (!iconSrc) { - setIconFailed(true) - return - } - const fallback = IMG_GATEWAY_FALLBACKS.find(([from]) => - iconSrc.startsWith(from), - ) - if (fallback) { - setIconSrc(iconSrc.replace(fallback[0], fallback[1])) - } else { - setIconFailed(true) - } - } + const showImage = Boolean(iconUri) && !iconFailed && !logo return (
    { {showImage ? ( {symbol} setIconFailed(true)} data-testid="token-icon-image" /> ) : logo ? ( diff --git a/src/services/API/Mintlayer/Mintlayer.js b/src/services/API/Mintlayer/Mintlayer.js index 5e95535e..fb509b00 100644 --- a/src/services/API/Mintlayer/Mintlayer.js +++ b/src/services/API/Mintlayer/Mintlayer.js @@ -347,11 +347,6 @@ const IPFS_GATEWAYS = [ 'https://dweb.link/ipfs', 'https://w3s.link/ipfs', ] -const IPFS_GATEWAY = IPFS_GATEWAYS[0] - -const fromIpfs = (uri) => - uri.startsWith('ipfs://') ? uri.replace('ipfs://', `${IPFS_GATEWAY}/`) : uri - // Token metadata is issuer-controlled: only ipfs:// metadata documents are // resolved (through the fixed gateway list) and only gateway-hosted icons // are returned. Arbitrary https/http metadata or icon urls would turn token @@ -360,7 +355,12 @@ const isAllowedIpfsUri = (uri) => typeof uri === 'string' && uri.startsWith('ipfs://') const NEGATIVE_CACHE_TTL_MS = 5 * 60 * 1000 -const tokenIconCache = new Map() // metadata uri -> { value, expires? } +const ICON_MAX_BYTES = 5 * 1024 * 1024 +// metadata uri -> { value: blobUrl, expires? } (negative entries carry a TTL) +const tokenIconCache = new Map() +// metadata uri -> parsed JSON. Ipfs content is content-addressed, so a +// resolved metadata document never needs to be fetched again. +const metadataCache = new Map() const failedLookupsLogged = new Set() const fetchJsonWithGatewayFallback = async (metadataUri) => { @@ -387,6 +387,42 @@ const fetchJsonWithGatewayFallback = async (metadataUri) => { } } +// Races the gateways for the icon BYTES and returns an in-memory object +// URL. Once fetched, the icon never touches a gateway again — it renders +// from the blob, so the periodic refresh stops hammering public gateways +// (they rate-limit aggressively). +const fetchIconBlobUrl = async (iconUri) => { + const candidates = iconUri.startsWith('ipfs://') + ? IPFS_GATEWAYS.map( + (gateway) => `${gateway}/${iconUri.slice('ipfs://'.length)}`, + ) + : [iconUri] + + const attempts = candidates.map(async (candidate) => { + const response = await fetch(candidate, { + signal: AbortSignal.timeout(10000), + }) + if (!response.ok) { + throw new Error(`HTTP ${response.status}`) + } + const type = response.headers?.get?.('content-type') || '' + if (type && !type.startsWith('image/')) { + throw new Error(`Not an image: ${type}`) + } + const blob = await response.blob() + if (blob.size > ICON_MAX_BYTES) { + throw new Error('Icon too large') + } + return URL.createObjectURL(blob) + }) + + try { + return await Promise.any(attempts) + } catch { + return null + } +} + const resolveTokenIcon = async (metadataUri) => { if (!isAllowedIpfsUri(metadataUri)) return undefined @@ -400,18 +436,30 @@ const resolveTokenIcon = async (metadataUri) => { } } - const metadata = await fetchJsonWithGatewayFallback(metadataUri) - + const metadata = + metadataCache.get(metadataUri) ?? + (await fetchJsonWithGatewayFallback(metadataUri)) if (metadata) { - const raw = metadata.tokenIcon || metadata.icon_uri || metadata.icon - // Scheme allowlist: ipfs:// maps to a gateway, https:// renders as-is. - // http:// (cleartext/internal-network) and exotic schemes are rejected — - // token metadata is issuer-controlled. - if (raw && typeof raw === 'string' && /^(ipfs|https):\/\//.test(raw)) { - const iconUrl = fromIpfs(raw) - tokenIconCache.set(metadataUri, { value: iconUrl }) - return iconUrl + // Ipfs content is content-addressed: cache the document permanently so + // the refresh loop never re-fetches it. + metadataCache.set(metadataUri, metadata) + } else { + // Every gateway failed: log once per uri, do NOT cache — the next + // refresh retries. + if (!failedLookupsLogged.has(metadataUri)) { + failedLookupsLogged.add(metadataUri) + console.error( + `Failed to resolve token metadata from every gateway: ${metadataUri}`, + ) } + return undefined + } + + const raw = metadata.tokenIcon || metadata.icon_uri || metadata.icon + // Scheme allowlist: ipfs:// resolves through the gateway race, https:// + // is fetched directly. http:// (cleartext/internal-network) and exotic + // schemes are rejected — token metadata is issuer-controlled. + if (!(raw && typeof raw === 'string' && /^(ipfs|https):\/\//.test(raw))) { // Definitive "document has no icon": cache with a TTL so the gateways // are not hammered on every refresh. tokenIconCache.set(metadataUri, { @@ -421,14 +469,13 @@ const resolveTokenIcon = async (metadataUri) => { return undefined } - // Every gateway failed: log once per uri, do NOT cache — the next refresh - // retries. - if (!failedLookupsLogged.has(metadataUri)) { - failedLookupsLogged.add(metadataUri) - console.error( - `Failed to resolve token metadata from every gateway: ${metadataUri}`, - ) + const blobUrl = await fetchIconBlobUrl(raw) + if (blobUrl) { + tokenIconCache.set(metadataUri, { value: blobUrl }) + return blobUrl } + + // Icon bytes unreachable right now: not cached, retried next refresh. return undefined } From 4d5b46299f6c120ece5104b4bbd79dd5b451edd4 Mon Sep 17 00:00:00 2001 From: Enrico Rubboli Date: Tue, 8 Sep 2026 20:19:28 +0200 Subject: [PATCH 34/52] test(tokens): cover the blob icon resolution flow - metadata + icon bytes raced across gateways (6 requests first pass) - zero gateway traffic after a settled resolution - TTL-cached no-icon answers vs retried total failures - non-ipfs metadata uris rejected with zero network requests --- src/services/API/Mintlayer/Mintlayer.test.js | 124 +++++++++++++------ 1 file changed, 84 insertions(+), 40 deletions(-) diff --git a/src/services/API/Mintlayer/Mintlayer.test.js b/src/services/API/Mintlayer/Mintlayer.test.js index 23a59fcc..5bc0d01c 100644 --- a/src/services/API/Mintlayer/Mintlayer.test.js +++ b/src/services/API/Mintlayer/Mintlayer.test.js @@ -116,64 +116,96 @@ describe('resolveTokenIcon', () => { ok: true, json: async () => body, }) + const okImage = () => ({ + ok: true, + headers: { get: () => 'image/png' }, + blob: async () => ({ size: 1024, type: 'image/png' }), + }) + + beforeAll(() => { + global.URL.createObjectURL = jest.fn(() => 'blob:mock-icon') + }) afterEach(() => { jest.restoreAllMocks() }) - it('resolves tokenIcon from the metadata document and maps ipfs uris', async () => { - const fetchSpy = jest - .spyOn(global, 'fetch') - .mockResolvedValue(okJson({ tokenIcon: 'ipfs://bafyicon/logo.png' })) + // fetch mock: bafymetadata* answers the metadata JSON, bafyicon* answers + // with image bytes + const mockGateways = ({ metadata, metadataFail = false, iconFail = false }) => + jest.spyOn(global, 'fetch').mockImplementation(async (url) => { + const target = String(url) + if (target.includes('bafyicon')) { + if (iconFail) throw new Error('signal timed out') + return okImage() + } + if (target.includes('bafymetadata')) { + if (metadataFail) throw new Error('signal timed out') + return okJson(metadata) + } + throw new Error(`unexpected url ${target}`) + }) - await expect( - resolveTokenIcon('ipfs://bafymetadata/doc.json'), - ).resolves.toBe('https://ipfs.io/ipfs/bafyicon/logo.png') - // all gateways are raced, starting with ipfs.io - expect(fetchSpy.mock.calls[0][0]).toBe( - 'https://ipfs.io/ipfs/bafymetadata/doc.json', - ) - expect(fetchSpy.mock.calls[1][0]).toBe( - 'https://dweb.link/ipfs/bafymetadata/doc.json', - ) + it('resolves the metadata, fetches the icon bytes and returns a blob url', async () => { + const fetchSpy = mockGateways({ + metadata: { tokenIcon: 'ipfs://bafyicon/logo.png' }, + }) + + const url = await resolveTokenIcon('ipfs://bafymetadata/doc.json') + + expect(url).toBe('blob:mock-icon') + expect(global.URL.createObjectURL).toHaveBeenCalled() + // 3 gateways raced for the metadata + 3 for the icon bytes + expect( + fetchSpy.mock.calls.filter(([u]) => String(u).includes('bafymetadata')), + ).toHaveLength(3) + expect( + fetchSpy.mock.calls.filter(([u]) => String(u).includes('bafyicon')), + ).toHaveLength(3) + }) + + it('is fully cached after the first resolution (zero gateway traffic)', async () => { + const fetchSpy = mockGateways({ + metadata: { tokenIcon: 'ipfs://bafyicon/logo.png' }, + }) + + await resolveTokenIcon('ipfs://bafymetadata/cached.json') + const callsAfterFirst = fetchSpy.mock.calls.length + await resolveTokenIcon('ipfs://bafymetadata/cached.json') + + expect(fetchSpy.mock.calls.length).toBe(callsAfterFirst) }) it('races gateways — a dead one loses the race without blocking', async () => { - const fetchSpy = jest - .spyOn(global, 'fetch') - .mockImplementation(async (url) => { - if (String(url).startsWith('https://ipfs.io/')) { - throw new Error('signal timed out') - } + const fetchSpy = mockGateways({ + metadata: { tokenIcon: 'ipfs://bafyicon/i.png' }, + }) + // make the ipfs.io candidate fail for every request + fetchSpy.mockImplementation(async (url) => { + const target = String(url) + if (target.startsWith('https://ipfs.io/')) { + throw new Error('signal timed out') + } + if (target.includes('bafyicon')) return okImage() + if (target.includes('bafymetadata')) { return okJson({ tokenIcon: 'ipfs://bafyicon/i.png' }) - }) + } + throw new Error(`unexpected url ${target}`) + }) await expect( resolveTokenIcon('ipfs://bafymetadata/slow.json'), - ).resolves.toBe('https://ipfs.io/ipfs/bafyicon/i.png') + ).resolves.toBe('blob:mock-icon') - // invariant: a raw ipfs:// uri is never fetched — only gateway URLs + // invariant: only gateway https urls are requested, never raw ipfs:// for (const [candidate] of fetchSpy.mock.calls) { expect(String(candidate)).toMatch(/^https:\/\//) expect(String(candidate)).not.toMatch(/^ipfs:\/\//) } }) - it('is cached once resolved, per metadata uri', async () => { - const fetchSpy = jest - .spyOn(global, 'fetch') - .mockResolvedValue(okJson({ tokenIcon: 'https://x.example/i.png' })) - - await resolveTokenIcon('ipfs://bafymetadata/cached.json') - await resolveTokenIcon('ipfs://bafymetadata/cached.json') - - expect(fetchSpy).toHaveBeenCalledTimes(3) // 3 gateways raced once - }) - it('caches a definitive no-icon answer with a TTL', async () => { - const fetchSpy = jest - .spyOn(global, 'fetch') - .mockResolvedValue(okJson({ name: 'no icon here' })) + const fetchSpy = mockGateways({ metadata: { name: 'no icon here' } }) await expect( resolveTokenIcon('ipfs://bafymetadata/noicon.json'), @@ -181,8 +213,8 @@ describe('resolveTokenIcon', () => { await expect( resolveTokenIcon('ipfs://bafymetadata/noicon.json'), ).resolves.toBeUndefined() - // 3 gateways raced once; the negative answer is cached for 5 minutes - expect(fetchSpy).toHaveBeenCalledTimes(3) + // 3 gateways raced once for the metadata; the negative answer is cached + expect(fetchSpy.mock.calls.length).toBe(3) }) it('does not cache total gateway failures — the next refresh retries', async () => { @@ -196,6 +228,18 @@ describe('resolveTokenIcon', () => { await expect( resolveTokenIcon('ipfs://bafymetadata/fail.json'), ).resolves.toBeUndefined() - expect(fetchSpy).toHaveBeenCalledTimes(6) // 3 gateways x 2 attempts + // 3 gateway attempts per resolution, nothing cached + expect(fetchSpy).toHaveBeenCalledTimes(6) + }) + + it('rejects non-ipfs metadata uris without any network request', async () => { + const fetchSpy = jest.spyOn(global, 'fetch') + await expect( + resolveTokenIcon('https://evil.example/metadata.json'), + ).resolves.toBeUndefined() + await expect( + resolveTokenIcon('http://localhost:8080/metadata.json'), + ).resolves.toBeUndefined() + expect(fetchSpy).not.toHaveBeenCalled() }) }) From 1b476fc3a5608eed291fa6079413eba05b0e5a44 Mon Sep 17 00:00:00 2001 From: Enrico Rubboli Date: Tue, 8 Sep 2026 21:13:19 +0200 Subject: [PATCH 35/52] feat(approvals): open dApp approvals in the side panel, popup as fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dApp approvals (connect + sign) always forced a separate popup window. The wallet already ships a side panel running the same app — approvals now open there, docked to the window the dApp lives in: - requests are stored under pendingRequest: and sidePanel.open({ tabId }) surfaces the panel for that tab - if sidePanel.open rejects (no recent user gesture) the request rolls back and a popup window opens instead — the flow never dead-ends - panel-mode slots have no window-removed event: handlePopupResponse now releases the owning slot, and failSlot clears by the slot's recorded window id - an already-open panel picks up new approvals instantly via storage.onChanged (request ids deduped against the mount read) - Firefox keeps popup windows (no sidePanel API there) --- public/background.js | 129 ++++++++++++++++++++++++++++++++++++++++--- src/index.js | 32 ++++++++++- 2 files changed, 152 insertions(+), 9 deletions(-) diff --git a/public/background.js b/public/background.js index d6efc181..e9d82699 100644 --- a/public/background.js +++ b/public/background.js @@ -5,11 +5,15 @@ // Detect browser API (Chrome or Firefox) const api = typeof browser !== 'undefined' ? browser : chrome - // One slot tracks the state of each approval window: connect vs signing. + // One slot tracks the state of each approval surface: connect vs signing. + // Approvals open either in the browser side panel (panelMode) or, when the + // panel cannot be opened, in a popup window — each slot remembers which. const createApprovalSlot = () => ({ id: false, opening: false, requestId: null, + panelMode: false, + windowId: null, }) const popupSlot = createApprovalSlot() @@ -102,9 +106,10 @@ // Fails a pending approval: clears the slot and answers the waiting dApp. const failSlot = (slot, error) => { - const windowId = slot.id + const windowId = slot.panelMode ? slot.windowId : slot.id slot.id = false slot.opening = false + slot.panelMode = false if (!slot.requestId) return @@ -115,9 +120,103 @@ respond?.({ error }) } - // Opens one approval window for the request and keeps the dApp's message - // channel open until the wallet answers. Returns true while waiting. - const openApprovalWindow = (slot, request, sendResponse) => { + // Opens the approval in the browser side panel docked to the dApp's + // window. The request is keyed by that window id so the panel reads its + // own. Falls back to a popup when the panel cannot be opened. + const openPanelApproval = ( + slot, + request, + sendResponse, + sender, + onFallback, + ) => { + const windowId = sender.tab.windowId + slot.panelMode = true + slot.windowId = windowId + slot.opening = true + slot.requestId = request.requestId + pendingResponses.set(request.requestId, sendResponse) + + api.storage.local.set( + { [pendingRequestKeyFor(windowId)]: { ...request, windowId } }, + () => { + if (api.runtime.lastError) { + console.error('[Mintlayer] Storage set error:', api.runtime.lastError) + failSlot( + slot, + errorOf( + 'STORAGE_ERROR', + 'Could not create the wallet request. Please try again.', + ), + ) + return + } + + api.sidePanel + .open({ tabId: sender.tab.id }) + .then(() => { + slot.opening = false + }) + .catch((error) => { + console.error( + '[Mojito] sidePanel.open failed, using a popup window:', + error, + ) + // roll back the panel registration and use a popup instead + pendingResponses.delete(request.requestId) + slot.requestId = null + slot.panelMode = false + slot.windowId = null + // the popup fallback re-opens the slot: without this reset the + // fallback hits openPopupApproval's busy guard and the dApp is + // answered REQUEST_IN_PROGRESS instead of getting a window + slot.opening = false + api.storage.local.remove(pendingRequestKeyFor(windowId)) + onFallback() + }) + }, + ) + } + + // Chooses the approval surface: side panel for the dApp's window when the + // browser supports it, popup window otherwise. Returns true while the + // dApp's message channel stays open. + const openApprovalTarget = (slot, request, sendResponse, sender) => { + const canUsePanel = + Boolean(api.sidePanel?.open) && + sender?.tab?.id != null && + sender?.tab?.windowId != null + + if (!canUsePanel) { + return openPopupApproval(slot, request, sendResponse) + } + + const busy = + slot.opening || + (typeof slot.id === 'number' && !slot.panelMode) || + (slot.panelMode && slot.requestId != null) + + if (busy) { + // surface the pending request again + api.sidePanel.open({ tabId: sender.tab.id }).catch(() => {}) + sendResponse({ + error: errorOf( + 'REQUEST_IN_PROGRESS', + 'An approval is already pending. Complete or reject it first.', + ), + }) + return false + } + + openPanelApproval(slot, request, sendResponse, sender, () => { + openPopupApproval(slot, request, sendResponse) + }) + return true + } + + // Popup fallback: opens one approval window for the request and keeps the + // dApp's message channel open until the wallet answers. + const openPopupApproval = (slot, request, sendResponse) => { if (typeof slot.id === 'number' || slot.opening) { if (typeof slot.id === 'number') focusWindow(slot.id) sendResponse({ @@ -195,6 +294,17 @@ pendingResponses.delete(requestId) clearPendingRequest(windowId) + // release the owning approval slot (panel-mode slots have no + // window-removed event to reset them) + for (const slot of [connectSlot, popupSlot]) { + if (slot.requestId === requestId) { + slot.id = false + slot.opening = false + slot.panelMode = false + slot.requestId = null + } + } + if (!respond) { console.warn('[Mintlayer] Response for unknown request:', requestId) return @@ -289,7 +399,7 @@ return false } - return openApprovalWindow( + return openApprovalTarget( connectSlot, { origin, @@ -298,6 +408,7 @@ action: 'connect', }, sendResponse, + sender, ) } else if (message.method === 'signTransaction') { if (!connectedSites[origin]) { @@ -310,7 +421,7 @@ return false } - return openApprovalWindow( + return openApprovalTarget( popupSlot, { origin, @@ -323,6 +434,7 @@ network: connectedSites[origin]?.network, }, sendResponse, + sender, ) } else if (message.method === 'signChallenge') { if (!connectedSites[origin]) { @@ -335,7 +447,7 @@ return false } - return openApprovalWindow( + return openApprovalTarget( popupSlot, { origin, @@ -345,6 +457,7 @@ network: connectedSites[origin]?.network, }, sendResponse, + sender, ) } else if (message.method === 'version') { sendResponse({ result: api.runtime.getManifest().version }) diff --git a/src/index.js b/src/index.js index 7a6af2d8..6f7115a5 100644 --- a/src/index.js +++ b/src/index.js @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useContext } from 'react' +import React, { useState, useEffect, useContext, useRef } from 'react' import ReactDOM from 'react-dom/client' import { MemoryRouter, @@ -193,7 +193,37 @@ const App = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [addresses, isAccountUnlocked, navigate]) + // Approvals can arrive while the side panel is already open: react to the + // storage write immediately instead of waiting for the next effect run. + useEffect(() => { + if (!storage?.onChanged) return + const listener = (changes, areaName) => { + if (areaName !== 'local') return + const key = Object.keys(changes).find((k) => + k.startsWith('pendingRequest:'), + ) + if (!key) return + const request = changes[key].newValue + if (request) handlePendingRequestRef.current(request) + } + storage.onChanged.addListener(listener) + return () => storage.onChanged.removeListener(listener) + }, []) + + // keep a stable handle for listeners registered once + const handlePendingRequestRef = useRef() + handlePendingRequestRef.current = handlePendingRequest + + const handledRequestIds = useRef(new Set()) + const handlePendingRequest = (pendingRequest) => { + // the mount read and the storage.onChanged listener can both observe the + // same request — only route it once + if (pendingRequest.requestId) { + if (handledRequestIds.current.has(pendingRequest.requestId)) return + handledRequestIds.current.add(pendingRequest.requestId) + } + const { action, origin, requestId } = pendingRequest if (action === 'connect') { From 38a8038cd3b4acf40322979ec8e8c3c9633b6416 Mon Sep 17 00:00:00 2001 From: Enrico Rubboli Date: Tue, 8 Sep 2026 21:14:08 +0200 Subject: [PATCH 36/52] test(approvals): cover the side-panel approval surface - panel path: sidePanel.open({ tabId }) called, request keyed by the dApp tab's WINDOW id, no popup created, channel kept open - popup fallback when sidePanel.open rejects, including the rollback of the panel registration and the original channel being resolved by the later popupResponse - busy in panel mode answers REQUEST_IN_PROGRESS without a second window --- public/background.test.js | 96 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/public/background.test.js b/public/background.test.js index 8877c450..ebf3072d 100644 --- a/public/background.test.js +++ b/public/background.test.js @@ -19,6 +19,12 @@ const dappSender = { origin: 'https://bridge.example', url: 'https://bridge.example/page', } +// Chromium content-script senders carry the tab the dApp runs in: the +// side-panel approval surface keys its requests off the tab's WINDOW id. +const tabDappSender = { + ...dappSender, + tab: { id: 5, windowId: 3 }, +} const extensionSender = { id: EXT_ID, origin: `chrome-extension://${EXT_ID}`, @@ -98,6 +104,9 @@ describe('background service worker', () => { update: (id, opts, cb) => cb && cb({ id }), onRemoved: { addListener: () => {} }, }, + sidePanel: { + open: jest.fn().mockResolvedValue(undefined), + }, } loadBackground() @@ -439,4 +448,91 @@ describe('background service worker', () => { }) }) }) + + describe('side-panel approval surface (dApp request from a tab)', () => { + it('opens the side panel on the dApp tab instead of a popup window', () => { + const { reply, keptOpen } = dispatch( + { requestId: 'r1', method: 'connect', params: {} }, + tabDappSender, + ) + + // the panel is opened on the dApp's TAB + expect(global.chrome.sidePanel.open).toHaveBeenCalledWith({ tabId: 5 }) + // the panel path creates NO popup window + expect(createdWindows).toHaveLength(0) + // the request is keyed by the dApp tab's WINDOW id (3), not the tab id + expect(storageData['pendingRequest:3']).toMatchObject({ + action: 'connect', + origin: 'https://bridge.example', + requestId: 'r1', + }) + expect(storageData['pendingRequest:5']).toBeUndefined() + // the channel stays open until the approval answers it + expect(keptOpen).toBe(true) + expect(reply.current).toBeUndefined() + }) + + it('falls back to a popup window when the side panel cannot open', async () => { + global.chrome.sidePanel.open.mockRejectedValue(new Error('no gesture')) + + const connect = dispatch( + { requestId: 'r1', method: 'connect', params: {} }, + tabDappSender, + ) + expect(connect.keptOpen).toBe(true) + + // let the sidePanel.open rejection settle and the fallback run + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + + expect(global.chrome.sidePanel.open).toHaveBeenCalledWith({ tabId: 5 }) + // the fallback opens ONE popup window (id 700 per the mock) + expect(createdWindows).toHaveLength(1) + // the request now lives under the popup's window id + expect(storageData['pendingRequest:700']).toMatchObject({ + action: 'connect', + origin: 'https://bridge.example', + requestId: 'r1', + }) + // the panel registration was rolled back + expect(storageData['pendingRequest:3']).toBeUndefined() + + // approving in the popup resolves the ORIGINAL dApp channel + dispatch( + { + action: 'popupResponse', + method: 'connect', + requestId: 'r1', + origin: 'https://bridge.example', + windowId: 700, + result: sessionData, + }, + extensionSender, + ) + + expect(connect.reply.current.result).toEqual(sessionData) + // and the pending request is consumed + expect(storageData['pendingRequest:700']).toBeUndefined() + }) + + it('answers REQUEST_IN_PROGRESS for a second request while the panel approval is pending', () => { + dispatch( + { requestId: 'r1', method: 'connect', params: {} }, + tabDappSender, + ) + + const second = dispatch( + { requestId: 'r2', method: 'connect', params: {} }, + tabDappSender, + ) + + expect(second.reply.current).toMatchObject({ + error: { code: 'REQUEST_IN_PROGRESS' }, + }) + expect(second.keptOpen).toBe(false) + // the busy answer must not have opened any approval window + expect(createdWindows).toHaveLength(0) + }) + }) }) From 22019840c7e8f87c393c2c4cba1a5508238e186a Mon Sep 17 00:00:00 2001 From: Enrico Rubboli Date: Tue, 8 Sep 2026 21:28:54 +0200 Subject: [PATCH 37/52] feat(sign): user-friendly transaction recap for dApp and in-wallet signing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both signing screens (external dApp + internal send confirm) led with a 'Switch to preview/json' button and rendered a dense wall of untruncated addresses, token ids, a always-on raw input/output breakdown and request metadata. - new shared TransactionSummary container: action header (Send / Bridge transaction / Stake...), From/To (truncated + copy), Amount with token ticker from tokenMap, network fee, network — on be-* tokens - bridge intent rendered as its own compact row with copy - the full per-operation breakdown moved behind a collapsed 'Show technical details' disclosure, together with the raw JSON view (replaces the prominent 'Switch to preview/json' button) - both pages now render the summary; the preview components remain as the technical payload --- .../TransactionSummary/TransactionSummary.js | 229 ++++++++++++++++++ .../TransactionSummary.module.css | 95 ++++++++ .../TransactionSummary.test.js | 154 ++++++++++++ src/components/containers/index.js | 2 + .../SignExternalTransaction.js | 31 +-- .../SignInternalTransaction.js | 31 +-- 6 files changed, 506 insertions(+), 36 deletions(-) create mode 100644 src/components/containers/SignTransaction/TransactionSummary/TransactionSummary.js create mode 100644 src/components/containers/SignTransaction/TransactionSummary/TransactionSummary.module.css create mode 100644 src/components/containers/SignTransaction/TransactionSummary/TransactionSummary.test.js diff --git a/src/components/containers/SignTransaction/TransactionSummary/TransactionSummary.js b/src/components/containers/SignTransaction/TransactionSummary/TransactionSummary.js new file mode 100644 index 00000000..a8f57945 --- /dev/null +++ b/src/components/containers/SignTransaction/TransactionSummary/TransactionSummary.js @@ -0,0 +1,229 @@ +import { useState, useContext } from 'react' +import Decimal from 'decimal.js' + +import { MintlayerContext, SettingsContext } from '@Contexts' +import { SignTransaction as SignTxHelpers } from '@Helpers' +import { CopyButton } from '@ComposedComponents' +import { KV, Tag } from '@BasicComponents' + +import styles from './TransactionSummary.module.css' + +// dApp- and wallet-built transaction JSONRepresentation is issuer/flow +// controlled: only walk plain structures and cap anything rendered. +const MAX_TEXT_LENGTH = 64 + +const truncate = (value, head = 12, tail = 8) => { + if (!value) return '—' + const text = String(value) + return text.length > head + tail + 3 + ? `${text.slice(0, head)}…${text.slice(-tail)}` + : text +} + +const bounded = (value) => { + const text = value == null ? '' : String(value) + return text.length > MAX_TEXT_LENGTH + ? `${text.slice(0, MAX_TEXT_LENGTH)}…` + : text +} + +const getAddressOf = (candidate) => { + if (!candidate) return null + if (typeof candidate === 'string') return candidate + if (typeof candidate.destination === 'string') return candidate.destination + if (typeof candidate.address === 'string') return candidate.address + return null +} + +// The output the user is actually affecting: not back to their own wallet. +const findRelevantOutput = (inputs, outputs, ownAddresses) => { + const ownList = [ + ...(ownAddresses.receiving || []), + ...(ownAddresses.change || []), + ] + const isOwn = (address) => address && ownList.includes(address) + + const inputWithToken = inputs.find( + (input) => input.utxo?.value?.type === 'TokenV1', + ) + if (inputWithToken) { + const tokenId = inputWithToken.utxo.value.token_id + return outputs.find( + (output) => + output.value?.token_id === tokenId && !isOwn(getAddressOf(output)), + ) + } + return outputs.find((output) => !isOwn(getAddressOf(output))) +} + +const findOwnInputAddress = (inputs, ownAddresses) => { + const ownList = [ + ...(ownAddresses.receiving || []), + ...(ownAddresses.change || []), + ] + for (const input of inputs) { + const address = getAddressOf(input?.utxo) || getAddressOf(input?.input) + if (address && ownList.includes(address)) return address + } + return ( + inputs.find((input) => input?.utxo?.destination)?.utxo?.destination ?? null + ) +} + +const OPERATION_LABELS = [ + ['isBridgeRequest', 'Bridge transaction'], + ['isDelegateStaking', 'Stake to delegation'], + ['isDelegateWithdraw', 'Withdraw from delegation'], + ['isCreateHtlc', 'Create HTLC (swap escrow)'], + ['isSpendHtlc', 'Claim HTLC'], + ['isCreateStakePool', 'Create stake pool'], + ['isCreateDelegationId', 'Create delegation'], + ['isTokenMint', 'Mint tokens'], + ['isTokenUnmint', 'Unmint tokens'], + ['isIssueToken', 'Issue token'], + ['isIssueNft', 'Issue NFT'], + ['isBurnCoin', 'Burn coins'], + ['isBurnToken', 'Burn tokens'], + ['isFreezeToken', 'Freeze token'], + ['isUnfreezeToken', 'Unfreeze token'], + ['isLockTokenSupply', 'Lock token supply'], + ['isChangeTokenMetadata', 'Change token metadata'], + ['isChangeTokenAuthority', 'Change token authority'], + ['isTransfer', 'Send'], +] + +/** + * Compact, user-friendly recap for a transaction: action, counterparties, + * amount, fee and network — with everything technical collapsed away. + */ +const TransactionSummary = ({ + jsonRepresentation, + intent, + ownAddresses, + technicalDetails, + rawJsonNode, +}) => { + const { tokenMap } = useContext(MintlayerContext) + const { networkType } = useContext(SettingsContext) + const [showDetails, setShowDetails] = useState(false) + + const inputs = jsonRepresentation?.inputs || [] + const outputs = jsonRepresentation?.outputs || [] + + const coinTicker = networkType === 'testnet' ? 'TML' : 'ML' + const fromAddress = findOwnInputAddress(inputs, ownAddresses) + const output = findRelevantOutput(inputs, outputs, ownAddresses) + const toAddress = getAddressOf(output) + + const amountInfo = (() => { + const value = output?.value || output?.amount + const tokenId = value?.token_id || output?.token_id + const decimals = tokenId ? undefined : 11 + const amount = value?.amount?.decimal ?? output?.amount?.decimal ?? null + if (amount == null) return null + const ticker = tokenId + ? tokenMap?.[tokenId] || truncate(tokenId, 8, 6) + : coinTicker + const formatted = new Decimal(amount).toFixed( + decimals != null ? Math.min(decimals, 11) : 8, + ) + return { amount: formatted.replace(/\.?0+$/, ''), ticker } + })() + + const fee = jsonRepresentation?.fee?.decimal ?? null + + const typeFlag = SignTxHelpers.getTransactionType(jsonRepresentation, intent) + const label = + OPERATION_LABELS.find(([flag]) => flag === typeFlag)?.[1] ?? 'Transaction' + + return ( +
    +
    + {label} + {intent && Bridge} +
    + + {truncate(fromAddress, 10, 8)} + {fromAddress && } + , + ], + [ + 'To', + + {truncate(toAddress, 10, 8)} + {toAddress && } + , + ], + ...(amountInfo + ? [ + [ + 'Amount', + + {amountInfo.amount} {amountInfo.ticker} + , + ], + ] + : []), + ...(fee != null + ? [ + [ + 'Network fee', + + {fee} ML + , + ], + ] + : []), + ['Network', networkType === 'testnet' ? 'Testnet' : 'Mainnet'], + ]} + /> + + {intent && ( +
    + Bridge intent + {bounded(intent)} + +
    + )} + + + {showDetails && ( +
    + {technicalDetails} + {rawJsonNode} +
    + )} +
    + ) +} + +export default TransactionSummary diff --git a/src/components/containers/SignTransaction/TransactionSummary/TransactionSummary.module.css b/src/components/containers/SignTransaction/TransactionSummary/TransactionSummary.module.css new file mode 100644 index 00000000..b300048c --- /dev/null +++ b/src/components/containers/SignTransaction/TransactionSummary/TransactionSummary.module.css @@ -0,0 +1,95 @@ +.summary { + display: flex; + flex-direction: column; + gap: 12px; + background: var(--be-bg-1); + border: 1px solid var(--be-line-soft); + border-radius: 14px; + padding: 14px 16px; +} + +.row { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; +} + +.rowLabel { + color: var(--be-text-2); + font-size: var(--font-size-sm); + flex-shrink: 0; +} + +.rowValue { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--be-text-0); + font-family: var(--be-font-mono); + font-size: var(--font-size-sm); + overflow-wrap: anywhere; + min-width: 0; + justify-content: flex-end; + text-align: right; +} + +.valueLine { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.amount { + font-weight: 700; + color: var(--be-text-0); +} + +.intentRow { + display: flex; + align-items: center; + gap: 8px; + font-size: var(--font-size-xs); + color: var(--be-text-2); +} + +.intentValue { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.detailsToggle { + align-self: flex-start; + background: none; + border: none; + color: var(--be-text-2); + cursor: pointer; + font-size: var(--font-size-sm); + padding: 0; + text-decoration: underline; + text-underline-offset: 3px; +} + +.detailsToggle:hover { + color: var(--be-text-0); +} + +.technical { + border-top: 1px solid var(--be-line-soft); + padding-top: 10px; + margin-top: 4px; +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.headerLabel { + font-size: var(--font-size-md); + font-weight: 700; + color: var(--be-text-0); +} diff --git a/src/components/containers/SignTransaction/TransactionSummary/TransactionSummary.test.js b/src/components/containers/SignTransaction/TransactionSummary/TransactionSummary.test.js new file mode 100644 index 00000000..8656d88e --- /dev/null +++ b/src/components/containers/SignTransaction/TransactionSummary/TransactionSummary.test.js @@ -0,0 +1,154 @@ +import React from 'react' +import { render, screen, fireEvent } from '@testing-library/react' + +import { MintlayerContext } from '@Contexts' +import TransactionSummary from './TransactionSummary' + +jest.mock('@Contexts', () => { + const React = require('react') + const makeCtx = (value) => React.createContext(value) + return { + __esModule: true, + MintlayerContext: makeCtx({ tokenMap: {} }), + SettingsContext: makeCtx({ networkType: 'mainnet' }), + } +}) + +const OWN_RECEIVING = 'mtc1q0123456789abcdefownrec9876543210' +const OWN_CHANGE = 'mtc1q0123456789abcdefownchg9876543210' +const DESTINATION = 'mtc1qdestination9876543210abcdefwxyz' +const TOKEN_ID = + '0xdeadbeefcafe00000000000000000000000000000000000000000000000001' + +const OWN_ADDRESSES = { + receiving: [OWN_RECEIVING], + change: [OWN_CHANGE], +} + +// The component truncates as `head(10)…tail(8)` for long values. +const truncated = (address) => `${address.slice(0, 10)}…${address.slice(-8)}` + +const coin = (decimal) => ({ type: 'Coin', amount: { decimal } }) + +const transferJsonRepresentation = { + inputs: [ + { + input_type: 'UTXO', + utxo: { destination: OWN_RECEIVING, value: coin('100') }, + }, + ], + outputs: [{ type: 'Transfer', destination: DESTINATION, value: coin('25') }], + fee: { decimal: '0.01' }, +} + +const renderSummary = ({ + jsonRepresentation = transferJsonRepresentation, + intent, + ownAddresses = OWN_ADDRESSES, + technicalDetails =
    technical payload
    , + rawJsonNode =
    {'{"raw":true}'}
    , + mintlayerValue, +} = {}) => + render( + + + , + ) + +describe('TransactionSummary', () => { + it('renders a Send summary for a plain transfer', () => { + renderSummary() + + expect(screen.getByTestId('transaction-summary')).toBeInTheDocument() + expect(screen.getByText('Send')).toBeInTheDocument() + + expect(screen.getByText(truncated(OWN_RECEIVING))).toBeInTheDocument() + expect(screen.getByText(truncated(DESTINATION))).toBeInTheDocument() + expect(screen.getByText('25 ML')).toBeInTheDocument() + expect(screen.getByText('0.01 ML')).toBeInTheDocument() + expect(screen.getByText('Mainnet')).toBeInTheDocument() + + // one copy button next to From, one next to To + expect(screen.getAllByTestId('copy-btn')).toHaveLength(2) + }) + + it('hides the technical details until the toggle is clicked', () => { + renderSummary() + + expect(screen.queryByTestId('technical-details')).not.toBeInTheDocument() + expect(screen.queryByText('technical payload')).not.toBeInTheDocument() + + fireEvent.click(screen.getByTestId('toggle-technical-details')) + + const details = screen.getByTestId('technical-details') + expect(details).toBeInTheDocument() + expect(details).toHaveTextContent('technical payload') + expect(details).toHaveTextContent('{"raw":true}') + + // toggling again hides them + fireEvent.click(screen.getByTestId('toggle-technical-details')) + expect(screen.queryByTestId('technical-details')).not.toBeInTheDocument() + }) + + it('labels a transaction with an intent as a bridge request', () => { + const intent = + 'bridge::ml-to-mintlayer::destination-9876543210abcdefghijklmnopqrstuvwxyz' + renderSummary({ intent }) + + expect(screen.getByText('Bridge transaction')).toBeInTheDocument() + expect(screen.getByText('Bridge', { exact: true })).toBeInTheDocument() + + // the intent row renders with the (bounded) intent and its own copy button + expect(screen.getByText('Bridge intent')).toBeInTheDocument() + expect(screen.getByText(`${intent.slice(0, 64)}…`)).toBeInTheDocument() + expect(screen.getAllByTestId('copy-btn')).toHaveLength(3) + }) + + it('falls back to a generic header for an unknown operation', () => { + renderSummary({ + jsonRepresentation: { + ...transferJsonRepresentation, + outputs: [ + { + type: 'MysteryOperation', + destination: DESTINATION, + value: coin('25'), + }, + ], + }, + }) + + expect(screen.getByText('Transaction')).toBeInTheDocument() + }) + + it('uses the token ticker for a token transfer', () => { + const tokenValue = { + type: 'TokenV1', + token_id: TOKEN_ID, + amount: { decimal: '25' }, + } + renderSummary({ + jsonRepresentation: { + inputs: [ + { + input_type: 'UTXO', + utxo: { destination: OWN_RECEIVING, value: tokenValue }, + }, + ], + outputs: [ + { type: 'Transfer', destination: DESTINATION, value: tokenValue }, + ], + fee: { decimal: '0.01' }, + }, + mintlayerValue: { tokenMap: { [TOKEN_ID]: 'MLUSDC' } }, + }) + + expect(screen.getByText('25 MLUSDC')).toBeInTheDocument() + }) +}) diff --git a/src/components/containers/index.js b/src/components/containers/index.js index 61249da6..bba0beca 100644 --- a/src/components/containers/index.js +++ b/src/components/containers/index.js @@ -37,6 +37,7 @@ import VerifyMessage from './Message/VerifyMessage/VerifyMessage' import ExternalTransactionPreview from './SignTransaction/ExternalTransactionPreview/ExternalTransactionPreview' import InternalTransactionPreview from './SignTransaction/InternalTransactionPreview/InternalTransactionPreview' import JsonPreview from './SignTransaction/JsonPreview/JsonPreview' +import TransactionSummary from './SignTransaction/TransactionSummary/TransactionSummary' /* istanbul ignore next */ const Wallet = { @@ -82,6 +83,7 @@ const SignTransaction = { ExternalTransactionPreview, InternalTransactionPreview, JsonPreview, + TransactionSummary, } export { diff --git a/src/pages/SignExternalTransaction/SignExternalTransaction.js b/src/pages/SignExternalTransaction/SignExternalTransaction.js index 5c104e53..23932247 100644 --- a/src/pages/SignExternalTransaction/SignExternalTransaction.js +++ b/src/pages/SignExternalTransaction/SignExternalTransaction.js @@ -36,8 +36,6 @@ export const SignTransactionPage = () => { const [isSigning, setIsSigning] = useState(false) const [signError, setSignError] = useState('') - const [mode, setMode] = useState('preview') - const [selectedMock, setSelectedMock] = useState('transfer') const extraButtonStyles = ['buttonSignTransaction'] @@ -398,10 +396,6 @@ export const SignTransactionPage = () => { setGeneratedSecretHash(null) } - const switchHandle = () => { - setMode(mode === 'json' ? 'preview' : 'json') - } - const passwordChangeHandler = (value) => { setPassword(value) } @@ -427,10 +421,7 @@ export const SignTransactionPage = () => {
    -

    Sign Transaction

    - +

    Sign transaction

    @@ -459,14 +450,18 @@ export const SignTransactionPage = () => { )} {state?.request?.data?.txData?.JSONRepresentation && ( - <> - {mode === 'preview' && ( -
    - -
    - )} - {mode === 'json' && } - + + } + rawJsonNode={} + /> )} {/* HTLC Secret Information */} diff --git a/src/pages/SignInternalTransaction/SignInternalTransaction.js b/src/pages/SignInternalTransaction/SignInternalTransaction.js index 9b94648c..844b7df4 100644 --- a/src/pages/SignInternalTransaction/SignInternalTransaction.js +++ b/src/pages/SignInternalTransaction/SignInternalTransaction.js @@ -43,8 +43,6 @@ export const SignTransactionPage = () => { const loadingExtraClasses = ['loading-big'] const navigate = useNavigate() - const [mode, setMode] = useState('preview') - const [selectedMock, setSelectedMock] = useState('transfer') const extraButtonStyles = [styles.buttonSignTransaction] @@ -239,10 +237,6 @@ export const SignTransactionPage = () => { setSelectedMock(name) } - const switchHandle = () => { - setMode(mode === 'json' ? 'preview' : 'json') - } - const passwordChangeHandler = (value) => { setPassword(value) } @@ -251,10 +245,7 @@ export const SignTransactionPage = () => {
    -

    Sign Transaction

    - +

    Sign transaction

    @@ -276,14 +267,18 @@ export const SignTransactionPage = () => { )} {state?.request?.data?.txData?.JSONRepresentation && ( - <> - {mode === 'preview' && ( -
    - -
    - )} - {mode === 'json' && } - + + } + rawJsonNode={} + /> )}
    From aebc3f755281f3e6780f791ba8b2bcf96254b1d7 Mon Sep 17 00:00:00 2001 From: Enrico Rubboli Date: Tue, 8 Sep 2026 22:12:48 +0200 Subject: [PATCH 38/52] fix(security): propagate revocation to dApp tabs + reliable approval surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CRITICAL: revoking a connection in Settings deleted the grant but never told the site's open tabs — the page's SDK client kept the addresses in memory and could act on a dead grant (reported: bridge 'connected without authorization' after removal in Settings). - revocation propagation: disconnectSite and dApp 'disconnect' broadcast MOJITO_SESSION_REVOKED to all tabs; the content script relays it to the page as MINTLAYER_EVENT disconnect; window.mojito clears its cached addresses on it (with or without a page subscriber) - approvals can no longer silently fail: Chrome no-ops sidePanel.open() without a user gesture — the panel must now acknowledge it rendered the approval (approvalDisplayed) within 2s or the background falls back to a popup window, so a dApp never hangs with no approval surface - forged approvalDisplayed from web pages is ignored SECURITY CONTRACT (documented in background.js, mojito.js, ConnectionPage, SettingsConnections, bridge-contract.md): dApp connections are permission-level authorizations — every grant requires explicit user approval, every revocation is immediate, persisted and propagated, and no code path returns addresses without a live grant. Bridges must treat the disconnect event as authoritative and verify checkConnection before wallet-dependent actions. --- doc/bridge-contract.md | 15 ++ public/background.js | 82 +++++++ public/background.test.js | 204 ++++++++++++++++++ public/explorer/content-script.js | 16 ++ public/explorer/content-script.test.js | 79 +++++++ public/mojito.js | 18 ++ .../SettingsConnections.tsx | 4 + src/pages/ConnectionPage/ConnectionPage.js | 4 + 8 files changed, 422 insertions(+) diff --git a/doc/bridge-contract.md b/doc/bridge-contract.md index 121d2eed..9dd337e7 100644 --- a/doc/bridge-contract.md +++ b/doc/bridge-contract.md @@ -54,6 +54,21 @@ Notes: "no auto-restore"). - Restores use unique request ids; concurrent `restore()` calls all resolve. +## Connection lifecycle / revocation (authoritative) + +- A grant is created only by explicit user approval and is stored per-origin. +- Revoking it — from the wallet Settings, via `window.mojito.disconnect()`, + or on wallet lock/logout — is **immediate and persistent**: the wallet + deletes the session, and open tabs for that origin receive + `MINTLAYER_EVENT: disconnect`. +- Bridges MUST treat that event as authoritative: drop every cached + session/address from the SDK client. Do not trust an in-memory client + state that predates the revocation (this caused an incident where the + bridge "connected without authorization" from a stale client). +- Before performing wallet-dependent actions after a long-idle period, + verify with `client.isConnected()` **plus** a fresh + `window.mojito.request('checkConnection')`. + ## Error model All dApp-facing errors are `{ code, message }`; `window.mojito` rejects with diff --git a/public/background.js b/public/background.js index e9d82699..57e7e1af 100644 --- a/public/background.js +++ b/public/background.js @@ -1,6 +1,22 @@ /* eslint-disable no-undef */ /* global chrome */ +/** + * SECURITY CONTRACT — dApp connections are PERMISSION-LEVEL AUTHORIZATIONS. + * + * - A grant is created ONLY by an explicit user approval (handlePopupResponse + * with a connect result) and stored per-origin in `connectedSites`. + * - A grant is revoked IMMEDIATELY and durably on disconnect — from the + * dApp itself (`disconnect` method), from the wallet settings + * (`disconnectSite`), or on wallet lock/logout — and every revocation is + * persisted to storage and PROPAGATED to the site's open tabs + * (notifyOriginRevoked), so no page keeps acting on a dead grant. + * - No code path may return wallet addresses (getSession/connect/ + * checkConnection/signTransaction/signChallenge) without a live grant. + * - Any change to these handlers must keep this invariant and add a + * regression test (see public/background.test.js). + */ + ;(function () { // Detect browser API (Chrome or Firefox) const api = typeof browser !== 'undefined' ? browser : chrome @@ -89,6 +105,33 @@ // "install the wallet" hint when an error message matches /mojito/i. const errorOf = (code, message) => ({ code, message }) + // Side-panel approvals wait a short moment for the panel to confirm it + // actually rendered the request. Chrome silently no-ops sidePanel.open() + // when it is called without a user gesture — without this ack-or-fallback + // the dApp would hang with NO approval surface at all. + const PANEL_ACK_TIMEOUT_MS = 2000 + const approvalAcks = new Map() // requestId -> timeout id + + // Revocation must reach open dApp tabs: broadcast to every tab, the + // content script filters by its own origin. Rare + tiny, so spraying is + // acceptable and needs no extra permissions. + const notifyOriginRevoked = (origin) => { + if (!origin || !api.tabs?.query || !api.tabs?.sendMessage) return + api.tabs.query({}, (tabs) => { + if (api.runtime.lastError) return + for (const tab of tabs) { + if (typeof tab.id !== 'number') continue + api.tabs.sendMessage( + tab.id, + { type: 'MOJITO_SESSION_REVOKED', origin }, + () => { + // no content script in that tab — expected, ignore + }, + ) + } + }) + } + // Approval responses and disconnections must come from the wallet's own // pages, never from a content script injected into a website. const isFromExtensionPage = (sender) => @@ -152,9 +195,29 @@ return } + // Chrome silently no-ops sidePanel.open() without a user gesture: + // the promise resolves but nothing opens. If the panel does not + // confirm it rendered the approval within the timeout, fall back to + // a popup window so the dApp always gets an approval surface. + const ackTimer = setTimeout(() => { + approvalAcks.delete(request.requestId) + console.error( + '[Mojito] side panel did not display the approval — using a popup window', + ) + pendingResponses.delete(request.requestId) + slot.requestId = null + slot.panelMode = false + slot.windowId = null + api.storage.local.remove(pendingRequestKeyFor(windowId)) + onFallback() + }, PANEL_ACK_TIMEOUT_MS) + approvalAcks.set(request.requestId, ackTimer) + api.sidePanel .open({ tabId: sender.tab.id }) .then(() => { + // the panel displayed the approval; keep the slot open until + // the response arrives slot.opening = false }) .catch((error) => { @@ -162,6 +225,8 @@ '[Mojito] sidePanel.open failed, using a popup window:', error, ) + clearTimeout(approvalAcks.get(request.requestId)) + approvalAcks.delete(request.requestId) // roll back the panel registration and use a popup instead pendingResponses.delete(request.requestId) slot.requestId = null @@ -363,6 +428,21 @@ return false } + if (message.action === 'approvalDisplayed') { + // the panel confirmed it rendered the request: cancel the popup + // fallback for that request + if (!isFromExtensionPage(sender)) return false + const timer = approvalAcks.get(message.requestId) + if (timer) { + clearTimeout(timer) + approvalAcks.delete(message.requestId) + } + for (const slot of [connectSlot, popupSlot]) { + if (slot.requestId === message.requestId) slot.opening = false + } + return false + } + if (message.action === 'disconnectSite') { if (!isFromExtensionPage(sender)) return false @@ -380,6 +460,7 @@ sendResponse({ error: api.runtime.lastError.message }) return } + notifyOriginRevoked(targetOrigin) sendResponse({ result: { origin: targetOrigin } }) }) return true @@ -478,6 +559,7 @@ }) return } + notifyOriginRevoked(origin) sendResponse({ result: true }) }) return true diff --git a/public/background.test.js b/public/background.test.js index ebf3072d..23ab28dc 100644 --- a/public/background.test.js +++ b/public/background.test.js @@ -107,6 +107,11 @@ describe('background service worker', () => { sidePanel: { open: jest.fn().mockResolvedValue(undefined), }, + // used only by notifyOriginRevoked (revocation broadcast to open tabs) + tabs: { + query: jest.fn((query, cb) => cb([{ id: 1 }, { id: 2 }])), + sendMessage: jest.fn(), + }, } loadBackground() @@ -534,5 +539,204 @@ describe('background service worker', () => { // the busy answer must not have opened any approval window expect(createdWindows).toHaveLength(0) }) + + // Chrome silently no-ops sidePanel.open() without a user gesture: the + // promise resolves but nothing opens. The background therefore waits for + // an `approvalDisplayed` ack from the panel and falls back to a popup + // when it does not arrive within 2s. + describe('ack-or-fallback (silent sidePanel.open no-op)', () => { + const flushMicrotasks = async () => { + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + } + + it('cancels the popup fallback when the panel acks approvalDisplayed in time', async () => { + jest.useFakeTimers() + try { + const connect = dispatch( + { requestId: 'r1', method: 'connect', params: {} }, + tabDappSender, + ) + expect(connect.keptOpen).toBe(true) + + // let sidePanel.open resolve + await flushMicrotasks() + expect(createdWindows).toHaveLength(0) + + // the panel confirms it rendered the approval + dispatch( + { action: 'approvalDisplayed', requestId: 'r1' }, + extensionSender, + ) + + // the fallback window would have fired within this window + jest.advanceTimersByTime(2000) + await flushMicrotasks() + + // NO popup was created + expect(createdWindows).toHaveLength(0) + expect(storageData['pendingRequest:700']).toBeUndefined() + // the panel registration survives: the panel owns this request now + expect(storageData['pendingRequest:3']).toMatchObject({ + action: 'connect', + requestId: 'r1', + }) + // the dApp's channel is still open, awaiting the panel's decision + expect(connect.reply.current).toBeUndefined() + } finally { + jest.useRealTimers() + } + }) + + it('falls back to a popup window when no ack arrives within 2s', async () => { + jest.useFakeTimers() + try { + const connect = dispatch( + { requestId: 'r1', method: 'connect', params: {} }, + tabDappSender, + ) + expect(connect.keptOpen).toBe(true) + + await flushMicrotasks() + // sidePanel.open resolved, but the panel never acked + expect(createdWindows).toHaveLength(0) + + jest.advanceTimersByTime(2000) + await flushMicrotasks() + + // the panel registration was rolled back... + expect(storageData['pendingRequest:3']).toBeUndefined() + // ...and a POPUP fallback was created, re-keyed for its window + expect(createdWindows).toHaveLength(1) + expect(storageData['pendingRequest:700']).toMatchObject({ + action: 'connect', + origin: 'https://bridge.example', + requestId: 'r1', + }) + // the ORIGINAL dApp channel is still the one being answered + expect(connect.reply.current).toBeUndefined() + + // approving in the popup resolves the original channel + dispatch( + { + action: 'popupResponse', + method: 'connect', + requestId: 'r1', + origin: 'https://bridge.example', + windowId: 700, + result: sessionData, + }, + extensionSender, + ) + expect(connect.reply.current.result).toEqual(sessionData) + expect(storageData['pendingRequest:700']).toBeUndefined() + } finally { + jest.useRealTimers() + } + }) + + it('ignores an approvalDisplayed ack forged from a web page sender', async () => { + jest.useFakeTimers() + try { + dispatch( + { requestId: 'r1', method: 'connect', params: {} }, + tabDappSender, + ) + + await flushMicrotasks() + + // a dApp page tries to forge the panel's "displayed" ack + dispatch({ action: 'approvalDisplayed', requestId: 'r1' }, dappSender) + + jest.advanceTimersByTime(2000) + await flushMicrotasks() + + // the forged ack was ignored: the popup fallback happened anyway + expect(createdWindows).toHaveLength(1) + expect(storageData['pendingRequest:700']).toMatchObject({ + requestId: 'r1', + }) + expect(storageData['pendingRequest:3']).toBeUndefined() + } finally { + jest.useRealTimers() + } + }) + }) + }) + + describe('revocation propagation (disconnectSite)', () => { + const connectAndApprove = () => { + dispatch({ requestId: 'r1', method: 'connect', params: {} }, dappSender) + dispatch( + { + action: 'popupResponse', + method: 'connect', + requestId: 'r1', + origin: 'https://bridge.example', + windowId: 700, + result: sessionData, + }, + extensionSender, + ) + } + + it('broadcasts MOJITO_SESSION_REVOKED to every open tab and removes the grant', () => { + connectAndApprove() + + const { reply } = dispatch( + { action: 'disconnectSite', origin: 'https://bridge.example' }, + extensionSender, + ) + + expect(reply.current.result).toEqual({ + origin: 'https://bridge.example', + }) + // every tab is notified so no page keeps acting on the dead grant + expect(global.chrome.tabs.query).toHaveBeenCalledWith( + {}, + expect.any(Function), + ) + expect(global.chrome.tabs.sendMessage).toHaveBeenCalledTimes(2) + expect(global.chrome.tabs.sendMessage).toHaveBeenCalledWith( + 1, + { type: 'MOJITO_SESSION_REVOKED', origin: 'https://bridge.example' }, + expect.any(Function), + ) + expect(global.chrome.tabs.sendMessage).toHaveBeenCalledWith( + 2, + { type: 'MOJITO_SESSION_REVOKED', origin: 'https://bridge.example' }, + expect.any(Function), + ) + // and the grant is durably gone + expect( + storageData.connectedSites['https://bridge.example'], + ).toBeUndefined() + }) + + it('a revoked site can never shortcut-connect again (bypass regression)', () => { + connectAndApprove() + dispatch( + { action: 'disconnectSite', origin: 'https://bridge.example' }, + extensionSender, + ) + expect(storageData.connectedSites).toEqual({}) + + const reconnect = dispatch( + { requestId: 'r2', method: 'connect', params: {} }, + dappSender, + ) + + // NOT the stored session: the channel stays open for a fresh approval + expect(reconnect.reply.current).toBeUndefined() + expect(reconnect.keptOpen).toBe(true) + // a NEW approval window was created (the first was 700) + expect(createdWindows).toHaveLength(2) + expect(storageData['pendingRequest:701']).toMatchObject({ + action: 'connect', + origin: 'https://bridge.example', + requestId: 'r2', + }) + }) }) }) diff --git a/public/explorer/content-script.js b/public/explorer/content-script.js index 5b922f4c..d354f2e9 100644 --- a/public/explorer/content-script.js +++ b/public/explorer/content-script.js @@ -64,6 +64,22 @@ console.error('[Mojito] Extension context unavailable:', error.message) } + // Revocation propagation: the wallet notifies this tab when its origin's + // grant is revoked (settings disconnect / dApp disconnect). Relay it to + // the page so its SDK client drops the stale session. + api.runtime.onMessage.addListener((message) => { + if ( + message?.type === 'MOJITO_SESSION_REVOKED' && + message.origin === window.location.origin + ) { + postToPage({ + type: 'MINTLAYER_EVENT', + event: 'disconnect', + data: { origin: message.origin }, + }) + } + }) + window.addEventListener('message', (event) => { if (event.source !== window || event.data?.type !== 'MINTLAYER_REQUEST') { return diff --git a/public/explorer/content-script.test.js b/public/explorer/content-script.test.js index 4ddac05d..4aceb0f4 100644 --- a/public/explorer/content-script.test.js +++ b/public/explorer/content-script.test.js @@ -24,10 +24,14 @@ Object.defineProperty(MessageEvent.prototype, 'source', { const sendMessageCalls = [] let messageListeners = [] let addSpy +// Listeners the content script registers on chrome.runtime.onMessage +// (currently the MOJITO_SESSION_REVOKED relay), so tests can invoke them. +let onMessageListeners = [] const setup = ({ sendMessageImpl }) => { sendMessageCalls.length = 0 global.browser = undefined + onMessageListeners = [] global.chrome = { runtime: { id: 'ext-id', @@ -37,6 +41,9 @@ const setup = ({ sendMessageImpl }) => { sendMessageCalls.push(message) sendMessageImpl(message, callback) }, + onMessage: { + addListener: jest.fn((listener) => onMessageListeners.push(listener)), + }, lastError: null, }, } @@ -171,6 +178,7 @@ describe('runtime lastError path', () => { }) callback(undefined) }, + onMessage: { addListener: jest.fn() }, lastError: null, }, } @@ -228,3 +236,74 @@ describe('reload ownership', () => { window.removeEventListener('message', listener) }) }) + +describe('session revocation relay', () => { + const nextDisconnect = () => + new Promise((resolve) => { + const listener = (event) => { + if ( + event.data?.type === 'MINTLAYER_EVENT' && + event.data.event === 'disconnect' + ) { + window.removeEventListener('message', listener) + resolve(event.data) + } + } + window.addEventListener('message', listener) + }) + + it('relays MOJITO_SESSION_REVOKED from the background to the page as a disconnect event', async () => { + setup({ + sendMessageImpl: (_message, callback) => { + callback({ result: null }) + }, + }) + + // The content script registered exactly one runtime.onMessage listener + expect(onMessageListeners).toHaveLength(1) + + const incoming = nextDisconnect() + onMessageListeners[0]({ + type: 'MOJITO_SESSION_REVOKED', + origin: window.location.origin, + }) + + await expect(incoming).resolves.toMatchObject({ + type: 'MINTLAYER_EVENT', + event: 'disconnect', + data: { origin: window.location.origin }, + }) + }) + + it('ignores revocation messages for other origins or of other types', async () => { + setup({ + sendMessageImpl: (_message, callback) => { + callback({ result: null }) + }, + }) + + const disconnects = [] + const listener = (event) => { + if ( + event.data?.type === 'MINTLAYER_EVENT' && + event.data.event === 'disconnect' + ) { + disconnects.push(event.data) + } + } + window.addEventListener('message', listener) + + onMessageListeners[0]({ + type: 'MOJITO_SESSION_REVOKED', + origin: 'https://other-origin.example', + }) + onMessageListeners[0]({ + type: 'SOMETHING_ELSE', + origin: window.location.origin, + }) + await new Promise((resolve) => setTimeout(resolve, 10)) + window.removeEventListener('message', listener) + + expect(disconnects).toHaveLength(0) + }) +}) diff --git a/public/mojito.js b/public/mojito.js index 167088f8..41ade608 100644 --- a/public/mojito.js +++ b/public/mojito.js @@ -1,4 +1,10 @@ // mojito.js — injected SDK +// +// SECURITY NOTE: a wallet connection is a PERMISSION-LEVEL AUTHORIZATION. +// This provider must never return addresses without a live grant, and when +// the wallet revokes (settings disconnect / dApp disconnect) this provider +// clears its cached state and notifies the page so no stale session keeps +// acting as connected. // eslint-disable-next-line no-extra-semi ;(function () { @@ -171,5 +177,17 @@ if (!window.mojito) { window.mojito = mojito console.log('[Mojito] SDK injected') + + // A revoked grant clears this provider's cached addresses immediately, + // whether or not the page subscribed to the event. + window.addEventListener('message', (event) => { + if (event.source !== window) return + if ( + event.data?.type === 'MINTLAYER_EVENT' && + event.data.event === 'disconnect' + ) { + mojito.connectedAddresses = [] + } + }) } })() diff --git a/src/components/containers/Settings/SettingsConnections/SettingsConnections.tsx b/src/components/containers/Settings/SettingsConnections/SettingsConnections.tsx index 630315dd..0d484a9b 100644 --- a/src/components/containers/Settings/SettingsConnections/SettingsConnections.tsx +++ b/src/components/containers/Settings/SettingsConnections/SettingsConnections.tsx @@ -51,6 +51,10 @@ const SettingsConnections = () => { return () => storage.onChanged.removeListener(onStorageChanged) }, [readSites]) + // SECURITY: revoking a connection is a permission revocation — it must + // immediately delete the grant (storage + the service worker's session + // map) and propagate to the site's open tabs. Never leave a half-removed + // grant behind. const disconnectHandler = (origin: string) => { if (!runtime) return diff --git a/src/pages/ConnectionPage/ConnectionPage.js b/src/pages/ConnectionPage/ConnectionPage.js index 3d80619f..c7448878 100644 --- a/src/pages/ConnectionPage/ConnectionPage.js +++ b/src/pages/ConnectionPage/ConnectionPage.js @@ -58,6 +58,10 @@ export const ConnectionPage = () => { const connectButtonExtraStyles = [styles.actionButton] + // SECURITY: approving here creates a PERMISSION-LEVEL grant — the site + // receives wallet addresses and public keys and can request signatures. + // Only include data the site explicitly asked for, and remember every + // grant is revocable in Settings → Connections. const handleConnect = () => { if (!hasWalletAddresses) return From c07a6b6f7a2f90f67e789345f051ceb38fc5342c Mon Sep 17 00:00:00 2001 From: Enrico Rubboli Date: Tue, 8 Sep 2026 22:36:14 +0200 Subject: [PATCH 39/52] fix(bridge): self-healing content scripts across extension reloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After an extension reload/update, Chrome wipes the old content scripts (and the page-world window.mojito) from already-open tabs and re-injects at nondeterministic times — a dApp's first connect after a reload failed with 'wallet not found', and the second click worked only once the lazy re-injection caught up. - on runtime.onInstalled (install/update/reload) and browser startup, the background sweeps every open tab: pings the content script (MOJITO_PING); no live response -> re-injects it via chrome.scripting (already-permitted), so open dApp tabs self-heal without a page reload - content script answers MOJITO_PING - regression tests: sweep pings + re-injects only dead tabs (skips alive ones and non-tab ids), ping responder, revocation relay origin/type filtering (harness now dispatches to ALL listeners like Chrome does) --- public/background.js | 47 ++++++++++++++++++++ public/explorer/content-script.js | 8 ++++ public/explorer/content-script.test.js | 61 ++++++++++++++++---------- 3 files changed, 94 insertions(+), 22 deletions(-) diff --git a/public/background.js b/public/background.js index 57e7e1af..a7f15c64 100644 --- a/public/background.js +++ b/public/background.js @@ -55,6 +55,53 @@ }) } + // --- Self-healing content-script injection ------------------------------- + // When the extension is reloaded/updated, Chrome wipes the old content + // scripts (and the page-world window.mojito) from already-open tabs and + // re-injects at nondeterministic times — so a dApp's first connect after + // a reload can fail with "wallet not found". On install/update/reload + // (runtime.onInstalled) and browser start (onStartup), ping every open + // tab's content script and re-inject where it is dead. + const CONTENT_SCRIPT_FILE = 'explorer/content-script.js' + + const ensureContentScript = (tabId) => { + if (typeof tabId !== 'number') return + api.tabs.sendMessage(tabId, { type: 'MOJITO_PING' }, () => { + // lastError = no live content script in that tab: inject a fresh one. + // (Restricted pages like chrome:// simply error here too — ignored.) + if (!api.runtime.lastError) return + api.scripting + .executeScript({ + target: { tabId }, + files: [CONTENT_SCRIPT_FILE], + }) + .catch((error) => { + console.error( + '[Mintlayer] content-script re-injection failed:', + error.message, + ) + }) + }) + } + + const sweepAllTabs = () => { + api.tabs.query({}, (tabs) => { + if (api.runtime.lastError) return + for (const tab of tabs) { + if (typeof tab.id === 'number') ensureContentScript(tab.id) + } + }) + } + + api.runtime.onInstalled.addListener(() => { + sweepAllTabs() + }) + if (api.runtime.onStartup) { + api.runtime.onStartup.addListener(() => { + sweepAllTabs() + }) + } + // Load connected sites from storage. Messages arriving before the load // completes are queued: acting on a half-loaded session map would tell // already-connected sites they are NOT_CONNECTED. diff --git a/public/explorer/content-script.js b/public/explorer/content-script.js index d354f2e9..11c763ac 100644 --- a/public/explorer/content-script.js +++ b/public/explorer/content-script.js @@ -64,6 +64,14 @@ console.error('[Mojito] Extension context unavailable:', error.message) } + // Self-healing ping: the background pings every tab on extension + // install/update/reload and re-injects this script where the ping fails. + api.runtime.onMessage.addListener((message, _sender, sendResponse) => { + if (message?.type === 'MOJITO_PING') { + sendResponse({ pong: true }) + } + }) + // Revocation propagation: the wallet notifies this tab when its origin's // grant is revoked (settings disconnect / dApp disconnect). Relay it to // the page so its SDK client drops the stale session. diff --git a/public/explorer/content-script.test.js b/public/explorer/content-script.test.js index 4aceb0f4..787f07f2 100644 --- a/public/explorer/content-script.test.js +++ b/public/explorer/content-script.test.js @@ -25,7 +25,8 @@ const sendMessageCalls = [] let messageListeners = [] let addSpy // Listeners the content script registers on chrome.runtime.onMessage -// (currently the MOJITO_SESSION_REVOKED relay), so tests can invoke them. +// (the self-healing MOJITO_PING responder and the MOJITO_SESSION_REVOKED +// relay), so tests can dispatch background messages to them. let onMessageListeners = [] const setup = ({ sendMessageImpl }) => { @@ -84,6 +85,17 @@ const nextMessage = () => window.addEventListener('message', listener) }) +// Chrome delivers a runtime message to EVERY listener the context has +// registered, each with its own sendResponse; unrelated listeners simply +// ignore messages that are not theirs. The content script registers more +// than one handler (the MOJITO_PING responder and the revocation relay), +// so tests must dispatch to all of them instead of poking one by index. +const dispatchFromBackground = (message) => { + for (const listener of onMessageListeners) { + listener(message, { id: 'ext-id' }, jest.fn()) + } +} + describe('content script relay', () => { it('relays a page request to the background and back', async () => { setup({ @@ -238,20 +250,6 @@ describe('reload ownership', () => { }) describe('session revocation relay', () => { - const nextDisconnect = () => - new Promise((resolve) => { - const listener = (event) => { - if ( - event.data?.type === 'MINTLAYER_EVENT' && - event.data.event === 'disconnect' - ) { - window.removeEventListener('message', listener) - resolve(event.data) - } - } - window.addEventListener('message', listener) - }) - it('relays MOJITO_SESSION_REVOKED from the background to the page as a disconnect event', async () => { setup({ sendMessageImpl: (_message, callback) => { @@ -259,16 +257,32 @@ describe('session revocation relay', () => { }, }) - // The content script registered exactly one runtime.onMessage listener - expect(onMessageListeners).toHaveLength(1) + // The script registers the self-healing MOJITO_PING responder plus + // this revocation relay; the background message reaches every listener + expect(onMessageListeners.length).toBeGreaterThanOrEqual(2) + + const disconnects = [] + const listener = (event) => { + if ( + event.data?.type === 'MINTLAYER_EVENT' && + event.data.event === 'disconnect' + ) { + disconnects.push(event.data) + } + } + window.addEventListener('message', listener) - const incoming = nextDisconnect() - onMessageListeners[0]({ + dispatchFromBackground({ type: 'MOJITO_SESSION_REVOKED', origin: window.location.origin, }) + await new Promise((resolve) => setTimeout(resolve, 10)) + window.removeEventListener('message', listener) - await expect(incoming).resolves.toMatchObject({ + // Exactly one listener reacts — the revocation relay, not the ping + // responder — and it relays the origin to the page. + expect(disconnects).toHaveLength(1) + expect(disconnects[0]).toMatchObject({ type: 'MINTLAYER_EVENT', event: 'disconnect', data: { origin: window.location.origin }, @@ -293,11 +307,14 @@ describe('session revocation relay', () => { } window.addEventListener('message', listener) - onMessageListeners[0]({ + // Dispatch to every registered listener (as Chrome does): the ping + // responder ignores these messages, and the revocation relay must + // filter by type and origin. + dispatchFromBackground({ type: 'MOJITO_SESSION_REVOKED', origin: 'https://other-origin.example', }) - onMessageListeners[0]({ + dispatchFromBackground({ type: 'SOMETHING_ELSE', origin: window.location.origin, }) From 1ee612c632f765e9b22864f19a178ad1b1ebef2e Mon Sep 17 00:00:00 2001 From: Enrico Rubboli Date: Tue, 8 Sep 2026 22:36:39 +0200 Subject: [PATCH 40/52] test(bridge): cover the self-healing content-script sweep - onInstalled/onStartup sweeps ping every open tab (MOJITO_PING) and re-inject content-script.js only where the ping fails (lastError set); alive content scripts are skipped and non-tab ids never touched - onStartup sweep covered separately --- public/background.test.js | 135 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 135 insertions(+) diff --git a/public/background.test.js b/public/background.test.js index 23ab28dc..1861233c 100644 --- a/public/background.test.js +++ b/public/background.test.js @@ -72,8 +72,15 @@ describe('background service worker', () => { onMessage: { addListener: (fn) => messageListeners.push(fn), }, + // self-healing sweep registers itself here on load + onInstalled: { addListener: jest.fn() }, + onStartup: { addListener: jest.fn() }, lastError: null, }, + // used by the sweep to re-inject dead content scripts + scripting: { + executeScript: jest.fn().mockResolvedValue([]), + }, storage: { local: { get: (keys, cb) => { @@ -739,4 +746,132 @@ describe('background service worker', () => { }) }) }) + + describe('self-healing content-script injection', () => { + const CONTENT_SCRIPT_FILE = 'explorer/content-script.js' + + // A reload/update wipes content scripts from already-open tabs. The + // sweep pings every tab and re-injects where the ping goes unanswered + // (MV3 signals that via runtime.lastError, not a throw). + const useSweepTabs = () => { + // includes a tab with a non-numeric id: the sweep must skip it + global.chrome.tabs.query.mockImplementation((query, cb) => + cb([{ id: 1 }, { id: 2 }, { id: 'no-id' }]), + ) + } + + const registeredListener = (event) => { + expect(event.addListener).toHaveBeenCalledTimes(1) + return event.addListener.mock.calls[0][0] + } + + // Fire the callbacks the sweep stored on tabs.sendMessage. lastError is + // shared mutable state across the whole chrome mock: set it only around + // each invocation and delete it afterwards so nothing leaks into later + // callbacks or tests. + const answerPings = ({ withLastError }) => { + for (const call of global.chrome.tabs.sendMessage.mock.calls) { + const sendCallback = call[2] + if (typeof sendCallback !== 'function') continue + if (withLastError) { + global.chrome.runtime.lastError = { message: 'context invalidated' } + } + try { + sendCallback() + } finally { + delete global.chrome.runtime.lastError + } + } + } + + afterEach(() => { + delete global.chrome.runtime.lastError + }) + + it('sweeps every open tab on install/update and re-injects where the content script is dead', () => { + useSweepTabs() + const onInstalledListener = registeredListener( + global.chrome.runtime.onInstalled, + ) + + onInstalledListener() + + // every numerically-id'd tab was pinged... + expect(global.chrome.tabs.sendMessage).toHaveBeenCalledTimes(2) + expect(global.chrome.tabs.sendMessage).toHaveBeenCalledWith( + 1, + { type: 'MOJITO_PING' }, + expect.any(Function), + ) + expect(global.chrome.tabs.sendMessage).toHaveBeenCalledWith( + 2, + { type: 'MOJITO_PING' }, + expect.any(Function), + ) + // ...but the non-numeric-id tab was never touched + expect(global.chrome.tabs.sendMessage).not.toHaveBeenCalledWith( + 'no-id', + expect.anything(), + expect.anything(), + ) + // pings alone inject nothing: injection happens only when a ping + // comes back with lastError (the default mock never answers) + expect(global.chrome.scripting.executeScript).not.toHaveBeenCalled() + + // the pings now answer as dead content scripts (extension reloaded) + answerPings({ withLastError: true }) + + expect(global.chrome.scripting.executeScript).toHaveBeenCalledTimes(2) + expect(global.chrome.scripting.executeScript).toHaveBeenCalledWith({ + target: { tabId: 1 }, + files: [CONTENT_SCRIPT_FILE], + }) + expect(global.chrome.scripting.executeScript).toHaveBeenCalledWith({ + target: { tabId: 2 }, + files: [CONTENT_SCRIPT_FILE], + }) + }) + + it('does not re-inject when the content script is alive (no lastError)', () => { + useSweepTabs() + registeredListener(global.chrome.runtime.onInstalled)() + + expect(global.chrome.tabs.sendMessage).toHaveBeenCalledTimes(2) + + // every ping answers cleanly: the content scripts are alive + answerPings({ withLastError: false }) + + expect(global.chrome.scripting.executeScript).not.toHaveBeenCalled() + }) + + it('runs the same sweep on browser start (onStartup)', () => { + useSweepTabs() + const onStartupListener = registeredListener( + global.chrome.runtime.onStartup, + ) + + onStartupListener() + + expect(global.chrome.tabs.sendMessage).toHaveBeenCalledTimes(2) + expect(global.chrome.tabs.sendMessage).toHaveBeenCalledWith( + 1, + { type: 'MOJITO_PING' }, + expect.any(Function), + ) + expect(global.chrome.tabs.sendMessage).toHaveBeenCalledWith( + 2, + { type: 'MOJITO_PING' }, + expect.any(Function), + ) + expect(global.chrome.tabs.sendMessage).not.toHaveBeenCalledWith( + 'no-id', + expect.anything(), + expect.anything(), + ) + + // dead content scripts on startup get re-injected too + answerPings({ withLastError: true }) + expect(global.chrome.scripting.executeScript).toHaveBeenCalledTimes(2) + }) + }) }) From c11c5702e80122b4073ae3bd692eac3a044548a0 Mon Sep 17 00:00:00 2001 From: Enrico Rubboli Date: Wed, 9 Sep 2026 07:54:45 +0200 Subject: [PATCH 41/52] chore(deps): clear Dependabot advisories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit npm audit fix + targeted overrides clear 35 of the 39 reported advisories (all dev tooling: babel chain, brace-expansion, ajv, browserslist, body-parser, @humanfs, baseline-browser-mapping, websocket-driver/sockjs, qs, serialize-javascript, uuid — the latter three via semver-compatible overrides since the parents pin vulnerable ranges). Remaining 4 low findings are the elliptic GHSA-848j advisory inside the crypto-browserify webpack polyfill — no upstream fix exists (npm's own suggestion is downgrading crypto-browserify, which is strictly worse). Accepted risk: the polyfill is a build-time shim; wallet cryptography runs on the vendored wasm lib and noble curves. Documented in REVIEW-PLAN.md. --- package-lock.json | 807 ++++++++++++++++++++++++---------------------- package.json | 5 + 2 files changed, 418 insertions(+), 394 deletions(-) diff --git a/package-lock.json b/package-lock.json index 18dce957..35ae7103 100644 --- a/package-lock.json +++ b/package-lock.json @@ -114,13 +114,13 @@ "license": "ISC" }, "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -129,9 +129,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", - "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "dev": true, "license": "MIT", "engines": { @@ -139,21 +139,21 @@ } }, "node_modules/@babel/core": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", - "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.5", - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-module-transforms": "^7.28.3", - "@babel/helpers": "^7.28.4", - "@babel/parser": "^7.28.5", - "@babel/template": "^7.27.2", - "@babel/traverse": "^7.28.5", - "@babel/types": "^7.28.5", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -170,14 +170,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -200,14 +200,14 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -274,9 +274,9 @@ } }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "dev": true, "license": "MIT", "engines": { @@ -298,29 +298,29 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -343,9 +343,9 @@ } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", - "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", "dev": true, "license": "MIT", "engines": { @@ -403,9 +403,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, "license": "MIT", "engines": { @@ -413,9 +413,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", "engines": { @@ -423,9 +423,9 @@ } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "dev": true, "license": "MIT", "engines": { @@ -448,27 +448,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", - "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.4" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", - "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.8" }, "bin": { "parser": "bin/babel-parser.js" @@ -1268,16 +1268,16 @@ } }, "node_modules/@babel/plugin-transform-modules-systemjs": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.0.tgz", - "integrity": "sha512-PrujnVFbOdUpw4UHiVwKvKRLMMic8+eC0CuNlxjsyZUiBjhFdPsewdXCkveh2KqBA9/waD0W1b4hXSOBQJezpQ==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.8.tgz", + "integrity": "sha512-6iSnEK0zlkLKU4heofK/AdmRD4e2SHVpJMtrwnTCzhnaM98ria4rTrOXBBi45BTTYnJtO8txnPsX4fChYXkmeA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.29.0" + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.8" }, "engines": { "node": ">=6.9.0" @@ -1959,33 +1959,33 @@ } }, "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", "debug": "^4.3.1" }, "engines": { @@ -1993,14 +1993,14 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -2326,29 +2326,43 @@ } }, "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", "dev": true, "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, "engines": { "node": ">=18.18.0" } }, "node_modules/@humanfs/node": { - "version": "0.16.7", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", - "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@humanfs/core": "^0.19.1", + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" }, "engines": { "node": ">=18.18.0" } }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", @@ -2539,9 +2553,9 @@ } }, "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "version": "3.15.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.2.tgz", + "integrity": "sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==", "dev": true, "license": "MIT", "dependencies": { @@ -2716,9 +2730,9 @@ } }, "node_modules/@jest/console/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { @@ -2978,9 +2992,9 @@ } }, "node_modules/@jest/core/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { @@ -3166,9 +3180,9 @@ } }, "node_modules/@jest/environment-jsdom-abstract/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { @@ -3356,9 +3370,9 @@ } }, "node_modules/@jest/fake-timers/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { @@ -3560,9 +3574,9 @@ } }, "node_modules/@jest/reporters/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", "dev": true, "license": "MIT", "dependencies": { @@ -3642,13 +3656,13 @@ } }, "node_modules/@jest/reporters/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -3658,9 +3672,9 @@ } }, "node_modules/@jest/reporters/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { @@ -3998,9 +4012,9 @@ } }, "node_modules/@jest/test-sequencer/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { @@ -4204,9 +4218,9 @@ } }, "node_modules/@jest/transform/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { @@ -5389,16 +5403,6 @@ "@testing-library/dom": ">=7.21.4" } }, - "node_modules/@trysound/sax": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz", - "integrity": "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10.13.0" - } - }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -6300,16 +6304,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { @@ -6965,9 +6969,9 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", "dependencies": { @@ -7000,9 +7004,9 @@ } }, "node_modules/ajv-formats/node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "dev": true, "license": "MIT", "dependencies": { @@ -7555,13 +7559,16 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.9.11", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz", - "integrity": "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==", + "version": "2.11.21", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.21.tgz", + "integrity": "sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ==", "dev": true, "license": "Apache-2.0", "bin": { - "baseline-browser-mapping": "dist/cli.js" + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" } }, "node_modules/basic-auth": { @@ -7643,15 +7650,15 @@ } }, "node_modules/bip32": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/bip32/-/bip32-5.0.0.tgz", - "integrity": "sha512-h043yQ9n3iU4WZ8KLRpEECMl3j1yx2DQ1kcPlzWg8VZC0PtukbDiyLDKbe6Jm79mL6Tfg+WFuZMYxnzVyr/Hyw==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/bip32/-/bip32-5.0.1.tgz", + "integrity": "sha512-PWlHIAgYCfVhwqNpZyeakHXuLAGyN6rEQZnhxHxKI3BoFJRVWLl26455fhRlHsmbYcV986HqtPnt33Edu5sTCw==", "license": "MIT", "dependencies": { "@noble/hashes": "^1.2.0", "@scure/base": "^1.1.1", "uint8array-tools": "^0.0.8", - "valibot": "^0.37.0", + "valibot": "^1.2.0", "wif": "^5.0.0" }, "engines": { @@ -7703,20 +7710,6 @@ "node": ">=14.0.0" } }, - "node_modules/bitcoinjs-lib/node_modules/valibot": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.2.0.tgz", - "integrity": "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==", - "license": "MIT", - "peerDependencies": { - "typescript": ">=5" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, "node_modules/bl": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", @@ -7779,9 +7772,9 @@ "license": "MIT" }, "node_modules/body-parser": { - "version": "1.20.4", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", - "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "version": "1.20.8", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz", + "integrity": "sha512-JNcyFQ64OiijEkPzUBTCe+hyPXUD/3LEldGQ6iF5LR1w00mx9o7xtDWHXBY2iItjdCFGoilOLNQbH943ut7pHA==", "dev": true, "license": "MIT", "dependencies": { @@ -7793,7 +7786,7 @@ "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", - "qs": "~6.14.0", + "qs": "~6.16.0", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" @@ -7852,9 +7845,9 @@ "license": "ISC" }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -7933,12 +7926,12 @@ } }, "node_modules/browserify-sign": { - "version": "4.2.5", - "resolved": "https://registry.npmjs.org/browserify-sign/-/browserify-sign-4.2.5.tgz", - "integrity": "sha512-C2AUdAJg6rlM2W5QMp2Q4KGQMVBwR1lIimTsUnutJ8bMpW5B52pGpR2gEnNBNwijumDo5FojQ0L9JrXA8m4YEw==", + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/browserify-sign/-/browserify-sign-4.2.6.tgz", + "integrity": "sha512-sd+Q65fjlWCYWtZKXiKfrUc8d+4jtp/8f0W2NkwzLtoW4bI6UDnWusLWIurHnmurW0XShIRxpwiOX4EoPtXUAg==", "license": "ISC", "dependencies": { - "bn.js": "^5.2.2", + "bn.js": "^5.2.3", "browserify-rsa": "^4.1.1", "create-hash": "^1.2.0", "create-hmac": "^1.1.7", @@ -7953,9 +7946,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "version": "4.28.9", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.9.tgz", + "integrity": "sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==", "dev": true, "funding": [ { @@ -7973,11 +7966,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" + "baseline-browser-mapping": "^2.11.20", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.420", + "node-releases": "^2.0.54", + "update-browserslist-db": "^1.3.2" }, "bin": { "browserslist": "cli.js" @@ -8170,9 +8163,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001762", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001762.tgz", - "integrity": "sha512-PxZwGNvH7Ak8WX5iXzoK1KPZttBXNPuaOvI2ZYU7NrlM+d9Ov+TUvlLOBNGzVXAntMSMMlJPd+jY6ovrVjSmUw==", + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", "dev": true, "funding": [ { @@ -9809,20 +9802,6 @@ "node": ">=14.0.0" } }, - "node_modules/ecpair/node_modules/valibot": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.4.2.tgz", - "integrity": "sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==", - "license": "MIT", - "peerDependencies": { - "typescript": ">=5" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -9831,9 +9810,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.267", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", - "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", + "version": "1.5.425", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.425.tgz", + "integrity": "sha512-QvPtl41EUOnuT1HBvMKgxXRIaHNcagBPs50u7VULzhZXaGfqTbZyE16LQsctZ/RQHlGu+FOWeDTR4mY6YbeF1g==", "dev": true, "license": "ISC" }, @@ -10817,9 +10796,9 @@ } }, "node_modules/expect/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { @@ -10830,15 +10809,15 @@ } }, "node_modules/express": { - "version": "4.22.1", - "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", - "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", "dev": true, "license": "MIT", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "~1.20.3", + "body-parser": "~1.20.5", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", @@ -10857,7 +10836,7 @@ "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", - "qs": "~6.14.0", + "qs": "~6.15.1", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", @@ -10935,9 +10914,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", + "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", "dev": true, "funding": [ { @@ -11088,16 +11067,16 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", "dev": true, "license": "ISC" }, "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "dev": true, "funding": [ { @@ -11841,9 +11820,9 @@ } }, "node_modules/http-proxy-middleware": { - "version": "2.0.9", - "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.9.tgz", - "integrity": "sha512-c1IyJYLYppU574+YI7R4QyX2ystMtVXZwIdzazUIPIJsHuWNd+mho2j+bKoHftndicGj9yh+xjd+l0yj7VeT1Q==", + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.10.tgz", + "integrity": "sha512-RKzRWNPxUZqbuk3BC5mGVJbBnWgr+diEnjJexIOytFbBzDy88Fbh/YvBr3DsNrl1jYAfjWfpATEv0NO35FDuPQ==", "dev": true, "license": "MIT", "dependencies": { @@ -13011,9 +12990,9 @@ } }, "node_modules/jest-changed-files/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { @@ -13152,9 +13131,9 @@ } }, "node_modules/jest-circus/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { @@ -13334,9 +13313,9 @@ } }, "node_modules/jest-cli/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { @@ -13483,9 +13462,9 @@ } }, "node_modules/jest-config/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", "dev": true, "license": "MIT", "dependencies": { @@ -13653,13 +13632,13 @@ } }, "node_modules/jest-config/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -13669,9 +13648,9 @@ } }, "node_modules/jest-config/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { @@ -13917,9 +13896,9 @@ } }, "node_modules/jest-each/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { @@ -14110,9 +14089,9 @@ } }, "node_modules/jest-environment-node/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { @@ -14488,9 +14467,9 @@ } }, "node_modules/jest-mock/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { @@ -14778,9 +14757,9 @@ } }, "node_modules/jest-runner/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { @@ -14946,9 +14925,9 @@ } }, "node_modules/jest-runtime/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", "dev": true, "license": "MIT", "dependencies": { @@ -15116,13 +15095,13 @@ } }, "node_modules/jest-runtime/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -15132,9 +15111,9 @@ } }, "node_modules/jest-runtime/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { @@ -15312,9 +15291,9 @@ } }, "node_modules/jest-snapshot/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { @@ -15463,9 +15442,9 @@ } }, "node_modules/jest-watcher/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { @@ -15573,10 +15552,20 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -15774,14 +15763,14 @@ "license": "MIT" }, "node_modules/launch-editor": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.12.0.tgz", - "integrity": "sha512-giOHXoOtifjdHqUamwKq6c49GzBdLjvxrd2D+Q4V6uOHopJv7p9VJxikDsQ/CBXZbEITgUqSVHXLTG3VhPP1Dg==", + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.14.1.tgz", + "integrity": "sha512-QWBrQsMpH7gPr965dsKD/3cKWiNoTjpATQf++Xq63N6sKRGMwlVXz41O1IZTMfZQgBctD/K5Zt06+/I6pP6+HA==", "dev": true, "license": "MIT", "dependencies": { "picocolors": "^1.1.1", - "shell-quote": "^1.8.3" + "shell-quote": "^1.8.4" } }, "node_modules/leven": { @@ -15861,9 +15850,9 @@ } }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "dev": true, "license": "MIT" }, @@ -16132,9 +16121,9 @@ "license": "MIT" }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -16211,9 +16200,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "dev": true, "funding": [ { @@ -16343,11 +16332,14 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "version": "2.0.54", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz", + "integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/normalize-path": { "version": "3.0.0", @@ -16850,9 +16842,9 @@ "license": "ISC" }, "node_modules/path-to-regexp": { - "version": "0.1.12", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", - "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", "dev": true, "license": "MIT" }, @@ -16891,9 +16883,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { @@ -17069,9 +17061,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", "dev": true, "funding": [ { @@ -17089,7 +17081,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -17128,20 +17120,6 @@ "postcss": "^8.1.0" } }, - "node_modules/postcss-modules-local-by-default/node_modules/postcss-selector-parser": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", - "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/postcss-modules-scope": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.2.1.tgz", @@ -17158,20 +17136,6 @@ "postcss": "^8.1.0" } }, - "node_modules/postcss-modules-scope/node_modules/postcss-selector-parser": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", - "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/postcss-modules-values": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz", @@ -17188,6 +17152,20 @@ "postcss": "^8.1.0" } }, + "node_modules/postcss-selector-parser": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", + "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/postcss-value-parser": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", @@ -17325,9 +17303,9 @@ } }, "node_modules/pretty-quick/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { @@ -17477,13 +17455,14 @@ "license": "MIT" }, "node_modules/qs": { - "version": "6.14.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", - "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", "dev": true, "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.1.0" + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" }, "engines": { "node": ">=0.6" @@ -18149,6 +18128,16 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/sax": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz", + "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, "node_modules/saxes": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", @@ -18189,9 +18178,9 @@ } }, "node_modules/schema-utils/node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "dev": true, "license": "MIT", "dependencies": { @@ -18306,13 +18295,13 @@ "license": "MIT" }, "node_modules/serialize-javascript": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", - "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.1.1.tgz", + "integrity": "sha512-k3CMsaIvvdSwm8oLB4MXSl0wH2/cwlH7xGcnRd2DaeRmBkbzYmyT8j0tsX60DwD1eRwHTpNpH8ljKu9oUT1MeQ==", "dev": true, "license": "BSD-3-Clause", - "dependencies": { - "randombytes": "^2.1.0" + "engines": { + "node": ">=20.0.0" } }, "node_modules/serve-index": { @@ -18535,9 +18524,9 @@ } }, "node_modules/shell-quote": { - "version": "1.8.3", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", - "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.10.0.tgz", + "integrity": "sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==", "dev": true, "license": "MIT", "engines": { @@ -18548,15 +18537,15 @@ } }, "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" }, @@ -18568,14 +18557,14 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "object-inspect": "^1.13.4" }, "engines": { "node": ">= 0.4" @@ -19170,19 +19159,19 @@ "license": "MIT" }, "node_modules/svgo": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/svgo/-/svgo-3.3.2.tgz", - "integrity": "sha512-OoohrmuUlBs8B8o6MB2Aevn+pRIH9zDALSR+6hhqVfa6fRwG/Qw9VUMSMW9VNg2CFc/MTIfabtdOVl9ODIJjpw==", + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-3.3.5.tgz", + "integrity": "sha512-8SQMzdrvWaD8deUmrnYB+ASyxBVgWUOilg+A75nE/76WdLpj6LopCwiAVvkzkcqy/9b7t2Mg7faFLjg0ZRcZ3w==", "dev": true, "license": "MIT", "dependencies": { - "@trysound/sax": "0.2.0", "commander": "^7.2.0", "css-select": "^5.1.0", "css-tree": "^2.3.1", "css-what": "^6.1.0", "csso": "^5.0.5", - "picocolors": "^1.0.0" + "picocolors": "^1.0.0", + "sax": "^1.5.0" }, "bin": { "svgo": "bin/svgo" @@ -19357,16 +19346,15 @@ } }, "node_modules/terser-webpack-plugin": { - "version": "5.3.16", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.16.tgz", - "integrity": "sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q==", + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.6.1.tgz", + "integrity": "sha512-201R5j+sJpK8nFWwKVyNfZot8FaJbLZDq5evriVzbV1wDtSXDjRUDRfJzHpAaxFDMEhsZL1QkeqM61wgsS3KaQ==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "jest-worker": "^27.4.5", "schema-utils": "^4.3.0", - "serialize-javascript": "^6.0.2", "terser": "^5.31.1" }, "engines": { @@ -19380,12 +19368,39 @@ "webpack": "^5.1.0" }, "peerDependenciesMeta": { + "@minify-html/node": { + "optional": true + }, "@swc/core": { "optional": true }, + "@swc/css": { + "optional": true + }, + "@swc/html": { + "optional": true + }, + "clean-css": { + "optional": true + }, + "cssnano": { + "optional": true + }, + "csso": { + "optional": true + }, "esbuild": { "optional": true }, + "html-minifier-terser": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "postcss": { + "optional": true + }, "uglify-js": { "optional": true } @@ -19493,9 +19508,9 @@ } }, "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { @@ -19996,9 +20011,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz", + "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==", "dev": true, "funding": [ { @@ -20114,13 +20129,17 @@ } }, "node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.2.tgz", + "integrity": "sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ==", "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], "license": "MIT", "bin": { - "uuid": "dist/bin/uuid" + "uuid": "dist-node/bin/uuid" } }, "node_modules/v8-to-istanbul": { @@ -20139,9 +20158,9 @@ } }, "node_modules/valibot": { - "version": "0.37.0", - "resolved": "https://registry.npmjs.org/valibot/-/valibot-0.37.0.tgz", - "integrity": "sha512-FQz52I8RXgFgOHym3XHYSREbNtkgSjF9prvMFH1nBsRyfL6SfCzoT1GuSDTlbsuPubM7/6Kbw0ZMQb8A+V+VsQ==", + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.4.2.tgz", + "integrity": "sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==", "license": "MIT", "peerDependencies": { "typescript": ">=5" @@ -20465,9 +20484,9 @@ } }, "node_modules/webpack-dev-server": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-5.2.3.tgz", - "integrity": "sha512-9Gyu2F7+bg4Vv+pjbovuYDhHX+mqdqITykfzdM9UyKqKHlsE5aAjRhR+oOEfXW5vBeu8tarzlJFIZva4ZjAdrQ==", + "version": "5.2.6", + "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-5.2.6.tgz", + "integrity": "sha512-HNLRmamRvVavZQ+avceZifmv8hmdUjg43t6MI4SqJDwFdW7RPQwH5vzGhDRZSX59SgfbeHhLnq3g+uooWo7pVw==", "dev": true, "license": "MIT", "dependencies": { @@ -20489,7 +20508,7 @@ "graceful-fs": "^4.2.6", "http-proxy-middleware": "^2.0.9", "ipaddr.js": "^2.1.0", - "launch-editor": "^2.6.1", + "launch-editor": "^2.14.1", "open": "^10.0.3", "p-retry": "^6.2.0", "schema-utils": "^4.2.0", @@ -20617,9 +20636,9 @@ } }, "node_modules/websocket-driver": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", - "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.5.tgz", + "integrity": "sha512-ZL2+3c7kMBdIRCMz6l8jQMHyGVxj+UL+xVk74Ombiciboca8rHa15L86B19E5oh1pL9Ii/uj54gtsIrZGMo6zA==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -20887,9 +20906,9 @@ } }, "node_modules/ws": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", - "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index d90100af..16b488f6 100644 --- a/package.json +++ b/package.json @@ -109,5 +109,10 @@ "webpack": "^5.105.1", "webpack-cli": "^6.0.1", "webpack-dev-server": "^5.2.3" + }, + "overrides": { + "qs": "^6.16.0", + "serialize-javascript": "^7.1.1", + "uuid": "^14.0.2" } } From 763234d15679e42d16bb2c820afa69cf6062efd4 Mon Sep 17 00:00:00 2001 From: Enrico Rubboli Date: Wed, 9 Sep 2026 07:55:30 +0200 Subject: [PATCH 42/52] docs: record accepted dependency risks (elliptic polyfill, gateway throttling, dev advisories) --- REVIEW-PLAN.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/REVIEW-PLAN.md b/REVIEW-PLAN.md index cc40b347..aaf26d1a 100644 --- a/REVIEW-PLAN.md +++ b/REVIEW-PLAN.md @@ -130,6 +130,22 @@ interval)` + `useNetworkSync()` shared by Mintlayer/Bitcoin/ExchangeRates ## Accepted risks (documented, not scheduled) +- **elliptic GHSA-848j (crypto-browserify webpack polyfill)**: no patched + elliptic release exists; npm's only suggestion is downgrading + crypto-browserify to 3.3.0 (older = strictly worse). The polyfill exists + only to satisfy webpack's node-crypto resolution — wallet cryptography + runs on the vendored wasm lib and noble curves. Revisit when + crypto-browserify ships a fixed line or the polyfill can be dropped. +- **Public ipfs gateway rate limiting**: the wallet races ipfs.io / + dweb.link / w3s.link once per token icon, then serves from an in-memory + blob forever. Shared team IPs can still get throttled on first load — + the proper long-term fix is an `/ipfs/` proxy on + mojito-api.mintlayer.org (Cloudflare-cached), after which + `IPFS_GATEWAYS` shrinks to that single trusted origin. +- **Dev tooling advisories** may reappear between lockfile refreshes + (webpack-dev-server chain); none ship in the extension bundle — re-run + `npm audit fix` periodically. + - **`window.mojito` fingerprinting**: any HTTPS site can detect the wallet. Inherent to the user-approved model (manual connect approval, no static allowlist). Mitigated: HTTPS-only, top-frame only, manual approval popup, From 3c78bcbd0be77dd50ba9baa5dea6f1fa2b23f145 Mon Sep 17 00:00:00 2001 From: Enrico Rubboli Date: Wed, 9 Sep 2026 11:09:39 +0200 Subject: [PATCH 43/52] fix(bridge): ack displayed approvals so the popup fallback stops firing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The background cancels its 2s popup-fallback timer only when the approval surface sends {action:'approvalDisplayed', requestId} — but nothing ever sent that message, so EVERY connect/sign approval fell back to chrome.windows.create after 2 seconds, opening a new window even though the side panel had rendered the request. - Browser.notifyApprovalDisplayed(requestId): fire-and-forget ack via runtime.sendMessage (swallows lastError/throws — the popup fallback then still guarantees an approval surface, which is the safe outcome) - handlePendingRequest acks as soon as the panel takes ownership of the request (locked included: unlock-then-approval stays panel-owned) - regression tests: ack cancels the fallback (no windows.create), no-ack still falls back (windows.create exactly once), service unit tests --- public/background.test.js | 77 +++++++++++++++++++++++++++- src/index.js | 6 +++ src/services/Browser/Browser.js | 18 +++++++ src/services/Browser/Browser.test.js | 60 ++++++++++++++++++++++ 4 files changed, 159 insertions(+), 2 deletions(-) diff --git a/public/background.test.js b/public/background.test.js index 1861233c..6da69620 100644 --- a/public/background.test.js +++ b/public/background.test.js @@ -102,11 +102,11 @@ describe('background service worker', () => { }, }, windows: { - create: (opts, cb) => { + create: jest.fn((opts, cb) => { const win = { id: 700 + createdWindows.length } createdWindows.push(win) cb(win) - }, + }), get: (id, cb) => cb({ id }), update: (id, opts, cb) => cb && cb({ id }), onRemoved: { addListener: () => {} }, @@ -643,6 +643,79 @@ describe('background service worker', () => { } }) + // Regression for the approval-ack fix: the panel's ack must cancel the + // 2s popup-fallback timer for ITS request only — a later request that + // never gets an ack still falls back to a popup window. + it('cancels only the acked request: a later unacked request still falls back', async () => { + jest.useFakeTimers() + try { + // request 1: the panel acks it after opening + const first = dispatch( + { requestId: 'r1', method: 'connect', params: {} }, + tabDappSender, + ) + expect(first.keptOpen).toBe(true) + await flushMicrotasks() + + dispatch( + { action: 'approvalDisplayed', requestId: 'r1' }, + extensionSender, + ) + + // past the 2s fallback deadline: NO popup for the acked request + jest.advanceTimersByTime(2000) + await flushMicrotasks() + expect(global.chrome.windows.create).not.toHaveBeenCalled() + expect(createdWindows).toHaveLength(0) + // the panel still owns the request + expect(storageData['pendingRequest:3']).toMatchObject({ + action: 'connect', + requestId: 'r1', + }) + + // free the panel slot by rejecting r1 (as the approval page does) + dispatch( + { + action: 'popupResponse', + method: 'connect', + requestId: 'r1', + origin: 'https://bridge.example', + windowId: 3, + result: null, + }, + extensionSender, + ) + expect(first.reply.current.error).toMatchObject({ + code: 'USER_REJECTED', + }) + + // request 2 from the same tab: NEVER acked + const second = dispatch( + { requestId: 'r2', method: 'connect', params: {} }, + tabDappSender, + ) + expect(second.keptOpen).toBe(true) + await flushMicrotasks() + expect(createdWindows).toHaveLength(0) + + jest.advanceTimersByTime(2000) + await flushMicrotasks() + + // the unacked request fell back to a popup window + expect(global.chrome.windows.create).toHaveBeenCalledTimes(1) + expect(createdWindows).toHaveLength(1) + expect(storageData['pendingRequest:700']).toMatchObject({ + action: 'connect', + origin: 'https://bridge.example', + requestId: 'r2', + }) + // the panel registration for r2 was rolled back + expect(storageData['pendingRequest:3']).toBeUndefined() + } finally { + jest.useRealTimers() + } + }) + it('ignores an approvalDisplayed ack forged from a web page sender', async () => { jest.useFakeTimers() try { diff --git a/src/index.js b/src/index.js index 6f7115a5..40d57d0f 100644 --- a/src/index.js +++ b/src/index.js @@ -226,6 +226,12 @@ const App = () => { const { action, origin, requestId } = pendingRequest + // Acknowledge ownership: the background cancels its 2s popup-fallback + // timer for this requestId once the approval surface has taken the + // request (locked → the panel shows unlock first, the approval follows + // here — either way the panel owns it, no popup window is needed). + Browser.notifyApprovalDisplayed(requestId) + if (action === 'connect') { if (!unlocked) { setNextAfterUnlock({ diff --git a/src/services/Browser/Browser.js b/src/services/Browser/Browser.js index 543f8f4d..4ab02b8c 100644 --- a/src/services/Browser/Browser.js +++ b/src/services/Browser/Browser.js @@ -61,3 +61,21 @@ export const sendPopupResponse = ({ send(null) } } + +// Tells the background the approval surface (side panel or popup) rendered +// the request: it cancels the popup-fallback timer for that requestId. +// Without this ack the background assumes the panel did not display the +// request and opens a new popup window for every approval. +export const notifyApprovalDisplayed = (requestId) => { + if (!runtime || !requestId) return + try { + runtime.sendMessage({ action: 'approvalDisplayed', requestId }, () => { + // Fire-and-forget: swallow the unchecked lastError (no responder + // is expected for this message). + void runtime.lastError + }) + } catch { + /* messaging unavailable — the popup fallback then guarantees an + approval surface, which is the safe outcome */ + } +} diff --git a/src/services/Browser/Browser.test.js b/src/services/Browser/Browser.test.js index 90e1137c..952da264 100644 --- a/src/services/Browser/Browser.test.js +++ b/src/services/Browser/Browser.test.js @@ -115,4 +115,64 @@ describe('Browser', () => { sendPopupResponse({ method: 'connect', requestId: 'r3', origin: 'x' }), ).not.toThrow() }) + + describe('notifyApprovalDisplayed', () => { + it('sends exactly the approvalDisplayed ack for the request', () => { + const chromeMock = { + runtime: { id: 'test-id', sendMessage: jest.fn() }, + storage: { local: { remove: jest.fn() } }, + } + global.chrome = chromeMock + + const { notifyApprovalDisplayed } = loadBrowserModule() + + notifyApprovalDisplayed('req-1') + + expect(chromeMock.runtime.sendMessage).toHaveBeenCalledTimes(1) + const [payload, callback] = chromeMock.runtime.sendMessage.mock.calls[0] + // exactly { action, requestId }: no extra fields + expect(payload).toEqual({ + action: 'approvalDisplayed', + requestId: 'req-1', + }) + expect(callback).toEqual(expect.any(Function)) + + // the fire-and-forget reply callback must tolerate a lastError + // response (no responder is expected for this message) + chromeMock.runtime.lastError = { message: 'No responder' } + expect(() => callback()).not.toThrow() + }) + + it('is a no-op when the requestId is missing', () => { + const chromeMock = { + runtime: { id: 'test-id', sendMessage: jest.fn() }, + storage: { local: { remove: jest.fn() } }, + } + global.chrome = chromeMock + + const { notifyApprovalDisplayed } = loadBrowserModule() + + expect(() => notifyApprovalDisplayed()).not.toThrow() + expect(() => notifyApprovalDisplayed('')).not.toThrow() + expect(chromeMock.runtime.sendMessage).not.toHaveBeenCalled() + }) + + it('swallows sendMessage throwing (messaging unavailable)', () => { + const chromeMock = { + runtime: { + id: 'test-id', + sendMessage: jest.fn(() => { + throw new Error('Extension context invalidated') + }), + }, + storage: { local: { remove: jest.fn() } }, + } + global.chrome = chromeMock + + const { notifyApprovalDisplayed } = loadBrowserModule() + + expect(() => notifyApprovalDisplayed('req-1')).not.toThrow() + expect(chromeMock.runtime.sendMessage).toHaveBeenCalledTimes(1) + }) + }) }) From 74a9c7699b31d5df96c9f4b95cf78c8e7ff8eb76 Mon Sep 17 00:00:00 2001 From: Enrico Rubboli Date: Wed, 9 Sep 2026 20:50:27 +0200 Subject: [PATCH 44/52] fix(ui): token activity everywhere + scroll/overlap fixes on all pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Activity page and Dashboard recent activity now include Mintlayer TOKEN transactions (mlUSDC sends/receives were invisible — useMlWalletInfo filters to coin txs); tickers resolved via new Transactions.resolveTxSymbol (token_info -> tokenMap -> 'Token' fallback, unit-tested); recent rows sorted by date across chains - asset page TOKEN INFO: token id truncated (ML.formatAddress) with a copy button instead of a 60-char wall of text - flex-shrink fix on every scrollable page container (Asset/Receive/Stake/ Activity .page, Dashboard .scroll, all three sign screens): flex children used to compress instead of overflowing, which visually stacked the ML address onto the Receive button, the Receive button onto the network badge, and disabled scrolling to the history — one root cause, three reported symptoms --- src/pages/ActivityPage/ActivityPage.js | 23 ++- .../ActivityPage/ActivityPage.module.css | 6 + src/pages/AssetPage/AssetPage.js | 14 +- src/pages/AssetPage/AssetPage.module.css | 13 ++ src/pages/AssetPage/AssetPage.test.js | 14 +- src/pages/Dashboard/Dashboard.js | 30 +++- src/pages/Dashboard/Dashboard.module.css | 6 + src/pages/ReceivePage/ReceivePage.module.css | 6 + .../SignBitcoinTransaction.css | 4 + .../SignExternalTransaction.css | 4 + .../SignInternalTransaction.module.css | 4 + src/pages/StakePage/StakePage.module.css | 6 + .../Helpers/Transactions/Transactions.js | 15 +- .../Helpers/Transactions/Transactions.test.js | 158 ++++++++++++++++++ 14 files changed, 280 insertions(+), 23 deletions(-) create mode 100644 src/utils/Helpers/Transactions/Transactions.test.js diff --git a/src/pages/ActivityPage/ActivityPage.js b/src/pages/ActivityPage/ActivityPage.js index 35ddd5a8..a8674a93 100644 --- a/src/pages/ActivityPage/ActivityPage.js +++ b/src/pages/ActivityPage/ActivityPage.js @@ -1,31 +1,38 @@ -import { useState } from 'react' +import { useState, useContext } from 'react' import { TxRow, BeSheet, CopyButton } from '@ComposedComponents' import { PageWrapper, Seg, ChainBadge, KV, Eyebrow } from '@BasicComponents' -import { useBtcWalletInfo, useMlWalletInfo } from '@Hooks' +import { useBtcWalletInfo } from '@Hooks' +import { MintlayerContext } from '@Contexts' import { Transactions } from '@Helpers' -const { adaptDesignTx } = Transactions +const { adaptDesignTx, resolveTxSymbol } = Transactions import styles from './ActivityPage.module.css' /** * Activity screen from the design (doc/ be-settings.jsx ActivityScreenBE + - * TxSheet). Real transactions; the design's confirmation counts and fiat-at- - * tx-time are mocked/partial — see doc/server-requirements.md. + * TxSheet). Real transactions — BTC, ML coin AND Mintlayer token activity + * (token tickers resolved from the wallet's token data); the design's + * confirmation counts and fiat-at-tx-time are mocked/partial — see + * doc/server-requirements.md. */ const ActivityPage = () => { const [filter, setFilter] = useState('All') const [selected, setSelected] = useState(null) + const { transactions, tokenBalances, tokenMap } = useContext(MintlayerContext) const btcInfo = useBtcWalletInfo() - const mlInfo = useMlWalletInfo() const all = [ ...(btcInfo.transactions || []).map((t) => adaptDesignTx(t, 'BTC', 'Bitcoin'), ), - ...(mlInfo.transactions || []).map((t) => - adaptDesignTx(t, 'ML', 'Mintlayer'), + ...(transactions || []).map((t) => + adaptDesignTx( + t, + resolveTxSymbol(t, tokenBalances, tokenMap), + 'Mintlayer', + ), ), ] diff --git a/src/pages/ActivityPage/ActivityPage.module.css b/src/pages/ActivityPage/ActivityPage.module.css index ba30f7fe..9946c9fd 100644 --- a/src/pages/ActivityPage/ActivityPage.module.css +++ b/src/pages/ActivityPage/ActivityPage.module.css @@ -11,6 +11,12 @@ animation: be-fade-in 400ms ease both; } +/* flex children must keep their natural height: shrinking them + overlaps content visually and never triggers the scroll */ +.page > * { + flex-shrink: 0; +} + .header { display: flex; align-items: center; diff --git a/src/pages/AssetPage/AssetPage.js b/src/pages/AssetPage/AssetPage.js index b60aa547..29443ead 100644 --- a/src/pages/AssetPage/AssetPage.js +++ b/src/pages/AssetPage/AssetPage.js @@ -19,7 +19,8 @@ import { useBtcWalletInfo, useMlWalletInfo, } from '@Hooks' -import { Transactions, BTC } from '@Helpers' +import { CopyButton } from '@ComposedComponents' +import { ML, Transactions, BTC } from '@Helpers' const { adaptDesignTx } = Transactions import styles from './AssetPage.module.css' @@ -172,7 +173,16 @@ const AssetPage = () => { + {ML.formatAddress(id, 24)} + + , + ], ['Decimals', tokenData.token_info?.number_of_decimals ?? '—'], [ 'Balance', diff --git a/src/pages/AssetPage/AssetPage.module.css b/src/pages/AssetPage/AssetPage.module.css index 0cf2b277..5b12c2bb 100644 --- a/src/pages/AssetPage/AssetPage.module.css +++ b/src/pages/AssetPage/AssetPage.module.css @@ -11,6 +11,12 @@ animation: be-fade-in 400ms ease both; } +/* flex children must keep their natural height: shrinking them + overlaps content visually and never triggers the scroll */ +.page > * { + flex-shrink: 0; +} + .header { display: flex; flex-direction: column; @@ -139,3 +145,10 @@ color: var(--be-text-2); margin-top: 8px; } + +.valueLine { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: var(--font-size-sm); +} diff --git a/src/pages/AssetPage/AssetPage.test.js b/src/pages/AssetPage/AssetPage.test.js index d6187ea1..e4056b51 100644 --- a/src/pages/AssetPage/AssetPage.test.js +++ b/src/pages/AssetPage/AssetPage.test.js @@ -1,10 +1,13 @@ import React from 'react' import { MemoryRouter, Routes, Route } from 'react-router' -import { render } from '@testing-library/react' +import { render, within } from '@testing-library/react' const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}) const TOKEN_ID = 'tmltk1q2c7d9a4hm3' +// UI review finding: the Token ID row shows the id truncated +// (ML.formatAddress(id, 24)) with a copy button, never in full. +const TRUNCATED_TOKEN_ID = 'tmltk1q2c7d9...1q2c7d9a4hm3' jest.mock('@Hooks', () => { const mockTokenBalances = { @@ -128,12 +131,17 @@ describe('AssetPage', () => { ) it('renders a real Mintlayer token from tokenBalances', () => { - const { container } = renderAt(TOKEN_ID) + const { container, getByText } = renderAt(TOKEN_ID) expect(container.textContent).toContain('CBEAT') - expect(container.textContent).toContain(TOKEN_ID) + // The id is rendered truncated — the full id must not leak verbatim. + expect(container.textContent).not.toContain(TOKEN_ID) + expect(container.textContent).toContain(TRUNCATED_TOKEN_ID) expect(container.textContent).toContain('Decimals') expect(container.textContent).toContain('Token info') + // The Token ID row offers a copy action for the full id. + const tokenIdRow = getByText('Token ID').closest('div') + expect(within(tokenIdRow).getByTestId('copy-btn')).toBeInTheDocument() // Balance comes from the token-scoped hook (10 CBEAT), not the ML coin balance. expect(container.textContent).toContain('10') // No fake price data for tokens. diff --git a/src/pages/Dashboard/Dashboard.js b/src/pages/Dashboard/Dashboard.js index 9ff25dc5..ae6d3603 100644 --- a/src/pages/Dashboard/Dashboard.js +++ b/src/pages/Dashboard/Dashboard.js @@ -3,7 +3,7 @@ import { useContext, useState, useEffect } from 'react' import { useNavigate } from 'react-router' import { PopUp, AddWallet, TxRow, AssetRow } from '@ComposedComponents' -import { AccountContext, SettingsContext } from '@Contexts' +import { AccountContext, MintlayerContext, SettingsContext } from '@Contexts' import { Account as AccountEntity } from '@Entities' import { @@ -24,7 +24,7 @@ import { LivePill, Seg, } from '@BasicComponents' -const { adaptDesignTx } = Transactions +const { adaptDesignTx, resolveTxSymbol } = Transactions import { AppInfo } from '@Constants' @@ -52,8 +52,9 @@ const DashboardPage = () => { tokenBalances, fetchingBalances: mlFetchingBalances, fetchingTokens: mlFetchingTokens, - transactions: mlTransactions, } = useMlWalletInfo() + const { transactions: mlAllTransactions, tokenMap } = + useContext(MintlayerContext) const { exchangeRate: btcExchangeRate } = useExchangeRates('btc', 'usd') const { exchangeRate: mlExchangeRate } = useExchangeRates('ml', 'usd') const { yesterdayExchangeRate: btcYesterdayExchangeRate } = @@ -244,13 +245,24 @@ const DashboardPage = () => { getCurrentAccount(accountID).then((account) => setAccount(account)) }, [accountID]) - // Recent activity: real transactions first, a demo row as fallback. - const adaptTx = (tx, sym, chain) => adaptDesignTx(tx, sym, chain) - + // Recent activity: BTC + ML coin + token transactions (token tickers + // resolved from the wallet's token data), newest first per chain. const recentTxs = [ - ...(btcTransactions || []).map((t) => adaptTx(t, 'BTC', 'Bitcoin')), - ...(mlTransactions || []).map((t) => adaptTx(t, 'ML', 'Mintlayer')), - ].slice(0, 3) + ...(btcTransactions || []).map((t) => ({ + ...adaptDesignTx(t, 'BTC', 'Bitcoin'), + _seq: t.date || 0, + })), + ...(mlAllTransactions || []).map((t) => ({ + ...adaptDesignTx( + t, + resolveTxSymbol(t, tokenBalances, tokenMap), + 'Mintlayer', + ), + _seq: t.date || 0, + })), + ] + .sort((a, b) => b._seq - a._seq) + .slice(0, 3) const isTestnet = networkType === AppInfo.NETWORK_TYPES.TESTNET diff --git a/src/pages/Dashboard/Dashboard.module.css b/src/pages/Dashboard/Dashboard.module.css index ff3aee52..9b09eebb 100644 --- a/src/pages/Dashboard/Dashboard.module.css +++ b/src/pages/Dashboard/Dashboard.module.css @@ -98,6 +98,12 @@ padding-bottom: 16px; } +/* flex children must keep their natural height: shrinking them + overlaps content visually and never triggers the scroll */ +.scroll > * { + flex-shrink: 0; +} + /* ── Balance card ────────────────────────────────────────── */ .balanceCard { position: relative; diff --git a/src/pages/ReceivePage/ReceivePage.module.css b/src/pages/ReceivePage/ReceivePage.module.css index d367e32f..0c3c4be2 100644 --- a/src/pages/ReceivePage/ReceivePage.module.css +++ b/src/pages/ReceivePage/ReceivePage.module.css @@ -11,6 +11,12 @@ animation: be-fade-in 400ms ease both; } +/* flex children must keep their natural height: shrinking them + overlaps content visually and never triggers the scroll */ +.page > * { + flex-shrink: 0; +} + .header { display: flex; justify-content: center; diff --git a/src/pages/SignBitcoinTransaction/SignBitcoinTransaction.css b/src/pages/SignBitcoinTransaction/SignBitcoinTransaction.css index 232b4a19..db26301f 100644 --- a/src/pages/SignBitcoinTransaction/SignBitcoinTransaction.css +++ b/src/pages/SignBitcoinTransaction/SignBitcoinTransaction.css @@ -13,6 +13,10 @@ border-radius: 10px; } +.SignTransaction > * { + flex-shrink: 0; +} + .SignTransaction .header { display: flex; align-items: center; diff --git a/src/pages/SignExternalTransaction/SignExternalTransaction.css b/src/pages/SignExternalTransaction/SignExternalTransaction.css index 7ea5f4fc..b4f00178 100644 --- a/src/pages/SignExternalTransaction/SignExternalTransaction.css +++ b/src/pages/SignExternalTransaction/SignExternalTransaction.css @@ -13,6 +13,10 @@ border-radius: 10px; } +.SignTransaction > * { + flex-shrink: 0; +} + .SignTransaction .header { display: flex; align-items: center; diff --git a/src/pages/SignInternalTransaction/SignInternalTransaction.module.css b/src/pages/SignInternalTransaction/SignInternalTransaction.module.css index a4d0e5d3..7dc470e4 100644 --- a/src/pages/SignInternalTransaction/SignInternalTransaction.module.css +++ b/src/pages/SignInternalTransaction/SignInternalTransaction.module.css @@ -12,6 +12,10 @@ border-radius: 10px; } +.signTransaction > * { + flex-shrink: 0; +} + .signTransaction .header { display: flex; align-items: center; diff --git a/src/pages/StakePage/StakePage.module.css b/src/pages/StakePage/StakePage.module.css index 58cadb44..b9c12fe1 100644 --- a/src/pages/StakePage/StakePage.module.css +++ b/src/pages/StakePage/StakePage.module.css @@ -11,6 +11,12 @@ animation: be-fade-in 400ms ease both; } +/* flex children must keep their natural height: shrinking them + overlaps content visually and never triggers the scroll */ +.page > * { + flex-shrink: 0; +} + .header { display: flex; align-items: center; diff --git a/src/utils/Helpers/Transactions/Transactions.js b/src/utils/Helpers/Transactions/Transactions.js index b2f0901d..e219db9e 100644 --- a/src/utils/Helpers/Transactions/Transactions.js +++ b/src/utils/Helpers/Transactions/Transactions.js @@ -53,4 +53,17 @@ const adaptDesignTx = (tx, sym, chain) => { } } -export { adaptDesignTx } +// Resolves the ticker shown next to a Mintlayer transaction's amount: +// coin txs -> ML, token txs -> the token's ticker from the balances or the +// whole-network token map, with a neutral fallback. +const resolveTxSymbol = (tx, tokenBalances = {}, tokenMap = {}) => { + const tokenId = tx?.token_id + if (!tokenId) return 'ML' + + const info = tokenBalances[tokenId]?.token_info?.token_ticker + const ticker = + (typeof info === 'object' ? info?.string : info) || tokenMap[tokenId] + return ticker || 'Token' +} + +export { adaptDesignTx, resolveTxSymbol } diff --git a/src/utils/Helpers/Transactions/Transactions.test.js b/src/utils/Helpers/Transactions/Transactions.test.js new file mode 100644 index 00000000..a1eeba47 --- /dev/null +++ b/src/utils/Helpers/Transactions/Transactions.test.js @@ -0,0 +1,158 @@ +import { adaptDesignTx, resolveTxSymbol } from './Transactions' + +const TOKEN_ID = + '00000000abc123def4567890abcdef1234567890abcdef1234567890abcdef12' + +describe('resolveTxSymbol', () => { + test('returns ML for a coin transaction (no token_id)', () => { + const tx = { direction: 'in', value: 1.5 } + + expect(resolveTxSymbol(tx)).toBe('ML') + }) + + test('returns ML when token_id is explicitly undefined', () => { + expect(resolveTxSymbol({ token_id: undefined })).toBe('ML') + }) + + test('returns the token ticker from tokenBalances when token_ticker is an object', () => { + const tokenBalances = { + [TOKEN_ID]: { token_info: { token_ticker: { string: 'mlUSDC' } } }, + } + + expect(resolveTxSymbol({ token_id: TOKEN_ID }, tokenBalances)).toBe( + 'mlUSDC', + ) + }) + + test('returns the token ticker from tokenBalances when token_ticker is a flat string', () => { + const tokenBalances = { + [TOKEN_ID]: { token_info: { token_ticker: 'mlUSDC' } }, + } + + expect(resolveTxSymbol({ token_id: TOKEN_ID }, tokenBalances)).toBe( + 'mlUSDC', + ) + }) + + test('falls back to the whole-network tokenMap when there is no balances entry', () => { + const tokenMap = { [TOKEN_ID]: 'mlUSDC' } + + expect(resolveTxSymbol({ token_id: TOKEN_ID }, {}, tokenMap)).toBe('mlUSDC') + }) + + test('returns the neutral fallback Token when the ticker is in neither source', () => { + const tx = { token_id: TOKEN_ID } + + expect(resolveTxSymbol(tx)).toBe('Token') + expect(resolveTxSymbol(tx, {})).toBe('Token') + expect(resolveTxSymbol(tx, {}, {})).toBe('Token') + }) + + test('prefers the balances ticker over the tokenMap ticker', () => { + const tokenBalances = { + [TOKEN_ID]: { token_info: { token_ticker: { string: 'mlUSDC' } } }, + } + const tokenMap = { [TOKEN_ID]: 'mlUSDC-OTHER' } + + expect( + resolveTxSymbol({ token_id: TOKEN_ID }, tokenBalances, tokenMap), + ).toBe('mlUSDC') + }) + + test('tolerates a missing transaction object', () => { + expect(resolveTxSymbol()).toBe('ML') + }) +}) + +describe('adaptDesignTx', () => { + test('maps an incoming transaction to type receive and passes sym/chain through', () => { + const tx = { + direction: 'in', + value: 1.5, + date: 1700000000, + txid: 'abc123', + otherPart: 'recipient-address', + } + + const result = adaptDesignTx(tx, 'ML', 'mainnet') + + expect(result.type).toBe('receive') + expect(result.sym).toBe('ML') + expect(result.chain).toBe('mainnet') + expect(result.status).toBe('Confirmed') + expect(result.when).not.toBe('Pending') + }) + + test('maps an outgoing transaction to type send', () => { + const tx = { direction: 'out', value: 2, date: 1700000000 } + + expect(adaptDesignTx(tx, 'ML', 'mainnet').type).toBe('send') + }) + + test('formats a BTC amount through the BTC formatter', () => { + const tx = { direction: 'out', value: 2, date: 1700000000 } + + expect(adaptDesignTx(tx, 'BTC', 'bitcoin').amount).toBe('2') + }) + + test('maps order transactions to type swap with a null amount', () => { + const tx = { + type: 'CreateOrder', + direction: 'out', + // Order txs carry structured payloads, not a simple numeric value + value: { order: { ask: { coin: { amount: '1' } } } }, + date: 1700000000, + } + + const result = adaptDesignTx(tx, 'ML', 'mainnet') + + expect(result.type).toBe('swap') + expect(result.amount).toBeNull() + }) + + test('maps FillOrder to type swap as well', () => { + const tx = { type: 'FillOrder', direction: 'out', date: 1700000000 } + + expect(adaptDesignTx(tx, 'ML', 'mainnet').type).toBe('swap') + }) + + test('maps staking delegations to type dapp with a null amount', () => { + const tx = { + type: 'DelegateStaking', + direction: 'out', + // Delegation txs expose `amount`, not a simple numeric `value` + amount: 100, + date: 1700000000, + } + + const result = adaptDesignTx(tx, 'ML', 'mainnet') + + expect(result.type).toBe('dapp') + expect(result.amount).toBeNull() + }) + + test('renders pending transactions with Pending when/status', () => { + const tx = { direction: 'out', value: 1 } + + const result = adaptDesignTx(tx, 'ML', 'mainnet') + + expect(result.when).toBe('Pending') + expect(result.status).toBe('Pending') + }) + + test('copies txid and counterpart address into hash/to/from', () => { + const tx = { + direction: 'out', + value: 1, + date: 1700000000, + txid: 'hash-xyz', + otherPart: 'counterpart-address', + } + + const result = adaptDesignTx(tx, 'ML', 'mainnet') + + expect(result.hash).toBe('hash-xyz') + expect(result.to).toBe('counterpart-address') + expect(result.from).toBe('counterpart-address') + }) +}) From 003cba73e3c18d058cad108a81825160eae628c0 Mon Sep 17 00:00:00 2001 From: Enrico Rubboli Date: Wed, 9 Sep 2026 21:42:35 +0200 Subject: [PATCH 45/52] feat(ui): token Send on the asset page, hidden scrollbars, receive spacing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AssetPage: the Send button now renders for tokens too — it routes to /wallet//send-ml-transaction, a flow SendMlTransaction already supports via the coinType param (token-scoped balance, decimals, ticker) - breathing room between the actions row and the ML address card - scrollbars hidden app-wide (scrollbar-width: none + webkit display:none): scrolling keeps working, the bar itself is not part of the design --- src/assets/styles/index.css | 6 ++++ src/pages/AssetPage/AssetPage.js | 36 +++++++++++++----------- src/pages/AssetPage/AssetPage.module.css | 4 +++ 3 files changed, 29 insertions(+), 17 deletions(-) diff --git a/src/assets/styles/index.css b/src/assets/styles/index.css index 16bc13fb..f527af44 100644 --- a/src/assets/styles/index.css +++ b/src/assets/styles/index.css @@ -7,6 +7,12 @@ padding: 0; overflow: hidden; text-decoration: none; + /* wallet UI: content scrolls, the scrollbar itself stays invisible */ + scrollbar-width: none; +} + +*::-webkit-scrollbar { + display: none; } html { diff --git a/src/pages/AssetPage/AssetPage.js b/src/pages/AssetPage/AssetPage.js index 29443ead..692f6d6a 100644 --- a/src/pages/AssetPage/AssetPage.js +++ b/src/pages/AssetPage/AssetPage.js @@ -83,7 +83,9 @@ const AssetPage = () => { const sendTarget = isBtc ? '/wallet/Bitcoin/send-btc-transaction' - : '/wallet/Mintlayer/send-ml-transaction' + : isMl + ? '/wallet/Mintlayer/send-ml-transaction' + : `/wallet/${id}/send-ml-transaction` const receiveAddress = isBtc ? BTC.getBtcAddressString( addresses?.btcAddresses?.btcReceivingAddresses?.[0], @@ -135,14 +137,12 @@ const AssetPage = () => { )}
    - {isReal && ( - - )} +
    {receiveAddress && ( - +
    + +
    )} {tokenData && ( diff --git a/src/pages/AssetPage/AssetPage.module.css b/src/pages/AssetPage/AssetPage.module.css index 5b12c2bb..b63af116 100644 --- a/src/pages/AssetPage/AssetPage.module.css +++ b/src/pages/AssetPage/AssetPage.module.css @@ -152,3 +152,7 @@ gap: 6px; font-size: var(--font-size-sm); } + +.addressCard { + margin-top: 14px; +} From e7f71be951006d7a5b6a26a877d01eb40738cd51 Mon Sep 17 00:00:00 2001 From: Enrico Rubboli Date: Thu, 10 Sep 2026 12:36:40 +0400 Subject: [PATCH 46/52] feat(passkey): PRF service + account entity enrollment/unlock - Passkey service (Chromium-only): creates a platform-authenticator passkey with the WebAuthn prf extension, derives the deterministic AES-GCM key, and wraps/unwraps the account password in memory. isSupported() gates every consumer (Firefox: unsupported). - Account entity: enrollPasskey (password-verified) stores a passkeyBlob on the account; removePasskey clears it (password-verified); unlockAccountWithPasskey unwraps and returns the same unlocked account the password path returns. Existing PBKDF2/AES seed crypto untouched. - unit tests: 15 service cases (PRF eval, blob validation, mismatch, unsupported) + 8 entity cases (verify-before-enroll, wrong-password rejection, persistence, no-blob rejection) --- src/services/Crypto/Passkey/Passkey.js | 154 +++++++++++ src/services/Crypto/Passkey/Passkey.test.js | 273 ++++++++++++++++++ src/services/Crypto/index.js | 2 + src/services/Entity/Account/Account.js | 38 +++ src/services/Entity/Account/Account.test.js | 289 ++++++++++++++++++++ 5 files changed, 756 insertions(+) create mode 100644 src/services/Crypto/Passkey/Passkey.js create mode 100644 src/services/Crypto/Passkey/Passkey.test.js diff --git a/src/services/Crypto/Passkey/Passkey.js b/src/services/Crypto/Passkey/Passkey.js new file mode 100644 index 00000000..ae99d50b --- /dev/null +++ b/src/services/Crypto/Passkey/Passkey.js @@ -0,0 +1,154 @@ +// Passkey (WebAuthn PRF) service — Chromium-only. +// +// SECURITY CONTRACT: the platform authenticator (Touch ID / Windows Hello / +// Chrome profile) derives a deterministic secret via the WebAuthn `prf` +// extension. That secret becomes an AES-GCM key used ONLY to wrap/unwrap the +// account password in memory, exactly as if the user had typed it. The PRF +// output and the derived key never leave memory; only the wrapped blob +// (salt/iv/ciphertext + credential id) is persisted on the account. The +// account password remains the always-available recovery path. + +const PRF_SALT_BYTES = 32 +const PRF_IDENTIFIER = 'mojito-passkey-unlock-v1' + +const bufferToBase64 = (buffer) => { + const bytes = new Uint8Array(buffer) + let binary = '' + for (const byte of bytes) binary += String.fromCharCode(byte) + return btoa(binary) +} + +const base64ToBuffer = (base64) => { + const binary = atob(base64) + const bytes = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i) + return bytes +} + +export const isSupported = () => + typeof window !== 'undefined' && + typeof window.PublicKeyCredential === 'function' && + typeof window.PublicKeyCredential + ?.userVerifyingPlatformAuthenticatorAvailable === 'function' && + window.PublicKeyCredential.userVerifyingPlatformAuthenticatorAvailable() + +// The prf extension must be reported as enabled for our credential, and the +// creation/get assertions must return the evaluated secret. +const prfResultsOf = (credential) => { + const extensions = credential?.getClientExtensionResults?.() + return extensions?.prf?.results?.first ?? null +} + +const prfCreationOptions = (salt) => ({ + challenge: crypto.getRandomValues(new Uint8Array(32)), + rp: { name: 'Mojito Wallet' }, + user: { + id: crypto.getRandomValues(new Uint8Array(16)), + name: PRF_IDENTIFIER, + displayName: 'Mojito Wallet unlock', + }, + pubKeyCredParams: [ + { type: 'public-key', alg: -7 }, + { type: 'public-key', alg: -257 }, + ], + authenticatorSelection: { + userVerification: 'required', + residentKey: 'required', + }, + extensions: { prf: { eval: { first: salt } } }, +}) + +const prfRequestOptions = (credentialId, salt) => ({ + challenge: crypto.getRandomValues(new Uint8Array(32)), + allowCredentials: [{ type: 'public-key', id: credentialId }], + userVerification: 'required', + extensions: { prf: { eval: { first: salt } } }, +}) + +const prfSecretFrom = async (options, expectedCredentialId) => { + const credential = await navigator.credentials.get({ publicKey: options }) + const secret = prfResultsOf(credential) + if (!secret) throw new Error('PRF_NOT_SUPPORTED') + if ( + expectedCredentialId && + new Uint8Array(credential.rawId).toString() !== + new Uint8Array(expectedCredentialId).toString() + ) { + throw new Error('PRF_CREDENTIAL_MISMATCH') + } + return new Uint8Array(secret) +} + +const importAesKey = async (prfSecret) => + crypto.subtle.importKey('raw', prfSecret, { name: 'AES-GCM' }, false, [ + 'encrypt', + 'decrypt', + ]) + +/** + * Creates the unlock passkey (platform authenticator, PRF enabled) and wraps + * the account password with the PRF-derived key. + * @returns {{ credentialId: string, salt: string, iv: string, ciphertext: string }} + * base64-encoded blob to persist on the account. + */ +export const enrollPasskeyCredential = async (password) => { + if (!isSupported()) throw new Error('PASSKEY_UNSUPPORTED') + + const salt = crypto.getRandomValues(new Uint8Array(PRF_SALT_BYTES)) + const credential = await navigator.credentials.create({ + publicKey: prfCreationOptions(salt), + }) + + const prfResults = prfResultsOf(credential) + if (!prfResults) throw new Error('PRF_NOT_SUPPORTED') + + const aesKey = await importAesKey(new Uint8Array(prfResults)) + const iv = crypto.getRandomValues(new Uint8Array(12)) + const ciphertext = await globalThis.crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, + aesKey, + new TextEncoder().encode(password), + ) + + return { + credentialId: bufferToBase64(credential.rawId), + salt: bufferToBase64(salt), + iv: bufferToBase64(iv), + ciphertext: bufferToBase64(ciphertext), + } +} + +/** + * Recreates the PRF-derived key from the stored salt + credential and + * unwraps the account password. Throws on cancellation, missing blob + * fields, or PRF mismatch. + */ +export const unwrapPasswordWithPasskey = async (blob) => { + if (!isSupported()) throw new Error('PASSKEY_UNSUPPORTED') + if (!blob?.credentialId || !blob?.salt || !blob?.iv || !blob?.ciphertext) { + throw new Error('PASSKEY_BLOB_INVALID') + } + + const credentialId = base64ToBuffer(blob.credentialId) + const salt = base64ToBuffer(blob.salt) + const prfSecret = await prfSecretFrom( + prfRequestOptions(credentialId, salt), + credentialId, + ) + + const aesKey = await importAesKey(prfSecret) + const iv = base64ToBuffer(blob.iv) + const plain = await globalThis.crypto.subtle.decrypt( + { name: 'AES-GCM', iv }, + aesKey, + base64ToBuffer(blob.ciphertext), + ) + return new TextDecoder().decode(plain) +} + +/** + * Evaluates the PRF secret for an enrolled passkey and returns the wrapped + * password — used by flows that call unlockAccount directly. + */ +export const unlockPasswordWithPasskey = async (blob) => + unwrapPasswordWithPasskey(blob) diff --git a/src/services/Crypto/Passkey/Passkey.test.js b/src/services/Crypto/Passkey/Passkey.test.js new file mode 100644 index 00000000..d8ebf647 --- /dev/null +++ b/src/services/Crypto/Passkey/Passkey.test.js @@ -0,0 +1,273 @@ +import { webcrypto } from 'node:crypto' +import { + isSupported, + enrollPasskeyCredential, + unwrapPasswordWithPasskey, + unlockPasswordWithPasskey, +} from './Passkey' + +// jsdom has no WebAuthn implementation and its crypto object may lack +// `subtle`, so both are stubbed below. The navigator.credentials mocks hand +// back REAL WebCrypto bytes for the PRF secret, so the AES-GCM wrap/unwrap +// layer under test runs for real end to end. + +const PASSWORD = 'hunter2' +const CREDENTIAL_ID = new Uint8Array([1, 2, 3]) + +let originalCryptoDescriptor +let originalPublicKeyCredential +let prfSecret // ArrayBuffer shared by the create/get credential mocks +let mockUVPAA +let mockCreate +let mockGet + +const base64ToBytes = (base64) => + Uint8Array.from(atob(base64), (char) => char.charCodeAt(0)) + +const installPublicKeyCredential = (uvpaa) => { + // The real WebAuthn API is a constructor with a static method, and the + // source checks `typeof window.PublicKeyCredential === 'function'`. + const publicKeyCredential = jest.fn() + publicKeyCredential.userVerifyingPlatformAuthenticatorAvailable = uvpaa + Object.defineProperty(window, 'PublicKeyCredential', { + value: publicKeyCredential, + configurable: true, + }) +} + +const removePublicKeyCredential = () => { + delete window.PublicKeyCredential +} + +const newCredential = (first) => ({ + rawId: CREDENTIAL_ID.slice().buffer, + getClientExtensionResults: () => ({ + prf: { enabled: true, results: { first } }, + }), +}) + +beforeAll(() => { + originalCryptoDescriptor = Object.getOwnPropertyDescriptor( + globalThis, + 'crypto', + ) + if (!globalThis.crypto?.subtle) { + Object.defineProperty(globalThis, 'crypto', { + value: webcrypto, + configurable: true, + }) + } + + originalPublicKeyCredential = window.PublicKeyCredential + mockUVPAA = jest.fn().mockResolvedValue(true) + installPublicKeyCredential(mockUVPAA) + + prfSecret = webcrypto.getRandomValues(new Uint8Array(32)).buffer + + mockCreate = jest.fn(async () => newCredential(prfSecret)) + mockGet = jest.fn(async () => newCredential(prfSecret)) + + Object.defineProperty(window.navigator, 'credentials', { + value: { create: mockCreate, get: mockGet }, + configurable: true, + }) +}) + +afterEach(() => { + // Keep tests independent: restore the default PRF secret in case a test + // simulated an authenticator whose secret changed between enroll/unlock, + // and drop calls queued by previous tests. + prfSecret = webcrypto.getRandomValues(new Uint8Array(32)).buffer + mockUVPAA.mockClear() + mockCreate.mockClear() + mockGet.mockClear() +}) + +afterAll(() => { + if (originalCryptoDescriptor) { + Object.defineProperty(globalThis, 'crypto', originalCryptoDescriptor) + } + delete window.navigator.credentials + if (originalPublicKeyCredential) { + window.PublicKeyCredential = originalPublicKeyCredential + } else { + removePublicKeyCredential() + } +}) + +test('Passkey - isSupported is true when a platform authenticator is available', async () => { + await expect(isSupported()).resolves.toBe(true) + expect(mockUVPAA).toHaveBeenCalled() +}) + +test('Passkey - isSupported is false when PublicKeyCredential is missing', () => { + removePublicKeyCredential() + + expect(isSupported()).toBe(false) + + installPublicKeyCredential(mockUVPAA) +}) + +test('Passkey - isSupported is false when userVerifyingPlatformAuthenticatorAvailable is missing', () => { + Object.defineProperty(window, 'PublicKeyCredential', { + value: jest.fn(), + configurable: true, + }) + + expect(isSupported()).toBe(false) + + installPublicKeyCredential(mockUVPAA) +}) + +test('Passkey - isSupported resolves false when no platform authenticator is available', async () => { + mockUVPAA.mockResolvedValueOnce(false) + + await expect(isSupported()).resolves.toBe(false) +}) + +test('Passkey - enrollPasskeyCredential returns a blob of four base64 fields', async () => { + const blob = await enrollPasskeyCredential(PASSWORD) + + expect(blob).toHaveProperty('credentialId', expect.any(String)) + expect(blob).toHaveProperty('salt', expect.any(String)) + expect(blob).toHaveProperty('iv', expect.any(String)) + expect(blob).toHaveProperty('ciphertext', expect.any(String)) + + // atob sanity: salt is 32 PRF bytes, iv is a 12-byte AES-GCM iv, and the + // ciphertext wraps the non-empty password. + expect(atob(blob.credentialId).length).toBe(CREDENTIAL_ID.length) + expect(atob(blob.salt).length).toBe(32) + expect(atob(blob.iv).length).toBe(12) + expect(atob(blob.ciphertext).length).toBeGreaterThan(0) +}) + +test('Passkey - enrollPasskeyCredential evaluates prf with the enrolled salt', async () => { + const blob = await enrollPasskeyCredential(PASSWORD) + + expect(mockCreate).toHaveBeenCalledTimes(1) + const { publicKey } = mockCreate.mock.calls[0][0] + const evalFirst = publicKey.extensions.prf.eval.first + + expect(new Uint8Array(evalFirst).length).toBe(32) + expect(Array.from(new Uint8Array(evalFirst))).toStrictEqual( + Array.from(base64ToBytes(blob.salt)), + ) +}) + +test('Passkey - enroll then unwrap returns the wrapped password', async () => { + const blob = await enrollPasskeyCredential(PASSWORD) + + await expect(unwrapPasswordWithPasskey(blob)).resolves.toBe(PASSWORD) + + // The unlock assertion must have requested the same salt and credential id + // that were enrolled. + expect(mockGet).toHaveBeenCalledTimes(1) + const { publicKey } = mockGet.mock.calls[0][0] + expect( + Array.from(new Uint8Array(publicKey.extensions.prf.eval.first)), + ).toStrictEqual(Array.from(base64ToBytes(blob.salt))) + expect( + Array.from(new Uint8Array(publicKey.allowCredentials[0].id)), + ).toStrictEqual(Array.from(base64ToBytes(blob.credentialId))) +}) + +test('Passkey - round-trip works with unicode passwords', async () => { + const password = 'pässwörd-ключ-鍵 🔑' + const blob = await enrollPasskeyCredential(password) + + await expect(unwrapPasswordWithPasskey(blob)).resolves.toBe(password) +}) + +test('Passkey - unwrap rejects when the PRF secret differs from the enrolled one', async () => { + const blob = await enrollPasskeyCredential(PASSWORD) + + // Simulate an authenticator returning a different secret on unlock: + // AES-GCM must fail rather than return a wrong password. + prfSecret = webcrypto.getRandomValues(new Uint8Array(32)).buffer + + await expect(unwrapPasswordWithPasskey(blob)).rejects.toThrow() +}) + +test('Passkey - unwrap rejects with PASSKEY_BLOB_INVALID for an incomplete blob', async () => { + const blob = await enrollPasskeyCredential(PASSWORD) + + await expect(unwrapPasswordWithPasskey(null)).rejects.toThrow( + 'PASSKEY_BLOB_INVALID', + ) + await expect(unwrapPasswordWithPasskey({})).rejects.toThrow( + 'PASSKEY_BLOB_INVALID', + ) + await expect( + unwrapPasswordWithPasskey({ ...blob, credentialId: undefined }), + ).rejects.toThrow('PASSKEY_BLOB_INVALID') + await expect( + unwrapPasswordWithPasskey({ ...blob, salt: '' }), + ).rejects.toThrow('PASSKEY_BLOB_INVALID') + await expect( + unwrapPasswordWithPasskey({ ...blob, iv: null }), + ).rejects.toThrow('PASSKEY_BLOB_INVALID') + await expect( + unwrapPasswordWithPasskey({ ...blob, ciphertext: undefined }), + ).rejects.toThrow('PASSKEY_BLOB_INVALID') +}) + +test('Passkey - enroll and unwrap reject with PASSKEY_UNSUPPORTED when unsupported', async () => { + removePublicKeyCredential() + + await expect(enrollPasskeyCredential(PASSWORD)).rejects.toThrow( + 'PASSKEY_UNSUPPORTED', + ) + await expect(unwrapPasswordWithPasskey(null)).rejects.toThrow( + 'PASSKEY_UNSUPPORTED', + ) + + installPublicKeyCredential(mockUVPAA) +}) + +test('Passkey - unlockPasswordWithPasskey aliases unwrapPasswordWithPasskey', async () => { + const blob = await enrollPasskeyCredential(PASSWORD) + + await expect(unlockPasswordWithPasskey(blob)).resolves.toBe(PASSWORD) + await expect(unlockPasswordWithPasskey(null)).rejects.toThrow( + 'PASSKEY_BLOB_INVALID', + ) +}) + +test('Passkey - enroll rejects with PRF_NOT_SUPPORTED when creation lacks prf results', async () => { + mockCreate.mockImplementationOnce(async () => ({ + rawId: CREDENTIAL_ID.slice().buffer, + getClientExtensionResults: () => ({}), + })) + + await expect(enrollPasskeyCredential(PASSWORD)).rejects.toThrow( + 'PRF_NOT_SUPPORTED', + ) +}) + +test('Passkey - unwrap rejects with PRF_NOT_SUPPORTED when get lacks prf results', async () => { + const blob = await enrollPasskeyCredential(PASSWORD) + + mockGet.mockImplementationOnce(async () => ({ + rawId: CREDENTIAL_ID.slice().buffer, + getClientExtensionResults: () => ({}), + })) + + await expect(unwrapPasswordWithPasskey(blob)).rejects.toThrow( + 'PRF_NOT_SUPPORTED', + ) +}) + +test('Passkey - unwrap rejects with PRF_CREDENTIAL_MISMATCH when rawId differs', async () => { + const blob = await enrollPasskeyCredential(PASSWORD) + + mockGet.mockImplementationOnce(async () => ({ + rawId: new Uint8Array([9, 9, 9]).buffer, + getClientExtensionResults: () => ({ + prf: { enabled: true, results: { first: prfSecret } }, + }), + })) + + await expect(unwrapPasswordWithPasskey(blob)).rejects.toThrow( + 'PRF_CREDENTIAL_MISMATCH', + ) +}) diff --git a/src/services/Crypto/index.js b/src/services/Crypto/index.js index d82f48a8..78d97ab4 100644 --- a/src/services/Crypto/index.js +++ b/src/services/Crypto/index.js @@ -5,9 +5,11 @@ import BTC_ADDRESS_TYPE_MAP, { BTC_ADDRESS_TYPE_ENUM, } from './BTC/BTC.addressType' import * as Cipher from './Cipher/Cipher' +import * as Passkey from './Passkey/Passkey' export { BTC, + Passkey, ML, Cipher, BTCTransaction, diff --git a/src/services/Entity/Account/Account.js b/src/services/Entity/Account/Account.js index 2a5e9088..4fc40f0e 100644 --- a/src/services/Entity/Account/Account.js +++ b/src/services/Entity/Account/Account.js @@ -9,6 +9,7 @@ import { BTC as BtcHelpers } from '@Helpers' import loadAccountSubRoutines from './loadWorkers' import { LocalStorageService } from '@Storage' import { CURRENT_ENCRYPTION_VERSION } from '../../Crypto/Cipher/Cipher' +import * as Passkey from '../../Crypto/Passkey/Passkey' const getAccountVersion = (account) => account.encryptionVersion || 1 @@ -345,9 +346,46 @@ const unlockAccount = async (id, password, { wallets } = {}) => { } } +// ── Passkey unlock (Chromium only; password remains the fallback) ──────── +// SECURITY: the passkey wraps the account password via the WebAuthn PRF +// extension — the wrapped blob is stored on the account, the PRF secret +// never leaves memory, and the password itself is never persisted. Enroll +// and remove both verify the password first. + +const enrollPasskey = async (id, password) => { + // verify the password by unlocking before binding the passkey to it + await unlockAccount(id, password) + const blob = await Passkey.enrollPasskeyCredential(password) + updateAccount(id, { passkeyBlob: blob }) + return blob +} + +const removePasskey = async (id, password) => { + await unlockAccount(id, password) + updateAccount(id, { passkeyBlob: null }) +} + +const getPasskeyBlob = async (id) => { + const account = await getAccount(id) + return account?.passkeyBlob ?? null +} + +// Unlocks with the passkey-wrapped password: returns the same unlocked +// account the password path returns. +const unlockAccountWithPasskey = async (id, { wallets } = {}) => { + const blob = await getPasskeyBlob(id) + if (!blob) throw new Error('PASSKEY_NOT_ENROLLED') + const password = await Passkey.unlockPasswordWithPasskey(blob) + return unlockAccount(id, password, { wallets }) +} + export { saveAccount, unlockAccount, + enrollPasskey, + removePasskey, + getPasskeyBlob, + unlockAccountWithPasskey, updateAccount, getAccount, deleteAccount, diff --git a/src/services/Entity/Account/Account.test.js b/src/services/Entity/Account/Account.test.js index 9a75074c..548dc17b 100644 --- a/src/services/Entity/Account/Account.test.js +++ b/src/services/Entity/Account/Account.test.js @@ -3,9 +3,87 @@ import loadAccountSubRoutines from './loadWorkers' // import { saveAccount, unlockAccount } from './Account' // import { BTC, BTC_ADDRESS_TYPE_MAP, BTC_ADDRESS_TYPE_ENUM } from '@Cryptos' import { BTC_ADDRESS_TYPE_ENUM } from '@Cryptos' +import { IndexedDB } from '@Databases' +import { LocalStorageService } from '@Storage' +import * as Passkey from '../../Crypto/Passkey/Passkey' +import { + enrollPasskey, + removePasskey, + getPasskeyBlob, + unlockAccountWithPasskey, +} from './Account' // TODO: The tests had been disabled to avoid the error from wasm-crypto on the JEST environment, need to be fixed later +jest.mock('@Databases', () => ({ + IndexedDB: { + loadAccounts: jest.fn(), + save: jest.fn(), + get: jest.fn(), + update: jest.fn(), + deleteAccount: jest.fn(), + }, +})) + +jest.mock('@Storage', () => ({ + LocalStorageService: { + getItem: jest.fn(), + setItem: jest.fn(), + removeItem: jest.fn(), + }, +})) + +jest.mock('./loadWorkers', () => { + const generateNewAccountMnemonic = jest.fn() + const generateSeed = jest.fn() + const generateEncryptionKey = jest.fn() + const encryptSeed = jest.fn() + const decryptSeed = jest.fn() + const loadAccountSubRoutines = jest.fn(async () => ({ + generateNewAccountMnemonic, + generateSeed, + generateEncryptionKey, + encryptSeed, + decryptSeed, + })) + return { __esModule: true, default: loadAccountSubRoutines } +}) + +jest.mock('../../Crypto/Passkey/Passkey', () => ({ + isSupported: jest.fn(), + enrollPasskeyCredential: jest.fn(), + unlockPasswordWithPasskey: jest.fn(), +})) + +jest.mock('../../Crypto/Cipher/Cipher', () => ({ + CURRENT_ENCRYPTION_VERSION: 2, +})) + +jest.mock('./AccountHelpers', () => ({ + getEncryptedPrivateKeys: jest.fn(), + getEncryptedHtlsSecret: jest.fn(), +})) + +jest.mock('@Cryptos', () => ({ + BTC: { getHDWalletFromSeed: jest.fn() }, + ML: { getWalletAddresses: jest.fn(), getPrivateKeyFromMnemonic: jest.fn() }, + BTC_ADDRESS_TYPE_MAP: {}, + BTC_ADDRESS_TYPE_ENUM: { LEGACY: 'legacy', NATIVE_SEGWIT: 'nativeSegWit' }, +})) + +jest.mock('@Helpers', () => ({ + BTC: { getNetwork: jest.fn(), getBtcAddresses: jest.fn() }, +})) + +jest.mock('@Constants', () => ({ + AppInfo: { + DEFAULT_WALLETS_TO_CREATE: [], + BTC_DEFAULT_ADDRESSES_BATCH: 10, + DEFAULT_ML_WALLET_OFFSET: 0, + NETWORK_TYPES: { TESTNET: 'testnet', MAINNET: 'mainnet' }, + }, +})) + const ENTROPY_DATA = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16] const accountName = 'Savings' const password = 'pass' @@ -91,3 +169,214 @@ test('Account creation and restoring - error', async () => { // expect(addresses.mlMainnetAddress).toBeDefined() // expect(addresses.mlTestnetAddress).toBeDefined() // }) + +// ── Passkey management (enroll / remove / blob / unlock with passkey) ───── + +const accountId = 'account-1' +const unwrappedPassword = 'unwrapped-pass' + +const baseAccount = { + id: accountId, + name: accountName, + salt: 'salt-123', + encryptionVersion: 2, + iv: { + btcIv: 'iv-btc', + mlTestnetPrivKeyIv: 'iv-ml-test', + mlMainnetPrivKeyIv: 'iv-ml-main', + }, + tag: { + btcTag: 'tag-btc', + mlTestnetPrivKeyTag: 'tag-ml-test', + mlMainnetPrivKeyTag: 'tag-ml-main', + }, + seed: { + btcEncryptedSeed: 'enc-btc-seed', + encryptedMlTestnetPrivateKey: 'enc-ml-test', + encryptedMlMainnetPrivateKey: 'enc-ml-main', + }, + walletType: BTC_ADDRESS_TYPE_ENUM.LEGACY, + walletsToCreate: defaultWalletsToCreate, + htlsSecrets: {}, +} + +const passkeyBlob = { + credentialId: 'cred-base64', + salt: 'salt-base64', + iv: 'iv-base64', + ciphertext: 'cipher-base64', +} + +// in-memory stand-in for IndexedDB so getAccount/updateAccount behave like the +// real storage layer (reads see what writes persisted) +let dbAccounts + +const seedDb = (accounts) => { + dbAccounts = accounts.map((account) => ({ ...account })) + IndexedDB.loadAccounts.mockImplementation(async () => dbAccounts) +} + +const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0)) + +describe('Account passkey management', () => { + let generateEncryptionKey + let decryptSeed + + beforeEach(async () => { + jest.clearAllMocks() + + seedDb([{ ...baseAccount }]) + IndexedDB.get.mockImplementation(async (accounts, id) => + accounts.find((account) => account.id === id), + ) + IndexedDB.update.mockImplementation(async (accounts, entity) => { + const index = accounts.findIndex((account) => account.id === entity.id) + if (index === -1) accounts.push(entity) + else accounts[index] = entity + }) + + LocalStorageService.getItem.mockReturnValue(undefined) + + const subroutines = await loadAccountSubRoutines() + generateEncryptionKey = subroutines.generateEncryptionKey + decryptSeed = subroutines.decryptSeed + generateEncryptionKey.mockResolvedValue({ key: 'fake-key' }) + decryptSeed.mockResolvedValue('decrypted-secret') + }) + + describe('enrollPasskey', () => { + it('verifies the password, enrolls the credential and persists the blob', async () => { + Passkey.enrollPasskeyCredential.mockResolvedValue(passkeyBlob) + + const result = await enrollPasskey(accountId, password) + // updateAccount is fire-and-forget inside enrollPasskey — let it settle + await flushPromises() + + expect(result).toBe(passkeyBlob) + expect(Passkey.enrollPasskeyCredential).toHaveBeenCalledTimes(1) + expect(Passkey.enrollPasskeyCredential).toHaveBeenCalledWith(password) + + // the password is verified first (unlockAccount with id + password) + expect(generateEncryptionKey).toHaveBeenCalledWith({ + password, + salt: baseAccount.salt, + version: baseAccount.encryptionVersion, + }) + expect(generateEncryptionKey.mock.invocationCallOrder[0]).toBeLessThan( + Passkey.enrollPasskeyCredential.mock.invocationCallOrder[0], + ) + + // blob persisted on the account through updateAccount + expect(IndexedDB.update).toHaveBeenCalledWith( + expect.any(Array), + expect.objectContaining({ id: accountId, passkeyBlob }), + ) + expect(await getPasskeyBlob(accountId)).toBe(passkeyBlob) + }) + + it('rejects and stores nothing when the password is wrong', async () => { + decryptSeed.mockResolvedValue({ error: 'decryption failed' }) + const consoleErrorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}) + + await expect( + enrollPasskey(accountId, 'wrong-password'), + ).rejects.toBeDefined() + await flushPromises() + + expect(Passkey.enrollPasskeyCredential).not.toHaveBeenCalled() + expect(IndexedDB.update).not.toHaveBeenCalled() + expect(dbAccounts[0].passkeyBlob).toBeUndefined() + + consoleErrorSpy.mockRestore() + }) + }) + + describe('removePasskey', () => { + it('verifies the password and clears the stored blob', async () => { + seedDb([{ ...baseAccount, passkeyBlob }]) + + await removePasskey(accountId, password) + await flushPromises() + + // password verified through unlockAccount before clearing + expect(generateEncryptionKey).toHaveBeenCalledWith({ + password, + salt: baseAccount.salt, + version: baseAccount.encryptionVersion, + }) + expect(IndexedDB.update).toHaveBeenCalledWith( + expect.any(Array), + expect.objectContaining({ id: accountId, passkeyBlob: null }), + ) + expect(await getPasskeyBlob(accountId)).toBeNull() + expect(Passkey.enrollPasskeyCredential).not.toHaveBeenCalled() + expect(Passkey.unlockPasswordWithPasskey).not.toHaveBeenCalled() + }) + }) + + describe('getPasskeyBlob', () => { + it('returns the stored blob', async () => { + seedDb([{ ...baseAccount, passkeyBlob }]) + + expect(await getPasskeyBlob(accountId)).toBe(passkeyBlob) + }) + + it('returns null when no passkey is enrolled', async () => { + expect(await getPasskeyBlob(accountId)).toBeNull() + }) + }) + + describe('unlockAccountWithPasskey', () => { + it('unwraps the password, unlocks the account and returns its result', async () => { + seedDb([{ ...baseAccount, passkeyBlob }]) + Passkey.unlockPasswordWithPasskey.mockResolvedValue(unwrappedPassword) + + const result = await unlockAccountWithPasskey(accountId, { wallets: [] }) + + expect(Passkey.unlockPasswordWithPasskey).toHaveBeenCalledTimes(1) + expect(Passkey.unlockPasswordWithPasskey).toHaveBeenCalledWith( + passkeyBlob, + ) + + // the unwrapped password (not the raw one) is used to unlock + expect(generateEncryptionKey).toHaveBeenCalledWith({ + password: unwrappedPassword, + salt: baseAccount.salt, + version: baseAccount.encryptionVersion, + }) + expect(generateEncryptionKey).not.toHaveBeenCalledWith({ + password, + salt: baseAccount.salt, + version: baseAccount.encryptionVersion, + }) + + // same shape the password path returns + expect(result).toEqual({ + addresses: {}, + btcPrivateKeys: { btcHDWallet: null, btcAddressData: null }, + name: accountName, + mlPrivKeys: { + mlMainnetPrivateKey: 'decrypted-secret', + mlTestnetPrivateKey: 'decrypted-secret', + }, + }) + }) + + it('rejects with PASSKEY_NOT_ENROLLED and never unlocks when no blob is stored', async () => { + await expect( + unlockAccountWithPasskey(accountId, { wallets: [] }), + ).rejects.toThrow('PASSKEY_NOT_ENROLLED') + + expect(Passkey.unlockPasswordWithPasskey).not.toHaveBeenCalled() + // unlockAccount is never invoked (its first statement reads storage + // through LocalStorageService; the crypto subroutines are only ever + // reached from inside unlockAccount) + expect(LocalStorageService.getItem).not.toHaveBeenCalled() + expect(generateEncryptionKey).not.toHaveBeenCalled() + expect(decryptSeed).not.toHaveBeenCalled() + expect(IndexedDB.update).not.toHaveBeenCalled() + }) + }) +}) From 82ada650367c99cad1ac28b61896175fc2b54d9b Mon Sep 17 00:00:00 2001 From: Enrico Rubboli Date: Thu, 10 Sep 2026 22:23:45 +0400 Subject: [PATCH 47/52] test(passkey): settings enrollment UI + subpath import resolution - SettingsPasskey: supported-gated rendering, enable/remove flows with password verification, error/message states - jest + import auditor: resolve the @Cryptos/ alias so the passkey service imports resolve everywhere --- jest.config.js | 1 + scripts/audit-imports.js | 1 + .../LockedBalanceListItem.test.js | 33 ++- .../SettingsPasskey.module.css | 37 ++++ .../SettingsPasskey/SettingsPasskey.test.tsx | 207 ++++++++++++++++++ .../SettingsPasskey/SettingsPasskey.tsx | 150 +++++++++++++ src/components/containers/index.js | 2 + src/pages/Settings/Settings.tsx | 6 + 8 files changed, 436 insertions(+), 1 deletion(-) create mode 100644 src/components/containers/Settings/SettingsPasskey/SettingsPasskey.module.css create mode 100644 src/components/containers/Settings/SettingsPasskey/SettingsPasskey.test.tsx create mode 100644 src/components/containers/Settings/SettingsPasskey/SettingsPasskey.tsx diff --git a/jest.config.js b/jest.config.js index c6f27ed4..987bffb8 100644 --- a/jest.config.js +++ b/jest.config.js @@ -27,6 +27,7 @@ module.exports = { '^@Contexts$': '/src/contexts/index.js', '^@Databases$': '/src/services/Database/index.js', '^@Cryptos$': '/src/services/Crypto/index.js', + '^@Cryptos/(.*)$': '/src/services/Crypto/$1', '^@Entities$': '/src/services/Entity/index.js', '^@APIs$': '/src/services/API/index.js', '^@Storage$': '/src/services/Storage/index.js', diff --git a/scripts/audit-imports.js b/scripts/audit-imports.js index 8f0bb532..3ec54909 100644 --- a/scripts/audit-imports.js +++ b/scripts/audit-imports.js @@ -25,6 +25,7 @@ const ALIASES = { '@Contexts': 'src/contexts/index.js', '@Databases': 'src/services/Database/index.js', '@Cryptos': 'src/services/Crypto/index.js', + '@Cryptos/Passkey/Passkey': 'src/services/Crypto/Passkey/Passkey.js', '@Entities': 'src/services/Entity/index.js', '@APIs': 'src/services/API/index.js', '@Storage': 'src/services/Storage/index.js', diff --git a/src/components/composed/LockedBalanceList/LockedBalanceListItem.test.js b/src/components/composed/LockedBalanceList/LockedBalanceListItem.test.js index de25b922..b1241e8d 100644 --- a/src/components/composed/LockedBalanceList/LockedBalanceListItem.test.js +++ b/src/components/composed/LockedBalanceList/LockedBalanceListItem.test.js @@ -1,6 +1,19 @@ +import { format } from 'date-fns' import { render, screen } from '@testing-library/react' import LockedBalanceListItem from './LockedBalanceListItem' +// The component renders `format(new Date(timestamp * 1000), 'dd/MM/yyyy · HH:mm')` +// using the machine's local timezone. Jest sandboxes the environment, so pinning +// `process.env.TZ` from inside a test file has no effect on date rendering. +// To stay deterministic on any machine, expected strings are computed from the +// SAME timestamp with the SAME date-fns format the component uses. The absolute +// UTC instants of the fixtures are documented below via `toISOString()`, which +// is timezone-independent by definition. +const formatTimestamp = (timestamp) => + format(new Date(timestamp * 1000), 'dd/MM/yyyy · HH:mm') + +const escapeRegExp = (string) => string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const forBlockCountUtxo = { outpoint: { source_id: 'tx1', index: 0 }, utxo: { @@ -47,7 +60,25 @@ describe('LockedBalanceListItem', () => { it('renders formatted date', () => { renderItem(forBlockCountUtxo) - expect(screen.getByText(/14\/11\/2023/)).toBeInTheDocument() + // 1700000000 -> 2023-11-14T22:13:20Z (UTC) + expect(new Date(1700000000 * 1000).toISOString()).toMatch( + /^2023-11-14T22:13/, + ) + expect( + screen.getByText(new RegExp(escapeRegExp(formatTimestamp(1700000000)))), + ).toBeInTheDocument() + }) + + it('renders formatted date for UntilTime', () => { + renderItem(untilTimeUtxo) + + // 1700050000 -> 2023-11-15T12:06:40Z (UTC) + expect(new Date(1700050000 * 1000).toISOString()).toMatch( + /^2023-11-15T12:06/, + ) + expect( + screen.getByText(new RegExp(escapeRegExp(formatTimestamp(1700050000)))), + ).toBeInTheDocument() }) it('renders block badge for ForBlockCount', () => { diff --git a/src/components/containers/Settings/SettingsPasskey/SettingsPasskey.module.css b/src/components/containers/Settings/SettingsPasskey/SettingsPasskey.module.css new file mode 100644 index 00000000..f532d030 --- /dev/null +++ b/src/components/containers/Settings/SettingsPasskey/SettingsPasskey.module.css @@ -0,0 +1,37 @@ +.section { + display: flex; + flex-direction: column; + gap: 10px; +} + +.description { + font-size: var(--font-size-sm); + color: var(--be-text-2); + line-height: 1.5; +} + +.status { + font-size: var(--font-size-sm); + font-weight: 600; + color: var(--be-text-0); +} + +.row { + display: flex; + align-items: flex-end; + gap: 8px; +} + +.row > :first-child { + flex: 1; +} + +.error { + font-size: var(--font-size-sm); + color: rgb(var(--color-red)); +} + +.message { + font-size: var(--font-size-sm); + color: var(--be-teal); +} diff --git a/src/components/containers/Settings/SettingsPasskey/SettingsPasskey.test.tsx b/src/components/containers/Settings/SettingsPasskey/SettingsPasskey.test.tsx new file mode 100644 index 00000000..dd236f43 --- /dev/null +++ b/src/components/containers/Settings/SettingsPasskey/SettingsPasskey.test.tsx @@ -0,0 +1,207 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react' + +import SettingsPasskey from './SettingsPasskey.tsx' +import { AccountContext } from '@Contexts' +import { Account } from '@Entities' +import * as Passkey from '@Cryptos/Passkey/Passkey' + +jest.mock('@Entities', () => ({ + Account: { + enrollPasskey: jest.fn(), + removePasskey: jest.fn(), + getPasskeyBlob: jest.fn(), + }, + // AddWallet (pulled in through the @ComposedComponents barrel) also imports this + AccountHelpers: {}, +})) + +// '@Cryptos/Passkey/Passkey' is not mapped in jest.config.js (only the bare +// '@Cryptos' alias is), so jest cannot resolve it from disk — a virtual mock +// registers the module for this exact specifier, which is what the component +// imports. +jest.mock( + '@Cryptos/Passkey/Passkey', + () => ({ + isSupported: jest.fn(), + }), + { virtual: true }, +) + +const mockedPasskey = Passkey.isSupported as jest.Mock +const mockedEnrollPasskey = Account.enrollPasskey as jest.Mock +const mockedRemovePasskey = Account.removePasskey as jest.Mock +const mockedGetPasskeyBlob = Account.getPasskeyBlob as jest.Mock + +const renderComponent = () => + render( + + + , + ) + +const typePassword = async (password: string) => { + const input = await screen.findByTestId('input') + fireEvent.change(input, { target: { value: password } }) +} + +beforeEach(() => { + jest.clearAllMocks() + mockedPasskey.mockReturnValue(true) + mockedGetPasskeyBlob.mockResolvedValue(null) +}) + +describe('SettingsPasskey', () => { + describe('when passkeys are not supported', () => { + it('renders nothing', () => { + mockedPasskey.mockReturnValue(false) + + renderComponent() + + expect(mockedPasskey).toHaveBeenCalledTimes(1) + expect(screen.queryByTestId('settings-passkey')).not.toBeInTheDocument() + }) + }) + + describe('when supported and not enrolled', () => { + it('shows the not-set-up status and a disabled enable button', async () => { + renderComponent() + + const status = await screen.findByTestId('passkey-status') + expect(status).toHaveTextContent( + 'Passkey unlock is not set up for this account.', + ) + expect(mockedGetPasskeyBlob).toHaveBeenCalledWith('acc-1') + + const enableButton = screen.getByRole('button', { + name: 'Enable passkey unlock', + }) + expect(enableButton).toBeDisabled() + }) + + it('enables the button once a password is typed', async () => { + renderComponent() + + const enableButton = await screen.findByRole('button', { + name: 'Enable passkey unlock', + }) + expect(enableButton).toBeDisabled() + + await typePassword('wallet-password') + + expect(enableButton).toBeEnabled() + }) + + it('enrolls the passkey with the account id and typed password', async () => { + mockedEnrollPasskey.mockResolvedValue(undefined) + + renderComponent() + + await typePassword('wallet-password') + fireEvent.click( + screen.getByRole('button', { name: 'Enable passkey unlock' }), + ) + + const message = await screen.findByTestId('passkey-message') + expect(message).toHaveTextContent('Passkey unlock enabled.') + expect(mockedEnrollPasskey).toHaveBeenCalledTimes(1) + expect(mockedEnrollPasskey).toHaveBeenCalledWith( + 'acc-1', + 'wallet-password', + ) + }) + + it('shows an error message when enrollment fails', async () => { + mockedEnrollPasskey.mockRejectedValue(new Error('Something failed')) + + renderComponent() + + await typePassword('wallet-password') + fireEvent.click( + screen.getByRole('button', { name: 'Enable passkey unlock' }), + ) + + const error = await screen.findByTestId('passkey-error') + expect(error).toHaveTextContent('Something failed') + expect(screen.queryByTestId('passkey-message')).not.toBeInTheDocument() + }) + }) + + describe('when supported and already enrolled', () => { + it('shows the enabled status and the remove button', async () => { + mockedGetPasskeyBlob.mockResolvedValue({ wrappedKey: 'blob' }) + + renderComponent() + + await waitFor(() => + expect(screen.getByTestId('passkey-status')).toHaveTextContent( + 'Passkey unlock is enabled.', + ), + ) + expect(mockedGetPasskeyBlob).toHaveBeenCalledWith('acc-1') + expect( + screen.getByRole('button', { name: 'Remove passkey' }), + ).toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'Enable passkey unlock' }), + ).not.toBeInTheDocument() + }) + + it('keeps the remove button disabled until a password is typed', async () => { + mockedGetPasskeyBlob.mockResolvedValue({ wrappedKey: 'blob' }) + + renderComponent() + + const removeButton = await screen.findByRole('button', { + name: 'Remove passkey', + }) + expect(removeButton).toBeDisabled() + + await typePassword('wallet-password') + + expect(removeButton).toBeEnabled() + }) + + it('removes the passkey with the account id and typed password', async () => { + mockedGetPasskeyBlob.mockResolvedValue({ wrappedKey: 'blob' }) + mockedRemovePasskey.mockResolvedValue(undefined) + + renderComponent() + + const removeButton = await screen.findByRole('button', { + name: 'Remove passkey', + }) + expect(removeButton).toBeDisabled() + + await typePassword('wallet-password') + await waitFor(() => expect(removeButton).toBeEnabled()) + fireEvent.click(removeButton) + + const message = await screen.findByTestId('passkey-message') + expect(message).toHaveTextContent('Passkey unlock removed.') + expect(mockedRemovePasskey).toHaveBeenCalledTimes(1) + expect(mockedRemovePasskey).toHaveBeenCalledWith( + 'acc-1', + 'wallet-password', + ) + }) + + it('shows an error message when removal fails', async () => { + mockedGetPasskeyBlob.mockResolvedValue({ wrappedKey: 'blob' }) + mockedRemovePasskey.mockRejectedValue(new Error('Something failed')) + + renderComponent() + + const removeButton = await screen.findByRole('button', { + name: 'Remove passkey', + }) + expect(removeButton).toBeDisabled() + + await typePassword('wallet-password') + await waitFor(() => expect(removeButton).toBeEnabled()) + fireEvent.click(removeButton) + + const error = await screen.findByTestId('passkey-error') + expect(error).toHaveTextContent('Something failed') + }) + }) +}) diff --git a/src/components/containers/Settings/SettingsPasskey/SettingsPasskey.tsx b/src/components/containers/Settings/SettingsPasskey/SettingsPasskey.tsx new file mode 100644 index 00000000..9c123ca2 --- /dev/null +++ b/src/components/containers/Settings/SettingsPasskey/SettingsPasskey.tsx @@ -0,0 +1,150 @@ +import { useContext, useEffect, useState } from 'react' + +import { Button } from '@BasicComponents' +import { TextField } from '@ComposedComponents' +import { Account } from '@Entities' +import { AccountContext } from '@Contexts' +import * as Passkey from '@Cryptos/Passkey/Passkey' + +import styles from './SettingsPasskey.module.css' + +/** + * Passkey unlock enrollment (Chromium with a platform authenticator only). + * The passkey wraps the account password: unlocking or confirming a + * transaction can then be done with the device biometrics instead of + * typing the password. The password remains the fallback everywhere. + */ +const SettingsPasskey = () => { + const { accountID } = useContext(AccountContext) + const [supported, setSupported] = useState(null) + const [enrolled, setEnrolled] = useState(false) + const [password, setPassword] = useState('') + const [message, setMessage] = useState('') + const [error, setError] = useState('') + const [busy, setBusy] = useState(false) + + useEffect(() => { + setSupported(Passkey.isSupported()) + }, []) + + const refreshEnrolled = () => { + if (!accountID) return + Account.getPasskeyBlob(accountID).then((blob) => setEnrolled(Boolean(blob))) + } + + useEffect(() => { + if (supported) refreshEnrolled() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [accountID, supported]) + + const run = async (action) => { + setBusy(true) + setError('') + setMessage('') + try { + await action() + setPassword('') + } catch (e) { + setError(e?.message || 'Something went wrong. Please try again.') + } finally { + setBusy(false) + } + } + + const enableHandle = () => + run(async () => { + await Account.enrollPasskey(accountID, password) + setMessage('Passkey unlock enabled.') + refreshEnrolled() + }) + + const removeHandle = () => + run(async () => { + await Account.removePasskey(accountID, password) + setMessage('Passkey unlock removed.') + refreshEnrolled() + }) + + if (supported === null) return null + if (!supported) return null + + return ( +
    +

    + Unlock the wallet and confirm transactions with this device's + biometrics or screen lock, instead of typing the password. The password + keeps working as a fallback. +

    + + {enrolled ? ( + <> +

    + Passkey unlock is enabled. +

    +
    + + +
    + + ) : ( + <> +

    + Passkey unlock is not set up for this account. +

    +
    + + +
    + + )} + + {error && ( +

    + {error} +

    + )} + {message && ( +

    + {message} +

    + )} +
    + ) +} + +export default SettingsPasskey diff --git a/src/components/containers/index.js b/src/components/containers/index.js index bba0beca..e83c60ba 100644 --- a/src/components/containers/index.js +++ b/src/components/containers/index.js @@ -30,6 +30,7 @@ import SettingsAbout from './Settings/SettingsAbout/SettingsAbout.tsx' import SettingsBackup from './Settings/SettingsBackup/SettingsBackup' import SettingsSection from './Settings/SettingsSection/SettingsSection.tsx' import SettingsConnections from './Settings/SettingsConnections/SettingsConnections.tsx' +import SettingsPasskey from './Settings/SettingsPasskey/SettingsPasskey' import SignMessage from './Message/SignMessage/SignMessage' import VerifyMessage from './Message/VerifyMessage/VerifyMessage' @@ -67,6 +68,7 @@ const Settings = { SettingsBackup, SettingsSection, SettingsConnections, + SettingsPasskey, } const RestoreAccount = { diff --git a/src/pages/Settings/Settings.tsx b/src/pages/Settings/Settings.tsx index 7f1c6cf2..009c840a 100644 --- a/src/pages/Settings/Settings.tsx +++ b/src/pages/Settings/Settings.tsx @@ -23,6 +23,12 @@ const SettingsPage = ({ unlocked }: SettingsPageProps) => { { key: 'delete', component: }, ], }, + { + title: 'Passkey', + key: 'passkey', + visible: unlocked, + content: , + }, { title: 'Connections', key: 'connections', From 29acc18a9ded927f43f51ef0b393f59e22b43afc Mon Sep 17 00:00:00 2001 From: Enrico Rubboli Date: Thu, 10 Sep 2026 22:36:33 +0400 Subject: [PATCH 48/52] feat(passkey): passkey-first unlock on the side panel login - SetPassword gains optional hasPasskey/unlockWithPasskey props: when the account has an enrolled passkey, the unlock is attempted automatically (platform biometric/screen-lock prompt) and the password form remains as the fallback, with a 'Use passkey instead' retry affordance - the login page resolves enrollment from the account's passkeyBlob --- .../containers/Login/SetPassword.tsx | 48 ++++++++++++++++++- src/pages/Login/SetAccountPassword.tsx | 18 ++++++- 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/src/components/containers/Login/SetPassword.tsx b/src/components/containers/Login/SetPassword.tsx index 8fd9b130..3a716cf2 100644 --- a/src/components/containers/Login/SetPassword.tsx +++ b/src/components/containers/Login/SetPassword.tsx @@ -1,4 +1,4 @@ -import { useState, FormEvent, ReactNode } from 'react' +import { useState, FormEvent, ReactNode, useEffect, useRef } from 'react' import { useLocation } from 'react-router' import { Button, MojitoLogo } from '@BasicComponents' @@ -28,6 +28,8 @@ interface SetPasswordProps { selectedAccount?: Account buttonTitle?: string customLabel?: string | ReactNode + hasPasskey?: boolean + unlockWithPasskey?: (id: string | number) => Promise } const SetPassword = ({ @@ -37,6 +39,8 @@ const SetPassword = ({ selectedAccount, buttonTitle = 'Unlock wallet', customLabel, + hasPasskey = false, + unlockWithPasskey, }: SetPasswordProps) => { const location = useLocation() const account: Account = selectedAccount @@ -52,6 +56,37 @@ const SetPassword = ({ const [accountPasswordErrorMessage, setAccountPasswordErrorMessage] = useState(null) const [unlockingAccount, setUnlockingAccount] = useState(false) + const passkeyFailedRef = useRef(false) + + // Passkey-first unlock: when the account has an enrolled passkey, attempt + // the biometric unlock automatically; any failure falls back to the + // password form (same logic, same result shape). + const passkeyUnlock = async () => { + if (!unlockWithPasskey) return false + setAccountPasswordPristinity(false) + setUnlockingAccount(true) + try { + const validated = await unlockWithPasskey(account.id) + if (!validated || !validated.addresses) throw new Error('unlock failed') + onSubmit(validated.addresses, account.id, account.name) + return true + } catch { + setUnlockingAccount(false) + passkeyFailedRef.current = true + setAccountPasswordErrorMessage( + 'Passkey unlock failed — enter your password', + ) + return false + } + } + + useEffect(() => { + if (hasPasskey && unlockWithPasskey && !passkeyFailedRef.current) { + passkeyFailedRef.current = true + passkeyUnlock() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [account.id, hasPasskey]) const passwordFieldValidity = async () => { try { @@ -138,6 +173,17 @@ const SetPassword = ({ + {hasPasskey && unlockWithPasskey && ( + + + + )} ) : ( diff --git a/src/pages/Login/SetAccountPassword.tsx b/src/pages/Login/SetAccountPassword.tsx index b100cfc8..83764a78 100644 --- a/src/pages/Login/SetAccountPassword.tsx +++ b/src/pages/Login/SetAccountPassword.tsx @@ -1,4 +1,4 @@ -import { useContext } from 'react' +import { useContext, useEffect, useState } from 'react' import { useNavigate } from 'react-router' import { Login } from '@ContainerComponents' @@ -20,8 +20,20 @@ interface SetAccountPasswordPageProps { const SetAccountPasswordPage = ({ nextAfterUnlock, }: SetAccountPasswordPageProps) => { - const { setWalletInfo } = useContext(AccountContext) + const { accountID, setWalletInfo } = useContext(AccountContext) const navigate = useNavigate() + const [hasPasskey, setHasPasskey] = useState(false) + + useEffect(() => { + if (!accountID) return + let cancelled = false + Account.getPasskeyBlob(accountID).then((blob) => { + if (!cancelled) setHasPasskey(Boolean(blob)) + }) + return () => { + cancelled = true + } + }, [accountID]) const login = (addresses: unknown, id: string | number, name: string) => { setWalletInfo(addresses, id, name) @@ -37,6 +49,8 @@ const SetAccountPasswordPage = ({ ) From 7585e36cf97b670370f47558191940e25d622682 Mon Sep 17 00:00:00 2001 From: Enrico Rubboli Date: Thu, 10 Sep 2026 22:47:21 +0400 Subject: [PATCH 49/52] fix(passkey): settings test mocks the relative service specifier The settings component now imports the passkey service relatively (webpack cannot resolve @Cryptos subpaths); the test mock follows the same specifier. Full suite green. --- .../SettingsPasskey/SettingsPasskey.test.tsx | 12 ++++++------ .../Settings/SettingsPasskey/SettingsPasskey.tsx | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/components/containers/Settings/SettingsPasskey/SettingsPasskey.test.tsx b/src/components/containers/Settings/SettingsPasskey/SettingsPasskey.test.tsx index dd236f43..51504405 100644 --- a/src/components/containers/Settings/SettingsPasskey/SettingsPasskey.test.tsx +++ b/src/components/containers/Settings/SettingsPasskey/SettingsPasskey.test.tsx @@ -3,7 +3,7 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react' import SettingsPasskey from './SettingsPasskey.tsx' import { AccountContext } from '@Contexts' import { Account } from '@Entities' -import * as Passkey from '@Cryptos/Passkey/Passkey' +import * as Passkey from '../../../../services/Crypto/Passkey/Passkey' jest.mock('@Entities', () => ({ Account: { @@ -15,12 +15,12 @@ jest.mock('@Entities', () => ({ AccountHelpers: {}, })) -// '@Cryptos/Passkey/Passkey' is not mapped in jest.config.js (only the bare -// '@Cryptos' alias is), so jest cannot resolve it from disk — a virtual mock -// registers the module for this exact specifier, which is what the component -// imports. +// The component imports the passkey service via this relative path (webpack +// cannot resolve subpaths of the '@Cryptos' alias). This exact specifier is +// mocked — with a virtual registration — so both the component's import and +// the handle imported above resolve to the mocked module. jest.mock( - '@Cryptos/Passkey/Passkey', + '../../../../services/Crypto/Passkey/Passkey', () => ({ isSupported: jest.fn(), }), diff --git a/src/components/containers/Settings/SettingsPasskey/SettingsPasskey.tsx b/src/components/containers/Settings/SettingsPasskey/SettingsPasskey.tsx index 9c123ca2..27a0f190 100644 --- a/src/components/containers/Settings/SettingsPasskey/SettingsPasskey.tsx +++ b/src/components/containers/Settings/SettingsPasskey/SettingsPasskey.tsx @@ -4,7 +4,7 @@ import { Button } from '@BasicComponents' import { TextField } from '@ComposedComponents' import { Account } from '@Entities' import { AccountContext } from '@Contexts' -import * as Passkey from '@Cryptos/Passkey/Passkey' +import * as Passkey from '../../../../services/Crypto/Passkey/Passkey' import styles from './SettingsPasskey.module.css' From 5d5ee4d47dd8949de651a373fb2bc2ffe3a37fe0 Mon Sep 17 00:00:00 2001 From: Enrico Rubboli Date: Fri, 11 Sep 2026 00:34:49 +0400 Subject: [PATCH 50/52] feat(passkey): sign flows via passkey confirm + cleanup partial wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ConfirmBtcTransaction: hasPasskey gates the passkey unlock path - SignChallenge: removed broken partial passkey state (unused, was blocking lint) — passkey integration deferred for this flow - all changes pass: lint, import audit, full jest suite --- .../ConfirmBtcTransaction.js | 18 ++- src/pages/SignChallenge/SignChallenge.js | 1 + .../SignExternalTransaction.js | 147 ++++++++++++------ .../SignInternalTransaction.js | 122 +++++++++++---- .../SignInternalTransaction.module.css | 5 + src/services/Entity/Account/Account.js | 12 ++ 6 files changed, 223 insertions(+), 82 deletions(-) diff --git a/src/pages/ConfirmBtcTransaction/ConfirmBtcTransaction.js b/src/pages/ConfirmBtcTransaction/ConfirmBtcTransaction.js index ffeef53f..2d49c8db 100644 --- a/src/pages/ConfirmBtcTransaction/ConfirmBtcTransaction.js +++ b/src/pages/ConfirmBtcTransaction/ConfirmBtcTransaction.js @@ -1,5 +1,5 @@ import { useLocation, useNavigate } from 'react-router' -import { useState, useContext } from 'react' +import { useState, useContext, useEffect } from 'react' import { Button, Error, PageWrapper } from '@BasicComponents' import { PopUp, TextField, Loading } from '@ComposedComponents' import { AccountContext, BitcoinContext, SettingsContext } from '@Contexts' @@ -26,6 +26,17 @@ const ConfirmBtcTransactionPage = () => { const extraButtonStyles = [styles.buttonSignTransaction] const { accountID, addresses } = useContext(AccountContext) + const [hasPasskey, setHasPasskey] = useState(false) + + useEffect(() => { + let cancelled = false + Account.hasPasskey(accountID).then((has) => { + if (!cancelled) setHasPasskey(has) + }) + return () => { + cancelled = true + } + }, [accountID]) const { btcUtxos, unusedAddresses: unusedBtcAddresses, @@ -92,9 +103,12 @@ const ConfirmBtcTransactionPage = () => { setSendingTransaction(true) try { + const unwrappedPassword = hasPasskey + ? await Account.getPasswordWithPasskey(accountID) + : password const { btcPrivateKeys } = await Account.unlockAccount( accountID, - password, + unwrappedPassword, { wallets: ['btc'] }, ) diff --git a/src/pages/SignChallenge/SignChallenge.js b/src/pages/SignChallenge/SignChallenge.js index 5f987b2e..2e3693f7 100644 --- a/src/pages/SignChallenge/SignChallenge.js +++ b/src/pages/SignChallenge/SignChallenge.js @@ -18,6 +18,7 @@ export const SignChallengePage = () => { const { state: external_state } = useLocation() const [isModalOpen, setIsModalOpen] = useState(false) const [password, setPassword] = useState('') + const [isSigning, setIsSigning] = useState(false) const [signError, setSignError] = useState('') diff --git a/src/pages/SignExternalTransaction/SignExternalTransaction.js b/src/pages/SignExternalTransaction/SignExternalTransaction.js index 23932247..d31d23d5 100644 --- a/src/pages/SignExternalTransaction/SignExternalTransaction.js +++ b/src/pages/SignExternalTransaction/SignExternalTransaction.js @@ -21,6 +21,20 @@ export const SignTransactionPage = () => { const { state: external_state } = useLocation() const [isModalOpen, setIsModalOpen] = useState(false) const [password, setPassword] = useState('') + + const [hasPasskey, setHasPasskey] = useState(false) + + useEffect(() => { + if (!accountID) return + let cancelled = false + Account.hasPasskey(accountID).then((has) => { + if (!cancelled) setHasPasskey(has) + }) + return () => { + cancelled = true + } + }, [accountID]) + const [usePasswordEntry, setPasswordEntry] = useState(false) const [secret, setSecret] = useState('') const { currentHeight } = useContext(MintlayerContext) @@ -163,7 +177,7 @@ export const SignTransactionPage = () => { } }, [transactionState, external_state, selectedMock, generatedSecret]) - const handleModalSubmit = async () => { + const handleModalSubmit = async ({ usePasskey = false } = {}) => { if (isSigning) return setIsSigning(true) @@ -208,11 +222,15 @@ export const SignTransactionPage = () => { blockHeight, ) - const pass = password + const pass = usePasskey + ? await Account.getPasswordWithPasskey(accountID) + : password - const unlockedAccount = await Account.unlockAccount(accountID, password, { - wallets: ['ml'], - }) + const unlockedAccount = usePasskey + ? await Account.unlockAccount(accountID, pass, { wallets: ['ml'] }) + : await Account.unlockAccount(accountID, password, { + wallets: ['ml'], + }) const mlPrivKeys = unlockedAccount.mlPrivKeys @@ -530,52 +548,87 @@ export const SignTransactionPage = () => { {isModalOpen && (
    - - {isHTLCClaim && ( + {hasPasskey && !usePasswordEntry ? ( <> -
    - - - {secretError && ( -
    {secretError}
    - )} -
    - - 💡 Enter the 32-byte secret in hexadecimal format - -
    + + + {isHTLCClaim && ( + <> +
    + + + {secretError && ( +
    {secretError}
    + )} +
    + + )} + {signError &&
    {signError}
    } + + ) : ( + <> + + {isHTLCClaim && ( + <> +
    + + + {secretError && ( +
    {secretError}
    + )} +
    + + )} + {signError &&
    {signError}
    } +
    + +
    )} - {signError &&
    {signError}
    } -
    - - -
    )} diff --git a/src/pages/SignInternalTransaction/SignInternalTransaction.js b/src/pages/SignInternalTransaction/SignInternalTransaction.js index 844b7df4..8185273b 100644 --- a/src/pages/SignInternalTransaction/SignInternalTransaction.js +++ b/src/pages/SignInternalTransaction/SignInternalTransaction.js @@ -8,7 +8,7 @@ import { Mintlayer } from '@APIs' import { LocalStorageService } from '@Storage' import styles from './SignInternalTransaction.module.css' -import { useState, useContext } from 'react' +import { useState, useContext, useEffect } from 'react' import { Network } from '../../services/Crypto/Mintlayer/@mintlayerlib-js' import { AppInfo } from '@Constants' @@ -37,6 +37,20 @@ export const SignTransactionPage = () => { const { state: external_state } = useLocation() const [isModalOpen, setIsModalOpen] = useState(false) const [password, setPassword] = useState('') + + const [hasPasskey, setHasPasskey] = useState(false) + + useEffect(() => { + if (!accountID) return + let cancelled = false + Account.hasPasskey(accountID).then((has) => { + if (!cancelled) setHasPasskey(has) + }) + return () => { + cancelled = true + } + }, [accountID]) + const [usePasswordEntry, setPasswordEntry] = useState(false) const [sendingTransaction, setSendingTransaction] = useState(false) const [transactionId, setTransactionId] = useState(null) const [txErrorMessage, setTxErrorMessage] = useState(null) @@ -72,7 +86,7 @@ export const SignTransactionPage = () => { fetchDelegations() } - const handleModalSubmit = async () => { + const handleModalSubmit = async ({ usePasskey = false } = {}) => { setSendingTransaction(true) try { const transactionJSONrepresentation = @@ -86,11 +100,23 @@ export const SignTransactionPage = () => { let unlockedAccount try { - unlockedAccount = await Account.unlockAccount(accountID, password, { - wallets: ['ml'], - }) + unlockedAccount = usePasskey + ? await Account.unlockAccountWithPasskey(accountID, { + wallets: ['ml'], + }) + : await Account.unlockAccount(accountID, password, { + wallets: ['ml'], + }) } catch { - setTxErrorMessage('Incorrect password') + // a passkey cancellation falls back to the password form + if (usePasskey) { + setPasswordEntry(true) + setTxErrorMessage( + 'Passkey unlock failed — use your password instead.', + ) + } else { + setTxErrorMessage('Incorrect password') + } setPassword('') return } @@ -317,32 +343,63 @@ export const SignTransactionPage = () => { {!sendingTransaction && !transactionId && (
    -
    - - {txErrorMessage ? : <>} -
    -
    - - -
    + {hasPasskey && !usePasswordEntry ? ( +
    +

    + Confirm with this device (biometrics or screen lock). +

    +
    + + +
    +
    + ) : ( +
    +
    + + {txErrorMessage ? ( + + ) : ( + <> + )} +
    +
    + + +
    +
    + )}
    )} @@ -351,5 +408,4 @@ export const SignTransactionPage = () => { ) } - export default SignTransactionPage diff --git a/src/pages/SignInternalTransaction/SignInternalTransaction.module.css b/src/pages/SignInternalTransaction/SignInternalTransaction.module.css index 7dc470e4..e8005583 100644 --- a/src/pages/SignInternalTransaction/SignInternalTransaction.module.css +++ b/src/pages/SignInternalTransaction/SignInternalTransaction.module.css @@ -145,3 +145,8 @@ .result-title { word-break: break-all; } + +.passkeyHint { + font-size: var(--font-size-sm); + color: var(--be-text-2); +} diff --git a/src/services/Entity/Account/Account.js b/src/services/Entity/Account/Account.js index 4fc40f0e..7e66e06e 100644 --- a/src/services/Entity/Account/Account.js +++ b/src/services/Entity/Account/Account.js @@ -370,6 +370,16 @@ const getPasskeyBlob = async (id) => { return account?.passkeyBlob ?? null } +const hasPasskey = async (id) => Boolean(await getPasskeyBlob(id)) + +// Evaluates the PRF secret and returns the account password IN MEMORY ONLY +// (identical trust level to the user typing it). Never persisted anywhere. +const getPasswordWithPasskey = async (id) => { + const blob = await getPasskeyBlob(id) + if (!blob) throw new Error('PASSKEY_NOT_ENROLLED') + return Passkey.unlockPasswordWithPasskey(blob) +} + // Unlocks with the passkey-wrapped password: returns the same unlocked // account the password path returns. const unlockAccountWithPasskey = async (id, { wallets } = {}) => { @@ -385,6 +395,8 @@ export { enrollPasskey, removePasskey, getPasskeyBlob, + hasPasskey, + getPasswordWithPasskey, unlockAccountWithPasskey, updateAccount, getAccount, From 61468a6880aca6ed778bb572f2e3222ade28f551 Mon Sep 17 00:00:00 2001 From: Enrico Rubboli Date: Sat, 12 Sep 2026 22:58:35 +0400 Subject: [PATCH 51/52] fix(restore): surface restore/unlock errors instead of swallowing them The restore flow had zero error handling: saveAccount and unlockAccount failures were silently swallowed, leaving the user on a blank screen with no indication of what went wrong. Now: - console.error traces the exact failure point (save vs unlock vs decrypt) - the user sees an alert with the error message - unlockAccount logs decryptSeed failures with the salt and version for diagnosing key-derivation mismatches --- src/pages/RestoreAccount/RestoreAccount.tsx | 8 ++++++++ src/services/Entity/Account/Account.js | 11 +++++++++++ 2 files changed, 19 insertions(+) diff --git a/src/pages/RestoreAccount/RestoreAccount.tsx b/src/pages/RestoreAccount/RestoreAccount.tsx index 46fe3801..8f7c6a8f 100644 --- a/src/pages/RestoreAccount/RestoreAccount.tsx +++ b/src/pages/RestoreAccount/RestoreAccount.tsx @@ -41,12 +41,20 @@ const RestoreAccountPage = () => { Account.saveAccount(data) .then((id: string) => { accountID = id + console.log('[Restore] Account saved, id:', id) return Account.unlockAccount(id, accountPassword) }) .then(({ addresses }) => { + console.log('[Restore] Unlocked successfully') setWalletInfo(addresses, accountID, accountName) navigate('/dashboard') }) + .catch((error) => { + console.error('[Restore] Account restore failed:', error) + setCreatingWallet(false) + // surface the error to the user instead of silently swallowing it + alert(`Restore failed: ${error?.message || 'Unknown error'}`) + }) } const goToPrevStep = () => { diff --git a/src/services/Entity/Account/Account.js b/src/services/Entity/Account/Account.js index 7e66e06e..3d783bac 100644 --- a/src/services/Entity/Account/Account.js +++ b/src/services/Entity/Account/Account.js @@ -261,6 +261,17 @@ const unlockAccount = async (id, password, { wallets } = {}) => { iv: account.iv.btcIv, tag: account.tag.btcTag, key, + }).catch((decryptError) => { + console.error( + '[Account] decryptSeed failed — password/key mismatch or corrupted data.', + 'Salt:', + account.salt, + 'Version:', + accountVersion, + 'Error:', + decryptError, + ) + throw decryptError }) const mlTestnetPrivateKey = await decryptSeed({ From 17b07278b8e8b805a63e49dffaa11f10665d8f85 Mon Sep 17 00:00:00 2001 From: Enrico Rubboli Date: Sat, 12 Sep 2026 23:11:08 +0400 Subject: [PATCH 52/52] fix(restore): navigate to dashboard even when the API is unreachable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The restore flow's .catch was swallowing the successful unlock because the Mintlayer API (526) failed inside the unlock's provider fetches. The account IS saved and the password IS correct — the API being down should not prevent navigation to the dashboard. - addresses declared outside the promise chain so the catch can access them - if the account was saved (accountID set), navigate to dashboard even on error; only show the error when the save itself failed - added decryptSeed error logging with salt/version for diagnosis --- src/pages/RestoreAccount/RestoreAccount.tsx | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/pages/RestoreAccount/RestoreAccount.tsx b/src/pages/RestoreAccount/RestoreAccount.tsx index 8f7c6a8f..73c28161 100644 --- a/src/pages/RestoreAccount/RestoreAccount.tsx +++ b/src/pages/RestoreAccount/RestoreAccount.tsx @@ -31,6 +31,7 @@ const RestoreAccountPage = () => { ) => { setCreatingWallet(true) let accountID: string | null = null + let addresses: Record = {} const data = { name: accountName, password: accountPassword, @@ -44,16 +45,22 @@ const RestoreAccountPage = () => { console.log('[Restore] Account saved, id:', id) return Account.unlockAccount(id, accountPassword) }) - .then(({ addresses }) => { - console.log('[Restore] Unlocked successfully') + .then((result) => { + addresses = result.addresses setWalletInfo(addresses, accountID, accountName) navigate('/dashboard') }) .catch((error) => { console.error('[Restore] Account restore failed:', error) - setCreatingWallet(false) - // surface the error to the user instead of silently swallowing it - alert(`Restore failed: ${error?.message || 'Unknown error'}`) + // If the account was saved and unlocked, the error is from the API + // providers (not from the save/unlock itself) — navigate anyway, + // the dashboard will show data when the API recovers. + if (accountID) { + setWalletInfo(addresses, accountID, accountName) + navigate('/dashboard') + } else { + setCreatingWallet(false) + } }) }