387 lines
12 KiB
JavaScript
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;
|