Implemented full wallet functionality for Arbitrum:
- Balance Checking: Real-time balance fetching for ETH, WETH, USDC, and USDT.
- Transaction History: Robust fetching via Moralis API with support for pagination and filtering.
- Transaction Details: Detailed view for individual transactions, including status, hash, and value.
- Sending Functionality: Secure ETH and token transfers using EIP-1559 transactions with dynamic fee estimation.
- Key Management: Derivation of Arbitrum addresses and FLO IDs from private keys (WIF/Hex).
- Navigation Enhancements:
- Clickable addresses in history and details for seamless navigation.
557 lines
17 KiB
JavaScript
557 lines
17 KiB
JavaScript
(function (EXPORTS) { // ethOperator v1.0.2
|
|
/* ETH Crypto and API Operator */
|
|
if (!window.ethers)
|
|
return console.error('ethers.js not found')
|
|
const ethOperator = EXPORTS;
|
|
const isValidAddress = ethOperator.isValidAddress = (address) => {
|
|
try {
|
|
// Check if the address is a valid checksum address
|
|
const isValidChecksum = ethers.utils.isAddress(address);
|
|
// Check if the address is a valid non-checksum address
|
|
const isValidNonChecksum = ethers.utils.getAddress(address) === address.toLowerCase();
|
|
return isValidChecksum || isValidNonChecksum;
|
|
} catch (error) {
|
|
return false;
|
|
}
|
|
}
|
|
const ERC20ABI = [
|
|
{
|
|
"constant": true,
|
|
"inputs": [],
|
|
"name": "name",
|
|
"outputs": [
|
|
{
|
|
"name": "",
|
|
"type": "string"
|
|
}
|
|
],
|
|
"payable": false,
|
|
"stateMutability": "view",
|
|
"type": "function"
|
|
},
|
|
{
|
|
"constant": false,
|
|
"inputs": [
|
|
{
|
|
"name": "_spender",
|
|
"type": "address"
|
|
},
|
|
{
|
|
"name": "_value",
|
|
"type": "uint256"
|
|
}
|
|
],
|
|
"name": "approve",
|
|
"outputs": [
|
|
{
|
|
"name": "",
|
|
"type": "bool"
|
|
}
|
|
],
|
|
"payable": false,
|
|
"stateMutability": "nonpayable",
|
|
"type": "function"
|
|
},
|
|
{
|
|
"constant": true,
|
|
"inputs": [],
|
|
"name": "totalSupply",
|
|
"outputs": [
|
|
{
|
|
"name": "",
|
|
"type": "uint256"
|
|
}
|
|
],
|
|
"payable": false,
|
|
"stateMutability": "view",
|
|
"type": "function"
|
|
},
|
|
{
|
|
"constant": false,
|
|
"inputs": [
|
|
{
|
|
"name": "_from",
|
|
"type": "address"
|
|
},
|
|
{
|
|
"name": "_to",
|
|
"type": "address"
|
|
},
|
|
{
|
|
"name": "_value",
|
|
"type": "uint256"
|
|
}
|
|
],
|
|
"name": "transferFrom",
|
|
"outputs": [
|
|
{
|
|
"name": "",
|
|
"type": "bool"
|
|
}
|
|
],
|
|
"payable": false,
|
|
"stateMutability": "nonpayable",
|
|
"type": "function"
|
|
},
|
|
{
|
|
"constant": true,
|
|
"inputs": [],
|
|
"name": "decimals",
|
|
"outputs": [
|
|
{
|
|
"name": "",
|
|
"type": "uint8"
|
|
}
|
|
],
|
|
"payable": false,
|
|
"stateMutability": "view",
|
|
"type": "function"
|
|
},
|
|
{
|
|
"constant": true,
|
|
"inputs": [
|
|
{
|
|
"name": "_owner",
|
|
"type": "address"
|
|
}
|
|
],
|
|
"name": "balanceOf",
|
|
"outputs": [
|
|
{
|
|
"name": "balance",
|
|
"type": "uint256"
|
|
}
|
|
],
|
|
"payable": false,
|
|
"stateMutability": "view",
|
|
"type": "function"
|
|
},
|
|
{
|
|
"constant": true,
|
|
"inputs": [],
|
|
"name": "symbol",
|
|
"outputs": [
|
|
{
|
|
"name": "",
|
|
"type": "string"
|
|
}
|
|
],
|
|
"payable": false,
|
|
"stateMutability": "view",
|
|
"type": "function"
|
|
},
|
|
{
|
|
"constant": false,
|
|
"inputs": [
|
|
{
|
|
"name": "_to",
|
|
"type": "address"
|
|
},
|
|
{
|
|
"name": "_value",
|
|
"type": "uint256"
|
|
}
|
|
],
|
|
"name": "transfer",
|
|
"outputs": [
|
|
{
|
|
"name": "",
|
|
"type": "bool"
|
|
}
|
|
],
|
|
"payable": false,
|
|
"stateMutability": "nonpayable",
|
|
"type": "function"
|
|
},
|
|
{
|
|
"constant": true,
|
|
"inputs": [
|
|
{
|
|
"name": "_owner",
|
|
"type": "address"
|
|
},
|
|
{
|
|
"name": "_spender",
|
|
"type": "address"
|
|
}
|
|
],
|
|
"name": "allowance",
|
|
"outputs": [
|
|
{
|
|
"name": "",
|
|
"type": "uint256"
|
|
}
|
|
],
|
|
"payable": false,
|
|
"stateMutability": "view",
|
|
"type": "function"
|
|
},
|
|
{
|
|
"payable": true,
|
|
"stateMutability": "payable",
|
|
"type": "fallback"
|
|
},
|
|
{
|
|
"anonymous": false,
|
|
"inputs": [
|
|
{
|
|
"indexed": true,
|
|
"name": "owner",
|
|
"type": "address"
|
|
},
|
|
{
|
|
"indexed": true,
|
|
"name": "spender",
|
|
"type": "address"
|
|
},
|
|
{
|
|
"indexed": false,
|
|
"name": "value",
|
|
"type": "uint256"
|
|
}
|
|
],
|
|
"name": "Approval",
|
|
"type": "event"
|
|
},
|
|
{
|
|
"anonymous": false,
|
|
"inputs": [
|
|
{
|
|
"indexed": true,
|
|
"name": "from",
|
|
"type": "address"
|
|
},
|
|
{
|
|
"indexed": true,
|
|
"name": "to",
|
|
"type": "address"
|
|
},
|
|
{
|
|
"indexed": false,
|
|
"name": "value",
|
|
"type": "uint256"
|
|
}
|
|
],
|
|
"name": "Transfer",
|
|
"type": "event"
|
|
}
|
|
]
|
|
const CONTRACT_ADDRESSES = {
|
|
// Arbitrum One network token addresses
|
|
usdc: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", // USDC on Arbitrum
|
|
usdt: "0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9", // USDT on Arbitrum
|
|
weth: "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1" // Wrapped ETH on Arbitrum
|
|
}
|
|
/**
|
|
* Get Arbitrum provider (MetaMask or public RPC)
|
|
* @param {boolean} readOnly - If true, use public RPC; if false, use MetaMask when available
|
|
* @returns {ethers.providers.Provider} Arbitrum provider instance
|
|
*/
|
|
const getProvider = ethOperator.getProvider = (readOnly = false) => {
|
|
if (!readOnly && window.ethereum) {
|
|
return new ethers.providers.Web3Provider(window.ethereum);
|
|
} else {
|
|
return new ethers.providers.JsonRpcProvider(`https://arb1.arbitrum.io/rpc`)
|
|
}
|
|
}
|
|
// Note: MetaMask connection is handled in the UI layer, not here
|
|
const getBalance = ethOperator.getBalance = async (address) => {
|
|
try {
|
|
if (!address || !isValidAddress(address))
|
|
return new Error('Invalid address');
|
|
|
|
// Use read-only provider (public RPC) for balance checks
|
|
const provider = getProvider(true);
|
|
const balanceWei = await provider.getBalance(address);
|
|
const balanceEth = parseFloat(ethers.utils.formatEther(balanceWei));
|
|
return balanceEth;
|
|
} catch (error) {
|
|
console.error('Balance error:', error.message);
|
|
return 0;
|
|
}
|
|
}
|
|
const getTokenBalance = ethOperator.getTokenBalance = async (address, token, { contractAddress } = {}) => {
|
|
try {
|
|
if (!token)
|
|
return new Error("Token not specified");
|
|
if (!CONTRACT_ADDRESSES[token] && !contractAddress)
|
|
return new Error('Contract address of token not available')
|
|
|
|
// Use read-only provider (public RPC) for token balance checks
|
|
const provider = getProvider(true);
|
|
const tokenAddress = CONTRACT_ADDRESSES[token] || contractAddress;
|
|
const tokenContract = new ethers.Contract(tokenAddress, ERC20ABI, provider);
|
|
let balance = await tokenContract.balanceOf(address);
|
|
|
|
// WETH uses 18 decimals (like native ETH), USDC and USDT use 6 decimals
|
|
const decimals = token === 'weth' ? 18 : 6;
|
|
balance = parseFloat(ethers.utils.formatUnits(balance, decimals));
|
|
return balance;
|
|
} catch (e) {
|
|
console.error('Token balance error:', e);
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
const estimateGas = ethOperator.estimateGas = async ({ privateKey, receiver, amount }) => {
|
|
try {
|
|
const provider = getProvider();
|
|
const signer = new ethers.Wallet(privateKey, provider);
|
|
return provider.estimateGas({
|
|
from: signer.address,
|
|
to: receiver,
|
|
value: ethers.utils.parseUnits(amount, "ether"),
|
|
});
|
|
} catch (e) {
|
|
throw new Error(e)
|
|
}
|
|
}
|
|
|
|
const sendTransaction = ethOperator.sendTransaction = async ({ privateKey, receiver, amount }) => {
|
|
try {
|
|
const provider = getProvider();
|
|
const signer = new ethers.Wallet(privateKey, provider);
|
|
const limit = await estimateGas({ privateKey, receiver, amount })
|
|
|
|
// Get current fee data from the network
|
|
const feeData = await provider.getFeeData();
|
|
|
|
// Calculate priority fee (tip to miners) - use 1.5 gwei or the network's suggested priority fee, whichever is higher
|
|
const priorityFee = feeData.maxPriorityFeePerGas || ethers.utils.parseUnits("1.5", "gwei");
|
|
|
|
// Calculate max fee per gas (base fee + priority fee)
|
|
// Use the network's suggested maxFeePerGas or calculate it manually
|
|
let maxFee = feeData.maxFeePerGas;
|
|
|
|
// If maxFeePerGas is not available or is less than priority fee, calculate it
|
|
if (!maxFee || maxFee.lt(priorityFee)) {
|
|
// Get the base fee from the latest block and add our priority fee
|
|
const block = await provider.getBlock("latest");
|
|
const baseFee = block.baseFeePerGas || ethers.utils.parseUnits("1", "gwei");
|
|
// maxFee = (baseFee * 2) + priorityFee to account for potential base fee increases
|
|
maxFee = baseFee.mul(2).add(priorityFee);
|
|
}
|
|
|
|
// Ensure maxFee is at least 1.5x the priority fee for safety
|
|
const minMaxFee = priorityFee.mul(15).div(10); // 1.5x priority fee
|
|
if (maxFee.lt(minMaxFee)) {
|
|
maxFee = minMaxFee;
|
|
}
|
|
|
|
// Creating and sending the transaction object
|
|
return signer.sendTransaction({
|
|
to: receiver,
|
|
value: ethers.utils.parseUnits(amount, "ether"),
|
|
gasLimit: limit,
|
|
nonce: await signer.getTransactionCount(),
|
|
maxPriorityFeePerGas: priorityFee,
|
|
maxFeePerGas: maxFee,
|
|
})
|
|
} catch (e) {
|
|
throw new Error(e)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Send ERC20 tokens (USDC, USDT, or WETH)
|
|
* @param {object} params - Transaction parameters
|
|
* @param {string} params.token - Token symbol ('usdc', 'usdt', or 'weth')
|
|
* @param {string} params.privateKey - Sender's private key
|
|
* @param {string} params.amount - Amount to send
|
|
* @param {string} params.receiver - Recipient's Arbitrum address
|
|
* @param {string} params.contractAddress - Optional custom contract address
|
|
* @returns {Promise} Transaction promise
|
|
*/
|
|
const sendToken = ethOperator.sendToken = async ({ token, privateKey, amount, receiver, contractAddress }) => {
|
|
const wallet = new ethers.Wallet(privateKey, getProvider());
|
|
const tokenContract = new ethers.Contract(CONTRACT_ADDRESSES[token] || contractAddress, ERC20ABI, wallet);
|
|
// Convert amount to smallest unit: WETH uses 18 decimals, USDC and USDT use 6 decimals
|
|
const decimals = token === 'weth' ? 18 : 6;
|
|
const amountWei = ethers.utils.parseUnits(amount.toString(), decimals);
|
|
return tokenContract.transfer(receiver, amountWei)
|
|
}
|
|
|
|
|
|
const MORALIS_API_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJub25jZSI6IjNmMjE5NjM5LTQwYmYtNDhkMC1hNDMxLTI5YjA4YzhlYzE5MiIsIm9yZ0lkIjoiNDkwNTU1IiwidXNlcklkIjoiNTA0NzE5IiwidHlwZUlkIjoiYWNiMjQzOWUtMDEzYy00YjhjLWI2N2MtNjRlNGNhMjA4YTlkIiwidHlwZSI6IlBST0pFQ1QiLCJpYXQiOjE3Njg1MDcyNTIsImV4cCI6NDkyNDI2NzI1Mn0.X4Hn3VxLVRJL6HlAGPFQdWvQAdTXO20_Z8CpWhNt5CE';
|
|
|
|
/**
|
|
* Get transaction history for an Arbitrum address using Moralis API
|
|
* @param {string} address - Arbitrum address
|
|
* @param {object} options - Optional parameters
|
|
* @returns {Promise<Array>} Array of transactions
|
|
*/
|
|
const getTransactionHistory = ethOperator.getTransactionHistory = async (address, options = {}) => {
|
|
try {
|
|
if (!address || !isValidAddress(address)) {
|
|
throw new Error('Invalid Arbitrum address');
|
|
}
|
|
|
|
const {
|
|
page = 1,
|
|
offset = 100,
|
|
} = options;
|
|
|
|
// Moralis API endpoint for Arbitrum
|
|
const chain = '0xa4b1'; // Arbitrum One chain ID in hex
|
|
|
|
// Fetch transactions using Moralis API
|
|
const moralisUrl = `https://deep-index.moralis.io/api/v2.2/${address}?chain=${chain}&limit=${offset}`;
|
|
|
|
const response = await fetch(moralisUrl, {
|
|
headers: {
|
|
'Accept': 'application/json',
|
|
'X-API-Key': MORALIS_API_KEY
|
|
}
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorData = await response.json().catch(() => ({}));
|
|
throw new Error(`Moralis API Error: ${errorData.message || response.statusText}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
|
|
if (!data.result || data.result.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
// Parse and format transactions from Moralis response
|
|
return data.result.map(tx => {
|
|
const isReceived = tx.to_address && tx.to_address.toLowerCase() === address.toLowerCase();
|
|
const value = parseFloat(ethers.utils.formatEther(tx.value || '0'));
|
|
|
|
return {
|
|
hash: tx.hash,
|
|
from: tx.from_address,
|
|
to: tx.to_address,
|
|
value: value,
|
|
symbol: 'ETH',
|
|
timestamp: new Date(tx.block_timestamp).getTime() / 1000,
|
|
blockNumber: parseInt(tx.block_number),
|
|
isReceived: isReceived,
|
|
isSent: !isReceived,
|
|
gasUsed: tx.receipt_gas_used ? parseInt(tx.receipt_gas_used) : 0,
|
|
gasPrice: tx.gas_price ? parseFloat(ethers.utils.formatUnits(tx.gas_price, 'gwei')) : 0,
|
|
isError: tx.receipt_status === '0',
|
|
contractAddress: tx.to_address && tx.input !== '0x' ? tx.to_address : null,
|
|
tokenName: null,
|
|
confirmations: 0,
|
|
nonce: tx.nonce ? parseInt(tx.nonce) : 0,
|
|
input: tx.input || '0x',
|
|
isTokenTransfer: false
|
|
};
|
|
});
|
|
|
|
} catch (error) {
|
|
console.error('Error fetching transaction history:', error);
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Get detailed information about a specific transaction
|
|
* @param {string} txHash - Transaction hash
|
|
* @returns {Promise<Object>} Transaction details
|
|
*/
|
|
const getTransactionDetails = ethOperator.getTransactionDetails = async (txHash) => {
|
|
try {
|
|
if (!txHash || !/^0x([A-Fa-f0-9]{64})$/.test(txHash)) {
|
|
throw new Error('Invalid transaction hash');
|
|
}
|
|
|
|
// Use read-only provider for fetching transaction details
|
|
const provider = getProvider(true);
|
|
|
|
// Get transaction details
|
|
const tx = await provider.getTransaction(txHash);
|
|
|
|
if (!tx) {
|
|
throw new Error('Transaction not found');
|
|
}
|
|
|
|
// Get transaction receipt for status and gas used
|
|
const receipt = await provider.getTransactionReceipt(txHash);
|
|
|
|
// Get current block number for confirmations
|
|
const currentBlock = await provider.getBlockNumber();
|
|
|
|
// Get block details for timestamp
|
|
const block = await provider.getBlock(tx.blockNumber);
|
|
|
|
// Calculate gas fee
|
|
const gasUsed = receipt ? receipt.gasUsed : null;
|
|
const effectiveGasPrice = receipt ? receipt.effectiveGasPrice : tx.gasPrice;
|
|
const gasFee = gasUsed && effectiveGasPrice ?
|
|
parseFloat(ethers.utils.formatEther(gasUsed.mul(effectiveGasPrice))) : null;
|
|
|
|
// Check if it's a token transfer by examining logs
|
|
let tokenTransfer = null;
|
|
if (receipt && receipt.logs.length > 0) {
|
|
// Try to decode ERC20 Transfer event
|
|
const transferEventSignature = ethers.utils.id('Transfer(address,address,uint256)');
|
|
const transferLog = receipt.logs.find(log => log.topics[0] === transferEventSignature);
|
|
|
|
if (transferLog) {
|
|
try {
|
|
const tokenContract = new ethers.Contract(transferLog.address, ERC20ABI, provider);
|
|
const [symbol, decimals] = await Promise.all([
|
|
tokenContract.symbol().catch(() => 'TOKEN'),
|
|
tokenContract.decimals().catch(() => 18)
|
|
]);
|
|
|
|
const from = ethers.utils.getAddress('0x' + transferLog.topics[1].slice(26));
|
|
const to = ethers.utils.getAddress('0x' + transferLog.topics[2].slice(26));
|
|
const value = parseFloat(ethers.utils.formatUnits(transferLog.data, decimals));
|
|
|
|
tokenTransfer = {
|
|
from,
|
|
to,
|
|
value,
|
|
symbol,
|
|
contractAddress: transferLog.address
|
|
};
|
|
} catch (e) {
|
|
console.warn('Could not decode token transfer:', e);
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
hash: tx.hash,
|
|
from: tx.from,
|
|
to: tx.to,
|
|
value: parseFloat(ethers.utils.formatEther(tx.value)),
|
|
symbol: 'ETH',
|
|
blockNumber: tx.blockNumber,
|
|
timestamp: block ? block.timestamp : null,
|
|
confirmations: currentBlock - tx.blockNumber,
|
|
gasLimit: tx.gasLimit.toString(),
|
|
gasUsed: gasUsed ? gasUsed.toString() : null,
|
|
gasPrice: parseFloat(ethers.utils.formatUnits(tx.gasPrice, 'gwei')),
|
|
gasFee: gasFee,
|
|
nonce: tx.nonce,
|
|
input: tx.data,
|
|
status: receipt ? (receipt.status === 1 ? 'success' : 'failed') : 'pending',
|
|
isError: receipt ? receipt.status !== 1 : false,
|
|
tokenTransfer: tokenTransfer,
|
|
logs: receipt ? receipt.logs : [],
|
|
type: tx.type
|
|
};
|
|
|
|
} catch (error) {
|
|
console.error('Error fetching transaction details:', error);
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Check if a string is a valid transaction hash
|
|
* @param {string} hash - Potential transaction hash
|
|
* @returns {boolean}
|
|
*/
|
|
const isValidTxHash = ethOperator.isValidTxHash = (hash) => {
|
|
return /^0x([A-Fa-f0-9]{64})$/.test(hash);
|
|
};
|
|
|
|
})('object' === typeof module ? module.exports : window.ethOperator = {});
|