diff --git a/blue_modules/ur/index.js b/blue_modules/ur/index.js index 8f379a961..d17e99a06 100644 --- a/blue_modules/ur/index.js +++ b/blue_modules/ur/index.js @@ -228,41 +228,58 @@ class BlueURDecoder extends URDecoder { if (decoded.type === 'crypto-account') { const cryptoAccount = CryptoAccount.fromCBOR(decoded.cbor); - // now, crafting zpub out of data we have - const hdKey = cryptoAccount.outputDescriptors[0].getCryptoKey(); - const derivationPath = 'm/' + hdKey.getOrigin().getPath(); - const script = cryptoAccount.outputDescriptors[0].getScriptExpressions()[0].getExpression(); - const isMultisig = - script === ScriptExpressions.WITNESS_SCRIPT_HASH.getExpression() || - // fallback to paths (unreliable). - // dont know how to add ms p2sh (legacy) or p2sh-p2wsh (wrapped segwit) atm - derivationPath === MultisigHDWallet.PATH_LEGACY || - derivationPath === MultisigHDWallet.PATH_WRAPPED_SEGWIT || - derivationPath === MultisigHDWallet.PATH_NATIVE_SEGWIT; - const version = Buffer.from(isMultisig ? '02aa7ed3' : '04b24746', 'hex'); - const parentFingerprint = hdKey.getParentFingerprint(); - const depth = hdKey.getOrigin().getDepth(); - const depthBuf = Buffer.alloc(1); - depthBuf.writeUInt8(depth); - const components = hdKey.getOrigin().getComponents(); - const lastComponents = components[components.length - 1]; - const index = lastComponents.isHardened() ? lastComponents.getIndex() + 0x80000000 : lastComponents.getIndex(); - const indexBuf = Buffer.alloc(4); - indexBuf.writeUInt32BE(index); - const chainCode = hdKey.getChainCode(); - const key = hdKey.getKey(); - const data = Buffer.concat([version, depthBuf, parentFingerprint, indexBuf, chainCode, key]); + const results = []; + for (const outputDescriptor of cryptoAccount.outputDescriptors) { + // now, crafting zpub out of data we have + const hdKey = outputDescriptor.getCryptoKey(); + const derivationPath = 'm/' + hdKey.getOrigin().getPath(); + const script = cryptoAccount.outputDescriptors[0].getScriptExpressions()[0].getExpression(); + const isMultisig = + script === ScriptExpressions.WITNESS_SCRIPT_HASH.getExpression() || + // fallback to paths (unreliable). + // dont know how to add ms p2sh (legacy) or p2sh-p2wsh (wrapped segwit) atm + derivationPath === MultisigHDWallet.PATH_LEGACY || + derivationPath === MultisigHDWallet.PATH_WRAPPED_SEGWIT || + derivationPath === MultisigHDWallet.PATH_NATIVE_SEGWIT; + const version = Buffer.from(isMultisig ? '02aa7ed3' : '04b24746', 'hex'); + const parentFingerprint = hdKey.getParentFingerprint(); + const depth = hdKey.getOrigin().getDepth(); + const depthBuf = Buffer.alloc(1); + depthBuf.writeUInt8(depth); + const components = hdKey.getOrigin().getComponents(); + const lastComponents = components[components.length - 1]; + const index = lastComponents.isHardened() ? lastComponents.getIndex() + 0x80000000 : lastComponents.getIndex(); + const indexBuf = Buffer.alloc(4); + indexBuf.writeUInt32BE(index); + const chainCode = hdKey.getChainCode(); + const key = hdKey.getKey(); + const data = Buffer.concat([version, depthBuf, parentFingerprint, indexBuf, chainCode, key]); - const zpub = b58.encode(data); + const zpub = b58.encode(data); - const result = {}; - result.ExtPubKey = zpub; - result.MasterFingerprint = cryptoAccount.getMasterFingerprint().toString('hex').toUpperCase(); - result.AccountKeyPath = derivationPath; + const result = {}; + result.ExtPubKey = zpub; + result.MasterFingerprint = cryptoAccount.getMasterFingerprint().toString('hex').toUpperCase(); + result.AccountKeyPath = derivationPath; - const str = JSON.stringify(result); - return str; - // return Buffer.from(str, 'ascii').toString('hex'); // we are expected to return hex-encoded string + if (derivationPath.startsWith("m/49'/0'/")) { + // converting to ypub + let data = b58.decode(result.ExtPubKey); + data = data.slice(4); + result.ExtPubKey = b58.encode(Buffer.concat([Buffer.from('049d7cb2', 'hex'), data])); + } + + if (derivationPath.startsWith("m/44'/0'/")) { + // converting to xpub + let data = b58.decode(result.ExtPubKey); + data = data.slice(4); + result.ExtPubKey = b58.encode(Buffer.concat([Buffer.from('0488b21e', 'hex'), data])); + } + + results.push(result); + } + + return JSON.stringify(results); } throw new Error('unsupported data format'); diff --git a/class/wallet-import.js b/class/wallet-import.js index deee8d4eb..a36371ea9 100644 --- a/class/wallet-import.js +++ b/class/wallet-import.js @@ -81,6 +81,7 @@ const startImport = (importTextOrig, askPassphrase = false, searchAccounts = fal // 6. check if its address (watch-only wallet) // 7. check if its private key (segwit address P2SH) TODO // 7. check if its private key (legacy address) TODO + // 8. check if its a json array from BC-UR with multiple accounts let text = importTextOrig.trim(); let password; @@ -383,6 +384,22 @@ const startImport = (importTextOrig, askPassphrase = false, searchAccounts = fal yield { wallet: s3 }; } } + + // is it BC-UR payload with multiple accounts? + yield { progress: 'BC-UR' }; + try { + const json = JSON.parse(text); + if (Array.isArray(json)) { + for (const account of json) { + if (account.ExtPubKey && account.MasterFingerprint && account.AccountKeyPath) { + const wallet = new WatchOnlyWallet(); + wallet.setSecret(JSON.stringify(account)); + wallet.init(); + yield { wallet }; + } + } + } + } catch (_) {} } // POEHALI diff --git a/tests/integration/import.test.js b/tests/integration/import.test.js index 2fe26c164..d7d8d7114 100644 --- a/tests/integration/import.test.js +++ b/tests/integration/import.test.js @@ -397,6 +397,41 @@ describe('import procedure', () => { await promise; assert.strictEqual(store.state.wallets[0].type, WatchOnlyWallet.type); assert.strictEqual(store.state.wallets[0].getDerivationPath(), "m/84'/0'/0'"); + assert.strictEqual(store.state.wallets[0].getMasterFingerprintHex(), '7d2f0272'); + }); + + it('can import watch-only Cobo vault export', async () => { + const store = createStore(); + const { promise } = startImport( + `[{"ExtPubKey":"zpub6rFR7y4Q2AijBEqTUquhVz398htDFrtymD9xYYfG1m4wAcvPhXNfE3EfH1r1ADqtfSdVCToUG868RvUUkgDKf31mGDtKsAYz2oz2AGutZYs","MasterFingerprint":"73C5DA0A","AccountKeyPath":"m/84'/0'/0'"},{"ExtPubKey":"ypub6Ww3ibxVfGzLrAH1PNcjyAWenMTbbAosGNB6VvmSEgytSER9azLDWCxoJwW7Ke7icmizBMXrzBx9979FfaHxHcrArf3zbeJJJUZPf663zsP","MasterFingerprint":"73C5DA0A","AccountKeyPath":"m/49'/0'/0'"},{"ExtPubKey":"xpub6BosfCnifzxcFwrSzQiqu2DBVTshkCXacvNsWGYJVVhhawA7d4R5WSWGFNbi8Aw6ZRc1brxMyWMzG3DSSSSoekkudhUd9yLb6qx39T9nMdj","MasterFingerprint":"73C5DA0A","AccountKeyPath":"m/44'/0'/0'"}]`, + false, + false, + ...store.callbacks, + ); + await promise; + assert.strictEqual(store.state.wallets[0].type, WatchOnlyWallet.type); + assert.strictEqual(store.state.wallets[0].getDerivationPath(), "m/84'/0'/0'"); + assert.strictEqual(store.state.wallets[0].getMasterFingerprintHex(), '73c5da0a'); + assert.strictEqual( + store.state.wallets[0].getSecret(), + 'zpub6rFR7y4Q2AijBEqTUquhVz398htDFrtymD9xYYfG1m4wAcvPhXNfE3EfH1r1ADqtfSdVCToUG868RvUUkgDKf31mGDtKsAYz2oz2AGutZYs', + ); + + assert.strictEqual(store.state.wallets[1].type, WatchOnlyWallet.type); + assert.strictEqual(store.state.wallets[1].getDerivationPath(), "m/49'/0'/0'"); + assert.strictEqual(store.state.wallets[1].getMasterFingerprintHex(), '73c5da0a'); + assert.strictEqual( + store.state.wallets[1].getSecret(), + 'ypub6Ww3ibxVfGzLrAH1PNcjyAWenMTbbAosGNB6VvmSEgytSER9azLDWCxoJwW7Ke7icmizBMXrzBx9979FfaHxHcrArf3zbeJJJUZPf663zsP', + ); + + assert.strictEqual(store.state.wallets[2].type, WatchOnlyWallet.type); + assert.strictEqual(store.state.wallets[2].getDerivationPath(), "m/44'/0'/0'"); + assert.strictEqual(store.state.wallets[2].getMasterFingerprintHex(), '73c5da0a'); + assert.strictEqual( + store.state.wallets[2].getSecret(), + 'xpub6BosfCnifzxcFwrSzQiqu2DBVTshkCXacvNsWGYJVVhhawA7d4R5WSWGFNbi8Aw6ZRc1brxMyWMzG3DSSSSoekkudhUd9yLb6qx39T9nMdj', + ); }); it('can import watch-only Keystone vault export', async () => { diff --git a/tests/unit/watch-only-wallet.test.js b/tests/unit/watch-only-wallet.test.js index d1e164e97..6da1055c1 100644 --- a/tests/unit/watch-only-wallet.test.js +++ b/tests/unit/watch-only-wallet.test.js @@ -577,6 +577,34 @@ describe('BC-UR', () => { assert.ok(str.includes('Keystone Multisig setup file')); }); + it('v2: can decodeUR() into accounts', () => { + const decoder = new BlueURDecoder(); + decoder.receivePart( + 'UR:CRYPTO-ACCOUNT/OEADCYJKSKTNBKAOLSTAADMWTAADDLONAXHDCLAOJOKNIDZCPSSAJTPTRPFRCECFKKAMYKJTVTCSBTBDTKCFIYVYOETNEEYKWFNBNYNDAAHDCXGEGUNBPYCLRHUOMDLNNSGLMOOYHSCFGLAXRTWSFHYKADGESWMOWKEOSSKOGHMHZTAMTAADDYOTADLNCSGHYKAEYKAEYKAOCYJKSKTNBKAXAXATTAADDYOYADLRAEWKLAWKAYCYKBWFDNUYTAADMHTAADMWTAADDLONAXHDCLAOWNWFFLLDCWCXYLHFMNPLFMSOLNNERSRPKGSGRPWFHDEYJLBEWPSSCNHFRYGOMUNTAAHDCXJTPKVLIHPLBABKBKPYLREYHHZEKETSJZFRMHMHECYALDVDTEWNROFLPTNBKKKBSBAMTAADDYOTADLNCSEHYKAEYKAEYKAOCYJKSKTNBKAXAXATTAADDYOYADLRAEWKLAWKAYCYFSAHZMKPTAADMUTAADDLONAXHDCLAXKTGSMEBSTKATZSMTLOJTOSMWWTTLSGWENYZEDYQZGRLSYLVOBWRKMOMUBAKIWKRYAAHDCXFSOXRFCFBKDSLABYCAEHZSURUOMHHEDRLBJZVDKEJLBENLCFBYJLDAFSFXFYGMCFAMTAADDYOTADLNCSDWYKAEYKAEYKAOCYJKSKTNBKAXAXATTAADDYOYADLRAEWKLAWKAYCYBZHPSGHKSOPAJSLN', + ); + let data = ''; + if (decoder.isComplete()) { + data = decoder.toString(); + } + + const json = JSON.parse(data); + + assert.ok(Array.isArray(json)); + assert.strictEqual(json.length, 3); + + assert.ok(json[0].ExtPubKey.startsWith('zpub')); + assert.ok(json[0].AccountKeyPath.startsWith('m/84')); + assert.ok(json[0].MasterFingerprint === '73C5DA0A'); + + assert.ok(json[1].ExtPubKey.startsWith('ypub')); + assert.ok(json[1].AccountKeyPath.startsWith('m/49')); + assert.ok(json[1].MasterFingerprint === '73C5DA0A'); + + assert.ok(json[2].ExtPubKey.startsWith('xpub')); + assert.ok(json[2].AccountKeyPath.startsWith('m/44')); + assert.ok(json[2].MasterFingerprint === '73C5DA0A'); + }); + it('v1: decodeUR() works', async () => { await new Promise(resolve => setTimeout(resolve, 1000)); // sleep // sleep is needed because in test envirnment setUseURv1() and init function have a race condition