initial commit
This commit is contained in:
BIN
Binary file not shown.
@@ -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
|
||||
+14
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
+16
@@ -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 }),
|
||||
});
|
||||
Vendored
BIN
Binary file not shown.
Binary file not shown.
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>macu</title>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script src="renderer.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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 = `
|
||||
<div class="center-screen">
|
||||
<div class="card">
|
||||
<h1 class="serif">Welcome to macu</h1>
|
||||
<p class="sub">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.</p>
|
||||
<button class="btn btn-primary" id="btn-create">Create a new wallet</button>
|
||||
<button class="btn btn-ghost" id="btn-import">Import an existing wallet</button>
|
||||
</div>
|
||||
</div>`;
|
||||
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 = `
|
||||
<div class="center-screen">
|
||||
<div class="card" style="max-width:560px;">
|
||||
<h1 class="serif">Your recovery phrase</h1>
|
||||
<p class="sub">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.</p>
|
||||
<div class="warning">macu cannot recover this for you. There is no "forgot password" for a recovery phrase.</div>
|
||||
<div class="mnemonic-grid">
|
||||
${words.map((w, i) => `<div class="mnemonic-word"><span class="idx">${i + 1}</span>${w}</div>`).join('')}
|
||||
</div>
|
||||
<button class="btn btn-primary" id="btn-saved">I've saved it — continue</button>
|
||||
</div>
|
||||
</div>`;
|
||||
document.getElementById('btn-saved').onclick = renderSetPassword;
|
||||
}
|
||||
|
||||
// ---------- Import: paste existing phrase ----------
|
||||
function renderImport() {
|
||||
app.innerHTML = `
|
||||
<div class="center-screen">
|
||||
<div class="card">
|
||||
<h1 class="serif">Import wallet</h1>
|
||||
<p class="sub">Enter your existing 12 or 24-word recovery phrase, separated by spaces.</p>
|
||||
<textarea id="mnemonic-input" rows="4" style="width:100%;padding:12px;border-radius:12px;border:1px solid var(--line);background:var(--bg-panel-2);font-family:'Inter',sans-serif;font-size:13px;margin-bottom:8px;"></textarea>
|
||||
<div class="error" id="import-error"></div>
|
||||
<button class="btn btn-primary" id="btn-continue">Continue</button>
|
||||
<button class="link-btn" id="btn-back">Back</button>
|
||||
</div>
|
||||
</div>`;
|
||||
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 = `
|
||||
<div class="center-screen">
|
||||
<div class="card">
|
||||
<h1 class="serif">Set a password</h1>
|
||||
<p class="sub">This encrypts your recovery phrase on this device. You'll need it every time you open macu.</p>
|
||||
<input type="password" id="pw1" placeholder="Password (12+ characters recommended)">
|
||||
<input type="password" id="pw2" placeholder="Confirm password">
|
||||
<div class="error" id="pw-error"></div>
|
||||
<button class="btn btn-primary" id="btn-finish">Create wallet</button>
|
||||
</div>
|
||||
</div>`;
|
||||
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 = `
|
||||
<div class="center-screen">
|
||||
<div class="card">
|
||||
<h1 class="serif">Welcome back</h1>
|
||||
<p class="sub">Enter your password to unlock macu.</p>
|
||||
<input type="password" id="unlock-pw" placeholder="Password">
|
||||
<div class="error" id="unlock-error"></div>
|
||||
<button class="btn btn-primary" id="btn-unlock">Unlock</button>
|
||||
</div>
|
||||
</div>`;
|
||||
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 = `
|
||||
<div class="topbar">
|
||||
<div class="brand"><div class="brand-mark"></div><div class="brand-name serif">macu</div></div>
|
||||
<button class="btn btn-ghost" style="width:auto;margin:0;" id="btn-lock">Lock</button>
|
||||
</div>
|
||||
|
||||
<div class="grid3" id="chain-cards">
|
||||
${chainCard('Ethereum', accounts.ethereum.address)}
|
||||
${chainCard('Bitcoin', accounts.bitcoin.address)}
|
||||
${chainCard('Solana', accounts.solana.address)}
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h2>Send ETH</h2>
|
||||
<div class="row">
|
||||
<input type="text" id="send-to" placeholder="Recipient address (0x...)">
|
||||
</div>
|
||||
<div class="row">
|
||||
<input type="text" id="send-amt" placeholder="Amount in ETH">
|
||||
</div>
|
||||
<button class="btn btn-primary" id="btn-send" style="width:auto;">Send</button>
|
||||
<div class="status-msg" id="send-status"></div>
|
||||
</div>`;
|
||||
|
||||
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 `
|
||||
<div class="chain-card">
|
||||
<div class="chain-label">${label}</div>
|
||||
<div class="balance" data-balance="${label.toLowerCase()}">…</div>
|
||||
<div class="address" data-full="${address}" title="Click to copy full address">${short}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -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);}
|
||||
Reference in New Issue
Block a user