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/REVIEW-PLAN.md b/REVIEW-PLAN.md new file mode 100644 index 00000000..aaf26d1a --- /dev/null +++ b/REVIEW-PLAN.md @@ -0,0 +1,163 @@ +# 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) + +- **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, + 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/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/bridge-contract.md b/doc/bridge-contract.md new file mode 100644 index 00000000..9dd337e7 --- /dev/null +++ b/doc/bridge-contract.md @@ -0,0 +1,117 @@ +# 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. + +## 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 +`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 | +| `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()` + +| 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/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/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/jest.config.js b/jest.config.js index aedc5619..987bffb8 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', }, @@ -27,9 +27,11 @@ 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', + '^@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/package-lock.json b/package-lock.json index ecd6c50e..35ae7103 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": { @@ -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", @@ -112,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" }, @@ -127,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": { @@ -137,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", @@ -168,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" @@ -198,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" @@ -272,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": { @@ -296,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" @@ -341,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": { @@ -401,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": { @@ -411,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": { @@ -421,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": { @@ -446,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" @@ -1266,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" @@ -1957,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": { @@ -1991,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" @@ -2324,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", @@ -2537,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": { @@ -2714,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": { @@ -2976,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": { @@ -3164,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": { @@ -3354,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": { @@ -3558,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": { @@ -3640,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" @@ -3656,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": { @@ -3996,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": { @@ -4202,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": { @@ -4991,6 +5007,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", @@ -5380,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", @@ -5924,6 +5937,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", @@ -6284,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": { @@ -6949,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": { @@ -6984,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": { @@ -7184,6 +7204,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", @@ -7273,9 +7315,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": { @@ -7517,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": { @@ -7605,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": { @@ -7665,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", @@ -7735,15 +7766,15 @@ "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": { - "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": { @@ -7755,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" @@ -7814,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": { @@ -7895,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", @@ -7915,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": [ { @@ -7935,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" @@ -8132,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": [ { @@ -8589,9 +8620,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": { @@ -9550,9 +9581,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": { @@ -9568,6 +9599,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", @@ -9736,13 +9780,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": { @@ -9766,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" }, @@ -9788,9 +9832,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": { @@ -10201,6 +10245,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 +10476,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", @@ -10587,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": { @@ -10600,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", @@ -10627,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", @@ -10705,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": [ { @@ -10858,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": [ { @@ -11126,6 +11335,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 +11578,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" @@ -11598,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": { @@ -11997,6 +12219,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 +12255,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" @@ -12745,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": { @@ -12886,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": { @@ -13068,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": { @@ -13217,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": { @@ -13387,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" @@ -13403,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": { @@ -13651,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": { @@ -13844,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": { @@ -14222,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": { @@ -14512,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": { @@ -14680,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": { @@ -14850,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" @@ -14866,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": { @@ -15046,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": { @@ -15197,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": { @@ -15307,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" @@ -15508,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": { @@ -15595,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" }, @@ -15759,9 +16014,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": { @@ -15866,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": { @@ -15945,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": [ { @@ -16050,6 +16305,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", @@ -16058,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", @@ -16195,6 +16472,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", @@ -16550,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" }, @@ -16591,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": { @@ -16769,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": [ { @@ -16789,7 +17081,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -16828,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", @@ -16858,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", @@ -16888,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", @@ -17025,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": { @@ -17108,9 +17386,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": { @@ -17177,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" @@ -17362,9 +17641,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", @@ -17384,12 +17663,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" @@ -17674,6 +17953,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", @@ -17839,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", @@ -17879,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": { @@ -17996,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": { @@ -18225,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": { @@ -18238,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" }, @@ -18258,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" @@ -18483,6 +18782,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", @@ -18850,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" @@ -19037,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": { @@ -19060,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 } @@ -19173,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": { @@ -19311,6 +19646,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", @@ -19640,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": [ { @@ -19758,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": { @@ -19783,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" @@ -20109,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": { @@ -20133,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", @@ -20261,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": { @@ -20531,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 3388d914..16b488f6 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": { @@ -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": [ @@ -81,6 +82,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", @@ -106,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" } } 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..a7f15c64 100644 --- a/public/background.js +++ b/public/background.js @@ -1,13 +1,39 @@ /* 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 - // Track popup window IDs - let popupWindowId = false - let connectWindowId = false + // 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() + const connectSlot = createApprovalSlot() let connectedSites = {} const pendingResponses = new Map() @@ -20,176 +46,458 @@ ) } - // Load connected sites from storage + // 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) + }) + }) + } + + // --- 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. + 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 || {} - console.log('[Mintlayer] Connected sites loaded:', connectedSites) + messageQueue.length = 0 }) - // Single listener for all messages - api.runtime.onMessage.addListener((message, sender, sendResponse) => { - console.log('[Mintlayer] Received message:', message, 'from', sender) + const pendingRequestKeyFor = (windowId) => `pendingRequest:${windowId}` - const origin = sender.origin || 'unknown' + const clearPendingRequest = (windowId) => { + if (typeof windowId !== 'number') return + api.storage.local.remove(pendingRequestKeyFor(windowId), () => { + if (api.runtime.lastError) { + console.error( + '[Mintlayer] Storage remove error:', + api.runtime.lastError, + ) + } + }) + } - // 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, - ) - } - }, - ) - }, + const getRequestOrigin = (sender) => { + if (sender.origin) return sender.origin + + try { + return sender.url ? new URL(sender.url).origin : 'unknown' + } catch { + return 'unknown' + } + } + + // 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 }) + + // 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) => + 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, error) => { + const windowId = slot.panelMode ? slot.windowId : slot.id + slot.id = false + slot.opening = false + slot.panelMode = false + + if (!slot.requestId) return + + const respond = pendingResponses.get(slot.requestId) + pendingResponses.delete(slot.requestId) + slot.requestId = null + clearPendingRequest(windowId) + respond?.({ error }) + } + + // 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 true // Keep channel open - } else if (typeof connectWindowId === 'number') { - api.windows.update(connectWindowId, { focused: true }) - sendResponse({ error: 'Connection window already open' }) + return } - } 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, - ) - } - }, - ) - }, + + // 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', ) - return true - } else if (typeof popupWindowId === 'number') { - api.windows.update(popupWindowId, { focused: true }) - sendResponse({ error: 'Transaction signing window already open' }) + 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) => { + console.error( + '[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 + 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({ + error: errorOf( + 'REQUEST_IN_PROGRESS', + 'An approval window is already open. Complete or close it first.', + ), + }) + 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, errorOf('REQUEST_CANCELLED', '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 (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 + } + + // 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.', + ), + ) + } }, ) - 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, - }, + }) + }, + ) + + return true + } + + // Handle popup responses from the wallet UI + const handlePopupResponse = (message) => { + const { requestId, origin, result, error, method, windowId } = message + const respond = pendingResponses.get(requestId) + 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 + } + + const rejected = + !result || Boolean(error) || (method && method.endsWith('_reject')) + + if (rejected) { + 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: errorOf( + 'STORAGE_ERROR', + 'Could not save the wallet connection. Please try again.', + ), }) - } else { - sendResponse({ result: null }) + return } + respond({ result }) + }) + return + } - return true - } else { - sendResponse({ error: 'Unknown method' }) + respond({ result, error }) + } + + const processMessage = (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 + } + + 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 } - // 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] @@ -199,69 +507,162 @@ sendResponse({ error: api.runtime.lastError.message }) return } + notifyOriginRevoked(targetOrigin) sendResponse({ result: { origin: targetOrigin } }) }) 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 openApprovalTarget( + connectSlot, + { + origin, + requestId: message.requestId, + permissions: message.params?.permissions || [], + action: 'connect', + }, + sendResponse, + sender, + ) + } else if (message.method === 'signTransaction') { + if (!connectedSites[origin]) { + sendResponse({ + error: errorOf( + 'NOT_CONNECTED', + 'This site is not connected to the wallet. Call connect first.', + ), + }) + return false + } + + return openApprovalTarget( + popupSlot, + { + origin, + 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, + sender, + ) + } else if (message.method === 'signChallenge') { + if (!connectedSites[origin]) { + sendResponse({ + error: errorOf( + 'NOT_CONNECTED', + 'This site is not connected to the wallet. Call connect first.', + ), + }) + return false + } + + return openApprovalTarget( + popupSlot, + { + origin, + requestId: message.requestId, + action: 'signChallenge', + data: message.params || {}, + network: connectedSites[origin]?.network, + }, + sendResponse, + sender, + ) + } 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: errorOf( + 'STORAGE_ERROR', + 'Could not disconnect the site. Please try again.', + ), + }) + return } + notifyOriginRevoked(origin) + sendResponse({ result: true }) + }) + return true + } - if (storedSendResponse) { - storedSendResponse({ result, error }) - pendingResponses.delete(requestId) - } + sendResponse({ result: true }) + } else if (message.method === 'getSession') { + // 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 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: errorOf( + 'UNSUPPORTED_METHOD', + `Unsupported wallet method: ${message.method}`, + ), + }) } + + 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 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, 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/background.test.js b/public/background.test.js new file mode 100644 index 00000000..6da69620 --- /dev/null +++ b/public/background.test.js @@ -0,0 +1,950 @@ +/** + * 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', +} +// 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}`, + 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), + }, + // 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) => { + 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: 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: () => {} }, + }, + 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() + }) + + 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) + // keyed by the window that will show the approval + expect(storageData['pendingRequest:700']).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', + windowId: 700, + 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:700']).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', + windowId: 700, + 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', + windowId: 700, + 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', + windowId: 700, + 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:701']).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', + windowId: 700, + 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', + windowId: 700, + 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', + windowId: 700, + 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'), + }) + }) + }) + 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', + }) + }) + }) + + 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) + }) + + // 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() + } + }) + + // 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 { + 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', + }) + }) + }) + + 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) + }) + }) +}) diff --git a/public/explorer/content-script.js b/public/explorer/content-script.js index c698bcb2..11c763ac 100644 --- a/public/explorer/content-script.js +++ b/public/explorer/content-script.js @@ -2,95 +2,173 @@ ;(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, - }, - '*', - ) + // 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) => { + if (!pendingRequests.has(requestId)) return + + clearTimeout(pendingRequests.get(requestId)) + pendingRequests.delete(requestId) + postToPage({ + 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) + } + + // 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. + 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') { - console.log('[Content] Received from SDK:', event.data) - - const requestData = { - requestId: event.data.requestId, - method: event.data.method, - params: event.data.params || {}, - } - - const message = - typeof cloneInto !== 'undefined' - ? cloneInto(requestData, window) - : requestData - - // 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', - }, - '*', - ) - return - } - - console.log('[Content] Response from background:', response) - const responseData = { - 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, '*') + if (event.source !== window || event.data?.type !== 'MINTLAYER_REQUEST') { + 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. + if (pendingRequests.has(requestId)) return + + // 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: '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) - // 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 + try { + api.runtime.sendMessage( + { + requestId, + method: event.data.method, + params: event.data.params || {}, + }, + (response) => { + if (!pendingRequests.has(requestId)) return + + if (api.runtime.lastError) { + console.error('[Mojito] Runtime error:', api.runtime.lastError) + failRequest( + requestId, + 'EXTENSION_ERROR', + 'Could not reach the wallet. Is it installed and enabled?', + ) + return + } + + clearTimeout(pendingRequests.get(requestId)) + pendingRequests.delete(requestId) + + 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.', + ) } }) - - const pendingResponses = new Set() // Track pending requests })() diff --git a/public/explorer/content-script.test.js b/public/explorer/content-script.test.js new file mode 100644 index 00000000..787f07f2 --- /dev/null +++ b/public/explorer/content-script.test.js @@ -0,0 +1,326 @@ +/** + * 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 +// Listeners the content script registers on chrome.runtime.onMessage +// (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 }) => { + sendMessageCalls.length = 0 + global.browser = undefined + onMessageListeners = [] + 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) + }, + onMessage: { + addListener: jest.fn((listener) => onMessageListeners.push(listener)), + }, + 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 = [] + addSpy?.mockRestore() + 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) + }) + +// 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({ + 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) + }) +}) + +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) + }, + onMessage: { addListener: jest.fn() }, + 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) + }) +}) + +describe('session revocation relay', () => { + it('relays MOJITO_SESSION_REVOKED from the background to the page as a disconnect event', async () => { + setup({ + sendMessageImpl: (_message, callback) => { + callback({ result: null }) + }, + }) + + // 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) + + dispatchFromBackground({ + type: 'MOJITO_SESSION_REVOKED', + origin: window.location.origin, + }) + await new Promise((resolve) => setTimeout(resolve, 10)) + window.removeEventListener('message', listener) + + // 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 }, + }) + }) + + 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) + + // 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', + }) + dispatchFromBackground({ + 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/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..a4c69337 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/", @@ -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: 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", @@ -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/*"] } ] } diff --git a/public/manifestFirefox.json b/public/manifestFirefox.json index 5c7d3c98..4d107496 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": { @@ -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" @@ -43,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: 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/public/mojito.js b/public/mojito.js index 06a38e1b..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 () { @@ -29,8 +35,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) + } } } @@ -49,14 +66,43 @@ 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. + 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 }, 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( { @@ -78,10 +124,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) } } @@ -102,21 +156,38 @@ }) }, - 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: {}, + }, + '*', + ) + } }, } 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/public/mojito.test.js b/public/mojito.test.js new file mode 100644 index 00000000..67d3fbc6 --- /dev/null +++ b/public/mojito.test.js @@ -0,0 +1,284 @@ +/** + * 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('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 + 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) + }) + }) +}) diff --git a/scripts/audit-imports.js b/scripts/audit-imports.js new file mode 100644 index 00000000..3ec54909 --- /dev/null +++ b/scripts/audit-imports.js @@ -0,0 +1,208 @@ +#!/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', + '@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', + '@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/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. +

    +
    + + + + + + + +
    +
    
    +    
    +  
    +
    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 2320267d..f527af44 100644
    --- a/src/assets/styles/index.css
    +++ b/src/assets/styles/index.css
    @@ -1,47 +1,38 @@
     * {
       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;
       padding: 0;
       overflow: hidden;
       text-decoration: none;
    +  /* wallet UI: content scrolls, the scrollbar itself stays invisible */
    +  scrollbar-width: none;
    +}
    +
    +*::-webkit-scrollbar {
    +  display: none;
     }
     
     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%
    -    ),
    -    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%
    -    ),
    -    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%
    +      120% 60% at 50% -10%,
    +      oklch(0.82 0.16 70 / 0.16) 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%
    +      80% 50% at 100% 100%,
    +      oklch(0.74 0.16 290 / 0.1) 0%,
    +      transparent 60%
         ),
         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%
    +      80% 50% at 0% 100%,
    +      oklch(0.82 0.12 195 / 0.08) 0%,
    +      transparent 60%
         ),
    -    rgb(235, 242, 240);
    +    var(--be-bg-0);
     }
     
     body {
    @@ -49,11 +40,38 @@ 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,
    +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;
    +  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/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/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/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..3a7bfea4 --- /dev/null +++ b/src/components/basic/Counter/Counter.tsx @@ -0,0 +1,67 @@ +import { useEffect, useRef, 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. 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, + prefix = '', + suffix = '', + duration = 1200, +}: CounterProps) => { + 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() + const tick = (t: number) => { + const p = Math.min(1, (t - start) / duration) + const eased = 1 - Math.pow(1 - p, 3) + setV(from + (value - from) * eased) + if (p < 1) rafRef.current = requestAnimationFrame(tick) + } + rafRef.current = requestAnimationFrame(tick) + return () => cancelAnimationFrame(rafRef.current) + }, [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/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/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..73d1578c --- /dev/null +++ b/src/components/basic/MojitoLogo/MojitoLogo.tsx @@ -0,0 +1,34 @@ +import { ReactComponent as MintlayerLogoSvg } from '@Assets/images/logo.svg' +import styles from './MojitoLogo.module.css' + +interface MojitoLogoProps { + size?: number + 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) => { + 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..77063312 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,12 +37,12 @@ .title { font-size: 16px; font-weight: 700; - color: rgb(var(--color-black)); + color: var(--be-text-0); } .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/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/Textarea/Textarea.css b/src/components/basic/Textarea/Textarea.css index 9d3f0b4e..ceaa2746 100644 --- a/src/components/basic/Textarea/Textarea.css +++ b/src/components/basic/Textarea/Textarea.css @@ -2,8 +2,11 @@ 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); + color: var(--be-text-0); } .textarea-invalid { 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/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..f97c0e62 --- /dev/null +++ b/src/components/basic/TokenIcon/TokenIcon.test.tsx @@ -0,0 +1,70 @@ +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 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(iconUri) +}) + +test('falls back to the glyph tile when the icon image fails to load', async () => { + const { getByTestId, queryByTestId } = render( + , + ) + + fireEvent.error(getByTestId('token-icon-image')) + await waitFor(() => + expect(queryByTestId('token-icon-image')).not.toBeInTheDocument(), + ) + expect(getByTestId('token-icon')).toHaveTextContent('$') +}) + +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( + , + ) + 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 new file mode 100644 index 00000000..9024278a --- /dev/null +++ b/src/components/basic/TokenIcon/TokenIcon.tsx @@ -0,0 +1,86 @@ +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, 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 +} + +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: , +} + +// 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://') + ? `https://ipfs.io/ipfs/${uri.slice('ipfs://'.length)}` + : 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/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/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/AssetRow/AssetRow.module.css b/src/components/composed/AssetRow/AssetRow.module.css new file mode 100644 index 00000000..0bdb3c00 --- /dev/null +++ b/src/components/composed/AssetRow/AssetRow.module.css @@ -0,0 +1,59 @@ +.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; +} + +.disabled { + opacity: 0.55; + cursor: not-allowed; +} 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/AssetRow/AssetRow.tsx b/src/components/composed/AssetRow/AssetRow.tsx new file mode 100644 index 00000000..87e1ed60 --- /dev/null +++ b/src/components/composed/AssetRow/AssetRow.tsx @@ -0,0 +1,80 @@ +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 + disabled?: boolean + 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.disabled && Sync issue} + {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/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/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/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.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.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/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/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 (
    + 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/composed/Navigation/Navigation.module.css b/src/components/composed/Navigation/Navigation.module.css index ffb21e98..a1086745 100644 --- a/src/components/composed/Navigation/Navigation.module.css +++ b/src/components/composed/Navigation/Navigation.module.css @@ -38,11 +38,11 @@ } .navigationItemActive { - background: rgb(var(--mojito-green-soft)); - color: rgb(var(--mojito-green)); + background: var(--be-amber-soft); + color: var(--be-amber); } .navigationItem:hover, .bottomMenuItem:hover { - background: rgb(var(--mojito-green-soft)); + background: oklch(1 0 0 / 0.06); } diff --git a/src/components/composed/Navigation/Navigation.test.js b/src/components/composed/Navigation/Navigation.test.js index cd1044d1..3ece8d09 100644 --- a/src/components/composed/Navigation/Navigation.test.js +++ b/src/components/composed/Navigation/Navigation.test.js @@ -92,6 +92,18 @@ describe('Navigation Component', () => { 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/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/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/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/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/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/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..45a28b94 100644 --- a/src/components/containers/DeleteAccount/DeleteAccount.module.css +++ b/src/components/containers/DeleteAccount/DeleteAccount.module.css @@ -29,13 +29,13 @@ font-size: 20px; font-weight: 700; text-align: center; - color: rgb(var(--color-black)); + color: var(--be-text-0); margin-bottom: 8px; } .subtitle { font-size: 14px; - color: rgb(var(--color-dark-gray)); + color: var(--be-text-2); text-align: center; margin-bottom: 24px; } @@ -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..3a716cf2 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 { useState, FormEvent, ReactNode, useEffect, useRef } 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' @@ -29,6 +28,8 @@ interface SetPasswordProps { selectedAccount?: Account buttonTitle?: string customLabel?: string | ReactNode + hasPasskey?: boolean + unlockWithPasskey?: (id: string | number) => Promise } const SetPassword = ({ @@ -38,6 +39,8 @@ const SetPassword = ({ selectedAccount, buttonTitle = 'Unlock wallet', customLabel, + hasPasskey = false, + unlockWithPasskey, }: SetPasswordProps) => { const location = useLocation() const account: Account = selectedAccount @@ -53,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 { @@ -110,8 +144,11 @@ const SetPassword = ({ {!unlockingAccount ? ( <> -
    - +
    +
    + {hasPasskey && unlockWithPasskey && ( + + + + )} ) : ( diff --git a/src/components/containers/RestoreAccount/RestoreAccountJson/FileUpload.module.css b/src/components/containers/RestoreAccount/RestoreAccountJson/FileUpload.module.css index dae6f93f..9028daba 100644 --- a/src/components/containers/RestoreAccount/RestoreAccountJson/FileUpload.module.css +++ b/src/components/containers/RestoreAccount/RestoreAccountJson/FileUpload.module.css @@ -3,13 +3,13 @@ font-size: var(--font-size-4xl); font-weight: 700; text-align: center; - color: rgb(var(--color-black)); + color: var(--be-text-0); } .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); } @@ -24,7 +24,7 @@ padding: var(--space-5xl) var(--space-4xl); border: 2px dashed rgb(var(--color-light-gray)); border-radius: 16px; - background: rgb(var(--color-gray)); + background: var(--be-bg-1); cursor: pointer; transition: border-color 0.2s, @@ -63,26 +63,26 @@ .uploadIcon { width: 24px; height: 24px; - color: rgb(var(--color-dark-gray)); + color: var(--be-text-2); } .dropzoneText { font-size: var(--font-size-lg); font-weight: 600; - color: rgb(var(--color-black)); + color: var(--be-text-0); text-align: center; } .dropzoneHint { font-size: var(--font-size-md); - color: rgb(var(--color-dark-gray)); + color: var(--be-text-2); text-align: center; } .uploadedFileName { font-size: var(--font-size-lg); font-weight: 600; - color: rgb(var(--color-black)); + color: var(--be-text-0); 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 c676057c..c71231d1 100644 --- a/src/components/containers/RestoreAccount/RestoreAccountJson/RestoreSuccess.module.css +++ b/src/components/containers/RestoreAccount/RestoreAccountJson/RestoreSuccess.module.css @@ -2,13 +2,13 @@ font-size: var(--font-size-5xl); font-weight: 700; text-align: center; - color: rgb(var(--color-black)); + color: var(--be-text-0); } .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 f28391f5..ad8ae445 100644 --- a/src/components/containers/RestoreAccount/RestoreAccountJson/WalletDetails.module.css +++ b/src/components/containers/RestoreAccount/RestoreAccountJson/WalletDetails.module.css @@ -2,7 +2,7 @@ font-size: var(--font-size-4xl); font-weight: 700; text-align: center; - color: rgb(var(--color-black)); + color: var(--be-text-0); @media (min-width: 901px) { margin-bottom: var(--space-xs); @@ -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); @@ -20,7 +20,7 @@ } .detailsCard { - background: rgb(var(--color-gray)); + background: var(--be-bg-1); border: 1px solid rgb(var(--color-medium-gray)); border-radius: 16px; overflow: hidden; @@ -60,14 +60,14 @@ 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; } .detailsValue { font-size: var(--font-size-lg); font-weight: 600; - color: rgb(var(--color-black)); + color: var(--be-text-0); word-break: break-word; } @@ -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/SendTransaction/AddressField.module.css b/src/components/containers/SendTransaction/AddressField.module.css index b50ef31b..fe46526f 100644 --- a/src/components/containers/SendTransaction/AddressField.module.css +++ b/src/components/containers/SendTransaction/AddressField.module.css @@ -7,11 +7,11 @@ .label { font-size: 13px; - color: rgba(var(--color-black), 0.5); + color: var(--be-text-3); } .separator { - color: rgba(var(--color-black), 0.25); + color: var(--be-text-3); } .networkLabel { diff --git a/src/components/containers/SendTransaction/AmountField.module.css b/src/components/containers/SendTransaction/AmountField.module.css index 118bad9f..97a8f126 100644 --- a/src/components/containers/SendTransaction/AmountField.module.css +++ b/src/components/containers/SendTransaction/AmountField.module.css @@ -7,7 +7,7 @@ .label { font-size: 13px; - color: rgba(var(--color-black), 0.5); + color: var(--be-text-3); } .errorMessage { diff --git a/src/components/containers/SendTransaction/FeesField.module.css b/src/components/containers/SendTransaction/FeesField.module.css index 82ca4dc3..17259d43 100644 --- a/src/components/containers/SendTransaction/FeesField.module.css +++ b/src/components/containers/SendTransaction/FeesField.module.css @@ -6,7 +6,7 @@ .label { font-size: 13px; - color: rgba(var(--color-black), 0.5); + color: var(--be-text-3); } .errorMessage { diff --git a/src/components/containers/Settings/SettingsAbout/SettingsAbout.module.css b/src/components/containers/Settings/SettingsAbout/SettingsAbout.module.css index c2468edd..348f4b8e 100644 --- a/src/components/containers/Settings/SettingsAbout/SettingsAbout.module.css +++ b/src/components/containers/Settings/SettingsAbout/SettingsAbout.module.css @@ -16,23 +16,23 @@ } .rowClickable:hover { - background: rgba(var(--color-black), 0.02); + background: oklch(1 0 0 / 0.02); } .label { font-size: 14px; font-weight: 600; - color: rgb(var(--color-black)); + color: var(--be-text-0); } .value { font-size: 13px; - color: rgb(var(--color-dark-gray)); + color: var(--be-text-2); } .chevron { width: 16px; height: 16px; - color: rgba(var(--color-black), 0.25); + color: var(--be-text-3); transform: rotate(-90deg); } diff --git a/src/components/containers/Settings/SettingsBackup/SettingsBackup.css b/src/components/containers/Settings/SettingsBackup/SettingsBackup.css index 4932e426..28307854 100644 --- a/src/components/containers/Settings/SettingsBackup/SettingsBackup.css +++ b/src/components/containers/Settings/SettingsBackup/SettingsBackup.css @@ -11,14 +11,14 @@ .backup-description h2 { font-size: 14px; letter-spacing: 0.05em; - color: rgb(var(--color-dark-gray)); + color: var(--be-text-2); margin-bottom: 6px; } .backup-description p { font-size: 13px; line-height: 1.5; - color: rgb(var(--color-black)); + color: var(--be-text-0); } .settings-backup-button { diff --git a/src/components/containers/Settings/SettingsConnections/SettingsConnections.module.css b/src/components/containers/Settings/SettingsConnections/SettingsConnections.module.css index 331813f9..d04930fa 100644 --- a/src/components/containers/Settings/SettingsConnections/SettingsConnections.module.css +++ b/src/components/containers/Settings/SettingsConnections/SettingsConnections.module.css @@ -28,7 +28,7 @@ .date { font-size: var(--font-size-sm); - color: rgba(var(--color-black), 0.5); + color: var(--be-text-3); } .disconnectButton.disconnectButton { @@ -51,7 +51,7 @@ .empty { padding: var(--space-lg) var(--space-xl); font-size: var(--font-size-sm); - color: rgba(var(--color-black), 0.5); + color: var(--be-text-3); } @media screen and (max-width: 420px) { diff --git a/src/components/containers/Settings/SettingsConnections/SettingsConnections.tsx b/src/components/containers/Settings/SettingsConnections/SettingsConnections.tsx index 9548de00..0d484a9b 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 @@ -63,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/components/containers/Settings/SettingsDelete/SettingsDelete.css b/src/components/containers/Settings/SettingsDelete/SettingsDelete.css index e5f7ebcc..4e842e42 100644 --- a/src/components/containers/Settings/SettingsDelete/SettingsDelete.css +++ b/src/components/containers/Settings/SettingsDelete/SettingsDelete.css @@ -18,7 +18,7 @@ .delete-description p { font-size: 13px; line-height: 1.5; - color: rgb(var(--color-black)); + color: var(--be-text-0); } .settings-delete-button { 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..51504405 --- /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 '../../../../services/Crypto/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: {}, +})) + +// 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( + '../../../../services/Crypto/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..27a0f190 --- /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 '../../../../services/Crypto/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/Settings/SettingsSection/SettingsSection.module.css b/src/components/containers/Settings/SettingsSection/SettingsSection.module.css index ebf07137..239b51aa 100644 --- a/src/components/containers/Settings/SettingsSection/SettingsSection.module.css +++ b/src/components/containers/Settings/SettingsSection/SettingsSection.module.css @@ -9,13 +9,13 @@ 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; } .card { - background: rgb(var(--color-gray)); + background: var(--be-bg-1); border-radius: var(--round-size); border: 1px solid rgba(var(--color-light-gray), 0.3); overflow: hidden; diff --git a/src/components/containers/Settings/SettingsTestnet/SettingsTestnet.module.css b/src/components/containers/Settings/SettingsTestnet/SettingsTestnet.module.css index 37f8405d..92143566 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); } @@ -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; } @@ -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/SignTransaction/ExternalTransactionPreview/ExternalTransactionPreview.css b/src/components/containers/SignTransaction/ExternalTransactionPreview/ExternalTransactionPreview.css index c6dd24ab..8acce01f 100644 --- a/src/components/containers/SignTransaction/ExternalTransactionPreview/ExternalTransactionPreview.css +++ b/src/components/containers/SignTransaction/ExternalTransactionPreview/ExternalTransactionPreview.css @@ -1,5 +1,5 @@ .transactionPreview { - background: #ffffff; + background: var(--be-bg-1); border-radius: 12px; } @@ -61,7 +61,7 @@ .detail-item { padding: 16px; - background: #ffffff; + background: var(--be-bg-1); border: 1px solid #e2e8f0; border-radius: 6px; } @@ -99,7 +99,7 @@ } .transaction-table td { - background: #ffffff; + background: var(--be-bg-1); color: #4a5568; } @@ -117,7 +117,7 @@ flex-direction: column; gap: var(--space-3xs); padding: var(--space-sm) 0; - border-bottom: 1px solid rgba(var(--color-black), 0.06); + border-bottom: 1px solid var(--be-line-soft); } .signTxSection:last-child { @@ -130,7 +130,7 @@ font-weight: 600; letter-spacing: 0.1em; text-transform: uppercase; - color: rgba(var(--color-black), 0.45); + color: var(--be-text-3); } .signTxSection p { diff --git a/src/components/containers/SignTransaction/InternalTransactionPreview/InternalTransactionPreview.css b/src/components/containers/SignTransaction/InternalTransactionPreview/InternalTransactionPreview.css index c6dd24ab..8acce01f 100644 --- a/src/components/containers/SignTransaction/InternalTransactionPreview/InternalTransactionPreview.css +++ b/src/components/containers/SignTransaction/InternalTransactionPreview/InternalTransactionPreview.css @@ -1,5 +1,5 @@ .transactionPreview { - background: #ffffff; + background: var(--be-bg-1); border-radius: 12px; } @@ -61,7 +61,7 @@ .detail-item { padding: 16px; - background: #ffffff; + background: var(--be-bg-1); border: 1px solid #e2e8f0; border-radius: 6px; } @@ -99,7 +99,7 @@ } .transaction-table td { - background: #ffffff; + background: var(--be-bg-1); color: #4a5568; } @@ -117,7 +117,7 @@ flex-direction: column; gap: var(--space-3xs); padding: var(--space-sm) 0; - border-bottom: 1px solid rgba(var(--color-black), 0.06); + border-bottom: 1px solid var(--be-line-soft); } .signTxSection:last-child { @@ -130,7 +130,7 @@ font-weight: 600; letter-spacing: 0.1em; text-transform: uppercase; - color: rgba(var(--color-black), 0.45); + color: var(--be-text-3); } .signTxSection p { 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/components/containers/SignTransaction/TransactionBreakdown/TransactionBreakdown.module.css b/src/components/containers/SignTransaction/TransactionBreakdown/TransactionBreakdown.module.css index bf5ef061..c334ead7 100644 --- a/src/components/containers/SignTransaction/TransactionBreakdown/TransactionBreakdown.module.css +++ b/src/components/containers/SignTransaction/TransactionBreakdown/TransactionBreakdown.module.css @@ -17,7 +17,7 @@ .heroLabel { font-size: var(--font-size-sm); - color: rgba(var(--color-black), 0.55); + color: var(--be-text-2); } .heroList { @@ -48,7 +48,7 @@ .heroEmpty { margin: 0; font-size: var(--font-size-sm); - color: rgba(var(--color-black), 0.55); + color: var(--be-text-2); } .group { @@ -66,14 +66,14 @@ font-weight: 600; letter-spacing: 0.1em; text-transform: uppercase; - color: rgba(var(--color-black), 0.45); + color: var(--be-text-3); } .groupHead::after { content: ''; flex: 1; height: 1px; - background: rgba(var(--color-black), 0.08); + background: oklch(1 0 0 / 0.08); } .rows { @@ -89,7 +89,7 @@ justify-content: space-between; gap: var(--space-3xs) var(--space-sm); padding: var(--space-sm) 0; - border-bottom: 1px solid rgba(var(--color-black), 0.06); + border-bottom: 1px solid var(--be-line-soft); } .row:last-child { @@ -117,7 +117,7 @@ .rowAddress { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: var(--font-size-xs); - color: rgba(var(--color-black), 0.5); + color: var(--be-text-3); word-break: break-all; } @@ -145,7 +145,7 @@ .fieldKey { flex-shrink: 0; - color: rgba(var(--color-black), 0.45); + color: var(--be-text-3); } .fieldValue { 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/Wallet/Delegation/Delegation.module.css b/src/components/containers/Wallet/Delegation/Delegation.module.css index a459c86e..a46241f4 100644 --- a/src/components/containers/Wallet/Delegation/Delegation.module.css +++ b/src/components/containers/Wallet/Delegation/Delegation.module.css @@ -5,8 +5,8 @@ min-height: max-content; padding: var(--space-lg) var(--space-xl); border-radius: 25px; - background: rgb(var(--color-white)); - border: 1px solid rgb(var(--color-medium-gray)); + background: var(--be-bg-1); + border: 1px solid var(--be-line-soft); cursor: pointer; transition: all 0.2s ease-in-out; position: relative; @@ -20,8 +20,8 @@ } .nonEmpty { - background: rgba(255, 223, 182, 0.3); - border-color: rgba(255, 200, 130, 0.4); + background: var(--be-amber-soft); + border-color: rgba(247, 178, 103, 0.4); } .icon { @@ -56,7 +56,7 @@ .poolId { font-size: var(--font-size-lg); font-weight: 600; - color: rgb(var(--color-black)); + color: var(--be-text-0); margin: 0; display: flex; align-items: center; @@ -78,7 +78,7 @@ .date { font-size: var(--font-size-sm); - color: rgb(var(--color-dark-gray)); + color: var(--be-text-2); margin: var(--space-3xs) 0 0; } @@ -91,14 +91,14 @@ .amount { font-size: var(--font-size-2xl); font-weight: 700; - color: rgb(var(--color-black)); + color: var(--be-text-0); margin: 0; overflow-wrap: anywhere; } .currency { font-size: var(--font-size-sm); - color: rgb(var(--color-dark-gray)); + color: var(--be-text-2); margin: 0; } @@ -108,7 +108,7 @@ left: 0; height: 3px; width: 40px; - background-color: rgb(17, 150, 127); + background-color: var(--be-teal); animation: grow 60s cubic-bezier(0.4, 0, 1, 1) forwards; } 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/Delegation/DelegationList.module.css b/src/components/containers/Wallet/Delegation/DelegationList.module.css index 421aab8d..d658d232 100644 --- a/src/components/containers/Wallet/Delegation/DelegationList.module.css +++ b/src/components/containers/Wallet/Delegation/DelegationList.module.css @@ -9,12 +9,12 @@ } .empty { - background: rgb(var(--color-white)); + background: var(--be-bg-1); font-size: var(--font-size-lg); - color: rgb(var(--color-dark-gray)); + color: var(--be-text-2); list-style: none; text-align: center; padding: var(--space-4xl) var(--space-2xl); border-radius: 20px; - border: 1px solid rgb(var(--color-medium-gray)); + border: 1px solid var(--be-line-soft); } diff --git a/src/components/containers/Wallet/Delegation/DelegationSkeleton.module.css b/src/components/containers/Wallet/Delegation/DelegationSkeleton.module.css index 63c98ada..3b86ddc6 100644 --- a/src/components/containers/Wallet/Delegation/DelegationSkeleton.module.css +++ b/src/components/containers/Wallet/Delegation/DelegationSkeleton.module.css @@ -4,8 +4,8 @@ gap: var(--space-lg); padding: var(--space-lg) var(--space-xl); border-radius: 35px; - background: rgb(var(--color-white)); - border: 1px solid rgb(var(--color-medium-gray)); + background: var(--be-bg-1); + border: 1px solid var(--be-line-soft); list-style: none; } @@ -21,9 +21,9 @@ .shimmer { background: linear-gradient( 90deg, - rgb(var(--color-medium-gray)) 25%, - rgba(var(--color-medium-gray), 0.4) 50%, - rgb(var(--color-medium-gray)) 75% + var(--be-bg-3) 25%, + var(--be-bg-2) 50%, + var(--be-bg-3) 75% ); background-size: 400px 100%; animation: shimmer 1.5s infinite linear; 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/Nft.module.css b/src/components/containers/Wallet/Nft/Nft.module.css index 54b4f5be..e40dba9c 100644 --- a/src/components/containers/Wallet/Nft/Nft.module.css +++ b/src/components/containers/Wallet/Nft/Nft.module.css @@ -4,7 +4,7 @@ padding: 12px 30px 12px 18px; margin-bottom: 10px; border-radius: 45px; - background: rgb(var(--color-gray)); + background: var(--be-bg-1); border: 1px solid rgba(var(--color-light-green), 0.2); word-break: break-all; cursor: pointer; 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/components/containers/Wallet/Orders/OrderDetails/OrderDetails.module.css b/src/components/containers/Wallet/Orders/OrderDetails/OrderDetails.module.css index 0e77f02c..1a4a2e99 100644 --- a/src/components/containers/Wallet/Orders/OrderDetails/OrderDetails.module.css +++ b/src/components/containers/Wallet/Orders/OrderDetails/OrderDetails.module.css @@ -11,7 +11,7 @@ .title { font-size: 20px; font-weight: 700; - color: rgb(var(--color-black)); + color: var(--be-text-0); margin: 0; padding-left: 5px; } @@ -29,7 +29,7 @@ font-size: 11px; font-weight: 600; letter-spacing: 0.8px; - color: rgba(var(--color-black), 0.45); + color: var(--be-text-3); text-transform: uppercase; } @@ -39,7 +39,7 @@ gap: 10px; padding: 8px 8px 8px 18px; border-radius: 14px; - background: rgba(var(--color-black), 0.04); + background: oklch(1 0 0 / 0.04); min-height: 52px; } @@ -48,7 +48,7 @@ font-family: monospace; font-size: 15px; font-weight: 600; - color: rgb(var(--color-black)); + color: var(--be-text-0); word-break: break-all; } @@ -91,7 +91,7 @@ flex-direction: column; min-height: max-content; gap: 23px; - background: rgba(var(--color-black), 0.03); + background: oklch(1 0 0 / 0.03); border-radius: 20px; padding: 16px; } @@ -101,8 +101,8 @@ align-items: center; gap: 14px; padding: 14px 18px; - background: rgb(var(--color-white)); - border: 1px solid rgba(var(--color-black), 0.06); + background: var(--be-bg-1); + border: 1px solid var(--be-line-soft); border-radius: 16px; } @@ -123,20 +123,20 @@ } .amountValue { - color: rgb(var(--color-black)); + color: var(--be-text-0); font-weight: 700; flex-shrink: 0; } .amountTicker { - color: rgba(var(--color-black), 0.3); + color: var(--be-text-3); font-weight: 600; } .swapTokenId { margin: 0; font-size: 12px; - color: rgba(var(--color-black), 0.45); + color: var(--be-text-3); font-family: monospace; white-space: nowrap; overflow: hidden; @@ -153,8 +153,8 @@ } .badgeFrom { - background: rgba(var(--color-black), 0.05); - color: rgba(var(--color-black), 0.4); + background: oklch(1 0 0 / 0.05); + color: var(--be-text-3); } .badgeTo { @@ -190,7 +190,7 @@ gap: 8px; padding: 0 4px; font-size: 13px; - color: rgba(var(--color-black), 0.55); + color: var(--be-text-2); } .exchangeRate span { @@ -199,7 +199,7 @@ .exchangeRate span:last-child { font-weight: 700; - color: rgb(var(--color-black)); + color: var(--be-text-0); } .actions { @@ -220,9 +220,9 @@ .amountInput { width: 100%; padding: 14px 18px; - border: 1px solid rgba(var(--color-black), 0.08); + border: 1px solid var(--be-line-soft); border-radius: 14px; - background: rgba(var(--color-black), 0.04); + background: oklch(1 0 0 / 0.04); font-size: 20px; } .amountInput::placeholder { @@ -231,7 +231,7 @@ .walletBalance { font-size: 12px; - color: rgba(var(--color-black), 0.5); + color: var(--be-text-3); margin: 0; padding: 0 4px; } diff --git a/src/components/containers/Wallet/Orders/OrderItem/OrderItem.module.css b/src/components/containers/Wallet/Orders/OrderItem/OrderItem.module.css index 1e5bbab9..3e99c5e0 100644 --- a/src/components/containers/Wallet/Orders/OrderItem/OrderItem.module.css +++ b/src/components/containers/Wallet/Orders/OrderItem/OrderItem.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); cursor: pointer; transition: background 0.15s ease; } @@ -9,7 +9,7 @@ } .row:hover { - background: rgba(var(--color-black), 0.02); + background: oklch(1 0 0 / 0.02); } .orderIdCell { @@ -24,12 +24,12 @@ font-weight: 700; font-size: 14px; display: block; - color: rgb(var(--color-black)); + color: var(--be-text-0); } .exchangeRate { font-size: 12px; - color: rgba(var(--color-black), 0.4); + color: var(--be-text-3); margin-top: 2px; display: block; } @@ -43,7 +43,7 @@ .amount { font-weight: 700; font-size: 16px; - color: rgb(var(--color-black)); + color: var(--be-text-0); } .amountGreen { @@ -52,7 +52,7 @@ .ticker { font-size: 12px; - color: rgba(var(--color-black), 0.4); + color: var(--be-text-3); margin-left: 4px; } @@ -65,5 +65,5 @@ .chevron { font-size: 18px; - color: rgba(var(--color-black), 0.3); + color: var(--be-text-3); } diff --git a/src/components/containers/Wallet/Orders/OrderItem/OrderItemSkeleton.module.css b/src/components/containers/Wallet/Orders/OrderItem/OrderItemSkeleton.module.css index 92082cc9..08d17be0 100644 --- a/src/components/containers/Wallet/Orders/OrderItem/OrderItemSkeleton.module.css +++ b/src/components/containers/Wallet/Orders/OrderItem/OrderItemSkeleton.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); } .row:last-child { diff --git a/src/components/containers/Wallet/Orders/OrderList/OrderList.module.css b/src/components/containers/Wallet/Orders/OrderList/OrderList.module.css index b3e87f4f..e2b0b54e 100644 --- a/src/components/containers/Wallet/Orders/OrderList/OrderList.module.css +++ b/src/components/containers/Wallet/Orders/OrderList/OrderList.module.css @@ -1,5 +1,5 @@ .card { - background: rgb(var(--color-white)); + background: var(--be-bg-1); border-radius: 16px; overflow: hidden; width: 100%; @@ -45,8 +45,8 @@ 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; } @@ -76,7 +76,7 @@ .noOrders { text-align: center; padding: 2rem; - color: rgba(var(--color-black), 0.4); + color: var(--be-text-3); } .table tbody::-webkit-scrollbar { @@ -88,10 +88,10 @@ } .table tbody::-webkit-scrollbar-thumb { - background: rgba(var(--color-black), 0.1); + background: oklch(1 0 0 / 0.1); border-radius: 2px; } .table tbody::-webkit-scrollbar-thumb:hover { - background: rgba(var(--color-black), 0.2); + background: oklch(1 0 0 / 0.2); } diff --git a/src/components/containers/Wallet/ShowAddress.css b/src/components/containers/Wallet/ShowAddress.css index 82f20dd1..01262b56 100644 --- a/src/components/containers/Wallet/ShowAddress.css +++ b/src/components/containers/Wallet/ShowAddress.css @@ -14,22 +14,22 @@ .press:active { position: relative; top: 1px; - background-color: rgb(var(--color-dark-gray)); + background-color: var(--be-bg-2); } .show-unused { cursor: pointer; - background: rgb(var(--color-main-green)); + background: var(--be-amber); padding: 2px 5px; border-radius: 5px; - color: rgb(var(--color-white)); + color: oklch(0.18 0.02 70); } .used-address { - color: rgb(var(--color-dark-gray)); + color: var(--be-text-3); } .unused-address { - color: rgb(var(--color-green)); + color: var(--be-green); font-weight: bold; } diff --git a/src/components/containers/Wallet/Transaction.module.css b/src/components/containers/Wallet/Transaction.module.css index 7d605318..d50d58bb 100644 --- a/src/components/containers/Wallet/Transaction.module.css +++ b/src/components/containers/Wallet/Transaction.module.css @@ -4,8 +4,8 @@ padding: 12px 29px 12px 8px; margin-bottom: 0; border-radius: 22px; - background: rgb(var(--color-gray)); - border: 1px solid rgba(var(--color-light-green), 0.2); + background: var(--be-bg-1); + border: 1px solid var(--be-line-soft); word-break: break-all; cursor: pointer; transition: all 0.3s ease-in-out; @@ -28,7 +28,7 @@ width: 60px; min-width: 60px; height: 60px; - background: rgb(var(--color-dim-green)); + background: var(--be-green-soft, var(--be-amber-soft)); border-radius: 50%; position: relative; z-index: 5; @@ -37,22 +37,22 @@ .logoTypeInfo { font-size: 1.4rem; font-weight: 600; - color: #fff; - background: rgb(var(--color-dark-gray)); + color: var(--be-text-0); + background: var(--be-line); } .logoTypeWithdrawal { font-size: 1.4rem; font-weight: 600; - color: #fff; - background: rgb(var(--color-dark-gray)); + color: var(--be-text-0); + background: var(--be-line); } .logoTypeSame { position: absolute; z-index: 10; left: 22px; - background: rgb(var(--color-dark-gray)); + background: var(--be-line); } .logoTypeSame .loopIcon { @@ -62,7 +62,7 @@ } .logoTypeSame .loopIcon path { - stroke: rgb(var(--color-white)) !important; + stroke: var(--be-text-0) !important; } .detail { @@ -71,19 +71,19 @@ } .logoOut { - background: rgb(var(--color-dim-corail)); + background: oklch(0.7 0.2 25 / 0.18); } .logoTypeStake { - background: rgb(var(--color-dim-blue)); + background: var(--be-teal-soft); } .logoTypeDelegate { - background: rgb(var(--color-dim-purple)); + background: var(--be-violet-soft); } .logoTypeUnconfirmed { - background: rgb(var(--color-dim-yellow)); + background: var(--be-amber-soft); } .arrowIcon { @@ -105,7 +105,7 @@ .unconfirmedIcon { width: 26px; height: 28px; - color: #fff; + color: var(--be-text-0); } .delegationIcon { @@ -113,11 +113,11 @@ height: 28px; margin-left: 2px; margin-top: -2px; - color: rgb(var(--color-black)); + color: var(--be-text-1); } .delegationIcon svg { - color: rgb(var(--color-black)); + color: var(--be-text-1); } .arrowIconOut { @@ -162,5 +162,5 @@ .transaction:hover { transform: scale(1.02); - border: 1px solid rgba(var(--color-light-green), 0.5); + border: 1px solid oklch(0.82 0.16 70 / 0.5); } diff --git a/src/components/containers/Wallet/TransactionButton.css b/src/components/containers/Wallet/TransactionButton.css index 6f8c02d2..077e3c30 100644 --- a/src/components/containers/Wallet/TransactionButton.css +++ b/src/components/containers/Wallet/TransactionButton.css @@ -18,8 +18,8 @@ padding: 0; border: none; border-radius: 50%; - background-color: rgb(var(--color-white)); - box-shadow: var(--shadow-sm); + background-color: var(--be-bg-2); + box-shadow: 0 4px 14px oklch(0 0 0 / 0.35); cursor: pointer; transition: background-color 0.2s ease; @@ -33,12 +33,12 @@ .button-transaction:hover:not(:disabled), .button-transaction:focus-visible { - background-color: rgb(var(--mojito-green)); + background-color: var(--be-amber); } .button-transaction:hover:not(:disabled) svg path, .button-transaction:focus-visible svg path { - stroke: rgb(var(--color-white)); + stroke: oklch(0.18 0.02 70); } .button-transaction:disabled { @@ -53,19 +53,19 @@ } .button-transaction svg path { - stroke: rgb(var(--color-dark-gray)); + stroke: var(--be-text-1); transition: stroke 0.2s ease; } /* Primary "Send" — filled green circle with a white icon */ .button-transaction-up { - background-color: rgb(var(--color-main-green)); - box-shadow: 0 6px 16px rgba(var(--color-main-green), 0.35); + background: linear-gradient(180deg, oklch(0.88 0.15 75), oklch(0.78 0.16 65)); + box-shadow: 0 6px 16px oklch(0.82 0.16 70 / 0.45); } .button-transaction-up:hover:not(:disabled), .button-transaction-up:focus-visible { - background-color: rgb(var(--mojito-green)); + background: linear-gradient(180deg, oklch(0.9 0.15 75), oklch(0.8 0.16 65)); } .button-transaction-up svg { @@ -73,14 +73,14 @@ } .button-transaction-up svg path { - stroke: rgb(var(--color-white)); + stroke: oklch(0.18 0.02 70); } /* Label below the circle */ .button-transaction-label { font-size: 13px; font-weight: 600; - color: rgb(var(--color-black)); + color: var(--be-text-1); text-align: center; line-height: 1.2; } 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; } diff --git a/src/components/containers/Wallet/TransactionsList.css b/src/components/containers/Wallet/TransactionsList.css index 96f8b70f..fec16afa 100644 --- a/src/components/containers/Wallet/TransactionsList.css +++ b/src/components/containers/Wallet/TransactionsList.css @@ -22,7 +22,7 @@ } .empty-list { - background: rgb(var(--color-gray)); + background: var(--be-bg-1); font-size: 1.5em; list-style: none; padding: 10px; @@ -30,6 +30,5 @@ padding: 26px 20px; border-radius: 20px; - border: 1px; - border: 1px solid rgba(var(--color-light-green), 0.2); + border: 1px solid var(--be-line-soft); } diff --git a/src/components/containers/index.js b/src/components/containers/index.js index b87a8ccb..e83c60ba 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' @@ -34,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' @@ -41,6 +38,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 = { @@ -63,13 +61,6 @@ const Login = { SetPassword, } -/* istanbul ignore next */ -const Dashboard = { - CryptoSharesChart, - Statistics, - CryptoList, -} - const Settings = { SettingsAbout, SettingsTestnet, @@ -77,6 +68,7 @@ const Settings = { SettingsBackup, SettingsSection, SettingsConnections, + SettingsPasskey, } const RestoreAccount = { @@ -93,6 +85,7 @@ const SignTransaction = { ExternalTransactionPreview, InternalTransactionPreview, JsonPreview, + TransactionSummary, } export { @@ -103,7 +96,6 @@ export { Login, SendBtcTransaction, SendMlTransaction, - Dashboard, Settings, Message, SignTransaction, 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/contexts/MintlayerProvider/MintlayerProvider.js b/src/contexts/MintlayerProvider/MintlayerProvider.js index ae8e80ce..5eb66ee8 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,359 @@ 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 tokensData = await Mintlayer.getTokensData( + Object.keys(tokenBalances), + ) - 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, - ) - } - return true + const mergedTokensDataEntries = await Promise.all( + Object.keys(tokenBalances).map(async (key) => { + if (!(tokensData[key] && Object.keys(tokensData[key]).length > 0)) { + return null + } + + // 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 = {} + + const allNetworkTokensData = await Mintlayer.getAllTokensData(networkType) + allNetworkTokensData.forEach((token) => { + newTokenMap[token.token_id] = token.symbol || '' }) - .reduce((acc, item) => { - acc.push(item) - return acc - }, []) + 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) - const availableUtxos = available.map((item) => item) + // fetch utxos + const accountName = account && account.name + const unconfirmedTransactionString = ML.getUnconfirmedTransactionKey( + accountName, + networkType, + ) + const unconfirmedTransactions = + LocalStorageService.getItem(unconfirmedTransactionString) || [] - 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 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', + ) + + const availableNftInitialUtxos = fetchedSpendableUtxos.filter( + (item) => item.utxo?.type === 'IssueNft', + ) - setNftInitialUtxos(availableNftInitialUtxos) - setUtxos(availableUtxos) - setLockedUtxos(lockedUtxos) + 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. + console.error('fetchAllData failed:', error) + setFetchError(error) + } finally { + setAllDataFetching(false) + setFetchingTransactions(false) + setFetchingBalances(false) + setFetchingUtxos(false) + setFetchingTokens(false) + setFetchingNft(false) + } + } - setFetchingUtxos(false) - setAllDataFetching(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 +528,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 +538,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 +582,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 +599,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 +637,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 +650,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 +714,7 @@ const MintlayerProvider = ({ value: propValue, children }) => { fetchOrdersPairInfo, orderPairLoading, tokenMap, + fetchError, } return ( diff --git a/src/hooks/index.js b/src/hooks/index.js index 6304ed8c..9a4c29c7 100644 --- a/src/hooks/index.js +++ b/src/hooks/index.js @@ -4,6 +4,7 @@ import useBtcWalletInfo from './UseWalletInfo/useBtcWalletInfo' import useMlWalletInfo from './UseWalletInfo/useMlWalletInfo' import useExchangeRates from './UseExchangeRates/useExchangeRates' import useOneDayAgoExchangeRates from './UseOneDayAgoExchangeRates/useOneDayAgoExchangeRates' +import useOneDayAgoHist from './UseOneDayAgoHist/useOneDayAgoHist' import useMediaQuery from './useMediaQuery/useMediaQuery' import useFillOrder from './UseFillOrder/useFillOrder' @@ -14,6 +15,7 @@ export { useMlWalletInfo, useExchangeRates, useOneDayAgoExchangeRates, + useOneDayAgoHist, useMediaQuery, useFillOrder, } diff --git a/src/index.js b/src/index.js index 943e9e7a..40d57d0f 100644 --- a/src/index.js +++ b/src/index.js @@ -1,10 +1,10 @@ -/* eslint-disable no-undef */ -import React, { useState, useEffect, useContext } from 'react' +import React, { useState, useEffect, useContext, useRef } from 'react' import ReactDOM from 'react-dom/client' import { MemoryRouter, Routes, Route, + Navigate, useLocation, useNavigate, } from 'react-router' @@ -15,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' @@ -23,14 +23,13 @@ import { HomePage, CreateAccountPage, RestoreAccountPage, - WalletPage, SetAccountPasswordPage, CreateRestorePage, SendBtcTransactionPage, SendMlTransactionPage, DashboardPage, SettingsPage, - StakingPage, + StakePage, ConnectionPage, CreateDelegationPage, DelegationStakePage, @@ -46,6 +45,9 @@ import { SignBitcoinTransactionPage, ConfirmBtcTransactionPage, AddressPage, + AssetPage, + ActivityPage, + ReceivePage, } from '@Pages' import { @@ -61,9 +63,11 @@ 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' +import '@Assets/styles/theme.css' import '@Assets/styles/index.css' const root = ReactDOM.createRoot(document.getElementById('root')) @@ -76,19 +80,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, windows } = Browser const App = () => { const [errorPopupOpen, setErrorPopupOpen] = useState(false) @@ -107,7 +99,6 @@ const App = () => { useContext(MintlayerContext) const { networkType } = useContext(SettingsContext) const [nextAfterUnlock, setNextAfterUnlock] = useState(null) - const [, setRequest] = useState(null) const currentMlAddresses = addresses.mlAddresses @@ -123,7 +114,7 @@ const App = () => { return !!mintlayerResponse && !!exchangeResponse } catch (error) { if (accountUnlocked) { - console.log(error) + console.error('Connection check failed:', error) setErrorPopupOpen(true) setAllDataFetching(false) logout() @@ -179,26 +170,68 @@ 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) { - setRequest(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]) + // 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 + // 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({ @@ -328,115 +361,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..a8674a93 --- /dev/null +++ b/src/pages/ActivityPage/ActivityPage.js @@ -0,0 +1,131 @@ +import { useState, useContext } from 'react' + +import { TxRow, BeSheet, CopyButton } from '@ComposedComponents' +import { PageWrapper, Seg, ChainBadge, KV, Eyebrow } from '@BasicComponents' +import { useBtcWalletInfo } from '@Hooks' +import { MintlayerContext } from '@Contexts' +import { Transactions } from '@Helpers' +const { adaptDesignTx, resolveTxSymbol } = Transactions + +import styles from './ActivityPage.module.css' + +/** + * Activity screen from the design (doc/ be-settings.jsx ActivityScreenBE + + * 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 all = [ + ...(btcInfo.transactions || []).map((t) => + adaptDesignTx(t, 'BTC', 'Bitcoin'), + ), + ...(transactions || []).map((t) => + adaptDesignTx( + t, + resolveTxSymbol(t, tokenBalances, tokenMap), + '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..9946c9fd --- /dev/null +++ b/src/pages/ActivityPage/ActivityPage.module.css @@ -0,0 +1,73 @@ +.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; +} + +/* 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; + 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.js b/src/pages/AssetPage/AssetPage.js new file mode 100644 index 00000000..692f6d6a --- /dev/null +++ b/src/pages/AssetPage/AssetPage.js @@ -0,0 +1,220 @@ +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 { CopyButton } from '@ComposedComponents' +import { ML, 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' + : isMl + ? '/wallet/Mintlayer/send-ml-transaction' + : `/wallet/${id}/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 && ( +
    + +
    + )} + +
    + + +
    + + {receiveAddress && ( +
    + +
    + )} + + {tokenData && ( +
    + Token info +
    + + {ML.formatAddress(id, 24)} + + , + ], + ['Decimals', tokenData.token_info?.number_of_decimals ?? '—'], + [ + 'Balance', + amount.toLocaleString(undefined, { + maximumFractionDigits: 8, + }), + ], + ]} + /> +
    + )} + +
    +
    Activity
    +
    + {txRows.length ? ( + txRows.map((t, i) => ( + + )) + ) : ( +
    No {ticker} transactions
    + )} +
    +
    +
    + + ) +} + +export default AssetPage diff --git a/src/pages/AssetPage/AssetPage.module.css b/src/pages/AssetPage/AssetPage.module.css new file mode 100644 index 00000000..b63af116 --- /dev/null +++ b/src/pages/AssetPage/AssetPage.module.css @@ -0,0 +1,158 @@ +.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; +} + +/* 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; + 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; +} + +.valueLine { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: var(--font-size-sm); +} + +.addressCard { + margin-top: 14px; +} diff --git a/src/pages/AssetPage/AssetPage.test.js b/src/pages/AssetPage/AssetPage.test.js new file mode 100644 index 00000000..e4056b51 --- /dev/null +++ b/src/pages/AssetPage/AssetPage.test.js @@ -0,0 +1,162 @@ +import React from 'react' +import { MemoryRouter, Routes, Route } from 'react-router' +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 = { + 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, getByText } = renderAt(TOKEN_ID) + + expect(container.textContent).toContain('CBEAT') + // 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. + 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/ConfirmBtcTransaction/ConfirmBtcTransaction.js b/src/pages/ConfirmBtcTransaction/ConfirmBtcTransaction.js index 671d5a76..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'] }, ) @@ -143,7 +157,7 @@ const ConfirmBtcTransactionPage = () => { } const goBackToWallet = async () => { - navigate('/wallet/Bitcoin') + navigate('/dashboard') } const passwordChangeHandler = (value) => { @@ -151,7 +165,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.js b/src/pages/ConnectionPage/ConnectionPage.js index 06b57c95..c7448878 100644 --- a/src/pages/ConnectionPage/ConnectionPage.js +++ b/src/pages/ConnectionPage/ConnectionPage.js @@ -1,7 +1,6 @@ -/* eslint-disable no-undef */ 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 +8,8 @@ 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 { BTC } from '@Helpers' import styles from './ConnectionPage.module.css' const toHexString = (obj) => { @@ -17,78 +18,88 @@ const toHexString = (obj) => { .join('') } -const storage = - typeof browser !== 'undefined' && browser.storage - ? browser.storage - : typeof chrome !== 'undefined' && chrome.storage - ? chrome.storage - : null +const btcPubKeyOf = (entry) => { + if (!entry || typeof entry === 'string') return undefined + const { pubkey } = Object.values(entry)[0] ?? {} + return pubkey ? toHexString(pubkey) : undefined +} -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 { networkType } = useContext(SettingsContext) 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 + + // 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 ?? {} + + 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] + // 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 = () => { - const remember = document.querySelector('.connect-page__checkbox')?.checked - const sessionKey = `session_${origin}` + 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), }, }, }), @@ -96,66 +107,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() - }) }) } @@ -221,25 +186,26 @@ 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.

    - {/* // TODO: Make this work */} - {/* */} -
    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..76f809f5 --- /dev/null +++ b/src/pages/ConnectionPage/ConnectionPage.test.js @@ -0,0 +1,234 @@ +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 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'] }, + } + + 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.js b/src/pages/Dashboard/Dashboard.js index f75f5d63..ae6d3603 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 { AccountContext, SettingsContext } from '@Contexts' -import { Account } from '@Entities' +import { useNavigate } from 'react-router' + +import { PopUp, AddWallet, TxRow, AssetRow } from '@ComposedComponents' +import { AccountContext, MintlayerContext, SettingsContext } from '@Contexts' +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, resolveTxSymbol } = Transactions + import { AppInfo } from '@Constants' +import styles from './Dashboard.module.css' + const DashboardPage = () => { const { addresses, accountName, accountID } = useContext(AccountContext) const { networkType } = useContext(SettingsContext) @@ -27,12 +37,15 @@ 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, @@ -40,6 +53,8 @@ const DashboardPage = () => { fetchingBalances: mlFetchingBalances, fetchingTokens: mlFetchingTokens, } = useMlWalletInfo() + const { transactions: mlAllTransactions, tokenMap } = + useContext(MintlayerContext) const { exchangeRate: btcExchangeRate } = useExchangeRates('btc', 'usd') const { exchangeRate: mlExchangeRate } = useExchangeRates('ml', 'usd') const { yesterdayExchangeRate: btcYesterdayExchangeRate } = @@ -74,6 +89,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 +139,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 +171,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 +205,30 @@ 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, + onClick: c.disabled ? undefined : () => navigate('/asset/' + c.id), + })) const onConnectItemClick = (walletType) => { setConnectedWalletType(walletType) @@ -199,7 +237,7 @@ const DashboardPage = () => { } const getCurrentAccount = async (accountID) => { - const currentAccount = await Account.getAccount(accountID) + const currentAccount = await AccountEntity.getAccount(accountID) return currentAccount } @@ -207,37 +245,255 @@ const DashboardPage = () => { getCurrentAccount(accountID).then((account) => setAccount(account)) }, [accountID]) + // 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) => ({ + ...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 + + const renderAssetRow = (a, index) => ( +
    + +
    + ) + 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, + onClick: () => navigate('/asset/' + c.id), + }, + 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 && ( - - - - )}
    ) } diff --git a/src/pages/Dashboard/Dashboard.module.css b/src/pages/Dashboard/Dashboard.module.css new file mode 100644 index 00000000..9b09eebb --- /dev/null +++ b/src/pages/Dashboard/Dashboard.module.css @@ -0,0 +1,265 @@ +.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; +} + +/* 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; + 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/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 = ({ ) 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..0c3c4be2 --- /dev/null +++ b/src/pages/ReceivePage/ReceivePage.module.css @@ -0,0 +1,102 @@ +.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; +} + +/* 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; + 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..e7615bf2 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,14 +12,14 @@ .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; } .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/RestoreAccount/RestoreAccount.tsx b/src/pages/RestoreAccount/RestoreAccount.tsx index 46fe3801..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, @@ -41,12 +42,26 @@ const RestoreAccountPage = () => { Account.saveAccount(data) .then((id: string) => { accountID = id + console.log('[Restore] Account saved, id:', id) return Account.unlockAccount(id, accountPassword) }) - .then(({ addresses }) => { + .then((result) => { + addresses = result.addresses setWalletInfo(addresses, accountID, accountName) navigate('/dashboard') }) + .catch((error) => { + console.error('[Restore] Account restore failed:', 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) + } + }) } const goToPrevStep = () => { 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/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', diff --git a/src/pages/SignBitcoinTransaction/SignBitcoinTransaction.css b/src/pages/SignBitcoinTransaction/SignBitcoinTransaction.css index 2a76407e..db26301f 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%; @@ -13,6 +13,10 @@ border-radius: 10px; } +.SignTransaction > * { + flex-shrink: 0; +} + .SignTransaction .header { display: flex; align-items: center; @@ -52,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; @@ -62,7 +66,7 @@ } .SignTransaction .mock_selector div:hover { - background-color: #e5e7eb; + background-color: var(--be-bg-3); } .SignTransaction .transaction-preview-wrapper { @@ -72,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; @@ -113,7 +117,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 +137,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 +146,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 +165,7 @@ .secret-item label { font-weight: 500; - color: #475569; + color: var(--be-text-2); font-size: 0.875rem; } @@ -169,8 +173,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 +184,7 @@ .secret-value span { flex: 1; - color: #374151; + color: var(--be-text-1); } .secret-value button { @@ -193,16 +197,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 +219,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,9 +231,18 @@ .secret-hint { margin-top: 4px; - color: #6b7280; + color: var(--be-text-2); } .requestOrigin { margin-bottom: 20px; } + +.sign-error { + color: rgb(var(--color-red)); + font-size: 0.75rem; + 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/SignBitcoinTransaction/SignBitcoinTransaction.js b/src/pages/SignBitcoinTransaction/SignBitcoinTransaction.js index 882bdfe0..539ea868 100644 --- a/src/pages/SignBitcoinTransaction/SignBitcoinTransaction.js +++ b/src/pages/SignBitcoinTransaction/SignBitcoinTransaction.js @@ -1,4 +1,3 @@ -/* eslint-disable no-undef */ import { useLocation } from 'react-router' import { MOCKS } from './mocks' import { Button, PageWrapper, SiteBadge } from '@BasicComponents' @@ -10,23 +9,29 @@ 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 { Secret } from '@Helpers' - -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, 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' + +const isDevelopment = process.env.NODE_ENV === 'development' + +// sat/vB used when the fee service is unreachable +const FALLBACK_BTC_FEE_RATE = 10 + +const getBtcFeeRate = async () => { + try { + const estimates = JSON.parse(await Electrum.getFeesEstimates()) + return ( + Math.ceil(BTCHelpers.parseFeesEstimates(estimates).MEDIUM) || + FALLBACK_BTC_FEE_RATE + ) + } catch (error) { + console.error('Fee estimates unavailable, using fallback rate:', error) + return FALLBACK_BTC_FEE_RATE + } +} function parseSecretHashFromRedeemScript(redeemScriptHex) { // Decompile the redeem script hex into chunks @@ -80,12 +85,14 @@ export const SignBitcoinTransactionPage = () => { // Secret management state for HTLC transactions const [secretError, setSecretError] = useState('') - const [mode, setMode] = useState('preview') - const [selectedMock, setSelectedMock] = useState('transfer') const extraButtonStyles = ['buttonSignTransaction'] - const initialState = external_state || MOCKS[selectedMock] + const [isSigning, setIsSigning] = useState(false) + const [signError, setSignError] = useState('') + + const initialState = + external_state || (isDevelopment ? MOCKS[selectedMock] : null) // Generate secret and prepare transaction state once for HTLC create transactions const { generatedSecret, generatedSecretHash, htlcTransactionState } = @@ -137,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 @@ -149,18 +157,38 @@ export const SignBitcoinTransactionPage = () => { state?.request?.data?.txData?.JSONRepresentation?.type === 'spendHtlc' // const isHTLCRefundTx = state?.request?.data?.txData?.JSONRepresentation?.type === 'refundHtlc' - const handleApprove = async () => { + const handleApprove = () => { + setSignError('') setIsModalOpen(true) // Open the modal } 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 = 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, @@ -170,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), // atoms - fee: 2000, // TODO: update calculation of fee - wif: WIF, - from: currentBtcAddress, - networkType, + amount: parseInt(transactionJSONrepresentation.amount), // satoshis + utxos: btcUtxos || [], + feeRate: await getBtcFeeRate(), + walletType: btcWalletType, + changeAddress: getChangeAddress(), + root: btcPrivateKeys, }) + const txId = tx?.getId() const requestId = state?.request?.requestId const method = 'signTransaction_approve' @@ -221,25 +268,34 @@ export const SignBitcoinTransactionPage = () => { } } - runtime.sendMessage( - { - action: 'popupResponse', - method, - requestId, - origin, - result, - }, - () => { - storage.local.remove('pendingRequest', () => { - window.close() - }) - }, - ) + sendPopupResponse({ + method, + requestId, + origin, + result, + }) } 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 @@ -291,25 +347,34 @@ export const SignBitcoinTransactionPage = () => { const result = { signedTxHex: tx, } - runtime.sendMessage( - { - action: 'popupResponse', - method, - requestId, - origin, - result, - }, - () => { - storage.local.remove('pendingRequest', () => { - window.close() - }) - }, - ) + sendPopupResponse({ + method, + requestId, + origin, + result, + }) } 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 @@ -329,23 +394,17 @@ export const SignBitcoinTransactionPage = () => { signedTxHex: tx, } - runtime.sendMessage( - { - action: 'popupResponse', - method, - requestId, - origin, - result, - }, - () => { - storage.local.remove('pendingRequest', () => { - window.close() - }) - }, - ) + sendPopupResponse({ + method, + requestId, + origin, + result, + }) } const handleModalSubmit = async () => { + if (isSigning) return + try { // Validate secret if it's an HTLC spend transaction and secret is manually entered if (isHTLCSpendTx && secret && !Secret.validateSecretHex(secret.trim())) { @@ -355,6 +414,9 @@ export const SignBitcoinTransactionPage = () => { return } + setIsSigning(true) + setSignError('') + const transactionJSONrepresentation = state?.request?.data?.txData?.JSONRepresentation @@ -376,40 +438,29 @@ export const SignBitcoinTransactionPage = () => { console.error('Error during transaction signing:', error) // If it's a secret validation error, keep the modal open if (error.message && error.message.includes('secret')) { + setIsSigning(false) return } - 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) => { setSelectedMock(name) } - const switchHandle = () => { - setMode(mode === 'json' ? 'preview' : 'json') - } - const passwordChangeHandler = (value) => { setPassword(value) } @@ -436,9 +487,6 @@ export const SignBitcoinTransactionPage = () => {

    Sign Transaction

    -
    @@ -449,7 +497,7 @@ export const SignBitcoinTransactionPage = () => {
    - {!external_state && ( + {!external_state && isDevelopment && (
    {Object.keys(MOCKS).map((key) => { return ( @@ -467,15 +515,9 @@ export const SignBitcoinTransactionPage = () => { )} {state?.request?.data?.txData?.JSONRepresentation && ( - <> - {mode === 'preview' && ( -
    - - {/**/} -
    - )} - {mode === 'json' && } - +
    + +
    )} {/* HTLC Secret Information */} @@ -532,7 +574,7 @@ export const SignBitcoinTransactionPage = () => { onClickHandle={handleApprove} extraStyleClasses={extraButtonStyles} > - Approve and return to page. + Approve and return to page
    @@ -568,19 +610,21 @@ export const SignBitcoinTransactionPage = () => {
    )} + {signError &&
    {signError}
    }
    diff --git a/src/pages/SignChallenge/SignChallenge.css b/src/pages/SignChallenge/SignChallenge.css index a6efc1e4..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,9 +56,18 @@ .SignChallenge .challenge_details .label { font-weight: bold; - color: #000; + color: var(--be-text-0); } .requestOrigin { margin-bottom: 20px; } + +.sign-error { + color: rgb(var(--color-red)); + font-size: 0.75rem; + 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.js b/src/pages/SignChallenge/SignChallenge.js index 2af1d9c8..2e3693f7 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,59 @@ 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('') + + // 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 = @@ -82,52 +99,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 +150,7 @@ export const SignChallengePage = () => {
    - {!external_state && ( + {!external_state && isDevelopment && (
    {Object.keys(MOCKS).map((key) => { return ( @@ -215,19 +212,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..b4f00178 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%; @@ -13,6 +13,10 @@ border-radius: 10px; } +.SignTransaction > * { + flex-shrink: 0; +} + .SignTransaction .header { display: flex; align-items: center; @@ -56,7 +60,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 +70,7 @@ } .SignTransaction .mock_selector div:hover { - background-color: #e5e7eb; + background-color: var(--be-bg-3); } .SignTransaction .transaction-preview-wrapper { @@ -76,12 +80,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; @@ -92,12 +96,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; } @@ -119,7 +123,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 +143,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 +152,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 +171,7 @@ .secret-item label { font-weight: 500; - color: #475569; + color: var(--be-text-2); font-size: 0.875rem; } @@ -175,8 +179,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 +190,7 @@ font-size: 0.75rem; word-break: break-all; flex: 1; - color: #374151; + color: var(--be-text-1); } .secret-value button { @@ -199,18 +203,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 +229,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; } @@ -250,3 +254,12 @@ .requestOrigin { margin-bottom: 20px; } + +.sign-error { + color: rgb(var(--color-red)); + font-size: 0.75rem; + 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.js b/src/pages/SignExternalTransaction/SignExternalTransaction.js index 6cb0d3f7..d31d23d5 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,25 +13,28 @@ 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() 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) @@ -45,7 +47,8 @@ export const SignTransactionPage = () => { const [generatedSecretHash, setGeneratedSecretHash] = useState(null) const [secretError, setSecretError] = useState('') - const [mode, setMode] = useState('preview') + const [isSigning, setIsSigning] = useState(false) + const [signError, setSignError] = useState('') const [selectedMock, setSelectedMock] = useState('transfer') const extraButtonStyles = ['buttonSignTransaction'] @@ -53,7 +56,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 +88,8 @@ export const SignTransactionPage = () => { (output) => output?.destination === HtlcInput.utxo.htlc.spend_key, ) - const handleApprove = async () => { + const handleApprove = () => { + setSignError('') setIsModalOpen(true) // Open the modal } @@ -170,7 +177,32 @@ export const SignTransactionPage = () => { } }, [transactionState, external_state, selectedMock, generatedSecret]) - const handleModalSubmit = async () => { + const handleModalSubmit = async ({ usePasskey = false } = {}) => { + if (isSigning) return + + setIsSigning(true) + setSignError('') + + // Wrong-chain guard: the session records the network the site was + // 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) { + setIsSigning(false) + 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 + } + try { // Validate secret if it's an HTLC claim transaction if (isHTLCClaim && secret && !Secret.validateSecretHex(secret.trim())) { @@ -190,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 @@ -296,8 +332,6 @@ export const SignTransactionPage = () => { } } - console.log('order_info', order_info) - const transactionHex = SignTxHelpers.getTransactionHEX( { transactionBINrepresentation, @@ -322,8 +356,6 @@ export const SignTransactionPage = () => { }, ) - console.log('transactionHex', transactionHex) - if (isHTLCCreateTx) { // save secret to account await Account.saveProvidedHtlsSecret({ @@ -349,47 +381,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) => { @@ -401,10 +414,6 @@ export const SignTransactionPage = () => { setGeneratedSecretHash(null) } - const switchHandle = () => { - setMode(mode === 'json' ? 'preview' : 'json') - } - const passwordChangeHandler = (value) => { setPassword(value) } @@ -430,10 +439,7 @@ export const SignTransactionPage = () => {
    -

    Sign Transaction

    - +

    Sign transaction

    @@ -444,7 +450,7 @@ export const SignTransactionPage = () => {
    - {!external_state && ( + {!external_state && isDevelopment && (
    {Object.keys(MOCKS).map((key) => { return ( @@ -462,14 +468,18 @@ export const SignTransactionPage = () => { )} {state?.request?.data?.txData?.JSONRepresentation && ( - <> - {mode === 'preview' && ( -
    - -
    - )} - {mode === 'json' && } - + + } + rawJsonNode={} + /> )} {/* HTLC Secret Information */} @@ -538,50 +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}
    } +
    + +
    )} -
    - - -
    )} diff --git a/src/pages/SignInternalTransaction/SignInternalTransaction.js b/src/pages/SignInternalTransaction/SignInternalTransaction.js index efea7c98..8185273b 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' @@ -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' @@ -20,7 +20,7 @@ import { VerticalGroup, CenteredLayout } from '@LayoutComponents' const TxResult = ({ transactionTxid }) => { const navigate = useNavigate() const goBackToWallet = () => { - navigate('/wallet/Mintlayer') + navigate('/dashboard') } return ( @@ -37,14 +37,26 @@ 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) const loadingExtraClasses = ['loading-big'] const navigate = useNavigate() - const [mode, setMode] = useState('preview') - const [selectedMock, setSelectedMock] = useState('transfer') const extraButtonStyles = [styles.buttonSignTransaction] @@ -74,7 +86,7 @@ export const SignTransactionPage = () => { fetchDelegations() } - const handleModalSubmit = async () => { + const handleModalSubmit = async ({ usePasskey = false } = {}) => { setSendingTransaction(true) try { const transactionJSONrepresentation = @@ -88,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 } @@ -190,7 +214,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,17 +256,13 @@ export const SignTransactionPage = () => { } const handleReject = () => { - navigate('/wallet/Mintlayer') + navigate('/dashboard') } const selectMock = (name) => { setSelectedMock(name) } - const switchHandle = () => { - setMode(mode === 'json' ? 'preview' : 'json') - } - const passwordChangeHandler = (value) => { setPassword(value) } @@ -250,14 +271,11 @@ export const SignTransactionPage = () => {
    -

    Sign Transaction

    - +

    Sign transaction

    - {!external_state && ( + {!external_state && process.env.NODE_ENV === 'development' && (
    {Object.keys(MOCKS).map((key) => { return ( @@ -275,14 +293,18 @@ export const SignTransactionPage = () => { )} {state?.request?.data?.txData?.JSONRepresentation && ( - <> - {mode === 'preview' && ( -
    - -
    - )} - {mode === 'json' && } - + + } + rawJsonNode={} + /> )}
    @@ -321,32 +343,63 @@ export const SignTransactionPage = () => { {!sendingTransaction && !transactionId && (
    -
    - - {txErrorMessage ? : <>} -
    -
    - - -
    + {hasPasskey && !usePasswordEntry ? ( +
    +

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

    +
    + + +
    +
    + ) : ( +
    +
    + + {txErrorMessage ? ( + + ) : ( + <> + )} +
    +
    + + +
    +
    + )}
    )} @@ -355,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 84e33b22..e8005583 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%; @@ -12,6 +12,10 @@ border-radius: 10px; } +.signTransaction > * { + flex-shrink: 0; +} + .signTransaction .header { display: flex; align-items: center; @@ -55,7 +59,7 @@ } .signTransaction .mockSelector div { - background-color: #f3f4f6; + background-color: var(--be-bg-2); padding: 8px 16px; border-radius: 6px; font-size: 14px; @@ -65,7 +69,7 @@ } .signTransaction .mockSelector div:hover { - background-color: #e5e7eb; + background-color: var(--be-bg-3); } .signTransaction .transactionPreviewWrapper { @@ -75,12 +79,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 +125,7 @@ flex-direction: column; align-items: center; gap: 40px; - background-color: #ffffff; + background-color: var(--be-bg-1); border-radius: 8px; text-align: center; } @@ -141,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/pages/StakePage/StakePage.js b/src/pages/StakePage/StakePage.js new file mode 100644 index 00000000..f0bb59d7 --- /dev/null +++ b/src/pages/StakePage/StakePage.js @@ -0,0 +1,130 @@ +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 || 0) - (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..b9c12fe1 --- /dev/null +++ b/src/pages/StakePage/StakePage.module.css @@ -0,0 +1,127 @@ +.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; +} + +/* 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; + 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, } diff --git a/src/services/API/Electrum/Electrum.js b/src/services/API/Electrum/Electrum.js index 853fd980..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,31 +45,20 @@ const requestElectrum = async (url, body = null, request = fetch) => { console.error(error) throw error } finally { - abortControllers.delete(url) + abortControllers.delete(`${method} ${url}`) } } 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/API/Mintlayer/Mintlayer.js b/src/services/API/Mintlayer/Mintlayer.js index 0e72a02c..fb509b00 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,15 +24,29 @@ 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() - 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() @@ -75,7 +90,7 @@ const requestMintlayer = async (url, body = null, request = fetch) => { console.error(error) throw error } finally { - abortControllers.delete(url) + abortControllers.delete(`${method} ${url}`) } } @@ -85,23 +100,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 +125,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 +333,179 @@ 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. +// 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://w3s.link/ipfs', +] +// 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 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) => { + 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 + // 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 + } +} + +// 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 + + 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 = + metadataCache.get(metadataUri) ?? + (await fetchJsonWithGatewayFallback(metadataUri)) + if (metadata) { + // 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, { + value: null, + expires: Date.now() + NEGATIVE_CACHE_TTL_MS, + }) + return undefined + } + + 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 +} + 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 +613,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..5bc0d01c 100644 --- a/src/services/API/Mintlayer/Mintlayer.test.js +++ b/src/services/API/Mintlayer/Mintlayer.test.js @@ -108,3 +108,138 @@ 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, + }) + 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() + }) + + // 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}`) + }) + + 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 = 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('blob:mock-icon') + + // 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('caches a definitive no-icon answer with a TTL', async () => { + const fetchSpy = mockGateways({ metadata: { name: 'no icon here' } }) + + await expect( + resolveTokenIcon('ipfs://bafymetadata/noicon.json'), + ).resolves.toBeUndefined() + await expect( + resolveTokenIcon('ipfs://bafymetadata/noicon.json'), + ).resolves.toBeUndefined() + // 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 () => { + const fetchSpy = jest + .spyOn(global, 'fetch') + .mockRejectedValue(new Error('signal timed out')) + + await expect( + resolveTokenIcon('ipfs://bafymetadata/fail.json'), + ).resolves.toBeUndefined() + await expect( + resolveTokenIcon('ipfs://bafymetadata/fail.json'), + ).resolves.toBeUndefined() + // 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() + }) +}) diff --git a/src/services/Browser/Browser.js b/src/services/Browser/Browser.js new file mode 100644 index 00000000..4ab02b8c --- /dev/null +++ b/src/services/Browser/Browser.js @@ -0,0 +1,81 @@ +/* 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 windows = api?.windows ?? 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 + + // 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 = () => { + 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() + } + } + + if (windows?.getCurrent) { + windows.getCurrent((win) => send(win?.id ?? null)) + } else { + 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 new file mode 100644 index 00000000..952da264 --- /dev/null +++ b/src/services/Browser/Browser.test.js @@ -0,0 +1,178 @@ +/* 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 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, 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', () => { + 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 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 + + const { sendPopupResponse } = loadBrowserModule() + + sendPopupResponse({ + method: 'connect', + requestId: 'r1', + 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), + ) + }) + + 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 + + const { sendPopupResponse } = loadBrowserModule() + + sendPopupResponse({ + method: 'signTransaction_reject', + requestId: 'r2', + 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') + 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() + }) + + 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) + }) + }) +}) diff --git a/src/services/Browser/index.js b/src/services/Browser/index.js new file mode 100644 index 00000000..925c84c4 --- /dev/null +++ b/src/services/Browser/index.js @@ -0,0 +1,5 @@ +import * as Browser from './Browser' + +export { Browser } + +export { runtime, storage, sendPopupResponse } from './Browser' 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..3d783bac 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 @@ -260,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({ @@ -345,9 +357,58 @@ 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 +} + +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 } = {}) => { + 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, + hasPasskey, + getPasswordWithPasskey, + 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() + }) + }) +}) diff --git a/src/utils/Constants/AppInfo/AppInfo.js b/src/utils/Constants/AppInfo/AppInfo.js index 92a73529..7f0d1a9b 100644 --- a/src/utils/Constants/AppInfo/AppInfo.js +++ b/src/utils/Constants/AppInfo/AppInfo.js @@ -10,7 +10,10 @@ const appAccounts = async () => { 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, diff --git a/src/utils/Helpers/BTC/BTC.js b/src/utils/Helpers/BTC/BTC.js index fd2617d0..b428cb81 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,18 @@ const calculateBalances = (cryptos, yesterdayExchangeRates) => { const getStats = (proportionDiffs, balanceDiffs, networkType) => { const isTestnet = networkType === AppInfo.NETWORK_TYPES.TESTNET - const hasBalance = proportionDiffs.total !== 0 + // 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 : 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 +350,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 +393,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..c106f451 100644 --- a/src/utils/Helpers/BTC/BTC.test.js +++ b/src/utils/Helpers/BTC/BTC.test.js @@ -5,6 +5,9 @@ import { getConfirmationsAmount, parseFeesEstimates, convertBtcToSatoshi, + getBtcAddressString, + calculateBalances, + getStats, } from './BTC' import { localStorageMock } from 'src/tests/mock/localStorage/localStorage' @@ -30,6 +33,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) @@ -138,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 cfc39f8a..b38f8cf9 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,54 @@ 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) + } + + // 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 } +} + export { getParsedTransactions, getAmountInAtoms, @@ -498,4 +564,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) + }) }) 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..e219db9e --- /dev/null +++ b/src/utils/Helpers/Transactions/Transactions.js @@ -0,0 +1,69 @@ +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, + } +} + +// 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') + }) +}) 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, } 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"] } }, diff --git a/webpack.config.js b/webpack.config.js index e67f5250..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') @@ -28,6 +29,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'), @@ -59,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: { @@ -252,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) + }, + }, ], }),