feat: Implement comprehensive token-based authentication system
Add complete Web3-powered token authentication module for SecureBit project - **TokenAuthManager.js**: Main authentication manager handling Web3 wallet connections, session creation/validation, and automatic session termination - **Web3ContractManager.js**: Smart contract interface for token operations and validation - **SecureBitAccessToken.sol**: ERC-721 smart contract for access tokens with monthly/yearly durations - **TokenAuthModal.jsx**: User interface for wallet connection and token purchase - **TokenStatus.jsx**: Header component displaying token status and remaining time - ERC-721 compliant access tokens with configurable durations (1 month/1 year) - OpenZeppelin security contracts integration (Ownable, ReentrancyGuard, Pausable) - Token purchase, renewal, and deactivation functionality - Automatic expiry validation and price management - Transfer handling with user token tracking - Pausable functionality for emergency contract control - `purchaseMonthlyToken()` / `purchaseYearlyToken()`: Token acquisition - `isTokenValid()`: Real-time token validation - `renewToken()`: Token extension functionality - `deactivateToken()`: Manual token deactivation - `getTokenPrices()`: Dynamic pricing information - `pause()` / `unpause()`: Emergency control functions - Web3 signature verification for wallet ownership - Single active session enforcement per account - Automatic session termination on new device login - Cryptographic signature validation - MITM and replay attack protection preservation - Blockchain-based token validation - Modular architecture for easy integration - Web3.js integration for Ethereum network interaction - MetaMask wallet support - Session heartbeat monitoring - Automatic token expiry handling - Comprehensive error handling and logging - src/token-auth/TokenAuthManager.js - src/token-auth/Web3ContractManager.js - src/token-auth/SecureBitAccessToken.sol - src/token-auth/config.js - src/components/ui/TokenAuthModal.jsx - src/components/ui/TokenStatus.jsx - Smart contract includes comprehensive test scenarios - Mock mode available for development testing - Hardhat deployment scripts provided
This commit is contained in:
621
src/token-auth/Web3ContractManager.js
Normal file
621
src/token-auth/Web3ContractManager.js
Normal file
@@ -0,0 +1,621 @@
|
||||
// ============================================
|
||||
// WEB3 CONTRACT MANAGER
|
||||
// ============================================
|
||||
// Управление смарт-контрактом токенов доступа
|
||||
// Интеграция с MetaMask и другими Web3 провайдерами
|
||||
// ============================================
|
||||
|
||||
class Web3ContractManager {
|
||||
constructor() {
|
||||
this.contract = null;
|
||||
this.web3 = null;
|
||||
this.contractAddress = null;
|
||||
this.contractABI = null;
|
||||
this.isInitialized = false;
|
||||
|
||||
// Адреса контрактов для разных сетей
|
||||
this.CONTRACT_ADDRESSES = {
|
||||
// Mainnet
|
||||
'0x1': '0x0000000000000000000000000000000000000000', // Заменить на реальный адрес
|
||||
// Ropsten (тестовая сеть)
|
||||
'0x3': '0x0000000000000000000000000000000000000000', // Заменить на реальный адрес
|
||||
// Goerli (тестовая сеть)
|
||||
'0x5': '0x0000000000000000000000000000000000000000', // Заменить на реальный адрес
|
||||
// Sepolia (тестовая сеть)
|
||||
'0xaa36a7': '0x0000000000000000000000000000000000000000' // Заменить на реальный адрес
|
||||
};
|
||||
|
||||
// ABI контракта (упрощенная версия)
|
||||
this.CONTRACT_ABI = [
|
||||
// События
|
||||
{
|
||||
"anonymous": false,
|
||||
"inputs": [
|
||||
{"indexed": true, "name": "tokenId", "type": "uint256"},
|
||||
{"indexed": true, "name": "owner", "type": "address"},
|
||||
{"indexed": false, "name": "tokenType", "type": "uint8"},
|
||||
{"indexed": false, "name": "expiryDate", "type": "uint256"}
|
||||
],
|
||||
"name": "TokenMinted",
|
||||
"type": "event"
|
||||
},
|
||||
{
|
||||
"anonymous": false,
|
||||
"inputs": [
|
||||
{"indexed": true, "name": "tokenId", "type": "uint256"},
|
||||
{"indexed": true, "name": "owner", "type": "address"}
|
||||
],
|
||||
"name": "TokenExpired",
|
||||
"type": "event"
|
||||
},
|
||||
{
|
||||
"anonymous": false,
|
||||
"inputs": [
|
||||
{"indexed": true, "name": "tokenId", "type": "uint256"},
|
||||
{"indexed": false, "name": "newExpiryDate", "type": "uint256"}
|
||||
],
|
||||
"name": "TokenRenewed",
|
||||
"type": "event"
|
||||
},
|
||||
|
||||
// Функции чтения
|
||||
{
|
||||
"inputs": [{"name": "tokenId", "type": "uint256"}],
|
||||
"name": "isTokenValid",
|
||||
"outputs": [{"name": "", "type": "bool"}],
|
||||
"stateMutability": "view",
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"inputs": [{"name": "tokenId", "type": "uint256"}],
|
||||
"name": "getTokenInfo",
|
||||
"outputs": [
|
||||
{"name": "tokenId", "type": "uint256"},
|
||||
{"name": "owner", "type": "address"},
|
||||
{"name": "expiryDate", "type": "uint256"},
|
||||
{"name": "tokenType", "type": "uint8"},
|
||||
{"name": "isActive", "type": "bool"},
|
||||
{"name": "createdAt", "type": "uint256"},
|
||||
{"name": "metadata", "type": "string"}
|
||||
],
|
||||
"stateMutability": "view",
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"inputs": [{"name": "user", "type": "address"}],
|
||||
"name": "getUserTokens",
|
||||
"outputs": [{"name": "", "type": "uint256[]"}],
|
||||
"stateMutability": "view",
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"inputs": [{"name": "user", "type": "address"}],
|
||||
"name": "getActiveUserTokens",
|
||||
"outputs": [{"name": "", "type": "uint256[]"}],
|
||||
"stateMutability": "view",
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"inputs": [{"name": "user", "type": "address"}],
|
||||
"name": "hasActiveToken",
|
||||
"outputs": [{"name": "", "type": "bool"}],
|
||||
"stateMutability": "view",
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"inputs": [],
|
||||
"name": "monthlyPrice",
|
||||
"outputs": [{"name": "", "type": "uint256"}],
|
||||
"stateMutability": "view",
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"inputs": [],
|
||||
"name": "yearlyPrice",
|
||||
"outputs": [{"name": "", "type": "uint256"}],
|
||||
"stateMutability": "view",
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"inputs": [],
|
||||
"name": "getStats",
|
||||
"outputs": [
|
||||
{"name": "totalTokens", "type": "uint256"},
|
||||
{"name": "activeTokens", "type": "uint256"},
|
||||
{"name": "monthlyTokens", "type": "uint256"},
|
||||
{"name": "yearlyTokens", "type": "uint256"}
|
||||
],
|
||||
"stateMutability": "view",
|
||||
"type": "function"
|
||||
},
|
||||
|
||||
// Функции записи
|
||||
{
|
||||
"inputs": [],
|
||||
"name": "purchaseMonthlyToken",
|
||||
"outputs": [],
|
||||
"stateMutability": "payable",
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"inputs": [],
|
||||
"name": "purchaseYearlyToken",
|
||||
"outputs": [],
|
||||
"stateMutability": "payable",
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"inputs": [{"name": "tokenId", "type": "uint256"}],
|
||||
"name": "deactivateToken",
|
||||
"outputs": [],
|
||||
"stateMutability": "nonpayable",
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"inputs": [{"name": "tokenId", "type": "uint256"}],
|
||||
"name": "renewToken",
|
||||
"outputs": [],
|
||||
"stateMutability": "payable",
|
||||
"type": "function"
|
||||
}
|
||||
];
|
||||
|
||||
this.initialize();
|
||||
}
|
||||
|
||||
// Инициализация Web3 и контракта
|
||||
async initialize() {
|
||||
try {
|
||||
// Проверяем поддержку Web3
|
||||
if (typeof window.ethereum !== 'undefined') {
|
||||
console.log('✅ Web3 detected');
|
||||
|
||||
// Создаем Web3 экземпляр
|
||||
this.web3 = new Web3(window.ethereum);
|
||||
|
||||
// Получаем текущую сеть
|
||||
const chainId = await this.getCurrentChainId();
|
||||
console.log('🔗 Current chain ID:', chainId);
|
||||
|
||||
// Получаем адрес контракта для текущей сети
|
||||
this.contractAddress = this.CONTRACT_ADDRESSES[chainId];
|
||||
|
||||
if (!this.contractAddress || this.contractAddress === '0x0000000000000000000000000000000000000000') {
|
||||
console.warn('⚠️ Contract not deployed on current network:', chainId);
|
||||
this.showContractNotDeployedMessage(chainId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Создаем экземпляр контракта
|
||||
this.contract = new this.web3.eth.Contract(
|
||||
this.CONTRACT_ABI,
|
||||
this.contractAddress
|
||||
);
|
||||
|
||||
console.log('📋 Contract initialized:', this.contractAddress);
|
||||
this.isInitialized = true;
|
||||
|
||||
} else {
|
||||
throw new Error('Web3 not detected');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Web3ContractManager initialization failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Получение текущего Chain ID
|
||||
async getCurrentChainId() {
|
||||
try {
|
||||
const chainId = await window.ethereum.request({ method: 'eth_chainId' });
|
||||
return chainId;
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to get chain ID:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Проверка валидности токена
|
||||
async isTokenValid(tokenId) {
|
||||
try {
|
||||
if (!this.isInitialized || !this.contract) {
|
||||
throw new Error('Contract not initialized');
|
||||
}
|
||||
|
||||
const result = await this.contract.methods.isTokenValid(tokenId).call();
|
||||
console.log('🔍 Token validation result:', { tokenId, isValid: result });
|
||||
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Token validation failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Получение информации о токене
|
||||
async getTokenInfo(tokenId) {
|
||||
try {
|
||||
if (!this.isInitialized || !this.contract) {
|
||||
throw new Error('Contract not initialized');
|
||||
}
|
||||
|
||||
const result = await this.contract.methods.getTokenInfo(tokenId).call();
|
||||
|
||||
// Преобразуем результат в удобный формат
|
||||
const tokenInfo = {
|
||||
tokenId: result.tokenId,
|
||||
owner: result.owner,
|
||||
expiryDate: parseInt(result.expiryDate),
|
||||
tokenType: parseInt(result.tokenType),
|
||||
isActive: result.isActive,
|
||||
createdAt: parseInt(result.createdAt),
|
||||
metadata: result.metadata
|
||||
};
|
||||
|
||||
console.log('📋 Token info retrieved:', tokenInfo);
|
||||
return tokenInfo;
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to get token info:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Получение токенов пользователя
|
||||
async getUserTokens(userAddress) {
|
||||
try {
|
||||
if (!this.isInitialized || !this.contract) {
|
||||
throw new Error('Contract not initialized');
|
||||
}
|
||||
|
||||
const result = await this.contract.methods.getUserTokens(userAddress).call();
|
||||
const tokenIds = result.map(id => parseInt(id));
|
||||
|
||||
console.log('👤 User tokens retrieved:', { user: userAddress, tokens: tokenIds });
|
||||
return tokenIds;
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to get user tokens:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Получение активных токенов пользователя
|
||||
async getActiveUserTokens(userAddress) {
|
||||
try {
|
||||
if (!this.isInitialized || !this.contract) {
|
||||
throw new Error('Contract not initialized');
|
||||
}
|
||||
|
||||
const result = await this.contract.methods.getActiveUserTokens(userAddress).call();
|
||||
const tokenIds = result.map(id => parseInt(id));
|
||||
|
||||
console.log('✅ Active user tokens retrieved:', { user: userAddress, activeTokens: tokenIds });
|
||||
return tokenIds;
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to get active user tokens:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Проверка наличия активного токена у пользователя
|
||||
async hasActiveToken(userAddress) {
|
||||
try {
|
||||
if (!this.isInitialized || !this.contract) {
|
||||
throw new Error('Contract not initialized');
|
||||
}
|
||||
|
||||
const result = await this.contract.methods.hasActiveToken(userAddress).call();
|
||||
console.log('🔍 Active token check:', { user: userAddress, hasActive: result });
|
||||
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to check active token:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Получение цен токенов
|
||||
async getTokenPrices() {
|
||||
try {
|
||||
if (!this.isInitialized || !this.contract) {
|
||||
throw new Error('Contract not initialized');
|
||||
}
|
||||
|
||||
const [monthlyPrice, yearlyPrice] = await Promise.all([
|
||||
this.contract.methods.monthlyPrice().call(),
|
||||
this.contract.methods.yearlyPrice().call()
|
||||
]);
|
||||
|
||||
const prices = {
|
||||
monthly: this.web3.utils.fromWei(monthlyPrice, 'ether'),
|
||||
yearly: this.web3.utils.fromWei(yearlyPrice, 'ether'),
|
||||
monthlyWei: monthlyPrice,
|
||||
yearlyWei: yearlyPrice
|
||||
};
|
||||
|
||||
console.log('💰 Token prices retrieved:', prices);
|
||||
return prices;
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to get token prices:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Получение статистики контракта
|
||||
async getContractStats() {
|
||||
try {
|
||||
if (!this.isInitialized || !this.contract) {
|
||||
throw new Error('Contract not initialized');
|
||||
}
|
||||
|
||||
const result = await this.contract.methods.getStats().call();
|
||||
|
||||
const stats = {
|
||||
totalTokens: parseInt(result.totalTokens),
|
||||
activeTokens: parseInt(result.activeTokens),
|
||||
monthlyTokens: parseInt(result.monthlyTokens),
|
||||
yearlyTokens: parseInt(result.yearlyTokens)
|
||||
};
|
||||
|
||||
console.log('📊 Contract stats retrieved:', stats);
|
||||
return stats;
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to get contract stats:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Покупка месячного токена
|
||||
async purchaseMonthlyToken(price) {
|
||||
try {
|
||||
if (!this.isInitialized || !this.contract) {
|
||||
throw new Error('Contract not initialized');
|
||||
}
|
||||
|
||||
const accounts = await this.web3.eth.getAccounts();
|
||||
const userAddress = accounts[0];
|
||||
|
||||
console.log('🛒 Purchasing monthly token:', { user: userAddress, price: price });
|
||||
|
||||
const result = await this.contract.methods.purchaseMonthlyToken().send({
|
||||
from: userAddress,
|
||||
value: price
|
||||
});
|
||||
|
||||
console.log('✅ Monthly token purchased:', result);
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Monthly token purchase failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Покупка годового токена
|
||||
async purchaseYearlyToken(price) {
|
||||
try {
|
||||
if (!this.isInitialized || !this.contract) {
|
||||
throw new Error('Contract not initialized');
|
||||
}
|
||||
|
||||
const accounts = await this.web3.eth.getAccounts();
|
||||
const userAddress = accounts[0];
|
||||
|
||||
console.log('🛒 Purchasing yearly token:', { user: userAddress, price: price });
|
||||
|
||||
const result = await this.contract.methods.purchaseYearlyToken().send({
|
||||
from: userAddress,
|
||||
value: price
|
||||
});
|
||||
|
||||
console.log('✅ Yearly token purchased:', result);
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Yearly token purchase failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Деактивация токена
|
||||
async deactivateToken(tokenId) {
|
||||
try {
|
||||
if (!this.isInitialized || !this.contract) {
|
||||
throw new Error('Contract not initialized');
|
||||
}
|
||||
|
||||
const accounts = await this.web3.eth.getAccounts();
|
||||
const userAddress = accounts[0];
|
||||
|
||||
console.log('🚫 Deactivating token:', { tokenId, user: userAddress });
|
||||
|
||||
const result = await this.contract.methods.deactivateToken(tokenId).send({
|
||||
from: userAddress
|
||||
});
|
||||
|
||||
console.log('✅ Token deactivated:', result);
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Token deactivation failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Продление токена
|
||||
async renewToken(tokenId, price) {
|
||||
try {
|
||||
if (!this.isInitialized || !this.contract) {
|
||||
throw new Error('Contract not initialized');
|
||||
}
|
||||
|
||||
const accounts = await this.web3.eth.getAccounts();
|
||||
const userAddress = accounts[0];
|
||||
|
||||
console.log('🔄 Renewing token:', { tokenId, user: userAddress, price: price });
|
||||
|
||||
const result = await this.contract.methods.renewToken(tokenId).send({
|
||||
from: userAddress,
|
||||
value: price
|
||||
});
|
||||
|
||||
console.log('✅ Token renewed:', result);
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Token renewal failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Получение событий о создании токенов
|
||||
async getTokenMintedEvents(fromBlock = 0, toBlock = 'latest') {
|
||||
try {
|
||||
if (!this.isInitialized || !this.contract) {
|
||||
throw new Error('Contract not initialized');
|
||||
}
|
||||
|
||||
const events = await this.contract.getPastEvents('TokenMinted', {
|
||||
fromBlock: fromBlock,
|
||||
toBlock: toBlock
|
||||
});
|
||||
|
||||
console.log('📝 Token minted events retrieved:', events.length);
|
||||
return events;
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to get token minted events:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Получение событий о продлении токенов
|
||||
async getTokenRenewedEvents(fromBlock = 0, toBlock = 'latest') {
|
||||
try {
|
||||
if (!this.isInitialized || !this.contract) {
|
||||
throw new Error('Contract not initialized');
|
||||
}
|
||||
|
||||
const events = await this.contract.getPastEvents('TokenRenewed', {
|
||||
fromBlock: fromBlock,
|
||||
toBlock: toBlock
|
||||
});
|
||||
|
||||
console.log('🔄 Token renewed events retrieved:', events.length);
|
||||
return events;
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to get token renewed events:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Проверка поддержки сети
|
||||
isNetworkSupported(chainId) {
|
||||
return this.CONTRACT_ADDRESSES.hasOwnProperty(chainId);
|
||||
}
|
||||
|
||||
// Получение поддерживаемых сетей
|
||||
getSupportedNetworks() {
|
||||
return Object.keys(this.CONTRACT_ADDRESSES).map(chainId => ({
|
||||
chainId: chainId,
|
||||
name: this.getNetworkName(chainId),
|
||||
contractAddress: this.CONTRACT_ADDRESSES[chainId]
|
||||
}));
|
||||
}
|
||||
|
||||
// Получение названия сети
|
||||
getNetworkName(chainId) {
|
||||
const networkNames = {
|
||||
'0x1': 'Ethereum Mainnet',
|
||||
'0x3': 'Ropsten Testnet',
|
||||
'0x5': 'Goerli Testnet',
|
||||
'0xaa36a7': 'Sepolia Testnet'
|
||||
};
|
||||
|
||||
return networkNames[chainId] || 'Unknown Network';
|
||||
}
|
||||
|
||||
// Переключение на поддерживаемую сеть
|
||||
async switchToNetwork(chainId) {
|
||||
try {
|
||||
if (!this.isNetworkSupported(chainId)) {
|
||||
throw new Error(`Network ${chainId} is not supported`);
|
||||
}
|
||||
|
||||
await window.ethereum.request({
|
||||
method: 'wallet_switchEthereumChain',
|
||||
params: [{ chainId: chainId }]
|
||||
});
|
||||
|
||||
console.log('🔄 Switched to network:', chainId);
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to switch network:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Добавление новой сети
|
||||
async addNetwork(chainId, networkName, rpcUrl, blockExplorerUrl) {
|
||||
try {
|
||||
await window.ethereum.request({
|
||||
method: 'wallet_addEthereumChain',
|
||||
params: [{
|
||||
chainId: chainId,
|
||||
chainName: networkName,
|
||||
rpcUrls: [rpcUrl],
|
||||
blockExplorerUrls: [blockExplorerUrl],
|
||||
nativeCurrency: {
|
||||
name: 'Ether',
|
||||
symbol: 'ETH',
|
||||
decimals: 18
|
||||
}
|
||||
}]
|
||||
});
|
||||
|
||||
console.log('➕ Network added:', { chainId, name: networkName });
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to add network:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Показ сообщений пользователю
|
||||
showContractNotDeployedMessage(chainId) {
|
||||
const message = `Smart contract not deployed on network ${chainId}. Please switch to a supported network or deploy the contract.`;
|
||||
console.warn('⚠️', message);
|
||||
// Здесь можно добавить UI уведомление
|
||||
}
|
||||
|
||||
// Получение статуса инициализации
|
||||
getInitializationStatus() {
|
||||
return {
|
||||
isInitialized: this.isInitialized,
|
||||
web3: !!this.web3,
|
||||
contract: !!this.contract,
|
||||
contractAddress: this.contractAddress,
|
||||
currentChainId: this.web3 ? this.web3.currentProvider.chainId : null
|
||||
};
|
||||
}
|
||||
|
||||
// Очистка ресурсов
|
||||
destroy() {
|
||||
this.contract = null;
|
||||
this.web3 = null;
|
||||
this.contractAddress = null;
|
||||
this.isInitialized = false;
|
||||
|
||||
console.log('🗑️ Web3ContractManager destroyed');
|
||||
}
|
||||
}
|
||||
|
||||
export { Web3ContractManager };
|
||||
Reference in New Issue
Block a user