From 48b44a81991fda4151c54a04ca5f7a2f85c3ff61 Mon Sep 17 00:00:00 2001 From: i2p Date: Thu, 27 Aug 2026 18:27:44 +0000 Subject: [PATCH] initial commit --- .DS_Store | Bin 0 -> 6148 bytes ._. | Bin 0 -> 220 bytes ._.DS_Store | Bin 0 -> 120 bytes README.md | 39 ++++++++ config.json | 14 +++ main.js | 111 ++++++++++++++++++++ package.json | 24 +++++ preload.js | 16 +++ src/.DS_Store | Bin 0 -> 6148 bytes src/._.DS_Store | Bin 0 -> 120 bytes src/core/balances.js | 39 ++++++++ src/core/chains.js | 83 +++++++++++++++ src/core/keystore.js | 101 +++++++++++++++++++ src/renderer/index.html | 12 +++ src/renderer/renderer.js | 211 +++++++++++++++++++++++++++++++++++++++ src/renderer/styles.css | 87 ++++++++++++++++ 16 files changed, 737 insertions(+) create mode 100644 .DS_Store create mode 100755 ._. create mode 100644 ._.DS_Store create mode 100644 README.md create mode 100644 config.json create mode 100644 main.js create mode 100644 package.json create mode 100644 preload.js create mode 100644 src/.DS_Store create mode 100644 src/._.DS_Store create mode 100644 src/core/balances.js create mode 100644 src/core/chains.js create mode 100644 src/core/keystore.js create mode 100644 src/renderer/index.html create mode 100644 src/renderer/renderer.js create mode 100644 src/renderer/styles.css diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..6de041031f3401b3e01ef851b98a7b7946dfa3eb GIT binary patch literal 6148 zcmeHKOHRWu5S^h#io~W88y@9F1ie)c=k|0>5a@C5x+4dB@0QcYs9EUfa0TL0h ztPq-!>^GT@KTnG7AtGKqcT1uL5mjh{EXst4dD3-e!RJ7hG1|UKsTb?aTz?*9?K9fb zj@oWNTz@h3kIQwNHe21s!{Ix4lj^$ZQv*wN{c`vEe))DY^me~^w@<@Sk4STI2AlzB zz!`7`PR9W5Y?10h(R*jW8E^)^7?ATJpb199tXPf?bfp9U&S5SBU1|x*35L-yE5ZX| zO$BNyTZzG%4tub;XqXi>o!E*GwwXVR7cR47{7}M)qoViDfHN>=z>4&R-2b=uWhRUK zaf*LA1J1xdV}R%Nx?W*ZcDMf6p4_zo?H)};;<6|Z=%YsfKI9y^Do*tW(GeF7v!aY5 S_L2_thd?33J7?eo4153{CM%Nw literal 0 HcmV?d00001 diff --git a/._. b/._. new file mode 100755 index 0000000000000000000000000000000000000000..be00a9bf8c69640466f640e5501f1b0ba2243eac GIT binary patch literal 220 zcmZQz6=P>$Vqox1Ojhs@R)|o50+1L3ClDI}@hTt&@$UdJ5x_AdBnYYuq+<#Yd!T7! uM2Pbz=jZAr78K;9>g6UT=YSL*WSq4_)%2sU{;anfL{$|(vZ3iFk_!M{EEU24 literal 0 HcmV?d00001 diff --git a/._.DS_Store b/._.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..a5b28df1cbc6e15bd0d35cdadd0c2e65d5131c7d GIT binary patch literal 120 zcmZQz6=P>$Vqox1Ojhs@R)|o50+1L3ClDI}u^SMB_!U6R08`;00ODZ-jv*mIP;rnB Iur73U08|YJ=l}o! literal 0 HcmV?d00001 diff --git a/README.md b/README.md new file mode 100644 index 0000000..731f4fc --- /dev/null +++ b/README.md @@ -0,0 +1,39 @@ +# macu wallet + +A non-custodial, multi-chain (Ethereum/EVM, Bitcoin, Solana) desktop wallet built on Electron. +One seed phrase, standard BIP44 derivation, encrypted at rest. + +## Run it + +```bash +npm install +npm start +``` + +Requires Node.js 18+ and npm. First run creates a new wallet (or import an existing 12/24-word phrase). + +## What's here + +- `src/core/keystore.js` — mnemonic generation, scrypt + AES-256-GCM encryption at rest +- `src/core/chains.js` — BIP44 derivation for Ethereum, Bitcoin (native segwit), and Solana from one seed +- `src/core/balances.js` — reads balances over RPC / a block explorer API +- `main.js` / `preload.js` — Electron process split: **only** `main.js` ever touches a decrypted private key; the UI talks to it over a narrow IPC bridge +- `src/renderer/` — the UI (create/import, backup phrase, password, dashboard, send ETH) + +## Before this holds real funds — read this + +This is a solid, correctly-structured foundation, not a finished, audited product. Specifically: + +1. **No security audit.** Wallet software handling real private keys should get an independent security review before real money touches it. I can't provide that as a chat assistant — please have someone qualified review this, or start on testnets. +2. **Sending is only wired up for Ethereum.** Bitcoin needs UTXO selection + PSBT signing; Solana needs instruction building. Same pattern as `wallet:sendEth` in `main.js`, but each chain's transaction format is different — happy to build these out next. +3. **Default RPC endpoints in `config.json` are free public ones.** Rate-limited, and you're trusting a third party's node for balance data. Swap in your own Infura/Alchemy/Helius keys for anything beyond testing. +4. **No hardware wallet support.** A production wallet usually lets a user keep keys on a Ledger/Trezor instead of software-only storage — worth adding before this holds meaningful amounts. +5. **The password is the only thing standing between the encrypted file and your funds.** There's no recovery mechanism by design (that's what non-custodial means) — losing the recovery phrase means losing access, permanently. +6. **Test on testnets first.** Use Sepolia (Ethereum), Bitcoin testnet, and Solana devnet before ever sending real assets. + +## Next steps I'd suggest, in order + +1. Get it running locally and create a test wallet on testnets +2. Wire up Bitcoin and Solana sending (I can help build these) +3. Add a "confirm transaction details" screen before any send fires — right now it sends immediately, which is too easy to fat-finger +4. Get a real security review before mainnet use with meaningful funds diff --git a/config.json b/config.json new file mode 100644 index 0000000..6b821c6 --- /dev/null +++ b/config.json @@ -0,0 +1,14 @@ +{ + "ethereum": { + "rpcUrl": "https://eth.llamarpc.com", + "_note": "Free public endpoint, rate-limited. Swap in your own Infura/Alchemy URL for real use." + }, + "solana": { + "rpcUrl": "https://api.mainnet-beta.solana.com", + "_note": "Free public endpoint, rate-limited. Swap in your own Helius/QuickNode URL for real use." + }, + "bitcoin": { + "apiBase": "https://blockstream.info/api", + "_note": "Public block explorer API. Fine for balance checks; run your own node for anything serious." + } +} diff --git a/main.js b/main.js new file mode 100644 index 0000000..90d22e3 --- /dev/null +++ b/main.js @@ -0,0 +1,111 @@ +const { app, BrowserWindow, ipcMain } = require('electron'); +const path = require('path'); +const fs = require('fs'); +const { ethers } = require('ethers'); + +const keystore = require('./src/core/keystore'); +const chains = require('./src/core/chains'); +const balances = require('./src/core/balances'); + +const KEYSTORE_PATH = path.join(app.getPath('userData'), 'keystore.json'); +const config = JSON.parse(fs.readFileSync(path.join(__dirname, 'config.json'), 'utf8')); + +// Holds the derived accounts ONLY while unlocked, in memory. Never written +// to disk in plaintext. Cleared on lock/quit. +let session = null; + +function createWindow() { + const win = new BrowserWindow({ + width: 1180, + height: 780, + webPreferences: { + preload: path.join(__dirname, 'preload.js'), + contextIsolation: true, + nodeIntegration: false, + }, + }); + win.loadFile(path.join(__dirname, 'src/renderer/index.html')); +} + +app.whenReady().then(createWindow); +app.on('window-all-closed', () => { + session = null; + if (process.platform !== 'darwin') app.quit(); +}); + +// ---------- IPC handlers ---------- + +ipcMain.handle('wallet:hasKeystore', () => fs.existsSync(KEYSTORE_PATH)); + +ipcMain.handle('wallet:generateMnemonic', () => keystore.generateMnemonic()); + +function createOrImportWallet(mnemonic, password) { + if (!keystore.validateMnemonic(mnemonic)) { + throw new Error('That recovery phrase does not look valid. Check the word list and try again.'); + } + const encrypted = keystore.encryptMnemonic(mnemonic.trim(), password); + keystore.saveKeystore(KEYSTORE_PATH, encrypted); + session = { accounts: chains.deriveAllChains(mnemonic.trim()) }; + return publicAccounts(); +} + +ipcMain.handle('wallet:create', (_evt, { mnemonic, password }) => createOrImportWallet(mnemonic, password)); +ipcMain.handle('wallet:import', (_evt, { mnemonic, password }) => createOrImportWallet(mnemonic, password)); + +ipcMain.handle('wallet:unlock', (_evt, { password }) => { + if (!fs.existsSync(KEYSTORE_PATH)) throw new Error('No wallet found on this device.'); + const stored = keystore.loadKeystore(KEYSTORE_PATH); + let mnemonic; + try { + mnemonic = keystore.decryptMnemonic(stored, password); + } catch { + throw new Error('Incorrect password.'); + } + session = { accounts: chains.deriveAllChains(mnemonic) }; + return publicAccounts(); +}); + +ipcMain.handle('wallet:lock', () => { + session = null; + return true; +}); + +ipcMain.handle('wallet:balances', async () => { + requireSession(); + const { ethereum, bitcoin, solana } = session.accounts; + const [eth, btc, sol] = await Promise.allSettled([ + balances.getEthBalance(ethereum.address, config.ethereum.rpcUrl), + balances.getBtcBalance(bitcoin.address, config.bitcoin.apiBase), + balances.getSolBalance(solana.address, config.solana.rpcUrl), + ]); + return { + ethereum: eth.status === 'fulfilled' ? eth.value : null, + bitcoin: btc.status === 'fulfilled' ? btc.value : null, + solana: sol.status === 'fulfilled' ? sol.value : null, + }; +}); + +// Ethereum send is wired up as the reference implementation; Bitcoin/Solana +// sending follow the same shape but need UTXO selection / instruction +// building respectively — see README for what's left to fill in. +ipcMain.handle('wallet:sendEth', async (_evt, { to, amountEth }) => { + requireSession(); + const provider = new ethers.JsonRpcProvider(config.ethereum.rpcUrl); + const wallet = new ethers.Wallet(session.accounts.ethereum.privateKey, provider); + const tx = await wallet.sendTransaction({ to, value: ethers.parseEther(amountEth) }); + return { hash: tx.hash }; +}); + +function requireSession() { + if (!session) throw new Error('Wallet is locked.'); +} + +function publicAccounts() { + // Strip private keys before sending anything to the renderer process. + const { ethereum, bitcoin, solana } = session.accounts; + return { + ethereum: { address: ethereum.address, path: ethereum.path }, + bitcoin: { address: bitcoin.address, path: bitcoin.path }, + solana: { address: solana.address, path: solana.path }, + }; +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..3bd35d0 --- /dev/null +++ b/package.json @@ -0,0 +1,24 @@ +{ + "name": "macu-wallet", + "version": "0.1.0", + "description": "Non-custodial multi-chain desktop wallet (Ethereum/EVM, Bitcoin, Solana)", + "main": "main.js", + "scripts": { + "start": "electron .", + "dist": "electron-builder" + }, + "dependencies": { + "bip39": "^3.1.0", + "bip32": "^4.0.0", + "tiny-secp256k1": "^2.2.3", + "ethers": "^6.13.0", + "bitcoinjs-lib": "^6.1.5", + "ecpair": "^2.1.0", + "@solana/web3.js": "^1.95.0", + "ed25519-hd-key": "^1.3.0" + }, + "devDependencies": { + "electron": "^31.0.0", + "electron-builder": "^24.13.3" + } +} diff --git a/preload.js b/preload.js new file mode 100644 index 0000000..10db192 --- /dev/null +++ b/preload.js @@ -0,0 +1,16 @@ +const { contextBridge, ipcRenderer } = require('electron'); + +// Only these specific calls are exposed to the renderer (webpage) side. +// Nothing here touches the filesystem or private keys directly — every +// call is proxied through main.js, which is the only place that ever +// holds a decrypted mnemonic or private key. +contextBridge.exposeInMainWorld('macu', { + hasKeystore: () => ipcRenderer.invoke('wallet:hasKeystore'), + generateMnemonic: () => ipcRenderer.invoke('wallet:generateMnemonic'), + createWallet: (mnemonic, password) => ipcRenderer.invoke('wallet:create', { mnemonic, password }), + importWallet: (mnemonic, password) => ipcRenderer.invoke('wallet:import', { mnemonic, password }), + unlock: (password) => ipcRenderer.invoke('wallet:unlock', { password }), + lock: () => ipcRenderer.invoke('wallet:lock'), + getBalances: () => ipcRenderer.invoke('wallet:balances'), + sendEth: (to, amountEth) => ipcRenderer.invoke('wallet:sendEth', { to, amountEth }), +}); diff --git a/src/.DS_Store b/src/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..a68b500783176b248f5a6a47b0ab336242638b5d GIT binary patch literal 6148 zcmeHKOHRW;47E!ZMQkduVZmY+9Dv*)t->Cu7eH-LSu~xh68pJBFU1i!4$ot&$`HyD zp~{v#Z{p8*@)E@vBI3n+R};;MXbKgaTx0k_#4kFMiCM(Svc`5(FW0f(k0RahUm1{R zSJ9dx9q6@ue)kVUUmx3cwT&y-kdro_w;%J;-~H;}zYX7wN2FpM)TAAC7-e=S>(^TA zTTHnRI@&%)YxQxspYw6$zMf@YF3x~6;0#AEZhDi|?i2n#=8hmgD{*-|)#2`jK literal 0 HcmV?d00001 diff --git a/src/._.DS_Store b/src/._.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..a5b28df1cbc6e15bd0d35cdadd0c2e65d5131c7d GIT binary patch literal 120 zcmZQz6=P>$Vqox1Ojhs@R)|o50+1L3ClDI}u^SMB_!U6R08`;00ODZ-jv*mIP;rnB Iur73U08|YJ=l}o! literal 0 HcmV?d00001 diff --git a/src/core/balances.js b/src/core/balances.js new file mode 100644 index 0000000..c2c1d49 --- /dev/null +++ b/src/core/balances.js @@ -0,0 +1,39 @@ +/** + * balances.js + * + * Reads on-chain balances. Requires the user to configure RPC endpoints + * (config.json) — we don't bake in a paid provider's API key. Free public + * endpoints are set as defaults so it works out of the box, but they are + * rate-limited and not meant for production use. + */ + +const { ethers } = require('ethers'); +const { Connection, PublicKey, LAMPORTS_PER_SOL } = require('@solana/web3.js'); + +async function getEthBalance(address, rpcUrl) { + const provider = new ethers.JsonRpcProvider(rpcUrl); + const wei = await provider.getBalance(address); + return ethers.formatEther(wei); // returns a decimal string, e.g. "1.234" +} + +async function getSolBalance(address, rpcUrl) { + const connection = new Connection(rpcUrl); + const lamports = await connection.getBalance(new PublicKey(address)); + return (lamports / LAMPORTS_PER_SOL).toString(); +} + +/** Bitcoin: no JSON-RPC by default without running your own node, so we + * use a block explorer's public REST API (Blockstream) instead. */ +async function getBtcBalance(address, apiBase = 'https://blockstream.info/api') { + const res = await fetch(`${apiBase}/address/${address}`); + if (!res.ok) throw new Error(`Blockstream API error: ${res.status}`); + const data = await res.json(); + const sats = + data.chain_stats.funded_txo_sum - + data.chain_stats.spent_txo_sum + + data.mempool_stats.funded_txo_sum - + data.mempool_stats.spent_txo_sum; + return (sats / 1e8).toString(); +} + +module.exports = { getEthBalance, getSolBalance, getBtcBalance }; diff --git a/src/core/chains.js b/src/core/chains.js new file mode 100644 index 0000000..fee7739 --- /dev/null +++ b/src/core/chains.js @@ -0,0 +1,83 @@ +/** + * chains.js + * + * Derives per-chain keypairs/addresses from one BIP39 mnemonic, using the + * standard BIP44 derivation paths so the wallet is compatible with other + * wallets/tools if the user ever needs to import the same seed elsewhere. + * + * Ethereum / EVM : m/44'/60'/0'/0/0 + * Bitcoin (native segwit, bech32) : m/84'/0'/0'/0/0 + * Solana : m/44'/501'/0'/0' (ed25519, hardened-only path per SLIP-0010) + * + * Private keys are returned in-memory only — callers are responsible for + * not logging them, not writing them to disk unencrypted, and clearing + * references when done. + */ + +const bip39 = require('bip39'); +const { BIP32Factory } = require('bip32'); +const ecc = require('tiny-secp256k1'); +const bitcoin = require('bitcoinjs-lib'); +const { ethers } = require('ethers'); +const { Keypair } = require('@solana/web3.js'); +const { derivePath: edDerivePath } = require('ed25519-hd-key'); + +const bip32 = BIP32Factory(ecc); + +function seedFromMnemonic(mnemonic) { + return bip39.mnemonicToSeedSync(mnemonic.trim()); +} + +function deriveEthereum(mnemonic, index = 0) { + const path = `m/44'/60'/0'/0/${index}`; + const wallet = ethers.HDNodeWallet.fromPhrase(mnemonic.trim(), undefined, path); + return { + chain: 'ethereum', + path, + address: wallet.address, + privateKey: wallet.privateKey, // 0x-prefixed hex + }; +} + +function deriveBitcoin(mnemonic, index = 0, network = bitcoin.networks.bitcoin) { + const path = `m/84'/0'/0'/0/${index}`; // native segwit (bech32, "bc1...") + const seed = seedFromMnemonic(mnemonic); + const root = bip32.fromSeed(seed, network); + const child = root.derivePath(path); + const { address } = bitcoin.payments.p2wpkh({ pubkey: child.publicKey, network }); + return { + chain: 'bitcoin', + path, + address, + privateKeyWIF: child.toWIF(), + }; +} + +function deriveSolana(mnemonic, index = 0) { + const path = `m/44'/501'/${index}'/0'`; + const seed = seedFromMnemonic(mnemonic); + const { key } = edDerivePath(path, seed.toString('hex')); + const keypair = Keypair.fromSeed(key); + return { + chain: 'solana', + path, + address: keypair.publicKey.toBase58(), + secretKey: Buffer.from(keypair.secretKey).toString('hex'), + }; +} + +/** Derive the default (index 0) account on every supported chain at once. */ +function deriveAllChains(mnemonic) { + return { + ethereum: deriveEthereum(mnemonic, 0), + bitcoin: deriveBitcoin(mnemonic, 0), + solana: deriveSolana(mnemonic, 0), + }; +} + +module.exports = { + deriveEthereum, + deriveBitcoin, + deriveSolana, + deriveAllChains, +}; diff --git a/src/core/keystore.js b/src/core/keystore.js new file mode 100644 index 0000000..2f61ff6 --- /dev/null +++ b/src/core/keystore.js @@ -0,0 +1,101 @@ +/** + * keystore.js + * + * Handles the ONE thing that must never go wrong: turning a 12/24-word + * recovery phrase into an encrypted file on disk, and back again. + * + * Design choices (why): + * - We never persist the mnemonic in plaintext, anywhere, ever. + * - Key derivation from password uses scrypt (memory-hard, resists GPU + * brute-force better than PBKDF2 at equivalent settings). + * - Encryption is AES-256-GCM, which gives us both confidentiality and + * integrity (tamper detection) in one primitive. + * - Every encryption uses a fresh random salt + IV, stored alongside the + * ciphertext (this is safe — salts/IVs are not secret). + */ + +const crypto = require('crypto'); +const fs = require('fs'); +const bip39 = require('bip39'); + +const SCRYPT_PARAMS = { N: 2 ** 17, r: 8, p: 1 }; // ~0.5-1s on modern hardware +const KEY_LEN = 32; // AES-256 + +/** Generate a brand-new 24-word mnemonic (256 bits of entropy). */ +function generateMnemonic() { + return bip39.generateMnemonic(256); +} + +function validateMnemonic(mnemonic) { + return bip39.validateMnemonic(mnemonic.trim()); +} + +/** + * Encrypt a mnemonic with a user password. + * Returns a JSON-serializable object safe to write to disk. + */ +function encryptMnemonic(mnemonic, password) { + const salt = crypto.randomBytes(16); + const iv = crypto.randomBytes(12); // GCM standard IV length + const key = crypto.scryptSync(password, salt, KEY_LEN, SCRYPT_PARAMS); + + const cipher = crypto.createCipheriv('aes-256-gcm', key, iv); + const ciphertext = Buffer.concat([ + cipher.update(mnemonic, 'utf8'), + cipher.final(), + ]); + const authTag = cipher.getAuthTag(); + + return { + version: 1, + kdf: 'scrypt', + kdfParams: { ...SCRYPT_PARAMS, salt: salt.toString('hex') }, + cipher: 'aes-256-gcm', + iv: iv.toString('hex'), + ciphertext: ciphertext.toString('hex'), + authTag: authTag.toString('hex'), + }; +} + +/** + * Decrypt a keystore object with the user's password. + * Throws if the password is wrong (auth tag check fails) — this is + * intentional; GCM detects tampering/incorrect keys and we don't want to + * silently return garbage. + */ +function decryptMnemonic(keystoreObj, password) { + const { kdfParams, iv, ciphertext, authTag } = keystoreObj; + const salt = Buffer.from(kdfParams.salt, 'hex'); + const key = crypto.scryptSync(password, salt, KEY_LEN, { + N: kdfParams.N, + r: kdfParams.r, + p: kdfParams.p, + }); + + const decipher = crypto.createDecipheriv('aes-256-gcm', key, Buffer.from(iv, 'hex')); + decipher.setAuthTag(Buffer.from(authTag, 'hex')); + + const plaintext = Buffer.concat([ + decipher.update(Buffer.from(ciphertext, 'hex')), + decipher.final(), // throws "Unsupported state or unable to authenticate data" on wrong password + ]); + + return plaintext.toString('utf8'); +} + +function saveKeystore(path, keystoreObj) { + fs.writeFileSync(path, JSON.stringify(keystoreObj, null, 2), { mode: 0o600 }); +} + +function loadKeystore(path) { + return JSON.parse(fs.readFileSync(path, 'utf8')); +} + +module.exports = { + generateMnemonic, + validateMnemonic, + encryptMnemonic, + decryptMnemonic, + saveKeystore, + loadKeystore, +}; diff --git a/src/renderer/index.html b/src/renderer/index.html new file mode 100644 index 0000000..6f70c23 --- /dev/null +++ b/src/renderer/index.html @@ -0,0 +1,12 @@ + + + + +macu + + + +
+ + + diff --git a/src/renderer/renderer.js b/src/renderer/renderer.js new file mode 100644 index 0000000..33d868a --- /dev/null +++ b/src/renderer/renderer.js @@ -0,0 +1,211 @@ +const app = document.getElementById('app'); + +let pendingMnemonic = null; // held briefly in renderer memory during onboarding only +let accounts = null; + +async function boot() { + const hasKeystore = await window.macu.hasKeystore(); + hasKeystore ? renderUnlock() : renderWelcome(); +} + +// ---------- Welcome / choose create vs import ---------- +function renderWelcome() { + app.innerHTML = ` +
+
+

Welcome to macu

+

A non-custodial wallet. Your keys are generated and encrypted on this device only — macu never sees them, and there's no account recovery if you lose your recovery phrase and password.

+ + +
+
`; + document.getElementById('btn-create').onclick = startCreate; + document.getElementById('btn-import').onclick = renderImport; +} + +// ---------- Create: generate + show backup phrase ---------- +async function startCreate() { + pendingMnemonic = await window.macu.generateMnemonic(); + renderBackup(); +} + +function renderBackup() { + const words = pendingMnemonic.split(' '); + app.innerHTML = ` +
+
+

Your recovery phrase

+

Write these 24 words down in order and store them somewhere safe and offline. Anyone with this phrase can access your funds on every chain — including you, if you lose it.

+
macu cannot recover this for you. There is no "forgot password" for a recovery phrase.
+
+ ${words.map((w, i) => `
${i + 1}${w}
`).join('')} +
+ +
+
`; + document.getElementById('btn-saved').onclick = renderSetPassword; +} + +// ---------- Import: paste existing phrase ---------- +function renderImport() { + app.innerHTML = ` +
+
+

Import wallet

+

Enter your existing 12 or 24-word recovery phrase, separated by spaces.

+ +
+ + +
+
`; + document.getElementById('btn-back').onclick = renderWelcome; + document.getElementById('btn-continue').onclick = () => { + const value = document.getElementById('mnemonic-input').value.trim(); + if (value.split(/\s+/).length < 12) { + document.getElementById('import-error').textContent = 'That phrase looks too short — check the word count.'; + return; + } + pendingMnemonic = value; + renderSetPassword(); + }; +} + +// ---------- Set a password to encrypt the phrase at rest ---------- +function renderSetPassword() { + app.innerHTML = ` +
+
+

Set a password

+

This encrypts your recovery phrase on this device. You'll need it every time you open macu.

+ + +
+ +
+
`; + document.getElementById('btn-finish').onclick = async () => { + const pw1 = document.getElementById('pw1').value; + const pw2 = document.getElementById('pw2').value; + const errEl = document.getElementById('pw-error'); + if (pw1.length < 8) { errEl.textContent = 'Use at least 8 characters.'; return; } + if (pw1 !== pw2) { errEl.textContent = 'Passwords do not match.'; return; } + try { + accounts = await window.macu.createWallet(pendingMnemonic, pw1); + pendingMnemonic = null; // drop it from renderer memory as soon as we're done with it + renderDashboard(); + } catch (e) { + errEl.textContent = e.message; + } + }; +} + +// ---------- Unlock existing keystore ---------- +function renderUnlock() { + app.innerHTML = ` +
+
+

Welcome back

+

Enter your password to unlock macu.

+ +
+ +
+
`; + const tryUnlock = async () => { + const pw = document.getElementById('unlock-pw').value; + const errEl = document.getElementById('unlock-error'); + try { + accounts = await window.macu.unlock(pw); + renderDashboard(); + } catch (e) { + errEl.textContent = e.message; + } + }; + document.getElementById('btn-unlock').onclick = tryUnlock; + document.getElementById('unlock-pw').addEventListener('keydown', (e) => { if (e.key === 'Enter') tryUnlock(); }); +} + +// ---------- Dashboard ---------- +async function renderDashboard() { + app.innerHTML = ` +
+
macu
+ +
+ +
+ ${chainCard('Ethereum', accounts.ethereum.address)} + ${chainCard('Bitcoin', accounts.bitcoin.address)} + ${chainCard('Solana', accounts.solana.address)} +
+ +
+

Send ETH

+
+ +
+
+ +
+ +
+
`; + + document.getElementById('btn-lock').onclick = async () => { + await window.macu.lock(); + accounts = null; + renderUnlock(); + }; + + document.querySelectorAll('.address').forEach((el) => { + el.onclick = () => navigator.clipboard.writeText(el.dataset.full); + }); + + document.getElementById('btn-send').onclick = async () => { + const to = document.getElementById('send-to').value.trim(); + const amt = document.getElementById('send-amt').value.trim(); + const statusEl = document.getElementById('send-status'); + statusEl.className = 'status-msg'; + statusEl.textContent = 'Sending…'; + try { + const { hash } = await window.macu.sendEth(to, amt); + statusEl.className = 'status-msg ok'; + statusEl.textContent = `Sent. Tx hash: ${hash}`; + } catch (e) { + statusEl.className = 'status-msg err'; + statusEl.textContent = e.message; + } + }; + + loadBalances(); +} + +function chainCard(label, address) { + const short = address.length > 14 ? `${address.slice(0, 8)}…${address.slice(-6)}` : address; + return ` +
+
${label}
+
+
${short}
+
`; +} + +async function loadBalances() { + try { + const bal = await window.macu.getBalances(); + setBalance('ethereum', bal.ethereum, 'ETH'); + setBalance('bitcoin', bal.bitcoin, 'BTC'); + setBalance('solana', bal.solana, 'SOL'); + } catch (e) { + console.error(e); + } +} + +function setBalance(chain, value, symbol) { + const el = document.querySelector(`[data-balance="${chain}"]`); + if (!el) return; + el.textContent = value === null ? 'Unavailable' : `${Number(value).toFixed(4)} ${symbol}`; +} + +boot(); diff --git a/src/renderer/styles.css b/src/renderer/styles.css new file mode 100644 index 0000000..96113bb --- /dev/null +++ b/src/renderer/styles.css @@ -0,0 +1,87 @@ +@import url('https://fonts.googleapis.com/css2?family=Fraunces:wght@500;600&family=Inter:wght@400;500;600;700&display=swap'); + +:root{ + --bg:#f4ede1; --bg-panel:#fffaf2; --bg-panel-2:#eee3d0; --line:#e0d2b8; + --ink:#2c241c; --ink-soft:#7c705d; + --clay:#bd6a3f; --clay-deep:#9c522d; --gold:#c99a4a; + --good:#5f7a4f; --bad:#b5563f; + --radius:18px; +} +*{box-sizing:border-box;margin:0;padding:0;} +body{ + font-family:'Inter',sans-serif; + background:var(--bg); color:var(--ink); + min-height:100vh; display:flex; align-items:center; justify-content:center; +} +#app{width:100%; max-width:1180px; min-height:100vh; padding:24px;} + +.serif{font-family:'Fraunces',serif;} +.center-screen{min-height:80vh; display:flex; align-items:center; justify-content:center;} +.card{ + background:var(--bg-panel); border:1px solid var(--line); border-radius:var(--radius); + padding:36px; max-width:460px; width:100%; +} +.card h1{font-family:'Fraunces',serif; font-size:24px; margin-bottom:10px;} +.card p.sub{color:var(--ink-soft); font-size:13.5px; margin-bottom:24px; line-height:1.5;} + +.btn{ + display:inline-flex; align-items:center; justify-content:center; gap:8px; + padding:12px 20px; border-radius:12px; border:none; cursor:pointer; + font-weight:600; font-size:13.5px; font-family:'Inter',sans-serif; + width:100%; margin-top:10px; transition:transform .1s; +} +.btn:hover{transform:translateY(-1px);} +.btn:disabled{opacity:.5; cursor:not-allowed; transform:none;} +.btn-primary{background:linear-gradient(135deg,var(--clay),var(--gold)); color:#fff8ee;} +.btn-ghost{background:var(--bg-panel-2); color:var(--ink); border:1px solid var(--line);} + +input[type=password], input[type=text]{ + width:100%; padding:12px 14px; border-radius:12px; border:1px solid var(--line); + background:var(--bg-panel-2); color:var(--ink); font-family:'Inter',sans-serif; + font-size:14px; margin-bottom:8px; +} + +.mnemonic-grid{ + display:grid; grid-template-columns:repeat(3,1fr); gap:8px; + background:var(--bg-panel-2); border:1px solid var(--line); border-radius:12px; + padding:16px; margin-bottom:18px; +} +.mnemonic-word{ + font-size:13px; padding:8px 10px; background:var(--bg-panel); border-radius:8px; + display:flex; gap:6px; align-items:baseline; +} +.mnemonic-word .idx{color:var(--ink-soft); font-size:11px; min-width:16px;} + +.warning{ + background:rgba(181,86,63,.1); border:1px solid rgba(181,86,63,.3); color:var(--bad); + border-radius:10px; padding:10px 14px; font-size:12.5px; margin-bottom:16px; line-height:1.5; +} +.error{color:var(--bad); font-size:12.5px; margin-top:6px; min-height:16px;} +.link-btn{background:none; border:none; color:var(--clay-deep); font-weight:600; font-size:12.5px; cursor:pointer; margin-top:14px;} + +/* dashboard */ +.topbar{display:flex; justify-content:space-between; align-items:center; margin-bottom:24px;} +.brand{display:flex; align-items:center; gap:10px;} +.brand-mark{ + width:32px;height:32px;border-radius:10px; + background:linear-gradient(135deg,var(--clay),var(--gold)); +} +.brand-name{font-family:'Fraunces',serif; font-weight:600; font-size:18px;} + +.grid3{display:grid; grid-template-columns:repeat(3,1fr); gap:18px; margin-bottom:22px;} +.chain-card{ + background:var(--bg-panel); border:1px solid var(--line); border-radius:var(--radius); padding:20px; +} +.chain-card .chain-label{font-size:11px; text-transform:uppercase; letter-spacing:.07em; color:var(--ink-soft); margin-bottom:8px;} +.chain-card .balance{font-family:'Fraunces',serif; font-size:24px; margin-bottom:8px;} +.chain-card .address{ + font-size:11px; color:var(--ink-soft); word-break:break-all; + background:var(--bg-panel-2); padding:6px 8px; border-radius:8px; cursor:pointer; +} + +.panel{background:var(--bg-panel); border:1px solid var(--line); border-radius:var(--radius); padding:24px;} +.panel h2{font-family:'Fraunces',serif; font-size:16px; margin-bottom:16px;} +.row{display:flex; gap:10px; margin-bottom:10px;} +.status-msg{font-size:12.5px; margin-top:8px; color:var(--ink-soft);} +.status-msg.ok{color:var(--good);} +.status-msg.err{color:var(--bad);}