import json, strformat, strutils, chronicles, sequtils, sugar, httpclient, tables, net import json_serialization, stint, stew/byteutils, algorithm from web3/ethtypes import Address, Quantity from web3/conversions import `$` import statusgo_backend/accounts as status_accounts import statusgo_backend/tokens as statusgo_backend_tokens import statusgo_backend/settings as status_settings import statusgo_backend/wallet as status_wallet import statusgo_backend/accounts/constants as constants import statusgo_backend/eth as eth import eth/contracts import eth/tokens as eth_tokens from utils as statusgo_backend_utils import eth2Wei, gwei2Wei, wei2Gwei, first, toUInt64, parseAddress import wallet/[balance_manager, collectibles] import wallet/account as wallet_account import transactions import ../eventemitter import options import ./types/[account, transaction, network, network_type, setting, gas_prediction, rpc_response] export wallet_account, collectibles export Transaction logScope: topics = "wallet-model" proc confirmed*(self:PendingTransactionType):string = result = "transaction:" & $self type TransactionMinedArgs* = ref object of Args data*: string transactionHash*: string success*: bool revertReason*: string # TODO: possible to get revert reason in here? type WalletModel* = ref object events*: EventEmitter accounts*: seq[WalletAccount] defaultCurrency*: string tokens*: seq[Erc20Contract] totalBalance*: float eip1559Enabled*: bool latestBaseFee*: string proc getDefaultCurrency*(self: WalletModel): string proc calculateTotalFiatBalance*(self: WalletModel) proc newWalletModel*(events: EventEmitter): WalletModel = result = WalletModel() result.accounts = @[] result.tokens = @[] result.events = events result.defaultCurrency = "" result.totalBalance = 0.0 result.eip1559Enabled = false proc initEvents*(self: WalletModel) = self.events.on("currencyChanged") do(e: Args): self.defaultCurrency = self.getDefaultCurrency() for account in self.accounts: updateBalance(account, self.getDefaultCurrency()) self.calculateTotalFiatBalance() self.events.emit("accountsUpdated", Args()) self.events.on("newAccountAdded") do(e: Args): self.calculateTotalFiatBalance() proc delete*(self: WalletModel) = discard proc buildTokenTransaction(source, to, assetAddress: Address, value: float, transfer: var Transfer, contract: var Erc20Contract, gas = "", gasPrice = "", isEIP1559Enabled: bool = false, maxPriorityFeePerGas = "", maxFeePerGas = ""): TransactionData = let network = status_settings.getCurrentNetwork().toNetwork() contract = findErc20Contract(network.chainId, assetAddress) if contract == nil: raise newException(ValueError, fmt"Could not find ERC-20 contract with address '{assetAddress}' for the current network") transfer = Transfer(to: to, value: eth2Wei(value, contract.decimals)) transactions.buildTokenTransaction(source, assetAddress, gas, gasPrice, isEIP1559Enabled, maxPriorityFeePerGas, maxFeePerGas) proc getKnownTokenContract*(self: WalletModel, address: Address): Erc20Contract = let network = status_settings.getCurrentNetwork().toNetwork() allErc20ContractsByChainId(network.chainId).concat(statusgo_backend_tokens.getCustomTokens()).findByAddress(address) proc estimateGas*(self: WalletModel, source, to, value, data: string, success: var bool): string = var tx = transactions.buildTransaction( parseAddress(source), eth2Wei(parseFloat(value), 18), data = data ) tx.to = parseAddress(to).some result = eth.estimateGas(tx, success) proc getTransactionReceipt*(self: WalletModel, transactionHash: string): JsonNode = result = eth.getTransactionReceipt(transactionHash).parseJSON()["result"] proc confirmTransactionStatus(self: WalletModel, pendingTransactions: JsonNode, blockNumber: int) = for trx in pendingTransactions.getElems(): let transactionReceipt = self.getTransactionReceipt(trx["hash"].getStr) if transactionReceipt.kind != JNull: status_wallet.deletePendingTransaction(trx["hash"].getStr) let ev = TransactionMinedArgs( data: trx["additionalData"].getStr, transactionHash: trx["hash"].getStr, success: transactionReceipt{"status"}.getStr == "0x1", revertReason: "" ) self.events.emit(parseEnum[PendingTransactionType](trx["type"].getStr).confirmed, ev) proc getLatestBlock*(): tuple[blockNumber: int, baseFee: string] = let response = getBlockByNumber("latest").parseJson() if response.hasKey("result"): let blockNumber = parseInt($fromHex(Stuint[256], response["result"]["number"].getStr)) let baseFee = $fromHex(Stuint[256], response["result"]{"baseFeePerGas"}.getStr) return (blockNumber, baseFee) return (-1, "") proc getLatestBlockNumber*(self: WalletModel): int = getLatestBlock()[0] proc checkPendingTransactions*(self: WalletModel, latestBlockNumber: int) = if latestBlockNumber == -1: return let pendingTransactions = status_wallet.getPendingTransactions() if (pendingTransactions != ""): self.confirmTransactionStatus(pendingTransactions.parseJson{"result"}, latestBlockNumber) proc checkPendingTransactions*(self: WalletModel, address: string, blockNumber: int) = self.confirmTransactionStatus(status_wallet.getPendingOutboundTransactionsByAddress(address).parseJson["result"], blockNumber) proc estimateTokenGas*(self: WalletModel, source, to, assetAddress, value: string, success: var bool): string = var transfer: Transfer contract: Erc20Contract tx = buildTokenTransaction( parseAddress(source), parseAddress(to), parseAddress(assetAddress), parseFloat(value), transfer, contract ) result = contract.methods["transfer"].estimateGas(tx, transfer, success) proc sendTransaction*(source, to, value, gas, gasPrice: string, isEIP1559Enabled: bool, maxPriorityFeePerGas, maxFeePerGas, password: string, success: var bool, data = ""): string = var tx = transactions.buildTransaction( parseAddress(source), eth2Wei(parseFloat(value), 18), gas, gasPrice, isEIP1559Enabled, maxPriorityFeePerGas, maxFeePerGas, data ) if to != "": tx.to = parseAddress(to).some result = eth.sendTransaction(tx, password, success) if success: trackPendingTransaction(result, $source, $to, PendingTransactionType.WalletTransfer, "") proc sendTokenTransaction*(source, to, assetAddress, value, gas, gasPrice: string, isEIP1559Enabled: bool, maxPriorityFeePerGas, maxFeePerGas, password: string, success: var bool): string = var transfer: Transfer contract: Erc20Contract tx = buildTokenTransaction( parseAddress(source), parseAddress(to), parseAddress(assetAddress), parseFloat(value), transfer, contract, gas, gasPrice, isEIP1559Enabled, maxPriorityFeePerGas, maxFeePerGas ) result = contract.methods["transfer"].send(tx, transfer, password, success) if success: trackPendingTransaction(result, $source, $to, PendingTransactionType.WalletTransfer, "") proc getDefaultCurrency*(self: WalletModel): string = # TODO: this should come from a model? It is going to be used too in the # profile section and ideally we should not call the settings more than once status_settings.getSetting[string](Setting.Currency, "usd") # TODO: This needs to be removed or refactored so that test tokens are shown # when on testnet https://github.com/status-im/nim-status-client/issues/613. proc getStatusToken*(self: WalletModel): string = let network = status_settings.getCurrentNetwork().toNetwork() var token = Asset() erc20Contract = findErc20Contract(network.chainId, network.sntSymbol()) token.name = erc20Contract.name token.symbol = erc20Contract.symbol token.address = $erc20Contract.address result = $(%token) proc setDefaultCurrency*(self: WalletModel, currency: string) = discard status_settings.saveSetting(Setting.Currency, currency) self.events.emit("currencyChanged", CurrencyArgs(currency: currency)) proc generateAccountConfiguredAssets*(self: WalletModel, accountAddress: string): seq[Asset] = var assets: seq[Asset] = @[] var asset = Asset(name:"Ethereum", symbol: "ETH", value: "0.0", fiatBalanceDisplay: "0.0", accountAddress: accountAddress) assets.add(asset) for token in self.tokens: var symbol = token.symbol var existingToken = Asset(name: token.name, symbol: symbol, value: fmt"0.0", fiatBalanceDisplay: "$0.0", accountAddress: accountAddress, address: $token.address) assets.add(existingToken) assets proc populateAccount*(self: WalletModel, walletAccount: var WalletAccount, balance: string, refreshCache: bool = false) = var assets: seq[Asset] = self.generateAccountConfiguredAssets(walletAccount.address) walletAccount.balance = none[string]() walletAccount.assetList = assets walletAccount.realFiatBalance = none[float]() proc update*(self: WalletModel, address: string, ethBalance: string, tokens: JsonNode) = for account in self.accounts: if account.address != address: continue storeBalances(account, ethBalance, tokens) updateBalance(account, self.getDefaultCurrency(), false) proc getEthBalance*(address: string): string = var balance = getBalance(address) result = hex2token(balance, 18) proc newAccount*(self: WalletModel, walletType: string, derivationPath: string, name: string, address: string, iconColor: string, balance: string, publicKey: string): WalletAccount = var assets: seq[Asset] = self.generateAccountConfiguredAssets(address) var account = WalletAccount(name: name, path: derivationPath, walletType: walletType, address: address, iconColor: iconColor, balance: none[string](), assetList: assets, realFiatBalance: none[float](), publicKey: publicKey) updateBalance(account, self.getDefaultCurrency()) account proc maxPriorityFeePerGas*(self: WalletModel):string = let response = eth.maxPriorityFeePerGas().parseJson() if response.hasKey("result"): return $fromHex(Stuint[256], response["result"].getStr) else: error "Error obtaining max priority fee per gas", error=response raise newException(StatusGoException, "Error obtaining max priority fee per gas") proc suggestFees*(self: WalletModel):JsonNode = let response = status_wallet.suggestFees().parseJson() if response.hasKey("result"): return response["result"].getElems()[0] else: error "Error obtaining suggested fees", error=response raise newException(StatusGoException, "Error obtaining suggested fees") proc cmpUint256(x, y: Uint256): int = if x > y: 1 elif x == y: 0 else: -1 proc feeHistory*(self: WalletModel, n:int):seq[Uint256] = let response = eth.feeHistory(101).parseJson() if response.hasKey("result"): for it in response["result"]["baseFeePerGas"]: result.add(fromHex(Stuint[256], it.getStr)) result.sort(cmpUint256) else: error "Error obtaining fee history", error=response raise newException(StatusGoException, "Error obtaining fee history") proc initAccounts*(self: WalletModel) = let network = status_settings.getCurrentNetwork().toNetwork() self.tokens = eth_tokens.getVisibleTokens(network) let accounts = status_accounts.getWalletAccounts() for account in accounts: var acc = WalletAccount(account) self.populateAccount(acc, "") updateBalance(acc, self.getDefaultCurrency(), true) self.accounts.add(acc) proc updateAccount*(self: WalletModel, address: string) = for acc in self.accounts.mitems: if acc.address == address: self.populateAccount(acc, "", true) updateBalance(acc, self.getDefaultCurrency(), true) self.events.emit("accountsUpdated", Args()) proc getTotalFiatBalance*(self: WalletModel): string = self.calculateTotalFiatBalance() fmt"{self.totalBalance:.2f}" proc convertValue*(self: WalletModel, balance: string, fromCurrency: string, toCurrency: string): float = result = convertValue(balance, fromCurrency, toCurrency) proc calculateTotalFiatBalance*(self: WalletModel) = self.totalBalance = 0.0 for account in self.accounts: if account.realFiatBalance.isSome: self.totalBalance += account.realFiatBalance.get() proc addNewGeneratedAccount(self: WalletModel, generatedAccount: GeneratedAccount, password: string, accountName: string, color: string, accountType: string, isADerivedAccount = true, walletIndex: int = 0) = try: generatedAccount.name = accountName var derivedAccount: DerivedAccount = status_accounts.saveAccount(generatedAccount, password, color, accountType, isADerivedAccount, walletIndex) var account = self.newAccount(accountType, derivedAccount.derivationPath, accountName, derivedAccount.address, color, fmt"0.00 {self.defaultCurrency}", derivedAccount.publicKey) self.accounts.add(account) # wallet_checkRecentHistory is required to be called when a new account is # added before wallet_getTransfersByAddress can be called. This is because # wallet_checkRecentHistory populates the status-go db that # wallet_getTransfersByAddress reads from discard status_wallet.checkRecentHistory(self.accounts.map(account => account.address)) self.events.emit("newAccountAdded", wallet_account.AccountArgs(account: account)) except Exception as e: raise newException(StatusGoException, fmt"Error adding new account: {e.msg}") proc generateNewAccount*(self: WalletModel, password: string, accountName: string, color: string) = let walletRootAddress = status_settings.getSetting[string](Setting.WalletRootAddress, "") walletIndex = status_settings.getSetting[int](Setting.LatestDerivedPath) + 1 loadedAccount = status_accounts.loadAccount(walletRootAddress, password) derivedAccount = status_accounts.deriveWallet(loadedAccount.id, walletIndex) generatedAccount = GeneratedAccount( id: loadedAccount.id, publicKey: derivedAccount.publicKey, address: derivedAccount.address ) # if we've gotten here, the password is ok (loadAccount requires a valid password) # so no need to check for a valid password self.addNewGeneratedAccount(generatedAccount, password, accountName, color, constants.GENERATED, true, walletIndex) let statusGoResult = status_settings.saveSetting(Setting.LatestDerivedPath, $walletIndex) if statusGoResult.error != "": error "Error storing the latest wallet index", msg=statusGoResult.error proc addAccountsFromSeed*(self: WalletModel, seed: string, password: string, accountName: string, color: string, keystoreDir: string) = let mnemonic = replace(seed, ',', ' ') var generatedAccount = status_accounts.multiAccountImportMnemonic(mnemonic) generatedAccount.derived = status_accounts.deriveAccounts(generatedAccount.id) let defaultAccount = eth.getDefaultAccount() isPasswordOk = status_accounts.verifyAccountPassword(defaultAccount, password, keystoreDir) if not isPasswordOk: raise newException(StatusGoException, "Error generating new account: invalid password") self.addNewGeneratedAccount(generatedAccount, password, accountName, color, constants.SEED) proc addAccountsFromPrivateKey*(self: WalletModel, privateKey: string, password: string, accountName: string, color: string, keystoreDir: string) = let generatedAccount = status_accounts.MultiAccountImportPrivateKey(privateKey) defaultAccount = eth.getDefaultAccount() isPasswordOk = status_accounts.verifyAccountPassword(defaultAccount, password, keystoreDir) if not isPasswordOk: raise newException(StatusGoException, "Error generating new account: invalid password") self.addNewGeneratedAccount(generatedAccount, password, accountName, color, constants.KEY, false) proc addWatchOnlyAccount*(self: WalletModel, address: string, accountName: string, color: string) = let account = GeneratedAccount(address: address) self.addNewGeneratedAccount(account, "", accountName, color, constants.WATCH, false) proc hasAsset*(self: WalletModel, symbol: string): bool = self.tokens.anyIt(it.symbol == symbol) proc changeAccountSettings*(self: WalletModel, address: string, accountName: string, color: string): string = var selectedAccount: WalletAccount for account in self.accounts: if (account.address == address): selectedAccount = account break if (isNil(selectedAccount)): result = "No account found with that address" error "No account found with that address", address selectedAccount.name = accountName selectedAccount.iconColor = color result = status_accounts.changeAccount(selectedAccount.name, selectedAccount.address, selectedAccount.publicKey, selectedAccount.walletType, selectedAccount.iconColor) proc deleteAccount*(self: WalletModel, address: string): string = result = status_accounts.deleteAccount(address) self.accounts = self.accounts.filter(acc => acc.address.toLowerAscii != address.toLowerAscii) proc toggleAsset*(self: WalletModel, symbol: string) = let network = status_settings.getCurrentNetwork().toNetwork() self.tokens = eth_tokens.toggleAsset(network, symbol) for account in self.accounts: account.assetList = self.generateAccountConfiguredAssets(account.address) updateBalance(account, self.getDefaultCurrency()) self.events.emit("assetChanged", Args()) proc hideAsset*(self: WalletModel, symbol: string) = let network = status_settings.getCurrentNetwork().toNetwork() eth_tokens.hideAsset(network, symbol) self.tokens = eth_tokens.getVisibleTokens(network) for account in self.accounts: account.assetList = self.generateAccountConfiguredAssets(account.address) updateBalance(account, self.getDefaultCurrency()) self.events.emit("assetChanged", Args()) proc addCustomToken*(self: WalletModel, symbol: string, enable: bool, address: string, name: string, decimals: int, color: string) = statusgo_backend_tokens.addCustomToken(address, name, symbol, decimals, color) proc getTransfersByAddress*(self: WalletModel, address: string, toBlock: Uint256, limit: int, loadMore: bool): seq[Transaction] = result = status_wallet.getTransfersByAddress(address, toBlock, limit, loadMore) proc validateMnemonic*(self: WalletModel, mnemonic: string): string = result = status_wallet.validateMnemonic(mnemonic).parseJSON()["error"].getStr proc checkRecentHistory*(self: WalletModel, addresses: seq[string]): string = result = status_wallet.checkRecentHistory(addresses) proc setInitialBlocksRange*(self: WalletModel): string = result = status_wallet.setInitialBlocksRange() proc getWalletAccounts*(self: WalletModel): seq[WalletAccount] = result = status_accounts.getWalletAccounts() proc watchTransaction*(self: WalletModel, transactionHash: string): string = result = status_wallet.watchTransaction(transactionHash) proc getPendingTransactions*(self: WalletModel): string = result = status_wallet.getPendingTransactions() # proc getTransfersByAddress*(address: string): seq[types.Transaction] = # result = status_wallet.getTransfersByAddress(address) proc getTransfersByAddress*(address: string, toBlock: Uint256, limit: int, loadMore: bool): seq[Transaction] = result = status_wallet.getTransfersByAddress(address, toBlock, limit, loadMore) proc watchTransaction*(transactionHash: string): string = result = status_wallet.watchTransaction(transactionHash) proc hex2Token*(self: WalletModel, input: string, decimals: int): string = result = status_wallet.hex2Token(input, decimals) proc getGasPrice*(self: WalletModel): string = let response = eth.getGasPrice().parseJson if response.hasKey("result"): return $fromHex(Stuint[256], response["result"].getStr) else: error "Error obtaining max priority fee per gas", error=response raise newException(StatusGoException, "Error obtaining gas price") proc setLatestBaseFee*(self: WalletModel, latestBaseFee: string) = self.latestBaseFee = latestBaseFee proc getLatestBaseFee*(self: WalletModel): string = result = self.latestBaseFee proc isEIP1559Enabled*(self: WalletModel, blockNumber: int):bool = let networkId = status_settings.getCurrentNetworkDetails().config.networkId let activationBlock = case status_settings.getCurrentNetworkDetails().config.networkId: of 3: 10499401 # Ropsten of 4: 8897988 # Rinkeby of 5: 5062605 # Goerli of 1: 12965000 # Mainnet else: -1 if activationBlock > -1 and blockNumber >= activationBlock: result = true else: result = false self.eip1559Enabled = result proc isEIP1559Enabled*(self: WalletModel): bool = result = self.eip1559Enabled