copay-crown
Version:
A Secure Crown Wallet
679 lines (573 loc) • 22.9 kB
text/typescript
import { Component } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
import { Events, ModalController, NavController, NavParams } from 'ionic-angular';
import * as _ from 'lodash';
import { Logger } from '../../../providers/logger/logger';
// Pages
import { FinishModalPage } from '../../finish/finish';
import { PayProPage } from '../../paypro/paypro';
import { ChooseFeeLevelPage } from '../choose-fee-level/choose-fee-level';
import { FeeWarningPage } from '../fee-warning/fee-warning';
// Providers
import { BwcErrorProvider } from '../../../providers/bwc-error/bwc-error';
import { BwcProvider } from '../../../providers/bwc/bwc';
import { ConfigProvider } from '../../../providers/config/config';
import { ExternalLinkProvider } from '../../../providers/external-link/external-link';
import { FeeProvider } from '../../../providers/fee/fee';
import { OnGoingProcessProvider } from '../../../providers/on-going-process/on-going-process';
import { PlatformProvider } from '../../../providers/platform/platform';
import { PopupProvider } from '../../../providers/popup/popup';
import { ProfileProvider } from '../../../providers/profile/profile';
import { TxConfirmNotificationProvider } from '../../../providers/tx-confirm-notification/tx-confirm-notification';
import { TxFormatProvider } from '../../../providers/tx-format/tx-format';
import { WalletProvider } from '../../../providers/wallet/wallet';
export class ConfirmPage {
private bitcore: any;
private bitcoreCash: any;
public countDown = null;
public CONFIRM_LIMIT_USD: number;
public FEE_TOO_HIGH_LIMIT_PER: number;
public tx: any;
public wallet: any;
public wallets: any;
public noWalletMessage: string;
public criticalError: boolean;
public showAddress: boolean;
public walletSelectorTitle: string;
public buttonText: string;
public successText: string;
public paymentExpired: boolean;
public remainingTimeStr: string;
// Config Related values
public config: any;
public configFeeLevel: string;
// Platform info
public isCordova: boolean;
// custom fee flag
public usingCustomFee: boolean = false;
constructor(
private bwcProvider: BwcProvider,
private navCtrl: NavController,
private navParams: NavParams,
private logger: Logger,
private configProvider: ConfigProvider,
private platformProvider: PlatformProvider,
private profileProvider: ProfileProvider,
private walletProvider: WalletProvider,
private popupProvider: PopupProvider,
private bwcErrorProvider: BwcErrorProvider,
private onGoingProcessProvider: OnGoingProcessProvider,
private feeProvider: FeeProvider,
private txConfirmNotificationProvider: TxConfirmNotificationProvider,
private modalCtrl: ModalController,
private txFormatProvider: TxFormatProvider,
private events: Events,
private translate: TranslateService,
private externalLinkProvider: ExternalLinkProvider
) {
this.bitcore = this.bwcProvider.getBitcore();
this.bitcoreCash = this.bwcProvider.getBitcoreCash();
this.CONFIRM_LIMIT_USD = 20;
this.FEE_TOO_HIGH_LIMIT_PER = 15;
this.config = this.configProvider.get();
this.configFeeLevel = this.config.wallet.settings.feeLevel ? this.config.wallet.settings.feeLevel : 'normal';
this.isCordova = this.platformProvider.isCordova;
}
ionViewWillEnter() {
let B = this.navParams.data.coin == 'bch' ? this.bitcoreCash : this.bitcore;
let networkName;
try {
networkName = (new B.Address(this.navParams.data.toAddress)).network.name;
} catch (e) {
var message = this.translate.instant('Copay only supports Bitcoin Cash using new version numbers addresses');
var backText = this.translate.instant('Go back');
var learnText = this.translate.instant('Learn more');
this.popupProvider.ionicConfirm(null, message, backText, learnText).then((back) => {
if (!back) {
var url = 'https://support.bitpay.com/hc/en-us/articles/115004671663';
this.externalLinkProvider.open(url);
}
this.navCtrl.pop();
});
return;
}
this.tx = {
toAddress: this.navParams.data.toAddress,
amount: parseInt(this.navParams.data.amount, 10),
sendMax: this.navParams.data.useSendMax ? true : false,
description: this.navParams.data.description,
paypro: this.navParams.data.paypro,
feeLevel: this.configFeeLevel,
spendUnconfirmed: this.config.wallet.spendUnconfirmed,
// Vanity tx info (not in the real tx)
recipientType: this.navParams.data.recipientType,
name: this.navParams.data.name,
email: this.navParams.data.email,
color: this.navParams.data.color,
network: networkName,
coin: this.navParams.data.coin,
txp: {},
};
this.tx.origToAddress = this.tx.toAddress;
if (this.tx.coin && this.tx.coin == 'bch') {
this.tx.feeLevel = 'normal';
// Use legacy address
this.tx.toAddress = this.bitcoreCash.Address(this.tx.toAddress).toString();
}
this.tx.feeLevelName = this.feeProvider.feeOpts[this.tx.feeLevel];
this.showAddress = false;
this.walletSelectorTitle = this.translate.instant('Send from');
this.setWalletSelector(this.tx.coin, this.tx.network, this.tx.amount).then(() => {
if (this.wallets.length > 1) {
this.showWallets();
} else if (this.wallets.length) {
this.setWallet(this.wallets[0]);
}
}).catch((err: any) => {
this.logger.error(err);
return this.exitWithError(err);
});
}
ionViewDidLoad() {
this.logger.info('ionViewDidLoad ConfirmPage');
}
private setWalletSelector(coin: string, network: string, minAmount: number): Promise<any> {
return new Promise((resolve, reject) => {
// no min amount? (sendMax) => look for no empty wallets
minAmount = minAmount ? minAmount : 1;
let filteredWallets: any[] = [];
let index: number = 0;
let walletsUpdated: number = 0;
this.wallets = this.profileProvider.getWallets({
onlyComplete: true,
network,
coin
});
if (!this.wallets || !this.wallets.length) {
this.setNoWallet(this.translate.instant('No wallets available'), true);
return resolve();
}
_.each(this.wallets, (wallet: any) => {
this.walletProvider.getStatus(wallet, {}).then((status: any) => {
walletsUpdated++;
wallet.status = status;
if (!status.availableBalanceSat) {
this.logger.debug('No balance available in: ' + wallet.name);
}
if (status.availableBalanceSat > minAmount) {
filteredWallets.push(wallet);
}
if (++index == this.wallets.length) {
if (!walletsUpdated)
return reject('Could not update any wallet');
if (_.isEmpty(filteredWallets)) {
this.setNoWallet(this.translate.instant('Insufficient funds'), true);
return reject('Insufficient funds');
}
this.wallets = _.clone(filteredWallets);
return resolve();
}
}).catch((err: any) => {
this.logger.error(err);
if (++index == this.wallets.length) {
if (!walletsUpdated)
return reject('Could not update any wallet');
if (_.isEmpty(filteredWallets)) {
this.setNoWallet(this.translate.instant('Insufficient funds'), true);
return reject('Insufficient funds for fee');
}
this.wallets = _.clone(filteredWallets);
return resolve();
}
});
});
});
}
private setNoWallet(msg: string, criticalError?: boolean) {
this.wallet = null;
this.noWalletMessage = msg;
this.criticalError = criticalError;
this.logger.warn('Not ready to make the payment: ' + msg);
};
private exitWithError(err: any) {
this.logger.info('Error setting wallet selector:' + err);
this.popupProvider.ionicAlert("", this.bwcErrorProvider.msg(err)).then(() => {
this.navCtrl.popToRoot({ animate: false });
});
};
/* sets a wallet on the UI, creates a TXPs for that wallet */
private setWallet(wallet: any): void {
this.wallet = wallet;
// If select another wallet
this.tx.coin = this.wallet.coin;
if (this.tx.coin == 'bch') this.tx.feeLevel = 'normal';
this.setButtonText(this.wallet.credentials.m > 1, !!this.tx.paypro);
if (this.tx.paypro)
this.paymentTimeControl(this.tx.paypro.expires);
this.tx.feeLevelName = this.feeProvider.feeOpts[this.tx.feeLevel];
this.updateTx(this.tx, this.wallet, { dryRun: true }).catch((err: any) => {
this.logger.warn(err);
});
}
private setButtonText(isMultisig: boolean, isPayPro: boolean): void {
if (isPayPro) {
this.buttonText = this.translate.instant('Click to pay');
} else if (isMultisig) {
this.buttonText = this.translate.instant('Click to accept');
this.successText = this.wallet.credentials.n == 1 ? this.translate.instant('Payment Sent') : this.translate.instant('Proposal created');
} else {
this.buttonText = this.translate.instant('Click to send');
this.successText = this.translate.instant('Payment Sent');
}
}
private paymentTimeControl(expirationTime: number): void {
this.paymentExpired = false;
this.setExpirationTime(expirationTime);
let countDown: any = setInterval(() => {
this.setExpirationTime(expirationTime, countDown);
}, 1000);
}
private setExpirationTime(expirationTime: number, countDown?: any): void {
let now = Math.floor(Date.now() / 1000);
if (now > expirationTime) {
this.paymentExpired = true;
this.remainingTimeStr = this.translate.instant('Expired');
if (countDown) {
/* later */
clearInterval(countDown);
}
return;
}
let totalSecs = expirationTime - now;
let m = Math.floor(totalSecs / 60);
let s = totalSecs % 60;
this.remainingTimeStr = ('0' + m).slice(-2) + ":" + ('0' + s).slice(-2);
}
private updateTx(tx: any, wallet: any, opts: any): Promise<any> {
return new Promise((resolve, reject) => {
if (opts.clearCache) {
tx.txp = {};
}
this.tx = tx;
// End of quick refresh, before wallet is selected.
if (!wallet) {
return resolve();
}
this.onGoingProcessProvider.set('calculatingFee');
this.feeProvider.getFeeRate(wallet.coin, tx.network, tx.feeLevel).then((feeRate: any) => {
if (!this.usingCustomFee) tx.feeRate = feeRate;
// call getSendMaxInfo if was selected from amount view
if (tx.sendMax) {
this.useSendMax(tx, wallet, opts).then(() => {
return resolve();
}).catch((err: any) => {
return reject(err);
});
} else {
// txp already generated for this wallet?
if (tx.txp[wallet.id]) {
this.onGoingProcessProvider.clear();
return resolve();
}
this.buildTxp(tx, wallet, opts).then(() => {
this.onGoingProcessProvider.clear();
return resolve();
}).catch((err: any) => {
this.onGoingProcessProvider.clear();
return reject(err);
});
}
}).catch((err: any) => {
this.onGoingProcessProvider.clear();
return reject(err);
});
});
}
private useSendMax(tx: any, wallet: any, opts: any) {
return new Promise((resolve, reject) => {
this.getSendMaxInfo(_.clone(tx), wallet).then((sendMaxInfo: any) => {
if (sendMaxInfo) {
this.logger.debug('Send max info', sendMaxInfo);
if (sendMaxInfo.amount == 0) {
this.setNoWallet(this.translate.instant('Insufficient funds for fee'), false);
this.popupProvider.ionicAlert('Error', 'Not enough funds for fee').then(() => {
return resolve('no_funds');
});
}
tx.sendMaxInfo = sendMaxInfo;
tx.amount = tx.sendMaxInfo.amount;
setTimeout(() => {
this.showSendMaxWarning(wallet, sendMaxInfo);
}, 200);
}
// txp already generated for this wallet?
if (tx.txp[wallet.id]) {
return resolve();
}
this.buildTxp(tx, wallet, opts).then(() => {
return resolve();
}).catch((err: any) => {
return reject(err);
});
}).catch((err: any) => {
let msg = this.translate.instant('Error getting SendMax information');
return reject(msg);
});
});
}
private buildTxp(tx: any, wallet: any, opts: any): Promise<any> {
return new Promise((resolve, reject) => {
this.getTxp(_.clone(tx), wallet, opts.dryRun).then((txp: any) => {
let per = (txp.fee / (txp.amount + txp.fee) * 100);
txp.feeRatePerStr = per.toFixed(2) + '%';
txp.feeTooHigh = per > this.FEE_TOO_HIGH_LIMIT_PER;
if (txp.feeTooHigh) {
let feeWarningModal = this.modalCtrl.create(FeeWarningPage, {}, { showBackdrop: false, enableBackdropDismiss: false });
feeWarningModal.present();
}
tx.txp[wallet.id] = txp;
this.tx = tx;
this.logger.debug('Confirm. TX Fully Updated for wallet:' + wallet.id, JSON.stringify(tx));
return resolve();
}).catch((err: any) => {
return reject(err);
});
});
}
private getSendMaxInfo(tx: any, wallet: any): Promise<any> {
return new Promise((resolve, reject) => {
if (!tx.sendMax) return resolve();
this.onGoingProcessProvider.set('retrievingInputs');
this.walletProvider.getSendMaxInfo(wallet, {
feePerKb: tx.feeRate,
excludeUnconfirmedUtxos: !tx.spendUnconfirmed,
returnInputs: true,
}).then((res: any) => {
this.onGoingProcessProvider.clear();
return resolve(res);
}).catch((err: any) => {
this.onGoingProcessProvider.clear();
this.logger.warn(err);
return reject(err);
});
});
}
private showSendMaxWarning(wallet: any, sendMaxInfo: any): void {
let fee = (sendMaxInfo.fee / 1e8);
let msg = fee + " " + this.tx.coin.toUpperCase() + " will be deducted for bitcoin networking fees.";
let warningMsg = this.verifyExcludedUtxos(wallet, sendMaxInfo);
if (!_.isEmpty(warningMsg))
msg += '\n' + warningMsg;
this.popupProvider.ionicAlert(null, msg);
}
private verifyExcludedUtxos(wallet: any, sendMaxInfo: any): any {
let warningMsg = [];
if (sendMaxInfo.utxosBelowFee > 0) {
let amountBelowFeeStr = (sendMaxInfo.amountBelowFee / 1e8);
let message = "A total of " + amountBelowFeeStr + " " + this.tx.coin.toUpperCase() + " were excluded. These funds come from UTXOs smaller than the network fee provided."; // TODO: translate
warningMsg.push(message);
}
if (sendMaxInfo.utxosAboveMaxSize > 0) {
let amountAboveMaxSizeStr = (sendMaxInfo.amountAboveMaxSize / 1e8);
let message = "A total of " + amountAboveMaxSizeStr + " " + this.tx.coin.toUpperCase() + " were excluded. The maximum size allowed for a transaction was exceeded."; // TODO: translate
warningMsg.push(message);
}
return warningMsg.join('\n');
};
private getTxp(tx: any, wallet: any, dryRun: boolean): Promise<any> {
return new Promise((resolve, reject) => {
// ToDo: use a credential's (or fc's) function for this
if (tx.description && !wallet.credentials.sharedEncryptingKey) {
let msg = this.translate.instant('Could not add message to imported wallet without shared encrypting key');
this.logger.warn(msg);
this.setSendError(msg);
return reject(msg);
}
if (tx.amount > Number.MAX_SAFE_INTEGER) {
let msg = this.translate.instant('Amount too big');
this.logger.warn(msg);
this.setSendError(msg);
return reject(msg);
}
let txp: any = {};
txp.outputs = [{
'toAddress': tx.toAddress,
'amount': tx.amount,
'message': tx.description
}];
if (tx.sendMaxInfo) {
txp.inputs = tx.sendMaxInfo.inputs;
txp.fee = tx.sendMaxInfo.fee;
} else {
if (this.usingCustomFee) {
txp.feePerKb = tx.feeRate;
} else txp.feeLevel = tx.feeLevel;
}
txp.message = tx.description;
if (tx.paypro) {
txp.payProUrl = tx.paypro.url;
}
txp.excludeUnconfirmedUtxos = !tx.spendUnconfirmed;
txp.dryRun = dryRun;
this.walletProvider.createTx(wallet, txp).then((ctxp: any) => {
return resolve(ctxp);
}).catch((err: any) => {
this.setSendError(err);
return reject(err);
});
});
}
private setSendError(msg: string) {
this.popupProvider.ionicAlert(this.translate.instant('Error at confirm'), this.bwcErrorProvider.msg(msg));
}
public toggleAddress(): void {
this.showAddress = !this.showAddress;
}
public onWalletSelect(wallet: any): void {
this.setWallet(wallet);
}
public showDescriptionPopup(tx) {
let message = this.translate.instant('Add description');
let opts = {
defaultText: tx.description
};
this.popupProvider.ionicPrompt(null, message, opts).then((res: string) => {
if (res) {
tx.description = res;
}
});
};
public approve(tx: any, wallet: any): void {
if (!tx || !wallet) return;
if (this.paymentExpired) {
this.popupProvider.ionicAlert(null, this.translate.instant('This bitcoin payment request has expired.'));
return;
}
this.onGoingProcessProvider.set('creatingTx');
this.getTxp(_.clone(tx), wallet, false).then((txp: any) => {
// confirm txs for more that 20usd, if not spending/touchid is enabled
let confirmTx = (): Promise<any> => {
return new Promise((resolve, reject) => {
if (this.walletProvider.isEncrypted(wallet))
return resolve();
this.txFormatProvider.formatToUSD(wallet.coin, txp.amount).then((val) => {
let amountUsd = parseFloat(val);
if (amountUsd <= this.CONFIRM_LIMIT_USD)
return resolve();
let amount = (this.tx.amount / 1e8).toFixed(8);
let unit = this.config.wallet.settings.unitName;
let name = wallet.name;
let message = 'Sending ' + amount + ' ' + unit + ' from your ' + name + ' wallet'; // TODO: translate
let okText = this.translate.instant('Confirm');
let cancelText = this.translate.instant('Cancel');
this.popupProvider.ionicConfirm(null, message, okText, cancelText).then((ok: boolean) => {
return resolve(!ok);
});
});
});
};
let publishAndSign = (): void => {
if (!wallet.canSign() && !wallet.isPrivKeyExternal()) {
this.logger.info('No signing proposal: No private key');
this.onGoingProcessProvider.set('sendingTx');
this.walletProvider.onlyPublish(wallet, txp).then(() => {
this.onGoingProcessProvider.clear();
this.openFinishModal(true);
}).catch((err: any) => {
this.onGoingProcessProvider.clear();
this.setSendError(err);
});
return;
}
this.walletProvider.publishAndSign(wallet, txp).then((txp: any) => {
this.onGoingProcessProvider.clear();
if (this.config.confirmedTxsNotifications && this.config.confirmedTxsNotifications.enabled) {
this.txConfirmNotificationProvider.subscribe(wallet, {
txid: txp.txid
});
}
this.openFinishModal();
}).catch((err: any) => {
this.onGoingProcessProvider.clear();
this.setSendError(err);
return;
});
};
confirmTx().then((nok: boolean) => {
if (nok) {
this.onGoingProcessProvider.clear();
return;
}
publishAndSign();
});
}).catch((err: any) => {
this.onGoingProcessProvider.clear();
this.logger.warn(err);
return;
});
}
private openFinishModal(onlyPublish?: boolean) {
let params: any = { finishText: this.successText };
if (onlyPublish) {
let finishText = this.translate.instant('Payment Published');
let finishComment = this.translate.instant('You could sign the transaction later in your wallet details');
params = { finishText, finishComment };
}
let modal = this.modalCtrl.create(FinishModalPage, params, { showBackdrop: true, enableBackdropDismiss: false });
modal.present();
modal.onDidDismiss(() => {
this.navCtrl.popToRoot({ animate: false }).then(() => {
this.navCtrl.parent.select(0);
});
})
}
public openPPModal(): void {
let modal = this.modalCtrl.create(PayProPage, { tx: this.tx }, {
showBackdrop: true,
enableBackdropDismiss: true,
});
modal.present();
};
public chooseFeeLevel(): void {
if (this.tx.coin == 'bch') return;
let txObject: any = {};
txObject.network = this.tx.network;
txObject.feeLevel = this.tx.feeLevel;
txObject.noSave = true;
txObject.coin = this.tx.coin;
if (this.usingCustomFee) {
txObject.customFeePerKB = this.tx.feeRate;
txObject.feePerSatByte = this.tx.feeRate / 1000;
}
const myModal = this.modalCtrl.create(ChooseFeeLevelPage, txObject, {
showBackdrop: true,
enableBackdropDismiss: false,
});
myModal.present();
myModal.onDidDismiss((data: any) => {
this.logger.debug('New fee level choosen:' + data.newFeeLevel + ' was:' + this.tx.feeLevel);
this.usingCustomFee = data.newFeeLevel == 'custom' ? true : false;
if (this.tx.feeLevel == data.newFeeLevel && !this.usingCustomFee) {
return;
}
this.tx.feeLevel = data.newFeeLevel;
this.tx.feeLevelName = this.feeProvider.feeOpts[this.tx.feeLevel];
if (this.usingCustomFee) this.tx.feeRate = parseInt(data.customFeePerKB, 10);
this.updateTx(this.tx, this.wallet, { clearCache: true, dryRun: true }).catch((err: any) => {
this.logger.warn(err);
});
});
};
public showWallets(): void {
let id = this.wallet ? this.wallet.credentials.walletId : null;
this.events.publish('showWalletsSelectorEvent', this.wallets, id, this.walletSelectorTitle);
this.events.subscribe('selectWalletEvent', (wallet: any) => {
if (!_.isEmpty(wallet)) this.onWalletSelect(wallet);
this.events.unsubscribe('selectWalletEvent');
});
}
}