ADD: multisig technical release

This commit is contained in:
Overtorment 2020-10-05 22:25:14 +01:00 committed by GitHub
parent 8ace25d140
commit 5c512833d9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
36 changed files with 3595 additions and 87 deletions

View file

@ -27,7 +27,7 @@ import {
import Clipboard from '@react-native-community/clipboard';
import LinearGradient from 'react-native-linear-gradient';
import ActionSheet from './screen/ActionSheet';
import { LightningCustodianWallet, PlaceholderWallet } from './class';
import { LightningCustodianWallet, MultisigHDWallet, PlaceholderWallet } from './class';
import Carousel from 'react-native-snap-carousel';
import { BitcoinUnit } from './models/bitcoinUnits';
import * as NavigationService from './NavigationService';
@ -339,9 +339,16 @@ export class BlueWalletNavigationHeader extends Component {
style={{ padding: 15, minHeight: 140, justifyContent: 'center' }}
>
<Image
source={
(LightningCustodianWallet.type === this.state.wallet.type && require('./img/lnd-shape.png')) || require('./img/btc-shape.png')
source={(() => {
switch (this.state.wallet.type) {
case LightningCustodianWallet.type:
return require('./img/lnd-shape.png');
case MultisigHDWallet.type:
return require('./img/vault-shape.png');
default:
return require('./img/btc-shape.png');
}
})()}
style={{
width: 99,
height: 94,
@ -1985,7 +1992,16 @@ const WalletCarouselItem = ({ item, index, onPress, handleLongPress, isSelectedW
}}
>
<Image
source={(LightningCustodianWallet.type === item.type && require('./img/lnd-shape.png')) || require('./img/btc-shape.png')}
source={(() => {
switch (item.type) {
case LightningCustodianWallet.type:
return require('./img/lnd-shape.png');
case MultisigHDWallet.type:
return require('./img/vault-shape.png');
default:
return require('./img/btc-shape.png');
}
})()}
style={{
width: 99,
height: 94,

View file

@ -27,6 +27,7 @@ import PleaseBackupLNDHub from './screen/wallets/pleaseBackupLNDHub';
import ImportWallet from './screen/wallets/import';
import WalletDetails from './screen/wallets/details';
import WalletExport from './screen/wallets/export';
import ExportMultisigCoordinationSetup from './screen/wallets/exportMultisigCoordinationSetup';
import WalletXpub from './screen/wallets/xpub';
import BuyBitcoin from './screen/wallets/buyBitcoin';
import HodlHodl from './screen/wallets/hodlHodl';
@ -53,6 +54,7 @@ import ScanQRCode from './screen/send/ScanQRCode';
import SendCreate from './screen/send/create';
import Confirm from './screen/send/confirm';
import PsbtWithHardwareWallet from './screen/send/psbtWithHardwareWallet';
import PsbtMultisig from './screen/send/psbtMultisig';
import Success from './screen/send/success';
import Broadcast from './screen/send/broadcast';
@ -175,6 +177,7 @@ const SendDetailsRoot = () => (
component={PsbtWithHardwareWallet}
options={PsbtWithHardwareWallet.navigationOptions}
/>
<SendDetailsStack.Screen name="PsbtMultisig" component={PsbtMultisig} options={PsbtMultisig.navigationOptions} />
<SendDetailsStack.Screen
name="CreateTransaction"
component={SendCreate}
@ -312,6 +315,11 @@ const Navigation = () => (
{/* screens */}
<RootStack.Screen name="WalletExport" component={WalletExport} options={WalletExport.navigationOptions} />
<RootStack.Screen
name="ExportMultisigCoordinationSetup"
component={ExportMultisigCoordinationSetup}
options={ExportMultisigCoordinationSetup.navigationOptions}
/>
<RootStack.Screen name="WalletXpub" component={WalletXpub} options={WalletXpub.navigationOptions} />
<RootStack.Screen name="BuyBitcoin" component={BuyBitcoin} options={BuyBitcoin.navigationOptions} />
<RootStack.Screen name="Marketplace" component={Marketplace} options={Marketplace.navigationOptions} />

79
blue_modules/fs.js Normal file
View file

@ -0,0 +1,79 @@
/* global alert */
import { PermissionsAndroid, Platform } from 'react-native';
import RNFS from 'react-native-fs';
import Share from 'react-native-share';
import loc from '../loc';
import { getSystemName } from 'react-native-device-info';
import DocumentPicker from 'react-native-document-picker';
const isDesktop = getSystemName() === 'Mac OS X';
const writeFileAndExport = async function (filename, contents) {
if (Platform.OS === 'ios') {
const filePath = RNFS.TemporaryDirectoryPath + `/${filename}`;
await RNFS.writeFile(filePath, contents);
Share.open({
url: 'file://' + filePath,
saveToFiles: isDesktop,
})
.catch(error => {
console.log(error);
// alert(error.message);
})
.finally(() => {
RNFS.unlink(filePath);
});
} else if (Platform.OS === 'android') {
const granted = await PermissionsAndroid.request(PermissionsAndroid.PERMISSIONS.WRITE_EXTERNAL_STORAGE, {
title: loc.send.permission_storage_title,
message: loc.send.permission_storage_message,
buttonNeutral: loc.send.permission_storage_later,
buttonNegative: loc._.cancel,
buttonPositive: loc._.ok,
});
if (granted === PermissionsAndroid.RESULTS.GRANTED) {
console.log('Storage Permission: Granted');
const filePath = RNFS.DownloadDirectoryPath + `/${filename}`;
await RNFS.writeFile(filePath, contents);
alert(loc.formatString(loc._.file_saved, { filePath: filename }));
} else {
console.log('Storage Permission: Denied');
}
}
};
/**
* Opens & reads *.psbt files, and returns base64 psbt. FALSE if something went wrong (wont throw).
*
* @returns {Promise<string|boolean>} Base64 PSBT
*/
const openSignedTransaction = async function () {
try {
const res = await DocumentPicker.pick({
type: Platform.OS === 'ios' ? ['io.bluewallet.psbt', 'io.bluewallt.psbt.txn'] : [DocumentPicker.types.allFiles],
});
const base64 = await RNFS.readFile(res.uri, 'base64');
const stringData = Buffer.from(base64, 'base64').toString(); // decode from base64
if (stringData.startsWith('psbt')) {
// file was binary, but outer code expects base64 psbt, so we return base64 we got from rn-fs;
// most likely produced by Electrum-desktop
return base64;
} else {
// file was a text file, having base64 psbt in there. so we basically have double base64encoded string
// thats why we are returning string that was decoded once;
// most likely produced by Coldcard
return stringData;
}
} catch (err) {
if (!DocumentPicker.isCancel(err)) {
alert(loc.send.details_no_signed_tx);
}
}
return false;
};
module.exports.writeFileAndExport = writeFileAndExport;
module.exports.openSignedTransaction = openSignedTransaction;

View file

@ -14,6 +14,7 @@ import {
LightningCustodianWallet,
HDLegacyElectrumSeedP2PKHWallet,
HDSegwitElectrumSeedP2WPKHWallet,
MultisigHDWallet,
} from './';
import DeviceQuickActions from './quick-actions';
import { AbstractHDElectrumWallet } from './wallets/abstract-hd-electrum-wallet';
@ -297,6 +298,9 @@ export class AppStorage {
case HDSegwitElectrumSeedP2WPKHWallet.type:
unserializedWallet = HDSegwitElectrumSeedP2WPKHWallet.fromJson(key);
break;
case MultisigHDWallet.type:
unserializedWallet = MultisigHDWallet.fromJson(key);
break;
case LightningCustodianWallet.type: {
/** @type {LightningCustodianWallet} */
unserializedWallet = LightningCustodianWallet.fromJson(key);
@ -433,7 +437,7 @@ export class AppStorage {
const realm = await this.getRealm();
for (const key of this.wallets) {
if (typeof key === 'boolean' || key.type === PlaceholderWallet.type) continue;
if (key.prepareForSerialization) key.prepareForSerialization();
key.prepareForSerialization();
const keyCloned = Object.assign({}, key); // stripped-down version of a wallet to save to secure keystore
if (key._hdWalletInstance) keyCloned._hdWalletInstance = Object.assign({}, key._hdWalletInstance);
this.offloadWalletToRealm(realm, key);

View file

@ -14,3 +14,4 @@ export * from './hd-segwit-bech32-transaction';
export * from './wallets/placeholder-wallet';
export * from './wallets/hd-legacy-electrum-seed-p2pkh-wallet';
export * from './wallets/hd-segwit-electrum-seed-p2wpkh-wallet';
export * from './wallets/multisig-hd-wallet';

View file

@ -10,6 +10,7 @@ import { SegwitBech32Wallet } from './wallets/segwit-bech32-wallet';
import { HDLegacyElectrumSeedP2PKHWallet } from './wallets/hd-legacy-electrum-seed-p2pkh-wallet';
import { HDSegwitElectrumSeedP2WPKHWallet } from './wallets/hd-segwit-electrum-seed-p2wpkh-wallet';
import { BlueCurrentTheme } from '../components/themes';
import { MultisigHDWallet } from './wallets/multisig-hd-wallet';
export default class WalletGradient {
static hdSegwitP2SHWallet = ['#65ceef', '#68bbe1'];
@ -19,6 +20,7 @@ export default class WalletGradient {
static legacyWallet = ['#40fad1', '#15be98'];
static hdLegacyP2PKHWallet = ['#e36dfa', '#bd10e0'];
static hdLegacyBreadWallet = ['#fe6381', '#f99c42'];
static multisigHdWallet = ['#1ce6eb', '#296fc5', '#3500A2'];
static defaultGradients = ['#c65afb', '#9053fe'];
static lightningCustodianWallet = ['#f1be07', '#f79056'];
static createWallet = BlueCurrentTheme.colors.lightButton;
@ -55,6 +57,9 @@ export default class WalletGradient {
case SegwitBech32Wallet.type:
gradient = WalletGradient.segwitBech32Wallet;
break;
case MultisigHDWallet.type:
gradient = WalletGradient.multisigHdWallet;
break;
default:
gradient = WalletGradient.defaultGradients;
break;
@ -88,6 +93,9 @@ export default class WalletGradient {
case SegwitBech32Wallet.type:
gradient = WalletGradient.segwitBech32Wallet;
break;
case MultisigHDWallet.type:
gradient = WalletGradient.multisigHdWallet;
break;
case LightningCustodianWallet.type:
gradient = WalletGradient.lightningCustodianWallet;
break;

View file

@ -13,6 +13,7 @@ import {
SegwitBech32Wallet,
HDLegacyElectrumSeedP2PKHWallet,
HDSegwitElectrumSeedP2WPKHWallet,
MultisigHDWallet,
} from '.';
import ReactNativeHapticFeedback from 'react-native-haptic-feedback';
import loc from '../loc';
@ -34,7 +35,9 @@ export default class WalletImport {
*/
static async _saveWallet(w, additionalProperties) {
try {
const wallet = BlueApp.getWallets().some(wallet => wallet.getSecret() === w.secret && wallet.type !== PlaceholderWallet.type);
const wallet = BlueApp.getWallets().some(
wallet => (wallet.getSecret() === w.secret || wallet.getID() === w.getID()) && wallet.type !== PlaceholderWallet.type,
);
if (wallet) {
alert('This wallet has been previously imported.');
WalletImport.removePlaceholderWallet();
@ -97,6 +100,7 @@ export default class WalletImport {
const placeholderWallet = WalletImport.addPlaceholderWallet(importText);
// Plan:
// -2. check if BIP38 encrypted
// -1a. check if multisig
// -1. check lightning custodian
// 0. check if its HDSegwitBech32Wallet (BIP84)
// 1. check if its HDSegwitP2SHWallet (BIP49)
@ -125,6 +129,18 @@ export default class WalletImport {
}
}
// is it multisig?
try {
const ms = new MultisigHDWallet();
ms.setSecret(importText);
if (ms.getN() > 0 && ms.getM() > 0) {
await ms.fetchBalance();
return WalletImport._saveWallet(ms);
}
} catch (e) {
console.log(e);
}
// is it lightning custodian?
if (importText.indexOf('blitzhub://') !== -1 || importText.indexOf('lndhub://') !== -1) {
const lnd = new LightningCustodianWallet();

View file

@ -264,4 +264,6 @@ export class AbstractWallet {
return b58.encode(data);
}
prepareForSerialization() {}
}

View file

@ -0,0 +1,801 @@
import { AbstractHDElectrumWallet } from './abstract-hd-electrum-wallet';
import bip39 from 'bip39';
import b58 from 'bs58check';
import { decodeUR } from 'bc-ur';
const BlueElectrum = require('../../blue_modules/BlueElectrum');
const coinSelectAccumulative = require('coinselect/accumulative');
const coinSelectSplit = require('coinselect/split');
const HDNode = require('bip32');
const bitcoin = require('bitcoinjs-lib');
const createHash = require('create-hash');
const reverse = require('buffer-reverse');
export class MultisigHDWallet extends AbstractHDElectrumWallet {
static type = 'HDmultisig';
static typeReadable = 'Multisig Vault';
constructor() {
super();
this._m = 0; // minimum required signatures so spend (m out of n)
this._cosigners = []; // array of xpubs or mnemonic seeds
this._cosignersFingerprints = []; // array of according fingerprints (if any provided)
this._cosignersCustomPaths = []; // array of according paths (if any provided)
this._derivationPath = '';
this._isNativeSegwit = false;
this._isWrappedSegwit = false;
this._isLegacy = false;
this.gap_limit = 10;
}
isLegacy() {
return this._isLegacy;
}
isNativeSegwit() {
return this._isNativeSegwit;
}
isWrappedSegwit() {
return this._isWrappedSegwit;
}
setWrappedSegwit() {
this._isWrappedSegwit = true;
}
setNativeSegwit() {
this._isNativeSegwit = true;
}
setLegacy() {
this._isLegacy = true;
}
setM(m) {
this._m = m;
}
/**
* @returns {number} How many minumim signatures required to authorize a spend
*/
getM() {
return this._m;
}
/**
* @returns {number} Total count of cosigners
*/
getN() {
return this._cosigners.length;
}
setDerivationPath(path) {
this._derivationPath = path;
switch (this._derivationPath) {
case "m/48'/0'/0'/2'":
this._isNativeSegwit = true;
break;
case "m/48'/0'/0'/1'":
this._isWrappedSegwit = true;
break;
case "m/45'":
this._isLegacy = true;
break;
case "m/44'":
this._isLegacy = true;
break;
}
}
getDerivationPath() {
return this._derivationPath;
}
getCustomDerivationPathForCosigner(index) {
if (index === 0) throw new Error('cosigners indexation starts from 1');
return this._cosignersCustomPaths[index - 1] || this.getDerivationPath();
}
getCosigner(index) {
if (index === 0) throw new Error('cosigners indexation starts from 1');
return this._cosigners[index - 1];
}
getFingerprint(index) {
if (index === 0) throw new Error('cosigners fingerprints indexation starts from 1');
return this._cosignersFingerprints[index - 1];
}
getCosignerForFingerprint(fp) {
const index = this._cosignersFingerprints.indexOf(fp);
return this._cosigners[index];
}
static isXpubValid(key) {
let xpub;
try {
xpub = super._zpubToXpub(key);
HDNode.fromBase58(xpub);
return true;
} catch (_) {}
return false;
}
/**
*
* @param key {string} Either xpub or mnemonic phrase
* @param fingerprint {string} Fingerprint for cosigner that is added as xpub
* @param path {string} Custom path (if any) for cosigner that is added as mnemonics
*/
addCosigner(key, fingerprint, path) {
if (MultisigHDWallet.isXpubString(key) && !fingerprint) {
throw new Error('fingerprint is required when adding cosigner as xpub (watch-only)');
}
if (path && !this.constructor.isPathValid(path)) {
throw new Error('path is not valid');
}
if (!MultisigHDWallet.isXpubString(key)) {
// mnemonics. lets derive fingerprint
if (!bip39.validateMnemonic(key)) throw new Error('Not a valid mnemonic phrase');
fingerprint = MultisigHDWallet.seedToFingerprint(key);
} else {
if (!MultisigHDWallet.isXpubValid(key)) throw new Error('Not a valid xpub: ' + key);
}
const index = this._cosigners.length;
this._cosigners[index] = key;
if (fingerprint) this._cosignersFingerprints[index] = fingerprint.toUpperCase();
if (path) this._cosignersCustomPaths[index] = path;
}
/**
* Stored cosigner can be EITHER xpub (or Zpub or smth), OR mnemonic phrase. This method converts it to xpub
*
* @param cosigner {string} Zpub (or similar) or mnemonic seed
* @returns {string} xpub
* @private
*/
_getXpubFromCosigner(cosigner) {
let xpub = cosigner;
if (!MultisigHDWallet.isXpubString(cosigner)) {
const index = this._cosigners.indexOf(cosigner);
xpub = MultisigHDWallet.seedToXpub(cosigner, this._cosignersCustomPaths[index] || this._derivationPath);
}
return this.constructor._zpubToXpub(xpub);
}
_getExternalAddressByIndex(index) {
if (!this._m) throw new Error('m is not set');
index = +index;
if (this.external_addresses_cache[index]) return this.external_addresses_cache[index]; // cache hit
const address = this._getAddressFromNode(0, index);
this.external_addresses_cache[index] = address;
return address;
}
_getAddressFromNode(nodeIndex, index) {
const pubkeys = [];
let cosignerIndex = 0;
for (const cosigner of this._cosigners) {
this._nodes = this._nodes || [];
this._nodes[nodeIndex] = this._nodes[nodeIndex] || [];
let _node;
if (!this._nodes[nodeIndex][cosignerIndex]) {
const xpub = this._getXpubFromCosigner(cosigner);
const hdNode = HDNode.fromBase58(xpub);
_node = hdNode.derive(nodeIndex);
} else {
_node = this._nodes[nodeIndex][cosignerIndex];
}
pubkeys.push(_node.derive(index).publicKey);
cosignerIndex++;
}
if (this.isWrappedSegwit()) {
const { address } = bitcoin.payments.p2sh({
redeem: bitcoin.payments.p2wsh({
redeem: bitcoin.payments.p2ms({ m: this._m, pubkeys: MultisigHDWallet.sortBuffers(pubkeys) }),
}),
});
return address;
} else if (this.isNativeSegwit()) {
const { address } = bitcoin.payments.p2wsh({
redeem: bitcoin.payments.p2ms({ m: this._m, pubkeys: MultisigHDWallet.sortBuffers(pubkeys) }),
});
return address;
} else if (this.isLegacy()) {
const { address } = bitcoin.payments.p2sh({
redeem: bitcoin.payments.p2ms({ m: this._m, pubkeys: MultisigHDWallet.sortBuffers(pubkeys) }),
});
return address;
} else {
throw new Error('Dont know how to make address');
}
}
_getInternalAddressByIndex(index) {
if (!this._m) throw new Error('m is not set');
index = +index;
if (this.internal_addresses_cache[index]) return this.internal_addresses_cache[index]; // cache hit
const address = this._getAddressFromNode(1, index);
this.internal_addresses_cache[index] = address;
return address;
}
static seedToXpub(mnemonic, path) {
const seed = bip39.mnemonicToSeed(mnemonic);
const root = bitcoin.bip32.fromSeed(seed);
const child = root.derivePath(path).neutered();
this._xpub = child.toBase58();
return this._xpub;
}
/**
* @param mnemonic {string} Mnemonic seed phrase
* @returns {string} Hex string of fingerprint derived from mnemonics. Always has lenght of 8 chars and correct leading zeroes
*/
static seedToFingerprint(mnemonic) {
const seed = bip39.mnemonicToSeed(mnemonic);
const root = bitcoin.bip32.fromSeed(seed);
let hex = root.fingerprint.toString('hex');
while (hex.length < 8) hex = '0' + hex; // leading zeroes
return hex.toUpperCase();
}
/**
* Returns xpub with correct prefix accodting to this objects set derivation path, for example 'Zpub' (with
* capital Z) for bech32 multisig
* @see https://github.com/satoshilabs/slips/blob/master/slip-0132.md
*
* @param xpub {string} Any kind of xpub, including zpub etc since we are only swapping the prefix bytes
* @returns {string}
*/
convertXpubToMultisignatureXpub(xpub) {
let data = b58.decode(xpub);
data = data.slice(4);
if (this.isNativeSegwit()) {
return b58.encode(Buffer.concat([Buffer.from('02aa7ed3', 'hex'), data]));
} else if (this.isWrappedSegwit()) {
return b58.encode(Buffer.concat([Buffer.from('0295b43f', 'hex'), data]));
}
return xpub;
}
static isXpubString(xpub) {
return ['xpub', 'ypub', 'zpub', 'Ypub', 'Zpub'].includes(xpub.substring(0, 4));
}
/**
* Converts fingerprint that is stored as a deciman number to hex string (all caps)
*
* @param xfp {number} For example 64392470
* @returns {string} For example 168DD603
*/
static ckccXfp2fingerprint(xfp) {
let masterFingerprintHex = Number(xfp).toString(16);
while (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]
).toUpperCase();
}
getXpub() {
return this.getSecret(true);
}
getSecret(coordinationSetup = false) {
let ret = '# BlueWallet Multisig setup file\n';
if (coordinationSetup) ret += '# this file contains only public keys and is safe to\n# distribute among cosigners\n';
if (!coordinationSetup) ret += '# this file may contain private information\n';
ret += '#\n';
ret += 'Name: ' + this.getLabel() + '\n';
ret += 'Policy: ' + this.getM() + ' of ' + this.getN() + '\n';
let hasCustomPaths = 0;
for (let index = 0; index < this.getN(); index++) {
if (this._cosignersCustomPaths[index]) hasCustomPaths++;
}
let printedGlobalDerivation = false;
if (hasCustomPaths !== this.getN()) {
printedGlobalDerivation = true;
ret += 'Derivation: ' + this.getDerivationPath() + '\n';
}
if (this.isNativeSegwit()) {
ret += 'Format: P2WSH\n';
} else if (this.isWrappedSegwit()) {
ret += 'Format: P2WSH-P2SH\n';
} else if (this.isLegacy()) {
ret += 'Format: P2SH\n';
} else {
ret += 'Format: unknown\n';
}
ret += '\n';
for (let index = 0; index < this.getN(); index++) {
if (
this._cosignersCustomPaths[index] &&
((printedGlobalDerivation && this._cosignersCustomPaths[index] !== this.getDerivationPath()) || !printedGlobalDerivation)
) {
ret += '# derivation: ' + this._cosignersCustomPaths[index] + '\n';
// if we printed global derivation and this cosigned _has_ derivation and its different from global - we print it ;
// or we print it if cosigner _has_ some derivation set and we did not print global
}
if (this.constructor.isXpubString(this._cosigners[index])) {
ret += this._cosignersFingerprints[index] + ': ' + this._cosigners[index] + '\n';
} else {
if (coordinationSetup) {
const xpub = this.convertXpubToMultisignatureXpub(
MultisigHDWallet.seedToXpub(this._cosigners[index], this._cosignersCustomPaths[index] || this._derivationPath),
);
const fingerprint = MultisigHDWallet.seedToFingerprint(this._cosigners[index]);
ret += fingerprint + ': ' + xpub + '\n';
} else {
ret += 'seed: ' + this._cosigners[index] + '\n';
ret += '# warning! sensitive information, do not disclose ^^^ \n';
}
}
ret += '\n';
}
return ret;
}
setSecret(secret) {
if (secret.toUpperCase().startsWith('UR:BYTES')) {
const decoded = decodeUR([secret]);
const b = Buffer.from(decoded, 'hex');
secret = b.toString();
}
// is it Coldcard json file?
let json;
try {
json = JSON.parse(secret);
} catch (_) {}
if (json && json.xfp && json.p2wsh_deriv && json.p2wsh) {
this.addCosigner(json.p2wsh, json.xfp); // technically we dont need deriv (json.p2wsh_deriv), since cosigner is already an xpub
return;
}
// is it electrum json?
if (json && json.wallet_type) {
const mofn = json.wallet_type.split('of');
this.setM(parseInt(mofn[0].trim()));
const n = parseInt(mofn[1].trim());
for (let c = 1; c <= n; c++) {
const cosignerData = json['x' + c + '/'];
if (cosignerData) {
const fingerprint = cosignerData.ckcc_xfp
? MultisigHDWallet.ckccXfp2fingerprint(cosignerData.ckcc_xfp)
: cosignerData.root_fingerprint?.toUpperCase();
if (cosignerData.seed) {
// TODO: support electrum's bip32
}
this.addCosigner(cosignerData.xpub, fingerprint, cosignerData.derivation);
}
}
if (this.getCosigner(1).startsWith('Zpub')) this.setNativeSegwit();
if (this.getCosigner(1).startsWith('Ypub')) this.setWrappedSegwit();
if (this.getCosigner(1).startsWith('xpub')) this.setLegacy();
}
// coldcard & cobo txt format:
let customPathForCurrentCosigner = false;
for (const line of secret.split('\n')) {
const [key, value] = line.split(':');
switch (key) {
case 'Name':
this.setLabel(value.trim());
break;
case 'Policy':
this.setM(parseInt(value.trim().split('of')[0].trim()));
break;
case 'Derivation':
this.setDerivationPath(value.trim());
break;
case 'Format':
switch (value.trim()) {
case 'P2WSH':
this.setNativeSegwit();
break;
case 'P2WSH-P2SH':
this.setWrappedSegwit();
break;
case 'P2SH':
this.setLegacy();
break;
}
break;
default:
if (key && value && MultisigHDWallet.isXpubString(value.trim())) {
this.addCosigner(value.trim(), key, customPathForCurrentCosigner);
} else if (key.replace('#', '').trim() === 'derivation') {
customPathForCurrentCosigner = value.trim();
} else if (key === 'seed') {
this.addCosigner(value.trim(), false, customPathForCurrentCosigner);
}
break;
}
}
if (!this.getLabel()) this.setLabel('Multisig vault');
}
_getDerivationPathByAddressWithCustomPath(address, customPathPrefix) {
const path = customPathPrefix || this._derivationPath;
for (let c = 0; c < this.next_free_address_index + this.gap_limit; c++) {
if (this._getExternalAddressByIndex(c) === address) return path + '/0/' + c;
}
for (let c = 0; c < this.next_free_change_address_index + this.gap_limit; c++) {
if (this._getInternalAddressByIndex(c) === address) return path + '/1/' + c;
}
return false;
}
_getWifForAddress(address) {
return false;
}
_getPubkeyByAddress(address) {
throw new Error('Not applicable in multisig');
}
_getDerivationPathByAddress(address) {
throw new Error('Not applicable in multisig');
}
_addPsbtInput(psbt, input, sequence, masterFingerprintBuffer) {
const bip32Derivation = []; // array per each pubkey thats gona be used
const pubkeys = [];
for (let c = 0; c < this._cosigners.length; c++) {
const cosigner = this._cosigners[c];
const path = this._getDerivationPathByAddressWithCustomPath(input.address, this._cosignersCustomPaths[c] || this._derivationPath);
// ^^ path resembles _custom path_, if provided by user during setup, otherwise default path for wallet type gona be used
const masterFingerprint = Buffer.from(this._cosignersFingerprints[c], 'hex');
const xpub = this._getXpubFromCosigner(cosigner);
const hdNode0 = HDNode.fromBase58(xpub);
const splt = path.split('/');
const internal = +splt[splt.length - 2];
const index = +splt[splt.length - 1];
const _node0 = hdNode0.derive(internal);
const pubkey = _node0.derive(index).publicKey;
pubkeys.push(pubkey);
bip32Derivation.push({
masterFingerprint,
path,
pubkey,
});
}
if (this.isNativeSegwit()) {
const p2wsh = bitcoin.payments.p2wsh({
redeem: bitcoin.payments.p2ms({ m: this._m, pubkeys: MultisigHDWallet.sortBuffers(pubkeys) }),
});
const witnessScript = p2wsh.redeem.output;
psbt.addInput({
hash: input.txId,
index: input.vout,
sequence,
bip32Derivation,
witnessUtxo: {
script: p2wsh.output,
value: input.value,
},
witnessScript,
// hw wallets now require passing the whole previous tx as Buffer, as if it was non-segwit input, to mitigate
// some hw wallets attack vector
nonWitnessUtxo: Buffer.from(input.txhex, 'hex'),
});
} else if (this.isWrappedSegwit()) {
const p2shP2wsh = bitcoin.payments.p2sh({
redeem: bitcoin.payments.p2wsh({
redeem: bitcoin.payments.p2ms({ m: this._m, pubkeys: MultisigHDWallet.sortBuffers(pubkeys) }),
}),
});
const witnessScript = p2shP2wsh.redeem.redeem.output;
const redeemScript = p2shP2wsh.redeem.output;
psbt.addInput({
hash: input.txId,
index: input.vout,
sequence,
bip32Derivation,
witnessUtxo: {
script: p2shP2wsh.output,
value: input.value,
},
witnessScript,
redeemScript,
// hw wallets now require passing the whole previous tx as Buffer, as if it was non-segwit input, to mitigate
// some hw wallets attack vector
nonWitnessUtxo: Buffer.from(input.txhex, 'hex'),
});
} else if (this.isLegacy()) {
const p2sh = bitcoin.payments.p2sh({
redeem: bitcoin.payments.p2ms({ m: this._m, pubkeys: MultisigHDWallet.sortBuffers(pubkeys) }),
});
const redeemScript = p2sh.redeem.output;
psbt.addInput({
hash: input.txId,
index: input.vout,
sequence,
bip32Derivation,
redeemScript,
nonWitnessUtxo: Buffer.from(input.txhex, 'hex'),
});
} else {
throw new Error('Dont know how to add input');
}
return psbt;
}
_getOutputDataForChange(outputData) {
const bip32Derivation = []; // array per each pubkey thats gona be used
const pubkeys = [];
for (let c = 0; c < this._cosigners.length; c++) {
const cosigner = this._cosigners[c];
const path = this._getDerivationPathByAddressWithCustomPath(
outputData.address,
this._cosignersCustomPaths[c] || this._derivationPath,
);
// ^^ path resembles _custom path_, if provided by user during setup, otherwise default path for wallet type gona be used
const masterFingerprint = Buffer.from(this._cosignersFingerprints[c], 'hex');
const xpub = this._getXpubFromCosigner(cosigner);
const hdNode0 = HDNode.fromBase58(xpub);
const splt = path.split('/');
const internal = +splt[splt.length - 2];
const index = +splt[splt.length - 1];
const _node0 = hdNode0.derive(internal);
const pubkey = _node0.derive(index).publicKey;
pubkeys.push(pubkey);
bip32Derivation.push({
masterFingerprint,
path,
pubkey,
});
}
outputData.bip32Derivation = bip32Derivation;
if (this.isLegacy()) {
const p2sh = bitcoin.payments.p2ms({ m: this._m, pubkeys: MultisigHDWallet.sortBuffers(pubkeys) });
outputData.redeemScript = p2sh.output;
} else if (this.isWrappedSegwit()) {
const p2shP2wsh = bitcoin.payments.p2sh({
redeem: bitcoin.payments.p2wsh({
redeem: bitcoin.payments.p2ms({ m: this._m, pubkeys: MultisigHDWallet.sortBuffers(pubkeys) }),
}),
});
outputData.witnessScript = p2shP2wsh.redeem.redeem.output;
outputData.redeemScript = p2shP2wsh.redeem.output;
} else if (this.isNativeSegwit()) {
// not needed by coldcard, apparently..?
const p2wsh = bitcoin.payments.p2wsh({
redeem: bitcoin.payments.p2ms({ m: this._m, pubkeys: MultisigHDWallet.sortBuffers(pubkeys) }),
});
outputData.witnessScript = p2wsh.redeem.output;
} else {
throw new Error('dont know how to add change output');
}
return outputData;
}
howManySignaturesCanWeMake() {
let howManyPrivKeysWeGot = 0;
for (const cosigner of this._cosigners) {
if (!MultisigHDWallet.isXpubString(cosigner)) howManyPrivKeysWeGot++;
}
return howManyPrivKeysWeGot;
}
/**
* @inheritDoc
*/
createTransaction(utxos, targets, feeRate, changeAddress, sequence, skipSigning = false, masterFingerprint) {
if (targets.length === 0) throw new Error('No destination provided');
if (this.howManySignaturesCanWeMake() === 0) skipSigning = true;
if (!changeAddress) throw new Error('No change address provided');
sequence = sequence || AbstractHDElectrumWallet.defaultRBFSequence;
let algo = coinSelectAccumulative;
if (targets.length === 1 && targets[0] && !targets[0].value) {
// we want to send MAX
algo = coinSelectSplit;
}
const { inputs, outputs, fee } = algo(utxos, targets, feeRate);
// .inputs and .outputs will be undefined if no solution was found
if (!inputs || !outputs) {
throw new Error('Not enough balance. Try sending smaller amount');
}
let psbt = new bitcoin.Psbt();
let c = 0;
inputs.forEach(input => {
c++;
psbt = this._addPsbtInput(psbt, input, sequence);
});
outputs.forEach(output => {
// if output has no address - this is change output
let change = false;
if (!output.address) {
change = true;
output.address = changeAddress;
}
let outputData = {
address: output.address,
value: output.value,
};
if (change) {
outputData = this._getOutputDataForChange(outputData);
}
psbt.addOutput(outputData);
});
if (!skipSigning) {
for (let cc = 0; cc < c; cc++) {
for (const cosigner of this._cosigners) {
if (!MultisigHDWallet.isXpubString(cosigner)) {
// ok this is a mnemonic, lets try to sign
const seed = bip39.mnemonicToSeed(cosigner);
const hdRoot = bitcoin.bip32.fromSeed(seed);
psbt.signInputHD(cc, hdRoot);
}
}
}
}
let tx;
if (!skipSigning && this.howManySignaturesCanWeMake() >= this.getM()) {
tx = psbt.finalizeAllInputs().extractTransaction();
}
return { tx, inputs, outputs, fee, psbt };
}
/**
* @see https://github.com/bitcoin/bips/blob/master/bip-0067.mediawiki
*
* @param bufArr {Array.<Buffer>}
* @returns {Array.<Buffer>}
*/
static sortBuffers(bufArr) {
return bufArr.sort(Buffer.compare);
}
prepareForSerialization() {
// deleting structures that cant be serialized
delete this._nodes;
}
static isPathValid(path) {
const root = bitcoin.bip32.fromSeed(Buffer.alloc(32));
try {
root.derivePath(path);
return true;
} catch (_) {}
return false;
}
allowSend() {
return true;
}
async fetchUtxo() {
await super.fetchUtxo();
// now we need to fetch txhash for each input as required by PSBT
const txhexes = await BlueElectrum.multiGetTransactionByTxid(
this.getUtxo().map(x => x.txid),
50,
false,
);
const newUtxos = [];
for (const u of this.getUtxo()) {
if (txhexes[u.txid]) u.txhex = txhexes[u.txid];
newUtxos.push(u);
}
return newUtxos;
}
getID() {
const string2hash = [...this._cosigners].sort().join(',') + ';' + [...this._cosignersFingerprints].sort().join(',');
return createHash('sha256').update(string2hash).digest().toString('hex');
}
calculateFeeFromPsbt(psbt) {
let goesIn = 0;
const cacheUtxoAmounts = {};
for (const inp of psbt.data.inputs) {
if (inp.witnessUtxo && inp.witnessUtxo.value) {
// segwit input
goesIn += inp.witnessUtxo.value;
} else if (inp.nonWitnessUtxo) {
// non-segwit input
// lets parse this transaction and cache how much each input was worth
const inputTx = bitcoin.Transaction.fromHex(inp.nonWitnessUtxo);
let index = 0;
for (const out of inputTx.outs) {
cacheUtxoAmounts[inputTx.getId() + ':' + index] = out.value;
index++;
}
}
}
if (goesIn === 0) {
// means we failed to get amounts that go in previously, so lets use utxo amounts cache we've build
// from non-segwit inputs
for (const inp of psbt.txInputs) {
const cacheKey = reverse(inp.hash).toString('hex') + ':' + inp.index;
if (cacheUtxoAmounts[cacheKey]) goesIn += cacheUtxoAmounts[cacheKey];
}
}
let goesOut = 0;
for (const output of psbt.txOutputs) {
goesOut += output.value;
}
return goesIn - goesOut;
}
calculateHowManySignaturesWeHaveFromPsbt(psbt) {
let sigsHave = 0;
for (const inp of psbt.data.inputs) {
sigsHave = Math.max(sigsHave, inp.partialSig?.length || 0);
if (inp.finalScriptSig || inp.finalScriptWitness) sigsHave = this.getM(); // hacky, but it means we have enough
// He who knows that enough is enough will always have enough. Lao Tzu
}
return sigsHave;
}
}

180
components/DynamicQRCode.js Normal file
View file

@ -0,0 +1,180 @@
/* eslint react/prop-types: "off", react-native/no-inline-styles: "off" */
import React, { Component } from 'react';
import { Text } from 'react-native-elements';
import { Dimensions, StyleSheet, TouchableOpacity, View } from 'react-native';
import { encodeUR } from 'bc-ur/dist';
import QRCode from 'react-native-qrcode-svg';
import { BlueCurrentTheme } from '../components/themes';
import { BlueSpacing20 } from '../BlueComponents';
import loc from '../loc';
const { height, width } = Dimensions.get('window');
export class DynamicQRCode extends Component {
constructor() {
super();
const qrCodeHeight = height > width ? width - 40 : width / 3;
const qrCodeMaxHeight = 370;
this.state = {
index: 0,
total: 0,
qrCodeHeight: Math.min(qrCodeHeight, qrCodeMaxHeight),
intervalHandler: null,
};
}
fragments = [];
componentDidMount() {
const { value, capacity = 800, hideControls = true } = this.props;
this.fragments = encodeUR(value, capacity);
this.setState(
{
total: this.fragments.length,
hideControls,
},
() => {
this.startAutoMove();
},
);
}
moveToNextFragment = () => {
const { index, total } = this.state;
if (index === total - 1) {
this.setState({
index: 0,
});
} else {
this.setState(state => ({
index: state.index + 1,
}));
}
};
startAutoMove = () => {
if (!this.state.intervalHandler)
this.setState(() => ({
intervalHandler: setInterval(this.moveToNextFragment, 500),
}));
};
stopAutoMove = () => {
clearInterval(this.state.intervalHandler);
this.setState(() => ({
intervalHandler: null,
}));
};
moveToPreviousFragment = () => {
const { index, total } = this.state;
if (index > 0) {
this.setState(state => ({
index: state.index - 1,
}));
} else {
this.setState(state => ({
index: total - 1,
}));
}
};
render() {
const currentFragment = this.fragments[this.state.index];
if (!currentFragment) {
return (
<View>
<Text>{loc.send.dynamic_init}</Text>
</View>
);
}
return (
<View style={animatedQRCodeStyle.container}>
<TouchableOpacity
style={animatedQRCodeStyle.qrcodeContainer}
onPress={() => {
this.setState(prevState => ({ hideControls: !prevState.hideControls }));
}}
>
<QRCode
value={currentFragment.toUpperCase()}
size={this.state.qrCodeHeight}
color="#000000"
logoBackgroundColor={BlueCurrentTheme.colors.brandingColor}
backgroundColor="#FFFFFF"
ecl="L"
/>
</TouchableOpacity>
{!this.state.hideControls && (
<View style={animatedQRCodeStyle.container}>
<BlueSpacing20 />
<View>
<Text style={animatedQRCodeStyle.text}>
{loc.formatString(loc._.of, { number: this.state.index + 1, total: this.state.total })}
</Text>
</View>
<BlueSpacing20 />
<View style={animatedQRCodeStyle.controller}>
<TouchableOpacity
style={[animatedQRCodeStyle.button, { width: '25%', alignItems: 'flex-start' }]}
onPress={this.moveToPreviousFragment}
>
<Text style={animatedQRCodeStyle.text}>{loc.send.dynamic_prev}</Text>
</TouchableOpacity>
<TouchableOpacity
style={[animatedQRCodeStyle.button, { width: '50%' }]}
onPress={this.state.intervalHandler ? this.stopAutoMove : this.startAutoMove}
>
<Text style={animatedQRCodeStyle.text}>{this.state.intervalHandler ? loc.send.dynamic_stop : loc.send.dynamic_start}</Text>
</TouchableOpacity>
<TouchableOpacity
style={[animatedQRCodeStyle.button, { width: '25%', alignItems: 'flex-end' }]}
onPress={this.moveToNextFragment}
>
<Text style={animatedQRCodeStyle.text}>{loc.send.dynamic_next}</Text>
</TouchableOpacity>
</View>
</View>
)}
</View>
);
}
}
const animatedQRCodeStyle = StyleSheet.create({
container: {
flex: 1,
flexDirection: 'column',
alignItems: 'center',
},
qrcodeContainer: {
alignItems: 'center',
justifyContent: 'center',
borderWidth: 6,
borderRadius: 8,
borderColor: '#FFFFFF',
margin: 6,
},
controller: {
width: '90%',
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
borderRadius: 25,
height: 45,
paddingHorizontal: 18,
},
button: {
alignItems: 'center',
height: 45,
justifyContent: 'center',
},
text: {
fontSize: 14,
color: BlueCurrentTheme.colors.foregroundColor,
fontWeight: 'bold',
},
});

View file

@ -0,0 +1,38 @@
/* eslint react/prop-types: "off", react-native/no-inline-styles: "off" */
import React from 'react';
import { TouchableOpacity, View, Text } from 'react-native';
import { Icon } from 'react-native-elements';
import { useTheme } from '@react-navigation/native';
export const SquareButton = props => {
const { colors } = useTheme();
let backgroundColor = props.backgroundColor ? props.backgroundColor : colors.buttonBlueBackgroundColor;
let fontColor = colors.buttonTextColor;
if (props.disabled === true) {
backgroundColor = colors.buttonDisabledBackgroundColor;
fontColor = colors.buttonDisabledTextColor;
}
return (
<TouchableOpacity
style={{
flex: 1,
borderWidth: 0.7,
borderColor: 'transparent',
backgroundColor: backgroundColor,
minHeight: 50,
height: 50,
maxHeight: 50,
borderRadius: 10,
justifyContent: 'center',
alignItems: 'center',
}}
{...props}
>
<View style={{ flexDirection: 'row', justifyContent: 'center', alignItems: 'center' }}>
{props.icon && <Icon name={props.icon.name} type={props.icon.type} color={props.icon.color} />}
{props.title && <Text style={{ marginHorizontal: 8, fontSize: 16, color: fontColor }}>{props.title}</Text>}
</View>
</TouchableOpacity>
);
};

View file

@ -54,6 +54,8 @@ export const BlueDefaultTheme = {
mainColor: '#CFDCF6',
success: '#ccddf9',
successCheck: '#0f5cc0',
msSuccessBG: '#37c0a1',
msSuccessCheck: '#ffffff',
},
};
@ -96,6 +98,8 @@ export const BlueDarkTheme = {
buttonBlueBackgroundColor: '#202020',
scanLabel: 'rgba(255,255,255,.2)',
labelText: '#ffffff',
msSuccessBG: '#8EFFE5',
msSuccessCheck: '#000000',
},
};

BIN
img/vault-shape.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

View file

@ -939,43 +939,12 @@
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"${PODS_ROOT}/Target Support Files/Pods-BlueWallet/Pods-BlueWallet-resources.sh",
"${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/AntDesign.ttf",
"${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/Entypo.ttf",
"${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/EvilIcons.ttf",
"${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/Feather.ttf",
"${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/FontAwesome.ttf",
"${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/FontAwesome5_Brands.ttf",
"${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/FontAwesome5_Regular.ttf",
"${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/FontAwesome5_Solid.ttf",
"${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/Fontisto.ttf",
"${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/Foundation.ttf",
"${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/Ionicons.ttf",
"${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/MaterialCommunityIcons.ttf",
"${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/MaterialIcons.ttf",
"${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/Octicons.ttf",
"${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/SimpleLineIcons.ttf",
"${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/Zocial.ttf",
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-BlueWallet/Pods-BlueWallet-resources-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Copy Pods Resources";
outputPaths = (
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/AntDesign.ttf",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Entypo.ttf",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EvilIcons.ttf",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Feather.ttf",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FontAwesome.ttf",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FontAwesome5_Brands.ttf",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FontAwesome5_Regular.ttf",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FontAwesome5_Solid.ttf",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Fontisto.ttf",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Foundation.ttf",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Ionicons.ttf",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/MaterialCommunityIcons.ttf",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/MaterialIcons.ttf",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Octicons.ttf",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/SimpleLineIcons.ttf",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Zocial.ttf",
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-BlueWallet/Pods-BlueWallet-resources-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;

View file

@ -36,6 +36,35 @@
<string>io.bluewallet.psbt.txn</string>
</array>
</dict>
<dict>
<key>CFBundleTypeIconFiles</key>
<array/>
<key>CFBundleTypeName</key>
<string>TXT</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>LSHandlerRank</key>
<string>Owner</string>
<key>LSItemContentTypes</key>
<array>
<string>io.bluewallet.txt</string>
</array>
</dict>
<dict>
<key>CFBundleTypeIconFiles</key>
<array/>
<key>CFBundleTypeName</key>
<string>JSON</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>LSHandlerRank</key>
<string>Owner</string>
<key>LSItemContentTypes</key>
<array>
<string>io.bluewallet.json</string>
</array>
</dict>
<dict/>
</array>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
@ -241,6 +270,44 @@
</array>
</dict>
</dict>
<dict>
<key>UTTypeConformsTo</key>
<array>
<string>public.plain-text</string>
</array>
<key>UTTypeDescription</key>
<string>Text File</string>
<key>UTTypeIconFiles</key>
<array/>
<key>UTTypeIdentifier</key>
<string>io.bluewallet.txt</string>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array>
<string>txt</string>
</array>
</dict>
</dict>
<dict>
<key>UTTypeConformsTo</key>
<array>
<string>public.json</string>
</array>
<key>UTTypeDescription</key>
<string>JSON File</string>
<key>UTTypeIconFiles</key>
<array/>
<key>UTTypeIdentifier</key>
<string>io.bluewallet.json</string>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array>
<string>json</string>
</array>
</dict>
</dict>
</array>
</dict>
</plist>

View file

@ -724,4 +724,4 @@ SPEC CHECKSUMS:
PODFILE CHECKSUM: e9c5efd531ca5ac67a4b743a179eeefb322cf387
COCOAPODS: 1.10.0.beta.2
COCOAPODS: 1.9.3

View file

@ -8,7 +8,9 @@
"of": "{number} of {total}",
"ok": "OK",
"storage_is_encrypted": "Your storage is encrypted. Password is required to decrypt it",
"yes": "Yes"
"yes": "Yes",
"invalid_animated_qr_code_fragment" : "Invalid animated QRCode fragment, please try again",
"file_saved": "File ({filePath}) has been saved in your Downloads folder ."
},
"azteco": {
"codeIs": "Your voucher code is",
@ -176,7 +178,7 @@
"details_total_exceeds_balance": "The sending amount exceeds the available balance.",
"details_wallet_before_tx": "Before creating a transaction, you must first add a Bitcoin wallet.",
"details_wallet_selection": "Wallet Selection",
"dynamic_init": "Initialing",
"dynamic_init": "Initializing",
"dynamic_next": "Next",
"dynamic_prev": "Previous",
"dynamic_start": "Start",
@ -209,7 +211,8 @@
"qr_error_no_qrcode": "The selected image does not contain a QR Code.",
"qr_error_no_wallet": "The selected file does not contain a wallet that can be imported.",
"success_done": "Done",
"txSaved": "The transaction file ({filePath}) has been saved in your Downloads folder ."
"txSaved": "The transaction file ({filePath}) has been saved in your Downloads folder .",
"problem_with_psbt": "Problem with PSBT"
},
"settings": {
"about": "About",
@ -371,5 +374,17 @@
"select_wallet": "Select Wallet",
"xpub_copiedToClipboard": "Copied to clipboard.",
"xpub_title": "wallet XPUB"
},
"multisig": {
"provide_signature": "Provide signature",
"vault_key": "Vault key {number}",
"fee": "Fee: {number}",
"fee_btc": "{number} BTC",
"confirm": "Confirm",
"header": "Send",
"share": "Share",
"how_many_signatures_can_bluewallet_make": "how many signatures can bluewallet make",
"scan_or_import_file": "Scan or import file",
"export_coordination_setup": "export coordination setup"
}
}

View file

@ -176,7 +176,7 @@
"details_total_exceeds_balance": "Jumlah yang dikirim melebihi saldo.",
"details_wallet_before_tx": "Before creating a transaction, you must first add a Bitcoin wallet.",
"details_wallet_selection": "Wallet Selection",
"dynamic_init": "Initialing",
"dynamic_init": "Initializing",
"dynamic_next": "Next",
"dynamic_prev": "Previous",
"dynamic_start": "Start",

View file

@ -176,7 +176,7 @@
"details_total_exceeds_balance": "L'importo da inviare eccede i fondi disponibili.",
"details_wallet_before_tx": "Before creating a transaction, you must first add a Bitcoin wallet.",
"details_wallet_selection": "Wallet Selection",
"dynamic_init": "Initialing",
"dynamic_init": "Initializing",
"dynamic_next": "Next",
"dynamic_prev": "Previous",
"dynamic_start": "Start",

View file

@ -176,7 +176,7 @@
"details_total_exceeds_balance": "Het verzendingsbedrag overschrijdt het beschikbare saldo.",
"details_wallet_before_tx": "Before creating a transaction, you must first add a Bitcoin wallet.",
"details_wallet_selection": "Wallet Selection",
"dynamic_init": "Initialing",
"dynamic_init": "Initializing",
"dynamic_next": "Volgende",
"dynamic_prev": "Vorige",
"dynamic_start": "Start",

View file

@ -176,7 +176,7 @@
"details_total_exceeds_balance": "Čiastka, ktorú chcete poslať, presahuje dostupný zostatok.",
"details_wallet_before_tx": "Pred vytvorením transakcie potrebujete najprv pridať Bitcoinovú peňaženku.",
"details_wallet_selection": "Výber peňaženky",
"dynamic_init": "Initialing",
"dynamic_init": "Initializing",
"dynamic_next": "Next",
"dynamic_prev": "Previous",
"dynamic_start": "Start",

View file

@ -176,7 +176,7 @@
"details_total_exceeds_balance": "Beloppet överstiger plånbokens tillgängliga belopp",
"details_wallet_before_tx": "Before creating a transaction, you must first add a Bitcoin wallet.",
"details_wallet_selection": "Wallet Selection",
"dynamic_init": "Initialing",
"dynamic_init": "Initializing",
"dynamic_next": "Nästa",
"dynamic_prev": "Föregående",
"dynamic_start": "Starta",

View file

@ -176,7 +176,7 @@
"details_total_exceeds_balance": "Gönderme miktarı mevcut bakiyeyi aşıyor.",
"details_wallet_before_tx": "Before creating a transaction, you must first add a Bitcoin wallet.",
"details_wallet_selection": "Wallet Selection",
"dynamic_init": "Initialing",
"dynamic_init": "Initializing",
"dynamic_next": "Next",
"dynamic_prev": "Previous",
"dynamic_start": "Start",

View file

@ -176,7 +176,7 @@
"details_total_exceeds_balance": "余额不足",
"details_wallet_before_tx": "Before creating a transaction, you must first add a Bitcoin wallet.",
"details_wallet_selection": "Wallet Selection",
"dynamic_init": "Initialing",
"dynamic_init": "Initializing",
"dynamic_next": "Next",
"dynamic_prev": "Previous",
"dynamic_start": "Start",

15
package-lock.json generated
View file

@ -6674,15 +6674,13 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-1.0.0.tgz",
"integrity": "sha1-rEaBd8SUNAWgkvyPKXYMb/xiBsA=",
"dev": true,
"optional": true
"dev": true
},
"is-glob": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-2.0.1.tgz",
"integrity": "sha1-0Jb5JqPe1WAPP9/ZEZjLCIjC2GM=",
"dev": true,
"optional": true,
"requires": {
"is-extglob": "^1.0.0"
}
@ -9444,7 +9442,6 @@
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-2.0.0.tgz",
"integrity": "sha1-gTg9ctsFT8zPUzbaqQLxgvbtuyg=",
"dev": true,
"optional": true,
"requires": {
"is-glob": "^2.0.0"
},
@ -9453,15 +9450,13 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-1.0.0.tgz",
"integrity": "sha1-rEaBd8SUNAWgkvyPKXYMb/xiBsA=",
"dev": true,
"optional": true
"dev": true
},
"is-glob": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-2.0.1.tgz",
"integrity": "sha1-0Jb5JqPe1WAPP9/ZEZjLCIjC2GM=",
"dev": true,
"optional": true,
"requires": {
"is-extglob": "^1.0.0"
}
@ -13952,8 +13947,7 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-1.0.0.tgz",
"integrity": "sha1-rEaBd8SUNAWgkvyPKXYMb/xiBsA=",
"dev": true,
"optional": true
"dev": true
},
"is-glob": {
"version": "2.0.1",
@ -15502,8 +15496,7 @@
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"dev": true,
"optional": true
"dev": true
},
"string_decoder": {
"version": "1.1.1",

View file

@ -42,7 +42,7 @@
"e2e:debug": "(test -f android/app/build/outputs/apk/debug/app-debug.apk && test -f android/app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk) || npm run e2e:debug-build; npm run e2e:debug-test",
"e2e:release-build": "npx detox build -c android.emu.release",
"e2e:release-test": "detox test -c android.emu.release --record-videos all --take-screenshots all --headless",
"lint": "eslint *.js screen/**/*.js blue_modules/*.js class/**/*.js models/ loc/ tests/**/*.js",
"lint": "eslint *.js screen/**/*.js blue_modules/*.js class/**/*.js models/ loc/ tests/**/*.js components/**/*.js",
"lint:fix": "npm run lint -- --fix",
"lint:quickfix": "git status --porcelain | grep -v '\\.json' | grep '\\.js' --color=never | awk '{print $2}' | xargs eslint --fix; exit 0",
"unit": "jest tests/unit/*"

View file

@ -133,25 +133,21 @@ const ScanQRCode = () => {
const showFilePicker = async () => {
try {
const res = await DocumentPicker.pick({
type:
Platform.OS === 'ios'
? ['io.bluewallet.psbt', 'io.bluewallet.psbt.txn', DocumentPicker.types.plainText, 'public.json']
: [DocumentPicker.types.allFiles],
});
setIsLoading(true);
const res = await DocumentPicker.pick();
const file = await RNFS.readFile(res.uri);
const fileParsed = JSON.parse(file);
if (fileParsed.keystore.xpub) {
let masterFingerprint;
if (fileParsed.keystore.ckcc_xfp) {
masterFingerprint = Number(fileParsed.keystore.ckcc_xfp);
}
onBarCodeRead({ data: fileParsed.keystore.xpub, additionalProperties: { masterFingerprint, label: fileParsed.keystore.label } });
} else {
throw new Error();
}
onBarCodeRead({ data: file });
} catch (err) {
if (!DocumentPicker.isCancel(err)) {
alert(loc.send.qr_error_no_wallet);
}
setIsLoading(false);
}
setIsLoading(false);
};
const showImagePicker = () => {

View file

@ -38,7 +38,7 @@ import * as bitcoin from 'bitcoinjs-lib';
import NetworkTransactionFees, { NetworkTransactionFee } from '../../models/networkTransactionFees';
import { BitcoinUnit, Chain } from '../../models/bitcoinUnits';
import { AppStorage, HDSegwitBech32Wallet, LightningCustodianWallet, WatchOnlyWallet } from '../../class';
import { AppStorage, HDSegwitBech32Wallet, LightningCustodianWallet, MultisigHDWallet, WatchOnlyWallet } from '../../class';
import { BitcoinTransaction } from '../../models/bitcoinTransactionInfo';
import DocumentPicker from 'react-native-document-picker';
import DeeplinkSchemaMatch from '../../class/deeplink-schema-match';
@ -47,6 +47,7 @@ import { BlueCurrentTheme } from '../../components/themes';
const currency = require('../../blue_modules/currency');
const BlueApp: AppStorage = require('../../BlueApp');
const prompt = require('../../blue_modules/prompt');
const fs = require('../../blue_modules/fs');
const btcAddressRx = /^[a-zA-Z0-9]{26,35}$/;
@ -588,6 +589,16 @@ export default class SendDetails extends Component {
return;
}
if (wallet.type === MultisigHDWallet.type) {
this.props.navigation.navigate('PsbtMultisig', {
memo: this.state.memo,
psbtBase64: psbt.toBase64(),
walletId: wallet.getID(),
});
this.setState({ isLoading: false });
return;
}
BlueApp.tx_metadata = BlueApp.tx_metadata || {};
BlueApp.tx_metadata[tx.getId()] = {
txhex: tx.toHex(),
@ -772,7 +783,10 @@ export default class SendDetails extends Component {
try {
const res = await DocumentPicker.pick({
type: Platform.OS === 'ios' ? ['io.bluewallet.psbt', 'io.bluewallet.psbt.txn'] : [DocumentPicker.types.allFiles],
type:
Platform.OS === 'ios'
? ['io.bluewallet.psbt', 'io.bluewallet.psbt.txn', DocumentPicker.types.plainText, 'public.json']
: [DocumentPicker.types.allFiles],
});
if (DeeplinkSchemaMatch.isPossiblySignedPSBTFile(res.uri)) {
@ -818,6 +832,21 @@ export default class SendDetails extends Component {
}
};
importTransactionMultisig = async () => {
try {
const base64 = await fs.openSignedTransaction();
const psbt = bitcoin.Psbt.fromBase64(base64); // if it doesnt throw - all good, its valid
this.props.navigation.navigate('PsbtMultisig', {
memo: this.state.memo,
psbtBase64: psbt.toBase64(),
walletId: this.state.fromWallet.getID(),
});
} catch (error) {
alert(loc.send.problem_with_psbt + ': ' + error.message);
}
this.setState({ isLoading: false, isAdvancedTransactionOptionsVisible: false });
};
handleAddRecipient = () => {
const { addresses } = this.state;
addresses.push(new BitcoinTransaction());
@ -893,6 +922,14 @@ export default class SendDetails extends Component {
onPress={this.importTransaction}
/>
)}
{this.state.fromWallet.type === MultisigHDWallet.type && (
<BlueListItem
title={loc.send.details_adv_import}
hideChevron
component={TouchableOpacity}
onPress={this.importTransactionMultisig}
/>
)}
{this.state.fromWallet.allowBatchSend() && (
<>
<BlueListItem

501
screen/send/psbtMultisig.js Normal file
View file

@ -0,0 +1,501 @@
/* global alert */
import React, { useState } from 'react';
import { FlatList, Platform, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { BlueButton, BlueButtonLink, BlueCard, BlueNavigationStyle, BlueSpacing20, BlueText, SafeBlueArea } from '../../BlueComponents';
import { DynamicQRCode } from '../../components/DynamicQRCode';
import { SquareButton } from '../../components/SquareButton';
import { getSystemName } from 'react-native-device-info';
import { decodeUR, extractSingleWorkload } from 'bc-ur/dist';
import loc from '../../loc';
import { Icon } from 'react-native-elements';
import ImagePicker from 'react-native-image-picker';
import ScanQRCode from './ScanQRCode';
import { useNavigation, useRoute, useTheme } from '@react-navigation/native';
import { BitcoinUnit } from '../../models/bitcoinUnits';
const BlueApp = require('../../BlueApp');
const bitcoin = require('bitcoinjs-lib');
const currency = require('../../blue_modules/currency');
const fs = require('../../blue_modules/fs');
const LocalQRCode = require('@remobile/react-native-qrcode-local-image');
const isDesktop = getSystemName() === 'Mac OS X';
const BigNumber = require('bignumber.js');
const shortenAddress = addr => {
return addr.substr(0, Math.floor(addr.length / 2) - 1) + '\n' + addr.substr(Math.floor(addr.length / 2) - 1, addr.length);
};
const PsbtMultisig = () => {
const navigation = useNavigation();
const route = useRoute();
const { colors } = useTheme();
const [flatListHeight, setFlatListHeight] = useState(0);
const walletId = route.params.walletId;
const psbtBase64 = route.params.psbtBase64;
const memo = route.params.memo;
const [psbt, setPsbt] = useState(bitcoin.Psbt.fromBase64(psbtBase64));
const [animatedQRCodeData, setAnimatedQRCodeData] = useState({});
const [isModalVisible, setIsModalVisible] = useState(false);
const stylesHook = StyleSheet.create({
root: {
backgroundColor: colors.elevated,
},
textBtc: {
color: colors.buttonAlternativeTextColor,
},
textDestinationFirstFour: {
color: colors.buttonAlternativeTextColor,
},
textBtcUnitValue: {
color: colors.buttonAlternativeTextColor,
},
textDestination: {
color: colors.foregroundColor,
},
modalContentShort: {
backgroundColor: colors.elevated,
},
textFiat: {
color: colors.alternativeTextColor,
},
provideSignatureButton: {
backgroundColor: colors.buttonDisabledBackgroundColor,
},
exportButton: {
backgroundColor: colors.buttonDisabledBackgroundColor,
},
provideSignatureButtonText: {
color: colors.buttonTextColor,
},
vaultKeyCircle: {
backgroundColor: colors.buttonDisabledBackgroundColor,
},
vaultKeyText: {
color: colors.alternativeTextColor,
},
feeFiatText: {
color: colors.alternativeTextColor,
},
vaultKeyCircleSuccess: {
backgroundColor: colors.msSuccessBG,
},
vaultKeyTextSigned: {
color: colors.msSuccessBG,
},
});
/** @type MultisigHDWallet */
const wallet = BlueApp.getWallets().find(w => w.getID() === walletId);
let destination = [];
let totalSat = 0;
const targets = [];
for (const output of psbt.txOutputs) {
if (output.address && !wallet.weOwnAddress(output.address)) {
totalSat += output.value;
destination.push(output.address);
targets.push({ address: output.address, value: output.value });
}
}
destination = shortenAddress(destination.join(', '));
const totalBtc = new BigNumber(totalSat).dividedBy(100000000).toNumber();
const totalFiat = currency.satoshiToLocalCurrency(totalSat);
const fileName = `${Date.now()}.psbt`;
const howManySignaturesWeHave = () => {
return wallet.calculateHowManySignaturesWeHaveFromPsbt(psbt);
};
const getFee = () => {
return wallet.calculateFeeFromPsbt(psbt);
};
const _renderItem = el => {
if (el.index >= howManySignaturesWeHave()) return _renderItemUnsigned(el);
else return _renderItemSigned(el);
};
const _renderItemUnsigned = el => {
const renderProvideSignature = el.index === howManySignaturesWeHave();
return (
<View>
<View style={styles.itemUnsignedWrapper}>
<View style={[styles.vaultKeyCircle, stylesHook.vaultKeyCircle]}>
<Text style={[styles.vaultKeyText, stylesHook.vaultKeyText]}>{el.index + 1}</Text>
</View>
<View style={styles.vaultKeyTextWrapper}>
<Text style={[styles.vaultKeyText, stylesHook.vaultKeyText]}>
{loc.formatString(loc.multisig.vault_key, { number: el.index + 1 })}
</Text>
</View>
</View>
{renderProvideSignature && (
<View>
<TouchableOpacity
style={[styles.provideSignatureButton, stylesHook.provideSignatureButton]}
onPress={() => {
setIsModalVisible(true);
}}
>
<Text style={[styles.provideSignatureButtonText, stylesHook.provideSignatureButtonText]}>
{loc.multisig.provide_signature}
</Text>
</TouchableOpacity>
</View>
)}
</View>
);
};
const _renderItemSigned = el => {
return (
<View style={styles.flexDirectionRow}>
<View style={[styles.vaultKeyCircleSuccess, stylesHook.vaultKeyCircleSuccess]}>
<Icon size={24} name="check" type="ionicons" color={colors.msSuccessCheck} />
</View>
<View style={styles.vaultKeyTextSignedWrapper}>
<Text style={[styles.vaultKeyTextSigned, stylesHook.vaultKeyTextSigned]}>
{loc.formatString(loc.multisig.vault_key, { number: el.index + 1 })}
</Text>
</View>
</View>
);
};
const _onReadUniformResource = ur => {
try {
const [index, total] = extractSingleWorkload(ur);
animatedQRCodeData[index + 'of' + total] = ur;
if (Object.values(animatedQRCodeData).length === total) {
const payload = decodeUR(Object.values(animatedQRCodeData));
const psbtB64 = Buffer.from(payload, 'hex').toString('base64');
_combinePSBT(psbtB64);
} else {
setAnimatedQRCodeData(animatedQRCodeData);
}
} catch (Err) {
alert(loc._.invalid_animated_qr_code_fragment);
}
};
const _combinePSBT = receivedPSBTBase64 => {
const receivedPSBT = bitcoin.Psbt.fromBase64(receivedPSBTBase64);
try {
const newPsbt = psbt.combine(receivedPSBT);
navigation.dangerouslyGetParent().pop();
setPsbt(newPsbt);
setIsModalVisible(false);
} catch (error) {
alert(error);
}
};
const onBarScanned = ret => {
if (!ret.data) ret = { data: ret };
if (ret.data.toUpperCase().startsWith('UR')) {
return _onReadUniformResource(ret.data);
} else if (ret.data.indexOf('+') === -1 && ret.data.indexOf('=') === -1 && ret.data.indexOf('=') === -1) {
// this looks like NOT base64, so maybe its transaction's hex
// we dont support it in this flow
} else {
// psbt base64?
_combinePSBT(ret.data);
}
};
const onConfirm = () => {
try {
psbt.finalizeAllInputs();
} catch (_) {} // ignore if it is already finalized
try {
const tx = psbt.extractTransaction().toHex();
const satoshiPerByte = Math.round(getFee() / (tx.length / 2));
navigation.navigate('Confirm', {
fee: new BigNumber(getFee()).dividedBy(100000000).toNumber(),
memo: memo,
fromWallet: wallet,
tx,
recipients: targets,
satoshiPerByte,
});
} catch (error) {
alert(error);
}
};
const openScanner = () => {
if (isDesktop) {
ImagePicker.launchCamera(
{
title: null,
mediaType: 'photo',
takePhotoButtonTitle: null,
},
response => {
if (response.uri) {
const uri = Platform.OS === 'ios' ? response.uri.toString().replace('file://', '') : response.path.toString();
LocalQRCode.decode(uri, (error, result) => {
if (!error) {
onBarScanned(result);
} else {
alert(loc.send.qr_error_no_qrcode);
}
});
} else if (response.error) {
ScanQRCode.presentCameraNotAuthorizedAlert(response.error);
}
},
);
} else {
navigation.navigate('ScanQRCodeRoot', {
screen: 'ScanQRCode',
params: {
onBarScanned: onBarScanned,
showFileImportButton: true,
},
});
}
};
const exportPSBT = async () => {
await fs.writeFileAndExport(fileName, psbt.toBase64());
};
const isConfirmEnabled = () => {
return howManySignaturesWeHave() >= wallet.getM();
};
const renderDynamicQrCode = () => {
return (
<SafeBlueArea style={[styles.root, stylesHook.root]}>
<ScrollView centerContent contentContainerStyle={styles.scrollViewContent}>
<View style={[styles.modalContentShort, stylesHook.modalContentShort]}>
<DynamicQRCode value={psbt.toHex()} capacity={666} />
<BlueSpacing20 />
<SquareButton
style={[styles.exportButton, stylesHook.exportButton]}
onPress={openScanner}
title={loc.multisig.scan_or_import_file}
/>
<BlueSpacing20 />
<SquareButton style={[styles.exportButton, stylesHook.exportButton]} onPress={exportPSBT} title={loc.multisig.share} />
<BlueSpacing20 />
<BlueButtonLink title={loc._.cancel} onPress={() => setIsModalVisible(false)} />
</View>
</ScrollView>
</SafeBlueArea>
);
};
const destinationAddress = () => {
// eslint-disable-next-line prefer-const
let destinationAddressView = [];
const destinations = Object.entries(destination.split(','));
for (const [index, address] of destinations) {
if (index > 1) {
destinationAddressView.push(
<View style={styles.destionationTextContainer} key={`end-${index}`}>
<Text style={[styles.textDestinationFirstFour, stylesHook.textFiat]}>and {destinations.length - 2} more...</Text>
</View>,
);
break;
} else {
const currentAddress = address.replace(/\s/g, '');
const firstFour = currentAddress.substring(0, 5);
const lastFour = currentAddress.substring(currentAddress.length - 5, currentAddress.length);
const middle = currentAddress.split(firstFour)[1].split(lastFour)[0];
destinationAddressView.push(
<View style={styles.destionationTextContainer} key={`${currentAddress}-${index}`}>
<Text style={[styles.textDestinationFirstFour, stylesHook.textBtc]}>{firstFour}</Text>
<View style={styles.textDestinationSpacingRight} />
<Text style={[styles.textDestinationFirstFour, stylesHook.textFiat]}>{middle}</Text>
<View style={styles.textDestinationSpacingLeft} />
<Text style={[styles.textDestinationFirstFour, stylesHook.textBtc]}>{lastFour}</Text>
</View>,
);
}
}
return destinationAddressView;
};
const header = (
<View style={stylesHook.root}>
<View style={styles.containerText}>
<BlueText style={[styles.textBtc, stylesHook.textBtc]}>{totalBtc}</BlueText>
<View style={styles.textBtcUnit}>
<BlueText style={[styles.textBtcUnitValue, stylesHook.textBtcUnitValue]}> {BitcoinUnit.BTC}</BlueText>
</View>
</View>
<View style={styles.containerText}>
<BlueText style={[styles.textFiat, stylesHook.textFiat]}>{totalFiat}</BlueText>
</View>
<View>{destinationAddress()}</View>
</View>
);
const footer = (
<View style={styles.bottomWrapper}>
<View style={styles.bottomFeesWrapper}>
<BlueText style={[styles.feeFiatText, stylesHook.feeFiatText]}>
{loc.formatString(loc.multisig.fee, { number: currency.satoshiToLocalCurrency(getFee()) })} -{' '}
</BlueText>
<BlueText>{loc.formatString(loc.multisig.fee_btc, { number: currency.satoshiToBTC(getFee()) })}</BlueText>
</View>
<BlueButton disabled={!isConfirmEnabled()} title={loc.multisig.confirm} onPress={onConfirm} />
</View>
);
if (isModalVisible) return renderDynamicQrCode();
const onLayout = e => {
setFlatListHeight(e.nativeEvent.layout.height);
};
const data = new Array(wallet.getM());
return (
<SafeBlueArea style={[styles.root, stylesHook.root]}>
<View style={styles.container}>
<View style={styles.mstopcontainer}>
<View style={styles.mscontainer}>
<View style={[styles.msleft, { height: flatListHeight - 200 }]} />
</View>
<View style={styles.msright}>
<BlueCard>
<FlatList
data={data}
onLayout={onLayout}
renderItem={_renderItem}
keyExtractor={(_item, index) => `${index}`}
ListHeaderComponent={header}
scrollEnabled={false}
/>
</BlueCard>
</View>
</View>
{footer}
</View>
</SafeBlueArea>
);
};
const styles = StyleSheet.create({
root: {
flex: 1,
},
mstopcontainer: {
flex: 1,
flexDirection: 'row',
},
mscontainer: {
flex: 10,
},
msleft: {
width: 1,
borderStyle: 'dashed',
borderWidth: 0.8,
borderColor: '#c4c4c4',
marginLeft: 40,
marginTop: 185,
},
msright: {
flex: 90,
marginLeft: '-11%',
},
scrollViewContent: {
flexGrow: 1,
justifyContent: 'space-between',
},
container: {
flexDirection: 'column',
paddingTop: 24,
flex: 1,
},
containerText: {
flexDirection: 'row',
justifyContent: 'center',
},
destionationTextContainer: {
flexDirection: 'row',
justifyContent: 'center',
marginBottom: 4,
},
textFiat: {
fontSize: 16,
fontWeight: '500',
marginBottom: 30,
},
textBtc: {
fontWeight: 'bold',
fontSize: 30,
},
textDestinationFirstFour: {
fontWeight: 'bold',
},
textDestination: {
paddingTop: 10,
paddingBottom: 40,
},
bottomModal: {
justifyContent: 'flex-end',
margin: 0,
},
modalContentShort: {
marginLeft: 20,
marginRight: 20,
},
copyToClipboard: {
justifyContent: 'center',
alignItems: 'center',
},
exportButton: {
height: 48,
borderRadius: 8,
flex: 1,
justifyContent: 'center',
paddingHorizontal: 16,
},
provideSignatureButton: {
marginTop: 24,
marginLeft: 40,
height: 48,
borderRadius: 8,
flex: 1,
justifyContent: 'center',
paddingHorizontal: 16,
marginBottom: 8,
},
provideSignatureButtonText: { fontWeight: '600', fontSize: 15 },
vaultKeyText: { fontSize: 18, fontWeight: 'bold' },
vaultKeyTextWrapper: { justifyContent: 'center', alignItems: 'center', paddingLeft: 16 },
vaultKeyCircle: {
width: 42,
height: 42,
borderRadius: 25,
justifyContent: 'center',
alignItems: 'center',
},
vaultKeyCircleSuccess: {
width: 42,
height: 42,
borderRadius: 25,
justifyContent: 'center',
alignItems: 'center',
},
itemUnsignedWrapper: { flexDirection: 'row', paddingTop: 16 },
textDestinationSpacingRight: { marginRight: 4 },
textDestinationSpacingLeft: { marginLeft: 4 },
vaultKeyTextSigned: { fontSize: 18, fontWeight: 'bold' },
vaultKeyTextSignedWrapper: { justifyContent: 'center', alignItems: 'center', paddingLeft: 16 },
flexDirectionRow: { flexDirection: 'row', paddingVertical: 12 },
textBtcUnit: { justifyContent: 'flex-end', bottom: 8 },
bottomFeesWrapper: { flexDirection: 'row', paddingBottom: 20 },
bottomWrapper: { justifyContent: 'center', alignItems: 'center', paddingVertical: 20 },
});
PsbtMultisig.navigationOptions = () => ({
...BlueNavigationStyle(null, false),
title: loc.multisig.header,
});
export default PsbtMultisig;

View file

@ -23,7 +23,7 @@ import { HDLegacyP2PKHWallet } from '../../class/wallets/hd-legacy-p2pkh-wallet'
import { HDSegwitP2SHWallet } from '../../class/wallets/hd-segwit-p2sh-wallet';
import ReactNativeHapticFeedback from 'react-native-haptic-feedback';
import Biometric from '../../class/biometrics';
import { HDSegwitBech32Wallet, SegwitP2SHWallet, LegacyWallet, SegwitBech32Wallet, WatchOnlyWallet } from '../../class';
import { HDSegwitBech32Wallet, SegwitP2SHWallet, LegacyWallet, SegwitBech32Wallet, WatchOnlyWallet, MultisigHDWallet } from '../../class';
import { ScrollView } from 'react-native-gesture-handler';
import loc from '../../loc';
import { useTheme, useRoute, useNavigation } from '@react-navigation/native';
@ -182,6 +182,11 @@ const WalletDetails = () => {
wallet,
});
};
const navigateToMultisigCoordinationSetup = () => {
navigate('ExportMultisigCoordinationSetup', {
walletId: wallet.getID(),
});
};
const navigateToXPub = () =>
navigate('WalletXpub', {
secret: wallet.getSecret(),
@ -360,6 +365,35 @@ const WalletDetails = () => {
<BlueSpacing20 />
<Text style={[styles.textLabel1, stylesHook.textLabel1]}>{loc.wallets.details_type.toLowerCase()}</Text>
<Text style={[styles.textValue, stylesHook.textValue]}>{wallet.typeReadable}</Text>
{wallet.type === MultisigHDWallet.type && (
<>
<Text style={[styles.textLabel2, stylesHook.textLabel2]}>multisig</Text>
<BlueText>
{wallet.getM()} of {wallet.getN()}{' '}
{wallet.isNativeSegwit()
? 'native segwit (p2wsh)'
: wallet.isWrappedSegwit()
? 'wrapped segwit (p2sh-p2wsh)'
: 'legacy (p2sh)'}
</BlueText>
</>
)}
{wallet.type === MultisigHDWallet.type && wallet.howManySignaturesCanWeMake() > 0 && (
<>
<Text style={[styles.textLabel2, stylesHook.textLabel2]}>{loc.multisig.how_many_signatures_can_bluewallet_make}</Text>
<BlueText>{wallet.howManySignaturesCanWeMake()}</BlueText>
</>
)}
{wallet.type === MultisigHDWallet.type && !!wallet.getDerivationPath() && (
<>
<Text style={[styles.textLabel2, stylesHook.textLabel2]}>derivation path</Text>
<BlueText>{wallet.getDerivationPath()}</BlueText>
</>
)}
{wallet.type === LightningCustodianWallet.type && (
<>
<Text style={[styles.textLabel1, stylesHook.textLabel1]}>{loc.wallets.details_connected_to.toLowerCase()}</Text>
@ -400,6 +434,15 @@ const WalletDetails = () => {
<BlueSpacing20 />
{wallet.type === MultisigHDWallet.type && (
<>
<SecondButton
onPress={navigateToMultisigCoordinationSetup}
title={loc.multisig.export_coordination_setup.replace(/^\w/, c => c.toUpperCase())}
/>
</>
)}
{(wallet.type === HDLegacyBreadwalletWallet.type ||
wallet.type === HDLegacyP2PKHWallet.type ||
wallet.type === HDSegwitBech32Wallet.type ||

View file

@ -0,0 +1,128 @@
import React, { useCallback, useState } from 'react';
import { ActivityIndicator, InteractionManager, ScrollView, StatusBar, StyleSheet, useWindowDimensions, View } from 'react-native';
import QRCode from 'react-native-qrcode-svg';
import { BlueNavigationStyle, BlueSpacing20, BlueText, SafeBlueArea } from '../../BlueComponents';
import Privacy from '../../Privacy';
import Biometric from '../../class/biometrics';
import loc from '../../loc';
import { encodeUR } from '../../blue_modules/bc-ur/dist';
import { useFocusEffect, useNavigation, useRoute, useTheme } from '@react-navigation/native';
import { SquareButton } from '../../components/SquareButton';
const BlueApp = require('../../BlueApp');
const fs = require('../../blue_modules/fs');
const ExportMultisigCoordinationSetup = () => {
const walletId = useRoute().params.walletId;
const wallet = BlueApp.getWallets().find(w => w.getID() === walletId);
const qrCodeContents = encodeUR(Buffer.from(wallet.getXpub(), 'ascii').toString('hex'), 77777)[0];
const [isLoading, setIsLoading] = useState(true);
const { goBack } = useNavigation();
const { colors } = useTheme();
const { width, height } = useWindowDimensions();
const stylesHook = {
...styles,
loading: {
...styles.loading,
backgroundColor: colors.elevated,
},
root: {
...styles.root,
backgroundColor: colors.elevated,
},
type: { ...styles.type, color: colors.foregroundColor },
secret: { ...styles.secret, color: colors.foregroundColor },
};
const exportTxtFile = async () => {
await fs.writeFileAndExport(wallet.getLabel() + '.txt', wallet.getXpub());
};
useFocusEffect(
useCallback(() => {
Privacy.enableBlur();
const task = InteractionManager.runAfterInteractions(async () => {
if (wallet) {
const isBiometricsEnabled = await Biometric.isBiometricUseCapableAndEnabled();
if (isBiometricsEnabled) {
if (!(await Biometric.unlockWithBiometrics())) {
return goBack();
}
}
setIsLoading(false);
}
});
return () => {
task.cancel();
Privacy.disableBlur();
};
}, [goBack, wallet]),
);
return isLoading ? (
<View style={stylesHook.loading}>
<ActivityIndicator />
</View>
) : (
<SafeBlueArea style={stylesHook.root}>
<StatusBar barStyle="light-content" />
<ScrollView contentContainerStyle={styles.scrollViewContent}>
<View>
<BlueText style={stylesHook.type}>{wallet.getLabel()}</BlueText>
</View>
<BlueSpacing20 />
<View style={styles.activeQrcode}>
<QRCode
value={qrCodeContents}
size={height > width ? width - 40 : width / 2}
logoSize={70}
color="#000000"
logoBackgroundColor={colors.brandingColor}
backgroundColor="#FFFFFF"
ecl="H"
/>
<BlueSpacing20 />
<SquareButton backgroundColor="#EEF0F4" onPress={exportTxtFile} title={loc.multisig.share} />
</View>
<BlueSpacing20 />
<BlueText style={stylesHook.secret}>{wallet.getXpub()}</BlueText>
</ScrollView>
</SafeBlueArea>
);
};
const styles = StyleSheet.create({
loading: {
flex: 1,
paddingTop: 20,
},
root: {
flex: 1,
},
scrollViewContent: {
alignItems: 'center',
justifyContent: 'center',
flexGrow: 1,
},
activeQrcode: { borderWidth: 6, borderRadius: 8, borderColor: '#FFFFFF' },
type: {
fontSize: 17,
fontWeight: '700',
},
secret: {
alignItems: 'center',
paddingHorizontal: 16,
fontSize: 16,
lineHeight: 24,
},
});
ExportMultisigCoordinationSetup.navigationOptions = ({ navigation }) => ({
...BlueNavigationStyle(navigation, true),
title: loc.multisig.export_coordination_setup,
headerLeft: null,
});
export default ExportMultisigCoordinationSetup;

View file

@ -3,7 +3,7 @@ import { View, ActivityIndicator, Image, Text, StyleSheet, StatusBar, ScrollView
import { BlueNavigationStyle } from '../../BlueComponents';
import SortableList from 'react-native-sortable-list';
import LinearGradient from 'react-native-linear-gradient';
import { PlaceholderWallet, LightningCustodianWallet } from '../../class';
import { PlaceholderWallet, LightningCustodianWallet, MultisigHDWallet } from '../../class';
import ReactNativeHapticFeedback from 'react-native-haptic-feedback';
import WalletGradient from '../../class/wallet-gradient';
import loc, { formatBalance, transactionTimeToReadable } from '../../loc';
@ -123,9 +123,16 @@ const ReorderWallets = () => {
<View shadowOpacity={40 / 100} shadowOffset={{ width: 0, height: 0 }} shadowRadius={5} style={styles.itemRoot}>
<LinearGradient shadowColor="#000000" colors={WalletGradient.gradientsFor(item.type)} style={styles.gradient}>
<Image
source={
(LightningCustodianWallet.type === item.type && require('../../img/lnd-shape.png')) || require('../../img/btc-shape.png')
source={(() => {
switch (item.type) {
case LightningCustodianWallet.type:
return require('../../img/lnd-shape.png');
case MultisigHDWallet.type:
return require('../../img/vault-shape.png');
default:
return require('../../img/btc-shape.png');
}
})()}
style={styles.image}
/>

View file

@ -8,6 +8,7 @@ import ReactNativeHapticFeedback from 'react-native-haptic-feedback';
import WalletGradient from '../../class/wallet-gradient';
import { useRoute, useTheme } from '@react-navigation/native';
import loc, { formatBalance, transactionTimeToReadable } from '../../loc';
import { MultisigHDWallet } from '../../class';
/** @type {AppStorage} */
const BlueApp = require('../../BlueApp');
@ -104,9 +105,16 @@ const SelectWallet = ({ navigation }) => {
<View shadowOpacity={40 / 100} shadowOffset={{ width: 0, height: 0 }} shadowRadius={5} style={styles.itemRoot}>
<LinearGradient shadowColor="#000000" colors={WalletGradient.gradientsFor(item.type)} style={styles.gradient}>
<Image
source={
(LightningCustodianWallet.type === item.type && require('../../img/lnd-shape.png')) || require('../../img/btc-shape.png')
source={(() => {
switch (item.type) {
case LightningCustodianWallet.type:
return require('../../img/lnd-shape.png');
case MultisigHDWallet.type:
return require('../../img/vault-shape.png');
default:
return require('../../img/btc-shape.png');
}
})()}
style={styles.image}
/>

View file

@ -0,0 +1,51 @@
/* global it, describe, jasmine, afterAll, beforeAll */
import assert from 'assert';
import { MultisigHDWallet } from '../../class/';
const BlueElectrum = require('../../blue_modules/BlueElectrum'); // so it connects ASAP
global.net = require('net'); // needed by Electrum client. For RN it is proviced in shim.js
global.tls = require('tls'); // needed by Electrum client. For RN it is proviced in shim.js
jasmine.DEFAULT_TIMEOUT_INTERVAL = 300 * 1000;
afterAll(() => {
// 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
try {
await BlueElectrum.waitTillConnected();
} catch (Err) {
console.log('failed to connect to Electrum:', Err);
process.exit(2);
}
});
describe('multisig-hd-wallet', () => {
it('can fetch balance & transactions', async () => {
const path = "m/48'/0'/0'/2'";
const fp1 = 'D37EAD88';
const fp2 = '168DD603';
const Zpub1 = 'Zpub74ijpfhERJNjhCKXRspTdLJV5eoEmSRZdHqDvp9kVtdVEyiXk7pXxRbfZzQvsDFpfDHEHVtVpx4Dz9DGUWGn2Xk5zG5u45QTMsYS2vjohNQ';
const Zpub2 = 'Zpub75mAE8EjyxSzoyPmGnd5E6MyD7ALGNndruWv52xpzimZQKukwvEfXTHqmH8nbbc6ccP5t2aM3mws3pKYSnKpKMMytdbNEZFUxKzztYFM8Pn';
const w = new MultisigHDWallet();
w.addCosigner(Zpub1, fp1);
w.addCosigner(Zpub2, fp2);
w.setDerivationPath(path);
w.setM(2);
assert.strictEqual(w.getM(), 2);
assert.strictEqual(w.getN(), 2);
assert.strictEqual(w.getDerivationPath(), path);
assert.strictEqual(w.getCosigner(1), Zpub1);
assert.strictEqual(w.getCosigner(2), Zpub2);
assert.strictEqual(w.getCosignerForFingerprint(fp1), Zpub1);
assert.strictEqual(w.getCosignerForFingerprint(fp2), Zpub2);
await w.fetchBalance();
await w.fetchTransactions();
assert.strictEqual(w.getTransactions().length, 3);
});
});

View file

@ -0,0 +1,237 @@
{
"addr_history": {
"bc1q0eklp8y3psgyvg5rkny6rvf5hjq9utut4gzdefk5rx993595zv5qhl7a7m": [],
"bc1q2mkhkvx9l7aqksvyf0dwd2x4yn8qx2w3sythjltdkjw70r8hsves2evfg6": [],
"bc1q2u6t2ckd3rzyq9g27t46eqv4nmdjxhkpgrj5affqule7w7tj0suqxely03": [],
"bc1q3p5krekxvrma9v7klsc4vs7gj6jw3lpepkx2vzq8ccdlevz7xaksplvmdw": [],
"bc1q43kuf0kfkjjqngery4qcgxzy6md4c808py6l64y5jvmaqgesmrtsur44nh": [],
"bc1q5k3v77hc4mtns8ufapeuvwwwurgywyvkjqs99xkdakvvwwqcwk0sxxhxvm": [],
"bc1q76vzr936jr9ej958eqpn8hhqcxv2k38jrwvhw2e6vqm8nnx4lcpst9pzua": [],
"bc1q7d3xeglhhgvhk0tkrx5xczvs3ceezvcws5yv2l2ngj3fwwhs8xpq5p675c": [],
"bc1q96pjhzas8da92w55t9e30u7f0k3n23v5er9zc8k8ctv958j4pptq8u6vxf": [],
"bc1q9x28wdnpdw0hv8dg0gg7w0h8x0p8m4dhp7htvr976za0nvkvw7lqsghqr6": [],
"bc1qa554n2j8wxcgpfk620ele5xaxllap5r5f5x6csndsrjpnr48ztdscvueyj": [],
"bc1qae520282au3dl6echfu5cqvec3plvn9fus5dv5dhxhexaezhvmcs9jwjxp": [],
"bc1qcsu2zsvdcumnazzq8quhlw0kcu52kkt4c0xuwpkn4a2cr9m5rmaqxdyt4q": [],
"bc1qe0cu3fh5hsr9t43n6qcj2nsmwf63tr24ahx0tk7evttklymz89qqk2nawa": [],
"bc1qg77km63d8la99gp3u3sgnvc4va7nmgptt6kztxz9v729tnv57maspsdvu8": [],
"bc1qgy840r9ce3z2896w5xfr6jauja3f43aqcajq3d3uxj2np2am3djsmsvc2e": [],
"bc1qhge6xjqcp5paz6hd9d55720qczjugk3kthe0a03epwm6g0p2e7cqftyp6j": [],
"bc1qj6dsj73033pjxf08m4tccddxerxeydjckeasl8d2z2r93lagvcvqzqslrr": [],
"bc1qjx4nka2nsgm2k8uvxa9g5f3xz09cfkxh38ufhqa5p9yxpel3h9wqgkk9s5": [],
"bc1qkzg22vej70cqnrlsxcee9nfnstcr70jalvsmjn0c8rjf0klwyydsk8nggs": [
[
"76bfabeba1fa447bb0ffa497f80585511d238b9c6bd0c3e04381b0152624ef09",
646410
],
[
"cb7fd9c2c937be547aa721fb7ffa0f5a28022d137a3912c9652d1d77fbd382ae",
647181
]
],
"bc1ql0zp6lvke3uang8lfs58tl5h62708petl04mtfknv3fjq7qc2e5q0kz7x2": [],
"bc1qmu4kefaqtp6kw5mfp2rpn03x4768cnt9ctgfmyz27rqq66atg5ks6vdhss": [],
"bc1qqj0zx85x3d2frn4nmdn32fgskq5c2qkvk9sukxp3xsdzuf234mds85w068": [],
"bc1qs97hede4c5fwyzrq0neve8keg6eetn9l4xzanz4h7dss98v7pltqmqwvyd": [],
"bc1qvkm6lkw7erx07g5gnysygecusj7dc8d4vmm6gj5aeetmlg852mmqr5wyy2": [],
"bc1qvkvut2lumw8dvslewt02qq62787eynrjmjdut56uplclqc9qr3pq20j05t": [],
"bc1qwf50xs9pqlrtesmu29d9a87d2vvpuec6l6qkaezty409ry88ahqsygzxe0": [],
"bc1qwpxkr4ac7fyp6y8uegfpqa6phyqex3vdf5mwwrfayrp8889adpgszge8m5": [],
"bc1qwvtu5amzl466ww3cq28svp2wqxhgvhp0gfk06dl0k73w9cwpv33splnrwx": [],
"bc1qx7ep575xfcrxdmpyxeupv9vq8gm52gd8wt0fkk0q7w6n6y89ruuq98crh8": [],
"bc1qzj8v6kxd9k829aqg9t7a5gcm9yj0tnqjlrhuden9zcafk50pqm3qrapvse": []
},
"addresses": {
"change": [
"bc1qqj0zx85x3d2frn4nmdn32fgskq5c2qkvk9sukxp3xsdzuf234mds85w068",
"bc1qwpxkr4ac7fyp6y8uegfpqa6phyqex3vdf5mwwrfayrp8889adpgszge8m5",
"bc1qj6dsj73033pjxf08m4tccddxerxeydjckeasl8d2z2r93lagvcvqzqslrr",
"bc1qg77km63d8la99gp3u3sgnvc4va7nmgptt6kztxz9v729tnv57maspsdvu8",
"bc1ql0zp6lvke3uang8lfs58tl5h62708petl04mtfknv3fjq7qc2e5q0kz7x2",
"bc1qwf50xs9pqlrtesmu29d9a87d2vvpuec6l6qkaezty409ry88ahqsygzxe0",
"bc1q7d3xeglhhgvhk0tkrx5xczvs3ceezvcws5yv2l2ngj3fwwhs8xpq5p675c",
"bc1q43kuf0kfkjjqngery4qcgxzy6md4c808py6l64y5jvmaqgesmrtsur44nh",
"bc1q96pjhzas8da92w55t9e30u7f0k3n23v5er9zc8k8ctv958j4pptq8u6vxf",
"bc1qx7ep575xfcrxdmpyxeupv9vq8gm52gd8wt0fkk0q7w6n6y89ruuq98crh8"
],
"receiving": [
"bc1qkzg22vej70cqnrlsxcee9nfnstcr70jalvsmjn0c8rjf0klwyydsk8nggs",
"bc1q2mkhkvx9l7aqksvyf0dwd2x4yn8qx2w3sythjltdkjw70r8hsves2evfg6",
"bc1q9x28wdnpdw0hv8dg0gg7w0h8x0p8m4dhp7htvr976za0nvkvw7lqsghqr6",
"bc1qa554n2j8wxcgpfk620ele5xaxllap5r5f5x6csndsrjpnr48ztdscvueyj",
"bc1qjx4nka2nsgm2k8uvxa9g5f3xz09cfkxh38ufhqa5p9yxpel3h9wqgkk9s5",
"bc1qwvtu5amzl466ww3cq28svp2wqxhgvhp0gfk06dl0k73w9cwpv33splnrwx",
"bc1qvkvut2lumw8dvslewt02qq62787eynrjmjdut56uplclqc9qr3pq20j05t",
"bc1qhge6xjqcp5paz6hd9d55720qczjugk3kthe0a03epwm6g0p2e7cqftyp6j",
"bc1qae520282au3dl6echfu5cqvec3plvn9fus5dv5dhxhexaezhvmcs9jwjxp",
"bc1qmu4kefaqtp6kw5mfp2rpn03x4768cnt9ctgfmyz27rqq66atg5ks6vdhss",
"bc1q2u6t2ckd3rzyq9g27t46eqv4nmdjxhkpgrj5affqule7w7tj0suqxely03",
"bc1qcsu2zsvdcumnazzq8quhlw0kcu52kkt4c0xuwpkn4a2cr9m5rmaqxdyt4q",
"bc1qe0cu3fh5hsr9t43n6qcj2nsmwf63tr24ahx0tk7evttklymz89qqk2nawa",
"bc1qs97hede4c5fwyzrq0neve8keg6eetn9l4xzanz4h7dss98v7pltqmqwvyd",
"bc1q5k3v77hc4mtns8ufapeuvwwwurgywyvkjqs99xkdakvvwwqcwk0sxxhxvm",
"bc1q0eklp8y3psgyvg5rkny6rvf5hjq9utut4gzdefk5rx993595zv5qhl7a7m",
"bc1q3p5krekxvrma9v7klsc4vs7gj6jw3lpepkx2vzq8ccdlevz7xaksplvmdw",
"bc1qvkm6lkw7erx07g5gnysygecusj7dc8d4vmm6gj5aeetmlg852mmqr5wyy2",
"bc1qgy840r9ce3z2896w5xfr6jauja3f43aqcajq3d3uxj2np2am3djsmsvc2e",
"bc1qzj8v6kxd9k829aqg9t7a5gcm9yj0tnqjlrhuden9zcafk50pqm3qrapvse",
"bc1q76vzr936jr9ej958eqpn8hhqcxv2k38jrwvhw2e6vqm8nnx4lcpst9pzua"
]
},
"channel_backups": {},
"fiat_value": {},
"invoices": {
"7bf2aec9c12aa8b0a2363f283d46769c": {
"amount_sat": "!",
"bip70": null,
"exp": 0,
"id": "7bf2aec9c12aa8b0a2363f283d46769c",
"message": "let's see",
"outputs": [
[
0,
"bc1qhhvjcww8q904upz96nnery7nz5qd404w2prsmf",
"!"
]
],
"requestor": null,
"time": 1598963621,
"type": 0
}
},
"labels": {
"76bfabeba1fa447bb0ffa497f80585511d238b9c6bd0c3e04381b0152624ef09": "test",
"bc1qkzg22vej70cqnrlsxcee9nfnstcr70jalvsmjn0c8rjf0klwyydsk8nggs": "test",
"cb7fd9c2c937be547aa721fb7ffa0f5a28022d137a3912c9652d1d77fbd382ae": "let's see"
},
"payment_requests": {
"bc1qkzg22vej70cqnrlsxcee9nfnstcr70jalvsmjn0c8rjf0klwyydsk8nggs": {
"amount_sat": 0,
"bip70": null,
"exp": 86400,
"id": "44291cd0de",
"message": "test",
"outputs": [
[
0,
"bc1qkzg22vej70cqnrlsxcee9nfnstcr70jalvsmjn0c8rjf0klwyydsk8nggs",
0
]
],
"requestor": null,
"time": 1598962791,
"type": 0
}
},
"prevouts_by_scripthash": {
"8aa1e2df0a1439e2bf1e3523284072c06b05ddd2f3742473009cb86edd5884e8": [
[
"cb7fd9c2c937be547aa721fb7ffa0f5a28022d137a3912c9652d1d77fbd382ae:0",
19854
]
],
"ea0ecf0f9c05b2c445966000703249c434b39fd858bb05dacfd8b18294cb4154": [
[
"76bfabeba1fa447bb0ffa497f80585511d238b9c6bd0c3e04381b0152624ef09:0",
20000
]
],
"eb2505bd6502a55e9acba99bda3795b34ffe564cb37de89b2cb077e85b6ef03b": [
[
"76bfabeba1fa447bb0ffa497f80585511d238b9c6bd0c3e04381b0152624ef09:1",
130625
]
]
},
"qt-console-history": [],
"seed_version": 32,
"spent_outpoints": {
"76bfabeba1fa447bb0ffa497f80585511d238b9c6bd0c3e04381b0152624ef09": {
"0": "cb7fd9c2c937be547aa721fb7ffa0f5a28022d137a3912c9652d1d77fbd382ae"
},
"e8030ec801e82cadee08c959b246b72199f2112e4b5fc95ce37abdb4ad591901": {
"1": "76bfabeba1fa447bb0ffa497f80585511d238b9c6bd0c3e04381b0152624ef09"
}
},
"stored_height": 649897,
"transactions": {
"76bfabeba1fa447bb0ffa497f80585511d238b9c6bd0c3e04381b0152624ef09": "02000000000101011959adb4bd7ae35cc95f4b2e11f29921b746b259c908eead2ce801c80e03e801000000000000008002204e000000000000220020b090a53332f3f0098ff0363392cd3382f03f3e5dfb21b94df838e497dbee211b41fe0100000000001600140bf2e2fddd5d11bbf533d02348dae0b5abddccf60248304502210089c592f2660b2b3cd60cf32fbb1ef2d8088273d54b79fd4ced3da8308bafa6b102205d6622c92dd39f2704d465cb4f62c6348f1daed0dff3f2037596c257ee628dd6012102cd5a0cf85fc0f1632cf08f280fec194f39048618402d74344b543a4cc29c098300000000",
"cb7fd9c2c937be547aa721fb7ffa0f5a28022d137a3912c9652d1d77fbd382ae": "0200000000010109ef242615b08143e0c3d06b9c8b231d518505f897a4ffb07b44faa1ebabbf760000000000fdffffff018e4d000000000000160014bdd92c39c7015f5e0445d4e79193d31500dabeae04004730440220614f412bb6d3a16b37fc8bda799ed624be0249c78b5cd8e489abc14ee137e20202201df43d9fc4ee974525a89a92fea3b9469a640981bedebf646f5fde9faf35aa300147304402205ba807eb6d3b7b9ad11c9d6d29aa21b12e2dda0d3ceecc51324c202715c2c10602207fd510556a9ee9650b69becfedd07e891f2add4c9d1a9f564b02d6aff383aa9701695221027b67bf915fa780268554d293aaf38219ebca75109a0292276ac7f249d8c020ce210396e5d921929dc655febfd2d378233347e9e38be88f6f18d82a36f1587d4afaf62103be43383e4661dfba8046f63d8b73a31dcef69c85dd44336d2a3053d064d02d5953ae88dc0900"
},
"tx_fees": {
"76bfabeba1fa447bb0ffa497f80585511d238b9c6bd0c3e04381b0152624ef09": [
null,
false,
1
],
"cb7fd9c2c937be547aa721fb7ffa0f5a28022d137a3912c9652d1d77fbd382ae": [
146,
true,
1
]
},
"txi": {
"cb7fd9c2c937be547aa721fb7ffa0f5a28022d137a3912c9652d1d77fbd382ae": {
"bc1qkzg22vej70cqnrlsxcee9nfnstcr70jalvsmjn0c8rjf0klwyydsk8nggs": {
"76bfabeba1fa447bb0ffa497f80585511d238b9c6bd0c3e04381b0152624ef09:0": 20000
}
}
},
"txo": {
"76bfabeba1fa447bb0ffa497f80585511d238b9c6bd0c3e04381b0152624ef09": {
"bc1qkzg22vej70cqnrlsxcee9nfnstcr70jalvsmjn0c8rjf0klwyydsk8nggs": {
"0": [
20000,
false
]
}
}
},
"use_encryption": false,
"verified_tx3": {
"76bfabeba1fa447bb0ffa497f80585511d238b9c6bd0c3e04381b0152624ef09": [
646410,
1599033977,
1607,
"000000000000000000066e6b1be6edf8b65fb1ecda09d4fd5f0387098db26d81"
],
"cb7fd9c2c937be547aa721fb7ffa0f5a28022d137a3912c9652d1d77fbd382ae": [
647181,
1599501121,
463,
"0000000000000000000d126b807c92de495668dd4924de6917b58b6d7b67da62"
]
},
"wallet_type": "2of3",
"winpos-qt": [
200,
175,
890,
463
],
"x1/": {
"derivation": "m/1'",
"pw_hash_version": 1,
"root_fingerprint": "8aaa5d05",
"seed": "during pride layer jelly admit army want melody check witness favorite prosper",
"type": "bip32",
"xprv": "ZprvAkUsoZMLiqxrhaM8VpmVJ6QhjH4dZnYpTNNHGMZ3VoE6vRv7xfDeMEiKAeH1eUcN3CFUP87CgM1anM2UytMkykUMtVmXkkohRsiVGth1VMG",
"xpub": "Zpub6yUED4tEZDX9v4RbbrJVfEMSHJu7yFGfpbHt4jxf48m5oEFGWCXtu32o1wQkEbCCrHJfRbc8GeoBwpRowcvTMHruNcsbm97QD4uUzaXrtNK"
},
"x2/": {
"derivation": "m/1'",
"pw_hash_version": 1,
"root_fingerprint": "ef748d2c",
"type": "bip32",
"xprv": null,
"xpub": "Zpub6zDCLaNWD5uppmN4gsUCGpVYpxMJMRLEx2MXeV4Qsj7VdzgTLL4VNhevYtjd8FjmMz2j9dw5oiamUF25hsxAYuFSSq2zoVHrv6MWXfjjHXq"
},
"x3/": {
"derivation": "m/1'",
"pw_hash_version": 1,
"root_fingerprint": "fdb6c4d8",
"type": "bip32",
"xprv": null,
"xpub": "Zpub6zKGu3ZgmL6WQ2kyVjSNtn8D3hKC26WBQW8tuyyRxkKrdAEQ6VCdqoFf68bVvQ289ZBTJVkUKaggwtBQFZBBMmozTPMVeBHGyHPXfvG9KkR"
}
}

File diff suppressed because it is too large Load diff