Skip to content

Commit

Permalink
ADD: support multiple accounts from Keystone hw wallet (closes BlueWa…
Browse files Browse the repository at this point in the history
  • Loading branch information
Overtorment committed May 9, 2023
1 parent eac4c1c commit c23dbfe
Show file tree
Hide file tree
Showing 4 changed files with 132 additions and 35 deletions.
87 changes: 52 additions & 35 deletions blue_modules/ur/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 zpub = b58.encode(data);

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
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 result = {};
result.ExtPubKey = zpub;
result.MasterFingerprint = cryptoAccount.getMasterFingerprint().toString('hex').toUpperCase();
result.AccountKeyPath = derivationPath;

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');
Expand Down
17 changes: 17 additions & 0 deletions class/wallet-import.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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
Expand Down
35 changes: 35 additions & 0 deletions tests/integration/import.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
28 changes: 28 additions & 0 deletions tests/unit/watch-only-wallet.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

0 comments on commit c23dbfe

Please sign in to comment.