diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6498f63c1..4ecd19304 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,6 +39,7 @@ jobs: - name: Run tests run: npm test || npm test || npm test env: + BIP47_HD_MNEMONIC: ${{ secrets.BIP47_HD_MNEMONIC}} HD_MNEMONIC: ${{ secrets.HD_MNEMONIC }} HD_MNEMONIC_BIP49: ${{ secrets.HD_MNEMONIC_BIP49 }} HD_MNEMONIC_BIP49_MANY_TX: ${{ secrets.HD_MNEMONIC_BIP49_MANY_TX }} diff --git a/Navigation.js b/Navigation.js index e3a426319..cbe95d84b 100644 --- a/Navigation.js +++ b/Navigation.js @@ -83,6 +83,9 @@ import { isDesktop, isTablet, isHandset } from './blue_modules/environment'; import SettingsPrivacy from './screen/settings/SettingsPrivacy'; import LNDViewAdditionalInvoicePreImage from './screen/lnd/lndViewAdditionalInvoicePreImage'; import LdkViewLogs from './screen/wallets/ldkViewLogs'; +import PaymentCode from './screen/wallets/paymentCode'; +import PaymentCodesList from './screen/wallets/paymentCodesList'; +import loc from './loc'; const WalletsStack = createNativeStackNavigator(); @@ -465,6 +468,20 @@ const ExportMultisigCoordinationSetupRoot = () => { ); }; +const PaymentCodeStack = createNativeStackNavigator(); +const PaymentCodeStackRoot = () => { + return ( + + + + + ); +}; + const RootStack = createNativeStackNavigator(); const NavigationDefaultOptions = { headerShown: false, stackPresentation: isDesktop ? 'containedModal' : 'modal' }; const Navigation = () => { @@ -500,6 +517,8 @@ const Navigation = () => { stackPresentation: isDesktop ? 'containedModal' : 'fullScreenModal', }} /> + + ); }; diff --git a/blue_modules/storage-context.js b/blue_modules/storage-context.js index b230a7184..700c1254c 100644 --- a/blue_modules/storage-context.js +++ b/blue_modules/storage-context.js @@ -120,6 +120,10 @@ export const BlueStorageProvider = ({ children }) => { setWalletTransactionUpdateStatus(WalletTransactionsStatus.ALL); } await BlueElectrum.waitTillConnected(); + const paymentCodesStart = Date.now(); + await fetchSenderPaymentCodes(lastSnappedTo); + const paymentCodesEnd = Date.now(); + console.log('fetch payment codes took', (paymentCodesEnd - paymentCodesStart) / 1000, 'sec'); const balanceStart = +new Date(); await fetchWalletBalances(lastSnappedTo); const balanceEnd = +new Date(); @@ -201,6 +205,7 @@ export const BlueStorageProvider = ({ children }) => { const getTransactions = BlueApp.getTransactions; const isAdvancedModeEnabled = BlueApp.isAdvancedModeEnabled; + const fetchSenderPaymentCodes = BlueApp.fetchSenderPaymentCodes; const fetchWalletBalances = BlueApp.fetchWalletBalances; const fetchWalletTransactions = BlueApp.fetchWalletTransactions; const getBalance = BlueApp.getBalance; diff --git a/class/app-storage.js b/class/app-storage.js index 08f1e29cc..7dcb7cb90 100644 --- a/class/app-storage.js +++ b/class/app-storage.js @@ -682,6 +682,7 @@ export class AppStorage { } } else { for (const wallet of this.wallets) { + console.log('fetching balance for', wallet.getLabel()); await wallet.fetchBalance(); } } @@ -725,6 +726,27 @@ export class AppStorage { } }; + fetchSenderPaymentCodes = async index => { + console.log('fetchSenderPaymentCodes for wallet#', typeof index === 'undefined' ? '(all)' : index); + if (index || index === 0) { + try { + if (!this.wallets[index].allowBIP47()) return; + await this.wallets[index].fetchBIP47SenderPaymentCodes(); + } catch (error) { + console.error('Failed to fetch sender payment codes for wallet', index, error); + } + } else { + for (const wallet of this.wallets) { + try { + if (!wallet.allowBIP47()) continue; + await wallet.fetchBIP47SenderPaymentCodes(); + } catch (error) { + console.error('Failed to fetch sender payment codes for wallet', wallet.label, error); + } + } + } + }; + /** * * @returns {Array.} diff --git a/class/wallets/abstract-hd-electrum-wallet.ts b/class/wallets/abstract-hd-electrum-wallet.ts index 409782fd5..393b9c58e 100644 --- a/class/wallets/abstract-hd-electrum-wallet.ts +++ b/class/wallets/abstract-hd-electrum-wallet.ts @@ -4,6 +4,7 @@ import BigNumber from 'bignumber.js'; import b58 from 'bs58check'; import BIP32Factory, { BIP32Interface } from 'bip32'; import * as ecc from 'tiny-secp256k1'; +import BIP47Factory, { BIP47Interface } from '@spsina/bip47'; import { randomBytes } from '../rng'; import { AbstractHDWallet } from './abstract-hd-wallet'; @@ -20,6 +21,7 @@ const bitcoin = require('bitcoinjs-lib'); const BlueElectrum: typeof BlueElectrumNs = require('../../blue_modules/BlueElectrum'); const reverse = require('buffer-reverse'); const bip32 = BIP32Factory(ecc); +const bip47 = BIP47Factory(ecc); type BalanceByIndex = { c: number; @@ -45,6 +47,16 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { _utxo: any[]; + // BIP47 + _enable_BIP47: boolean; + _payment_code: string; + _sender_payment_codes: string[]; + _addresses_by_payment_code: Record; + _next_free_payment_code_address_index: Record; + _txs_by_payment_code_index: Record; + _balances_by_payment_code_index: Record; + _bip47_instance?: BIP47Interface; + constructor() { super(); this._balances_by_external_index = {}; // 0 => { c: 0, u: 0 } // confirmed/unconfirmed @@ -54,6 +66,15 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { this._txs_by_internal_index = {}; this._utxo = []; + + // BIP47 + this._enable_BIP47 = false; + this._payment_code = ''; + this._sender_payment_codes = []; + this._next_free_payment_code_address_index = {}; + this._txs_by_payment_code_index = {}; + this._balances_by_payment_code_index = {}; + this._addresses_by_payment_code = {}; } /** @@ -67,6 +88,9 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { for (const bal of Object.values(this._balances_by_internal_index)) { ret += bal.c; } + for (const pc of this._sender_payment_codes) { + ret += this._getBalancesByPaymentCodeIndex(pc).c; + } return ret + (this.getUnconfirmedBalance() < 0 ? this.getUnconfirmedBalance() : 0); } @@ -82,6 +106,9 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { for (const bal of Object.values(this._balances_by_internal_index)) { ret += bal.u; } + for (const pc of this._sender_payment_codes) { + ret += this._getBalancesByPaymentCodeIndex(pc).u; + } return ret; } @@ -121,7 +148,7 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { return child.toWIF(); } - _getNodeAddressByIndex(node: number, index: number) { + _getNodeAddressByIndex(node: number, index: number): string { index = index * 1; // cast to int if (node === 0) { if (this.external_addresses_cache[index]) return this.external_addresses_cache[index]; // cache hit @@ -143,22 +170,20 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { this._node1 = hdNode.derive(node); } - let address; + let address: string; if (node === 0) { // @ts-ignore - address = this.constructor._nodeToBech32SegwitAddress(this._node0.derive(index)); - } - - if (node === 1) { + address = this._hdNodeToAddress(this._node0.derive(index)); + } else { + // tbh the only possible else is node === 1 // @ts-ignore - address = this.constructor._nodeToBech32SegwitAddress(this._node1.derive(index)); + address = this._hdNodeToAddress(this._node1.derive(index)); } if (node === 0) { return (this.external_addresses_cache[index] = address); - } - - if (node === 1) { + } else { + // tbh the only possible else option is node === 1 return (this.internal_addresses_cache[index] = address); } } @@ -189,7 +214,7 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { throw new Error('Internal error: this._node0 or this._node1 is undefined'); } - _getExternalAddressByIndex(index: number) { + _getExternalAddressByIndex(index: number): string { return this._getNodeAddressByIndex(0, index); } @@ -266,6 +291,21 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { } } + // next, bip47 addresses + for (const pc of this._sender_payment_codes) { + for (let c = 0; c < this._getNextFreePaymentCodeAddress(pc) + this.gap_limit; c++) { + let hasUnconfirmed = false; + this._txs_by_payment_code_index[pc] = this._txs_by_payment_code_index[pc] || {}; + this._txs_by_payment_code_index[pc][c] = this._txs_by_payment_code_index[pc][c] || []; + for (const tx of this._txs_by_payment_code_index[pc][c]) + hasUnconfirmed = hasUnconfirmed || !tx.confirmations || tx.confirmations < 7; + + if (hasUnconfirmed || this._txs_by_payment_code_index[pc][c].length === 0 || this._balances_by_payment_code_index[pc].u !== 0) { + addresses2fetch.push(this._getBIP47Address(pc, c)); + } + } + } + // first: batch fetch for all addresses histories const histories = await BlueElectrum.multiGetHistoryByAddress(addresses2fetch); const txs: Record = {}; @@ -312,6 +352,11 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { for (let c = 0; c < this.next_free_change_address_index + this.gap_limit; c++) { this._txs_by_internal_index[c] = this._txs_by_internal_index[c].filter(tx => !!tx.confirmations); } + for (const pc of this._sender_payment_codes) { + for (let c = 0; c < this._getNextFreePaymentCodeAddress(pc) + this.gap_limit; c++) { + this._txs_by_payment_code_index[pc][c] = this._txs_by_payment_code_index[pc][c].filter(tx => !!tx.confirmations); + } + } // now, we need to put transactions in all relevant `cells` of internal hashmaps: this._txs_by_internal_index && this._txs_by_external_index @@ -397,6 +442,51 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { } } + for (const pc of this._sender_payment_codes) { + for (let c = 0; c < this._getNextFreePaymentCodeAddress(pc) + this.gap_limit; c++) { + for (const tx of Object.values(txdatas)) { + for (const vin of tx.vin) { + if (vin.addresses && vin.addresses.indexOf(this._getBIP47Address(pc, c)) !== -1) { + // this TX is related to our address + this._txs_by_payment_code_index[pc] = this._txs_by_payment_code_index[pc] || {}; + this._txs_by_payment_code_index[pc][c] = this._txs_by_payment_code_index[pc][c] || []; + const { vin: txVin, vout: txVout, ...txRest } = tx; + const clonedTx = { ...txRest, inputs: txVin.slice(0), outputs: txVout.slice(0) }; + + // trying to replace tx if it exists already (because it has lower confirmations, for example) + let replaced = false; + for (let cc = 0; cc < this._txs_by_payment_code_index[pc][c].length; cc++) { + if (this._txs_by_payment_code_index[pc][c][cc].txid === clonedTx.txid) { + replaced = true; + this._txs_by_payment_code_index[pc][c][cc] = clonedTx; + } + } + if (!replaced) this._txs_by_payment_code_index[pc][c].push(clonedTx); + } + } + for (const vout of tx.vout) { + if (vout.scriptPubKey.addresses && vout.scriptPubKey.addresses.indexOf(this._getBIP47Address(pc, c)) !== -1) { + // this TX is related to our address + this._txs_by_payment_code_index[pc] = this._txs_by_payment_code_index[pc] || {}; + this._txs_by_payment_code_index[pc][c] = this._txs_by_payment_code_index[pc][c] || []; + const { vin: txVin, vout: txVout, ...txRest } = tx; + const clonedTx = { ...txRest, inputs: txVin.slice(0), outputs: txVout.slice(0) }; + + // trying to replace tx if it exists already (because it has lower confirmations, for example) + let replaced = false; + for (let cc = 0; cc < this._txs_by_internal_index[c].length; cc++) { + if (this._txs_by_internal_index[c][cc].txid === clonedTx.txid) { + replaced = true; + this._txs_by_internal_index[c][cc] = clonedTx; + } + } + if (!replaced) this._txs_by_internal_index[c].push(clonedTx); + } + } + } + } + } + this._lastTxFetch = +new Date(); } @@ -409,6 +499,14 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { for (const addressTxs of Object.values(this._txs_by_internal_index)) { txs = txs.concat(addressTxs); } + if (this._sender_payment_codes) { + for (const pc of this._sender_payment_codes) { + if (this._txs_by_payment_code_index[pc]) + for (const addressTxs of Object.values(this._txs_by_payment_code_index[pc])) { + txs = txs.concat(addressTxs); + } + } + } if (txs.length === 0) return []; // guard clause; so we wont spend time calculating addresses @@ -421,6 +519,12 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { for (let c = 0; c < this.next_free_change_address_index + 1; c++) { ownedAddressesHashmap[this._getInternalAddressByIndex(c)] = true; } + if (this._sender_payment_codes) + for (const pc of this._sender_payment_codes) { + for (let c = 0; c < this._getNextFreePaymentCodeAddress(pc) + 1; c++) { + ownedAddressesHashmap[this._getBIP47Address(pc, c)] = true; + } + } // hack: in case this code is called from LegacyWallet: if (this.getAddress()) ownedAddressesHashmap[String(this.getAddress())] = true; @@ -499,7 +603,7 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { ) { const address = this._getInternalAddressByIndex(c); if (lastHistoriesWithUsedAddresses[address] && lastHistoriesWithUsedAddresses[address].length > 0) { - lastUsedIndex = Math.max(c, lastUsedIndex) + 1; // point to next, which is supposed to be unsued + lastUsedIndex = Math.max(c, lastUsedIndex) + 1; // point to next, which is supposed to be unused } } } @@ -542,7 +646,50 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { ) { const address = this._getExternalAddressByIndex(c); if (lastHistoriesWithUsedAddresses[address] && lastHistoriesWithUsedAddresses[address].length > 0) { - lastUsedIndex = Math.max(c, lastUsedIndex) + 1; // point to next, which is supposed to be unsued + lastUsedIndex = Math.max(c, lastUsedIndex) + 1; // point to next, which is supposed to be unused + } + } + } + + return lastUsedIndex; + } + + async _binarySearchIterationForBIP47Address(paymentCode: string, index: number) { + const generateChunkAddresses = (chunkNum: number) => { + const ret = []; + for (let c = this.gap_limit * chunkNum; c < this.gap_limit * (chunkNum + 1); c++) { + ret.push(this._getBIP47Address(paymentCode, c)); + } + return ret; + }; + + let lastChunkWithUsedAddressesNum = null; + let lastHistoriesWithUsedAddresses = null; + for (let c = 0; c < Math.round(index / this.gap_limit); c++) { + const histories = await BlueElectrum.multiGetHistoryByAddress(generateChunkAddresses(c)); + // @ts-ignore + if (this.constructor._getTransactionsFromHistories(histories).length > 0) { + // in this particular chunk we have used addresses + lastChunkWithUsedAddressesNum = c; + lastHistoriesWithUsedAddresses = histories; + } else { + // empty chunk. no sense searching more chunks + break; + } + } + + let lastUsedIndex = 0; + + if (lastHistoriesWithUsedAddresses) { + // now searching for last used address in batch lastChunkWithUsedAddressesNum + for ( + let c = Number(lastChunkWithUsedAddressesNum) * this.gap_limit; + c < Number(lastChunkWithUsedAddressesNum) * this.gap_limit + this.gap_limit; + c++ + ) { + const address = this._getBIP47Address(paymentCode, c); + if (lastHistoriesWithUsedAddresses[address] && lastHistoriesWithUsedAddresses[address].length > 0) { + lastUsedIndex = Math.max(c, lastUsedIndex) + 1; // point to next, which is supposed to be unused } } } @@ -556,6 +703,11 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { // doing binary search for last used address: this.next_free_change_address_index = await this._binarySearchIterationForInternalAddress(1000); this.next_free_address_index = await this._binarySearchIterationForExternalAddress(1000); + if (this._sender_payment_codes) { + for (const pc of this._sender_payment_codes) { + this._next_free_payment_code_address_index[pc] = await this._binarySearchIterationForBIP47Address(pc, 1000); + } + } } // end rescanning fresh wallet // finally fetching balance @@ -577,6 +729,15 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { for (let c = this.next_free_change_address_index; c < this.next_free_change_address_index + this.gap_limit; c++) { lagAddressesToFetch.push(this._getInternalAddressByIndex(c)); } + for (const pc of this._sender_payment_codes) { + for ( + let c = this._next_free_payment_code_address_index[pc]; + c < this._next_free_payment_code_address_index[pc] + this.gap_limit; + c++ + ) { + lagAddressesToFetch.push(this._getBIP47Address(pc, c)); + } + } const txs = await BlueElectrum.multiGetHistoryByAddress(lagAddressesToFetch); // <------ electrum call @@ -596,6 +757,20 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { } } + for (const pc of this._sender_payment_codes) { + for ( + let c = this._next_free_payment_code_address_index[pc]; + c < this._next_free_payment_code_address_index[pc] + this.gap_limit; + c++ + ) { + const address = this._getBIP47Address(pc, c); + if (txs[address] && Array.isArray(txs[address]) && txs[address].length > 0) { + // whoa, someone uses our wallet outside! better catch up + this._next_free_payment_code_address_index[pc] = c + 1; + } + } + } + // next, business as usuall. fetch balances const addresses2fetch = []; @@ -614,6 +789,12 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { addresses2fetch.push(this._getInternalAddressByIndex(c)); } + for (const pc of this._sender_payment_codes) { + for (let c = 0; c < this._next_free_payment_code_address_index[pc] + this.gap_limit; c++) { + addresses2fetch.push(this._getBIP47Address(pc, c)); + } + } + const balances = await BlueElectrum.multiGetBalanceByAddress(addresses2fetch); // converting to a more compact internal format @@ -658,6 +839,22 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { } } + for (const pc of this._sender_payment_codes) { + let confirmed = 0; + let unconfirmed = 0; + for (let c = 0; c < this._getNextFreePaymentCodeAddress(pc) + this.gap_limit; c++) { + const addr = this._getBIP47Address(pc, c); + if (balances.addresses[addr].confirmed || balances.addresses[addr].unconfirmed) { + confirmed = confirmed + balances.addresses[addr].confirmed; + unconfirmed = unconfirmed + balances.addresses[addr].unconfirmed; + } + } + this._balances_by_payment_code_index[pc] = { + c: confirmed, + u: unconfirmed, + }; + } + this._lastBalanceFetch = +new Date(); } @@ -677,6 +874,18 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { } } + for (const pc of this._sender_payment_codes) { + for (let c = 0; c < this._next_free_payment_code_address_index[pc] + this.gap_limit; c++) { + if ( + this._balances_by_payment_code_index[pc] && + this._balances_by_payment_code_index[pc].c && + this._balances_by_payment_code_index[pc].c > 0 + ) { + addressess.push(this._getBIP47Address(pc, c)); + } + } + } + // considering UNconfirmed balance: for (let c = 0; c < this.next_free_address_index + this.gap_limit; c++) { if (this._balances_by_external_index[c] && this._balances_by_external_index[c].u && this._balances_by_external_index[c].u > 0) { @@ -689,6 +898,18 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { } } + for (const pc of this._sender_payment_codes) { + for (let c = 0; c < this._next_free_payment_code_address_index[pc] + this.gap_limit; c++) { + if ( + this._balances_by_payment_code_index[pc] && + this._balances_by_payment_code_index[pc].u && + this._balances_by_payment_code_index[pc].u > 0 + ) { + addressess.push(this._getBIP47Address(pc, c)); + } + } + } + // note: we could remove checks `.c` and `.u` to simplify code, but the resulting `addressess` array would be bigger, thus bigger batch // to fetch (or maybe even several fetches), which is not critical but undesirable. // anyway, result has `.confirmations` property for each utxo, so outside caller can easily filter out unconfirmed if he wants to @@ -756,6 +977,11 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { for (let c = 0; c < this.next_free_change_address_index + 1; c++) { ownedAddressesHashmap[this._getInternalAddressByIndex(c)] = true; } + for (const pc of this._sender_payment_codes) { + for (let c = 0; c < this._getNextFreePaymentCodeAddress(pc) + 1; c++) { + ownedAddressesHashmap[this._getBIP47Address(pc, c)] = true; + } + } for (const tx of this.getTransactions()) { for (const output of tx.outputs) { @@ -827,6 +1053,11 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { for (let c = 0; c < this.next_free_change_address_index + this.gap_limit; c++) { if (this._getInternalAddressByIndex(c) === address) return this._getNodePubkeyByIndex(1, c); } + for (const pc of this._sender_payment_codes) { + for (let c = 0; c < this._getNextFreePaymentCodeAddress(pc) + this.gap_limit; c++) { + if (this._getBIP47Address(pc, c) === address) return this._getBIP47PubkeyByIndex(pc, c); + } + } return false; } @@ -844,6 +1075,11 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { for (let c = 0; c < this.next_free_change_address_index + this.gap_limit; c++) { if (this._getInternalAddressByIndex(c) === address) return this._getWIFByIndex(true, c); } + for (const pc of this._sender_payment_codes) { + for (let c = 0; c < this._getNextFreePaymentCodeAddress(pc) + this.gap_limit; c++) { + if (this._getBIP47Address(pc, c) === address) return this._getBIP47WIF(pc, c); + } + } return false; } @@ -861,6 +1097,11 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { for (let c = 0; c < this.next_free_change_address_index + this.gap_limit; c++) { if (this._getInternalAddressByIndex(c) === cleanAddress) return true; } + for (const pc of this._sender_payment_codes) { + for (let c = 0; c < this._getNextFreePaymentCodeAddress(pc) + this.gap_limit; c++) { + if (this._getBIP47Address(pc, c) === address) return true; + } + } return false; } @@ -1052,22 +1293,29 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { /** * Creates Segwit Bech32 Bitcoin address - * - * @param hdNode - * @returns {String} */ - static _nodeToBech32SegwitAddress(hdNode: BIP32Interface) { + _nodeToBech32SegwitAddress(hdNode: BIP32Interface): string { return bitcoin.payments.p2wpkh({ pubkey: hdNode.publicKey, }).address; } - static _nodeToLegacyAddress(hdNode: BIP32Interface) { + _nodeToLegacyAddress(hdNode: BIP32Interface): string { return bitcoin.payments.p2pkh({ pubkey: hdNode.publicKey, }).address; } + /** + * Creates Segwit P2SH Bitcoin address + */ + _nodeToP2shSegwitAddress(hdNode: BIP32Interface): string { + const { address } = bitcoin.payments.p2sh({ + redeem: bitcoin.payments.p2wpkh({ pubkey: hdNode.publicKey }), + }); + return address; + } + static _getTransactionsFromHistories(histories: Record) { const txs = []; for (const history of Object.values(histories)) { @@ -1192,4 +1440,108 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { const seed = this._getSeed(); return AbstractHDElectrumWallet.seedToFingerprint(seed); } + + prepareForSerialization() { + super.prepareForSerialization(); + delete this._bip47_instance; + } + + /** + * Whether BIP47 is enabled. This is per-wallet setting that can be changed, NOT a feature-flag + * @returns boolean + */ + isBIP47Enabled(): boolean { + return this._enable_BIP47; + } + + switchBIP47(value: boolean): void { + this._enable_BIP47 = value; + } + + getBIP47FromSeed(): BIP47Interface { + if (!this._bip47_instance) { + this._bip47_instance = bip47.fromBip39Seed(this.secret, undefined, this.passphrase); + } + + return this._bip47_instance; + } + + getBIP47PaymentCode(): string { + if (!this._payment_code) { + this._payment_code = this.getBIP47FromSeed().getSerializedPaymentCode(); + } + + return this._payment_code; + } + + async fetchBIP47SenderPaymentCodes(): Promise { + const bip47_instance = BIP47Factory(ecc).fromBip39Seed(this.secret, undefined, this.passphrase); + + const address = bip47_instance.getNotificationAddress(); + + const histories = await BlueElectrum.multiGetHistoryByAddress([address]); + const txHashes = histories[address].map(({ tx_hash }) => tx_hash); + + const txHexs = await BlueElectrum.multiGetTransactionByTxid(txHashes, 50, false); + for (const txHex of Object.values(txHexs)) { + try { + const paymentCode = bip47_instance.getPaymentCodeFromRawNotificationTransaction(txHex); + if (this._sender_payment_codes.includes(paymentCode)) continue; // already have it + this._sender_payment_codes.push(paymentCode); + this._next_free_payment_code_address_index[paymentCode] = 0; // initialize + this._balances_by_payment_code_index[paymentCode] = { c: 0, u: 0 }; + } catch (e) { + // do nothing + } + } + } + + getBIP47SenderPaymentCodes(): string[] { + return this._sender_payment_codes; + } + + _hdNodeToAddress(hdNode: BIP32Interface): string { + return this._nodeToBech32SegwitAddress(hdNode); + } + + _getBIP47Address(paymentCode: string, index: number): string { + if (!this._addresses_by_payment_code[paymentCode]) this._addresses_by_payment_code[paymentCode] = []; + + if (this._addresses_by_payment_code[paymentCode][index]) { + return this._addresses_by_payment_code[paymentCode][index]; + } + + const bip47_instance = this.getBIP47FromSeed(); + const senderBIP47_instance = bip47.fromPaymentCode(paymentCode); + const remotePaymentNode = senderBIP47_instance.getPaymentCodeNode(); + const hdNode = bip47_instance.getPaymentWallet(remotePaymentNode, index); + const address = this._hdNodeToAddress(hdNode); + this._address_to_wif_cache[address] = hdNode.toWIF(); + this._addresses_by_payment_code[paymentCode][index] = address; + return address; + } + + _getNextFreePaymentCodeAddress(paymentCode: string) { + return this._next_free_payment_code_address_index[paymentCode] || 0; + } + + _getBalancesByPaymentCodeIndex(paymentCode: string): BalanceByIndex { + return this._balances_by_payment_code_index[paymentCode] || { c: 0, u: 0 }; + } + + _getBIP47WIF(paymentCode: string, index: number): string { + const bip47_instance = this.getBIP47FromSeed(); + const senderBIP47_instance = bip47.fromPaymentCode(paymentCode); + const remotePaymentNode = senderBIP47_instance.getPaymentCodeNode(); + const hdNode = bip47_instance.getPaymentWallet(remotePaymentNode, index); + return hdNode.toWIF(); + } + + _getBIP47PubkeyByIndex(paymentCode: string, index: number): Buffer { + const bip47_instance = this.getBIP47FromSeed(); + const senderBIP47_instance = bip47.fromPaymentCode(paymentCode); + const remotePaymentNode = senderBIP47_instance.getPaymentCodeNode(); + const hdNode = bip47_instance.getPaymentWallet(remotePaymentNode, index); + return hdNode.publicKey; + } } diff --git a/class/wallets/abstract-wallet.ts b/class/wallets/abstract-wallet.ts index d86381bb6..a33684d73 100644 --- a/class/wallets/abstract-wallet.ts +++ b/class/wallets/abstract-wallet.ts @@ -143,6 +143,10 @@ export class AbstractWallet { return BitcoinUnit.BTC; } + allowBIP47(): boolean { + return false; + } + allowReceive(): boolean { return true; } @@ -358,6 +362,10 @@ export class AbstractWallet { return false; } + isBIP47Enabled(): boolean { + return false; + } + async wasEverUsed(): Promise { throw new Error('Not implemented'); } diff --git a/class/wallets/hd-legacy-p2pkh-wallet.js b/class/wallets/hd-legacy-p2pkh-wallet.js index c1a012d47..a85486ca0 100644 --- a/class/wallets/hd-legacy-p2pkh-wallet.js +++ b/class/wallets/hd-legacy-p2pkh-wallet.js @@ -34,6 +34,10 @@ export class HDLegacyP2PKHWallet extends AbstractHDElectrumWallet { return true; } + allowBIP47() { + return true; + } + getXpub() { if (this._xpub) { return this._xpub; // cache hit @@ -48,44 +52,8 @@ export class HDLegacyP2PKHWallet extends AbstractHDElectrumWallet { return this._xpub; } - _getNodeAddressByIndex(node, index) { - index = index * 1; // cast to int - if (node === 0) { - if (this.external_addresses_cache[index]) return this.external_addresses_cache[index]; // cache hit - } - - if (node === 1) { - if (this.internal_addresses_cache[index]) return this.internal_addresses_cache[index]; // cache hit - } - - if (node === 0 && !this._node0) { - const xpub = this.getXpub(); - const hdNode = bip32.fromBase58(xpub); - this._node0 = hdNode.derive(node); - } - - if (node === 1 && !this._node1) { - const xpub = this.getXpub(); - const hdNode = bip32.fromBase58(xpub); - this._node1 = hdNode.derive(node); - } - - let address; - if (node === 0) { - address = this.constructor._nodeToLegacyAddress(this._node0.derive(index)); - } - - if (node === 1) { - address = this.constructor._nodeToLegacyAddress(this._node1.derive(index)); - } - - if (node === 0) { - return (this.external_addresses_cache[index] = address); - } - - if (node === 1) { - return (this.internal_addresses_cache[index] = address); - } + _hdNodeToAddress(hdNode) { + return this._nodeToLegacyAddress(hdNode); } async fetchUtxo() { diff --git a/class/wallets/hd-segwit-bech32-wallet.js b/class/wallets/hd-segwit-bech32-wallet.js index 82c6caaf0..e86d158ed 100644 --- a/class/wallets/hd-segwit-bech32-wallet.js +++ b/class/wallets/hd-segwit-bech32-wallet.js @@ -46,4 +46,8 @@ export class HDSegwitBech32Wallet extends AbstractHDElectrumWallet { allowXpub() { return true; } + + allowBIP47() { + return true; + } } diff --git a/class/wallets/hd-segwit-p2sh-wallet.js b/class/wallets/hd-segwit-p2sh-wallet.js index d5fff5f16..41da38c64 100644 --- a/class/wallets/hd-segwit-p2sh-wallet.js +++ b/class/wallets/hd-segwit-p2sh-wallet.js @@ -40,44 +40,8 @@ export class HDSegwitP2SHWallet extends AbstractHDElectrumWallet { return true; } - _getNodeAddressByIndex(node, index) { - index = index * 1; // cast to int - if (node === 0) { - if (this.external_addresses_cache[index]) return this.external_addresses_cache[index]; // cache hit - } - - if (node === 1) { - if (this.internal_addresses_cache[index]) return this.internal_addresses_cache[index]; // cache hit - } - - if (node === 0 && !this._node0) { - const xpub = this.constructor._ypubToXpub(this.getXpub()); - const hdNode = bip32.fromBase58(xpub); - this._node0 = hdNode.derive(0); - } - - if (node === 1 && !this._node1) { - const xpub = this.constructor._ypubToXpub(this.getXpub()); - const hdNode = bip32.fromBase58(xpub); - this._node1 = hdNode.derive(1); - } - - let address; - if (node === 0) { - address = this.constructor._nodeToP2shSegwitAddress(this._node0.derive(index)); - } - - if (node === 1) { - address = this.constructor._nodeToP2shSegwitAddress(this._node1.derive(index)); - } - - if (node === 0) { - return (this.external_addresses_cache[index] = address); - } - - if (node === 1) { - return (this.internal_addresses_cache[index] = address); - } + _hdNodeToAddress(hdNode) { + return this._nodeToP2shSegwitAddress(hdNode); } /** @@ -134,18 +98,6 @@ export class HDSegwitP2SHWallet extends AbstractHDElectrumWallet { return psbt; } - /** - * Creates Segwit P2SH Bitcoin address - * @param hdNode - * @returns {String} - */ - static _nodeToP2shSegwitAddress(hdNode) { - const { address } = bitcoin.payments.p2sh({ - redeem: bitcoin.payments.p2wpkh({ pubkey: hdNode.publicKey }), - }); - return address; - } - isSegwit() { return true; } diff --git a/class/wallets/watch-only-wallet.js b/class/wallets/watch-only-wallet.js index 1ea4abac0..bf20dd3e9 100644 --- a/class/wallets/watch-only-wallet.js +++ b/class/wallets/watch-only-wallet.js @@ -100,6 +100,7 @@ export class WatchOnlyWallet extends LegacyWallet { if (this._hdWalletInstance) { delete this._hdWalletInstance._node0; delete this._hdWalletInstance._node1; + delete this._hdWalletInstance._bip47_instance; } } diff --git a/components/TransactionsNavigationHeader.js b/components/TransactionsNavigationHeader.js index 95e63c0b8..aabfb8ebd 100644 --- a/components/TransactionsNavigationHeader.js +++ b/components/TransactionsNavigationHeader.js @@ -16,7 +16,10 @@ export default class TransactionsNavigationHeader extends Component { static propTypes = { wallet: PropTypes.shape().isRequired, onWalletUnitChange: PropTypes.func, - navigation: PropTypes.shape(), + navigation: PropTypes.shape({ + navigate: PropTypes.func, + goBack: PropTypes.func, + }), onManageFundsPressed: PropTypes.func, }; @@ -241,6 +244,21 @@ export default class TransactionsNavigationHeader extends Component { {loc.lnd.title} )} + {this.state.wallet.allowBIP47() && this.state.wallet.isBIP47Enabled() && ( + { + this.props.navigation.navigate('PaymentCodeRoot', { + screen: 'PaymentCode', + params: { paymentCode: this.state.wallet.getBIP47PaymentCode() }, + }); + }} + > + + {loc.bip47.payment_code} + + + )} {this.state.wallet.type === LightningLdkWallet.type && ( diff --git a/loc/en.json b/loc/en.json index 9ed1d9111..8b11d00a7 100644 --- a/loc/en.json +++ b/loc/en.json @@ -16,7 +16,7 @@ "seed": "Seed", "success": "Success", "wallet_key": "Wallet key", - "invalid_animated_qr_code_fragment" : "Invalid animated QRCode fragment. Please try again.", + "invalid_animated_qr_code_fragment": "Invalid animated QRCode fragment. Please try again.", "file_saved": "File {filePath} has been saved in your {destination}.", "downloads_folder": "Downloads Folder" }, @@ -43,13 +43,13 @@ "network": "Network Error" }, "lnd": { - "active":"Active", - "inactive":"Inactive", + "active": "Active", + "inactive": "Inactive", "channels": "Channels", "no_channels": "No channels", "claim_balance": "Claim balance {balance}", "close_channel": "Close channel", - "new_channel" : "New channel", + "new_channel": "New channel", "errorInvoiceExpired": "Invoice expired", "force_close_channel": "Force close channel?", "expired": "Expired", @@ -59,7 +59,7 @@ "placeholder": "Invoice", "open_channel": "Open Channel", "funding_amount_placeholder": "Funding amount, for example 0.001", - "opening_channnel_for_from":"Opening channel for wallet {forWalletLabel}, by funding from {fromWalletLabel}", + "opening_channnel_for_from": "Opening channel for wallet {forWalletLabel}, by funding from {fromWalletLabel}", "are_you_sure_open_channel": "Are you sure you want to open this channel?", "potentialFee": "Potential Fee: {fee}", "remote_host": "Remote host", @@ -68,7 +68,7 @@ "refill_create": "In order to proceed, please create a Bitcoin wallet to refill with.", "refill_external": "Refill with External Wallet", "refill_lnd_balance": "Refill Lightning Wallet Balance", - "sameWalletAsInvoiceError": "You can’t pay an invoice with the same wallet used to create it.", + "sameWalletAsInvoiceError": "You can't pay an invoice with the same wallet used to create it.", "title": "Manage Funds", "can_send": "Can Send", "can_receive": "Can Receive", @@ -287,7 +287,7 @@ "network_electrum": "Electrum Server", "not_a_valid_uri": "Invalid URI", "notifications": "Notifications", - "open_link_in_explorer" : "Open link in explorer", + "open_link_in_explorer": "Open link in explorer", "password": "Password", "password_explain": "Create the password you will use to decrypt the storage.", "passwords_do_not_match": "Passwords do not match.", @@ -306,7 +306,7 @@ "selfTest": "Self-Test", "save": "Save", "saved": "Saved", - "success_transaction_broadcasted" : "Success! Your transaction has been broadcasted!", + "success_transaction_broadcasted": "Success! Your transaction has been broadcasted!", "total_balance": "Total Balance", "total_balance_explanation": "Display the total balance of all your wallets on your home screen widgets.", "widgets": "Widgets", @@ -590,5 +590,11 @@ "auth_answer": "You have successfully authenticated at {hostname}!", "could_not_auth": "We couldn’t authenticate you to {hostname}.", "authenticate": "Authenticate" + }, + "bip47": { + "payment_code": "Payment Code", + "payment_codes_list": "Payment Codes List", + "who_can_pay_me": "Who can pay me:", + "purpose": "Reusable and shareable code (BIP47)" } } diff --git a/package-lock.json b/package-lock.json index 8fd47cb94..9d49981ad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "@react-navigation/drawer": "5.12.9", "@react-navigation/native": "5.9.8", "@remobile/react-native-qrcode-local-image": "https://github.com/BlueWallet/react-native-qrcode-local-image", + "@spsina/bip47": "1.0.1", "aez": "1.0.1", "assert": "2.0.0", "base-x": "3.0.9", @@ -5860,6 +5861,24 @@ "@sinonjs/commons": "^2.0.0" } }, + "node_modules/@spsina/bip47": { + "version": "1.0.1", + "resolved": "git+ssh://git@github.com/abhishandy/bip47.git#1abcd4c20a387e43ed55bacc52726690bf417559", + "integrity": "sha512-lsgEpiEMDgpiYOA2kizOwiSS3vjTeLe2VnkOTIGnJ7Eu7Mkgl9dLES7oSLAjY64aQXr0VolqCRciRDc2nAC++w==", + "license": "MIT", + "dependencies": { + "bip32": "^3.0.1", + "bip39": "^3.0.4", + "bitcoinjs-lib": "^6.0.1", + "bs58check": "^2.1.1", + "create-hmac": "^1.1.7", + "ecpair": "^2.0.1", + "tiny-secp256k1": "^1.1.6" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@tootallnate/once": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", @@ -28459,6 +28478,20 @@ "@sinonjs/commons": "^2.0.0" } }, + "@spsina/bip47": { + "version": "git+ssh://git@github.com/abhishandy/bip47.git#1abcd4c20a387e43ed55bacc52726690bf417559", + "integrity": "sha512-lsgEpiEMDgpiYOA2kizOwiSS3vjTeLe2VnkOTIGnJ7Eu7Mkgl9dLES7oSLAjY64aQXr0VolqCRciRDc2nAC++w==", + "from": "@spsina/bip47@1.0.1", + "requires": { + "bip32": "^3.0.1", + "bip39": "^3.0.4", + "bitcoinjs-lib": "^6.0.1", + "bs58check": "^2.1.1", + "create-hmac": "^1.1.7", + "ecpair": "^2.0.1", + "tiny-secp256k1": "^1.1.6" + } + }, "@tootallnate/once": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", diff --git a/package.json b/package.json index e4ace72f8..a28aac789 100644 --- a/package.json +++ b/package.json @@ -110,6 +110,7 @@ "@react-navigation/drawer": "5.12.9", "@react-navigation/native": "5.9.8", "@remobile/react-native-qrcode-local-image": "https://github.com/BlueWallet/react-native-qrcode-local-image", + "@spsina/bip47": "1.0.1", "aez": "1.0.1", "assert": "2.0.0", "base-x": "3.0.9", diff --git a/screen/wallets/details.js b/screen/wallets/details.js index 18a50c227..c665aef08 100644 --- a/screen/wallets/details.js +++ b/screen/wallets/details.js @@ -128,6 +128,7 @@ const WalletDetails = () => { const [useWithHardwareWallet, setUseWithHardwareWallet] = useState(wallet.useWithHardwareWalletEnabled()); const { isAdvancedModeEnabled } = useContext(BlueStorageContext); const [isAdvancedModeEnabledRender, setIsAdvancedModeEnabledRender] = useState(false); + const [isBIP47Enabled, setIsBIP47Enabled] = useState(wallet.isBIP47Enabled()); const [hideTransactionsInWalletsList, setHideTransactionsInWalletsList] = useState(!wallet.getHideTransactionsInWalletsList()); const { goBack, navigate, setOptions, popToTop } = useNavigation(); const { colors } = useTheme(); @@ -179,7 +180,7 @@ const WalletDetails = () => { } }, [wallet]); - const setLabel = () => { + const save = () => { setIsLoading(true); if (walletName.trim().length > 0) { wallet.setLabel(walletName.trim()); @@ -187,6 +188,7 @@ const WalletDetails = () => { wallet.setUseWithHardwareWalletEnabled(useWithHardwareWallet); } wallet.setHideTransactionsInWalletsList(!hideTransactionsInWalletsList); + wallet.switchBIP47(isBIP47Enabled); } saveToDisk() .then(() => { @@ -209,14 +211,14 @@ const WalletDetails = () => { testID="Save" disabled={isLoading} style={[styles.save, stylesHook.save]} - onPress={setLabel} + onPress={save} > {loc.wallets.details_save} ), }); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isLoading, colors, walletName, useWithHardwareWallet, hideTransactionsInWalletsList]); + }, [isLoading, colors, walletName, useWithHardwareWallet, hideTransactionsInWalletsList, isBIP47Enabled]); useEffect(() => { if (wallets.some(wallet => wallet.getID() === walletID)) { @@ -310,6 +312,14 @@ const WalletDetails = () => { walletID: wallet.getID(), }); + const navigateToPaymentCodes = () => + navigate('PaymentCodeRoot', { + screen: 'PaymentCodesList', + params: { + walletID: wallet.getID(), + }, + }); + const exportInternals = async () => { if (backdoorPressed < 10) return setBackdoorPressed(backdoorPressed + 1); setBackdoorPressed(0); @@ -566,6 +576,16 @@ const WalletDetails = () => { {wallet.getTransactions().length} + {wallet.allowBIP47() ? ( + <> + {loc.bip47.payment_code} + + {loc.bip47.purpose} + + + + ) : null} + {wallet.type === WatchOnlyWallet.type && wallet.isHd() && ( <> @@ -601,6 +621,7 @@ const WalletDetails = () => { {(wallet instanceof AbstractHDElectrumWallet || (wallet.type === WatchOnlyWallet.type && wallet.isHd())) && ( )} + {wallet.allowBIP47() && isBIP47Enabled && } diff --git a/screen/wallets/paymentCode.tsx b/screen/wallets/paymentCode.tsx new file mode 100644 index 000000000..0a53386c5 --- /dev/null +++ b/screen/wallets/paymentCode.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { View, Text, StyleSheet } from 'react-native'; +import { NativeStackScreenProps } from 'react-native-screens/lib/typescript/native-stack'; +import { BlueCopyTextToClipboard } from '../../BlueComponents'; +import QRCodeComponent from '../../components/QRCodeComponent'; + +type PaymentCodeStackParamList = { + PaymentCode: { paymentCode: string }; +}; + +export default function PaymentCode({ route }: NativeStackScreenProps) { + const { paymentCode } = route.params; + + return ( + + {!paymentCode && Payment code not found} + {paymentCode && ( + <> + + + + )} + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + }, +}); diff --git a/screen/wallets/paymentCodesList.tsx b/screen/wallets/paymentCodesList.tsx new file mode 100644 index 000000000..2e757c588 --- /dev/null +++ b/screen/wallets/paymentCodesList.tsx @@ -0,0 +1,67 @@ +import React, { useContext, useEffect, useState } from 'react'; +import { SectionList, StyleSheet, Text, View } from 'react-native'; +import { NativeStackScreenProps } from 'react-native-screens/lib/typescript/native-stack'; +import { BlueStorageContext } from '../../blue_modules/storage-context'; +import { AbstractHDElectrumWallet } from '../../class/wallets/abstract-hd-electrum-wallet'; +import { BlueCopyTextToClipboard } from '../../BlueComponents'; +import loc from '../../loc'; + +type PaymentCodesListStackParamList = { + PaymentCodesList: { walletID: string }; +}; + +interface DataSection { + title: string; + data: string[]; +} + +export default function PaymentCodesList({ route }: NativeStackScreenProps) { + const { walletID } = route.params; + const { wallets } = useContext(BlueStorageContext); + const [data, setData] = useState([]); + + useEffect(() => { + if (!walletID) return; + + const foundWallet: AbstractHDElectrumWallet = wallets.find((w: AbstractHDElectrumWallet) => w.getID() === walletID); + if (!foundWallet) return; + + const newData: DataSection[] = [ + { + title: loc.bip47.who_can_pay_me, + data: foundWallet.getBIP47SenderPaymentCodes(), + }, + ]; + setData(newData); + }, [walletID, wallets]); + + return ( + + {!walletID ? ( + Internal error + ) : ( + + item + index} + renderItem={({ item }) => ( + + + + )} + renderSectionHeader={({ section: { title } }) => {title}} + /> + + )} + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + }, + titleText: { fontSize: 20 }, +}); diff --git a/screen/wallets/transactions.js b/screen/wallets/transactions.js index b107ad75f..846f005fa 100644 --- a/screen/wallets/transactions.js +++ b/screen/wallets/transactions.js @@ -33,6 +33,7 @@ import LNNodeBar from '../../components/LNNodeBar'; import TransactionsNavigationHeader from '../../components/TransactionsNavigationHeader'; import { TransactionListItem } from '../../components/TransactionListItem'; import alert from '../../components/Alert'; +import PropTypes from 'prop-types'; const fs = require('../../blue_modules/fs'); const BlueElectrum = require('../../blue_modules/BlueElectrum'); @@ -42,7 +43,7 @@ const buttonFontSize = ? 22 : PixelRatio.roundToNearestPixel(Dimensions.get('window').width / 26); -const WalletTransactions = () => { +const WalletTransactions = ({ navigation }) => { const { wallets, saveToDisk, setSelectedWallet, walletTransactionUpdateStatus, isElectrumDisabled } = useContext(BlueStorageContext); const [isLoading, setIsLoading] = useState(false); const { walletID } = useRoute().params; @@ -177,7 +178,12 @@ const WalletTransactions = () => { refreshLnNodeInfo(); // await BlueElectrum.ping(); await BlueElectrum.waitTillConnected(); - /** @type {LegacyWallet} */ + if (wallet.allowBIP47()) { + const pcStart = +new Date(); + await wallet.fetchBIP47SenderPaymentCodes(); + const pcEnd = +new Date(); + console.log(wallet.getLabel(), 'fetch payment codes took', (pcEnd - pcStart) / 1000, 'sec'); + } const balanceStart = +new Date(); const oldBalance = wallet.getBalance(); await wallet.fetchBalance(); @@ -467,6 +473,7 @@ const WalletTransactions = () => { InteractionManager.runAfterInteractions(async () => { @@ -605,6 +612,10 @@ WalletTransactions.navigationOptions = navigationStyle({}, (options, { theme, na }; }); +WalletTransactions.propTypes = { + navigation: PropTypes.shape(), +}; + const styles = StyleSheet.create({ flex: { flex: 1, diff --git a/scripts/find-unused-loc.js b/scripts/find-unused-loc.js index 58d4c819c..2c3e4c837 100644 --- a/scripts/find-unused-loc.js +++ b/scripts/find-unused-loc.js @@ -3,7 +3,7 @@ const path = require('path'); const mainLocFile = './loc/en.json'; const dirsToInterate = ['components', 'screen', 'blue_modules', 'class']; -const addFiles = ['BlueComponents.js', 'App.js', 'BlueApp.js']; +const addFiles = ['BlueComponents.js', 'App.js', 'BlueApp.js', 'Navigation.js']; const allowedLocPrefixes = ['loc.lnurl_auth', 'loc.units']; const allLocKeysHashmap = {}; // loc key -> used or not diff --git a/tests/integration/bip47.test.ts b/tests/integration/bip47.test.ts new file mode 100644 index 000000000..e320535ac --- /dev/null +++ b/tests/integration/bip47.test.ts @@ -0,0 +1,139 @@ +// import assert from 'assert'; +import { ECPairFactory } from 'ecpair'; + +import { HDLegacyP2PKHWallet, HDSegwitBech32Wallet } from '../../class'; +import * as BlueElectrum from '../../blue_modules/BlueElectrum'; +import BIP47Factory from '@spsina/bip47'; +import assert from 'assert'; + +const bitcoin = require('bitcoinjs-lib'); +const ecc = require('tiny-secp256k1'); +const ECPair = ECPairFactory(ecc); + +jest.setTimeout(30 * 1000); + +afterAll(async () => { + // after all tests we close socket so the test suite can actually terminate + BlueElectrum.forceDisconnect(); +}); + +beforeAll(async () => { + // awaiting for Electrum to be connected. For RN Electrum would naturally connect + // while app starts up, but for tests we need to wait for it + await BlueElectrum.connectMain(); +}); + +describe('Bech32 Segwit HD (BIP84) with BIP47', () => { + it('should work', async () => { + const hd = new HDLegacyP2PKHWallet(); + // @see https://gist.github.com/SamouraiDev/6aad669604c5930864bd + hd.setSecret('reward upper indicate eight swift arch injury crystal super wrestle already dentist'); + + expect(hd.getBIP47PaymentCode()).toEqual( + 'PM8TJS2JxQ5ztXUpBBRnpTbcUXbUHy2T1abfrb3KkAAtMEGNbey4oumH7Hc578WgQJhPjBxteQ5GHHToTYHE3A1w6p7tU6KSoFmWBVbFGjKPisZDbP97', + ); + + expect(hd.allowBIP47()).toEqual(true); + + await hd.fetchBIP47SenderPaymentCodes(); + expect(hd.getBIP47SenderPaymentCodes().length).toBeGreaterThanOrEqual(3); + expect(hd.getBIP47SenderPaymentCodes()).toContain( + 'PM8TJTLJbPRGxSbc8EJi42Wrr6QbNSaSSVJ5Y3E4pbCYiTHUskHg13935Ubb7q8tx9GVbh2UuRnBc3WSyJHhUrw8KhprKnn9eDznYGieTzFcwQRya4GA', + ); + expect(hd.getBIP47SenderPaymentCodes()).toContain( + 'PM8TJgndZSWCBPG5zCsqdXmCKLi7sP13jXuRp6b5X7G9geA3vRXQKAoXDf4Eym2RJB3vvcBdpDQT4vbo5QX7UfeV2ddjM8s79ERUTFS2ScKggSrciUsU', + ); + expect(hd.getBIP47SenderPaymentCodes()).toContain( + 'PM8TJNiWKcyiA2MsWCfuAr9jvhA5qMEdEkjNypEnUbxMRa1D5ttQWdggQ7ib9VNFbRBSuw7i6RkqPSkCMR1XGPSikJHaCSfqWtsb1fn4WNAXjp5JVL5z', + ); + + await hd.fetchBalance(); + await hd.fetchTransactions(); + expect(hd.getTransactions().length).toBeGreaterThanOrEqual(4); + }); + + it('should work (samurai)', async () => { + if (!process.env.BIP47_HD_MNEMONIC) { + console.error('process.env.BIP47_HD_MNEMONIC not set, skipped'); + return; + } + + const w = new HDSegwitBech32Wallet(); + w.setSecret(process.env.BIP47_HD_MNEMONIC.split(':')[0]); + w.setPassphrase('1'); + + expect(w.getBIP47PaymentCode()).toEqual( + 'PM8TJXuZNUtSibuXKFM6bhCxpNaSye6r4px2GXRV5v86uRdH9Raa8ZtXEkG7S4zLREf4ierjMsxLXSFTbRVUnRmvjw9qnc7zZbyXyBstSmjcb7uVcDYF', + ); + + expect(w._getExternalAddressByIndex(0)).toEqual('bc1q07l355j4yd5kyut36vjxn2u60d3dknnpt39t6y'); + + const bip47 = BIP47Factory(ecc).fromBip39Seed(w.getSecret(), undefined, w.getPassphrase()); + const ourNotificationAddress = bip47.getNotificationAddress(); + + const publicBip47 = BIP47Factory(ecc).fromPaymentCode(w.getBIP47PaymentCode()); + expect(ourNotificationAddress).toEqual(publicBip47.getNotificationAddress()); + + expect(ourNotificationAddress).toEqual('1EiP2kSqxNqRhn8MPMkrtSEqaWiCWLYyTS'); // our notif address + + await w.fetchBIP47SenderPaymentCodes(); + assert.ok( + w + .getBIP47SenderPaymentCodes() + .includes('PM8TJi1RuCrgSHTzGMoayUf8xUW6zYBGXBPSWwTiMhMMwqto7G6NA4z9pN5Kn8Pbhryo2eaHMFRRcidCGdB3VCDXJD4DdPD2ZyG3ScLMEvtStAetvPMo'), + ); // sparrow payment code + + assert.ok(w.weOwnAddress('bc1q57nwf9vfq2qsl80q37wq5h0tjytsk95vgjq4fe')); // this is an address that was derived (and paid) from counterparty payment code + + const keyPair2 = ECPair.fromWIF(w._getWIFbyAddress('bc1q57nwf9vfq2qsl80q37wq5h0tjytsk95vgjq4fe') || ''); + const address = bitcoin.payments.p2wpkh({ + pubkey: keyPair2.publicKey, + }).address; + assert.strictEqual(address, 'bc1q57nwf9vfq2qsl80q37wq5h0tjytsk95vgjq4fe'); + + await w.fetchTransactions(); + + assert.ok(w.getTransactions().length >= 3); + + assert.strictEqual( + w.getTransactions().find(tx => tx.txid === '64058a49bb75481fc0bebbb0d84a4aceebe319f9d32929e73cefb21d83342e9f')?.value, + 100000, + ); // initial deposit from sparrow after sparrow made a notification tx + + assert.strictEqual( + w.getTransactions().find(tx => tx.txid === '06b4c14587182fd0474f265a77b156519b4778769a99c21623863a8194d0fa4f')?.value, + -22692, + ); // notification tx to sparrow so we can pay sparrow + + assert.strictEqual( + w.getTransactions().find(tx => tx.txid === '73a2ac70858c5b306b101a861d582f40c456a692096a4e4805aa739258c4400d')?.value, + -77308, + ); // paying to sparrow + + // now, constructing OP_RETURN data to notify sparrow about us + + const aliceBip47 = bip47; + const keyPair = ECPair.fromWIF(w._getWIFbyAddress('bc1q57nwf9vfq2qsl80q37wq5h0tjytsk95vgjq4fe') || ''); + const bobBip47 = BIP47Factory(ecc).fromPaymentCode( + 'PM8TJi1RuCrgSHTzGMoayUf8xUW6zYBGXBPSWwTiMhMMwqto7G6NA4z9pN5Kn8Pbhryo2eaHMFRRcidCGdB3VCDXJD4DdPD2ZyG3ScLMEvtStAetvPMo', + ); + const blindedPaymentCode = aliceBip47.getBlindedPaymentCode( + bobBip47, + keyPair.privateKey as Buffer, + // txid is reversed, as well as output number () + Buffer.from('64058a49bb75481fc0bebbb0d84a4aceebe319f9d32929e73cefb21d83342e9f', 'hex').reverse().toString('hex') + '01000000', + ); + + assert.strictEqual( + blindedPaymentCode, + '0100039da7642943ec5d16c9bce09b71f240fe246d891fa3b52a7d236fece98318e1ae972f3747672f7e79a23fc88c4dc91a8d014233e14a9e4417e132405b6a6c166d00000000000000000000000000', + ); + + // checking that this is exactly a data payload we have in an actual notification transaction we have sent: + assert.strictEqual( + w.getTransactions().find(tx => tx.txid === '06b4c14587182fd0474f265a77b156519b4778769a99c21623863a8194d0fa4f')?.outputs?.[0] + ?.scriptPubKey.hex, + '6a4c50' + blindedPaymentCode, + ); + }); +}); diff --git a/tests/setup.js b/tests/setup.js index b56bb046a..5f1e062b9 100644 --- a/tests/setup.js +++ b/tests/setup.js @@ -5,8 +5,9 @@ import mockClipboard from '@react-native-clipboard/clipboard/jest/clipboard-mock const consoleWarnOrig = console.warn; console.warn = (...args) => { if ( - args[0]?.startsWith('WARNING: Sending to a future segwit version address can lead to loss of funds') || - args[0]?.startsWith('only compressed public keys are good') + typeof args[0] === 'string' && + (args[0].startsWith('WARNING: Sending to a future segwit version address can lead to loss of funds') || + args[0].startsWith('only compressed public keys are good')) ) { return; } @@ -16,10 +17,11 @@ console.warn = (...args) => { const consoleLogOrig = console.log; console.log = (...args) => { if ( - args[0]?.startsWith('updating exchange rate') || - args[0]?.startsWith('begin connection') || - args[0]?.startsWith('TLS Connected to') || - args[0]?.startsWith('connected to') + typeof args[0] === 'string' && + (args[0].startsWith('updating exchange rate') || + args[0].startsWith('begin connection') || + args[0].startsWith('TLS Connected to') || + args[0].startsWith('connected to')) ) { return; } diff --git a/tests/unit/bip47.test.ts b/tests/unit/bip47.test.ts new file mode 100644 index 000000000..0da10894a --- /dev/null +++ b/tests/unit/bip47.test.ts @@ -0,0 +1,96 @@ +import BIP47Factory from '@spsina/bip47'; +import ecc from 'tiny-secp256k1'; +import assert from 'assert'; + +import { HDSegwitBech32Wallet, WatchOnlyWallet } from '../../class'; + +describe('Bech32 Segwit HD (BIP84) with BIP47', () => { + it('should work', async () => { + const bobWallet = new HDSegwitBech32Wallet(); + // @see https://gist.github.com/SamouraiDev/6aad669604c5930864bd + bobWallet.setSecret('reward upper indicate eight swift arch injury crystal super wrestle already dentist'); + + expect(bobWallet.getBIP47PaymentCode()).toEqual( + 'PM8TJS2JxQ5ztXUpBBRnpTbcUXbUHy2T1abfrb3KkAAtMEGNbey4oumH7Hc578WgQJhPjBxteQ5GHHToTYHE3A1w6p7tU6KSoFmWBVbFGjKPisZDbP97', + ); + + const bip47 = BIP47Factory(ecc).fromBip39Seed(bobWallet.getSecret(), undefined, ''); + const bobNotificationAddress = bip47.getNotificationAddress(); + + expect(bobNotificationAddress).toEqual('1ChvUUvht2hUQufHBXF8NgLhW8SwE2ecGV'); // our notif address + + assert.ok(!bobWallet.weOwnAddress('1JDdmqFLhpzcUwPeinhJbUPw4Co3aWLyzW')); // alice notif address, we dont own it + }); + + it('getters, setters, flags work', async () => { + const w = new HDSegwitBech32Wallet(); + await w.generate(); + + expect(w.allowBIP47()).toEqual(true); + + expect(w.isBIP47Enabled()).toEqual(false); + w.switchBIP47(true); + expect(w.isBIP47Enabled()).toEqual(true); + w.switchBIP47(false); + expect(w.isBIP47Enabled()).toEqual(false); + + // checking that derived watch-only does not support that: + const ww = new WatchOnlyWallet(); + ww.setSecret(w.getXpub()); + expect(ww.allowBIP47()).toEqual(false); + }); + + it('should work (samurai)', async () => { + if (!process.env.BIP47_HD_MNEMONIC) { + console.error('process.env.BIP47_HD_MNEMONIC not set, skipped'); + return; + } + + const w = new HDSegwitBech32Wallet(); + w.setSecret(process.env.BIP47_HD_MNEMONIC.split(':')[0]); + w.setPassphrase('1'); + + expect(w.getBIP47PaymentCode()).toEqual( + 'PM8TJXuZNUtSibuXKFM6bhCxpNaSye6r4px2GXRV5v86uRdH9Raa8ZtXEkG7S4zLREf4ierjMsxLXSFTbRVUnRmvjw9qnc7zZbyXyBstSmjcb7uVcDYF', + ); + + expect(w._getExternalAddressByIndex(0)).toEqual('bc1q07l355j4yd5kyut36vjxn2u60d3dknnpt39t6y'); + + const bip47 = BIP47Factory(ecc).fromBip39Seed(w.getSecret(), undefined, w.getPassphrase()); + const ourNotificationAddress = bip47.getNotificationAddress(); + + const publicBip47 = BIP47Factory(ecc).fromPaymentCode(w.getBIP47PaymentCode()); + expect(ourNotificationAddress).toEqual(publicBip47.getNotificationAddress()); + + expect(ourNotificationAddress).toEqual('1EiP2kSqxNqRhn8MPMkrtSEqaWiCWLYyTS'); // our notif address + + assert.ok(!w.weOwnAddress('1JDdmqFLhpzcUwPeinhJbUPw4Co3aWLyzW')); // alice notif address, we dont own it + }); + + it('should work (sparrow)', async () => { + if (!process.env.BIP47_HD_MNEMONIC) { + console.error('process.env.BIP47_HD_MNEMONIC not set, skipped'); + return; + } + + const w = new HDSegwitBech32Wallet(); + w.setSecret(process.env.BIP47_HD_MNEMONIC.split(':')[1]); + + assert.strictEqual( + w.getXpub(), + 'zpub6r4KaQRsLuhHSGx8b9wGHh18UnawBs49jtiDzZYh9DSgKGwD72jWR3v54fkyy1UKVxt9HvCkYHmMAUe2YjKefofWzYp9YD62sUp6nNsEDMs', + ); + + expect(w.getBIP47PaymentCode()).toEqual( + 'PM8TJi1RuCrgSHTzGMoayUf8xUW6zYBGXBPSWwTiMhMMwqto7G6NA4z9pN5Kn8Pbhryo2eaHMFRRcidCGdB3VCDXJD4DdPD2ZyG3ScLMEvtStAetvPMo', + ); + + const bip47 = BIP47Factory(ecc).fromBip39Seed(w.getSecret(), undefined, w.getPassphrase()); + const ourNotificationAddress = bip47.getNotificationAddress(); + + const publicBip47 = BIP47Factory(ecc).fromPaymentCode(w.getBIP47PaymentCode()); + expect(ourNotificationAddress).toEqual(publicBip47.getNotificationAddress()); + + expect(ourNotificationAddress).toEqual('16xPugarxLzuNdhDu6XCMJBsMYrTN2fghN'); // our notif address + }); +});