From a28ffa272a3c4d90d0273d9aa223a23becc08e0e Mon Sep 17 00:00:00 2001 From: Mark Tyneway Date: Mon, 18 Mar 2019 22:45:00 -0700 Subject: [PATCH] wallet rpc: add getaddressinfo rpc command update the rpc command to better match bitcoind. add the ismine and iswatchonly fields and segwit related fields such as witness_version and witness_program. --- lib/wallet/rpc.js | 35 +++++ test/http-test.js | 54 ++++---- test/wallet-rpc-test.js | 275 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 335 insertions(+), 29 deletions(-) create mode 100644 test/wallet-rpc-test.js diff --git a/lib/wallet/rpc.js b/lib/wallet/rpc.js index d14ccc36..1dd9ccec 100644 --- a/lib/wallet/rpc.js +++ b/lib/wallet/rpc.js @@ -129,6 +129,7 @@ class RPC extends RPCBase { this.add('dumpprivkey', this.dumpPrivKey); this.add('dumpwallet', this.dumpWallet); this.add('encryptwallet', this.encryptWallet); + this.add('getaddressinfo', this.getAddressInfo); this.add('getaccountaddress', this.getAccountAddress); this.add('getaccount', this.getAccount); this.add('getaddressesbyaccount', this.getAddressesByAccount); @@ -443,6 +444,40 @@ class RPC extends RPCBase { return addrs; } + async getAddressInfo(args, help) { + if (help || args.length !== 1) + throw new RPCError(errs.MISC_ERROR, 'getaddressinfo "address"'); + + const valid = new Validator(args); + const addr = valid.str(0, ''); + + const address = parseAddress(addr, this.network); + const script = Script.fromAddress(address); + const wallet = this.wallet.toJSON(); + + const path = await this.wallet.getPath(address); + + const isScript = script.isScripthash() || script.isWitnessScripthash(); + const isWitness = address.isProgram(); + + const result = { + address: address.toString(this.network), + scriptPubKey: script ? script.toJSON() : undefined, + ismine: path != null, + ischange: path ? path.branch === 1 : false, + iswatchonly: wallet.watchOnly, + isscript: isScript, + iswitness: isWitness + }; + + if (isWitness) { + result.witness_version = address.version; + result.witness_program = address.hash.toString('hex'); + } + + return result; + } + async getBalance(args, help) { if (help || args.length > 3) { throw new RPCError(errs.MISC_ERROR, diff --git a/test/http-test.js b/test/http-test.js index f464950a..e16d4623 100644 --- a/test/http-test.js +++ b/test/http-test.js @@ -56,6 +56,14 @@ let hash = null; describe('HTTP', function() { this.timeout(15000); + // m/44'/1'/0'/0/{0,1} + const pubkeys = [ + Buffer.from('02a7451395735369f2ecdfc829c0f' + + '774e88ef1303dfe5b2f04dbaab30a535dfdd6', 'hex'), + Buffer.from('03589ae7c835ce76e23cf8feb32f1a' + + 'df4a7f2ba0ed2ad70801802b0bcd70e99c1c', 'hex') + ]; + it('should open node', async () => { consensus.COINBASE_MATURITY = 0; await node.open(); @@ -273,29 +281,32 @@ describe('HTTP', function() { }); it('should not validate invalid address', async () => { - // send an address for the wrong network + // Valid Mainnet P2WPKH from + // https://github.com/bitcoin/bips/blob/master/bip-0173.mediawiki const json = await nclient.execute('validateaddress', [ - addr.toString('main') + 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4' ]); + + // Sending an address from the incorrect network + // should result in an invalid address assert.deepStrictEqual(json, { isvalid: false }); }); it('should validate a p2wpkh address', async () => { - const info = await wallet.createAccount('foo3', { - witness: true - }); - const json = await nclient.execute('validateaddress', [ - info.receiveAddress - ]); - const addr = Address.fromString(info.receiveAddress); + const address = 'bcrt1q8gk5z3dy7zv9ywe7synlrk58elz4hrnegvpv6m'; + const addr = Address.fromString(address); const script = Script.fromAddress(addr); + const json = await nclient.execute('validateaddress', [ + address + ]); + assert.deepStrictEqual(json, { isvalid: true, iswitness: true, - address: info.receiveAddress, + address: address, isscript: addr.isScripthash(), scriptPubKey: script.toJSON(), witness_version: addr.version, @@ -304,18 +315,11 @@ describe('HTTP', function() { }); it('should validate a p2sh address', async () => { - await wallet.createAccount('foo4'); - - const pubkeys = []; - for (let i = 0; i < 2; i++) { - const result = await wallet.createAddress('foo4', 'default'); - pubkeys.push(Buffer.from(result.publicKey, 'hex')); - } - const script = Script.fromMultisig(2, 2, pubkeys); const address = Address.fromScript(script); - // test the valid case + // Test the valid case - render the address to the + // correct network { const json = await nclient.execute('validateaddress', [ address.toString(node.network) @@ -330,7 +334,8 @@ describe('HTTP', function() { }); } - // test the invalid case + // Test the invalid case - render the address to the + // incorrect network, making it an invalid address { const json = await nclient.execute('validateaddress', [ address.toString('main') @@ -343,15 +348,6 @@ describe('HTTP', function() { }); it('should validate a p2wsh address', async () => { - await wallet.createAccount('foo5', { - witness: true - }); - - const pubkeys = []; - for (let i = 0; i < 2; i++) { - const result = await wallet.createAddress('foo5', 'default'); - pubkeys.push(Buffer.from(result.publicKey, 'hex')); - } const script = Script.fromMultisig(2, 2, pubkeys); const scriptPubKey = script.forWitness(); const program = script.sha256(); diff --git a/test/wallet-rpc-test.js b/test/wallet-rpc-test.js new file mode 100644 index 00000000..3ffd4b31 --- /dev/null +++ b/test/wallet-rpc-test.js @@ -0,0 +1,275 @@ +/* eslint-env mocha */ + +'use strict'; + +const {NodeClient, WalletClient} = require('bclient'); +const assert = require('./util/assert'); +const FullNode = require('../lib/node/fullnode'); +const Network = require('../lib/protocol/network'); +const Mnemonic = require('../lib/hd/mnemonic'); +const HDPrivateKey = require('../lib/hd/private'); +const Script = require('../lib/script/script'); +const Address = require('../lib/primitives/address'); +const network = Network.get('regtest'); +const mnemonics = require('./data/mnemonic-english.json'); +// Commonly used test mnemonic +const phrase = mnemonics[0][1]; + +const ports = { + p2p: 49331, + node: 49332, + wallet: 49333 +}; + +const node = new FullNode({ + network: network.type, + apiKey: 'bar', + walletAuth: true, + memory: true, + workers: true, + plugins: [require('../lib/wallet/plugin')], + port: ports.p2p, + httpPort: ports.node, + env: { + 'BCOIN_WALLET_HTTP_PORT': ports.wallet.toString() + } +}); + +const nclient = new NodeClient({ + port: ports.node, + apiKey: 'bar' +}); + +const wclient = new WalletClient({ + port: ports.wallet, + apiKey: 'bar' +}); + +describe('Wallet RPC Methods', function() { + this.timeout(15000); + + // Define an account level hd extended public key to be + // used to derive addresses throughout the test suite + let xpub; + + before(async () => { + await node.open(); + await nclient.open(); + await wclient.open(); + + // Derive the xpub using the well known + // mnemonic and network's coin type + const mnemonic = Mnemonic.fromPhrase(phrase); + const priv = HDPrivateKey.fromMnemonic(mnemonic); + const type = network.keyPrefix.coinType; + const key = priv.derive(44, true).derive(type, true).derive(0, true); + + xpub = key.toPublic(); + + // Assert that the expected test phrase was + // read from disk + assert.equal(phrase, [ + 'abandon', 'abandon', 'abandon', 'abandon', + 'abandon', 'abandon', 'abandon', 'abandon', + 'abandon', 'abandon', 'abandon', 'about' + ].join(' ')); + }); + + after(async () => { + await nclient.close(); + await wclient.close(); + await node.close(); + }); + + describe('getaddressinfo', () => { + const watchOnlyWalletId = 'foo'; + const standardWalletId = 'bar'; + + // m/44'/1'/0'/0/{0,1} + const pubkeys = [ + Buffer.from('02a7451395735369f2ecdfc829c0f' + + '774e88ef1303dfe5b2f04dbaab30a535dfdd6', 'hex'), + Buffer.from('03589ae7c835ce76e23cf8feb32f1a' + + 'df4a7f2ba0ed2ad70801802b0bcd70e99c1c', 'hex') + ]; + + // set up the initial testing state + before(async () => { + { + // Set up the testing environment + // by creating a wallet and a watch + // only wallet + const info = await nclient.getInfo(); + assert.equal(info.chain.height, 0); + } + + { + // Create a watch only wallet using the path + // m/44'/1'/0' and assert that the wallet + // was properly created + const accountKey = xpub.xpubkey(network.type); + const response = await wclient.createWallet(watchOnlyWalletId, { + watchOnly: true, + accountKey: accountKey + }); + + assert.equal(response.id, watchOnlyWalletId); + + const wallet = wclient.wallet(watchOnlyWalletId); + const info = await wallet.getAccount('default'); + assert.equal(info.accountKey, accountKey); + assert.equal(info.watchOnly, true); + } + + { + // Create a wallet that manages the private keys itself + const response = await wclient.createWallet(standardWalletId); + assert.equal(response.id, standardWalletId); + + const info = await wclient.getAccount(standardWalletId, 'default'); + assert.equal(info.watchOnly, false); + }; + }); + + // The rpc interface requires the wallet to be selected first + it('should return iswatchonly correctly', async () => { + // m/44'/1'/0'/0/0 + const receive = 'mkpZhYtJu2r87Js3pDiWJDmPte2NRZ8bJV'; + + { + await wclient.execute('selectwallet', [standardWalletId]); + const response = await wclient.execute('getaddressinfo', [receive]); + assert.equal(response.iswatchonly, false); + } + { + await wclient.execute('selectwallet', [watchOnlyWalletId]); + const response = await wclient.execute('getaddressinfo', [receive]); + assert.equal(response.iswatchonly, true); + } + }); + + it('should return the correct address', async () => { + // m/44'/1'/0'/0/0 + const receive = 'mkpZhYtJu2r87Js3pDiWJDmPte2NRZ8bJV'; + + await wclient.execute('selectwallet', [watchOnlyWalletId]); + const response = await wclient.execute('getaddressinfo', [receive]); + assert.equal(response.address, receive); + }); + + it('should detect owned address', async () => { + // m/44'/1'/0'/0/0 + const receive = 'mkpZhYtJu2r87Js3pDiWJDmPte2NRZ8bJV'; + { + await wclient.execute('selectwallet', [watchOnlyWalletId]); + const response = await wclient.execute('getaddressinfo', [receive]); + assert.equal(response.ismine, true); + } + { + await wclient.execute('selectwallet', [standardWalletId]); + const response = await wclient.execute('getaddressinfo', [receive]); + assert.equal(response.ismine, false); + } + }); + + it('should detect a p2sh address', async () => { + const script = Script.fromMultisig(2, 2, pubkeys); + const address = Address.fromScript(script); + const addr = address.toString(network); + const response = await wclient.execute('getaddressinfo', [addr]); + + assert.equal(response.isscript, true); + assert.equal(response.iswitness, false); + assert.equal(response.witness_program, undefined); + }); + + it('should return the correct program for a p2wpkh address', async () => { + // m/44'/1'/0'/0/5 + const receive = 'bcrt1q53724q6cywuzsvq5e3nvdeuwrepu69jsc6ulmx'; + const addr = Address.fromString(receive); + + await wclient.execute('selectwallet', [watchOnlyWalletId]); + const str = addr.toString(network); + const response = await wclient.execute('getaddressinfo', [str]); + assert.equal(response.witness_program, addr.hash.toString('hex')); + }); + + it('should detect p2wsh program', async () => { + const script = Script.fromMultisig(2, 2, pubkeys); + const address = Address.fromWitnessScripthash(script.sha256()); + const addr = address.toString(network); + const response = await wclient.execute('getaddressinfo', [addr]); + + assert.equal(response.isscript, true); + assert.equal(response.iswitness, true); + assert.equal(response.witness_program, address.hash.toString('hex')); + }); + + it('should detect ismine up to the lookahead', async () => { + const info = await wclient.getAccount(watchOnlyWalletId, 'default'); + await wclient.execute('selectwallet', [watchOnlyWalletId]); + + // m/44'/1'/0' + const addresses = [ + 'mkpZhYtJu2r87Js3pDiWJDmPte2NRZ8bJV', // /0/0 + 'mzpbWabUQm1w8ijuJnAof5eiSTep27deVH', // /0/1 + 'mnTkxhNkgx7TsZrEdRcPti564yQTzynGJp', // /0/2 + 'mpW3iVi2Td1vqDK8Nfie29ddZXf9spmZkX', // /0/3 + 'n2BMo5arHDyAK2CM8c56eoEd18uEkKnRLC', // /0/4 + 'mvWgTTtQqZohUPnykucneWNXzM5PLj83an', // /0/5 + 'muTU2Av1EwnsyhieQhyPL7hgEf883LR4xg', // /0/6 + 'mwduZ8Ksa563v7rWdSPmqyKR4y2FeB5g8p', // /0/7 + 'miyBE85ro5zt9RseSzYVEbB3TfzkxgSm8C', // /0/8 + 'mnYwW7mU3jajB11vrpDZwZDrXwVfE5Jc31', // /0/9 + 'mx3YNRT8Vg8QwFq5Z5MAVDDVHp4ihHsffn' // /0/10 + ]; + + // Assert that the lookahead is configured as expected + // subtract one from addresses.length, it is 0 indexed + assert.equal(addresses.length - 1, info.lookahead); + + // Each address through the lookahead number should + // be recognized as an owned address + for (let i = 0; i <= info.lookahead; i++) { + const address = addresses[i]; + const response = await wclient.execute('getaddressinfo', [address]); + assert.equal(response.ismine, true); + } + + // m/44'/1'/0'/0/11 + // This address is outside of the lookahead range + const failed = 'myHL2QuECVYkx9Y94gyC6RSweLNnteETsB'; + + const response = await wclient.execute('getaddressinfo', [failed]); + assert.equal(response.ismine, false); + }); + + it('should detect change addresses', async () => { + // m/44'/1'/0'/1/0 + const address = 'mi8nhzZgGZQthq6DQHbru9crMDerUdTKva'; + const info = await wclient.execute('getaddressinfo', [address]); + + assert.equal(info.ischange, true); + }); + + it('should throw for the wrong network', async () => { + // m/44'/1'/0'/0/0 + const failed = '16JcQVoL61QsLCPS6ek8UJZ52eRfaFqLJt'; + + // Match the bitcoind response when sending the incorrect + // network. Expect an RPC error + const fn = async () => await wclient.execute('getaddressinfo', [failed]); + await assert.asyncThrows(fn, 'Invalid address.'); + }); + + it('should fail for invalid address', async () => { + // m/44'/1'/0'/0/0 + let failed = '16JcQVoL61QsLCPS6ek8UJZ52eRfaFqLJt'; + // remove the first character + failed = failed.slice(1, failed.length); + + const fn = async () => await wclient.execute('getaddressinfo', [failed]); + await assert.asyncThrows(fn, 'Invalid address.'); + }); + }); +});