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
This commit is contained in:
292
verifier.js
Normal file
292
verifier.js
Normal file
@ -0,0 +1,292 @@
|
||||
/**
|
||||
* Wallet Verifier — EIP-712 signature verification via window.ethereum.
|
||||
*
|
||||
* Orchestrates connection, typed-data signing, and state persistence.
|
||||
* Designed to work alongside WalletManager.
|
||||
*
|
||||
* Usage:
|
||||
* import { WalletVerifier } from './verifier.js';
|
||||
* const verifier = new WalletVerifier(walletManagerInstance);
|
||||
* await verifier.triggerWalletVerification('base', 'My Wallet');
|
||||
*/
|
||||
|
||||
import { WalletManager } from './wallets.js';
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* EIP-712 Domain */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const EIP712_DOMAIN = {
|
||||
name: 'Anonymous Wallet Tracker',
|
||||
version: '1',
|
||||
chainId: 1,
|
||||
};
|
||||
|
||||
const EIP712_TYPES = {
|
||||
VerifyTracking: [
|
||||
{ name: 'action', type: 'string' },
|
||||
{ name: 'walletAddress', type: 'address' },
|
||||
{ name: 'timestamp', type: 'uint256' },
|
||||
],
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Error Codes (EIP-1193) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/* User rejected request */
|
||||
const USER_REJECTED_CODE = 4001;
|
||||
/* User switched accounts mid-flow */
|
||||
const CHAIN_DISCONNECTED_CODE = 4900;
|
||||
|
||||
/* Human-friendly message map */
|
||||
const ERROR_MESSAGES = {
|
||||
[USER_REJECTED_CODE]: 'Wallet request rejected. Verification cancelled.',
|
||||
[CHAIN_DISCONNECTED_CODE]: 'Wallet disconnected unexpectedly.',
|
||||
NO_EXTENSION: 'No Web3 wallet extension found. Install MetaMask or similar.',
|
||||
NO_ACCOUNT: 'Could not retrieve an active wallet address.',
|
||||
SIGN_FAILED: 'Signature request failed.',
|
||||
WALLET_EXISTS: 'This wallet address is already being tracked.',
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* Check if window.ethereum is available and callable.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function hasEthereumProvider() {
|
||||
return (
|
||||
typeof window !== 'undefined' &&
|
||||
typeof window.ethereum !== 'undefined' &&
|
||||
typeof window.ethereum.request === 'function'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an EIP-1193 provider error into a user-friendly string.
|
||||
*
|
||||
* @param {Error} err
|
||||
* @returns {string}
|
||||
*/
|
||||
function providerErrorMessage(err) {
|
||||
/* MetaMask and compatible providers attach a code property */
|
||||
const code = err?.code ?? err?.cause?.code;
|
||||
if (code === USER_REJECTED_CODE || code === 4001) {
|
||||
return 'Verification cancelled — you rejected the wallet request.';
|
||||
}
|
||||
if (code === CHAIN_DISCONNECTED_CODE || code === 4900) {
|
||||
return 'Wallet connection dropped unexpectedly.';
|
||||
}
|
||||
return err?.message ?? 'An unexpected error occurred.';
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* WalletVerifier */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export class WalletVerifier {
|
||||
/**
|
||||
* @param {WalletManager} walletManager — shared state manager instance
|
||||
*/
|
||||
constructor(walletManager) {
|
||||
if (!(walletManager instanceof WalletManager)) {
|
||||
throw new TypeError('WalletVerifier requires a WalletManager instance.');
|
||||
}
|
||||
|
||||
/** @type {WalletManager} */
|
||||
this._wm = walletManager;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Public API */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Full verification flow: request account → sign EIP-712 → persist.
|
||||
*
|
||||
* @param {string} chain — chain name for storage (e.g. 'base')
|
||||
* @param {string} [nickname=''] — display nickname
|
||||
* @returns {Promise<{ success: true, wallet: object } | { success: false, error: string }>}
|
||||
*/
|
||||
async triggerWalletVerification(chain, nickname = '') {
|
||||
/* ---- Step 0: Provider check ---- */
|
||||
if (!hasEthereumProvider()) {
|
||||
return { success: false, error: ERROR_MESSAGES.NO_EXTENSION };
|
||||
}
|
||||
|
||||
let address;
|
||||
let signature;
|
||||
|
||||
try {
|
||||
/* ---- Step 1: Request active account ---- */
|
||||
address = await this._requestAccount();
|
||||
|
||||
/* ---- Step 2: Build EIP-712 payload ---- */
|
||||
const typedData = this._buildTypedData(address);
|
||||
|
||||
/* ---- Step 3: Request signature ---- */
|
||||
signature = await this._requestSignature(address, typedData);
|
||||
|
||||
} catch (err) {
|
||||
return { success: false, error: providerErrorMessage(err) };
|
||||
}
|
||||
|
||||
/* ---- Step 4: Store in WalletManager ---- */
|
||||
const normalChain = String(chain).toLowerCase();
|
||||
const existing = this._wm.findWallet(address, normalChain);
|
||||
|
||||
if (existing) {
|
||||
/* Already tracked — just verify if not yet verified */
|
||||
if (!existing.isVerified) {
|
||||
const verifyResult = this._wm.verifyWallet(
|
||||
address,
|
||||
normalChain,
|
||||
signature,
|
||||
this._buildMessageData(address)
|
||||
);
|
||||
if (!verifyResult.success) {
|
||||
return { success: false, error: verifyResult.error };
|
||||
}
|
||||
return { success: true, wallet: this._wm.findWallet(address, normalChain) };
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: 'This wallet is already verified and being tracked.',
|
||||
};
|
||||
}
|
||||
|
||||
/* New wallet — add then verify */
|
||||
const addResult = this._wm.addWallet(address, normalChain, nickname);
|
||||
if (!addResult.success) {
|
||||
return { success: false, error: addResult.error };
|
||||
}
|
||||
|
||||
const verifyResult = this._wm.verifyWallet(
|
||||
address,
|
||||
normalChain,
|
||||
signature,
|
||||
this._buildMessageData(address)
|
||||
);
|
||||
if (!verifyResult.success) {
|
||||
return { success: false, error: verifyResult.error };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
wallet: this._wm.findWallet(address, normalChain),
|
||||
};
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Internal Steps */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Request `eth_requestAccounts`. Throws on rejection.
|
||||
* @returns {Promise<string>} The connected address.
|
||||
*/
|
||||
async _requestAccount() {
|
||||
const accounts = await window.ethereum.request({
|
||||
method: 'eth_requestAccounts',
|
||||
});
|
||||
|
||||
if (!accounts || accounts.length === 0) {
|
||||
throw new Error(ERROR_MESSAGES.NO_ACCOUNT);
|
||||
}
|
||||
|
||||
return accounts[0].toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the EIP-712 typed data payload.
|
||||
*
|
||||
* @param {string} address
|
||||
* @returns {object} Full typedData object (domain + types + message)
|
||||
*/
|
||||
_buildTypedData(address) {
|
||||
const message = this._buildMessageData(address);
|
||||
return {
|
||||
domain: EIP712_DOMAIN,
|
||||
types: {
|
||||
EIP712Domain: [
|
||||
{ name: 'name', type: 'string' },
|
||||
{ name: 'version', type: 'string' },
|
||||
{ name: 'chainId', type: 'uint256' },
|
||||
],
|
||||
VerifyTracking: EIP712_TYPES.VerifyTracking,
|
||||
},
|
||||
primaryType: 'VerifyTracking',
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the message portion only (for storage).
|
||||
*
|
||||
* @param {string} address
|
||||
* @returns {object}
|
||||
*/
|
||||
_buildMessageData(address) {
|
||||
return {
|
||||
action: 'Verify Ownership for Live Tracking',
|
||||
walletAddress: address,
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Request `eth_signTypedData_v4`. Throws on rejection.
|
||||
*
|
||||
* @param {string} address
|
||||
* @param {object} typedData
|
||||
* @returns {Promise<string>} The signature hex string.
|
||||
*/
|
||||
async _requestSignature(address, typedData) {
|
||||
try {
|
||||
const sig = await window.ethereum.request({
|
||||
method: 'eth_signTypedData_v4',
|
||||
params: [address, JSON.stringify(typedData)],
|
||||
});
|
||||
|
||||
if (!sig || typeof sig !== 'string') {
|
||||
throw new Error(ERROR_MESSAGES.SIGN_FAILED);
|
||||
}
|
||||
|
||||
return sig;
|
||||
} catch (err) {
|
||||
/* Re-throw EIP-1193 code errors for upstream handling */
|
||||
if (err?.code === USER_REJECTED_CODE) {
|
||||
throw new Error('User rejected the signature request.');
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Quick connect (no signature) */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Minimal connection check — only requests accounts, does NOT sign.
|
||||
* Useful for pre-flight checks or auto-connect on page load.
|
||||
*
|
||||
* @returns {Promise<string|null>} Connected address, or null on rejection.
|
||||
*/
|
||||
async connect() {
|
||||
if (!hasEthereumProvider()) return null;
|
||||
try {
|
||||
const accounts = await window.ethereum.request({
|
||||
method: 'eth_requestAccounts',
|
||||
});
|
||||
return accounts?.[0]?.toLowerCase() ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default WalletVerifier;
|
||||
Reference in New Issue
Block a user