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