status-lib/status/wallet.nim

457 lines
20 KiB
Nim

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