Files
dione/verifier.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

293 lines
8.8 KiB
JavaScript

/**
* 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;