@agentauth/mcp
Version:
Universal payment-enabled MCP gateway for AI agents with native x402 protocol support.
424 lines (423 loc) • 16.9 kB
JavaScript
import { ethers } from 'ethers';
import { debugLog } from '../lib/utils.js';
const CHAIN_CONFIGS = {
8453: {
chainId: 8453,
rpcUrls: [
'https://mainnet.base.org',
'https://base.publicnode.com'
],
usdcAddress: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913'
},
84532: {
chainId: 84532,
rpcUrls: [
'https://sepolia.base.org',
'https://base-sepolia.publicnode.com'
],
usdcAddress: '0x036CbD53842c5426634e7929541eC2318f3dCF7e'
}
};
/**
* Core wallet service for AgentAuth MCP Gateway
* Handles address derivation, balance checking, transaction signing and submission
*/
export class WalletService {
currentProvider;
wallet;
currentChainId;
baseWallet; // Wallet without provider for re-connection
constructor(agentAuthToken, rpcUrl, chainId = 8453) {
// Derive private key from AgentAuth token (same as existing AgentAuth flow)
const privateKey = this.derivePrivateKey(agentAuthToken);
this.baseWallet = new ethers.Wallet(privateKey);
// For backward compatibility, initialize with default chain if provided
this.currentProvider = null;
this.currentChainId = null;
this.wallet = this.baseWallet;
// If constructor parameters provided, configure immediately (legacy support)
if (rpcUrl || chainId !== 8453) {
const provider = new ethers.JsonRpcProvider(rpcUrl || process.env.BASE_RPC_URL || 'https://mainnet.base.org');
this.currentProvider = provider;
this.wallet = this.baseWallet.connect(provider);
this.currentChainId = chainId;
debugLog('WalletService initialized with legacy parameters:', { chainId, rpcUrl });
}
else {
// New zero-config mode - will be configured dynamically
debugLog('WalletService initialized in zero-config mode');
}
}
/**
* Derive private key from AgentAuth token
* Handles aa-, 0x, and raw hex formats
*/
derivePrivateKey(agentAuthToken) {
const cleanToken = agentAuthToken.startsWith('aa-')
? agentAuthToken.slice(3)
: agentAuthToken;
const privateKey = cleanToken.startsWith('0x')
? cleanToken
: '0x' + cleanToken;
// Validate private key format
if (!/^0x[0-9a-fA-F]{64}$/.test(privateKey)) {
throw new Error('Invalid private key format');
}
return privateKey;
}
/**
* Get the wallet's Ethereum address
*/
getAddress() {
return this.wallet.address;
}
/**
* Configure wallet for specific chain
* Enables zero-configuration setup based on payment requirements
*/
configureForChain(chainId) {
if (this.currentChainId === chainId) {
debugLog(`Already configured for chain ${chainId}`);
return; // Already configured
}
const chainConfig = CHAIN_CONFIGS[chainId];
if (!chainConfig) {
const supportedChains = Object.keys(CHAIN_CONFIGS).join(', ');
throw new Error(`Unsupported chain ID: ${chainId}. Supported chains: ${supportedChains}`);
}
try {
// Try primary RPC first, with fallback support
const rpcUrl = process.env.BASE_RPC_URL ||
process.env.BASE_SEPOLIA_RPC_URL ||
chainConfig.rpcUrls[0];
this.currentProvider = new ethers.JsonRpcProvider(rpcUrl);
this.wallet = this.baseWallet.connect(this.currentProvider);
this.currentChainId = chainId;
debugLog(`Configured wallet for chain ${chainId}:`, {
chainId,
rpcUrl,
usdcAddress: chainConfig.usdcAddress
});
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
throw new Error(`Failed to configure chain ${chainId}: ${errorMessage}`);
}
}
/**
* Get ETH balance in ETH units
*/
async getEthBalance() {
if (!this.currentProvider) {
throw new Error('Chain not configured. Call configureForChain() first.');
}
try {
const balance = await this.currentProvider.getBalance(this.wallet.address);
return ethers.formatEther(balance);
}
catch (error) {
throw new Error(`Failed to get ETH balance: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Get ERC-20 token balance
* @param tokenAddress Contract address of the token
* @param decimals Token decimals (default 18)
*/
async getTokenBalance(tokenAddress, decimals = 18) {
if (!this.currentProvider) {
throw new Error('Chain not configured. Call configureForChain() first.');
}
try {
const tokenContract = new ethers.Contract(tokenAddress, ['function balanceOf(address) view returns (uint256)'], this.currentProvider);
const balance = await tokenContract.balanceOf(this.wallet.address);
return ethers.formatUnits(balance, decimals);
}
catch (error) {
throw new Error(`Failed to get token balance: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Get USDC balance (6 decimals)
* Uses current chain's USDC contract address
*/
async getUsdcBalance() {
if (!this.currentProvider || !this.currentChainId) {
throw new Error('Chain not configured. Call configureForChain() first.');
}
const usdcAddress = this.getUsdcContractAddress();
return this.getTokenBalance(usdcAddress, 6);
}
/**
* Get USDC contract address for current chain
*/
getUsdcContractAddress() {
if (!this.currentChainId) {
throw new Error('Chain not configured. Call configureForChain() first.');
}
const chainConfig = CHAIN_CONFIGS[this.currentChainId];
if (!chainConfig) {
throw new Error(`No USDC contract configured for chain ${this.currentChainId}`);
}
return chainConfig.usdcAddress;
}
/**
* Get current chain ID
*/
getCurrentChainId() {
return this.currentChainId;
}
/**
* Check if wallet is configured for any chain
*/
isConfigured() {
return this.currentProvider !== null && this.currentChainId !== null;
}
/**
* Estimate gas for a transaction
*/
async estimateGas(txRequest) {
if (!this.currentProvider) {
throw new Error('Chain not configured. Call configureForChain() first.');
}
try {
const gasLimit = await this.currentProvider.estimateGas({
...txRequest,
from: this.wallet.address
});
return gasLimit.toString();
}
catch (error) {
throw new Error(`Failed to estimate gas: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Get current gas price (returns in wei as bigint)
*/
async getGasPrice() {
if (!this.currentProvider) {
throw new Error('Chain not configured. Call configureForChain() first.');
}
try {
const feeData = await this.currentProvider.getFeeData();
return feeData.gasPrice || ethers.parseUnits('20', 'gwei');
}
catch (error) {
throw new Error(`Failed to get gas price: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Get current transaction count (nonce)
*/
async getTransactionCount() {
if (!this.currentProvider) {
throw new Error('Chain not configured. Call configureForChain() first.');
}
try {
return await this.currentProvider.getTransactionCount(this.wallet.address, 'pending');
}
catch (error) {
throw new Error(`Failed to get transaction count: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Get wallet balances (ETH and USDC)
*/
async getWalletBalances() {
try {
const [ethBalance, usdcBalance] = await Promise.all([
this.getEthBalance(),
this.getUsdcBalance()
]);
return {
address: this.wallet.address,
eth: ethBalance,
usdc: usdcBalance
};
}
catch (error) {
throw new Error(`Failed to get wallet balances: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Calculate estimated transaction cost in ETH
*/
async estimateTxCost(txRequest) {
if (!this.currentProvider) {
throw new Error('Chain not configured. Call configureForChain() first.');
}
try {
const gasLimit = await this.estimateGas(txRequest);
const feeData = await this.currentProvider.getFeeData();
const gasPrice = feeData.gasPrice || ethers.parseUnits('1', 'gwei');
const cost = BigInt(gasLimit) * gasPrice;
return ethers.formatEther(cost);
}
catch (error) {
throw new Error(`Failed to estimate transaction cost: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Sign a transaction
*/
async signTransaction(txRequest) {
try {
if (!this.currentProvider || !this.currentChainId) {
throw new Error('Chain not configured. Call configureForChain() first.');
}
// Get current nonce if not provided
const nonce = txRequest.nonce ?? await this.currentProvider.getTransactionCount(this.wallet.address, 'pending');
// Get current gas prices from network
const feeData = await this.currentProvider.getFeeData();
debugLog('Current network fee data:', feeData);
// Ensure chainId, nonce, and gas pricing are set
const fullTxRequest = {
...txRequest,
chainId: this.currentChainId,
nonce: nonce,
// Use EIP-1559 pricing if available, otherwise legacy gas price
maxFeePerGas: feeData.maxFeePerGas || feeData.gasPrice,
maxPriorityFeePerGas: feeData.maxPriorityFeePerGas || ethers.parseUnits('2', 'gwei'), // 2 gwei priority
gasPrice: feeData.gasPrice // Fallback for legacy transactions
};
debugLog('Final transaction request:', fullTxRequest);
const signedTx = await this.wallet.signTransaction(fullTxRequest);
return signedTx;
}
catch (error) {
throw new Error(`Failed to sign transaction: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Submit a signed transaction to the blockchain
*/
async submitTransaction(signedTx) {
if (!this.currentProvider) {
throw new Error('Chain not configured. Call configureForChain() first.');
}
try {
const txResponse = await this.currentProvider.broadcastTransaction(signedTx);
return txResponse.hash;
}
catch (error) {
throw new Error(`Failed to submit transaction: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Sign and submit a transaction in one step
*/
async signAndSubmitTransaction(txRequest) {
const signedTx = await this.signTransaction(txRequest);
return this.submitTransaction(signedTx);
}
/**
* Sign EIP-712 typed data (for x402 exact scheme EIP-3009 authorization)
*/
async signTypedData(domain, types, message) {
try {
// ethers v6 uses signTypedData with domain, types, and value
const signature = await this.wallet.signTypedData(domain, types, message);
debugLog('EIP-712 signature created');
return signature;
}
catch (error) {
throw new Error(`Failed to sign typed data: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Wait for transaction confirmation
*/
async waitForTransaction(txHash, confirmations = 1) {
if (!this.currentProvider) {
throw new Error('Chain not configured. Call configureForChain() first.');
}
try {
return await this.currentProvider.waitForTransaction(txHash, confirmations);
}
catch (error) {
throw new Error(`Failed to wait for transaction: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Get transaction receipt
*/
async getTransactionReceipt(txHash) {
if (!this.currentProvider) {
throw new Error('Chain not configured. Call configureForChain() first.');
}
try {
return await this.currentProvider.getTransactionReceipt(txHash);
}
catch (error) {
throw new Error(`Failed to get transaction receipt: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Validate that the wallet has sufficient balance for a transaction
*/
async validateSufficientBalance(txRequest) {
try {
const ethBalance = await this.getEthBalance();
const usdcBalance = await this.getUsdcBalance();
// Check if this is a USDC transfer by looking at the contract address and data
const isUsdcTransfer = txRequest.to?.toString().toLowerCase() === this.getUsdcContractAddress().toLowerCase() &&
txRequest.data?.startsWith('0xa9059cbb'); // transfer(address,uint256)
if (isUsdcTransfer && txRequest.data) {
// Decode the USDC transfer amount from the transaction data
try {
const abiCoder = ethers.AbiCoder.defaultAbiCoder();
const decoded = abiCoder.decode(['address', 'uint256'], '0x' + txRequest.data.slice(10));
const usdcAmount = ethers.formatUnits(decoded[1], 6); // USDC has 6 decimals
const usdcBalance_num = parseFloat(usdcBalance);
const requiredUsdc_num = parseFloat(usdcAmount);
if (usdcBalance_num < requiredUsdc_num) {
return {
sufficient: false,
currentBalance: usdcBalance,
required: usdcAmount,
error: 'insufficient_usdc',
details: {
type: 'USDC',
currentBalance: `${usdcBalance} USDC`,
required: `${usdcAmount} USDC`,
shortfall: `${(requiredUsdc_num - usdcBalance_num).toFixed(6)} USDC`
}
};
}
}
catch (decodeError) {
debugLog('Could not decode USDC transfer data:', decodeError);
// Fall through to ETH balance check
}
}
// Check ETH balance for gas fees
const txCost = await this.estimateTxCost(txRequest);
const currentBalanceWei = ethers.parseEther(ethBalance);
const requiredWei = ethers.parseEther(txCost);
// Add the transaction value if it's an ETH transfer
const totalRequired = txRequest.value ? requiredWei + BigInt(txRequest.value.toString()) : requiredWei;
if (currentBalanceWei < totalRequired) {
return {
sufficient: false,
currentBalance: ethBalance,
required: ethers.formatEther(totalRequired),
error: 'insufficient_eth',
details: {
type: 'ETH',
currentBalance: `${ethBalance} ETH`,
required: `${ethers.formatEther(totalRequired)} ETH`,
purpose: 'gas fees'
}
};
}
return {
sufficient: true,
currentBalance: ethBalance,
required: ethers.formatEther(totalRequired)
};
}
catch (error) {
throw new Error(`Failed to validate balance: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
}