Files
dione/wallets.js
Dione a61e0b0457 feat: multi-wallet architecture with localStorage state, EIP-712 verification, cross-tab WS leadership
- wallets.js: WalletManager state management with chain validation
- verifier.js: WalletVerifier EIP-712 signing via window.ethereum
- stream.js: WalletStreamManager with BroadcastChannel leader election, backoff + jitter
- AGENTS.md: updated with coding guidelines
2026-06-06 12:16:41 +00:00

324 lines
9.4 KiB
JavaScript

/**
* Wallet Management Module — Anonymous localStorage-backed state.
*
* Handles lifecycle of tracked wallets: load, add, remove, verify.
* Addresses are validated per-chain. Unverified wallets can never
* slip through as verified unless a valid signature is explicitly set.
*
* Usage:
* import { WalletManager } from './wallets.js';
* const wm = new WalletManager();
* wm.loadWallets();
*/
/* ------------------------------------------------------------------ */
/* Types (JSDoc for editor support; runtime is plain JS) */
/* ------------------------------------------------------------------ */
/**
* @typedef {'base'|'ethereum'|'bitcoin'|'arbitrum'|'optimism'|'polygon'|'solana'|string} ChainId
*/
/**
* @typedef {Object} TrackedWallet
* @property {string} address
* @property {ChainId} chain
* @property {string} nickname
* @property {boolean} isVerified
* @property {string|null} signature
* @property {object|null} messageData
*/
/* ------------------------------------------------------------------ */
/* Constants */
/* ------------------------------------------------------------------ */
const LS_KEY = 'cbbtc_tracked_wallets';
/* Accepted chain identifiers */
const VALID_CHAINS = new Set([
'base', 'ethereum', 'bitcoin', 'arbitrum', 'optimism',
'polygon', 'solana', 'avalanche', 'bsc', 'fantom', 'btc',
]);
/* Regex patterns for address validation per chain */
const ADDRESS_PATTERNS = {
/* EVM-compatible — 0x-prefixed 40-hex */
base: /^(0x)?[0-9a-fA-F]{40}$/i,
ethereum: /^(0x)?[0-9a-fA-F]{40}$/i,
arbitrum: /^(0x)?[0-9a-fA-F]{40}$/i,
optimism: /^(0x)?[0-9a-fA-F]{40}$/i,
polygon: /^(0x)?[0-9a-fA-F]{40}$/i,
avalanche: /^(0x)?[0-9a-fA-F]{40}$/i,
bsc: /^(0x)?[0-9a-fA-F]{40}$/i,
fantom: /^(0x)?[0-9a-fA-F]{40}$/i,
/* Bitcoin — bech32 or legacy pubkey hash */
bitcoin: /^(bc1[a-z0-9]{25,39}|1[a-km-zA-HJ-NP-Z1-9]{25,34})$/,
btc: /^(bc1[a-z0-9]{25,39}|1[a-km-zA-HJ-NP-Z1-9]{25,34})$/,
/* Solana — base58, 32-44 chars */
solana: /^[1-9A-HJ-NP-Za-km-z]{32,44}$/,
};
/* ------------------------------------------------------------------ */
/* Validators */
/* ------------------------------------------------------------------ */
/**
* Validate an address against the chain's known format.
* Falls back to a looser check for chains without a pattern.
*
* @param {string} address
* @param {ChainId} chain
* @returns {boolean}
*/
function validateAddress(address, chain) {
if (!address || typeof address !== 'string') return false;
const pattern = ADDRESS_PATTERNS[chain];
if (pattern) {
return pattern.test(address.trim());
}
/* Fallback: non-empty, printable chars, reasonable length */
return /^[A-Za-z0-9_.+/=-]{6,100}$/.test(address.trim());
}
/* ------------------------------------------------------------------ */
/* WalletManager */
/* ------------------------------------------------------------------ */
export class WalletManager {
constructor() {
/** @type {TrackedWallet[]} */
this._wallets = [];
}
/* ---------------------------------------------------------------- */
/* Persist / Load */
/* ---------------------------------------------------------------- */
/**
* Serialize state to localStorage.
* Silently ignores write errors (private-browsing mode).
*
* @private
*/
_persist() {
try {
localStorage.setItem(LS_KEY, JSON.stringify(this._wallets));
} catch (_ignored) {
/* quotaExceeded or private-browsing — degrade gracefully */
}
}
/**
* Load wallets from localStorage.
*
* On first run returns an empty array. Restores any stored
* entries — even if the browser was closed between sessions.
*
* Invariant: every wallet read from storage has `isVerified: false`
* because a signature cannot survive an empty localStorage round-trip
* without an accompanying non-empty `signature` field, and we
* deliberately do NOT trust persisted signatures.
*
* @returns {TrackedWallet[]} loaded wallets
*/
loadWallets() {
try {
const raw = localStorage.getItem(LS_KEY);
if (!raw) {
this._wallets = [];
return [];
}
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) {
this._wallets = [];
return [];
}
/* Sanitize: enforce isVerified = false for all persisted entries.
A wallet can only be verified by calling verifyWallet(), not
by tampering with localStorage. */
this._wallets = parsed
.filter((w) => w && typeof w === 'object' && w.address && w.chain)
.map((w) => ({
address: String(w.address),
chain: String(w.chain).toLowerCase(),
nickname: w.nickname ? String(w.nickname) : '',
isVerified: false, /* SECURITY: never trust persisted flag */
signature: null, /* SECURITY: discard persisted signatures */
messageData: null,
}));
return this._wallets;
} catch (_ignored) {
this._wallets = [];
return [];
}
}
/* ---------------------------------------------------------------- */
/* CRUD */
/* ---------------------------------------------------------------- */
/**
* @returns {TrackedWallet[]} deep copy of state
*/
getWallets() {
return this._wallets.map((w) => ({ ...w }));
}
/**
* Add a new wallet. Rejects duplicates (same address+chain).
*
* @param {string} address
* @param {ChainId} chain
* @param {string} [nickname='']
* @returns {{ success: true, wallet: TrackedWallet } | { success: false, error: string }}
*/
addWallet(address, chain, nickname = '') {
const normalAddress = String(address).trim();
const normalChain = String(chain).toLowerCase();
const normalNickname = String(nickname).trim();
/* Validate chain */
if (!VALID_CHAINS.has(normalChain)) {
return { success: false, error: `Unsupported chain: ${normalChain}` };
}
/* Validate address format */
if (!validateAddress(normalAddress, normalChain)) {
return {
success: false,
error: `Invalid ${normalChain} address: ${normalAddress}`,
};
}
/* Duplicate check */
if (this._wallets.some((w) =>
w.address === normalAddress && w.chain === normalChain
)) {
return {
success: false,
error: `Wallet already tracked: ${normalAddress} (${normalChain})`,
};
}
const wallet = {
address: normalAddress,
chain: normalChain,
nickname: normalNickname,
isVerified: false,
signature: null,
messageData: null,
};
this._wallets.push(wallet);
this._persist();
return { success: true, wallet };
}
/**
* Remove a wallet by address + chain.
*
* @param {string} address
* @param {ChainId} chain
* @returns {{ success: true, removedAddress: string } | { success: false, error: string }}
*/
removeWallet(address, chain) {
const normalAddress = String(address).trim();
const normalChain = String(chain).toLowerCase();
const idx = this._wallets.findIndex(
(w) => w.address === normalAddress && w.chain === normalChain
);
if (idx === -1) {
return {
success: false,
error: `Wallet not found: ${normalAddress} (${normalChain})`,
};
}
this._wallets.splice(idx, 1);
this._persist();
return { success: true, removedAddress: normalAddress };
}
/**
* Find a wallet by address + chain.
*
* @param {string} address
* @param {ChainId} chain
* @returns {TrackedWallet | undefined}
*/
findWallet(address, chain) {
const normalAddress = String(address).trim();
const normalChain = String(chain).toLowerCase();
return this._wallets.find(
(w) => w.address === normalAddress && w.chain === normalChain
);
}
/* ---------------------------------------------------------------- */
/* Verification */
/* ---------------------------------------------------------------- */
/**
* Mark a wallet as verified after a successful signature check.
* Only works if the wallet exists in state.
*
* @param {string} address
* @param {ChainId} chain
* @param {string} signature
* @param {object} messageData
* @returns {{ success: true } | { success: false, error: string }}
*/
verifyWallet(address, chain, signature, messageData) {
const w = this.findWallet(address, chain);
if (!w) {
return {
success: false,
error: `Wallet not found: ${address} (${chain})`,
};
}
w.isVerified = true;
w.signature = String(signature);
w.messageData = messageData || null;
this._persist();
return { success: true };
}
/**
* Revoke verification — resets back to unverified.
*
* @param {string} address
* @param {ChainId} chain
* @returns {{ success: true } | { success: false, error: string }}
*/
revokeVerification(address, chain) {
const w = this.findWallet(address, chain);
if (!w) {
return {
success: false,
error: `Wallet not found: ${address} (${chain})`,
};
}
w.isVerified = false;
w.signature = null;
w.messageData = null;
this._persist();
return { success: true };
}
}
export default WalletManager;