Implemented full wallet functionality for Ethereum Classic:
- Balance Checking: Real-time balance fetching for ETC and WETC.
- Transaction History: Robust fetching via BlockScout API with fallback CORS proxies and retry logic.
- Transaction Details: Detailed view for individual transactions, including status, hash, and value.
- Sending Functionality: Secure ETC transfers using Type 0 (Legacy) transactions.
- Key Management: Generate new addresses and retrieve existing ones from private keys (WIF).
- Navigation Enhancements:
- Clickable addresses in history for seamless navigation.
563 lines
17 KiB
JavaScript
563 lines
17 KiB
JavaScript
(function (EXPORTS) { // ethOperator v1.0.2
|
|
/* ETC 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 = {
|
|
// Ethereum Classic network token addresses
|
|
wetc: "0x1953cab0E5bFa6D4a9BaD6E05fD46C1CC6527a5a" // Wrapped ETC (canonical address)
|
|
}
|
|
/**
|
|
* Get Ethereum Classic provider (MetaMask or public RPC)
|
|
* @param {boolean} readOnly - If true, use public RPC; if false, use MetaMask when available
|
|
* @returns {ethers.providers.Provider} Ethereum Classic 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://go.getblock.io/25daad33439f4cd0ae1ffdde6ff6b560`)
|
|
}
|
|
}
|
|
// 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);
|
|
|
|
// WETC uses 18 decimals (like native ETC), USDC and USDT use 6 decimals
|
|
const decimals = token === 'wetc' ? 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 gas price from the network (Legacy transaction)
|
|
const gasPrice = await provider.getGasPrice();
|
|
|
|
// Creating and sending the transaction object (Type 0 Legacy)
|
|
return signer.sendTransaction({
|
|
to: receiver,
|
|
value: ethers.utils.parseUnits(amount, "ether"),
|
|
gasLimit: limit,
|
|
nonce: await signer.getTransactionCount(),
|
|
gasPrice: gasPrice,
|
|
type: 0
|
|
})
|
|
} catch (e) {
|
|
throw new Error(e)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Send ERC20 tokens (USDC, USDT, or WETC)
|
|
* @param {object} params - Transaction parameters
|
|
* @param {string} params.token - Token symbol ('usdc', 'usdt', or 'wetc')
|
|
* @param {string} params.privateKey - Sender's private key
|
|
* @param {string} params.amount - Amount to send
|
|
* @param {string} params.receiver - Recipient's Ethereum Classic address
|
|
* @param {string} params.contractAddress - Optional custom contract address
|
|
* @returns {Promise} Transaction promise
|
|
*/
|
|
const sendToken = ethOperator.sendToken = async ({ token, privateKey, amount, receiver, contractAddress }) => {
|
|
const provider = getProvider();
|
|
const wallet = new ethers.Wallet(privateKey, provider);
|
|
const tokenContract = new ethers.Contract(CONTRACT_ADDRESSES[token] || contractAddress, ERC20ABI, wallet);
|
|
// Convert amount to smallest unit: WETC uses 18 decimals, USDC and USDT use 6 decimals
|
|
const decimals = token === 'wetc' ? 18 : 6;
|
|
const amountWei = ethers.utils.parseUnits(amount.toString(), decimals);
|
|
|
|
// Get gas price for legacy transaction
|
|
const gasPrice = await provider.getGasPrice();
|
|
|
|
return tokenContract.transfer(receiver, amountWei, {
|
|
gasPrice: gasPrice,
|
|
type: 0
|
|
})
|
|
}
|
|
|
|
|
|
const MORALIS_API_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJub25jZSI6IjNmMjE5NjM5LTQwYmYtNDhkMC1hNDMxLTI5YjA4YzhlYzE5MiIsIm9yZ0lkIjoiNDkwNTU1IiwidXNlcklkIjoiNTA0NzE5IiwidHlwZUlkIjoiYWNiMjQzOWUtMDEzYy00YjhjLWI2N2MtNjRlNGNhMjA4YTlkIiwidHlwZSI6IlBST0pFQ1QiLCJpYXQiOjE3Njg1MDcyNTIsImV4cCI6NDkyNDI2NzI1Mn0.X4Hn3VxLVRJL6HlAGPFQdWvQAdTXO20_Z8CpWhNt5CE';
|
|
|
|
/**
|
|
* Get transaction history for an Ethereum Classic address using BlockScout API
|
|
* @param {string} address - Ethereum Classic 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 Ethereum Classic address');
|
|
}
|
|
|
|
const {
|
|
page = 1,
|
|
offset = 100,
|
|
} = options;
|
|
|
|
// BlockScout API endpoint for Ethereum Classic
|
|
const blockscoutUrl = `https://blockscout.com/etc/mainnet/api?module=account&action=txlist&address=${address}&startblock=0&endblock=99999999&page=${page}&offset=${offset}&sort=desc`;
|
|
|
|
// List of CORS proxies for redundancy
|
|
const proxies = [
|
|
'https://api.allorigins.win/get?url=',
|
|
'https://corsproxy.io/?'
|
|
];
|
|
|
|
// Retry logic (up to 5 attempts with proxy rotation)
|
|
let response, data, lastError;
|
|
for (let attempt = 1; attempt <= 5; attempt++) {
|
|
const currentProxy = proxies[(attempt - 1) % proxies.length];
|
|
try {
|
|
if (attempt > 1) {
|
|
const delay = 500 * attempt; // Exponential backoff for retries
|
|
await new Promise(resolve => setTimeout(resolve, delay));
|
|
console.log(`Retry ${attempt}/5 for ${address} using ${currentProxy}`);
|
|
}
|
|
|
|
const finalUrl = currentProxy + encodeURIComponent(blockscoutUrl);
|
|
response = await fetch(finalUrl);
|
|
|
|
if (response.ok) {
|
|
const result = await response.json();
|
|
// Handle different proxy response formats (AllOrigins wraps JSON, CORSProxy.io is direct)
|
|
const contents = result.contents ? result.contents : JSON.stringify(result);
|
|
data = typeof contents === 'string' ? JSON.parse(contents) : contents;
|
|
break; // Success!
|
|
}
|
|
|
|
lastError = `Status ${response.status} ${response.statusText}`;
|
|
} catch (err) {
|
|
lastError = err.message;
|
|
console.warn(`Proxy ${currentProxy} failed on attempt ${attempt}: ${lastError}`);
|
|
}
|
|
}
|
|
|
|
if (!data || !data.result || data.status !== '1') {
|
|
if (lastError && !data) {
|
|
throw new Error(`Failed to fetch transactions after 5 attempts. Last error: ${lastError}`);
|
|
}
|
|
// No transactions found or API returned status '0' (which usually means no txs for this address)
|
|
return [];
|
|
}
|
|
|
|
// Parse and format transactions from BlockScout response
|
|
return data.result.map(tx => {
|
|
const isReceived = tx.to && tx.to.toLowerCase() === address.toLowerCase();
|
|
const value = parseFloat(ethers.utils.formatEther(tx.value || '0'));
|
|
|
|
return {
|
|
hash: tx.hash,
|
|
from: tx.from,
|
|
to: tx.to,
|
|
value: value,
|
|
symbol: 'ETC',
|
|
timestamp: parseInt(tx.timeStamp),
|
|
blockNumber: parseInt(tx.blockNumber),
|
|
isReceived: isReceived,
|
|
isSent: !isReceived,
|
|
gasUsed: tx.gasUsed ? parseInt(tx.gasUsed) : 0,
|
|
gasPrice: tx.gasPrice ? parseFloat(ethers.utils.formatUnits(tx.gasPrice, 'gwei')) : 0,
|
|
isError: tx.isError === '1',
|
|
contractAddress: tx.contractAddress || null,
|
|
tokenName: null,
|
|
confirmations: parseInt(tx.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: 'ETC',
|
|
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 = {});
|