Files
dione/wallets.js

387 lines
12 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 = 'tracked_wallets';
/* Accepted chain identifiers */
const VALID_CHAINS = new Set([
'base', 'ethereum', 'bitcoin', 'arbitrum', 'optimism',
'polygon', 'solana', 'avalanche', 'bsc', 'fantom', 'btc',
'hyperliquide',
]);
/* 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,
hyperliquide: /^(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. Filters out any
* unverified wallets — they never survive a page reload.
* Only verified wallets (or non-verification-chain wallets that
* are always implicitly verified) are retained.
*
* @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 [];
}
/* Restore only verified wallets. Unverified wallets are discarded
immediately — they were added but the user didn't complete the
signature flow in time. */
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) : '',
lendingPlatform: w.lendingPlatform ? String(w.lendingPlatform).toLowerCase() : 'aave',
isVerified: !!(w.isVerified && w.messageData),
signature: w.signature ? String(w.signature) : null,
messageData: w.messageData ? w.messageData : null,
}))
.filter((w) => {
/* Non-verification chains (btc, bitcoin, solana) are always kept */
if (['btc', 'bitcoin', 'solana'].includes(w.chain)) return true;
/* EVM chains: only keep if verified */
return w.isVerified;
});
/* Persist the trimmed list so discarded unverified wallets don't
reappear on subsequent reloads. */
this._persist();
return this._wallets;
} catch (_ignored) {
this._wallets = [];
return [];
}
}
/* ---------------------------------------------------------------- */
/* CRUD */
/* ---------------------------------------------------------------- */
/**
* @returns {TrackedWallet[]} deep copy of state
*/
getWallets() {
return this._wallets.map((w) => ({ ...w }));
}
/**
* Return wallets that are either verified, or on a non-verification-required chain.
* Non-verification chains (btc, solana, etc.) are always included.
* EVM-only chains require isVerified === true to be returned.
*
* @returns {TrackedWallet[]}
*/
getVerifiedWallets() {
return this._wallets.filter((w) => {
if (['btc', 'bitcoin', 'solana'].includes(w.chain)) return true;
return w.isVerified;
}).map((w) => ({ ...w }));
}
/**
* Add a new wallet. Rejects duplicates (same address+chain).
*
* @param {string} address
* @param {ChainId} chain
* @param {string} [nickname='']
* @param {string} [lendingPlatform='aave']
* @returns {{ success: true, wallet: TrackedWallet } | { success: false, error: string }}
*/
addWallet(address, chain, nickname = '', lendingPlatform = 'aave') {
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,
lendingPlatform: String(lendingPlatform).toLowerCase(),
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
);
}
/**
* Rename a wallet's nickname.
*
* @param {string} address
* @param {ChainId} chain
* @param {string} nickname
* @returns {{ success: true } | { success: false, error: string }}
*/
renameWallet(address, chain, nickname) {
const w = this.findWallet(address, chain);
if (!w) {
return { success: false, error: 'Wallet not found' };
}
w.nickname = String(nickname).trim();
this._persist();
return { success: true };
}
/* ---------------------------------------------------------------- */
/* 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 };
}
/**
* Reorder wallets to match the given address sequence.
* Addresses listed first are moved to front; remaining keep their original relative order.
* Only recognized addresses are kept in the new order.
* @param {string[]} addresses
*/
setWalletOrder(addresses) {
const addrSet = this._wallets.map(w => w.address);
const ordered = [];
for (const addr of addresses) {
if (addrSet.includes(addr)) {
const w = this._wallets.find(x => x.address === addr);
if (w) ordered.push(w);
}
}
const remaining = this._wallets.filter(w => !ordered.includes(w));
this._wallets = [...ordered, ...remaining];
this._persist();
}
}
export default WalletManager;