<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: WebMonero - online monero wallet</title>
    <description>The latest articles on DEV Community by WebMonero - online monero wallet (@webmonero).</description>
    <link>https://dev.to/webmonero</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3792299%2Fddb8cba4-2344-4091-9d2c-2a8693af723d.png</url>
      <title>DEV Community: WebMonero - online monero wallet</title>
      <link>https://dev.to/webmonero</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/webmonero"/>
    <language>en</language>
    <item>
      <title>How We Built a Browser-Based Monero Wallet With Zero KYC</title>
      <dc:creator>WebMonero - online monero wallet</dc:creator>
      <pubDate>Wed, 25 Feb 2026 17:39:57 +0000</pubDate>
      <link>https://dev.to/webmonero/how-we-built-a-browser-based-monero-wallet-with-zero-kyc-117a</link>
      <guid>https://dev.to/webmonero/how-we-built-a-browser-based-monero-wallet-with-zero-kyc-117a</guid>
      <description>&lt;p&gt;Most Monero wallets require you to either download an app, sync a full node, or hand over your personal data. We wanted to build something different — a Monero wallet that works the moment you open it in your browser. No installs, no KYC, no email. Just a seed phrase, a password, and a working XMR address in under 30 seconds.&lt;/p&gt;

&lt;p&gt;Here's how we built &lt;a href="https://webmonero.com" rel="noopener noreferrer"&gt;WebMonero&lt;/a&gt; and the technical decisions behind it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;If you want to use Monero today, your options look like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Monero GUI&lt;/strong&gt; — full node sync (100+ GB, several hours), desktop only&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Feather Wallet&lt;/strong&gt; — lightweight but still desktop only&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cake Wallet&lt;/strong&gt; — great mobile app, but you need to install it&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MyMonero&lt;/strong&gt; — has a web version, but the interface is from 2017&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There's a gap: no modern, fast, browser-based wallet for Monero that respects user privacy. We decided to fill it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture Overview
&lt;/h2&gt;

&lt;p&gt;The stack is intentionally simple. Fewer moving parts = fewer things that break.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Backend:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Python + Flask&lt;/li&gt;
&lt;li&gt;SQLite with WAL mode (fast reads, atomic writes)&lt;/li&gt;
&lt;li&gt;Gunicorn with gthread workers behind Nginx reverse proxy&lt;/li&gt;
&lt;li&gt;Cloudflare for DDoS protection and CDN&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Monero Infrastructure:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;monerod&lt;/code&gt; running in pruned mode on the same VPS&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;monero-wallet-rpc&lt;/code&gt; for all wallet operations&lt;/li&gt;
&lt;li&gt;Each user gets a unique Monero subaddress (not a shared deposit address)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Frontend:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Vanilla HTML/CSS/JS — no React, no Vue, no framework&lt;/li&gt;
&lt;li&gt;SPA-like navigation using fetch + pushState (partial HTML responses)&lt;/li&gt;
&lt;li&gt;Dark theme, responsive, mobile-first&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How User Accounts Work
&lt;/h2&gt;

&lt;p&gt;We don't use email/password authentication. Instead:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;User clicks "Create Wallet"&lt;/li&gt;
&lt;li&gt;Server generates a cryptographically random 12-word mnemonic seed phrase&lt;/li&gt;
&lt;li&gt;User writes it down and verifies by confirming random words&lt;/li&gt;
&lt;li&gt;User sets a password&lt;/li&gt;
&lt;li&gt;We store &lt;code&gt;SHA-256(seed_phrase)&lt;/code&gt; as the user identifier and &lt;code&gt;bcrypt(password)&lt;/code&gt; for auth&lt;/li&gt;
&lt;li&gt;A new Monero subaddress is created via &lt;code&gt;monero-wallet-rpc&lt;/code&gt; and linked to the user&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Login = seed phrase + password. No email, no phone, no cookies to track you across sessions.&lt;/p&gt;

&lt;p&gt;The seed phrase is never stored — only its SHA-256 hash. If the user loses their seed, we cannot recover it. This is by design.&lt;/p&gt;

&lt;h2&gt;
  
  
  Subaddress Isolation
&lt;/h2&gt;

&lt;p&gt;Every user gets their own Monero subaddress within a single wallet account. This means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Deposits go directly to the user's unique address&lt;/li&gt;
&lt;li&gt;We track incoming transfers per subaddress via &lt;code&gt;get_transfers&lt;/code&gt; RPC&lt;/li&gt;
&lt;li&gt;Balance = (total incoming to subaddress) - (total sent from DB) - (deposit fee)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the same model custodial exchanges use, but simplified for a wallet service.&lt;/p&gt;

&lt;h2&gt;
  
  
  Balance Calculation
&lt;/h2&gt;

&lt;p&gt;We don't store balances in the database. They're calculated in real time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;available = confirmed_incoming + internal_receives - total_spent - deposit_fee
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;confirmed_incoming&lt;/code&gt; — from Monero RPC (on-chain deposits to user's subaddress)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;internal_receives&lt;/code&gt; — from our DB (transfers from other platform users)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;total_spent&lt;/code&gt; — from our DB (all completed sends + network fees)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;deposit_fee&lt;/code&gt; — 0.5% of total chain deposits&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This approach means the balance is always accurate and can't drift out of sync.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sending XMR
&lt;/h2&gt;

&lt;p&gt;When a user sends Monero, we first check if the recipient is another user on our platform:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Internal transfer (recipient is on WebMonero):&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Atomic database transaction: debit sender + credit receiver in one SQL transaction&lt;/li&gt;
&lt;li&gt;Instant, zero fees&lt;/li&gt;
&lt;li&gt;No blockchain transaction needed&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;External transfer (recipient is an outside address):&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Validate address via &lt;code&gt;validate_address&lt;/code&gt; RPC&lt;/li&gt;
&lt;li&gt;Check available balance including fee buffer for network costs&lt;/li&gt;
&lt;li&gt;Execute transfer via &lt;code&gt;transfer&lt;/code&gt; RPC with user-selected priority&lt;/li&gt;
&lt;li&gt;Record in database after RPC confirms&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The amount the user enters is the amount the recipient gets. Network fees are charged on top, not deducted from the send amount. This matches how most exchanges handle it and avoids the "I sent 1 XMR but they received 0.99997" confusion.&lt;/p&gt;

&lt;h2&gt;
  
  
  Preventing Double-Spend
&lt;/h2&gt;

&lt;p&gt;With multiple Gunicorn workers handling concurrent requests, a race condition could allow a user to submit two send requests simultaneously and overdraw their balance.&lt;/p&gt;

&lt;p&gt;Our solution: database-level locking.&lt;/p&gt;

&lt;p&gt;Before any send operation, we atomically acquire a lock in SQLite:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;wallets&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;send_locked&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;send_locked_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'now'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;send_locked&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="n"&gt;send_locked_at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nb"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'now'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'-60 seconds'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;rowcount = 0&lt;/code&gt;, another worker is already processing a send for this user. The request gets a 429 response.&lt;/p&gt;

&lt;p&gt;The 60-second stale lock timeout handles edge cases where a worker crashes mid-transaction.&lt;/p&gt;

&lt;h2&gt;
  
  
  Commission Model
&lt;/h2&gt;

&lt;p&gt;0.5% of every incoming chain deposit. That's it.&lt;/p&gt;

&lt;p&gt;The fee is calculated dynamically:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;deposit_fee = total_chain_incoming * 0.005
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every new deposit proportionally increases the fee. The user sees their balance net of fees at all times. Sending has no platform fee — only the standard Monero network fee.&lt;/p&gt;

&lt;h2&gt;
  
  
  Performance Optimizations
&lt;/h2&gt;

&lt;p&gt;Initial page loads were slow (~3-4 seconds). We brought it down to under 1 second:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;SPA navigation&lt;/strong&gt; — after the initial load, page transitions fetch only &lt;code&gt;&amp;lt;main&amp;gt;&lt;/code&gt; content via &lt;code&gt;X-SPA: 1&lt;/code&gt; header, reducing HTML by ~83%&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Parallel API calls&lt;/strong&gt; — dashboard loads balance, transactions, and XMR price simultaneously via &lt;code&gt;Promise.all&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nginx&lt;/strong&gt; — gzip, static asset caching (30 days), keepalive connections&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare&lt;/strong&gt; — edge caching, Brotli compression&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Async font loading&lt;/strong&gt; — fonts don't block render&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;instant.page&lt;/strong&gt; — prefetches links on hover&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  SEO for a Web App
&lt;/h2&gt;

&lt;p&gt;Since the app is partially SPA, we needed to ensure Google can crawl all content:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Server-side rendered HTML for all routes (no client-side-only rendering)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;sitemap.xml&lt;/code&gt; with all public pages including blog posts&lt;/li&gt;
&lt;li&gt;Schema.org markup: &lt;code&gt;WebApplication&lt;/code&gt;, &lt;code&gt;FAQPage&lt;/code&gt;, &lt;code&gt;Organization&lt;/code&gt;, &lt;code&gt;BlogPosting&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Open Graph and Twitter Card meta tags&lt;/li&gt;
&lt;li&gt;Blog with SEO-optimized articles targeting long-tail Monero keywords&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I'd Do Differently
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Use PostgreSQL instead of SQLite.&lt;/strong&gt; SQLite works fine at our current scale, but the single-writer limitation means we need application-level locking for sends. PostgreSQL's &lt;code&gt;SELECT FOR UPDATE&lt;/code&gt; would handle this more cleanly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Add WebSocket for real-time balance updates.&lt;/strong&gt; Currently the dashboard polls the API. WebSocket would give instant feedback when deposits arrive.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Consider a non-custodial model.&lt;/strong&gt; Client-side key derivation from the seed phrase would eliminate the need to trust the server. But it would also eliminate internal transfers, make the UX harder, and complicate fee collection. Trade-offs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try It
&lt;/h2&gt;

&lt;p&gt;If you want to see it in action: &lt;a href="https://webmonero.com" rel="noopener noreferrer"&gt;webmonero.com&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Create a wallet, send yourself some XMR from another wallet, poke around. It takes 30 seconds.&lt;/p&gt;

&lt;p&gt;The project is actively maintained. Feedback, bug reports, and questions are welcome.&lt;/p&gt;




&lt;p&gt;Follow the project:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://webmonero.com" rel="noopener noreferrer"&gt;webmonero.com&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://x.com/webmonero" rel="noopener noreferrer"&gt;Twitter/X&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://t.me/webmonero" rel="noopener noreferrer"&gt;Telegram&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>monero</category>
      <category>crypto</category>
      <category>privacy</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
