From 5c512833d9f87a1067677effe1daf9e65cc1a29b Mon Sep 17 00:00:00 2001 From: Overtorment Date: Mon, 5 Oct 2020 22:25:14 +0100 Subject: [PATCH] ADD: multisig technical release --- BlueComponents.js | 26 +- Navigation.js | 8 + blue_modules/fs.js | 79 + class/app-storage.js | 6 +- class/index.js | 1 + class/wallet-gradient.js | 8 + class/wallet-import.js | 18 +- class/wallets/abstract-wallet.js | 2 + class/wallets/multisig-hd-wallet.js | 801 ++++++++++ components/DynamicQRCode.js | 180 +++ components/SquareButton.js | 38 + components/themes.js | 4 + img/vault-shape.png | Bin 0 -> 7815 bytes ios/BlueWallet.xcodeproj/project.pbxproj | 39 +- ios/BlueWallet/Info.plist | 67 + ios/Podfile.lock | 2 +- loc/en.json | 21 +- loc/id_id.json | 2 +- loc/it.json | 2 +- loc/nl_nl.json | 2 +- loc/sk_sk.json | 2 +- loc/sv_se.json | 2 +- loc/tr_tr.json | 2 +- loc/zh_cn.json | 2 +- package-lock.json | 15 +- package.json | 2 +- screen/send/ScanQRCode.js | 20 +- screen/send/details.js | 41 +- screen/send/psbtMultisig.js | 501 +++++++ screen/wallets/details.js | 45 +- .../exportMultisigCoordinationSetup.js | 128 ++ screen/wallets/reorderWallets.js | 15 +- screen/wallets/selectWallet.js | 14 +- tests/integration/multisig-hd-wallet.test.js | 51 + .../electrum-multisig-wallet-with-seed.json | 237 +++ tests/unit/multisig-hd-wallet.test.js | 1299 +++++++++++++++++ 36 files changed, 3595 insertions(+), 87 deletions(-) create mode 100644 blue_modules/fs.js create mode 100644 class/wallets/multisig-hd-wallet.js create mode 100644 components/DynamicQRCode.js create mode 100644 components/SquareButton.js create mode 100644 img/vault-shape.png create mode 100644 screen/send/psbtMultisig.js create mode 100644 screen/wallets/exportMultisigCoordinationSetup.js create mode 100644 tests/integration/multisig-hd-wallet.test.js create mode 100644 tests/unit/fixtures/electrum-multisig-wallet-with-seed.json create mode 100644 tests/unit/multisig-hd-wallet.test.js diff --git a/BlueComponents.js b/BlueComponents.js index 7e63981ee..108b476aa 100644 --- a/BlueComponents.js +++ b/BlueComponents.js @@ -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' }} > { + 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 }} > { + 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, diff --git a/Navigation.js b/Navigation.js index 4ddff7297..6e2e8d6f6 100644 --- a/Navigation.js +++ b/Navigation.js @@ -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} /> + ( {/* screens */} + diff --git a/blue_modules/fs.js b/blue_modules/fs.js new file mode 100644 index 000000000..d9be99199 --- /dev/null +++ b/blue_modules/fs.js @@ -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} 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; diff --git a/class/app-storage.js b/class/app-storage.js index b13c181ec..21cf14b03 100644 --- a/class/app-storage.js +++ b/class/app-storage.js @@ -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); diff --git a/class/index.js b/class/index.js index 0096065c3..2be347e70 100644 --- a/class/index.js +++ b/class/index.js @@ -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'; diff --git a/class/wallet-gradient.js b/class/wallet-gradient.js index 591f7e946..f65f916d2 100644 --- a/class/wallet-gradient.js +++ b/class/wallet-gradient.js @@ -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; diff --git a/class/wallet-import.js b/class/wallet-import.js index be5ba2946..51574fd30 100644 --- a/class/wallet-import.js +++ b/class/wallet-import.js @@ -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(); diff --git a/class/wallets/abstract-wallet.js b/class/wallets/abstract-wallet.js index 956f0a564..20b6c84ba 100644 --- a/class/wallets/abstract-wallet.js +++ b/class/wallets/abstract-wallet.js @@ -264,4 +264,6 @@ export class AbstractWallet { return b58.encode(data); } + + prepareForSerialization() {} } diff --git a/class/wallets/multisig-hd-wallet.js b/class/wallets/multisig-hd-wallet.js new file mode 100644 index 000000000..3fb885e8c --- /dev/null +++ b/class/wallets/multisig-hd-wallet.js @@ -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.} + * @returns {Array.} + */ + 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; + } +} diff --git a/components/DynamicQRCode.js b/components/DynamicQRCode.js new file mode 100644 index 000000000..feaaad70a --- /dev/null +++ b/components/DynamicQRCode.js @@ -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 ( + + {loc.send.dynamic_init} + + ); + } + + return ( + + { + this.setState(prevState => ({ hideControls: !prevState.hideControls })); + }} + > + + + + {!this.state.hideControls && ( + + + + + {loc.formatString(loc._.of, { number: this.state.index + 1, total: this.state.total })} + + + + + + {loc.send.dynamic_prev} + + + {this.state.intervalHandler ? loc.send.dynamic_stop : loc.send.dynamic_start} + + + {loc.send.dynamic_next} + + + + )} + + ); + } +} + +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', + }, +}); diff --git a/components/SquareButton.js b/components/SquareButton.js new file mode 100644 index 000000000..e7f43a31d --- /dev/null +++ b/components/SquareButton.js @@ -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 ( + + + {props.icon && } + {props.title && {props.title}} + + + ); +}; diff --git a/components/themes.js b/components/themes.js index a90c1cf96..d81dbe7c5 100644 --- a/components/themes.js +++ b/components/themes.js @@ -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', }, }; diff --git a/img/vault-shape.png b/img/vault-shape.png new file mode 100644 index 0000000000000000000000000000000000000000..3fa02de759c34b0fdf622c5943b10a7a7a790c99 GIT binary patch literal 7815 zcmXY02{=^W`yXQ;`?oAv#*!$+SCM^;rI1K5mTVzPH6z;?J73$B7*UkH>||%`OSYjH zB-_}HCHpejxBu1e|J>(3_dM@;_xE$(bMCpXjSO{IAiNL|2*jeNtNjoJqKX0T6b3qg zqAGmlAK*b}udkyGIy=A88*<_R3gatX3wJ!r5!EBc5*hx_8Gdv6_j!v$Ix1hR1wt%WSdD>}-qt1!5_S7Lgws zJzCN~Pm=NrA9BN)Tl>@w(k5KaHl#%km(nKU(vr?LU?SmDr*)@QXN|rK!|Q1W86LgI zr!prslZOWZpIa%Uv?VoYf@;_RZZQCcKR%d=N)xo%S5bvgR@i!vd3#SVyS+o66GjyO zflJCGttG#6zImRy@^I*IxNeU1Hr;x)&{#_U6vaul-Mg!(20K{P(jLEm=JzpRT~-8I zSL8dsNX<~wI{OUrx>a1*b@QGsPiCI$OK2)xXIxNClIB8@NtOuQ+~INZowlt%8MsA3 zK@Cd6kqZ^Y|IjtA;I6AyzngjYU~B(H-lxWnpA#QXNhTa&t5X8}(!fqz@|;wfFFwnv zhbt#ayp3B5WYeU!;h)+_4ej#R9+%+|1BaSwIs{1VcnD}^QPm;D*^EDqqpiIp0fW!{ zKK@5;u7o(FDtQE5taaLki&mgC4A<#8{H~|oZaXkR=1L2>gl(&dG~Q`5&}Mc~Tlh!l zJ!`bL8u^c2rLbc9%b0FNmw~olU|xZKkdcJqf{ux}agfJ<)cH)Y!U1@CTQ6%barjjM z{siO7nZuB9)YLUySg*z`otVzE?v3Yu$r7v|=qju~jo)c|N~QUI`gczA$GSXCO!IS1 zZCcH;H)Pd-#yUHH>Y+rDlE)Y;Lry|)1{Ij*R-PiPt-7PX;1)cn+47q!51E(X{Wfy$ zKWZ9=dJ9*pw*8^%X%H2dFN_euGes`xIG2G$XJGd)?&gAN7%XZ2vXfk?s9KV~^%HtM zqqV+KF|gla#0_ApAuIerAiKzXvRl5Y2{4@Lnf`6>mqwOQEdpfu!U-C0U1G*;1lWaM z@wd`vA^6A*RcZUx5@mMi{}>#`!{>heKZl+6 z@g;N*E#OQjVadcB^Mn?#&x$*Ua$Dp9G+*Sm);{JKQUOu5aLx4Vw4H_Su)>2%Ze#cf z)#j)M>c7SX?fsPc;za@F~L){N8L?&V#_A zQLPTB0a|t;2DWsy2v&^?Gp_99vH2G2-0(fRWj36*wNP$WW$fC}&|JFI?-b=Ls{u3Ug}8}Cp7G(}Zn_GXE!$lqbK!#F<& zVB8hcopf258*olCFdS_SP#kw$IUVYXJlX!vCKE=ABvpY(J7!$@ITE620BJGRLmrhP ze&7Fz3z*@Z=z)-GfFKrz)XrH431OQ+lw;w92X6(;8k@j>(}#kv*#^=8oJuSkzCg6s z3vjTGBFPz`xcF@y^S$%Dmd2yP{;CPCALj?RVsed5ua zyLIPSGZhrHAK$pUbB-@oHkV{-aA?Z2vGc;40kQ$k?B|w{bwMCk27D1LT5C)?2s9du zw+2y_dG5GE$q`>n1kWb{aKI|oz{ih;G42WAgv3R~u)si^CoO}_xeozXA=H~M&iwbf z(@AUp_O0J|u*4)-_qohnNiM*EMaIe)oLRd0+kUlHnE_Wk%R8*Z~*m|TM$2hItXsiwmat53FWum&UF1x%^1}?Z4)LRtcL#)1<&nlRx?j2xr~x8 z@-E84iUw)E@rr5VIJT~fG`@!tXghxZ800wfe4FTbk*uGVuQS9ic6N_b@7;^>>3z2# zKL{YZ=CqvCT+J}$Y^UxaD_s`&8asx?bSf1w#Vodx6-7>&ajK6XIB#6pz)~$2bk#D( zFD?I_%ey>U^PpoDpllxt*g}l>U>(&%Gd>+po?E><^Uc3QqZ_=eHHPA2y*!IsN+WU~ zFBkLF;c0X`KnE_EZ)IU_Ct7dEet>#c9Q=}Zlsbro?FXVO#WSuPT*MbLy=)GCZJ6~} z&3E>G=)GlHi&X#lkC1w+1N5VbB zd>)HizfH-CyhUGgiA-oY$Xd>Lv}o5r9si0~U8Qoe$9}7Kv~U8w@<;Zo{NK5vGyU!RHo;zxm)$Or<5%II7{_y#pyT_c zr!eBKVTay%!rmqL>yg(b5K+F+(z~)kJzB?3Z ztzkg4ZLh)1OCdZKum)KNAsiHuKM6BMpZvBS&}aV=>`X!o40c_StnbA z@$17s8kl494mXn<8q4;7DFtVz@+7Z1(3Vle>_lDJ`J}q35T?QtlQf-icziuTg9K-_3Z6^OGKfNb(};aeu(*+T0q$w zP(R)>MX*;l*fT-+MIKC5B9DNR2AcdYEC22^+X^Ggh=VMRMQR$|9oVI>%UzVdP7pZr z-7>I#)#hJQCAJ2b3Bh?8aNb_JgE|7NEg6#RLM)+#$fE({io-rU6FAd{@EKW!r+h|o z)979pjKELSCg;K6K^4uXcRfqFXu+A6%;*YyAT58HU-v)ffraUq$%hec?;BlYh@op~ z1^D{U6}{g4$W5$V8`8Q=%@897tX!`Q1)`JL(hvWJAPsc0DCY0tfH5)j80|7!j2+Km z$ruWeBYHk#A|%+F5u4cJfEwfc4R4^j!Q5Kk6#FfomEJf=Q4k>5S4~JbXV4|)!VOy@ zUO)eWm1xC$H1AOXG#v6anQIz-$2%%HYAK(yOg8@2Pu21JMtww_zB-F? zPKyIVcn-6W4~VwjL0l(wF@MBk*mPC_lF;g5se6g}Y;|kd zO*vAD<=n%!m$iT4EpY^%F?0&9;;ZlGs-JQIdalq3oFD}cw<~FS=zpbx@(vxO$ha}M zS({nhK}l_$Z@ROzY2nsjA>=i=jmt`3PrDK0kl zL7fjFNtAa1@RB;YF~hQ?Ch;R}`i_@hp-=PK2I&zt3O&Vo*$nK+I?t3T98qWcQ)ZYu z+I8G#zV=xVbL(TTVV~~0wOHb@g)vo6(cZIxIyo6G0FK>)$zC@H-R*oTV&kL!v9M=` zp$^!Z-bZehCMOaL@Dml$)*DiDS9$&2$~#&9Wo~_d(JK@ax0@ON`p%SJI|JFE-;++% z>zHiS6Y<2ueAA6cg=12yZkpW>?}OOLiap2*U_>BLwC0YW&OMKi(cqFNQ|Q z7Wp$!Dimr~QZ6pw?#sKMHA(_`Qe;jslRuba7iD`kbs&_LJt^tqLOVP5k0+14Rm6?z zteZ_rt3Kj8o_sl~K7*LTly!xx2Ro3VIh5R&xoeZD^4 z3r1m596|$xFZJ_Qy-M}WIMZ$hi#Rcok5?g_IBtaa2ox-`Vld*Q=&GxWSrdXd1T+9G zLFMA-HOPqj z`!qc>U)>y!0P>xFm>Yth_+z@&^}zHq>Asi?A056cw>5A1vNzoKT3lj&jKr9a0xQsn z3|lGFkBFmTtuen!mqWhu4=Os3XM>g8jX4zpvxgko0M`<3Vk-vY%EUpP2w`XV*v1h=mZ)UseXAI}|^PqcUcuNzhr>HUc>MUc&K(&|Y`=mdXGmmqI%Vu1;NND3kK5c&6 zN6^dpEbNvR32F+kiyw9u-2De?y)_y-6}Rkz?oFxg{mvYp&PFO+#}%1J+Uv6vpJanG zSI;Ra-tNl73NMUaYBU6XKK$K+%Dgg)RPZ0&*e}CNh#rOJdW9gAst=x-k(OH{zL-03 z#-32BP9y_)dZ#D@_@vW)(#D%gn=wL-_~}{gyKI8lkotEk(9gmdz7%JH2twIvZ)K{Z)ROIfPnZ7K`Wq)l zqi;I_?^hHKQ>xr4DooZc*97umBR4G)Z`(lFdtXyNd5w8?4_K3VFO2BqD4~i30T?s% z&baf6%u0CF%d1}qfe755^)EXdf88!K}QsLpFv+ z&~>~N@~ zr#XeZzw64dWUDleR+Wc$NY#p4oz0~=wJ%vK(R2ap)2#wc!}$6o8r{^<(vzZ}$}BH1 zC9m5X6(!-@J<+|)ue$gdo{r>0EHe8u1GkxY$A2oZrfB5hidVRAmk>!vCvbeKo zE|zoxawOd1)C~_NmR=MpG28phC#Q9nQbdNB_y`_yVS`zx(YHV(BBOmQy-+P(S}%>mgbt8F>fs?8hrLM`wV78+gYSK>fG>oI=_thjIi2 z7M>NS$l2+(F7RJ_&x(+kqS3->U8KS!wXD01bY9rjcB2Z7(JEte14MM{-X}TkQU|63 zLdAv);;gnL-d+4{94%IYMxuU$2K&j4NfjDezwQt+ALY%`CNl4aU~mR&D{8^`5GN8b zjuQ}aCU=Dd&9;dk-+YTzaJB4I@y*UF#q0ObLcI<62VU#--0`N9j;i5%8V~9IM74st z2qk7yC;Mt}sZ(i{PU{GQd7cbPD5_A**`0P4Ipugih+zJ~YPz`ticd*bV_}a^y3%w2 zW>w*{e`yMePfJG1qjs!^y+bRHK~Lk8hdLttnv)jj(hJ?2MSN1q)f}}BC}+#jMn=l;#Ut@1>^GDW z&_SRMS!x}7(X#cv$)p;LQE;Z3Qks^6>GNS$s?|9d9^-5jG`FXQein7vmXTG=wr=IJ zNVcgBRctooKwK4i&%jdVrFxD3p4z~fDY2wJq6B~i$>($P$~uE-xi|)bb`3aV3rUo# zk^cM+jX6+jTA4{LGwcIrzR?rg8cM5p^teA9{p$!r?XVY(`* z!PFEk^SbuEI+8s6{jv8`0$tUg1()*NUGsvkJ1uE&3c@a^@!c}aD#GiPTc2nP>7qq>fIK`KB~_QW zFrOrx&pv|IG)rTNeZ4FZ-dbN6uefjNkqpzU&*>NXn;BtI8AbyISGA8J@#~_ArQy- zxK^wn362sFvL03ozdi8~(P=QkDFl@7*OS~GpH;8p4UZ=unBMg|5iFw1B|;yW9Jw~7 zfHPNWH)fPvyGBbP|El>mKhWQKfb{h~i$2@6#BAliPuj#@(c!dDle$}QNIMK{6Mahi z>KNzmK3dB@XyLt!_=ad2CpA%;hB-}8=wpK*-_YiK{{nwMe=`6VUVNz+5|UFGHdUo^ z$%*<#!P;*vqm8p6HAuVovRH|>M+{2h5e8N+`ZS%#s*^&dV%n#S*9U8yLZfdgjaRxA zHmJa}*9;mZwjzn9jmpdx=0@UXJN<2;^|rr-%_>Ii%hRWBEn?_RP8@Xra@ov;f6F!uZbVG?AHIX&$)16Z^%f+1p zt(e<4Ba`Jay6#KJ@B-+&@LH5gzQ#Nc-NPDqT5Jv*jdg^l(v)fS_^e&JrMln}M#w6C(kSH5xS`SUu^ zAKBojrETfb_ziAe9wj!0E~mq;O&0Ks$jR}r=l*yO_1(P`fa|r(Xpcp7DwLC@7Eru) zJuggmI&XRdC3wnZ6%PQ+4r%;gvi1&YNbd9c%@Y`x$B$as5=VCdsN+_BUFAfC1^7w6 zX1zxiWHhHad}`y{pg*R#ci(r^KErS6>%^~namNeXhGu4;oS0&kl7s^zDq~`WzZ5FF zaN6Hj6fv!Lc|?Q&K6nGi!+yP#|du)op)&idYd#YUAiRZUhGS~kZ!#wQnYu% zfS-zJ9^^0a{b3*1jW=}xqP?RdR2pLWczz>aL6*AcIQ}l)^#WV0aZA9)fh?B z`D^G!)ubC8LiZi}!9UQaYg+TA3UOhL(;G;Dyik}aFZZ5t-Khyqh=k@GITufQNdlj+ zJ_QUMNAEu{zVlA^esxkj3rj4B-Zu_eMgtp{xrf5@UD(G&CrCNb`uHG8<}f@<>!JK!u`f7nS@ zoMWqN8sYbNIY&tS4VF40vz9{H6y40b3TG9WRsUe3edHrx)xLi=g0RNMr6aemFu}@WE0M;Lhff};MY8scdzFr02@YzKGUFkh)%Pd z0u1LDV6F(nuhA<6RaPTW%BzX z&7#&v3=A=MPST9c|B(8JfU~PniMB|pXMq*C7 zL+6_Ef8WEa$!y?EJwRrA=GtEkIJ>IJ*zjv`)$d@cpPA=Q*Fyf^m$`xg%l8RPb`8KE zh{@tz7lrkqmH8I8^&Iw6F7V2q8ruU~BTJXjOWzYVuW}4wTmqx8M+kZ>eo{hOPMCFN zo18Thz;;>&82dTIwBn|Q%PU}uan`Wrd^n^a%v!L?ejlyAu{S5io.bluewallet.psbt.txn + + CFBundleTypeIconFiles + + CFBundleTypeName + TXT + CFBundleTypeRole + Editor + LSHandlerRank + Owner + LSItemContentTypes + + io.bluewallet.txt + + + + CFBundleTypeIconFiles + + CFBundleTypeName + JSON + CFBundleTypeRole + Editor + LSHandlerRank + Owner + LSItemContentTypes + + io.bluewallet.json + + + CFBundleExecutable $(EXECUTABLE_NAME) @@ -241,6 +270,44 @@ + + UTTypeConformsTo + + public.plain-text + + UTTypeDescription + Text File + UTTypeIconFiles + + UTTypeIdentifier + io.bluewallet.txt + UTTypeTagSpecification + + public.filename-extension + + txt + + + + + UTTypeConformsTo + + public.json + + UTTypeDescription + JSON File + UTTypeIconFiles + + UTTypeIdentifier + io.bluewallet.json + UTTypeTagSpecification + + public.filename-extension + + json + + + diff --git a/ios/Podfile.lock b/ios/Podfile.lock index ef48da307..5a4f4dc4e 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -724,4 +724,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: e9c5efd531ca5ac67a4b743a179eeefb322cf387 -COCOAPODS: 1.10.0.beta.2 +COCOAPODS: 1.9.3 diff --git a/loc/en.json b/loc/en.json index ebe0880c9..08d2d70e0 100644 --- a/loc/en.json +++ b/loc/en.json @@ -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" } } diff --git a/loc/id_id.json b/loc/id_id.json index 1ea5fac5f..9d084b0d3 100644 --- a/loc/id_id.json +++ b/loc/id_id.json @@ -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", diff --git a/loc/it.json b/loc/it.json index 5cb0a5031..9e8352b3d 100644 --- a/loc/it.json +++ b/loc/it.json @@ -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", diff --git a/loc/nl_nl.json b/loc/nl_nl.json index 540283eff..099cfd14d 100644 --- a/loc/nl_nl.json +++ b/loc/nl_nl.json @@ -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", diff --git a/loc/sk_sk.json b/loc/sk_sk.json index ec84214e9..ab6bd4b1f 100644 --- a/loc/sk_sk.json +++ b/loc/sk_sk.json @@ -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", diff --git a/loc/sv_se.json b/loc/sv_se.json index 585dd9c30..b81709b0e 100644 --- a/loc/sv_se.json +++ b/loc/sv_se.json @@ -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", diff --git a/loc/tr_tr.json b/loc/tr_tr.json index 3e8023f30..0d7119352 100644 --- a/loc/tr_tr.json +++ b/loc/tr_tr.json @@ -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", diff --git a/loc/zh_cn.json b/loc/zh_cn.json index 63420baf9..991a539f3 100644 --- a/loc/zh_cn.json +++ b/loc/zh_cn.json @@ -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", diff --git a/package-lock.json b/package-lock.json index 59c443a05..d253b392b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 44f294f3d..bad7dd898 100644 --- a/package.json +++ b/package.json @@ -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/*" diff --git a/screen/send/ScanQRCode.js b/screen/send/ScanQRCode.js index 478db3a35..3b3474d03 100644 --- a/screen/send/ScanQRCode.js +++ b/screen/send/ScanQRCode.js @@ -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 = () => { diff --git a/screen/send/details.js b/screen/send/details.js index a1dd992b3..df8f51df3 100644 --- a/screen/send/details.js +++ b/screen/send/details.js @@ -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 && ( + + )} {this.state.fromWallet.allowBatchSend() && ( <> { + 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 ( + + + + {el.index + 1} + + + + {loc.formatString(loc.multisig.vault_key, { number: el.index + 1 })} + + + + + {renderProvideSignature && ( + + { + setIsModalVisible(true); + }} + > + + {loc.multisig.provide_signature} + + + + )} + + ); + }; + + const _renderItemSigned = el => { + return ( + + + + + + + {loc.formatString(loc.multisig.vault_key, { number: el.index + 1 })} + + + + ); + }; + + 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 ( + + + + + + + + + + setIsModalVisible(false)} /> + + + + ); + }; + + 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( + + and {destinations.length - 2} more... + , + ); + 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( + + {firstFour} + + {middle} + + {lastFour} + , + ); + } + } + return destinationAddressView; + }; + + const header = ( + + + {totalBtc} + + {BitcoinUnit.BTC} + + + + {totalFiat} + + {destinationAddress()} + + ); + const footer = ( + + + + {loc.formatString(loc.multisig.fee, { number: currency.satoshiToLocalCurrency(getFee()) })} -{' '} + + {loc.formatString(loc.multisig.fee_btc, { number: currency.satoshiToBTC(getFee()) })} + + + + ); + + if (isModalVisible) return renderDynamicQrCode(); + + const onLayout = e => { + setFlatListHeight(e.nativeEvent.layout.height); + }; + + const data = new Array(wallet.getM()); + return ( + + + + + + + + + `${index}`} + ListHeaderComponent={header} + scrollEnabled={false} + /> + + + + {footer} + + + ); +}; + +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; diff --git a/screen/wallets/details.js b/screen/wallets/details.js index 036c1b5f3..e2eb44400 100644 --- a/screen/wallets/details.js +++ b/screen/wallets/details.js @@ -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 = () => { {loc.wallets.details_type.toLowerCase()} {wallet.typeReadable} + + {wallet.type === MultisigHDWallet.type && ( + <> + multisig + + {wallet.getM()} of {wallet.getN()}{' '} + {wallet.isNativeSegwit() + ? 'native segwit (p2wsh)' + : wallet.isWrappedSegwit() + ? 'wrapped segwit (p2sh-p2wsh)' + : 'legacy (p2sh)'} + + + )} + + {wallet.type === MultisigHDWallet.type && wallet.howManySignaturesCanWeMake() > 0 && ( + <> + {loc.multisig.how_many_signatures_can_bluewallet_make} + {wallet.howManySignaturesCanWeMake()} + + )} + + {wallet.type === MultisigHDWallet.type && !!wallet.getDerivationPath() && ( + <> + derivation path + {wallet.getDerivationPath()} + + )} + {wallet.type === LightningCustodianWallet.type && ( <> {loc.wallets.details_connected_to.toLowerCase()} @@ -400,6 +434,15 @@ const WalletDetails = () => { + {wallet.type === MultisigHDWallet.type && ( + <> + c.toUpperCase())} + /> + + )} + {(wallet.type === HDLegacyBreadwalletWallet.type || wallet.type === HDLegacyP2PKHWallet.type || wallet.type === HDSegwitBech32Wallet.type || diff --git a/screen/wallets/exportMultisigCoordinationSetup.js b/screen/wallets/exportMultisigCoordinationSetup.js new file mode 100644 index 000000000..7b0257503 --- /dev/null +++ b/screen/wallets/exportMultisigCoordinationSetup.js @@ -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 ? ( + + + + ) : ( + + + + + {wallet.getLabel()} + + + + width ? width - 40 : width / 2} + logoSize={70} + color="#000000" + logoBackgroundColor={colors.brandingColor} + backgroundColor="#FFFFFF" + ecl="H" + /> + + + + + {wallet.getXpub()} + + + ); +}; + +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; diff --git a/screen/wallets/reorderWallets.js b/screen/wallets/reorderWallets.js index 695747e8f..f1caa5e86 100644 --- a/screen/wallets/reorderWallets.js +++ b/screen/wallets/reorderWallets.js @@ -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 = () => { { + 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} /> diff --git a/screen/wallets/selectWallet.js b/screen/wallets/selectWallet.js index 8c95f8b1f..5669b94c6 100644 --- a/screen/wallets/selectWallet.js +++ b/screen/wallets/selectWallet.js @@ -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 }) => { { + 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} /> diff --git a/tests/integration/multisig-hd-wallet.test.js b/tests/integration/multisig-hd-wallet.test.js new file mode 100644 index 000000000..f3b218a2f --- /dev/null +++ b/tests/integration/multisig-hd-wallet.test.js @@ -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); + }); +}); diff --git a/tests/unit/fixtures/electrum-multisig-wallet-with-seed.json b/tests/unit/fixtures/electrum-multisig-wallet-with-seed.json new file mode 100644 index 000000000..c030016c5 --- /dev/null +++ b/tests/unit/fixtures/electrum-multisig-wallet-with-seed.json @@ -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" + } +} \ No newline at end of file diff --git a/tests/unit/multisig-hd-wallet.test.js b/tests/unit/multisig-hd-wallet.test.js new file mode 100644 index 000000000..de5e99e36 --- /dev/null +++ b/tests/unit/multisig-hd-wallet.test.js @@ -0,0 +1,1299 @@ +/* global it, describe */ +import assert from 'assert'; +import { MultisigHDWallet } from '../../class/'; +import { decodeUR } from 'bc-ur/dist'; +const bitcoin = require('bitcoinjs-lib'); + +const mnemonicsCobo = + 'fossil glove maze chest logic shadow document describe awake card bunker lottery sunset athlete giant among logic capable happy sword ridge beef warfare fire'; + +const mnemonicsColdcard = + 'inhale flip hundred clock onion wool upgrade unable cigar cricket move federal drum firm excuse adapt parade flag rice assume acid inch park cool'; + +const fp1cobo = 'D37EAD88'; +const Zpub1 = 'Zpub74ijpfhERJNjhCKXRspTdLJV5eoEmSRZdHqDvp9kVtdVEyiXk7pXxRbfZzQvsDFpfDHEHVtVpx4Dz9DGUWGn2Xk5zG5u45QTMsYS2vjohNQ'; + +const fp2coldcard = '168DD603'; +const Zpub2 = 'Zpub75mAE8EjyxSzoyPmGnd5E6MyD7ALGNndruWv52xpzimZQKukwvEfXTHqmH8nbbc6ccP5t2aM3mws3pKYSnKpKMMytdbNEZFUxKzztYFM8Pn'; + +const txtFileFormatMultisigLegacy = + 'UR:BYTES/TYQHZGEQGDHKYM6KV96KCAPQF46KCARFWD5KWGRNV4682UPQVE5KCEFQ9P3HYETPW3JKGGR0DCSYGVEHG4Q5GWPC9Y9ZXZJWV9KK2W3QGDT97DPNGVER2VEJGF0NYTFJPFGX7MRFVDUN5GPJYPHKVGPJPFZX2UNFWESHG6T0DCAZQMF0XS6JWZJXDAEX6CT58GS9QVJNFQ9Q53PNXAZ5Z3PC8QAZQ7RSW43RVW2NVERXS3E4V4QNJCM30PYYKNFKVGC5S6ZCF4CYG7NFWP24Q3ZZFEX5YUN2FEN4W4MZVFAYKUTWW9MHSVNDWEXHJS34VFFXWM2VG95NWC6ZVAERSET4W4ARGNRK0GEK6C2H0PCXV4TDV3XNWVTY09GH2AN3XCUX64ZPGU6YXUQ2XYMRS3ZYXCCRXW3Q0PC82C3K8Q6RW4EKVDV42UT3X35HSCMDGE3RSVMFW9G8GJJ6VEHY65Z5DDC9J62RWD6427TZ0FR8QUZ2WQE8Z7NGXD95X4JGWDXYW5TEX3TKSCTCGACKKJEEV9ZYGKNW2DNXSS3EXFGXKJZYFD5KSCJGXET5C7N50FK5UD6H2UU5WKTS2G9QHU0U3D'; +const txtFileFormatMultisigWrappedSegwit = + 'UR:BYTES/TYQCQGEQGDHKYM6KV96KCAPQF46KCARFWD5KWGRNV4682UPQVE5KCEFQ9P3HYETPW3JKGGR0DCSYGVEHG4Q5GWPC9Y9ZXZJWV9KK2W3QGDT97S6PXU6YV32ZGE0NYTFJPFGX7MRFVDUN5GPJYPHKVGPJPFZX2UNFWESHG6T0DCAZQMF0XSUZWTESYUHNQFE0XYNS53N0WFKKZAP6YPGRY46NFQK4QVJNFQ9Q53PNXAZ5Z3PC8QAZQKTSW43RV6N524VRZVJTGA3HZ3N0WDD9W5P5T935SCEEW93YK5J5WEN5YURZ8PS52DFCDPE4JUTZ0YE4X52K23ER2J68VEXK6EZDVUENSETTD4GNJ62VDPPKGEMZG93X56TGXAQ4W5MTGYMHQE6JDP55CENPDQEH54PKW5C4Q3NK2EZKYCC2XYMRS3ZYXCCRXW3QT9C82C3KDDM8GAJ5TFC8Z3M42A69ZEN8893YCDTCV568V3ZHW3MHX6TJ2GUYC7JYWEE4JVMKVAV8V7TWVDTNZNJ8TPP42J3E2PENWSMFD9A9X56V2CMYUMNWTPF4J72KG3H8SSM4XGM9ZSMG2AA9WNR8X4V5XS2GV9KNVC6EDFRHG7JJ0G9QPZ2FEZ'; +const txtFileFormatMultisigNativeSegwit = + 'UR:BYTES/TYQHKGEQGDHKYM6KV96KCAPQF46KCARFWD5KWGRNV4682UPQVE5KCEFQ9P3HYETPW3JKGGR0DCSYGVEHG4Q5GWPC9Y9ZXZJWV9KK2W3QGDT97VENGG65YWF3G90NYTFJPFGX7MRFVDUN5GPJYPHKVGPJPFZX2UNFWESHG6T0DCAZQMF0XSUZWTESYUHNQFE0XGNS53N0WFKKZAP6YPGRY46NFQ9Q53PNXAZ5Z3PC8QAZQKNSW43RWDRFDFCXV6Z92F9YU6NGGD94S5NNWP2XGNZ22C6K2M69D4F4YKNYFPC5GANS8944VARY2EZHJ62CDVMHQKRC2F3XVKN629M8X3ZXWPNYGJZ9FPT8G4NS0Q6YG73EG3R42468DCE9S6E40FRN2AF5X4G4GNTNT9FNYAN2DA5YU5G2XYMRS3ZYXCCRXW3QTFC82C3HX4K5Z3FCG448J7ZN0FHHJ5RDGAHXGD29XEXHJ3PHG9XYWNNWV3E824MKX5E8SUR6D9K4552TW44HWAJ9VEV9GJR3D4YRSMNZVF3NVCMR2Q6HGVNPF5EK6AMNXDCYKK2NDE9HQJ6DF4UHGERZFEZ453J40P9H57N5T9RY6WZSDC9QWZ5LU2'; +const coldcardExport = + '{"p2sh_deriv":"m/45\'","p2sh":"xpub6847W6cYUqq4ixcmFb83iqPtJZfnMPTkpYiCsuUybzFppJp2qzh3KCVHsLGQy4WhaxGqkK9aDDZnSfhB92PkHDKihbH6WLztzmN7WW9GYpR","p2wsh_p2sh_deriv":"m/48\'/0\'/0\'/1\'","p2wsh_p2sh":"Ypub6kvtvTZpqGuWtQfg9bL5xe4vDWtwsirR8LzDvsY3vgXvyncW1NGXCUJ9Ps7CiizSSLV6NnnXSYyVDnxCu26QChWzWLg5YCAHam6cYjGtzRz","p2wsh_deriv":"m/48\'/0\'/0\'/2\'","p2wsh":"Zpub75mAE8EjyxSzoyPmGnd5E6MyD7ALGNndruWv52xpzimZQKukwvEfXTHqmH8nbbc6ccP5t2aM3mws3pKYSnKpKMMytdbNEZFUxKzztYFM8Pn","xfp":"168DD603"}'; +const electumJson = + '{"x2/": {"xpub": "Zpub75mAE8EjyxSzoyPmGnd5E6MyD7ALGNndruWv52xpzimZQKukwvEfXTHqmH8nbbc6ccP5t2aM3mws3pKYSnKpKMMytdbNEZFUxKzztYFM8Pn", "hw_type": "coldcard", "ckcc_xfp": 64392470, "label": "Coldcard", "derivation": "m/48\'/1\'/0\'/1\'", "type": "hardware"}, "x1/": {"xpub": "Zpub74ijpfhERJNjhCKXRspTdLJV5eoEmSRZdHqDvp9kVtdVEyiXk7pXxRbfZzQvsDFpfDHEHVtVpx4Dz9DGUWGn2Xk5zG5u45QTMsYS2vjohNQ", "hw_type": "coldcard", "ckcc_xfp": 2293071571, "label": "Coldcard", "derivation": "m/48\'/1\'/0\'/1\'", "type": "hardware"}, "wallet_type": "2of2", "use_encryption": false, "seed_version": 17}'; + +describe('multisig-wallet (p2sh)', () => { + it('basic operations work', async () => { + const w = new MultisigHDWallet(); + w.setSecret(txtFileFormatMultisigLegacy); + + assert.strictEqual(w.getDerivationPath(), "m/45'"); + assert.strictEqual(w.getM(), 2); + assert.strictEqual(w.getN(), 2); + assert.strictEqual(w.howManySignaturesCanWeMake(), 0); + assert.strictEqual( + w.getCosigner(1), + 'xpub69SfFhG5eA9cqxHKM6b1HhXMpDzipUPDBNMBrjNgWWbbzKqnqwx2mvMyB5bRgmLAi7cBgr8euuz4Lvz3maWxpfUmdM71dyQuvq68mTAG4Cp', + ); + assert.strictEqual( + w.getCosigner(2), + 'xpub6847W6cYUqq4ixcmFb83iqPtJZfnMPTkpYiCsuUybzFppJp2qzh3KCVHsLGQy4WhaxGqkK9aDDZnSfhB92PkHDKihbH6WLztzmN7WW9GYpR', + ); + assert.strictEqual(w.getCosignerForFingerprint(fp1cobo), w.getCosigner(1)); + assert.strictEqual(w.getCosignerForFingerprint(fp2coldcard), w.getCosigner(2)); + assert.strictEqual(w.getFingerprint(1), fp1cobo); + assert.strictEqual(w.getFingerprint(2), fp2coldcard); + + assert.strictEqual(w._getExternalAddressByIndex(0), '3J5xQcgBqoykSHhmDJLYp87SgVSNhYrvnz'); + assert.strictEqual(w._getExternalAddressByIndex(1), '3GkEXFYUifSmQ9SgzJDWL37pjMj4LT6vbq'); + assert.strictEqual(w._getInternalAddressByIndex(0), '365MagKko4t7L9XPXSYGkFj23dknTCx4UW'); + assert.strictEqual(w._getInternalAddressByIndex(1), '36j8Qx6vxUknTxGFa5yYuxifEK9PGPEKso'); + assert.ok(!w.isWrappedSegwit()); + assert.ok(!w.isNativeSegwit()); + assert.ok(w.isLegacy()); + }); + + it('can coordinate tx creation', async () => { + const utxos = [ + { + height: 666, + value: 100000, + address: '3J5xQcgBqoykSHhmDJLYp87SgVSNhYrvnz', + txId: '630a227c0b4ca30bc98689d40d31e0407fcc5d61730ce1fa548b26630efddeec', + vout: 0, + txid: '630a227c0b4ca30bc98689d40d31e0407fcc5d61730ce1fa548b26630efddeec', + amount: 100000, + wif: false, + confirmations: 666, + txhex: + '0200000000010211f8cdc7b1255b8d3eb951db2fc6964766aaf6e6d1b42e777c90a52977b3e8e50000000000ffffffff00696f18c09d884c100254825f3ca4f41ca35fbb5e988d3526cbdcfcb30c335b0000000000ffffffff02a08601000000000017a914b3d8a5081a9477dd5d354d4c8a7efc0e64689d1087e8340f0000000000160014eef1091149ba3658a5dfe9c8a8924b3a4f0e1baa02473044022068548d4369730e90f33d4243420b40d4c7ef240bbac1db33354c0e108d503f24022062adcc1d19756bcb3ecae9fe988af7c3147ba7df5ff6b26f4a135037669fcef001210211edf8b518a1ac28d1f9a956a5ddeddaea0df435f2386e7fb86f0e9fde818dda0247304402203140f8ee8311562f15eb1f062f3be98fbe41615262491ad5625d8541ce2e4386022077343891d341112a2b75647d5a1faec0f0a79dac8052249e22eb39591a4bb70c0121023f05c145e61311eb725fdea9834fe20c4e7bbb639def8e47137a2696001f9e9d00000000', + }, + ]; + + const w = new MultisigHDWallet(); + w.setSecret(txtFileFormatMultisigLegacy); + + assert.strictEqual(w.getDerivationPath(), "m/45'"); + assert.strictEqual(w.getM(), 2); + assert.strictEqual(w.getN(), 2); + assert.strictEqual(w.howManySignaturesCanWeMake(), 0); + assert.strictEqual( + w.getCosigner(1), + 'xpub69SfFhG5eA9cqxHKM6b1HhXMpDzipUPDBNMBrjNgWWbbzKqnqwx2mvMyB5bRgmLAi7cBgr8euuz4Lvz3maWxpfUmdM71dyQuvq68mTAG4Cp', + ); + assert.strictEqual( + w.getCosigner(2), + 'xpub6847W6cYUqq4ixcmFb83iqPtJZfnMPTkpYiCsuUybzFppJp2qzh3KCVHsLGQy4WhaxGqkK9aDDZnSfhB92PkHDKihbH6WLztzmN7WW9GYpR', + ); + assert.strictEqual(w.getCosignerForFingerprint(fp1cobo), w.getCosigner(1)); + assert.strictEqual(w.getCosignerForFingerprint(fp2coldcard), w.getCosigner(2)); + assert.strictEqual(w.getFingerprint(1), fp1cobo); + assert.strictEqual(w.getFingerprint(2), fp2coldcard); + + assert.strictEqual(w._getExternalAddressByIndex(0), '3J5xQcgBqoykSHhmDJLYp87SgVSNhYrvnz'); + assert.strictEqual(w._getExternalAddressByIndex(1), '3GkEXFYUifSmQ9SgzJDWL37pjMj4LT6vbq'); + assert.strictEqual(w._getInternalAddressByIndex(0), '365MagKko4t7L9XPXSYGkFj23dknTCx4UW'); + assert.strictEqual(w._getInternalAddressByIndex(1), '36j8Qx6vxUknTxGFa5yYuxifEK9PGPEKso'); + assert.ok(!w.isWrappedSegwit()); + assert.ok(!w.isNativeSegwit()); + assert.ok(w.isLegacy()); + + // transaction is gona be UNsigned because we have no keys + const { psbt, tx } = w.createTransaction( + utxos, + [{ address: '13HaCAB4jf7FYSZexJxoczyDDnutzZigjS', value: 10000 }], + 10, + w._getInternalAddressByIndex(3), + false, + false, + ); + assert.ok(!tx, 'tx should not be provided when PSBT is only partially signed'); + assert.throws(() => psbt.finalizeAllInputs().extractTransaction()); + + assert.strictEqual(w.calculateHowManySignaturesWeHaveFromPsbt(psbt), 0); + assert.ok(w.calculateFeeFromPsbt(psbt) < 3000); + assert.ok(w.calculateFeeFromPsbt(psbt) > 0); + + assert.strictEqual( + psbt.toBase64(), + 'cHNidP8BAHUCAAAAAeze/Q5jJotU+uEMc2FdzH9A4DEN1ImGyQujTAt8IgpjAAAAAAAAAACAAhAnAAAAAAAAGXapFBkSnVPmMZuvGdugWb6tFm35Crj1iKy8VgEAAAAAABepFPI8FESPOvVVeCas7ULqnnFYnc5JhwAAAAAAAQD9cwECAAAAAAECEfjNx7ElW40+uVHbL8aWR2aq9ubRtC53fJClKXez6OUAAAAAAP////8AaW8YwJ2ITBACVIJfPKT0HKNfu16YjTUmy9z8swwzWwAAAAAA/////wKghgEAAAAAABepFLPYpQgalHfdXTVNTIp+/A5kaJ0Qh+g0DwAAAAAAFgAU7vEJEUm6Nlil3+nIqJJLOk8OG6oCRzBEAiBoVI1DaXMOkPM9QkNCC0DUx+8kC7rB2zM1TA4QjVA/JAIgYq3MHRl1a8s+yun+mIr3wxR7p99f9rJvShNQN2afzvABIQIR7fi1GKGsKNH5qVal3e3a6g30NfI4bn+4bw6f3oGN2gJHMEQCIDFA+O6DEVYvFesfBi876Y++QWFSYkka1WJdhUHOLkOGAiB3NDiR00ERKit1ZH1aH67A8KedrIBSJJ4i6zlZGku3DAEhAj8FwUXmExHrcl/eqYNP4gxOe7tjne+ORxN6JpYAH56dAAAAAAEER1IhAuKpQFZsreTHbjczMj+gmPaW2MpWjN/NE9t30TCFV6boIQMM4zcNTGSH1VK788aPJgBwsMhTJ20S4lg/3JkC8mJIjVKuIgYC4qlAVmyt5MduNzMyP6CY9pbYylaM380T23fRMIVXpugQFo3WAy0AAIAAAAAAAAAAACIGAwzjNw1MZIfVUrvzxo8mAHCwyFMnbRLiWD/cmQLyYkiNENN+rYgtAACAAAAAAAAAAAAAAAEAR1IhAoJkVao8TQcPGdH2rhAUNLNEoDTaWVlIZXZEEk77O3NoIQOJYtHCrnVaHKX4kVFrtjn9dVGJENMKTTOYLAY/aCS3rFKuIgICgmRVqjxNBw8Z0fauEBQ0s0SgNNpZWUhldkQSTvs7c2gQ036tiC0AAIABAAAAAwAAACICA4li0cKudVocpfiRUWu2Of11UYkQ0wpNM5gsBj9oJLesEBaN1gMtAACAAQAAAAMAAAAA', + ); + + // signed it on real coldcard device: + const partSignedFromColdcard = + 'cHNidP8BAHUCAAAAAeze/Q5jJotU+uEMc2FdzH9A4DEN1ImGyQujTAt8IgpjAAAAAAAAAACAAhAnAAAAAAAAGXapFBkSnVPmMZuvGdugWb6tFm35Crj1iKy8VgEAAAAAABepFPI8FESPOvVVeCas7ULqnnFYnc5JhwAAAAAAAQD9cwECAAAAAAECEfjNx7ElW40+uVHbL8aWR2aq9ubRtC53fJClKXez6OUAAAAAAP////8AaW8YwJ2ITBACVIJfPKT0HKNfu16YjTUmy9z8swwzWwAAAAAA/////wKghgEAAAAAABepFLPYpQgalHfdXTVNTIp+/A5kaJ0Qh+g0DwAAAAAAFgAU7vEJEUm6Nlil3+nIqJJLOk8OG6oCRzBEAiBoVI1DaXMOkPM9QkNCC0DUx+8kC7rB2zM1TA4QjVA/JAIgYq3MHRl1a8s+yun+mIr3wxR7p99f9rJvShNQN2afzvABIQIR7fi1GKGsKNH5qVal3e3a6g30NfI4bn+4bw6f3oGN2gJHMEQCIDFA+O6DEVYvFesfBi876Y++QWFSYkka1WJdhUHOLkOGAiB3NDiR00ERKit1ZH1aH67A8KedrIBSJJ4i6zlZGku3DAEhAj8FwUXmExHrcl/eqYNP4gxOe7tjne+ORxN6JpYAH56dAAAAACICAuKpQFZsreTHbjczMj+gmPaW2MpWjN/NE9t30TCFV6boRzBEAiA7xMszlRAzEJDo++ZfweUQ1qQS+N7hCHnuZe9ifT11swIgFzcqL0y6iTN9OqIIfLLYA7aydcK3EgtCIpjPl+u//kQBAQMEAQAAACIGAwzjNw1MZIfVUrvzxo8mAHCwyFMnbRLiWD/cmQLyYkiNENN+rYgtAACAAAAAAAAAAAAiBgLiqUBWbK3kx243MzI/oJj2ltjKVozfzRPbd9EwhVem6BAWjdYDLQAAgAAAAAAAAAAAAQRHUiEC4qlAVmyt5MduNzMyP6CY9pbYylaM380T23fRMIVXpughAwzjNw1MZIfVUrvzxo8mAHCwyFMnbRLiWD/cmQLyYkiNUq4AACICAoJkVao8TQcPGdH2rhAUNLNEoDTaWVlIZXZEEk77O3NoENN+rYgtAACAAQAAAAMAAAAiAgOJYtHCrnVaHKX4kVFrtjn9dVGJENMKTTOYLAY/aCS3rBAWjdYDLQAAgAEAAAADAAAAAQBHUiECgmRVqjxNBw8Z0fauEBQ0s0SgNNpZWUhldkQSTvs7c2ghA4li0cKudVocpfiRUWu2Of11UYkQ0wpNM5gsBj9oJLesUq4A\n'; + const psbtFromColdcard = bitcoin.Psbt.fromBase64(partSignedFromColdcard); + + assert.throws(() => psbt.finalizeAllInputs().extractTransaction()); + psbt.combine(psbtFromColdcard); // should not throw an exception + + // signed on real Cobo device: + const psbtFromCobo = bitcoin.Psbt.fromHex( + decodeUR([ + 'UR:BYTES/2OF2/HLCR0ERXVY0S4LY2V6EQUVEXL5XHRNEYC09GZVDX3T7H2KKLX5ASZWFVZJ/Q80ZVKVU4ZQE3PY8GL0N9LS09ZRT2GYHCMMSSS70WVHHKYLFAWKESYGQHXU4Z7N963YEH6W4ZPP7T9KQRK6E8TS4HZG95YG5CE7T7H0L7GSQJYQSRPN3NWR2VVJRA254M70RG7FSQWZCVS5E8D5FWYKPLMJVS9UNZFZX5WVZYQGSZ43YDGF3RAXYWQWRZWYF4JN3KC0QWN3QXJ7FW7WZH8YG9E7ZRE8GZYPEZ7VKKF4HERFVNYHGUF9XVF5X03TJSDSXTYHZXGSKM589KTC2K6QGPQVZQZQQQQQQSG36JYYPW922Q2EK2MEX8DCMNXV3L5ZV0D9KCEFTGEH7DZ0DH05FSS4T6D6PPQVXWXDCDF3JG042JH0EUDREXQPCTPJZNYAK39CJC8LWFJQHJVFYG654WYGRQ9C4FGPTXET0YCAHRWVEJ87SF3A5KMR99DRXLE5FAKA73XZZ40FHGZQTGM4SR95QQPQQQQQQQQQQQQQQZYPSRPN3NWR2VVJRA254M70RG7FSQWZCVS5E8D5FWYKPLMJVS9UNZFZX3P5M74KYZ6QQQSQQQQQQQQQQQQQQQQQQSQ36JYYPGYEZ44G7Y6PC0R8GLDTSSZS6TX39QXND9JK2GV4MYGYJWLVAHX6PPQWYK95WZ4E645899LZG4Z6AK887H25VFZRFS5NFNNQKQV0MGYJM6C54WYGPQ9QNY2K4RCNG8PUVARA4WZQ2RFV6Y5Q6D5K2EFPJHV3QJFMANKUMGZRFHATVG95QQPQQPQQQQQQCQQQQZYQSR393DRS4WW4DPEF0CJ9GKHD3EL464RZGS6V9Y6VUC9SRR76PYK7KPQ95D6CPJ6QQQSQQSQQQQQVQQQQQQ7C43S3', + 'UR:BYTES/1OF2/HLCR0ERXVY0S4LY2V6EQUVEXL5XHRNEYC09GZVDX3T7H2KKLX5ASZWFVZJ/TYZR5URNVF607QGQW5PQQQQQQ8KDALGWVVNGK486UYX8XC2AE3L5PCP3PH2GNPKFPW35CZMUYG9XXQQQQQQQQQQQQZQQYYP8QQQQQQQQQQVHD2G5RYFF65LXXXD67XWM5PVMATGKDHUS4W843ZKTC4SPQQQQQQQQZ753FU3UZ3ZG7WH424UZDT8DGT4FUU2CNH8YNPCQQQQQQQQPQR7HXQGZQQQQQQQPQGGL3NW8KYJ4HRF7H9GAKT7XJERKD2HKUMGMGTNH0JG222THK05W2QQQQQQQPLLLLLLSQ6T0RRQFMZZVZQP9FQJL8JJ0G89RT7A4AXYDX5NVHH8UKVXRXKCQQQQQQQ8LLLLL7Q4QSCQSQQQQQQQP02G5K0V22ZQ6J3MA6HF4F4XG5LHUPEJX38GSSL5RGRCQQQQQQQQKQQ2WAUGFZ9YM5DJC5H07NJ9GJF9N5NCWRW4QY3ESGSPZQ6Z534PKJUCWJREN6SJRGG95P4X8AUJQHWKPMVEN2NQWZZX4Q0EYQGSX9TWVR5VH267T8M9WNL5C3TMUX9RM5L04LA4JDA9PX5PHV60UAUQPYYPPRM0CK5V2RTPG68U6J449MHKA46SD7S6LYWRW07UX7R5LM6QCMKSZGUCYGQ3QX9Q03M5RZ9TZ790TRURZ7WLF37LYZC2JVFY344TZTKZ5RN3WGWRQYGRHXSUFR56PZY4ZKATY04DPLTKQ7ZNEMTYQ2GJFUGHT89V35JAHPSQJZQ3LQHQ5TESNZ84HYH774XP5LCSVFEAMKCUAA78YWYM6Y6TQQ8U7N5QQQQQQYGPQ9C4FGPTXET0YCAHRWVEJ87SF3A5KMR99DRXLE5FAKA73XZZ40FHGGUCYGQ3', + ]), + ); + + psbt.combine(psbtFromCobo); + const txhex = psbt.finalizeAllInputs().extractTransaction().toHex(); + assert.strictEqual( + txhex, + '0200000001ecdefd0e63268b54fae10c73615dcc7f40e0310dd48986c90ba34c0b7c220a6300000000d90047304402203bc4cb339510331090e8fbe65fc1e510d6a412f8dee10879ee65ef627d3d75b3022017372a2f4cba89337d3aa2087cb2d803b6b275c2b7120b422298cf97ebbffe440147304402202ac48d42623e988e038627113594e36c3c0e9c4069792ef385739105cf843c9d0220722f32d64d6f91a59325d1c494cc4d0cf8ae506c0cb25c46442dba1cb65e156d0147522102e2a940566cade4c76e3733323fa098f696d8ca568cdfcd13db77d1308557a6e821030ce3370d4c6487d552bbf3c68f260070b0c853276d12e2583fdc9902f262488d52ae000000800210270000000000001976a91419129d53e6319baf19dba059bead166df90ab8f588acbc5601000000000017a914f23c14448f3af5557826aced42ea9e71589dce498700000000', + ); + }); + + it('can coordinate tx creation and sign 1 of 2', async () => { + const path = "m/45'"; + + const utxos = [ + { + height: 666, + value: 100000, + address: '3J5xQcgBqoykSHhmDJLYp87SgVSNhYrvnz', + txId: '630a227c0b4ca30bc98689d40d31e0407fcc5d61730ce1fa548b26630efddeec', + vout: 0, + txid: '630a227c0b4ca30bc98689d40d31e0407fcc5d61730ce1fa548b26630efddeec', + amount: 100000, + wif: false, + confirmations: 666, + txhex: + '0200000000010211f8cdc7b1255b8d3eb951db2fc6964766aaf6e6d1b42e777c90a52977b3e8e50000000000ffffffff00696f18c09d884c100254825f3ca4f41ca35fbb5e988d3526cbdcfcb30c335b0000000000ffffffff02a08601000000000017a914b3d8a5081a9477dd5d354d4c8a7efc0e64689d1087e8340f0000000000160014eef1091149ba3658a5dfe9c8a8924b3a4f0e1baa02473044022068548d4369730e90f33d4243420b40d4c7ef240bbac1db33354c0e108d503f24022062adcc1d19756bcb3ecae9fe988af7c3147ba7df5ff6b26f4a135037669fcef001210211edf8b518a1ac28d1f9a956a5ddeddaea0df435f2386e7fb86f0e9fde818dda0247304402203140f8ee8311562f15eb1f062f3be98fbe41615262491ad5625d8541ce2e4386022077343891d341112a2b75647d5a1faec0f0a79dac8052249e22eb39591a4bb70c0121023f05c145e61311eb725fdea9834fe20c4e7bbb639def8e47137a2696001f9e9d00000000', + }, + ]; + + const w = new MultisigHDWallet(); + w.addCosigner( + 'xpub69SfFhG5eA9cqxHKM6b1HhXMpDzipUPDBNMBrjNgWWbbzKqnqwx2mvMyB5bRgmLAi7cBgr8euuz4Lvz3maWxpfUmdM71dyQuvq68mTAG4Cp', + fp1cobo, + ); + w.addCosigner(mnemonicsColdcard); + w.setDerivationPath(path); + w.setM(2); + + assert.strictEqual(w.getDerivationPath(), "m/45'"); + assert.strictEqual(w.getM(), 2); + assert.strictEqual(w.getN(), 2); + assert.strictEqual(w.howManySignaturesCanWeMake(), 1); + assert.strictEqual( + w.getCosigner(1), + 'xpub69SfFhG5eA9cqxHKM6b1HhXMpDzipUPDBNMBrjNgWWbbzKqnqwx2mvMyB5bRgmLAi7cBgr8euuz4Lvz3maWxpfUmdM71dyQuvq68mTAG4Cp', + ); + assert.strictEqual(w.getFingerprint(1), fp1cobo); + assert.strictEqual(w.getFingerprint(2), fp2coldcard); + + assert.strictEqual(w._getExternalAddressByIndex(0), '3J5xQcgBqoykSHhmDJLYp87SgVSNhYrvnz'); + assert.strictEqual(w._getExternalAddressByIndex(1), '3GkEXFYUifSmQ9SgzJDWL37pjMj4LT6vbq'); + assert.strictEqual(w._getInternalAddressByIndex(0), '365MagKko4t7L9XPXSYGkFj23dknTCx4UW'); + assert.strictEqual(w._getInternalAddressByIndex(1), '36j8Qx6vxUknTxGFa5yYuxifEK9PGPEKso'); + assert.ok(!w.isWrappedSegwit()); + assert.ok(!w.isNativeSegwit()); + assert.ok(w.isLegacy()); + + // transaction is gona be signed with one key + const { tx, psbt } = w.createTransaction( + utxos, + [{ address: '13HaCAB4jf7FYSZexJxoczyDDnutzZigjS', value: 10000 }], + 10, + w._getInternalAddressByIndex(3), + false, + false, + ); + assert.ok(!tx, 'tx should not be provided when PSBT is only partially signed'); + + assert.strictEqual(w.calculateHowManySignaturesWeHaveFromPsbt(psbt), 1); + assert.strictEqual( + psbt.toBase64(), + 'cHNidP8BAHUCAAAAAeze/Q5jJotU+uEMc2FdzH9A4DEN1ImGyQujTAt8IgpjAAAAAAAAAACAAhAnAAAAAAAAGXapFBkSnVPmMZuvGdugWb6tFm35Crj1iKy8VgEAAAAAABepFPI8FESPOvVVeCas7ULqnnFYnc5JhwAAAAAAAQD9cwECAAAAAAECEfjNx7ElW40+uVHbL8aWR2aq9ubRtC53fJClKXez6OUAAAAAAP////8AaW8YwJ2ITBACVIJfPKT0HKNfu16YjTUmy9z8swwzWwAAAAAA/////wKghgEAAAAAABepFLPYpQgalHfdXTVNTIp+/A5kaJ0Qh+g0DwAAAAAAFgAU7vEJEUm6Nlil3+nIqJJLOk8OG6oCRzBEAiBoVI1DaXMOkPM9QkNCC0DUx+8kC7rB2zM1TA4QjVA/JAIgYq3MHRl1a8s+yun+mIr3wxR7p99f9rJvShNQN2afzvABIQIR7fi1GKGsKNH5qVal3e3a6g30NfI4bn+4bw6f3oGN2gJHMEQCIDFA+O6DEVYvFesfBi876Y++QWFSYkka1WJdhUHOLkOGAiB3NDiR00ERKit1ZH1aH67A8KedrIBSJJ4i6zlZGku3DAEhAj8FwUXmExHrcl/eqYNP4gxOe7tjne+ORxN6JpYAH56dAAAAACICAuKpQFZsreTHbjczMj+gmPaW2MpWjN/NE9t30TCFV6boRzBEAiA7xMszlRAzEJDo++ZfweUQ1qQS+N7hCHnuZe9ifT11swIgFzcqL0y6iTN9OqIIfLLYA7aydcK3EgtCIpjPl+u//kQBAQRHUiEC4qlAVmyt5MduNzMyP6CY9pbYylaM380T23fRMIVXpughAwzjNw1MZIfVUrvzxo8mAHCwyFMnbRLiWD/cmQLyYkiNUq4iBgLiqUBWbK3kx243MzI/oJj2ltjKVozfzRPbd9EwhVem6BAWjdYDLQAAgAAAAAAAAAAAIgYDDOM3DUxkh9VSu/PGjyYAcLDIUydtEuJYP9yZAvJiSI0Q036tiC0AAIAAAAAAAAAAAAAAAQBHUiECgmRVqjxNBw8Z0fauEBQ0s0SgNNpZWUhldkQSTvs7c2ghA4li0cKudVocpfiRUWu2Of11UYkQ0wpNM5gsBj9oJLesUq4iAgKCZFWqPE0HDxnR9q4QFDSzRKA02llZSGV2RBJO+ztzaBDTfq2ILQAAgAEAAAADAAAAIgIDiWLRwq51Whyl+JFRa7Y5/XVRiRDTCk0zmCwGP2gkt6wQFo3WAy0AAIABAAAAAwAAAAA=', + ); + + const psbtSignedOnCobo = bitcoin.Psbt.fromHex( + decodeUR([ + 'UR:BYTES/2OF2/HKFUJ0HDC2GXTAJDW99S7D7KJF9HREVM33DN756FEJJ04MEN2WZSZX97JS/CYGQ3Q80ZVKVU4ZQE3PY8GL0N9LS09ZRT2GYHCMMSSS70WVHHKYLFAWKESYGQHXU4Z7N963YEH6W4ZPP7T9KQRK6E8TS4HZG95YG5CE7T7H0L7GSQJYQSRPN3NWR2VVJRA254M70RG7FSQWZCVS5E8D5FWYKPLMJVS9UNZFZX5WVZYQGSXMGM4P97RM7D5JFLH2EGCKQD74NUGDJMH42JYYXN8TQFPWNSLF6QZYPHVKP7SK456TVRPKALFGDDYHJW7UTVRWELCVTUWJW5GJ8H8E8G57QGPQ3R4YGGZU255Q4NV4HJVWM3HXVERLGYC76TD3JJK3N0U6Y7MWLGNPP2H5M5ZZQCVUVMS6NRYSL249WLNC68JVQRSKRY9XFMDZT39S07UNYP0YCJG34F2UGSXQT32JSZKDJK7F3MWXUENY0AQNRMFDKX226XDLNGNMDMAZVY927NWSYQK3HTQXTGQQZQQQQQQQQQQQQQQYGRQXR8RXUX5CEY864FTHU7X3UNQQU9SEPFJWMGJUFVRLHYEQTEXYJYDZRFHATVG95QQPQQQQQQQQQQQQQQQQQQPQPR4YGGZSFJ9T23UF5RS7XW376HPQ9P5KDZ2QDX6T9V5SETKGSFYA7EMWD5ZZQUFVTGU9TN4TGW2T7Y3294MVW0AW4GCJYXNPFXN8XPVQCLKSF9H43F2UGSZQ2PXG4D283XSWRCE68M2UYQ5XJE5FGP5MFV4JJR9WEZPYNHM8DEKSYXN06KCSTGQQZQQZQQQQQPSQQQQYGPQ8ZTZ68P2UA26RJJL3Y23DWMRNLT42XY3P5C2F5EESTQX8A5ZFDAVZQTGM4SR95QQPQQPQQQQQQCQQQQQQ55FRG0', + 'UR:BYTES/1OF2/HKFUJ0HDC2GXTAJDW99S7D7KJF9HREVM33DN756FEJJ04MEN2WZSZX97JS/TYZRXURNVF607QGQW5PQQQQQQ8KDALGWVVNGK486UYX8XC2AE3L5PCP3PH2GNPKFPW35CZMUYG9XXQQQQQQQQQQQQZQQYYP8QQQQQQQQQQVHD2G5RYFF65LXXXD67XWM5PVMATGKDHUS4W843ZKTC4SPQQQQQQQQZ753FU3UZ3ZG7WH424UZDT8DGT4FUU2CNH8YNPCQQQQQQQQPQR7HXQGZQQQQQQQPQGGL3NW8KYJ4HRF7H9GAKT7XJERKD2HKUMGMGTNH0JG222THK05W2QQQQQQQPLLLLLLSQ6T0RRQFMZZVZQP9FQJL8JJ0G89RT7A4AXYDX5NVHH8UKVXRXKCQQQQQQQ8LLLLL7Q4QSCQSQQQQQQQP02G5K0V22ZQ6J3MA6HF4F4XG5LHUPEJX38GSSL5RGRCQQQQQQQQKQQ2WAUGFZ9YM5DJC5H07NJ9GJF9N5NCWRW4QY3ESGSPZQ6Z534PKJUCWJREN6SJRGG95P4X8AUJQHWKPMVEN2NQWZZX4Q0EYQGSX9TWVR5VH267T8M9WNL5C3TMUX9RM5L04LA4JDA9PX5PHV60UAUQPYYPPRM0CK5V2RTPG68U6J449MHKA46SD7S6LYWRW07UX7R5LM6QCMKSZGUCYGQ3QX9Q03M5RZ9TZ790TRURZ7WLF37LYZC2JVFY344TZTKZ5RN3WGWRQYGRHXSUFR56PZY4ZKATY04DPLTKQ7ZNEMTYQ2GJFUGHT89V35JAHPSQJZQ3LQHQ5TESNZ84HYH774XP5LCSVFEAMKCUAA78YWYM6Y6TQQ8U7N5QQQQQQYGPQ9C4FGPTXET0YCAHRWVEJ87SF3A5KMR99DRXLE5FAKA73XZZ40FHGGU', + ]), + ); + + psbt.combine(psbtSignedOnCobo); + + const hex = psbt.finalizeAllInputs().extractTransaction().toHex(); + assert.strictEqual( + hex, + '0200000001ecdefd0e63268b54fae10c73615dcc7f40e0310dd48986c90ba34c0b7c220a6300000000d90047304402203bc4cb339510331090e8fbe65fc1e510d6a412f8dee10879ee65ef627d3d75b3022017372a2f4cba89337d3aa2087cb2d803b6b275c2b7120b422298cf97ebbffe440147304402206da375097c3df9b4927f756518b01beacf886cb77aaa4421a675812174e1f4e802206ecb07d0b569a5b061b77e9435a4bc9dee2d83767f862f8e93a8891ee7c9d14f0147522102e2a940566cade4c76e3733323fa098f696d8ca568cdfcd13db77d1308557a6e821030ce3370d4c6487d552bbf3c68f260070b0c853276d12e2583fdc9902f262488d52ae000000800210270000000000001976a91419129d53e6319baf19dba059bead166df90ab8f588acbc5601000000000017a914f23c14448f3af5557826aced42ea9e71589dce498700000000', + ); + }); + + it('can do both signatures', () => { + const path = "m/45'"; + + const utxos = [ + { + height: 666, + value: 87740, + address: '3PmqRLiPnBXhdYGN6mAHChXLPvw8wb3Yt8', + txId: '33eaa5193c71519deb968852c9938824d14504a785479a051ea07cc68400ee23', + vout: 1, + txid: '33eaa5193c71519deb968852c9938824d14504a785479a051ea07cc68400ee23', + amount: 87740, + wif: false, + confirmations: 666, + txhex: + '0200000001ecdefd0e63268b54fae10c73615dcc7f40e0310dd48986c90ba34c0b7c220a6300000000d90047304402203bc4cb339510331090e8fbe65fc1e510d6a412f8dee10879ee65ef627d3d75b3022017372a2f4cba89337d3aa2087cb2d803b6b275c2b7120b422298cf97ebbffe440147304402202ac48d42623e988e038627113594e36c3c0e9c4069792ef385739105cf843c9d0220722f32d64d6f91a59325d1c494cc4d0cf8ae506c0cb25c46442dba1cb65e156d0147522102e2a940566cade4c76e3733323fa098f696d8ca568cdfcd13db77d1308557a6e821030ce3370d4c6487d552bbf3c68f260070b0c853276d12e2583fdc9902f262488d52ae000000800210270000000000001976a91419129d53e6319baf19dba059bead166df90ab8f588acbc5601000000000017a914f23c14448f3af5557826aced42ea9e71589dce498700000000', + }, + ]; + + const w = new MultisigHDWallet(); + w.addCosigner(mnemonicsCobo); + w.addCosigner(mnemonicsColdcard); + w.setDerivationPath(path); + w.setM(2); + + assert.strictEqual(w.getDerivationPath(), "m/45'"); + assert.strictEqual(w.getM(), 2); + assert.strictEqual(w.getN(), 2); + assert.strictEqual(w.howManySignaturesCanWeMake(), 2); + assert.strictEqual(w.getFingerprint(1), fp1cobo); + assert.strictEqual(w.getFingerprint(2), fp2coldcard); + + assert.strictEqual(w._getExternalAddressByIndex(0), '3J5xQcgBqoykSHhmDJLYp87SgVSNhYrvnz'); + assert.strictEqual(w._getExternalAddressByIndex(1), '3GkEXFYUifSmQ9SgzJDWL37pjMj4LT6vbq'); + assert.strictEqual(w._getInternalAddressByIndex(0), '365MagKko4t7L9XPXSYGkFj23dknTCx4UW'); + assert.strictEqual(w._getInternalAddressByIndex(1), '36j8Qx6vxUknTxGFa5yYuxifEK9PGPEKso'); + assert.strictEqual(w._getInternalAddressByIndex(3), '3PmqRLiPnBXhdYGN6mAHChXLPvw8wb3Yt8'); + assert.ok(!w.isWrappedSegwit()); + assert.ok(!w.isNativeSegwit()); + assert.ok(w.isLegacy()); + + // transaction is gona be signed with both keys + const { tx, psbt } = w.createTransaction( + utxos, + [{ address: '13HaCAB4jf7FYSZexJxoczyDDnutzZigjS' }], // no change + 10, + w._getInternalAddressByIndex(3), + false, + false, + ); + assert.ok(tx); + assert.ok(psbt); + + assert.throws(() => psbt.finalizeAllInputs()); // throws as it is already finalized + assert.strictEqual(w.calculateHowManySignaturesWeHaveFromPsbt(psbt), 2); + + assert.strictEqual( + psbt.toBase64(), + 'cHNidP8BAFUCAAAAASPuAITGfKAeBZpHhacERdEkiJPJUoiW651RcTwZpeozAQAAAAAAAACAATxPAQAAAAAAGXapFBkSnVPmMZuvGdugWb6tFm35Crj1iKwAAAAAAAEA/U4BAgAAAAHs3v0OYyaLVPrhDHNhXcx/QOAxDdSJhskLo0wLfCIKYwAAAADZAEcwRAIgO8TLM5UQMxCQ6PvmX8HlENakEvje4Qh57mXvYn09dbMCIBc3Ki9MuokzfTqiCHyy2AO2snXCtxILQiKYz5frv/5EAUcwRAIgKsSNQmI+mI4DhicRNZTjbDwOnEBpeS7zhXORBc+EPJ0CIHIvMtZNb5GlkyXRxJTMTQz4rlBsDLJcRkQtuhy2XhVtAUdSIQLiqUBWbK3kx243MzI/oJj2ltjKVozfzRPbd9EwhVem6CEDDOM3DUxkh9VSu/PGjyYAcLDIUydtEuJYP9yZAvJiSI1SrgAAAIACECcAAAAAAAAZdqkUGRKdU+Yxm68Z26BZvq0WbfkKuPWIrLxWAQAAAAAAF6kU8jwURI869VV4JqztQuqecVidzkmHAAAAAAEH2gBIMEUCIQDiNd/+7jidaMNr62dXs0PaHebV/u/XeOin63Jvb8r0vQIgc48RIH515lkuia5Mo6cgSBJzhSCDX8EJqE8jjrFbiaYBRzBEAiBhF7Q0mzTS/KF9YAvGbnWpwOjFot1cwjITOS8GkWk1wQIgWvvztERtgktCQIAy1qm3ON7iknfmCuXLiwheD/xZeVcBR1IhAoJkVao8TQcPGdH2rhAUNLNEoDTaWVlIZXZEEk77O3NoIQOJYtHCrnVaHKX4kVFrtjn9dVGJENMKTTOYLAY/aCS3rFKuAAA=', + ); + + assert.strictEqual( + psbt.extractTransaction().toHex(), + '020000000123ee0084c67ca01e059a4785a70445d1248893c9528896eb9d51713c19a5ea3301000000da00483045022100e235dffeee389d68c36beb6757b343da1de6d5feefd778e8a7eb726f6fcaf4bd0220738f11207e75e6592e89ae4ca3a7204812738520835fc109a84f238eb15b89a60147304402206117b4349b34d2fca17d600bc66e75a9c0e8c5a2dd5cc23213392f06916935c102205afbf3b4446d824b42408032d6a9b738dee29277e60ae5cb8b085e0ffc5979570147522102826455aa3c4d070f19d1f6ae101434b344a034da595948657644124efb3b736821038962d1c2ae755a1ca5f891516bb639fd75518910d30a4d33982c063f6824b7ac52ae00000080013c4f0100000000001976a91419129d53e6319baf19dba059bead166df90ab8f588ac00000000', + ); + }); +}); + +describe('multisig-wallet (wrapped segwit)', () => { + it('basic operations work', async () => { + const w = new MultisigHDWallet(); + w.setSecret(txtFileFormatMultisigWrappedSegwit); + assert.strictEqual(w.getDerivationPath(), "m/48'/0'/0'/1'"); + assert.strictEqual(w.getM(), 2); + assert.strictEqual(w.getN(), 2); + assert.strictEqual(w.howManySignaturesCanWeMake(), 0); + assert.strictEqual( + w.getCosigner(1), + 'Ypub6jtUX12KGcqFosZWP4YcHc9qbKRTvgBpb8aE58hsYqby3SQVTr5KGfMmdMg38ekmQ9iLhCdgbAbjih7AWSkA7pgRhiLfah3zT6u1PFvVEbc', + ); + assert.strictEqual( + w.getCosigner(2), + 'Ypub6kvtvTZpqGuWtQfg9bL5xe4vDWtwsirR8LzDvsY3vgXvyncW1NGXCUJ9Ps7CiizSSLV6NnnXSYyVDnxCu26QChWzWLg5YCAHam6cYjGtzRz', + ); + assert.strictEqual(w.getCosignerForFingerprint(fp1cobo), w.getCosigner(1)); + assert.strictEqual(w.getCosignerForFingerprint(fp2coldcard), w.getCosigner(2)); + assert.strictEqual(w.getFingerprint(1), fp1cobo); + assert.strictEqual(w.getFingerprint(2), fp2coldcard); + + assert.strictEqual(w._getExternalAddressByIndex(0), '38xA38nfy649CC2JjjZj1CYAhtrcRc67dk'); + assert.strictEqual(w._getExternalAddressByIndex(1), '35ixkuzbrLb7Pr3j89uVYvYPe3jKSrbeB3'); + assert.strictEqual(w._getInternalAddressByIndex(0), '35yBZiSz9aBCz7HcobJzYpsuKhcgJ1Vrnd'); + assert.strictEqual(w._getInternalAddressByIndex(1), '36uoZnudzSSUryHmWyNy3xfChmzvK35AL9'); + assert.ok(w.isWrappedSegwit()); + assert.ok(!w.isNativeSegwit()); + assert.ok(!w.isLegacy()); + }); + + it('can coordinate tx creation', async () => { + const utxos = [ + { + height: 666, + value: 100000, + address: '38xA38nfy649CC2JjjZj1CYAhtrcRc67dk', + txId: 'e36f630517f5b094a9287e73bdb443792088255c50d74414c7f25bd7fbdcf18e', + vout: 0, + txid: 'e36f630517f5b094a9287e73bdb443792088255c50d74414c7f25bd7fbdcf18e', + amount: 100000, + wif: false, + confirmations: 666, + txhex: + '0200000000010196d35e9f8f176e83895a3fe9595a6245dfbab28d6ab67b3792fd5de22e1f6b6601000000000000008002a08601000000000017a9144fa5e491491e9ff7a4a1acfad13b1f40394a807587cc1e040000000000160014266425288c8b2b0ff90f2cffb630f174b1e1915602473044022027a467bd3d4aeb3d7e37d9e1218016e06ad0d5d55ce36f2852dc685e4261d5fb022006acb3e4ecb6c8b887ad94893a8b447a7003a34c0422864d2403493d8ab07fd60121022974397dca958232181a717400dc31629b4daad87e1e314e3b02dd059e88141000000000', + }, + ]; + + const w = new MultisigHDWallet(); + w.setSecret(txtFileFormatMultisigWrappedSegwit); + + // transaction is gona be UNsigned because we have no keys + const { psbt, tx } = w.createTransaction( + utxos, + [{ address: 'bc1qlhpaukt44ru7044uqdf0hp2qs0ut0p93g66k8h', value: 10000 }], + 10, + w._getInternalAddressByIndex(3), + false, + false, + ); + assert.ok(!tx, 'tx should not be provided when PSBT is only partially signed'); + + assert.strictEqual(w.calculateHowManySignaturesWeHaveFromPsbt(psbt), 0); + assert.ok(w.calculateFeeFromPsbt(psbt) < 3000); + assert.ok(w.calculateFeeFromPsbt(psbt) > 0); + + assert.strictEqual( + psbt.toBase64(), + 'cHNidP8BAHICAAAAAY7x3PvXW/LHFETXUFwliCB5Q7S9c34oqZSw9RcFY2/jAAAAAAAAAACAAhAnAAAAAAAAFgAU/cPeWXWo+efWvANS+4VAg/i3hLG8VgEAAAAAABepFO7ireZCjVtHj35I/Nl7BehvwLUehwAAAAAAAQDfAgAAAAABAZbTXp+PF26DiVo/6VlaYkXfurKNarZ7N5L9XeIuH2tmAQAAAAAAAACAAqCGAQAAAAAAF6kUT6XkkUken/ekoaz60TsfQDlKgHWHzB4EAAAAAAAWABQmZCUojIsrD/kPLP+2MPF0seGRVgJHMEQCICekZ709Sus9fjfZ4SGAFuBq0NXVXONvKFLcaF5CYdX7AiAGrLPk7LbIuIetlIk6i0R6cAOjTAQihk0kA0k9irB/1gEhAil0OX3KlYIyGBpxdADcMWKbTarYfh4xTjsC3QWeiBQQAAAAAAEBIKCGAQAAAAAAF6kUT6XkkUken/ekoaz60TsfQDlKgHWHAQQiACDPTtxNIhrXXpN96Ge4RqNg6G5S2ekVWeRIxC6lrW7xpgEFR1IhAs4nrGikNjRB6wBod7xyiPlsajHLe784+TGSBSn1La1FIQNdxFn6ZgrWx54rXwjY8th8oxuC1GGaYJuwUCPwiHLvMlKuIgYCziesaKQ2NEHrAGh3vHKI+WxqMct7vzj5MZIFKfUtrUUcFo3WAzAAAIAAAACAAAAAgAEAAIAAAAAAAAAAACIGA13EWfpmCtbHnitfCNjy2HyjG4LUYZpgm7BQI/CIcu8yHNN+rYgwAACAAAAAgAAAAIABAACAAAAAAAAAAAAAAAEAIgAgmEK/lrkZU6YPI0E7hA3gl4FCIJrzjUCO1HWnberBUHMBAUdSIQJiYhkhelPzRR4CoVURPwwFCiw4bloiKfu5PeiBcpsoBiEDu+RSG3dUJUlQnENUxiTZbrK1Nfe7LV+YDo2tmbD+GOBSriICAmJiGSF6U/NFHgKhVRE/DAUKLDhuWiIp+7k96IFymygGHNN+rYgwAACAAAAAgAAAAIABAACAAQAAAAMAAAAiAgO75FIbd1QlSVCcQ1TGJNlusrU197stX5gOja2ZsP4Y4BwWjdYDMAAAgAAAAIAAAACAAQAAgAEAAAADAAAAAA==', + ); + + // now, signing it on coldcard. + + const signedOnColdcard = + 'cHNidP8BAHICAAAAAY7x3PvXW/LHFETXUFwliCB5Q7S9c34oqZSw9RcFY2/jAAAAAAAAAACAAhAnAAAAAAAAFgAU/cPeWXWo+efWvANS+4VAg/i3hLG8VgEAAAAAABepFO7ireZCjVtHj35I/Nl7BehvwLUehwAAAAAAAQDfAgAAAAABAZbTXp+PF26DiVo/6VlaYkXfurKNarZ7N5L9XeIuH2tmAQAAAAAAAACAAqCGAQAAAAAAF6kUT6XkkUken/ekoaz60TsfQDlKgHWHzB4EAAAAAAAWABQmZCUojIsrD/kPLP+2MPF0seGRVgJHMEQCICekZ709Sus9fjfZ4SGAFuBq0NXVXONvKFLcaF5CYdX7AiAGrLPk7LbIuIetlIk6i0R6cAOjTAQihk0kA0k9irB/1gEhAil0OX3KlYIyGBpxdADcMWKbTarYfh4xTjsC3QWeiBQQAAAAAAEBIKCGAQAAAAAAF6kUT6XkkUken/ekoaz60TsfQDlKgHWHIgICziesaKQ2NEHrAGh3vHKI+WxqMct7vzj5MZIFKfUtrUVHMEQCIGpbamFnic4XMuNHOC8AulQmuUzVdE+67aWOZC0lwwQJAiB1LID+LeJC87bL7U0wGtAfzLah8iScywpIhzVrVIrofwEBAwQBAAAAIgYCziesaKQ2NEHrAGh3vHKI+WxqMct7vzj5MZIFKfUtrUUcFo3WAzAAAIAAAACAAAAAgAEAAIAAAAAAAAAAACIGA13EWfpmCtbHnitfCNjy2HyjG4LUYZpgm7BQI/CIcu8yHNN+rYgwAACAAAAAgAAAAIABAACAAAAAAAAAAAABBCIAIM9O3E0iGtdek33oZ7hGo2DoblLZ6RVZ5EjELqWtbvGmAQVHUiECziesaKQ2NEHrAGh3vHKI+WxqMct7vzj5MZIFKfUtrUUhA13EWfpmCtbHnitfCNjy2HyjG4LUYZpgm7BQI/CIcu8yUq4AACICA7vkUht3VCVJUJxDVMYk2W6ytTX3uy1fmA6NrZmw/hjgHBaN1gMwAACAAAAAgAAAAIABAACAAQAAAAMAAAAiAgJiYhkhelPzRR4CoVURPwwFCiw4bloiKfu5PeiBcpsoBhzTfq2IMAAAgAAAAIAAAACAAQAAgAEAAAADAAAAAQAiACCYQr+WuRlTpg8jQTuEDeCXgUIgmvONQI7Udadt6sFQcwEBR1IhAmJiGSF6U/NFHgKhVRE/DAUKLDhuWiIp+7k96IFymygGIQO75FIbd1QlSVCcQ1TGJNlusrU197stX5gOja2ZsP4Y4FKuAA=='; + const psbtSignedOnColdcard = bitcoin.Psbt.fromBase64(signedOnColdcard); + + psbt.combine(psbtSignedOnColdcard); // should not throw + + // signed on real Cobo device: + const psbtFromCobo = bitcoin.Psbt.fromHex( + decodeUR([ + 'UR:BYTES/1OF2/C3YV8V6ZLYC0XV3KCL3TLTYVJAMVAV2EGUKEA5XRPHHSTRFWR67QD0422H/TYZPJURNVF607QGQWGPQQQQQQX80RH8M6ADL93C5GNT4QHP93QS8JSA5H4EHU29FJJC029C9VDH7XQQQQQQQQQQQQZQQYYP8QQQQQQQQQQTQQ98AC009JADGL8NAD0QR2TAC2SYRLZMCFVDU2CQSQQQQQQQP02G5AM32MEJZ34D50RM7FR7DJ7C9APHUPDG7SUQQQQQQQQQSPHCZQQQQQQQPQXTDXH5L3UTKAQUFTGL7JK26VFZALW4J344TV7EHJT74MC3WRA4KVQGQQQQQQQQQQZQQ9GYXQYQQQQQQQQT6J9Z05HJFZJG7NLM6FGDVLTGNK86Q899GQAV8ES0QGQQQQQQQQ9SQZSNXGFFG3J9JKRLEPUK0LD3S796TRCV32CPYWVZYQGSZ0FR8H57546EA0CMANCFPSQTWQ6KS6H24ECM09PFDC6Z7GFSAT7CZYQR2EVLYAJMV3WY84K2GJW5TG3A8QQARFSZZ9PJDYSP5J0V2KPLAVQFPQG5HGWTAE22CYVSCRFCHGQXUX93FKND2MPLPUV2W8VPD6PV73Q2PQQQQQQQQZQFQ5ZRQZQQQQQQQQ9AFZ386TEY3FY0FLAAY5XK045FMRAQRJJ5QWKRJYQSZECN6C69YXC6YR6CQDPMMCU5GL9KX5VWT0WLN37F3JGZJNAFD44Z5WVZYQGSX5KM2V9NCNNSHXT35WWP0QZA9GF4EFN2HGNA6AKJCUEPDYHPSGZGZYP6JEQ879H3Y9UAKE0K56VQ66Q0UED4P7GJFEJC2FZRN26653T587QFZQGP4M3ZELFNQ44K8NC447ZXC7TV8EGCMST2XRXNQNWC9QGLS3PEW7', + 'UR:BYTES/2OF2/C3YV8V6ZLYC0XV3KCL3TLTYVJAMVAV2EGUKEA5XRPHHSTRFWR67QD0422H/VJ8XPZQYGZDUG0080MKV7J470KZDUQ6P059YR8WPJ0U86369FJVRAVV0W0CHSPZQH6RPNTKY4SU6TYH899522UP5U57DPPSG6P3D5F0AAH82C0X8MGGQYQSXPQPQQQQQQGYYGQZPN6WM3XJYXKHT6FHM6R8HPR2XC8GDEFDN6G4T8JY33PW5KKKAUDXQYZ5W53PQT8Z0TRG5SMRGS0TQP5800RJ3RUKC633EDAM7W8EXXFQ22049KK52GGRTHZ9N7NXPTTV083TTUYD3UKC0J33HQK5VXDXPXAS2Q3LPZRJAUE99T3ZQCPVUFAVDZJRVDZPAVQXSAAUW2Y0JMR2X89HH0ECLYCEYPFF75K663GUZ6XAVQESQQQGQQQQQZQQQQQQSQQSQQYQQQQQQQQQQQQQQGSXQDWUGK06VC9DD3U79D0S3K8JMP72XXUZ63SE5CYMKPGZ8UYGWTHNY8XN06KCSVQQQZQQQQQQSQQQQQYQQYQQPQQQQQQQQQQQQQQQQQQPQ9R4YGGZVF3PJGT620E528SZ5923Z0CVQ59ZCWRWTG3ZN7AE8H5GZU5M9QRZZQAMU3FPKA65Y4Y4P8ZR2NRZFKTWK26NTAAM940ESR5D4KVMPLSCUPF2UGSZQF3XYXFP0FFLX3G7Q2S42YFLPSZS5TPCDEDZY20MHY773QTJNV5QV8XN06KCSVQQQZQQQQQQSQQQQQYQQYQQPQQPQQQQQQCQQQQZYQSRH0J9YXMH2SJ5J5YUGD2VVFXED6ET2D0HHVK4LXQW3KKENV87RRSPC95D6CPNQQQQSQQQQQYQQQQQPQQPQQQGQQGQQQQQXQQQQQQQ2VFE47', + ]), + ); + + psbt.combine(psbtFromCobo); + const txhex = psbt.finalizeAllInputs().extractTransaction().toHex(); + assert.strictEqual( + txhex, + '020000000001018ef1dcfbd75bf2c71444d7505c2588207943b4bd737e28a994b0f51705636fe30000000023220020cf4edc4d221ad75e937de867b846a360e86e52d9e91559e448c42ea5ad6ef1a600000080021027000000000000160014fdc3de5975a8f9e7d6bc0352fb854083f8b784b1bc5601000000000017a914eee2ade6428d5b478f7e48fcd97b05e86fc0b51e87040047304402206a5b6a616789ce1732e347382f00ba5426b94cd5744fbaeda58e642d25c304090220752c80fe2de242f3b6cbed4d301ad01fccb6a1f2249ccb0a4887356b548ae87f0147304402204de21ef3bf7667a55f3ec26f01a0be8520cee0c9fc3ea3a2a64c1f58c7b9f8bc02205f430cd762561cd2c97394b452b81a729e68430468316d12fef6e7561e63ed080147522102ce27ac68a4363441eb006877bc7288f96c6a31cb7bbf38f931920529f52dad4521035dc459fa660ad6c79e2b5f08d8f2d87ca31b82d4619a609bb05023f08872ef3252ae00000000', + ); + }); + + it('can coordinate tx creation and sign 1 of 2', async () => { + const path = "m/48'/0'/0'/1'"; + const Ypub1 = 'Ypub6jtUX12KGcqFosZWP4YcHc9qbKRTvgBpb8aE58hsYqby3SQVTr5KGfMmdMg38ekmQ9iLhCdgbAbjih7AWSkA7pgRhiLfah3zT6u1PFvVEbc'; + + const utxos = [ + { + height: 666, + value: 100000, + address: '38xA38nfy649CC2JjjZj1CYAhtrcRc67dk', + txId: 'e36f630517f5b094a9287e73bdb443792088255c50d74414c7f25bd7fbdcf18e', + vout: 0, + txid: 'e36f630517f5b094a9287e73bdb443792088255c50d74414c7f25bd7fbdcf18e', + amount: 100000, + wif: false, + confirmations: 666, + txhex: + '0200000000010196d35e9f8f176e83895a3fe9595a6245dfbab28d6ab67b3792fd5de22e1f6b6601000000000000008002a08601000000000017a9144fa5e491491e9ff7a4a1acfad13b1f40394a807587cc1e040000000000160014266425288c8b2b0ff90f2cffb630f174b1e1915602473044022027a467bd3d4aeb3d7e37d9e1218016e06ad0d5d55ce36f2852dc685e4261d5fb022006acb3e4ecb6c8b887ad94893a8b447a7003a34c0422864d2403493d8ab07fd60121022974397dca958232181a717400dc31629b4daad87e1e314e3b02dd059e88141000000000', + }, + ]; + + const w = new MultisigHDWallet(); + w.addCosigner(Ypub1, fp1cobo); + w.addCosigner(mnemonicsColdcard); + w.setDerivationPath(path); + w.setM(2); + + assert.strictEqual( + w.convertXpubToMultisignatureXpub(MultisigHDWallet.seedToXpub(mnemonicsColdcard, path)), + 'Ypub6kvtvTZpqGuWtQfg9bL5xe4vDWtwsirR8LzDvsY3vgXvyncW1NGXCUJ9Ps7CiizSSLV6NnnXSYyVDnxCu26QChWzWLg5YCAHam6cYjGtzRz', + ); + assert.strictEqual(w.getCosignerForFingerprint(fp1cobo), w.getCosigner(1)); + assert.strictEqual(w.getCosignerForFingerprint(fp2coldcard), w.getCosigner(2)); + assert.strictEqual(w._getExternalAddressByIndex(0), '38xA38nfy649CC2JjjZj1CYAhtrcRc67dk'); + assert.strictEqual(w._getExternalAddressByIndex(1), '35ixkuzbrLb7Pr3j89uVYvYPe3jKSrbeB3'); + assert.strictEqual(w._getInternalAddressByIndex(0), '35yBZiSz9aBCz7HcobJzYpsuKhcgJ1Vrnd'); + assert.strictEqual(w._getInternalAddressByIndex(1), '36uoZnudzSSUryHmWyNy3xfChmzvK35AL9'); + assert.strictEqual(w._getInternalAddressByIndex(3), '3PU8J9pdiKAMsLnrhyrG7RZ4LZiTURQp5r'); + assert.strictEqual(w.howManySignaturesCanWeMake(), 1); + assert.ok(w.isWrappedSegwit()); + assert.ok(!w.isNativeSegwit()); + assert.ok(!w.isLegacy()); + + // transaction is gona be partially signed because we have one of two signing keys + const { psbt, tx } = w.createTransaction( + utxos, + [{ address: '13HaCAB4jf7FYSZexJxoczyDDnutzZigjS', value: 10000 }], + 10, + w._getInternalAddressByIndex(3), + false, + false, + ); + assert.ok(!tx, 'tx should not be provided when PSBT is only partially signed'); + assert.strictEqual(w.calculateHowManySignaturesWeHaveFromPsbt(psbt), 1); + + assert.strictEqual( + psbt.toBase64(), + 'cHNidP8BAHUCAAAAAY7x3PvXW/LHFETXUFwliCB5Q7S9c34oqZSw9RcFY2/jAAAAAAAAAACAAhAnAAAAAAAAGXapFBkSnVPmMZuvGdugWb6tFm35Crj1iKy8VgEAAAAAABepFO7ireZCjVtHj35I/Nl7BehvwLUehwAAAAAAAQDfAgAAAAABAZbTXp+PF26DiVo/6VlaYkXfurKNarZ7N5L9XeIuH2tmAQAAAAAAAACAAqCGAQAAAAAAF6kUT6XkkUken/ekoaz60TsfQDlKgHWHzB4EAAAAAAAWABQmZCUojIsrD/kPLP+2MPF0seGRVgJHMEQCICekZ709Sus9fjfZ4SGAFuBq0NXVXONvKFLcaF5CYdX7AiAGrLPk7LbIuIetlIk6i0R6cAOjTAQihk0kA0k9irB/1gEhAil0OX3KlYIyGBpxdADcMWKbTarYfh4xTjsC3QWeiBQQAAAAAAEBIKCGAQAAAAAAF6kUT6XkkUken/ekoaz60TsfQDlKgHWHIgICziesaKQ2NEHrAGh3vHKI+WxqMct7vzj5MZIFKfUtrUVHMEQCIHgfpvZsDT4VkHSxGL5nGcRpP55V4r7jmNRj4vr85NNXAiBio79Ta0Tr9skEiLJ/hXnNFR+3ZdRcpFRX59HIqGmorQEBBCIAIM9O3E0iGtdek33oZ7hGo2DoblLZ6RVZ5EjELqWtbvGmAQVHUiECziesaKQ2NEHrAGh3vHKI+WxqMct7vzj5MZIFKfUtrUUhA13EWfpmCtbHnitfCNjy2HyjG4LUYZpgm7BQI/CIcu8yUq4iBgLOJ6xopDY0QesAaHe8coj5bGoxy3u/OPkxkgUp9S2tRRwWjdYDMAAAgAAAAIAAAACAAQAAgAAAAAAAAAAAIgYDXcRZ+mYK1seeK18I2PLYfKMbgtRhmmCbsFAj8Ihy7zIc036tiDAAAIAAAACAAAAAgAEAAIAAAAAAAAAAAAAAAQAiACCYQr+WuRlTpg8jQTuEDeCXgUIgmvONQI7Udadt6sFQcwEBR1IhAmJiGSF6U/NFHgKhVRE/DAUKLDhuWiIp+7k96IFymygGIQO75FIbd1QlSVCcQ1TGJNlusrU197stX5gOja2ZsP4Y4FKuIgICYmIZIXpT80UeAqFVET8MBQosOG5aIin7uT3ogXKbKAYc036tiDAAAIAAAACAAAAAgAEAAIABAAAAAwAAACICA7vkUht3VCVJUJxDVMYk2W6ytTX3uy1fmA6NrZmw/hjgHBaN1gMwAACAAAAAgAAAAIABAACAAQAAAAMAAAAA', + ); + + // got that from real Cobo vault device: + const ur1 = + 'UR:BYTES/2OF2/TJX8DNDQAX50U29GZNW7RWEQ7JNZLLS6U2NKPKP35N4XX80J2WTQYYVK7D/76D7H8DNNFD6R4D79DKKC0Z8FP2E8ESHNQHUL4ASZYQ9SDTHMKAQG2FJM2YP6P7YXK72J77CJW7C3RSJ9YC594FHPY44UGQGPQS3QQGX0FMWY6GS66A0FXL0GV7UYDGMQAPH99K0FZ4V7GJXY96J66MH35CQS236JYYPVUFAVDZJRVDZPAVQXSAAUW2Y0JMR2X89HH0ECLYCEYPFF75K663FPQDWUGK06VC9DD3U79D0S3K8JMP72XXUZ63SE5CYMKPGZ8UYGWTHNY54WYGRQ9N384352GD35G84SQ6RHH3EG37TVDGCUK7AL8RUNRYS9986JMT29RSTGM4SRXQQQPQQQQQQGQQQQQZQQZQQQSQQQQQQQQQQQQQPZQCP4M3ZELFNQ44K8NC447ZXC7TV8EGCMST2XRXNQNWC9QGLS3PEW7VSU6DL2MZPSQQQGQQQQQZQQQQQQSQQSQQYQQQQQQQQQQQQQQQQQQYQZYQPQNPPTL94ER9F6VRERGYACGR0QJ7Q5YGY67WX5PRK5WKNKM6KP2PESZQ282GSSYCNZRYSH55LNG50Q9G24ZYLSCPG29SUXUK3Z98AMJ00GS9EFK2QXYYPMHEZJRDM4GF2F2ZWYX4XXYNVKAV44XHMMKT2LNQ8GMTVEKRLP3CZJ4C3QYQNZVGVJZ7JN7DZ3UQ4P25GN7RQ9PGKRSMJ6YG5LHWFAAZQH9XEGQCWDXL4D3QCQQQYQQQQQPQQQQQQGQQGQQZQQZQQQQQPSQQQQYGPQ8WLY2GDHW4P9F9GFCS65CCJDJM4JK56L0WEDT7VQARDDNXC0UX8QRSTGM4SRXQQQPQQQQQQGQQQQQZQQZQQQSQQSQQQQQVQQQQQQRSVZ9T'; + const ur2 = + 'UR:BYTES/1OF2/TJX8DNDQAX50U29GZNW7RWEQ7JNZLLS6U2NKPKP35N4XX80J2WTQYYVK7D/TYZR5URNVF607QGQW5PQQQQQQX80RH8M6ADL93C5GNT4QHP93QS8JSA5H4EHU29FJJC029C9VDH7XQQQQQQQQQQQQZQQYYP8QQQQQQQQQQVHD2G5RYFF65LXXXD67XWM5PVMATGKDHUS4W843ZKTC4SPQQQQQQQQZ753FMHZ4HNY9R2MG78HUJ8UM9AST6R0CZ63APCQQQQQQQQPQR0SYQQQQQQQZQVK6D0FLRCHD6PCJK3LA9V45CJ9M7AT9RT2KEAN0YHATH3ZU8MTVCQSQQQQQQQQQQYQQ2SGVQGQQQQQQQQH4Y2YLF0YJ9Y3A8LH5JS6E7K38V05QW22SP6C0NQ7QSQQQQQQQQTQQ9PXVSJJ3RYT9V8LJREVL7MRPUT5K8SEZ4SZGUCYGQ3QY7JX00FAFT4N6L3HM8SJRQQKUP4DP4W4TN3K72ZJM359USNP6HASYGQX4JE7FM9KEZUG0TV53YAGK3R6WQP6XNQYY2RY6FQRFY7C4VRL6CQJZQ3FWSUHMJ54SGEPSXN3WSQDCVTZNDX64KR7RCC5UWCZM5ZEAZQ5ZQQQQQQQQYQJPGYXQYQQQQQQQQT6J9Z05HJFZJG7NLM6FGDVLTGNK86Q899GQAV8YGPQ9N384352GD35G84SQ6RHH3EG37TVDGCUK7AL8RUNRYS9986JMT29GUCYGQ3Q0Q06DANVP5LPTYR5KYVTUECEC35NL8J4U2LW8XX5V0304L8Y6DTSYGRZ5WL4X66YA0MVJPYGKFLC27WDZ50MWEW5TJJ9G4L868Y2S6DG45QJYQSRTHZ9N7NXPTTV083TTUYD3UKC0J33HQK5VXDXPXAS2Q3LPZRJAUEYWVZYQGS929YQ92ZV8XF'; + const payload = decodeUR([ur1, ur2]); + + const psbtFromCobo = bitcoin.Psbt.fromHex(payload); + psbt.combine(psbtFromCobo); + const tx2 = psbt.finalizeAllInputs().extractTransaction(); + assert.strictEqual( + tx2.toHex(), + '020000000001018ef1dcfbd75bf2c71444d7505c2588207943b4bd737e28a994b0f51705636fe30000000023220020cf4edc4d221ad75e937de867b846a360e86e52d9e91559e448c42ea5ad6ef1a6000000800210270000000000001976a91419129d53e6319baf19dba059bead166df90ab8f588acbc5601000000000017a914eee2ade6428d5b478f7e48fcd97b05e86fc0b51e8704004730440220781fa6f66c0d3e159074b118be6719c4693f9e55e2bee398d463e2fafce4d357022062a3bf536b44ebf6c90488b27f8579cd151fb765d45ca45457e7d1c8a869a8ad0147304402205514802a84c3993ed37d73b6734b743ab7c56dad8788e90ab27cc2f305f9faf602200b06aefbb74085265b5103a0f886b7952f7b1277b111c24526285aa6e1256bc40147522102ce27ac68a4363441eb006877bc7288f96c6a31cb7bbf38f931920529f52dad4521035dc459fa660ad6c79e2b5f08d8f2d87ca31b82d4619a609bb05023f08872ef3252ae00000000', + ); + }); + + it('can coordinate tx creation and sign 1 of 2 (spend from change)', async () => { + const path = "m/48'/0'/0'/1'"; + const Ypub1 = 'Ypub6jtUX12KGcqFosZWP4YcHc9qbKRTvgBpb8aE58hsYqby3SQVTr5KGfMmdMg38ekmQ9iLhCdgbAbjih7AWSkA7pgRhiLfah3zT6u1PFvVEbc'; + + const utxos = [ + { + height: 666, + value: 87740, + address: '3PU8J9pdiKAMsLnrhyrG7RZ4LZiTURQp5r', + txId: '31d614bc1d6fcbcb273f585f87d2e619784920f8cb0c2396e4a03f1bb86fed64', + vout: 1, + txid: '31d614bc1d6fcbcb273f585f87d2e619784920f8cb0c2396e4a03f1bb86fed64', + amount: 87740, + wif: false, + confirmations: 666, + txhex: + '020000000001018ef1dcfbd75bf2c71444d7505c2588207943b4bd737e28a994b0f51705636fe30000000023220020cf4edc4d221ad75e937de867b846a360e86e52d9e91559e448c42ea5ad6ef1a6000000800210270000000000001976a91419129d53e6319baf19dba059bead166df90ab8f588acbc5601000000000017a914eee2ade6428d5b478f7e48fcd97b05e86fc0b51e8704004730440220781fa6f66c0d3e159074b118be6719c4693f9e55e2bee398d463e2fafce4d357022062a3bf536b44ebf6c90488b27f8579cd151fb765d45ca45457e7d1c8a869a8ad0147304402207e13fe2321ab8b80f3d415b28e37a11224ff9b9caf8be710d0f30f41939ab3df0220031da773d0dd13f99b0c7e33e7cb2dbc5af480cf219e7933c092eeb354787f780147522102ce27ac68a4363441eb006877bc7288f96c6a31cb7bbf38f931920529f52dad4521035dc459fa660ad6c79e2b5f08d8f2d87ca31b82d4619a609bb05023f08872ef3252ae00000000', + }, + ]; + + const w = new MultisigHDWallet(); + w.addCosigner(Ypub1, fp1cobo); + w.addCosigner(mnemonicsColdcard); + w.setDerivationPath(path); + w.setM(2); + + assert.strictEqual( + w.convertXpubToMultisignatureXpub(MultisigHDWallet.seedToXpub(mnemonicsColdcard, path)), + 'Ypub6kvtvTZpqGuWtQfg9bL5xe4vDWtwsirR8LzDvsY3vgXvyncW1NGXCUJ9Ps7CiizSSLV6NnnXSYyVDnxCu26QChWzWLg5YCAHam6cYjGtzRz', + ); + assert.strictEqual(w.getCosignerForFingerprint(fp1cobo), w.getCosigner(1)); + assert.strictEqual(w.getCosignerForFingerprint(fp2coldcard), w.getCosigner(2)); + assert.strictEqual(w._getExternalAddressByIndex(0), '38xA38nfy649CC2JjjZj1CYAhtrcRc67dk'); + assert.strictEqual(w._getExternalAddressByIndex(1), '35ixkuzbrLb7Pr3j89uVYvYPe3jKSrbeB3'); + assert.strictEqual(w._getInternalAddressByIndex(0), '35yBZiSz9aBCz7HcobJzYpsuKhcgJ1Vrnd'); + assert.strictEqual(w._getInternalAddressByIndex(1), '36uoZnudzSSUryHmWyNy3xfChmzvK35AL9'); + assert.strictEqual(w._getInternalAddressByIndex(3), '3PU8J9pdiKAMsLnrhyrG7RZ4LZiTURQp5r'); + assert.strictEqual(w.howManySignaturesCanWeMake(), 1); + assert.ok(w.isWrappedSegwit()); + assert.ok(!w.isNativeSegwit()); + assert.ok(!w.isLegacy()); + + // transaction is gona be partially signed because we have one of two signing keys + const { psbt, tx } = w.createTransaction( + utxos, + [{ address: '13HaCAB4jf7FYSZexJxoczyDDnutzZigjS' }], + 10, + w._getInternalAddressByIndex(3), + false, + false, + ); + assert.ok(!tx, 'tx should not be provided when PSBT is only partially signed'); + + assert.strictEqual( + psbt.toBase64(), + 'cHNidP8BAFUCAAAAAWTtb7gbP6DkliMMy/ggSXgZ5tKHX1g/J8vLbx28FNYxAQAAAAAAAACAATxPAQAAAAAAGXapFBkSnVPmMZuvGdugWb6tFm35Crj1iKwAAAAAAAEA/XQBAgAAAAABAY7x3PvXW/LHFETXUFwliCB5Q7S9c34oqZSw9RcFY2/jAAAAACMiACDPTtxNIhrXXpN96Ge4RqNg6G5S2ekVWeRIxC6lrW7xpgAAAIACECcAAAAAAAAZdqkUGRKdU+Yxm68Z26BZvq0WbfkKuPWIrLxWAQAAAAAAF6kU7uKt5kKNW0ePfkj82XsF6G/AtR6HBABHMEQCIHgfpvZsDT4VkHSxGL5nGcRpP55V4r7jmNRj4vr85NNXAiBio79Ta0Tr9skEiLJ/hXnNFR+3ZdRcpFRX59HIqGmorQFHMEQCIH4T/iMhq4uA89QVso43oRIk/5ucr4vnENDzD0GTmrPfAiADHadz0N0T+ZsMfjPnyy28WvSAzyGeeTPAku6zVHh/eAFHUiECziesaKQ2NEHrAGh3vHKI+WxqMct7vzj5MZIFKfUtrUUhA13EWfpmCtbHnitfCNjy2HyjG4LUYZpgm7BQI/CIcu8yUq4AAAAAAQEgvFYBAAAAAAAXqRTu4q3mQo1bR49+SPzZewXob8C1HociAgO75FIbd1QlSVCcQ1TGJNlusrU197stX5gOja2ZsP4Y4EgwRQIhAP3v+3DseVI63T8N9TGi3j9mQvjkIFZTUu0aeQbRhm+NAiASJatbXcK+36jF34Eeg5Hvy+vx+Q5bNB3tJWWBS3tqDAEBBCIAIJhCv5a5GVOmDyNBO4QN4JeBQiCa841AjtR1p23qwVBzAQVHUiECYmIZIXpT80UeAqFVET8MBQosOG5aIin7uT3ogXKbKAYhA7vkUht3VCVJUJxDVMYk2W6ytTX3uy1fmA6NrZmw/hjgUq4iBgJiYhkhelPzRR4CoVURPwwFCiw4bloiKfu5PeiBcpsoBhzTfq2IMAAAgAAAAIAAAACAAQAAgAEAAAADAAAAIgYDu+RSG3dUJUlQnENUxiTZbrK1Nfe7LV+YDo2tmbD+GOAcFo3WAzAAAIAAAACAAAAAgAEAAIABAAAAAwAAAAAA', + ); + + // got that from real Cobo vault device: + const payload = decodeUR([ + 'UR:BYTES/2OF2/645VJ6CR74M52HV073W978F7QQ09MQGKMK9RECKH9R4W047TMWKQKHL9CD/QZ753FMHZ4HNY9R2MG78HUJ8UM9AST6R0CZ63APEZQGPXYCSEY9A98U69RCP2Z4G38UXQ2Z3V8PH95G3FLWUNM6YPW2DJSPJ8XPZQYGZ9MR2S3AG7D7H2A0SKF5NEHTAWKQXAJ5U3WAH6GNUKH0JC0M2795PZQANMMTXYKFW30745FSYMGH850MF4SQU82TZF4U6XJS8L27545AYRQY3QYQAMU3FPKA65Y4Y4P8ZR2NRZFKTWK26NTAAM940ESR5D4KVMPLSCUPYRQ3GZYYQ0MMLMWRK8J536M5LSMAF35T0R7EJZLRJZQ4JN2TK357GX6XRXLRGZYQFZT26MTHPTAHAGCH0CZ85RJ8HUH6L3LY89KDQAA5JKTQ2T0D4QCQGPQS3QQGYCG2LEDWGE2WNQ7G6P8WZQMCYHS9PZPXHN34QGA4R45AK74S2SWVQS236JYYPXYCSEY9A98U69RCP2Z4G38UXQ2Z3V8PH95G3FLWUNM6YPW2DJSP3PQWA7G5SMWA2Z2J2SN3P4F33YM9HT9DF477AJ6HUCP6X6MXDSLCVWQ54WYGRQYCNZRYSH55LNG50Q9G24ZYLSCPG29SUXUK3Z98AMJ00GS9EFK2QXRNFHATVGXQQQPQQQQQQGQQQQQZQQZQQQSQQSQQQQQVQQQQPZQCPMHEZJRDM4GF2F2ZWYX4XXYNVKAV44XHMMKT2LNQ8GMTVEKRLP3CQUZ6XAVQESQQQGQQQQQZQQQQQQSQQSQQYQQYQQQQQRQQQQQQQQTQLHYU', + 'UR:BYTES/1OF2/645VJ6CR74M52HV073W978F7QQ09MQGKMK9RECKH9R4W047TMWKQKHL9CD/TYPUYURNVF607QGQ25PQQQQQQ9JW6MACRVL6PEYKYVXVH7PQF9UPNEKJSA04S0E8E09K78DUZNTRZQGQQQQQQQQQQZQQZ0Z0QYQQQQQQQQVHD2G5RYFF65LXXXD67XWM5PVMATGKDHUS4W843ZKQQQQQQQQQZQ8AWSQSYQQQQQQQZQVW78W0H46M7TR3G3XH2PWZTZPQ09PMF0TN0C52N99S75TS2CM0UVQQQQQQYV3QQGX0FMWY6GS66A0FXL0GV7UYDGMQAPH99K0FZ4V7GJXY96J66MH35CQQQQYQQGGZWQQQQQQQQQQEW653GXGJN4F7VVVM4UVAHGZEH6K3VM0EP2U0TZ9VH3TQZQQQQQQQQ9AFZNHW9T0XG2X4K3U00EY0EKTMQH5XLS94R6RSGQZ8XPZQYGRCR7N0VMQD8C2EQA93RZLXWXWYDYLEU40ZHM3E34RRUTA0EEXN2UPZQC4RHAFKK38T7MYSFZ9J07ZHNNG4R7MKT4ZU53290E73EZ5XN29DQ9RNQ3QZYPLP8L3RYX4CHQ8N6S2M9R3H5YFZFLUMNJHCHECS6RES7SVNN2EA7Q3QQVW6WU7SM5FLNXCV0CE70JEDH3D0FQX0YX08JV7QJTHTX4RC0AUQZ36JYYPVUFAVDZJRVDZPAVQXSAAUW2Y0JMR2X89HH0ECLYCEYPFF75K663FPQDWUGK06VC9DD3U79D0S3K8JMP72XXUZ63SE5CYMKPGZ8UYGWTHNY54WQQQQQQQPQYSTC4SPQQQQQQQ', + ]); + + const psbtFromCobo = bitcoin.Psbt.fromHex(payload); + psbt.combine(psbtFromCobo); + const tx2 = psbt.finalizeAllInputs().extractTransaction(); + assert.strictEqual( + tx2.toHex(), + '0200000000010164ed6fb81b3fa0e496230ccbf820497819e6d2875f583f27cbcb6f1dbc14d63101000000232200209842bf96b91953a60f23413b840de0978142209af38d408ed475a76deac1507300000080013c4f0100000000001976a91419129d53e6319baf19dba059bead166df90ab8f588ac0400473044022045d8d508f51e6faeaebe164d279bafaeb00dd95391776fa44f96bbe587ed5e2d0220767bdacc4b25d17fab44c09b45cf47ed358038752c49af346940ff57a95a748301483045022100fdeffb70ec79523add3f0df531a2de3f6642f8e420565352ed1a7906d1866f8d02201225ab5b5dc2bedfa8c5df811e8391efcbebf1f90e5b341ded2565814b7b6a0c0147522102626219217a53f3451e02a155113f0c050a2c386e5a2229fbb93de881729b28062103bbe4521b77542549509c4354c624d96eb2b535f7bb2d5f980e8dad99b0fe18e052ae00000000', + ); + }); +}); + +describe('multisig-wallet (native segwit)', () => { + it('can sort buffers', async () => { + let sorted; + sorted = MultisigHDWallet.sortBuffers([Buffer.from('10', 'hex'), Buffer.from('0011', 'hex')]); + assert.strictEqual(sorted[0].toString('hex'), '0011'); + assert.strictEqual(sorted[1].toString('hex'), '10'); + + sorted = MultisigHDWallet.sortBuffers([ + Buffer.from('022df8750480ad5b26950b25c7ba79d3e37d75f640f8e5d9bcd5b150a0f85014da', 'hex'), + Buffer.from('03e3818b65bcc73a7d64064106a859cc1a5a728c4345ff0b641209fba0d90de6e9', 'hex'), + Buffer.from('021f2f6e1e50cb6a953935c3601284925decd3fd21bc445712576873fb8c6ebc18', 'hex'), + ]); + assert.strictEqual(sorted[0].toString('hex'), '021f2f6e1e50cb6a953935c3601284925decd3fd21bc445712576873fb8c6ebc18'); + assert.strictEqual(sorted[1].toString('hex'), '022df8750480ad5b26950b25c7ba79d3e37d75f640f8e5d9bcd5b150a0f85014da'); + assert.strictEqual(sorted[2].toString('hex'), '03e3818b65bcc73a7d64064106a859cc1a5a728c4345ff0b641209fba0d90de6e9'); + + sorted = MultisigHDWallet.sortBuffers([ + Buffer.from('02632b12f4ac5b1d1b72b2a3b508c19172de44f6f46bcee50ba33f3f9291e47ed0', 'hex'), + Buffer.from('027735a29bae7780a9755fae7a1c4374c656ac6a69ea9f3697fda61bb99a4f3e77', 'hex'), + Buffer.from('02e2cc6bd5f45edd43bebe7cb9b675f0ce9ed3efe613b177588290ad188d11b404', 'hex'), + ]); + assert.strictEqual(sorted[0].toString('hex'), '02632b12f4ac5b1d1b72b2a3b508c19172de44f6f46bcee50ba33f3f9291e47ed0'); + assert.strictEqual(sorted[1].toString('hex'), '027735a29bae7780a9755fae7a1c4374c656ac6a69ea9f3697fda61bb99a4f3e77'); + assert.strictEqual(sorted[2].toString('hex'), '02e2cc6bd5f45edd43bebe7cb9b675f0ce9ed3efe613b177588290ad188d11b404'); + + sorted = MultisigHDWallet.sortBuffers([ + Buffer.from('030000000000000000000000000000000000004141414141414141414141414141', 'hex'), + Buffer.from('020000000000000000000000000000000000004141414141414141414141414141', 'hex'), + Buffer.from('020000000000000000000000000000000000004141414141414141414141414140', 'hex'), + Buffer.from('030000000000000000000000000000000000004141414141414141414141414140', 'hex'), + ]); + assert.strictEqual(sorted[0].toString('hex'), '020000000000000000000000000000000000004141414141414141414141414140'); + assert.strictEqual(sorted[1].toString('hex'), '020000000000000000000000000000000000004141414141414141414141414141'); + assert.strictEqual(sorted[2].toString('hex'), '030000000000000000000000000000000000004141414141414141414141414140'); + assert.strictEqual(sorted[3].toString('hex'), '030000000000000000000000000000000000004141414141414141414141414141'); + + sorted = MultisigHDWallet.sortBuffers([ + Buffer.from('02ff12471208c14bd580709cb2358d98975247d8765f92bc25eab3b2763ed605f8', 'hex'), + Buffer.from('02fe6f0a5a297eb38c391581c4413e084773ea23954d93f7753db7dc0adc188b2f', 'hex'), + ]); + assert.strictEqual( + sorted[0].toString('hex'), + '02fe6f0a5a297eb38c391581c4413e084773ea23954d93f7753db7dc0adc188b2f', + JSON.stringify(sorted), + ); + assert.strictEqual(sorted[1].toString('hex'), '02ff12471208c14bd580709cb2358d98975247d8765f92bc25eab3b2763ed605f8'); + }); + + it('some validations work', () => { + assert.ok(MultisigHDWallet.isXpubValid(Zpub1)); + assert.ok(!MultisigHDWallet.isXpubValid('invalid')); + assert.ok(!MultisigHDWallet.isXpubValid('xpubinvalid')); + assert.ok(!MultisigHDWallet.isXpubValid('ypubinvalid')); + assert.ok(!MultisigHDWallet.isXpubValid('Zpubinvalid')); + + assert.ok(MultisigHDWallet.isPathValid("m/45'")); + assert.ok(MultisigHDWallet.isPathValid("m/48'/0'/0'/2'")); + assert.ok(!MultisigHDWallet.isPathValid('ROFLBOATS')); + }); + + it('basic operations work', async () => { + const path = "m/48'/0'/0'/2'"; + + let w = new MultisigHDWallet(); + w.addCosigner(Zpub1, fp1cobo); + w.addCosigner(Zpub2, fp2coldcard); + w.setDerivationPath(path); + w.setM(2); + assert.strictEqual(w._getExternalAddressByIndex(0), 'bc1qxzrzh4caw7e3genwtldtxntzj0ktfl7mhf2lh4fj8h7hnkvtvc4salvp85'); + assert.strictEqual(w._getInternalAddressByIndex(0), 'bc1qtah0p50d4qlftn049k7lldcwh7cs3zkjy9g8xegv63p308hsh9zsf5567q'); + assert.strictEqual( + w._getDerivationPathByAddressWithCustomPath(w._getExternalAddressByIndex(2), w.getDerivationPath()), + "m/48'/0'/0'/2'/0/2", + ); + assert.strictEqual( + w._getDerivationPathByAddressWithCustomPath(w._getInternalAddressByIndex(3), w.getDerivationPath()), + "m/48'/0'/0'/2'/1/3", + ); + assert.strictEqual( + MultisigHDWallet.seedToXpub(mnemonicsColdcard, path), + 'xpub6FCYVZAU7dofgor9fQaqyqqA9NqBAn83iQpoayuWrwBPfwiPgCXGCD7dvAG93M5MZs5VWVP7FErGA5UeiALqaPt7KV67fL9WX9bqXTyeWxb', + ); + assert.strictEqual( + w.convertXpubToMultisignatureXpub( + 'xpub6FCYVZAU7dofgor9fQaqyqqA9NqBAn83iQpoayuWrwBPfwiPgCXGCD7dvAG93M5MZs5VWVP7FErGA5UeiALqaPt7KV67fL9WX9bqXTyeWxb', + ), + Zpub2, + ); + assert.throws(() => w.addCosigner('invalid')); + assert.throws(() => w.addCosigner('xpubinvalid')); + assert.throws(() => w.addCosigner('ypubinvalid')); + assert.throws(() => w.addCosigner('Zpubinvalid')); + assert.throws(() => w.addCosigner(Zpub1, fp1cobo, 'ROFLBOATS')); // invalid path + + 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.getFingerprint(1), fp1cobo); + assert.strictEqual(w.getFingerprint(2), fp2coldcard); + assert.strictEqual(w.getCosignerForFingerprint(fp1cobo), Zpub1); + assert.strictEqual(w.getCosignerForFingerprint(fp2coldcard), Zpub2); + assert.strictEqual(w.howManySignaturesCanWeMake(), 0); + assert.ok(!w.isWrappedSegwit()); + assert.ok(w.isNativeSegwit()); + assert.ok(!w.isLegacy()); + + // now, one of cosigners is mnemonics + + w = new MultisigHDWallet(); + w.addCosigner(Zpub1, fp1cobo); + w.addCosigner(mnemonicsColdcard); + w.setDerivationPath(path); + w.setM(2); + assert.strictEqual(w._getExternalAddressByIndex(0), 'bc1qxzrzh4caw7e3genwtldtxntzj0ktfl7mhf2lh4fj8h7hnkvtvc4salvp85'); + assert.strictEqual(w._getExternalAddressByIndex(1), 'bc1qvwd2d7r46j7u9qyxpedfhe5p075sxuhzd0n6napuvvhq2u5nrmqs9ex90q'); + assert.strictEqual(w._getInternalAddressByIndex(0), 'bc1qtah0p50d4qlftn049k7lldcwh7cs3zkjy9g8xegv63p308hsh9zsf5567q'); + assert.strictEqual(w._getInternalAddressByIndex(1), 'bc1qv84pedzkqz2p4sd2dxm9krs0tcfatqcn73nndycaky9qttczj9qq3az9ma'); + + 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), mnemonicsColdcard); + assert.strictEqual(w.getCosignerForFingerprint(fp1cobo), Zpub1); + assert.strictEqual(w.getCosignerForFingerprint(fp2coldcard), mnemonicsColdcard); + assert.strictEqual(w.howManySignaturesCanWeMake(), 1); + }); + + it('basic operations work for 2-of-3', async () => { + const path = "m/48'/0'/0'/2'"; + + const w = new MultisigHDWallet(); + w.addCosigner(Zpub1, fp1cobo); + w.addCosigner(Zpub2, fp2coldcard); + w.addCosigner( + 'accident olympic spawn spider cable track pluck fat code grab fine salt garment kidney crime old often worth member impulse brother smoke garden trash', + ); + w.setDerivationPath(path); + w.setM(2); + + assert.strictEqual( + w.convertXpubToMultisignatureXpub( + MultisigHDWallet.seedToXpub( + 'accident olympic spawn spider cable track pluck fat code grab fine salt garment kidney crime old often worth member impulse brother smoke garden trash', + path, + ), + ), + 'Zpub74k35j5DkSA6t6SFhPeHv8ENBHdNgAPALWodSWoWxsHo6vbAu2FUGq9QmUEvdEPzBoMswizfsAbTWQYU2ZnvCjdKsFje5TEfjLxuH8arBtp', + ); + + assert.strictEqual(w._getExternalAddressByIndex(0), 'bc1qnpy7c7wz6tvmhdwgyk8ka4du3s9x6uhgjal305xdatmwfa538zxsys5l0t'); + assert.strictEqual(w._getExternalAddressByIndex(1), 'bc1qvuum7egsw4r4utzart88pergghy9rp8m4j5m4s464lz6u39sn6usn89w7c'); + assert.strictEqual(w._getInternalAddressByIndex(0), 'bc1qatmvfj5nzh4z3njxeg8z86y592clqe7sfgvp5cpund47knnm6pxsswl2lr'); + assert.strictEqual(w._getInternalAddressByIndex(1), 'bc1qpqa9c6nkqgcruegnh8wcsr0gzc4x9y90v9k0nxr6lww0gts430zqp7wm86'); + + assert.strictEqual(w.getM(), 2); + assert.strictEqual(w.getN(), 3); + assert.strictEqual(w.howManySignaturesCanWeMake(), 1); + assert.ok(!w.isWrappedSegwit()); + assert.ok(w.isNativeSegwit()); + assert.ok(!w.isLegacy()); + }); + + it('can coordinate tx creation', async () => { + const path = "m/48'/0'/0'/2'"; + + const utxos = [ + { + height: 666, + value: 100000, + address: 'bc1qxzrzh4caw7e3genwtldtxntzj0ktfl7mhf2lh4fj8h7hnkvtvc4salvp85', + txId: '666b1f2ee25dfd92377bb66a8db2badf45625a59e93f5a89836e178f9f5ed396', + vout: 0, + txid: '666b1f2ee25dfd92377bb66a8db2badf45625a59e93f5a89836e178f9f5ed396', + amount: 100000, + wif: false, + confirmations: 0, + txhex: + '02000000000101b67e455069a0f44c9df4849ee1167b06c26f8478daefa9c8aeedf1da3d7d81860f000000000000008002a08601000000000022002030862bd71d77b314666e5fdab34d6293ecb4ffdbba55fbd5323dfd79d98b662b04b005000000000016001461e37702582ecf8c87c1eb5008f2afb17acc9d3c02473044022077268bb0f3060b737b657c3c990107be5db41fd311cc64abeab96cff621146fc0220766e2409c0669020ea2160b358037fdb17f49e59faf8e9c50ac946019be079e6012103c3ed17035033b2cb0ce03694d402c37a307f0eea2b909b0272816bfcea83714f00000000', + }, + ]; + + const w = new MultisigHDWallet(); + w.addCosigner(Zpub1, fp1cobo); + w.addCosigner(Zpub2, fp2coldcard); + w.setDerivationPath(path); + w.setM(2); + + assert.strictEqual(w.getCustomDerivationPathForCosigner(1), path); // not provided, so should be default + assert.strictEqual(w.getCustomDerivationPathForCosigner(1), w.getDerivationPath()); // not provided, so should be default + assert.strictEqual(w.getCustomDerivationPathForCosigner(2), w.getDerivationPath()); // not provided, so should be default + + const { psbt } = w.createTransaction( + utxos, + [{ address: 'bc1qxzrzh4caw7e3genwtldtxntzj0ktfl7mhf2lh4fj8h7hnkvtvc4salvp85' }], // sendMax + 1, + w._getInternalAddressByIndex(0), // there should be no change in this tx + false, + false, + ); + + assert.strictEqual( + psbt.toBase64(), + 'cHNidP8BAF4CAAAAAZbTXp+PF26DiVo/6VlaYkXfurKNarZ7N5L9XeIuH2tmAAAAAAAAAACAAeCFAQAAAAAAIgAgMIYr1x13sxRmbl/as01ik+y0/9u6VfvVMj39edmLZisAAAAAAAEA6gIAAAAAAQG2fkVQaaD0TJ30hJ7hFnsGwm+EeNrvqciu7fHaPX2Bhg8AAAAAAAAAgAKghgEAAAAAACIAIDCGK9cdd7MUZm5f2rNNYpPstP/bulX71TI9/XnZi2YrBLAFAAAAAAAWABRh43cCWC7PjIfB61AI8q+xesydPAJHMEQCIHcmi7DzBgtze2V8PJkBB75dtB/TEcxkq+q5bP9iEUb8AiB2biQJwGaQIOohYLNYA3/bF/SeWfr46cUKyUYBm+B55gEhA8PtFwNQM7LLDOA2lNQCw3owfw7qK5CbAnKBa/zqg3FPAAAAAAEBK6CGAQAAAAAAIgAgMIYr1x13sxRmbl/as01ik+y0/9u6VfvVMj39edmLZisBBUdSIQL3PcZ3OXAqrpAGpxAfeH8tGlIosSQDQjFhbP8RIOZRyyED1Ql1CX8NiH3x6Uj22iu8SEwewHmhRSyqJtbmfw+g11pSriIGAvc9xnc5cCqukAanEB94fy0aUiixJANCMWFs/xEg5lHLHNN+rYgwAACAAAAAgAAAAIACAACAAAAAAAAAAAAiBgPVCXUJfw2IffHpSPbaK7xITB7AeaFFLKom1uZ/D6DXWhwWjdYDMAAAgAAAAIAAAACAAgAAgAAAAAAAAAAAAAA=', + ); + + assert.strictEqual(w.calculateHowManySignaturesWeHaveFromPsbt(psbt), 0); + + const signedOnColdcard = + 'cHNidP8BAF4CAAAAAZbTXp+PF26DiVo/6VlaYkXfurKNarZ7N5L9XeIuH2tmAAAAAAAAAACAAeCFAQAAAAAAIgAgMIYr1x13sxRmbl/as01ik+y0/9u6VfvVMj39edmLZisAAAAAAAEA6gIAAAAAAQG2fkVQaaD0TJ30hJ7hFnsGwm+EeNrvqciu7fHaPX2Bhg8AAAAAAAAAgAKghgEAAAAAACIAIDCGK9cdd7MUZm5f2rNNYpPstP/bulX71TI9/XnZi2YrBLAFAAAAAAAWABRh43cCWC7PjIfB61AI8q+xesydPAJHMEQCIHcmi7DzBgtze2V8PJkBB75dtB/TEcxkq+q5bP9iEUb8AiB2biQJwGaQIOohYLNYA3/bF/SeWfr46cUKyUYBm+B55gEhA8PtFwNQM7LLDOA2lNQCw3owfw7qK5CbAnKBa/zqg3FPAAAAAAEBK6CGAQAAAAAAIgAgMIYr1x13sxRmbl/as01ik+y0/9u6VfvVMj39edmLZisiAgPVCXUJfw2IffHpSPbaK7xITB7AeaFFLKom1uZ/D6DXWkcwRAIgfydmSzg/YjlUZDjgfrZGPKOXv5z7do3r5L8YePt5srYCIAD0JWkLVVPeeMsLOUHngsTd01Dx8OezzEmzRGYg9I+2AQEDBAEAAAAiBgPVCXUJfw2IffHpSPbaK7xITB7AeaFFLKom1uZ/D6DXWhwWjdYDMAAAgAAAAIAAAACAAgAAgAAAAAAAAAAAIgYC9z3GdzlwKq6QBqcQH3h/LRpSKLEkA0IxYWz/ESDmUcsc036tiDAAAIAAAACAAAAAgAIAAIAAAAAAAAAAAAEFR1IhAvc9xnc5cCqukAanEB94fy0aUiixJANCMWFs/xEg5lHLIQPVCXUJfw2IffHpSPbaK7xITB7AeaFFLKom1uZ/D6DXWlKuAAA='; + const psbtSignedOnColdcard = bitcoin.Psbt.fromBase64(signedOnColdcard); + psbt.combine(psbtSignedOnColdcard); // should not throw + assert.strictEqual(w.calculateHowManySignaturesWeHaveFromPsbt(psbt), 1); + + // signed on real Cobo device: + const psbtFromCobo = bitcoin.Psbt.fromHex( + decodeUR([ + 'UR:BYTES/2OF2/KP2PVX8V4F6ERP7X6ZZC9TDQ8VQQVASXMM4EUF9TL5AST2HDXSVS30JH3S/J9ZCJGQ6ZX9SKELC3YRN9RJ68XPZQYGQVTXZCWR85MH933S8YXJVVEA7M6G262E4ADTRJR5ZTXJWEKMCLPYPZQ3FDYRG9ZJEUZMM5Q5KFM6SV3VXCHZY3FEPGV0T40ULWX2SXGF2ZQY3QYQ74P96SJLCD3P7LR62G7MDZH0ZGFS0VQ7DPG5K25FKKUELSLGXHTFRNQ3QZYPLJWEJT8QLKYW25VSUWQL4KGC7289ALNNAHDR0TUJL3S78M0XETVQ3QQR6Z26GT24FAU7XTPVU5REUZCNWAX5837RNM8NZFKDZXVG8537MQZQGRQSQSQQQQQYZ5W53PQTMNM3NH89CZ4T5SQ6N3Q8MC0UK3553GKYJQXS33V9K07YFQUEGUKGGR65YH2ZTLPKY8MU0FFRMD52AUFPXPASRE59ZJE23X6MN87RAQ6AD99T3ZQCP0W0WXWUUHQ24WJQR2WYQL0PLJ6XJJ9ZCJGQ6ZX9SKELC3YRN9RJCU6DL2MZPSQQQGQQQQQZQQQQQQSQPQQQYQQQQQQQQQQQQQQGSXQ02SJAGF0UXCSL03A9Y0DK3TH3YYC8KQ0XS52T92YMTWVLC05RT458QK3HTQXVQQQZQQQQQQSQQQQQYQQGQQPQQQQQQQQQQQQQQQQQQZR7R39', + 'UR:BYTES/1OF2/KP2PVX8V4F6ERP7X6ZZC9TDQ8VQQVASXMM4EUF9TL5AST2HDXSVS30JH3S/TYPJKURNVF607QGQTCPQQQQQQXTDXH5L3UTKAQUFTGL7JK26VFZALW4J344TV7EHJT74MC3WRA4KVQQQQQQQQQQQQZQQRCY9QYQQQQQQQQ3QQGPSSC4AW8THKV2XVMJLM2E56C5NAJ60LKA62HAA2V3AL4UANZMX9VQQQQQQQQQSP6SZQQQQQQQPQXM8U32SDXS0GNYA7JZFACGK0VRVYMUY0RDWL2WG4MKLRK3A0KQCVRCQQQQQQQQQQZQQ9GYXQYQQQQQQQQ3QQGPSSC4AW8THKV2XVMJLM2E56C5NAJ60LKA62HAA2V3AL4UANZMX9VZTQPGQQQQQQQQKQQ2XRCMHQFVZANUVSLQ7K5QG72HMZ7KVN57QY3ESGSPZQAEX3WC0XPSTWDAK2LPUNYQS00JAKS0AXYWVVJ474WTVLA3PZ3HUQGS8VM3YP8QXDYPQAGSKPV6CQDLAK9L5NEVL478FC59VJ3SPN0S8NESPYYPU8MGHQDGR8VKTPNSRD9X5QTPH5VRLPM4ZHYYMQFEGZ6LUA2PHZNCQQQQQQQGP9WSGVQGQQQQQQQPZQQSRPP3T6UWH0VC5VEH9LK4NF43F8M95LLDM540M65ERMLTEMX9KV2EZQGP0W0WXWUUHQ24WJQR2WYQL0PLJ6XJ', + ]), + ); + + psbt.combine(psbtFromCobo); + assert.strictEqual(w.calculateHowManySignaturesWeHaveFromPsbt(psbt), 2); + const txhex = psbt.finalizeAllInputs().extractTransaction().toHex(); + assert.strictEqual( + txhex, + '0200000000010196d35e9f8f176e83895a3fe9595a6245dfbab28d6ab67b3792fd5de22e1f6b6600000000000000008001e08501000000000022002030862bd71d77b314666e5fdab34d6293ecb4ffdbba55fbd5323dfd79d98b662b040047304402200c5985870cf4ddcb18c0e43498ccf7dbd215a566bd6ac721d04b349d9b6f1f090220452d20d0514b3c16f74052c9dea0c8b0d8b88914e42863d757f3ee32a06425420147304402207f27664b383f6239546438e07eb6463ca397bf9cfb768debe4bf1878fb79b2b6022000f425690b5553de78cb0b3941e782c4ddd350f1f0e7b3cc49b3446620f48fb60147522102f73dc67739702aae9006a7101f787f2d1a5228b124034231616cff1120e651cb2103d50975097f0d887df1e948f6da2bbc484c1ec079a1452caa26d6e67f0fa0d75a52ae00000000', + ); + + // now, tx with change and weird paths for keys: + + const w2 = new MultisigHDWallet(); + w2.addCosigner(Zpub1, fp1cobo, "m/6'/7'/8'/2'"); + w2.addCosigner(Zpub2, fp2coldcard, "m/5'/4'/3'/2'"); + w2.setDerivationPath(path); + w2.setM(2); + + assert.strictEqual(w2.getCustomDerivationPathForCosigner(1), "m/6'/7'/8'/2'"); + assert.strictEqual(w2.getCustomDerivationPathForCosigner(2), "m/5'/4'/3'/2'"); + + const { psbt: psbt2 } = w2.createTransaction( + utxos, + [{ address: 'bc1qxzrzh4caw7e3genwtldtxntzj0ktfl7mhf2lh4fj8h7hnkvtvc4salvp85', value: 10000 }], + 1, + w2._getInternalAddressByIndex(3), + false, + false, + ); + + assert.ok(w2.calculateFeeFromPsbt(psbt2) < 300); + assert.ok(w2.calculateFeeFromPsbt(psbt2) > 0); + + assert.strictEqual(psbt2.data.outputs[1].bip32Derivation[0].masterFingerprint.toString('hex').toUpperCase(), fp1cobo); + assert.strictEqual(psbt2.data.outputs[1].bip32Derivation[1].masterFingerprint.toString('hex').toUpperCase(), fp2coldcard); + assert.strictEqual(psbt2.data.outputs[1].bip32Derivation[0].path, "m/6'/7'/8'/2'" + '/1/3'); + assert.strictEqual(psbt2.data.outputs[1].bip32Derivation[1].path, "m/5'/4'/3'/2'" + '/1/3'); + + assert.strictEqual(psbt2.data.inputs[0].bip32Derivation[0].path, "m/6'/7'/8'/2'/0/0"); + assert.strictEqual(psbt2.data.inputs[0].bip32Derivation[1].path, "m/5'/4'/3'/2'/0/0"); + + assert.strictEqual(psbt2.data.inputs[0].bip32Derivation[0].masterFingerprint.toString('hex').toUpperCase(), fp1cobo); + assert.strictEqual( + psbt2.data.inputs[0].bip32Derivation[0].pubkey.toString('hex').toUpperCase(), + '02F73DC67739702AAE9006A7101F787F2D1A5228B124034231616CFF1120E651CB', + ); + assert.strictEqual(psbt2.data.inputs[0].bip32Derivation[1].masterFingerprint.toString('hex').toUpperCase(), fp2coldcard); + assert.strictEqual( + psbt2.data.inputs[0].bip32Derivation[1].pubkey.toString('hex').toUpperCase(), + '03D50975097F0D887DF1E948F6DA2BBC484C1EC079A1452CAA26D6E67F0FA0D75A', + ); + }); + + it('can export/import wallet with all seeds in place, and also export coordination setup', () => { + const path = "m/48'/0'/0'/2'"; + + const w = new MultisigHDWallet(); + w.addCosigner(mnemonicsCobo, false, path); + w.addCosigner(mnemonicsColdcard, false, path); + w.setDerivationPath(path); + w.setM(2); + + const ww = new MultisigHDWallet(); + ww.setSecret(w.getSecret()); + + assert.strictEqual(ww._getExternalAddressByIndex(0), 'bc1qxzrzh4caw7e3genwtldtxntzj0ktfl7mhf2lh4fj8h7hnkvtvc4salvp85'); + assert.strictEqual(ww._getInternalAddressByIndex(0), 'bc1qtah0p50d4qlftn049k7lldcwh7cs3zkjy9g8xegv63p308hsh9zsf5567q'); + + assert.strictEqual(ww.getM(), 2); + assert.strictEqual(ww.getN(), 2); + assert.strictEqual(ww.howManySignaturesCanWeMake(), 2); + assert.ok(!ww.isWrappedSegwit()); + assert.ok(ww.isNativeSegwit()); + assert.ok(!ww.isLegacy()); + + assert.strictEqual(w.getID(), ww.getID()); + assert.ok(w.getID() !== new MultisigHDWallet().getID()); + + // now, exporting coordination setup: + + const w3 = new MultisigHDWallet(); + w3.setSecret(ww.getXpub()); + assert.strictEqual(w3._getExternalAddressByIndex(0), ww._getExternalAddressByIndex(0)); + assert.strictEqual(w3._getInternalAddressByIndex(0), ww._getInternalAddressByIndex(0)); + assert.strictEqual(w3.getM(), 2); + assert.strictEqual(w3.getN(), 2); + assert.strictEqual(w3.howManySignaturesCanWeMake(), 0); + assert.ok(!w3.isWrappedSegwit()); + assert.ok(w3.isNativeSegwit()); + assert.ok(!w3.isLegacy()); + assert.ok(MultisigHDWallet.isXpubString(w3.getCosigner(1)) && MultisigHDWallet.isXpubValid(w3.getCosigner(1))); + assert.ok(MultisigHDWallet.isXpubString(w3.getCosigner(2)) && MultisigHDWallet.isXpubValid(w3.getCosigner(2))); + }); + + it('can coordinate tx creation and cosign 1 of 2', async () => { + const path = "m/48'/0'/0'/2'"; + + const utxos = [ + { + height: 666, + value: 100000, + address: 'bc1qxzrzh4caw7e3genwtldtxntzj0ktfl7mhf2lh4fj8h7hnkvtvc4salvp85', + txId: '666b1f2ee25dfd92377bb66a8db2badf45625a59e93f5a89836e178f9f5ed396', + vout: 0, + txid: '666b1f2ee25dfd92377bb66a8db2badf45625a59e93f5a89836e178f9f5ed396', + amount: 100000, + wif: false, + confirmations: 0, + txhex: + '02000000000101b67e455069a0f44c9df4849ee1167b06c26f8478daefa9c8aeedf1da3d7d81860f000000000000008002a08601000000000022002030862bd71d77b314666e5fdab34d6293ecb4ffdbba55fbd5323dfd79d98b662b04b005000000000016001461e37702582ecf8c87c1eb5008f2afb17acc9d3c02473044022077268bb0f3060b737b657c3c990107be5db41fd311cc64abeab96cff621146fc0220766e2409c0669020ea2160b358037fdb17f49e59faf8e9c50ac946019be079e6012103c3ed17035033b2cb0ce03694d402c37a307f0eea2b909b0272816bfcea83714f00000000', + }, + ]; + + const w = new MultisigHDWallet(); + w.addCosigner(Zpub1, fp1cobo); + w.addCosigner(mnemonicsColdcard, false, path); + w.setDerivationPath(path); + w.setM(2); + + // transaction is gona be partially signed because we have one of two signing keys + const { psbt, tx } = w.createTransaction( + utxos, + [{ address: 'bc1qlhpaukt44ru7044uqdf0hp2qs0ut0p93g66k8h' }], // sendMax + 10, + w._getInternalAddressByIndex(0), // there should be no change in this tx + false, + false, + ); + + assert.strictEqual(w.calculateHowManySignaturesWeHaveFromPsbt(psbt), 1); + + assert.strictEqual(psbt.data.inputs[0].partialSig.length, 1); + assert.ok(!tx, 'tx should not be provided when PSBT is only partially signed'); + assert.strictEqual( + psbt.toBase64(), + 'cHNidP8BAFICAAAAAZbTXp+PF26DiVo/6VlaYkXfurKNarZ7N5L9XeIuH2tmAAAAAAAAAACAASB/AQAAAAAAFgAU/cPeWXWo+efWvANS+4VAg/i3hLEAAAAAAAEA6gIAAAAAAQG2fkVQaaD0TJ30hJ7hFnsGwm+EeNrvqciu7fHaPX2Bhg8AAAAAAAAAgAKghgEAAAAAACIAIDCGK9cdd7MUZm5f2rNNYpPstP/bulX71TI9/XnZi2YrBLAFAAAAAAAWABRh43cCWC7PjIfB61AI8q+xesydPAJHMEQCIHcmi7DzBgtze2V8PJkBB75dtB/TEcxkq+q5bP9iEUb8AiB2biQJwGaQIOohYLNYA3/bF/SeWfr46cUKyUYBm+B55gEhA8PtFwNQM7LLDOA2lNQCw3owfw7qK5CbAnKBa/zqg3FPAAAAAAEBK6CGAQAAAAAAIgAgMIYr1x13sxRmbl/as01ik+y0/9u6VfvVMj39edmLZisiAgPVCXUJfw2IffHpSPbaK7xITB7AeaFFLKom1uZ/D6DXWkgwRQIhAMlUC0EwNieytD8U9AUITLBvorNMUfWwJqsGJXRdZA2TAiA7k6ddbqnLKPwswk/D9ehGBIMNzKEfJYW7DkGGYRJdYAEBBUdSIQL3PcZ3OXAqrpAGpxAfeH8tGlIosSQDQjFhbP8RIOZRyyED1Ql1CX8NiH3x6Uj22iu8SEwewHmhRSyqJtbmfw+g11pSriIGAvc9xnc5cCqukAanEB94fy0aUiixJANCMWFs/xEg5lHLHNN+rYgwAACAAAAAgAAAAIACAACAAAAAAAAAAAAiBgPVCXUJfw2IffHpSPbaK7xITB7AeaFFLKom1uZ/D6DXWhwWjdYDMAAAgAAAAIAAAACAAgAAgAAAAAAAAAAAAAA=', + ); + + // got that from real Cobo vault device: + const payload = decodeUR([ + 'UR:BYTES/1OF2/WDZ4928G5MLENN8JFGWM988QCZPUZQ8P64N34EWP3C3TTQRR4C7QH86JMF/TYP3JURNVF607QGQ2GPQQQQQQXTDXH5L3UTKAQUFTGL7JK26VFZALW4J344TV7EHJT74MC3WRA4KVQQQQQQQQQQQQZQQZGRLQYQQQQQQQQTQQ98AC009JADGL8NAD0QR2TAC2SYRLZMCFVGQQQQQQQQPQR4QYQQQQQQQZQDK0EZ4Q6DQ73XFMAYYNMS3V7CXCFHCG7X6A75U3THD78DR6LVPSC8SQQQQQQQQQQYQQ2SGVQGQQQQQQQPZQQSRPP3T6UWH0VC5VEH9LK4NF43F8M95LLDM540M65ERMLTEMX9KV2CYKQZSQQQQQQQPVQQ5V83HWQJC9M8CEP7PADGQ3U40K9AVE8FUQFRNQ3QZYPMJDZAS7VRQKUMMV47REXGPQ7L9MDQL6VGUCE9TA2UKELMZZ9R0CQ3QWEHZGZWQV6GZP63PVZE4SQMLMVTLF8JELTUWN3G2E9RQRXLQ08NQZGGRC0K3WQ6SXWEVKR8QX62DGQKR0GC87RH29WGFKQNJS94LE65RW98SQQQQQQQSZ2AQSCQSQQQQQQQZYQPQXZRZH4CAW7E3GENWTLDTXNTZJ0KTFL7MHF2LH4FJ8H7HNKVTVC4JYQSZ7U7UVAEEWQ42AYQX5UGP77RL95D9Y293', + 'UR:BYTES/2OF2/WDZ4928G5MLENN8JFGWM988QCZPUZQ8P64N34EWP3C3TTQRR4C7QH86JMF/YSP5YVTPDNL3ZG8X2895WVZYQGS9980HLVW7QEG5V8YHAJLZJ5TDHWNSH6HRZ34QHQHJSYSL0E23G7GZYPAXMSVNDXD2GYE597LSU89DYW2RYTTLXP0H4UR4VTMPQHPH3AHTYQFZQGPA2ZT4P9LSMZRA78553AK69W7YSNQ7CPU6Z3FV4GNDDENLP7SDWKJGXPZSYGGQE92QKSFSXCNM9DPLZN6Q2ZZVKPH69V6V286MQF4TQCJHGHTYPKFSYGPMJWN46M4FEV50CTXZFLPLT6ZXQJPSMN9PRUJCTWCWGXRXZYJAVQQSZP282GSS9AEACEMNJUP246GQDFCSRAU87TG62G5TZFQRGGCKZM8LZYSWV5WTYYPA2ZT4P9LSMZRA78553AK69W7YSNQ7CPU6Z3FV4GNDDENLP7SDWKJJ4C3QVQHH8HR8WWTS92HFQP48ZQ0HSLEDRFFZ3VFYQDPRZCTVLUGJPEJ3EVWDXL4D3QCQQQYQQQQQPQQQQQQGQQSQQZQQQQQQQQQQQQQQYGRQ84GFW5YH7RVG0HC7JJ8KMG4MCJZVRMQ8NG299J4ZD4HX0U86P466RSTGM4SRXQQQPQQQQQQGQQQQQZQQYQQQSQQQQQQQQQQQQQQQQQK820JL', + ]); + + const psbtFromCobo = bitcoin.Psbt.fromHex(payload); + psbt.combine(psbtFromCobo); + assert.strictEqual(psbt.data.inputs[0].partialSig.length, 2); + + assert.strictEqual(w.calculateHowManySignaturesWeHaveFromPsbt(psbt), 2); + + const tx2 = psbt.finalizeAllInputs().extractTransaction(); + assert.strictEqual( + tx2.toHex(), + '0200000000010196d35e9f8f176e83895a3fe9595a6245dfbab28d6ab67b3792fd5de22e1f6b6600000000000000008001207f010000000000160014fdc3de5975a8f9e7d6bc0352fb854083f8b784b104004730440220529df7fb1de0651461c97ecbe29516dbba70beae3146a0b82f28121f7e55147902207a6dc193699aa413342fbf0e1cad2394322d7f305f7af07562f6105c378f6eb201483045022100c9540b41303627b2b43f14f405084cb06fa2b34c51f5b026ab0625745d640d9302203b93a75d6ea9cb28fc2cc24fc3f5e84604830dcca11f2585bb0e418661125d600147522102f73dc67739702aae9006a7101f787f2d1a5228b124034231616cff1120e651cb2103d50975097f0d887df1e948f6da2bbc484c1ec079a1452caa26d6e67f0fa0d75a52ae00000000', + ); + + // to be precise in that case we dont need combine, we could just do: + // psbtFromCobo.finalizeAllInputs().extractTransaction().toHex() + }); + + it('can export/import when one of cosigners is mnemonic seed', async () => { + const path = "m/48'/0'/0'/2'"; + + const w = new MultisigHDWallet(); + w.addCosigner(Zpub1, fp1cobo); + w.addCosigner(mnemonicsColdcard, false, path); + w.setDerivationPath(path); + w.setM(2); + + assert.ok(w.getID()); + + const w2 = new MultisigHDWallet(); + w2.setSecret(w.getSecret()); + assert.strictEqual(w2.getID(), w.getID()); + + assert.strictEqual(w._getExternalAddressByIndex(0), w2._getExternalAddressByIndex(0)); + assert.strictEqual(w._getExternalAddressByIndex(1), w2._getExternalAddressByIndex(1)); + assert.strictEqual(w._getInternalAddressByIndex(0), w2._getInternalAddressByIndex(0)); + assert.strictEqual(w._getInternalAddressByIndex(1), w2._getInternalAddressByIndex(1)); + assert.strictEqual(w.getM(), w2.getM()); + assert.strictEqual(w.getN(), w2.getN()); + assert.strictEqual(w.getDerivationPath(), w2.getDerivationPath()); + assert.strictEqual(w.getCosigner(1), w2.getCosigner(1)); + assert.strictEqual(w.getCosigner(2), w2.getCosigner(2)); + assert.strictEqual(w.getCosignerForFingerprint(fp1cobo), w2.getCosignerForFingerprint(fp1cobo)); + assert.strictEqual(w.getCosignerForFingerprint(fp2coldcard), w2.getCosignerForFingerprint(fp2coldcard)); + assert.strictEqual(w.getCustomDerivationPathForCosigner(1), w2.getCustomDerivationPathForCosigner(1)); + assert.strictEqual(w.getCustomDerivationPathForCosigner(2), w2.getCustomDerivationPathForCosigner(2)); + assert.strictEqual(w.howManySignaturesCanWeMake(), w2.howManySignaturesCanWeMake()); + assert.strictEqual(w.isNativeSegwit(), w2.isNativeSegwit()); + assert.strictEqual(w.isWrappedSegwit(), w2.isWrappedSegwit()); + assert.strictEqual(w.isLegacy(), w2.isLegacy()); + assert.strictEqual(w.getLabel(), w2.getLabel()); + }); + + it('can import txt from Cobo and export it back', async () => { + const path = "m/48'/0'/0'/2'"; + + // can work with same secret win different formats: as TXT and as same TXT encoded in UR: + const secrets = [ + txtFileFormatMultisigNativeSegwit, + Buffer.from(decodeUR([txtFileFormatMultisigNativeSegwit]), 'hex').toString(), + txtFileFormatMultisigNativeSegwit.toLowerCase(), + txtFileFormatMultisigNativeSegwit.toUpperCase(), + ]; + + for (const secret of secrets) { + const w = new MultisigHDWallet(); + w.setSecret(secret); + + assert.strictEqual(w._getExternalAddressByIndex(0), 'bc1qxzrzh4caw7e3genwtldtxntzj0ktfl7mhf2lh4fj8h7hnkvtvc4salvp85'); + assert.strictEqual(w._getExternalAddressByIndex(1), 'bc1qvwd2d7r46j7u9qyxpedfhe5p075sxuhzd0n6napuvvhq2u5nrmqs9ex90q'); + assert.strictEqual(w._getInternalAddressByIndex(0), 'bc1qtah0p50d4qlftn049k7lldcwh7cs3zkjy9g8xegv63p308hsh9zsf5567q'); + assert.strictEqual(w._getInternalAddressByIndex(1), 'bc1qv84pedzkqz2p4sd2dxm9krs0tcfatqcn73nndycaky9qttczj9qq3az9ma'); + 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(fp1cobo), Zpub1); + assert.strictEqual(w.getCosignerForFingerprint(fp2coldcard), Zpub2); + assert.strictEqual(w.getCustomDerivationPathForCosigner(1), path); // default since custom was not provided + assert.strictEqual(w.getCustomDerivationPathForCosigner(2), path); // default since custom was not provided + assert.strictEqual(w.howManySignaturesCanWeMake(), 0); + + const w2 = new MultisigHDWallet(); + w2.setSecret(w.getSecret()); + + assert.strictEqual(w._getExternalAddressByIndex(0), w2._getExternalAddressByIndex(0)); + assert.strictEqual(w._getExternalAddressByIndex(1), w2._getExternalAddressByIndex(1)); + assert.strictEqual(w._getInternalAddressByIndex(0), w2._getInternalAddressByIndex(0)); + assert.strictEqual(w._getInternalAddressByIndex(1), w2._getInternalAddressByIndex(1)); + assert.strictEqual(w.getM(), w2.getM()); + assert.strictEqual(w.getN(), w2.getN()); + assert.strictEqual(w.getDerivationPath(), w2.getDerivationPath()); + assert.strictEqual(w.getCosigner(1), w2.getCosigner(1)); + assert.strictEqual(w.getCosigner(2), w2.getCosigner(2)); + assert.strictEqual(w.getCosignerForFingerprint(fp1cobo), w2.getCosignerForFingerprint(fp1cobo)); + assert.strictEqual(w.getCosignerForFingerprint(fp2coldcard), w2.getCosignerForFingerprint(fp2coldcard)); + assert.strictEqual(w.getCustomDerivationPathForCosigner(1), w2.getCustomDerivationPathForCosigner(1)); // default since custom was not provided + assert.strictEqual(w.getCustomDerivationPathForCosigner(2), w2.getCustomDerivationPathForCosigner(2)); // default since custom was not provided + assert.strictEqual(w.howManySignaturesCanWeMake(), w2.howManySignaturesCanWeMake()); + assert.strictEqual(w.isNativeSegwit(), w2.isNativeSegwit()); + assert.strictEqual(w.isWrappedSegwit(), w2.isWrappedSegwit()); + assert.strictEqual(w.isLegacy(), w2.isLegacy()); + assert.strictEqual(w.getLabel(), w2.getLabel()); + } + }); + + it('can import txt with custom paths per each cosigner (and export it back)', async () => { + const secret = + '# CoboVault Multisig setup file (created on D37EAD88)\n' + + '#\n' + + 'Name: CV_33B5B91A_2-2\n' + + 'Policy: 2 of 2\n' + + 'Format: P2WSH\n' + + '\n' + + "# derivation: m/47'/0'/0'/1'\n" + + 'D37EAD88: Zpub74ijpfhERJNjhCKXRspTdLJV5eoEmSRZdHqDvp9kVtdVEyiXk7pXxRbfZzQvsDFpfDHEHVtVpx4Dz9DGUWGn2Xk5zG5u45QTMsYS2vjohNQ\n' + + '\n' + + "# derivation: m/46'/0'/0'/1'\n" + + '168DD603: Zpub75mAE8EjyxSzoyPmGnd5E6MyD7ALGNndruWv52xpzimZQKukwvEfXTHqmH8nbbc6ccP5t2aM3mws3pKYSnKpKMMytdbNEZFUxKzztYFM8Pn\n'; + + const w = new MultisigHDWallet(); + w.setSecret(secret); + + assert.strictEqual(w._getExternalAddressByIndex(0), 'bc1qxzrzh4caw7e3genwtldtxntzj0ktfl7mhf2lh4fj8h7hnkvtvc4salvp85'); + assert.strictEqual(w._getExternalAddressByIndex(1), 'bc1qvwd2d7r46j7u9qyxpedfhe5p075sxuhzd0n6napuvvhq2u5nrmqs9ex90q'); + assert.strictEqual(w._getInternalAddressByIndex(0), 'bc1qtah0p50d4qlftn049k7lldcwh7cs3zkjy9g8xegv63p308hsh9zsf5567q'); + assert.strictEqual(w._getInternalAddressByIndex(1), 'bc1qv84pedzkqz2p4sd2dxm9krs0tcfatqcn73nndycaky9qttczj9qq3az9ma'); + assert.strictEqual(w.getM(), 2); + assert.strictEqual(w.getN(), 2); + assert.strictEqual(w.getCustomDerivationPathForCosigner(1), "m/47'/0'/0'/1'"); + assert.strictEqual(w.getCustomDerivationPathForCosigner(2), "m/46'/0'/0'/1'"); + assert.strictEqual(w.getDerivationPath(), ''); + assert.strictEqual(w.getCosigner(1), Zpub1); + assert.strictEqual(w.getCosigner(2), Zpub2); + assert.strictEqual(w.getCosignerForFingerprint(fp1cobo), Zpub1); + assert.strictEqual(w.getCosignerForFingerprint(fp2coldcard), Zpub2); + assert.strictEqual(w.howManySignaturesCanWeMake(), 0); + + const utxos = [ + { + height: 666, + value: 100000, + address: 'bc1qxzrzh4caw7e3genwtldtxntzj0ktfl7mhf2lh4fj8h7hnkvtvc4salvp85', + txId: '666b1f2ee25dfd92377bb66a8db2badf45625a59e93f5a89836e178f9f5ed396', + vout: 0, + txid: '666b1f2ee25dfd92377bb66a8db2badf45625a59e93f5a89836e178f9f5ed396', + amount: 100000, + wif: false, + confirmations: 0, + txhex: + '02000000000101b67e455069a0f44c9df4849ee1167b06c26f8478daefa9c8aeedf1da3d7d81860f000000000000008002a08601000000000022002030862bd71d77b314666e5fdab34d6293ecb4ffdbba55fbd5323dfd79d98b662b04b005000000000016001461e37702582ecf8c87c1eb5008f2afb17acc9d3c02473044022077268bb0f3060b737b657c3c990107be5db41fd311cc64abeab96cff621146fc0220766e2409c0669020ea2160b358037fdb17f49e59faf8e9c50ac946019be079e6012103c3ed17035033b2cb0ce03694d402c37a307f0eea2b909b0272816bfcea83714f00000000', + }, + ]; + + const { psbt: psbt2 } = w.createTransaction( + utxos, + [{ address: 'bc1qxzrzh4caw7e3genwtldtxntzj0ktfl7mhf2lh4fj8h7hnkvtvc4salvp85', value: 10000 }], + 1, + w._getInternalAddressByIndex(3), + false, + false, + ); + + assert.strictEqual(psbt2.data.outputs[1].bip32Derivation[0].path, "m/47'/0'/0'/1'" + '/1/3'); + assert.strictEqual(psbt2.data.outputs[1].bip32Derivation[1].path, "m/46'/0'/0'/1'" + '/1/3'); + + assert.strictEqual(psbt2.data.inputs[0].bip32Derivation[0].path, "m/47'/0'/0'/1'/0/0"); + assert.strictEqual(psbt2.data.inputs[0].bip32Derivation[1].path, "m/46'/0'/0'/1'/0/0"); + + // testing that custom paths survive export/import + + const w2 = new MultisigHDWallet(); + w2.setSecret(w.getSecret()); + + assert.strictEqual(w._getExternalAddressByIndex(0), w2._getExternalAddressByIndex(0)); + assert.strictEqual(w._getExternalAddressByIndex(1), w2._getExternalAddressByIndex(1)); + assert.strictEqual(w._getInternalAddressByIndex(0), w2._getInternalAddressByIndex(0)); + assert.strictEqual(w._getInternalAddressByIndex(1), w2._getInternalAddressByIndex(1)); + assert.strictEqual(w.getM(), w2.getM()); + assert.strictEqual(w.getN(), w2.getN()); + assert.strictEqual(w.getDerivationPath(), w2.getDerivationPath()); + assert.strictEqual(w.getCosigner(1), w2.getCosigner(1)); + assert.strictEqual(w.getCosigner(2), w2.getCosigner(2)); + assert.strictEqual(w.getCosignerForFingerprint(fp1cobo), w2.getCosignerForFingerprint(fp1cobo)); + assert.strictEqual(w.getCosignerForFingerprint(fp2coldcard), w2.getCosignerForFingerprint(fp2coldcard)); + assert.strictEqual(w.getCustomDerivationPathForCosigner(1), w2.getCustomDerivationPathForCosigner(1)); + assert.strictEqual(w.getCustomDerivationPathForCosigner(2), w2.getCustomDerivationPathForCosigner(2)); + assert.strictEqual(w.howManySignaturesCanWeMake(), w2.howManySignaturesCanWeMake()); + assert.strictEqual(w.getLabel(), w2.getLabel()); + }); + + it('can import incomplete wallet from Coldcard', async () => { + const Zpub2 = 'Zpub75mAE8EjyxSzoyPmGnd5E6MyD7ALGNndruWv52xpzimZQKukwvEfXTHqmH8nbbc6ccP5t2aM3mws3pKYSnKpKMMytdbNEZFUxKzztYFM8Pn'; + + const w = new MultisigHDWallet(); + w.setSecret(coldcardExport); + + assert.throws(() => w._getExternalAddressByIndex(0)); + assert.throws(() => w._getInternalAddressByIndex(0)); + + assert.strictEqual(w.getM(), 0); // zero means unknown + assert.strictEqual(w.getN(), 1); // added only one cosigner + assert.strictEqual(w.getCosigner(1), Zpub2); + assert.strictEqual(w.getCosignerForFingerprint(fp2coldcard), Zpub2); + assert.strictEqual(w.getDerivationPath(), ''); // unknown + }); + + it('can import electrum json file format', () => { + assert.strictEqual(MultisigHDWallet.ckccXfp2fingerprint(64392470), '168DD603'); + assert.strictEqual(MultisigHDWallet.ckccXfp2fingerprint('64392470'), '168DD603'); + assert.strictEqual(MultisigHDWallet.ckccXfp2fingerprint(2389277556), '747B698E'); + assert.strictEqual(MultisigHDWallet.ckccXfp2fingerprint(1130956047), '0F056943'); + assert.strictEqual(MultisigHDWallet.ckccXfp2fingerprint(2293071571), 'D37EAD88'); + + const w = new MultisigHDWallet(); + w.setSecret(electumJson); + + assert.strictEqual(w.getM(), 2); + assert.strictEqual(w.getN(), 2); + assert.strictEqual(w.getCustomDerivationPathForCosigner(1), "m/48'/1'/0'/1'"); + assert.strictEqual(w.getCustomDerivationPathForCosigner(2), "m/48'/1'/0'/1'"); + assert.strictEqual(w.getCosigner(1), Zpub1); + assert.strictEqual(w.getCosigner(2), Zpub2); + assert.strictEqual(w.getCosignerForFingerprint(fp1cobo), Zpub1); + assert.strictEqual(w.getCosignerForFingerprint(fp2coldcard), Zpub2); + assert.strictEqual(w.howManySignaturesCanWeMake(), 0); + assert.ok(w.isNativeSegwit()); + assert.ok(!w.isWrappedSegwit()); + assert.ok(!w.isLegacy()); + + assert.strictEqual(w._getExternalAddressByIndex(0), 'bc1qxzrzh4caw7e3genwtldtxntzj0ktfl7mhf2lh4fj8h7hnkvtvc4salvp85'); + assert.strictEqual(w._getExternalAddressByIndex(1), 'bc1qvwd2d7r46j7u9qyxpedfhe5p075sxuhzd0n6napuvvhq2u5nrmqs9ex90q'); + assert.strictEqual(w._getInternalAddressByIndex(0), 'bc1qtah0p50d4qlftn049k7lldcwh7cs3zkjy9g8xegv63p308hsh9zsf5567q'); + assert.strictEqual(w._getInternalAddressByIndex(1), 'bc1qv84pedzkqz2p4sd2dxm9krs0tcfatqcn73nndycaky9qttczj9qq3az9ma'); + }); + + it.skip('can import electrum json file format with seeds', () => { + const w = new MultisigHDWallet(); + w.setSecret(JSON.stringify(require('./fixtures/electrum-multisig-wallet-with-seed.json'))); + + assert.strictEqual(w.getM(), 2); + assert.strictEqual(w.getN(), 3); + assert.strictEqual(w.howManySignaturesCanWeMake(), 1); + + assert.strictEqual(w._getExternalAddressByIndex(0), 'bc1qkzg22vej70cqnrlsxcee9nfnstcr70jalvsmjn0c8rjf0klwyydsk8nggs'); + assert.strictEqual(w._getExternalAddressByIndex(1), 'bc1q2mkhkvx9l7aqksvyf0dwd2x4yn8qx2w3sythjltdkjw70r8hsves2evfg6'); + assert.strictEqual(w._getInternalAddressByIndex(0), 'bc1qqj0zx85x3d2frn4nmdn32fgskq5c2qkvk9sukxp3xsdzuf234mds85w068'); + assert.strictEqual(w._getInternalAddressByIndex(1), 'bc1qwpxkr4ac7fyp6y8uegfpqa6phyqex3vdf5mwwrfayrp8889adpgszge8m5'); + + assert.ok(w.isNativeSegwit()); + assert.ok(!w.isWrappedSegwit()); + assert.ok(!w.isLegacy()); + + assert.strictEqual(w.getCustomDerivationPathForCosigner(1), "m/1'"); + assert.strictEqual(w.getCustomDerivationPathForCosigner(2), "m/1'"); + + assert.strictEqual(w.getFingerprint(1), '8aaa5d05'.toUpperCase()); + assert.strictEqual(w.getFingerprint(2), 'ef748d2c'.toUpperCase()); + assert.strictEqual(w.getFingerprint(3), 'fdb6c4d8'.toUpperCase()); + }); + + it('cant import garbage', () => { + const w = new MultisigHDWallet(); + w.setSecret('garbage'); + assert.strictEqual(w.getM(), 0); + assert.strictEqual(w.getN(), 0); + + w.setSecret(Zpub1); + assert.strictEqual(w.getM(), 0); + assert.strictEqual(w.getN(), 0); + + w.setSecret(mnemonicsCobo); + assert.strictEqual(w.getM(), 0); + assert.strictEqual(w.getN(), 0); + + w.setSecret(MultisigHDWallet.seedToXpub(mnemonicsColdcard, "m/48'/0'/0'/1'")); + assert.strictEqual(w.getM(), 0); + assert.strictEqual(w.getN(), 0); + }); + + it.skip('can import from caravan', () => { + const json = JSON.stringify({ + name: 'My Multisig Wallet', + addressType: 'P2WSH', + network: 'mainnet', + client: { + type: 'public', + }, + quorum: { + requiredSigners: 2, + totalSigners: 3, + }, + extendedPublicKeys: [ + { + name: 'Extended Public Key 1', + bip32Path: 'Unknown (make sure you have written this down previously!)', + xpub: 'xpub6EA866cxYyjQa2mupVnEP5mg1vU5fqkyUo97Sm6SN73KWbXAUQ78dBRTisYHJxj5cTyduxhG2Qxd6QNNjtHoHaGDR7aeUrJUvh9GfqvsRQQ', + method: 'text', + }, + { + name: 'Extended Public Key 2', + bip32Path: 'Unknown (make sure you have written this down previously!)', + xpub: 'xpub6FCYVZAU7dofgor9fQaqyqqA9NqBAn83iQpoayuWrwBPfwiPgCXGCD7dvAG93M5MZs5VWVP7FErGA5UeiALqaPt7KV67fL9WX9bqXTyeWxb', + method: 'text', + }, + { + name: 'Extended Public Key 3', + bip32Path: 'Unknown (make sure you have written this down previously!)', + xpub: 'xpub6EBRM9zwt7Wmkvte61c4fshZ7ZJDaZiaC27WxTkCq5hdNYPodJY4wayCvMNH4ysF944HaBoS4dVrcfhaHwowTn9TJ7EPWE8hJAZjv7gwtew', + method: 'text', + }, + ], + }); + let w = new MultisigHDWallet(); + w.setSecret(json); + + assert.strictEqual(w.getM(), 2); + assert.strictEqual(w.getN(), 3); + assert.strictEqual(w._getExternalAddressByIndex(0), 'bc1qnpy7c7wz6tvmhdwgyk8ka4du3s9x6uhgjal305xdatmwfa538zxsys5l0t'); + assert.strictEqual(w._getExternalAddressByIndex(1), 'bc1qvuum7egsw4r4utzart88pergghy9rp8m4j5m4s464lz6u39sn6usn89w7c'); + assert.strictEqual(w._getInternalAddressByIndex(0), 'bc1qatmvfj5nzh4z3njxeg8z86y592clqe7sfgvp5cpund47knnm6pxsswl2lr'); + assert.strictEqual(w._getInternalAddressByIndex(1), 'bc1qpqa9c6nkqgcruegnh8wcsr0gzc4x9y90v9k0nxr6lww0gts430zqp7wm86'); + assert.ok(!w.isWrappedSegwit()); + assert.ok(w.isNativeSegwit()); + assert.ok(!w.isLegacy()); + + // take 2 + + const json2 = JSON.stringify({ + name: 'My Multisig Wallet', + addressType: 'P2WSH', + network: 'mainnet', + client: { + type: 'public', + }, + quorum: { + requiredSigners: 2, + totalSigners: 2, + }, + extendedPublicKeys: [ + { + name: 'Extended Public Key 1', + bip32Path: 'Unknown (make sure you have written this down previously!)', + xpub: 'xpub6EA866cxYyjQa2mupVnEP5mg1vU5fqkyUo97Sm6SN73KWbXAUQ78dBRTisYHJxj5cTyduxhG2Qxd6QNNjtHoHaGDR7aeUrJUvh9GfqvsRQQ', + method: 'text', + }, + { + name: 'Extended Public Key 2', + bip32Path: 'Unknown (make sure you have written this down previously!)', + xpub: 'xpub6FCYVZAU7dofgor9fQaqyqqA9NqBAn83iQpoayuWrwBPfwiPgCXGCD7dvAG93M5MZs5VWVP7FErGA5UeiALqaPt7KV67fL9WX9bqXTyeWxb', + method: 'text', + }, + ], + startingAddressIndex: 0, + }); + + w = new MultisigHDWallet(); + w.setSecret(json2); + + assert.strictEqual(w._getExternalAddressByIndex(0), 'bc1qxzrzh4caw7e3genwtldtxntzj0ktfl7mhf2lh4fj8h7hnkvtvc4salvp85'); + assert.strictEqual(w._getInternalAddressByIndex(0), 'bc1qtah0p50d4qlftn049k7lldcwh7cs3zkjy9g8xegv63p308hsh9zsf5567q'); + + assert.strictEqual(w.getM(), 2); + assert.strictEqual(w.getN(), 2); + // assert.strictEqual(w.getCosigner(1), Zpub1); + // assert.strictEqual(w.getCosigner(2), Zpub2); + assert.strictEqual(w.getFingerprint(1), fp1cobo); + assert.strictEqual(w.getFingerprint(2), fp2coldcard); + assert.strictEqual(w.howManySignaturesCanWeMake(), 0); + assert.ok(!w.isWrappedSegwit()); + assert.ok(w.isNativeSegwit()); + assert.ok(!w.isLegacy()); + }); + + it.skip('can import from specter-desktop/fullynoded', () => { + // @see https://github.com/Fonta1n3/FullyNoded/blob/master/Docs/Wallets/Wallet-Export-Spec.md + const json = JSON.stringify({ + label: 'Multisig', + blockheight: 649459, + descriptor: + 'wsh(sortedmulti(2,[1104442d/48h/0h/0h/2h]xpub6ERaLLFZ3qu7X4cpiMAvSZ6UZVXJfxY5FoNvVJgai1V78DmeNHTcNVfu4cK2RmvTNXU4s1tFpGMPTwqoQ1RraE2o9iiNw2s2aHESpandSFY/0/*,[8cce63f8/48h/0h/0h/2h]xpub6FCSLcRY99737oUAnvXd1k2gSz9P4zi4gQJ8UChSPSCxCK7XS9kLzoLHKNBiR26d3ivT7w3oka9f4BepVLoQ875XzgejjbDo626R6NBUJDW/0/*,[bf27bd7b/48h/0h/0h/2h]xpub6FE9uTPh1RxPRAfFVaET75vdfdQzXKZrT7LxukkqY4KhwUm4haMSPCwERfPouG6da6uZTRCXettvYFDck7nbw6JdBztGr1VBLonWch7NpJo/0/*))#erxvm6x2', + }); + const w = new MultisigHDWallet(); + w.setSecret(json); + assert.strictEqual(w._getExternalAddressByIndex(0), 'bc1q338rmdygx0weah4pdrp9xyycxlv2t48276gk3gxmg6m7xdkkglsqgzm6mz'); + assert.strictEqual(w._getInternalAddressByIndex(0), 'bc1qcgn73pjlwtt6krs2u6as0kh2jp486fa0t93yyq4d7xxxc37rf24qg67ewq'); + assert.strictEqual(w.getM(), 2); + assert.strictEqual(w.getN(), 3); + assert.ok(!w.isWrappedSegwit()); + assert.ok(w.isNativeSegwit()); + assert.ok(!w.isLegacy()); + }); +});