diff --git a/lib/wallet/txdb.js b/lib/wallet/txdb.js index d23372328..a22a1c80b 100644 --- a/lib/wallet/txdb.js +++ b/lib/wallet/txdb.js @@ -2250,6 +2250,20 @@ class TXDB { }); } + /** + * Test whether an account owns a coin. + * @param {Number} acct + * @param {Hash} hash + * @param {Index} number + * @returns {Promise} - Returns Boolean. + */ + + hasCoinByAccount(acct, hash, index) { + assert(typeof acct === 'number'); + + return this.bucket.has(layout.C.encode(acct, hash, index)); + } + /** * Get hashes of all transactions in the database. * @param {Number} acct diff --git a/lib/wallet/wallet.js b/lib/wallet/wallet.js index c25e0b2a8..cf78b81ac 100644 --- a/lib/wallet/wallet.js +++ b/lib/wallet/wallet.js @@ -1747,12 +1747,18 @@ class Wallet extends EventEmitter { /** * Make a reveal MTX. * @param {String} name + * @param {(Number|String)?} acct * @returns {MTX} */ - async makeReveal(name) { + async makeReveal(name, acct) { assert(typeof name === 'string'); + if (acct != null) { + assert((acct >>> 0) === acct || typeof acct === 'string'); + acct = await this.getAccountIndex(acct); + } + if (!rules.verifyName(name)) throw new Error('Invalid name.'); @@ -1789,6 +1795,9 @@ class Wallet extends EventEmitter { if (!coin) continue; + if (acct != null && !await this.txdb.hasCoinByAccount(acct, hash, index)) + continue; + // Is local? if (coin.height < ns.height) continue; @@ -1828,7 +1837,8 @@ class Wallet extends EventEmitter { */ async _createReveal(name, options) { - const mtx = await this.makeReveal(name); + const acct = options ? options.account : null; + const mtx = await this.makeReveal(name, acct); await this.fill(mtx, options); return this.finalize(mtx, options); } @@ -2000,15 +2010,21 @@ class Wallet extends EventEmitter { /** * Make a redeem MTX. * @param {String} name + * @param {(Number|String)?} acct * @returns {MTX} */ - async makeRedeem(name) { + async makeRedeem(name, acct) { assert(typeof name === 'string'); if (!rules.verifyName(name)) throw new Error('Invalid name.'); + if (acct != null) { + assert((acct >>> 0) === acct || typeof acct === 'string'); + acct = await this.getAccountIndex(acct); + } + const rawName = Buffer.from(name, 'ascii'); const nameHash = rules.hashName(rawName); const ns = await this.getNameState(nameHash); @@ -2036,6 +2052,7 @@ class Wallet extends EventEmitter { if (!own) continue; + // Winner can not redeem if (prevout.equals(ns.owner)) continue; @@ -2044,6 +2061,9 @@ class Wallet extends EventEmitter { if (!coin) continue; + if (acct != null && !await this.txdb.hasCoinByAccount(acct, hash, index)) + continue; + // Is local? if (coin.height < ns.height) continue; @@ -2075,7 +2095,8 @@ class Wallet extends EventEmitter { */ async _createRedeem(name, options) { - const mtx = await this.makeRedeem(name); + const acct = options ? options.account : null; + const mtx = await this.makeRedeem(name, acct); await this.fill(mtx, options); return this.finalize(mtx, options); } @@ -2325,16 +2346,22 @@ class Wallet extends EventEmitter { * Make an update MTX. * @param {String} name * @param {Resource} resource + * @param {(Number|String)?} acct * @returns {MTX} */ - async makeUpdate(name, resource) { + async makeUpdate(name, resource, acct) { assert(typeof name === 'string'); assert(resource instanceof Resource); if (!rules.verifyName(name)) throw new Error('Invalid name.'); + if (acct != null) { + assert((acct >>> 0) === acct || typeof acct === 'string'); + acct = await this.getAccountIndex(acct); + } + const rawName = Buffer.from(name, 'ascii'); const nameHash = rules.hashName(rawName); const ns = await this.getNameState(nameHash); @@ -2350,6 +2377,9 @@ class Wallet extends EventEmitter { if (!coin) throw new Error(`Wallet does not own: "${name}".`); + if (acct != null && !await this.txdb.hasCoinByAccount(acct, hash, index)) + throw new Error(`Account does not own: "${name}".`); + if (coin.covenant.isReveal() || coin.covenant.isClaim()) return this._makeRegister(name, resource); @@ -2402,7 +2432,8 @@ class Wallet extends EventEmitter { */ async _createUpdate(name, resource, options) { - const mtx = await this.makeUpdate(name, resource); + const acct = options ? options.account : null; + const mtx = await this.makeUpdate(name, resource, acct); await this.fill(mtx, options); return this.finalize(mtx, options); } @@ -2460,16 +2491,21 @@ class Wallet extends EventEmitter { * Make a renewal MTX. * @private * @param {String} name - * @param {Resource?} resource + * @param {(Number|String)?} acct * @returns {MTX} */ - async makeRenewal(name) { + async makeRenewal(name, acct) { assert(typeof name === 'string'); if (!rules.verifyName(name)) throw new Error('Invalid name.'); + if (acct != null) { + assert((acct >>> 0) === acct || typeof acct === 'string'); + acct = await this.getAccountIndex(acct); + } + const rawName = Buffer.from(name, 'ascii'); const nameHash = rules.hashName(rawName); const ns = await this.getNameState(nameHash); @@ -2492,6 +2528,9 @@ class Wallet extends EventEmitter { if (coin.height < ns.height) throw new Error(`Wallet does not own: "${name}".`); + if (acct != null && !await this.txdb.hasCoinByAccount(acct, hash, index)) + throw new Error(`Account does not own: "${name}".`); + const state = ns.state(height, network); if (state !== states.CLOSED) @@ -2531,7 +2570,8 @@ class Wallet extends EventEmitter { */ async _createRenewal(name, options) { - const mtx = await this.makeRenewal(name); + const acct = options ? options.account : null; + const mtx = await this.makeRenewal(name, acct); await this.fill(mtx, options); return this.finalize(mtx, options); } @@ -2586,16 +2626,22 @@ class Wallet extends EventEmitter { * Make a transfer MTX. * @param {String} name * @param {Address} address + * @param {(Number|String)?} acct * @returns {MTX} */ - async makeTransfer(name, address) { + async makeTransfer(name, address, acct) { assert(typeof name === 'string'); assert(address instanceof Address); if (!rules.verifyName(name)) throw new Error('Invalid name.'); + if (acct != null) { + assert((acct >>> 0) === acct || typeof acct === 'string'); + acct = await this.getAccountIndex(acct); + } + const rawName = Buffer.from(name, 'ascii'); const nameHash = rules.hashName(rawName); const ns = await this.getNameState(nameHash); @@ -2618,6 +2664,9 @@ class Wallet extends EventEmitter { if (coin.height < ns.height) throw new Error(`Wallet does not own: "${name}".`); + if (acct != null && !await this.txdb.hasCoinByAccount(acct, hash, index)) + throw new Error(`Account does not own: "${name}".`); + const state = ns.state(height, network); if (state !== states.CLOSED) @@ -2656,7 +2705,8 @@ class Wallet extends EventEmitter { */ async _createTransfer(name, address, options) { - const mtx = await this.makeTransfer(name, address); + const acct = options ? options.account : null; + const mtx = await this.makeTransfer(name, address, acct); await this.fill(mtx, options); return this.finalize(mtx, options); } @@ -2718,15 +2768,21 @@ class Wallet extends EventEmitter { * Make a transfer-cancelling MTX. * @private * @param {String} name + * @param {(Number|String)?} acct * @returns {MTX} */ - async makeCancel(name) { + async makeCancel(name, acct) { assert(typeof name === 'string'); if (!rules.verifyName(name)) throw new Error('Invalid name.'); + if (acct != null) { + assert((acct >>> 0) === acct || typeof acct === 'string'); + acct = await this.getAccountIndex(acct); + } + const rawName = Buffer.from(name, 'ascii'); const nameHash = rules.hashName(rawName); const ns = await this.getNameState(nameHash); @@ -2749,6 +2805,9 @@ class Wallet extends EventEmitter { if (coin.height < ns.height) throw new Error(`Wallet does not own: "${name}".`); + if (acct != null && !await this.txdb.hasCoinByAccount(acct, hash, index)) + throw new Error(`Account does not own: "${name}".`); + const state = ns.state(height, network); if (state !== states.CLOSED) @@ -2781,7 +2840,8 @@ class Wallet extends EventEmitter { */ async _createCancel(name, options) { - const mtx = await this.makeCancel(name); + const acct = options ? options.account : null; + const mtx = await this.makeCancel(name, acct); await this.fill(mtx, options); return this.finalize(mtx, options); } @@ -2836,15 +2896,21 @@ class Wallet extends EventEmitter { * Make a transfer-finalizing MTX. * @private * @param {String} name + * @param {(Number|String)?} acct * @returns {MTX} */ - async makeFinalize(name) { + async makeFinalize(name, acct) { assert(typeof name === 'string'); if (!rules.verifyName(name)) throw new Error('Invalid name.'); + if (acct != null) { + assert((acct >>> 0) === acct || typeof acct === 'string'); + acct = await this.getAccountIndex(acct); + } + const rawName = Buffer.from(name, 'ascii'); const nameHash = rules.hashName(rawName); const ns = await this.getNameState(nameHash); @@ -2867,6 +2933,9 @@ class Wallet extends EventEmitter { if (coin.height < ns.height) throw new Error(`Wallet does not own: "${name}".`); + if (acct != null && !await this.txdb.hasCoinByAccount(acct, hash, index)) + throw new Error(`Account does not own: "${name}".`); + const state = ns.state(height, network); if (state !== states.CLOSED) @@ -2915,7 +2984,8 @@ class Wallet extends EventEmitter { */ async _createFinalize(name, options) { - const mtx = await this.makeFinalize(name); + const acct = options ? options.account : null; + const mtx = await this.makeFinalize(name, acct); await this.fill(mtx, options); return this.finalize(mtx, options); } @@ -2969,15 +3039,21 @@ class Wallet extends EventEmitter { /** * Make a revoke MTX. * @param {String} name + * @param {(Number|String)?} acct * @returns {MTX} */ - async makeRevoke(name) { + async makeRevoke(name, acct) { assert(typeof name === 'string'); if (!rules.verifyName(name)) throw new Error('Invalid name.'); + if (acct != null) { + assert((acct >>> 0) === acct || typeof acct === 'string'); + acct = await this.getAccountIndex(acct); + } + const rawName = Buffer.from(name, 'ascii'); const nameHash = rules.hashName(rawName); const ns = await this.getNameState(nameHash); @@ -2993,6 +3069,9 @@ class Wallet extends EventEmitter { if (!coin) throw new Error(`Wallet does not own: "${name}".`); + if (acct != null && !await this.txdb.hasCoinByAccount(acct, hash, index)) + throw new Error(`Account does not own: "${name}".`); + // Is local? if (coin.height < ns.height) throw new Error(`Wallet does not own: "${name}".`); @@ -3036,7 +3115,8 @@ class Wallet extends EventEmitter { */ async _createRevoke(name, options) { - const mtx = await this.makeRevoke(name); + const acct = options ? options.account : null; + const mtx = await this.makeRevoke(name, acct); await this.fill(mtx, options); return this.finalize(mtx, options); } @@ -3067,7 +3147,7 @@ class Wallet extends EventEmitter { async _sendRevoke(name, options) { const passphrase = options ? options.passphrase : null; - const mtx = await this._createRevoke(name); + const mtx = await this._createRevoke(name, options); return this.sendMTX(mtx, passphrase); } diff --git a/test/wallet-accounts-auction-test.js b/test/wallet-accounts-auction-test.js new file mode 100644 index 000000000..34515512b --- /dev/null +++ b/test/wallet-accounts-auction-test.js @@ -0,0 +1,402 @@ +/* eslint-env mocha */ +/* eslint prefer-arrow-callback: "off" */ + +'use strict'; + +const assert = require('bsert'); +const Network = require('../lib/protocol/network'); +const FullNode = require('../lib/node/fullnode'); +const Address = require('../lib/primitives/address'); +const rules = require('../lib/covenants/rules'); +const Resource = require('../lib/dns/resource'); +const {WalletClient} = require('hs-client'); + +const network = Network.get('regtest'); + +const node = new FullNode({ + memory: true, + network: 'regtest', + plugins: [require('../lib/wallet/plugin')] +}); + +// Prevent mempool from sending duplicate TXs back to the walletDB and txdb. +// This will prevent a race condition when we need to remove spent (but +// unconfirmed) outputs from the wallet so they can be reused in other tests. +node.mempool.emit = () => {}; + +const wclient = new WalletClient({ + port: network.walletPort +}); + +const {wdb} = node.require('walletdb'); + +const name = rules.grindName(5, 1, network); +let wallet, alice, bob, aliceReceive, bobReceive; + +async function mineBlocks(n, addr) { + addr = addr ? addr : new Address().toString('regtest'); + for (let i = 0; i < n; i++) { + const block = await node.miner.mineBlock(null, addr); + await node.chain.add(block); + } +} + +describe('Multiple accounts participating in same auction', function() { + before(async () => { + await node.open(); + await wclient.open(); + + wallet = await wdb.create(); + + // We'll use an account number for alice and a string for bob + // to ensure that both types work as options. + alice = await wallet.getAccount(0); + bob = await wallet.createAccount({name: 'bob'}); + + aliceReceive = await alice.receiveAddress(); + bobReceive = await bob.receiveAddress(); + }); + + after(async () => { + await wclient.close(); + await node.close(); + }); + + it('should fund both accounts', async () => { + await mineBlocks(2, aliceReceive); + await mineBlocks(2, bobReceive); + + // Wallet rescan is an effective way to ensure that + // wallet and chain are synced before proceeding. + await wdb.rescan(0); + + const aliceBal = await wallet.getBalance(0); + const bobBal = await wallet.getBalance('bob'); + assert(aliceBal.confirmed === 2000 * 2 * 1e6); + assert(bobBal.confirmed === 2000 * 2 * 1e6); + }); + + it('should open an auction and proceed to REVEAL phase', async () => { + await wallet.sendOpen(name, false, {account: 0}); + await mineBlocks(network.names.treeInterval + 2); + let ns = await node.chain.db.getNameStateByName(name); + assert(ns.isBidding(node.chain.height, network)); + + await wdb.rescan(0); + + await wallet.sendBid(name, 100000, 200000, {account: 0}); + await wallet.sendBid(name, 50000, 200000, {account: 'bob'}); + await mineBlocks(network.names.biddingPeriod); + ns = await node.chain.db.getNameStateByName(name); + assert(ns.isReveal(node.chain.height, network)); + + await wdb.rescan(0); + + const walletBids = await wallet.getBidsByName(name); + assert.strictEqual(walletBids.length, 2); + + for (const bid of walletBids) + assert(bid.own); + + assert.strictEqual(node.mempool.map.size, 0); + }); + + describe('REVEAL', function() { + it('should send one REVEAL per account', async () => { + const tx1 = await wallet.sendReveal(name, {account: 0}); + assert(tx1); + + const tx2 = await wallet.sendReveal(name, {account: 'bob'}); + assert(tx2); + + // Reset for next test + await wallet.abandon(tx1.hash()); + await wallet.abandon(tx2.hash()); + + assert.strictEqual(node.mempool.map.size, 2); + await node.mempool.reset(); + assert.strictEqual(node.mempool.map.size, 0); + }); + + it('should send one REVEAL for all accounts in one tx', async () => { + const tx = await wallet.sendRevealAll(); + assert(tx); + + // Reset for next test + await wallet.abandon(tx.hash()); + + assert.strictEqual(node.mempool.map.size, 1); + await mineBlocks(1); + assert.strictEqual(node.mempool.map.size, 0); + }); + }); + + describe('UPDATE', function() { + const aliceResource = Resource.Resource.fromJSON({ + records: [ + { + type: 'TXT', + txt: ['ALICE'] + } + ]}); + const bobResource = Resource.Resource.fromJSON({ + records: [ + { + type: 'TXT', + txt: ['BOB'] + } + ]}); + + it('should advance auction to REGISTER phase', async () => { + await mineBlocks(network.names.revealPeriod); + const ns = await node.chain.db.getNameStateByName(name); + assert(ns.isClosed(node.chain.height, network)); + + await wdb.rescan(0); + + // Alice is the winner + const {hash, index} = ns.owner; + assert(await wallet.txdb.hasCoinByAccount(0, hash, index)); + + // ...not Bob (sanity check) + assert(!await wallet.txdb.hasCoinByAccount(1, hash, index)); + }); + + it('should reject REGISTER given wrong account', async () => { + await assert.rejects(async () => { + await wallet.sendUpdate(name, bobResource, {account: 'bob'}); + }, { + name: 'Error', + message: `Account does not own: "${name}".` + }); + }); + + it('should send REGISTER given correct account', async () => { + const tx = await wallet.sendUpdate(name, aliceResource, {account: 0}); + assert(tx); + + await wallet.abandon(tx.hash()); + + assert.strictEqual(node.mempool.map.size, 1); + await node.mempool.reset(); + assert.strictEqual(node.mempool.map.size, 0); + }); + + it('should send REGISTER from correct account automatically', async () => { + const tx = await wallet.sendUpdate(name, aliceResource); + assert(tx); + + await mineBlocks(1); + }); + }); + + describe('REDEEM', function() { + it('should reject REDEEM given wrong account', async () => { + await assert.rejects(async () => { + await wallet.sendRedeem(name, {account: 0}); + }, { + name: 'Error', + message: 'No reveals to redeem.' + }); + }); + + it('should send REDEEM from correct account', async () => { + const tx = await wallet.sendRedeem(name, {account: 'bob'}); + assert(tx); + + await wallet.abandon(tx.hash()); + + assert.strictEqual(node.mempool.map.size, 1); + await node.mempool.reset(); + assert.strictEqual(node.mempool.map.size, 0); + }); + + it('should send REDEEM from correct account automatically', async () => { + const tx = await wallet.sendRedeem(name); + assert(tx); + + await mineBlocks(1); + }); + }); + + describe('RENEW', function() { + it('should advance chain to allow renewal', async () => { + await mineBlocks(network.names.treeInterval); + await wdb.rescan(0); + }); + + it('should reject RENEW from wrong account', async () => { + await assert.rejects(async () => { + await wallet.sendRenewal(name, {account: 'bob'}); + }, { + name: 'Error', + message: `Account does not own: "${name}".` + }); + }); + + it('should send RENEW from correct account', async () => { + const tx = await wallet.sendRenewal(name, {account: 0}); + assert(tx); + + await wallet.abandon(tx.hash()); + + assert.strictEqual(node.mempool.map.size, 1); + await node.mempool.reset(); + assert.strictEqual(node.mempool.map.size, 0); + }); + + it('should send RENEW from correct account automatically', async () => { + const tx = await wallet.sendRenewal(name); + assert(tx); + + await wallet.abandon(tx.hash()); + + assert.strictEqual(node.mempool.map.size, 1); + await node.mempool.reset(); + assert.strictEqual(node.mempool.map.size, 0); + }); + }); + + describe('TRANSFER', function() { + // Alice will transfer to Bob + let toAddr; + + before(async () => { + toAddr = await bob.receiveAddress(); + }); + + it('should reject TRANSFER from wrong account', async () => { + await assert.rejects(async () => { + await wallet.sendTransfer(name, toAddr, {account: 'bob'}); + }, { + name: 'Error', + message: `Account does not own: "${name}".` + }); + }); + + it('should send TRANSFER from correct account', async () => { + const tx = await wallet.sendTransfer(name, toAddr, {account: 0}); + assert(tx); + + await wallet.abandon(tx.hash()); + + assert.strictEqual(node.mempool.map.size, 1); + await node.mempool.reset(); + assert.strictEqual(node.mempool.map.size, 0); + }); + + it('should send TRANSFER from correct account automatically', async () => { + const tx = await wallet.sendTransfer(name, toAddr); + assert(tx); + + await mineBlocks(1); + }); + }); + + describe('FINALIZE', function() { + it('should advance chain until FINALIZE is allowed', async () => { + await mineBlocks(network.names.transferLockup); + const ns = await node.chain.db.getNameStateByName(name); + assert(ns.isClosed(node.chain.height, network)); + + await wdb.rescan(0); + }); + + it('should reject FINALIZE from wrong account', async () => { + await assert.rejects(async () => { + await wallet.sendFinalize(name, {account: 'bob'}); + }, { + name: 'Error', + message: `Account does not own: "${name}".` + }); + }); + + it('should send FINALIZE from correct account', async () => { + const tx = await wallet.sendFinalize(name, {account: 0}); + assert(tx); + + await wallet.abandon(tx.hash()); + + assert.strictEqual(node.mempool.map.size, 1); + await node.mempool.reset(); + assert.strictEqual(node.mempool.map.size, 0); + }); + + it('should send FINALIZE from correct account automatically', async () => { + const tx = await wallet.sendFinalize(name); + assert(tx); + + await wallet.abandon(tx.hash()); + + assert.strictEqual(node.mempool.map.size, 1); + await node.mempool.reset(); + assert.strictEqual(node.mempool.map.size, 0); + }); + }); + + describe('CANCEL', function() { + it('should reject CANCEL from wrong account', async () => { + await assert.rejects(async () => { + await wallet.sendCancel(name, {account: 'bob'}); + }, { + name: 'Error', + message: `Account does not own: "${name}".` + }); + }); + + it('should send CANCEL from correct account', async () => { + const tx = await wallet.sendCancel(name, {account: 0}); + assert(tx); + + await wallet.abandon(tx.hash()); + + assert.strictEqual(node.mempool.map.size, 1); + await node.mempool.reset(); + assert.strictEqual(node.mempool.map.size, 0); + }); + + it('should send CANCEL from correct account automatically', async () => { + const tx = await wallet.sendCancel(name); + assert(tx); + + await wallet.abandon(tx.hash()); + + assert.strictEqual(node.mempool.map.size, 1); + await node.mempool.reset(); + assert.strictEqual(node.mempool.map.size, 0); + }); + }); + + describe('REVOKE', function() { + it('should reject REVOKE from wrong account', async () => { + await assert.rejects(async () => { + await wallet.sendRevoke(name, {account: 'bob'}); + }, { + name: 'Error', + message: `Account does not own: "${name}".` + }); + }); + + it('should send REVOKE from correct account', async () => { + const tx = await wallet.sendRevoke(name, {account: 0}); + assert(tx); + + await wallet.abandon(tx.hash()); + + assert.strictEqual(node.mempool.map.size, 1); + await node.mempool.reset(); + assert.strictEqual(node.mempool.map.size, 0); + }); + + it('should send REVOKE from correct account automatically', async () => { + const tx = await wallet.sendRevoke(name); + assert(tx); + + await wallet.abandon(tx.hash()); + + assert.strictEqual(node.mempool.map.size, 1); + await node.mempool.reset(); + assert.strictEqual(node.mempool.map.size, 0); + }); + }); +});