From 2138493bf13a2de55b8650567fa34d94bf629b04 Mon Sep 17 00:00:00 2001 From: Overtorment Date: Mon, 24 Feb 2020 21:45:14 +0000 Subject: [PATCH] REF: coldcard integration --- class/abstract-hd-electrum-wallet.js | 10 ++++-- class/abstract-wallet.js | 13 +++++++ class/watch-only-wallet.js | 41 +++++++++++++---------- screen/send/details.js | 5 +-- screen/send/psbtWithHardwareWallet.js | 17 ++++++++-- screen/wallets/details.js | 29 ++++++++-------- screen/wallets/transactions.js | 4 +-- tests/integration/WatchOnlyWallet.test.js | 41 +++++++++++++++++++++++ 8 files changed, 119 insertions(+), 41 deletions(-) diff --git a/class/abstract-hd-electrum-wallet.js b/class/abstract-hd-electrum-wallet.js index 0d8f287be..0e1859b39 100644 --- a/class/abstract-hd-electrum-wallet.js +++ b/class/abstract-hd-electrum-wallet.js @@ -636,6 +636,7 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { async fetchUtxo() { // considering only confirmed balance + // also, fetching utxo of addresses that only have some balance let addressess = []; for (let c = 0; c < this.next_free_address_index + this.gap_limit; c++) { @@ -718,6 +719,7 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { * @param changeAddress {String} Excessive coins will go back to that address * @param sequence {Number} Used in RBF * @param skipSigning {boolean} Whether we should skip signing, use returned `psbt` in that case + * @param masterFingerprint {number} Decimal number of wallet's master fingerprint * @returns {{outputs: Array, tx: Transaction, inputs: Array, fee: Number, psbt: Psbt}} */ createTransaction(utxos, targets, feeRate, changeAddress, sequence, skipSigning = false, masterFingerprint) { @@ -759,7 +761,9 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { let pubkey = this._getPubkeyByAddress(input.address); let masterFingerprintBuffer; if (masterFingerprint) { - const hexBuffer = Buffer.from(Number(masterFingerprint).toString(16), 'hex'); + let masterFingerprintHex = Number(masterFingerprint).toString(16); + if (masterFingerprintHex.length < 8) masterFingerprintHex = '0' + masterFingerprintHex; // conversion without explicit zero might result in lost byte + const hexBuffer = Buffer.from(masterFingerprintHex, 'hex'); masterFingerprintBuffer = Buffer.from(reverse(hexBuffer)); } else { masterFingerprintBuffer = Buffer.from([0x00, 0x00, 0x00, 0x00]); @@ -799,7 +803,9 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { let masterFingerprintBuffer; if (masterFingerprint) { - const hexBuffer = Buffer.from(Number(masterFingerprint).toString(16), 'hex'); + let masterFingerprintHex = Number(masterFingerprint).toString(16); + if (masterFingerprintHex.length < 8) masterFingerprintHex = '0' + masterFingerprintHex; // conversion without explicit zero might result in lost byte + const hexBuffer = Buffer.from(masterFingerprintHex, 'hex'); masterFingerprintBuffer = Buffer.from(reverse(hexBuffer)); } else { masterFingerprintBuffer = Buffer.from([0x00, 0x00, 0x00, 0x00]); diff --git a/class/abstract-wallet.js b/class/abstract-wallet.js index bf0eea6e8..7cbc54dfc 100644 --- a/class/abstract-wallet.js +++ b/class/abstract-wallet.js @@ -128,6 +128,19 @@ export class AbstractWallet { setSecret(newSecret) { this.secret = newSecret.trim(); + + try { + const parsedSecret = JSON.parse(this.secret); + if (parsedSecret && parsedSecret.keystore && parsedSecret.keystore.xpub) { + let masterFingerprint = false; + if (parsedSecret.keystore.ckcc_xfp) { + // It is a ColdCard Hardware Wallet + masterFingerprint = Number(parsedSecret.keystore.ckcc_xfp); + } + this.secret = parsedSecret.keystore.xpub; + this.masterFingerprint = masterFingerprint; + } + } catch (_) {} return this; } diff --git a/class/watch-only-wallet.js b/class/watch-only-wallet.js index 868d6405c..2bf0bbdba 100644 --- a/class/watch-only-wallet.js +++ b/class/watch-only-wallet.js @@ -44,22 +44,8 @@ export class WatchOnlyWallet extends LegacyWallet { try { bitcoin.address.toOutputScript(this.getAddress()); return true; - } catch (_e) { - try { - const parsedSecret = JSON.parse(this.secret); - if (parsedSecret.keystore.xpub) { - let masterFingerprint = false; - if (parsedSecret.keystore.ckcc_xfp) { - // It is a ColdCard Hardware Wallet - masterFingerprint = Number(parsedSecret.keystore.ckcc_xfp); - } - this.setSecret(parsedSecret.keystore.xpub); - this.masterFingerprint = masterFingerprint; - } - return true; - } catch (_e) { - return false; - } + } catch (_) { + return false; } } @@ -161,9 +147,30 @@ export class WatchOnlyWallet extends LegacyWallet { */ createTransaction(utxos, targets, feeRate, changeAddress, sequence) { if (this._hdWalletInstance instanceof HDSegwitBech32Wallet) { - return this._hdWalletInstance.createTransaction(utxos, targets, feeRate, changeAddress, sequence, true, this.masterFingerprint); + return this._hdWalletInstance.createTransaction(utxos, targets, feeRate, changeAddress, sequence, true, this.getMasterFingerprint()); } else { throw new Error('Not a zpub watch-only wallet, cant create PSBT (or just not initialized)'); } } + + getMasterFingerprint() { + return this.masterFingerprint; + } + + getMasterFingerprintHex() { + let masterFingerprintHex = Number(this.masterFingerprint).toString(16); + if (masterFingerprintHex.length < 8) masterFingerprintHex = '0' + masterFingerprintHex; // conversion without explicit zero might result in lost byte + // poor man's little-endian conversion: + // ¯\_(ツ)_/¯ + return ( + masterFingerprintHex[6] + + masterFingerprintHex[7] + + masterFingerprintHex[4] + + masterFingerprintHex[5] + + masterFingerprintHex[2] + + masterFingerprintHex[3] + + masterFingerprintHex[0] + + masterFingerprintHex[1] + ); + } } diff --git a/screen/send/details.js b/screen/send/details.js index efd78ce5f..49afd7a6b 100644 --- a/screen/send/details.js +++ b/screen/send/details.js @@ -712,8 +712,9 @@ export default class SendDetails extends Component { importTransaction = async () => { try { - const res = await DocumentPicker.pick({ type: Platform.OS === 'ios' - ? ['io.bluewallet.psbt', 'io.bluewallet.psbt.txn'] : [DocumentPicker.types.allFiles] }); + const res = await DocumentPicker.pick({ + type: Platform.OS === 'ios' ? ['io.bluewallet.psbt', 'io.bluewallet.psbt.txn'] : [DocumentPicker.types.allFiles], + }); if (DeeplinkSchemaMatch.isPossiblyPSBTFile(res.uri)) { const file = await RNFS.readFile(res.uri, 'ascii'); const bufferDecoded = Buffer.from(file, 'ascii').toString('base64'); diff --git a/screen/send/psbtWithHardwareWallet.js b/screen/send/psbtWithHardwareWallet.js index 5813c8b62..8d7dd0151 100644 --- a/screen/send/psbtWithHardwareWallet.js +++ b/screen/send/psbtWithHardwareWallet.js @@ -47,11 +47,20 @@ export default class PsbtWithHardwareWallet extends Component { cameraRef = null; onBarCodeRead = ret => { + if (ret.data.indexOf('+') === -1 && ret.data.indexOf('=') === -1 && ret.data.indexOf('=') === -1) { + // this looks like NOT base64, so maybe its transaction's hex + this.setState({ txhex: ret.data }); + return; + } + if (RNCamera.Constants.CameraStatus === RNCamera.Constants.CameraStatus.READY) this.cameraRef.pausePreview(); this.setState({ renderScanner: false }, () => { console.log(ret.data); try { - let Tx = this.state.fromWallet.combinePsbt(this.state.psbt, ret.data); + let Tx = this.state.fromWallet.combinePsbt( + this.state.isFirstPSBTAlreadyBase64 ? this.state.psbt : this.state.psbt.toBase64(), + ret.data, + ); this.setState({ txhex: Tx.toHex() }); } catch (Err) { alert(Err); @@ -262,7 +271,9 @@ export default class PsbtWithHardwareWallet extends Component { openSignedTransaction = async () => { try { - const res = await DocumentPicker.pick({ type: Platform.OS === 'ios' ? ['io.bluewallet.psbt', 'io.bluewallt.psbt.txn'] : [DocumentPicker.types.allFiles] }); + const res = await DocumentPicker.pick({ + type: Platform.OS === 'ios' ? ['io.bluewallet.psbt', 'io.bluewallt.psbt.txn'] : [DocumentPicker.types.allFiles], + }); const file = await RNFS.readFile(res.uri); if (file) { this.setState({ isSecondPSBTAlreadyBase64: true }, () => this.onBarCodeRead({ data: file })); @@ -332,7 +343,7 @@ export default class PsbtWithHardwareWallet extends Component { color: BlueApp.settings.buttonTextColor, }} onPress={this.exportPSBT} - title={'Export'} + title={'Export to file'} /> diff --git a/screen/wallets/details.js b/screen/wallets/details.js index 1c2437970..3d0762d99 100644 --- a/screen/wallets/details.js +++ b/screen/wallets/details.js @@ -51,7 +51,6 @@ export default class WalletDetails extends Component { super(props); const wallet = props.navigation.getParam('wallet'); - console.warn(wallet.masterFingerprint) const isLoading = true; this.state = { isLoading, @@ -271,20 +270,20 @@ export default class WalletDetails extends Component { return; } } - if (this.state.wallet.getBalance() > 0 && this.state.wallet.allowSend()) { - this.presentWalletHasBalanceAlert(); - } else { - this.props.navigation.setParams({ isLoading: true }); - this.setState({ isLoading: true }, async () => { - BlueApp.deleteWallet(this.state.wallet); - ReactNativeHapticFeedback.trigger('notificationSuccess', { ignoreAndroidSystemSettings: false }); - await BlueApp.saveToDisk(); - EV(EV.enum.TRANSACTIONS_COUNT_CHANGED); - EV(EV.enum.WALLETS_COUNT_CHANGED); - this.props.navigation.navigate('Wallets'); - }); - } - } + if (this.state.wallet.getBalance() > 0 && this.state.wallet.allowSend()) { + this.presentWalletHasBalanceAlert(); + } else { + this.props.navigation.setParams({ isLoading: true }); + this.setState({ isLoading: true }, async () => { + BlueApp.deleteWallet(this.state.wallet); + ReactNativeHapticFeedback.trigger('notificationSuccess', { ignoreAndroidSystemSettings: false }); + await BlueApp.saveToDisk(); + EV(EV.enum.TRANSACTIONS_COUNT_CHANGED); + EV(EV.enum.WALLETS_COUNT_CHANGED); + this.props.navigation.navigate('Wallets'); + }); + } + }, }, { text: loc.wallets.details.no_cancel, onPress: () => {}, style: 'cancel' }, ], diff --git a/screen/wallets/transactions.js b/screen/wallets/transactions.js index c60ebd429..0ebfeeb8f 100644 --- a/screen/wallets/transactions.js +++ b/screen/wallets/transactions.js @@ -603,11 +603,11 @@ export default class WalletTransactions extends Component { text: loc._.ok, onPress: () => { const wallet = this.state.wallet; - wallet.use_with_hardware_wallet = true + wallet.use_with_hardware_wallet = true; this.setState({ wallet }, async () => { await BlueApp.saveToDisk(); this.navigateToSendScreen(); - }) + }); }, style: 'default', }, diff --git a/tests/integration/WatchOnlyWallet.test.js b/tests/integration/WatchOnlyWallet.test.js index ef307fa27..3beacaba6 100644 --- a/tests/integration/WatchOnlyWallet.test.js +++ b/tests/integration/WatchOnlyWallet.test.js @@ -111,6 +111,47 @@ describe('Watch only wallet', () => { ); }); + it('can import coldcard/electrum compatible JSON skeleton wallet, and create a tx with master fingerprint', async () => { + jasmine.DEFAULT_TIMEOUT_INTERVAL = 120 * 1000; + const skeleton = + '{"keystore": {"ckcc_xpub": "xpub661MyMwAqRbcGmUDQVKxmhEESB5xTk8hbsdTSV3Pmhm3HE9Fj3s45R9Y8LwyaQWjXXPytZjuhTKSyCBPeNrB1VVWQq1HCvjbEZ27k44oNmg", "xpub": "zpub6rFDtF1nuXZ9PUL4XzKURh3vJBW6Kj6TUrYL4qPtFNtDXtcTVfiqjQDyrZNwjwzt5HS14qdqo3Co2282Lv3Re6Y5wFZxAVuMEpeygnnDwfx", "label": "Coldcard Import 168DD603", "ckcc_xfp": 64392470, "type": "hardware", "hw_type": "coldcard", "derivation": "m/84\'/0\'/0\'"}, "wallet_type": "standard", "use_encryption": false, "seed_version": 17}'; + let w = new WatchOnlyWallet(); + w.setSecret(skeleton); + w.init(); + assert.ok(w.valid()); + assert.strictEqual( + w.getSecret(), + 'zpub6rFDtF1nuXZ9PUL4XzKURh3vJBW6Kj6TUrYL4qPtFNtDXtcTVfiqjQDyrZNwjwzt5HS14qdqo3Co2282Lv3Re6Y5wFZxAVuMEpeygnnDwfx', + ); + assert.strictEqual(w.getMasterFingerprint(), 64392470); + assert.strictEqual(w.getMasterFingerprintHex(), '168dd603'); + + const utxos = [ + { + height: 618811, + value: 66600, + address: 'bc1qzqjwye4musmz56cg44ttnchj49zueh9yr0qsxt', + txId: '5df595dc09ee7a5c245b34ea519288137ffee731629c4ff322a6de4f72c06222', + vout: 0, + txid: '5df595dc09ee7a5c245b34ea519288137ffee731629c4ff322a6de4f72c06222', + amount: 66600, + wif: false, + confirmations: 1, + }, + ]; + + let { psbt } = await w.createTransaction( + utxos, + [{ address: 'bc1qdamevhw3zwm0ajsmyh39x8ygf0jr0syadmzepn', value: 5000 }], + 22, + 'bc1qtutssamysdkgd87df0afjct0mztx56qpze7wqe', + ); + assert.strictEqual( + psbt.toBase64(), + 'cHNidP8BAHECAAAAASJiwHJP3qYi80+cYjHn/n8TiJJR6jRbJFx67gnclfVdAAAAAAAAAACAAogTAAAAAAAAFgAUb3eWXdETtv7KGyXiUxyIS+Q3wJ1K3QAAAAAAABYAFF8XCHdkg2yGn81L+plhb9iWamgBAAAAAAABAR8oBAEAAAAAABYAFBAk4ma75DYqawitVrni8qlFzNykIgYDNK9TxoCjQ8P0+qI2Hu4hrnXnJuYAC3h2puZbgRORp+sYFo3WA1QAAIAAAACAAAAAgAAAAAAAAAAAAAAiAgL1DWeV+AfIP5RRB5zHv5vuXsIt8+rF9rrsji3FhQlhzBgWjdYDVAAAgAAAAIAAAACAAQAAAAAAAAAA', + ); + }); + it('can combine signed PSBT and prepare it for broadcast', async () => { let w = new WatchOnlyWallet(); w.setSecret('zpub6rjLjQVqVnj7crz9E4QWj4WgczmEseJq22u2B6k2HZr6NE2PQx3ZYg8BnbjN9kCfHymSeMd2EpwpM5iiz5Nrb3TzvddxW2RMcE3VXdVaXHk');