diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d52a155dc..7d8c0882a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # HSD Release Notes & Changelog +## unreleased + +- Adds new wallet HTTP endpoint `/wallet/:id/auction` based on `POST /wallet/:id/bid`. +It requires an additional parameter `broadcastBid` set to either true or false. +This action returns a bid and its corresponding reveal, the reveal being prepared in advance. +The bid will be broadcaster either during the creation (`broadcastBid=true`) or at a later time +(`broadcastBid=false`). +The reveal will have to be broadcasted at a later time, during the REVEAL phase. +The lockup must include a blind big enough to ensure the BID will be the only input of the REVEAL +transaction. + ## v2.3.0 ### Node changes diff --git a/lib/wallet/http.js b/lib/wallet/http.js index f9723dfc36..700bd95061 100644 --- a/lib/wallet/http.js +++ b/lib/wallet/http.js @@ -1084,6 +1084,45 @@ class HTTP extends Server { return res.json(200, mtx.getJSON(this.network)); }); + // Create auction-related transactions in advance (bid and reveal for now) + this.post('/wallet/:id/auction', async (req, res) => { + const valid = Validator.fromRequest(req); + const name = valid.str('name'); + const bid = valid.u64('bid'); + const lockup = valid.u64('lockup'); + const passphrase = valid.str('passphrase'); + const sign = valid.bool('sign', true); + const broadcastBid = valid.bool('broadcastBid'); + + assert(name, 'Name is required.'); + assert(bid != null, 'Bid is required.'); + assert(lockup != null, 'Lockup is required.'); + assert(broadcastBid != null, 'broadcastBid is required.'); + assert(broadcastBid ? sign : true, 'Must sign when broadcasting.'); + + const options = TransactionOptions.fromValidator(valid); + const auctionTxs = await req.wallet.createAuctionTxs( + name, + bid, + lockup, + options + ); + + if (sign) { + if (broadcastBid) { + auctionTxs.bid = await req.wallet.sendMTX(auctionTxs.bid, passphrase); + } else { + await req.wallet.sign(auctionTxs.bid, passphrase); + } + await req.wallet.sign(auctionTxs.reveal, passphrase); + } + + return res.json(200, { + bid: auctionTxs.bid.getJSON(this.network), + reveal: auctionTxs.reveal.getJSON(this.network) + }); + }); + // Create Reveal this.post('/wallet/:id/reveal', async (req, res) => { const valid = Validator.fromRequest(req); diff --git a/lib/wallet/wallet.js b/lib/wallet/wallet.js index 5fe6181df4..e7b54a3657 100644 --- a/lib/wallet/wallet.js +++ b/lib/wallet/wallet.js @@ -37,6 +37,8 @@ const {states} = require('../covenants/namestate'); const {types} = rules; const {Mnemonic} = HD; const {BufferSet} = require('buffer-map'); +const Coin = require('../primitives/coin'); +const Outpoint = require('../primitives/outpoint'); /* * Constants @@ -1195,12 +1197,15 @@ class Wallet extends EventEmitter { if (rate == null) rate = await this.wdb.estimateFee(options.blocks); - let coins; + let coins = options.coins || []; + assert(Array.isArray(coins)); if (options.smart) { - coins = await this.getSmartCoins(options.account); + const smartCoins = await this.getSmartCoins(options.account); + coins = coins.concat(smartCoins); } else { - coins = await this.getCoins(options.account); - coins = this.txdb.filterLocked(coins); + let availableCoins = await this.getCoins(options.account); + availableCoins = this.txdb.filterLocked(availableCoins); + coins = coins.concat(availableCoins); } await mtx.fund(coins, { @@ -1820,6 +1825,76 @@ class Wallet extends EventEmitter { } } + /** + * Create and finalize a bid & a reveal (in advance) + * MTX with a lock. + * @param {String} name + * @param {Number} value + * @param {Number} lockup + * @param {Object} options + * @returns {{ bid: MTX; reveal: MTX;}} {bid, reveal} + */ + + async createAuctionTxs(name, value, lockup, options) { + const unlock = await this.fundLock.lock(); + try { + return await this._createAuctionTxs(name, value, lockup, options); + } finally { + unlock(); + } + } + + /** + * Create and finalize a bid & a reveal (in advance) + * MTX without a lock. + * @param {String} name + * @param {Number} value + * @param {Number} lockup + * @param {Object} options + * @returns {{ bid: MTX; reveal: MTX;}} {bid, reveal} + */ + + async _createAuctionTxs(name, value, lockup, options) { + const bid = await this._createBid(name, value, lockup, options); + + const bidOuputIndex = bid.outputs.findIndex(o => o.covenant.isBid()); + const bidOutput = bid.outputs[bidOuputIndex]; + const bidCoin = Coin.fromTX(bid, bidOuputIndex, -1); + + // Prepare the data needed to make the reveal in advance + const nameHash = bidOutput.covenant.getHash(0); + const height = bidOutput.covenant.getU32(1); + + const coins = []; + coins.push(bidCoin); + + const blind = bidOutput.covenant.getHash(3); + const bv = await this.getBlind(blind); + if (!bv) + throw new Error('Blind value not found.'); + const { nonce } = bv; + + const reveal = new MTX(); + const output = new Output(); + output.address = bidCoin.address; + output.value = value; + output.covenant.type = types.REVEAL; + output.covenant.pushHash(nameHash); + output.covenant.pushU32(height); + output.covenant.pushHash(nonce); + reveal.addOutpoint(Outpoint.fromTX(bid, bidOuputIndex)); + reveal.outputs.push(output); + + await this.fill(reveal, { ...options, coins: coins }); + assert( + reveal.inputs.length === 1, + 'Pre-signed REVEAL must not require additional inputs' + ); + + const finalReveal = await this.finalize(reveal, options); + return { bid, reveal: finalReveal }; + } + /** * Make a reveal MTX. * @param {String} name diff --git a/test/wallet-http-test.js b/test/wallet-http-test.js index 878496d7e0..c95f6082d5 100644 --- a/test/wallet-http-test.js +++ b/test/wallet-http-test.js @@ -846,6 +846,71 @@ describe('Wallet HTTP', function() { matchTxId(auction.reveals, state.reveals[1].hash); }); + it('should create a bid and a reveal (reveal in advance)', async () => { + const balanceBeforeTest = await wallet.getBalance(); + const lockConfirmedBeforeTest = balanceBeforeTest.lockedConfirmed; + const lockUnconfirmedBeforeTest = balanceBeforeTest.lockedUnconfirmed; + + await wallet.createOpen({ name: name }); + + await mineBlocks(treeInterval + 2, cbAddress); + + const balanceBeforeBid = await wallet.getBalance(); + assert.equal(balanceBeforeBid.lockedConfirmed - lockConfirmedBeforeTest, 0); + assert.equal( + balanceBeforeBid.lockedUnconfirmed - lockUnconfirmedBeforeTest, + 0 + ); + + const bidValue = 1000000; + const lockupValue = 5000000; + + const auctionTxs = await wallet.client.post( + `/wallet/${wallet.id}/auction`, + { + name: name, + bid: 1000000, + lockup: 5000000, + broadcastBid: true + } + ); + + await mineBlocks(biddingPeriod + 1, cbAddress); + + let walletAuction = await wallet.getAuctionByName(name); + const bidFromWallet = walletAuction.bids.find( + b => b.prevout.hash === auctionTxs.bid.hash + ); + assert(bidFromWallet); + + const { info } = await nclient.execute('getnameinfo', [name]); + assert.equal(info.name, name); + assert.equal(info.state, 'REVEAL'); + + const b5 = await wallet.getBalance(); + assert.equal(b5.lockedConfirmed - lockConfirmedBeforeTest, lockupValue); + assert.equal(b5.lockedUnconfirmed - lockUnconfirmedBeforeTest, lockupValue); + + await nclient.broadcast(auctionTxs.reveal.hex); + await mineBlocks(1, cbAddress); + + walletAuction = await wallet.getAuctionByName(name); + const revealFromWallet = walletAuction.reveals.find( + b => b.prevout.hash === auctionTxs.reveal.hash + ); + assert(revealFromWallet); + + const b6 = await wallet.getBalance(); + assert.equal(b6.lockedConfirmed - lockConfirmedBeforeTest, bidValue); + assert.equal(b6.lockedUnconfirmed - lockUnconfirmedBeforeTest, bidValue); + + await mineBlocks(revealPeriod + 1, cbAddress); + + const ns = await nclient.execute('getnameinfo', [name]); + const coin = await wallet.getCoin(ns.info.owner.hash, ns.info.owner.index); + assert.ok(coin); + }); + it('should create a redeem', async () => { await wallet.createOpen({ name: name diff --git a/test/wallet-test.js b/test/wallet-test.js index fdd3c554e5..bad3368582 100644 --- a/test/wallet-test.js +++ b/test/wallet-test.js @@ -2869,4 +2869,344 @@ describe('Wallet', function() { assert.equal(claim.covenant.isClaim(), true); }); }); + + describe('Create auction-related TX in advance', function () { + const network = Network.get('regtest'); + const workers = new WorkerPool({ enabled }); + const wdb = new WalletDB({ network, workers }); + // This test executes a complete auction for this name + const name = 'satoshi-in-advance'; + // There will be two bids. Both from our wallet, one made in advance and + // another created and broadcasted right away + const value = 1e6; + const lockup = 2e6; + const secondHighest = value - 1; + // All TXs will have a hard-coded fee to simplify the expected balances, + // along with counters for OUTGOING (un)confirmed transactions. + const fee = 10000; + let uTXCount = 0; + let cTXCount = 0; + // Initial wallet funds + const fund = 10e6; + // Store height of auction OPEN to be used in second bid. + // The main test wallet, and wallet that will receive the FINALIZE. + let wallet; + let unsentReveal; + + // Hack required to focus test on txdb mechanics. + // We don't otherwise need WalletDB or Blockchain + wdb.getRenewalBlock = () => { + return network.genesis.hash; + }; + + before(async () => { + await wdb.open(); + wallet = await wdb.create(); + // rollout all names + wdb.height = 52 * 144 * 7; + }); + + after(async () => { + await wdb.close(); + }); + + it('should fund wallet', async () => { + const addr = await wallet.receiveAddress(); + + // Fund wallet + const mtx = new MTX(); + mtx.addOutpoint(new Outpoint(Buffer.alloc(32), 0)); + mtx.addOutput(addr, fund); + const tx = mtx.toTX(); + + // Dummy block + const block = { + height: wdb.height + 1, + hash: Buffer.alloc(32), + time: Date.now() + }; + + // Add confirmed funding TX to wallet + await wallet.txdb.add(tx, block); + + // Check + const bal = await wallet.getBalance(); + assert.strictEqual(bal.tx, 1); + assert.strictEqual(bal.coin, 1); + assert.strictEqual(bal.confirmed, fund); + assert.strictEqual(bal.unconfirmed, fund); + assert.strictEqual(bal.ulocked, 0); + assert.strictEqual(bal.clocked, 0); + }); + + it('should send and confirm OPEN', async () => { + const open = await wallet.sendOpen(name, false, { hardFee: fee }); + uTXCount++; + + // Check + let bal = await wallet.getBalance(); + assert.strictEqual(bal.tx, 2); + assert.strictEqual(bal.coin, 2); + assert.strictEqual(bal.confirmed, fund); + assert.strictEqual(bal.unconfirmed, fund - uTXCount * fee); + assert.strictEqual(bal.ulocked, 0); + assert.strictEqual(bal.clocked, 0); + + // Confirm OPEN + const block = { + height: wdb.height + 1, + hash: Buffer.alloc(32), + time: Date.now() + }; + await wallet.txdb.add(open, block); + cTXCount++; + + // Check + bal = await wallet.getBalance(); + assert.strictEqual(bal.tx, 2); + assert.strictEqual(bal.coin, 2); + assert.strictEqual(bal.confirmed, fund - cTXCount * fee); + assert.strictEqual(bal.unconfirmed, fund - uTXCount * fee); + assert.strictEqual(bal.ulocked, 0); + assert.strictEqual(bal.clocked, 0); + }); + + it('should send and confirm BID', async () => { + // Advance to bidding + wdb.height += network.names.treeInterval + 1; + const losingBid = await wallet.sendBid(name, secondHighest, lockup, { + hardFee: fee + }); + uTXCount++; + + // Check + let bal = await wallet.getBalance(); + assert.strictEqual(bal.tx, 3); + assert.strictEqual(bal.coin, 3); + assert.strictEqual(bal.confirmed, fund - cTXCount * fee); + assert.strictEqual(bal.unconfirmed, fund - uTXCount * fee); + assert.strictEqual(bal.ulocked, lockup); + assert.strictEqual(bal.clocked, 0); + + // Confirm BID + let block = { + height: wdb.height + 1, + hash: Buffer.alloc(32), + time: Date.now() + }; + await wallet.txdb.add(losingBid, block); + cTXCount++; + + const losingBlindFromMtx = losingBid.outputs + .find(o => o.covenant.isBid()) + .covenant.getHash(3); + let allBids = await wallet.getBids(); + assert.strictEqual(allBids.length, 1); + const losingBlindFromWallet = allBids.find(b => + b.blind.equals(losingBlindFromMtx) + ); + assert(losingBlindFromWallet); + + bal = await wallet.getBalance(); + assert.strictEqual(bal.tx, 3); + assert.strictEqual(bal.coin, 3); + assert.strictEqual(bal.confirmed, fund - cTXCount * fee); + assert.strictEqual(bal.unconfirmed, fund - uTXCount * fee); + assert.strictEqual(bal.ulocked, lockup); + assert.strictEqual(bal.clocked, lockup); + + const auctionsTxs = await wallet.createAuctionTxs(name, value, lockup, { + hardFee: fee + }); + const winningBidUnsent = auctionsTxs.bid; + unsentReveal = auctionsTxs.reveal; + + const winningBid = await wallet.sendMTX(winningBidUnsent, null); + uTXCount++; + + // Check + bal = await wallet.getBalance(); + assert.strictEqual(bal.tx, 4); + assert.strictEqual(bal.coin, 4); + assert.strictEqual(bal.confirmed, fund - cTXCount * fee); + assert.strictEqual(bal.unconfirmed, fund - uTXCount * fee); + assert.strictEqual(bal.ulocked, 2 * lockup); + assert.strictEqual(bal.clocked, lockup); + + // Confirm BID + block = { + height: wdb.height + 1, + hash: Buffer.alloc(32), + time: Date.now() + }; + await wallet.txdb.add(winningBid, block); + cTXCount++; + + const winningBlindFromMtx = winningBid.outputs + .find(o => o.covenant.isBid()) + .covenant.getHash(3); + allBids = await wallet.getBids(); + assert.strictEqual(allBids.length, 2); + const winningBlindFromWallet = allBids.find(b => + b.blind.equals(winningBlindFromMtx) + ); + assert(winningBlindFromWallet); + + // Check + bal = await wallet.getBalance(); + assert.strictEqual(bal.tx, 4); + assert.strictEqual(bal.coin, 4); + assert.strictEqual(bal.confirmed, fund - cTXCount * fee); + assert.strictEqual(bal.unconfirmed, fund - uTXCount * fee); + assert.strictEqual(bal.ulocked, 2 * lockup); + assert.strictEqual(bal.clocked, 2 * lockup); + }); + + it('should send and confirm REVEAL', async () => { + // Advance to reveal + wdb.height += network.names.biddingPeriod; + const reveal = await wallet.sendMTX(unsentReveal, null); + uTXCount++; + + // Check + let bal = await wallet.getBalance(); + assert.strictEqual(bal.tx, 5); + assert.strictEqual(bal.coin, 5); + assert.strictEqual(bal.confirmed, fund - cTXCount * fee); + assert.strictEqual(bal.unconfirmed, fund - uTXCount * fee); + assert.strictEqual(bal.ulocked, lockup + value); + assert.strictEqual(bal.clocked, 2 * lockup); + + // Confirm REVEAL + const block = { + height: wdb.height + 1, + hash: Buffer.alloc(32), + time: Date.now() + }; + await wallet.txdb.add(reveal, block); + cTXCount++; + + const revealValueFromMtx = reveal.outputs.find(o => o.covenant.isReveal()) + .value; + let allReveals = await wallet.getReveals(); + assert.strictEqual(allReveals.length, 1); + const revealFromWallet = allReveals.find( + b => b.value === revealValueFromMtx + ); + assert(revealFromWallet); + + // Check + bal = await wallet.getBalance(); + assert.strictEqual(bal.tx, 5); + assert.strictEqual(bal.coin, 5); + assert.strictEqual(bal.confirmed, fund - cTXCount * fee); + assert.strictEqual(bal.unconfirmed, fund - uTXCount * fee); + assert.strictEqual(bal.ulocked, lockup + value); + assert.strictEqual(bal.clocked, lockup + value); + + const reveal2 = await wallet.sendReveal(name, { hardFee: fee }); + uTXCount++; + + // Confirm REVEAL + const block2 = { + height: wdb.height + 1, + hash: Buffer.alloc(32), + time: Date.now() + }; + await wallet.txdb.add(reveal2, block2); + cTXCount++; + + const reveal2ValueFromMtx = reveal.outputs.find(o => + o.covenant.isReveal() + ).value; + allReveals = await wallet.getReveals(); + assert.strictEqual(allReveals.length, 2); + const reveal2FromWallet = allReveals.find( + b => b.value === reveal2ValueFromMtx + ); + assert(reveal2FromWallet); + + // Check + bal = await wallet.getBalance(); + assert.strictEqual(bal.tx, 6); + assert.strictEqual(bal.coin, 6); + assert.strictEqual(bal.confirmed, fund - cTXCount * fee); + assert.strictEqual(bal.unconfirmed, fund - uTXCount * fee); + assert.strictEqual(bal.ulocked, value + secondHighest); + assert.strictEqual(bal.clocked, value + secondHighest); + }); + + it('should send and confirm REDEEM', async () => { + // Advance to close + wdb.height += network.names.revealPeriod; + const redeem = await wallet.sendRedeem(name, { hardFee: fee }); + uTXCount++; + + // Check + let bal = await wallet.getBalance(); + assert.strictEqual(bal.tx, 7); + // Wallet coin count doesn't change: + // REVEAL + fee money -> REDEEM + change + assert.strictEqual(bal.coin, 6); + assert.strictEqual(bal.confirmed, fund - cTXCount * fee); + assert.strictEqual(bal.unconfirmed, fund - uTXCount * fee); + assert.strictEqual(bal.ulocked, value); + assert.strictEqual(bal.clocked, value + secondHighest); + + // Confirm REGISTER + const block = { + height: wdb.height + 1, + hash: Buffer.alloc(32), + time: Date.now() + }; + await wallet.txdb.add(redeem, block); + cTXCount++; + + // Check + bal = await wallet.getBalance(); + assert.strictEqual(bal.tx, 7); + assert.strictEqual(bal.coin, 6); + assert.strictEqual(bal.confirmed, fund - cTXCount * fee); + assert.strictEqual(bal.unconfirmed, fund - uTXCount * fee); + assert.strictEqual(bal.ulocked, value); + assert.strictEqual(bal.clocked, value); + }); + + it('should send and confirm REGISTER', async () => { + const resource = Resource.fromJSON({ records: [] }); + const register = await wallet.sendUpdate(name, resource, { + hardFee: fee + }); + uTXCount++; + + // Check + let bal = await wallet.getBalance(); + assert.strictEqual(bal.tx, 8); + // Wallet coin count doesn't change: + // REVEAL + fee money -> REGISTER + change + assert.strictEqual(bal.coin, 6); + assert.strictEqual(bal.confirmed, fund - cTXCount * fee); + assert.strictEqual(bal.unconfirmed, fund - uTXCount * fee); + assert.strictEqual(bal.ulocked, secondHighest); + assert.strictEqual(bal.clocked, value); + + // Confirm REGISTER + const block = { + height: wdb.height + 1, + hash: Buffer.alloc(32), + time: Date.now() + }; + await wallet.txdb.add(register, block); + cTXCount++; + + // Check + bal = await wallet.getBalance(); + assert.strictEqual(bal.tx, 8); + assert.strictEqual(bal.coin, 6); + assert.strictEqual(bal.confirmed, fund - cTXCount * fee); + assert.strictEqual(bal.unconfirmed, fund - uTXCount * fee); + assert.strictEqual(bal.ulocked, secondHighest); + assert.strictEqual(bal.clocked, secondHighest); + }); + }); });