import { keys, makeAutoObservable, observable, ObservableMap, runInAction, toJS, values, } from 'mobx'; import { AccountState } from 'types/generated/trader_pb'; import Big from 'big.js'; import copyToClipboard from 'copy-to-clipboard'; import debounce from 'lodash/debounce'; import { hex } from 'util/strings'; import { Store } from 'store'; import { Account } from 'store/models'; export default class AccountStore { private _store: Store; /** the collection of accounts */ accounts: ObservableMap<string, Account> = observable.map(); /** the currently active account */ activeTraderKey?: string; /** indicates when accounts have been initially fetched from the backend */ loaded = false; constructor(store: Store) { makeAutoObservable(this, {}, { deep: false, autoBind: true }); this._store = store; } /** returns the active account model */ get activeAccount() { if (!this.activeTraderKey) { throw new Error('Select an account to manage'); } const account = this.accounts.get(this.activeTraderKey); if (!account) { throw new Error('The selected account is not valid'); } return account; } /** an array of accounts with opened first */ get sortedAccounts() { const accts = values(this.accounts).slice(); // sort opened accounts by the account balance const open = accts .filter(a => a.state === AccountState.OPEN) .sort((a, b) => { return +b.totalBalance.minus(a.totalBalance); }); // sort unopened accounts (excluding closed) by the expiration height descending const other = accts .filter( a => a.state !== AccountState.OPEN && a.state !== AccountState.CLOSED && a.state !== AccountState.PENDING_CLOSED, ) .sort((a, b) => b.expirationHeight - a.expirationHeight); // return the opened accounts before the unopened accounts return [...open, ...other]; } /** switch to a different account */ setActiveTraderKey(traderKey: string) { this.activeTraderKey = traderKey; this._store.log.info( 'updated accountStore.activeTraderKey', toJS(this.activeTraderKey), ); } copyTxnId() { copyToClipboard(this.activeAccount.fundingTxnId); const msg = `Copied funding txn ID to clipboard`; this._store.appView.notify(msg, '', 'success'); } /** * Creates an account via the pool API * @param amount the amount (sats) to fund the account with * @param expiryBlocks the number of blocks from now to expire the account */ async createAccount(amount: Big, expiryBlocks: number, confTarget?: number) { this._store.log.info(`creating new account with ${amount}sats`); try { const acct = await this._store.api.pool.initAccount( amount, expiryBlocks, confTarget, ); runInAction(() => { const traderKey = hex(acct.traderKey); this.accounts.set(traderKey, new Account(this._store, acct)); this.setActiveTraderKey(traderKey); this._store.log.info('updated accountStore.accounts', toJS(this.accounts)); }); return acct.traderKey; } catch (error) { this._store.appView.handleError(error, 'Unable to create the account'); } } /** * Closes an account via the pool API */ async closeAccount(feeRate: number, destination?: string) { try { const acct = this.activeAccount; this._store.log.info(`closing account ${acct.traderKey}`); const res = await this._store.api.pool.closeAccount( acct.traderKey, feeRate, destination, ); runInAction(() => { this.activeTraderKey = undefined; }); await this.fetchAccounts(); return res.closeTxid; } catch (error) { this._store.appView.handleError(error, 'Unable to close the account'); } } /** * Renews an account via the pool API */ async renewAccount(expiryBlocks: number, feeRate: number) { try { const acct = this.activeAccount; this._store.log.info( `renewing account ${acct.traderKey} to expire in ${expiryBlocks} blocks`, ); const res = await this._store.api.pool.renewAccount( acct.traderKey, expiryBlocks, Big(feeRate), ); runInAction(() => { // the account should always be defined but if not, fetch all accounts as a fallback if (res.account) { acct.update(res.account); } else { this.fetchAccounts(); } }); return res.renewalTxid; } catch (error) { this._store.appView.handleError(error, 'Unable to renew the account'); } } /** * queries the pool api to fetch the list of accounts and stores them * in the state */ async fetchAccounts() { this._store.log.info('fetching accounts'); try { const { accountsList } = await this._store.api.pool.listAccounts(); // also update the node info since accounts rely on current block height // to calculate the time/blocks remaining until expiration await this._store.nodeStore.fetchInfo(); runInAction(() => { accountsList.forEach(poolAcct => { // update existing accounts or create new ones in state. using this // approach instead of overwriting the array will cause fewer state // mutations, resulting in better react rendering performance const traderKey = hex(poolAcct.traderKey); const existing = this.accounts.get(traderKey); if (existing) { existing.update(poolAcct); } else { this.accounts.set(traderKey, new Account(this._store, poolAcct)); } }); // remove any accounts in state that are not in the API response const serverIds = accountsList.map(a => hex(a.traderKey)); const localIds = keys(this.accounts).map(key => String(key)); localIds .filter(id => !serverIds.includes(id)) .forEach(id => this.accounts.delete(id)); // pre-select the open account with the highest balance if (this.sortedAccounts.length) { this.setActiveTraderKey(this.sortedAccounts[0].traderKey); } this._store.log.info('updated accountStore.accounts', toJS(this.accounts)); this.loaded = true; }); } catch (error) { this._store.appView.handleError(error, 'Unable to fetch Accounts'); } } /** fetch accounts at most once every 2 seconds when using this func */ fetchAccountsThrottled = debounce(this.fetchAccounts, 2000); /** * submits a deposit of the specified amount to the pool api */ async deposit(amount: number, feeRate?: number) { try { const acct = this.activeAccount; this._store.log.info(`depositing ${amount}sats into account ${acct.traderKey}`); const res = await this._store.api.pool.deposit( acct.traderKey, Big(amount), feeRate, ); runInAction(() => { // the account should always be defined but if not, fetch all accounts as a fallback if (res.account) { acct.update(res.account); } else { this.fetchAccounts(); } this._store.log.info('deposit successful', toJS(acct)); }); return res.depositTxid; } catch (error) { this._store.appView.handleError(error, 'Unable to deposit funds'); } } /** * submits a withdraw of the specified amount to the pool api */ async withdraw(amount: number, feeRate?: number) { try { const acct = this.activeAccount; this._store.log.info(`withdrawing ${amount}sats into account ${acct.traderKey}`); const res = await this._store.api.pool.withdraw( acct.traderKey, Big(amount), feeRate, ); runInAction(() => { if (res.account) { acct.update(res.account); } else { this.fetchAccounts(); } this._store.log.info('withdraw successful', toJS(acct)); }); return res.withdrawTxid; } catch (error) { this._store.appView.handleError(error, 'Unable to withdraw funds'); } } /** * submits a sidecar ticket registration to the pool api */ async registerSidecar(ticket: string) { try { this._store.log.info('registering sidecar ticket'); await this._store.api.pool.registerSidecar(ticket); } catch (error) { this._store.appView.handleError(error, 'Unable to register sidecar ticket'); } } }