diff --git a/packages/bitcore-wallet-client/src/lib/api.ts b/packages/bitcore-wallet-client/src/lib/api.ts index d43d345862..cf6a1b6e01 100644 --- a/packages/bitcore-wallet-client/src/lib/api.ts +++ b/packages/bitcore-wallet-client/src/lib/api.ts @@ -2062,6 +2062,36 @@ export class API extends EventEmitter { } } + /** + * Assign a fresh nonce to a deferred-nonce transaction proposal. + * Call this just before signing a deferred-nonce txp. + */ + async assignNonce( + opts: { + /** The transaction proposal to assign nonce to */ + txp: Txp; + }, + /** @deprecated */ + cb?: (err?: Error, txp?: Txp) => void + ) { + if (cb) { + log.warn('DEPRECATED: assignNonce will remove callback support in the future.'); + } + try { + $.checkState(this.credentials && this.credentials.isComplete(), + 'Failed state: this.credentials at '); + + const url = '/v1/txproposals/' + opts.txp.id + '/assign-nonce/'; + const { body: txp } = await this.request.post(url, {}); + this._processTxps(txp); + if (cb) { cb(null, txp); } + return txp; + } catch (err) { + if (cb) cb(err); + else throw err; + } + } + /** * Create advertisement for bitpay app - (limited to marketing staff) * @returns {object} Returns the created advertisement diff --git a/packages/bitcore-wallet-service/src/lib/expressapp.ts b/packages/bitcore-wallet-service/src/lib/expressapp.ts index 349ca34ef9..7e065494d8 100644 --- a/packages/bitcore-wallet-service/src/lib/expressapp.ts +++ b/packages/bitcore-wallet-service/src/lib/expressapp.ts @@ -1069,6 +1069,17 @@ export class ExpressApp { }); */ + router.post('/v1/txproposals/:id/assign-nonce/', (req, res) => { + getServerWithAuth(req, res, server => { + req.body.txProposalId = req.params['id']; + server.assignNonce(req.body, (err, txp) => { + if (err) return returnError(err, res, req); + res.json(txp); + res.end(); + }); + }); + }); + // router.post('/v1/txproposals/:id/publish/', (req, res) => { getServerWithAuth(req, res, server => { diff --git a/packages/bitcore-wallet-service/src/lib/model/txproposal.ts b/packages/bitcore-wallet-service/src/lib/model/txproposal.ts index 2113b4e306..bc5e7c24a2 100644 --- a/packages/bitcore-wallet-service/src/lib/model/txproposal.ts +++ b/packages/bitcore-wallet-service/src/lib/model/txproposal.ts @@ -66,6 +66,7 @@ export interface ITxProposal { signingMethod: string; lowFees?: boolean; nonce?: number | string; + deferNonce?: boolean; gasPrice?: number; maxGasFee?: number; priorityGasFee?: number; @@ -150,6 +151,7 @@ export class TxProposal implements ITxProposal { lowFees?: boolean; raw?: Array | string; nonce?: number | string; + deferNonce?: boolean; gasPrice?: number; maxGasFee?: number; priorityGasFee?: number; @@ -273,6 +275,7 @@ export class TxProposal implements ITxProposal { x.txType = opts.txType; x.from = opts.from; x.nonce = opts.nonce; + x.deferNonce = opts.deferNonce; x.gasLimit = opts.gasLimit; // Backward compatibility for BWC <= 8.9.0 x.data = opts.data; // Backward compatibility for BWC <= 8.9.0 x.tokenAddress = opts.tokenAddress; @@ -363,6 +366,7 @@ export class TxProposal implements ITxProposal { x.txType = obj.txType; x.from = obj.from; x.nonce = obj.nonce; + x.deferNonce = obj.deferNonce; x.gasLimit = obj.gasLimit; // Backward compatibility for BWC <= 8.9.0 x.data = obj.data; // Backward compatibility for BWC <= 8.9.0 x.tokenAddress = obj.tokenAddress; @@ -531,6 +535,10 @@ export class TxProposal implements ITxProposal { return !!this.refreshOnPublish; } + hasMutableTxData() { + return this.isRepublishEnabled() || !!this.deferNonce; + } + isTemporary() { return this.status === 'temporary'; } diff --git a/packages/bitcore-wallet-service/src/lib/server.ts b/packages/bitcore-wallet-service/src/lib/server.ts index a53a98ada9..af75d3581c 100644 --- a/packages/bitcore-wallet-service/src/lib/server.ts +++ b/packages/bitcore-wallet-service/src/lib/server.ts @@ -2694,7 +2694,7 @@ export class WalletService implements IWalletService { }, async next => { // SOL is skipped since its a non necessary field that is expected to be provided by the client. - if (!opts.nonce && !Constants.SVM_CHAINS[wallet.chain.toUpperCase()]) { + if (!opts.nonce && !Constants.SVM_CHAINS[wallet.chain.toUpperCase()] && !opts.deferNonce) { try { opts.nonce = await ChainService.getTransactionCount(this, wallet, opts.from); } catch (error) { @@ -2794,7 +2794,8 @@ export class WalletService implements IWalletService { memo: opts.memo, fromAta: opts.fromAta, decimals: opts.decimals, - refreshOnPublish: opts.refreshOnPublish + refreshOnPublish: opts.refreshOnPublish, + deferNonce: opts.deferNonce }; txp = TxProposal.create(txOpts); next(); @@ -2906,7 +2907,7 @@ export class WalletService implements IWalletService { this.storage.fetchTx(this.walletId, opts.txProposalId, (err, txp) => { if (err) return cb(err); if (!txp) return cb(Errors.TX_NOT_FOUND); - if (!txp.isTemporary() && !txp.isRepublishEnabled()) return cb(null, txp); + if (!txp.isTemporary() && !txp.hasMutableTxData()) return cb(null, txp); const copayer = wallet.getCopayer(this.copayerId); @@ -2920,7 +2921,7 @@ export class WalletService implements IWalletService { let signingKey = this._getSigningKey(raw, opts.proposalSignature, copayer.requestPubKeys); if (!signingKey) { // If the txp has been published previously, we will verify the signature against the previously published raw tx - if (txp.isRepublishEnabled() && txp.prePublishRaw) { + if (txp.hasMutableTxData() && txp.prePublishRaw) { raw = txp.prePublishRaw; signingKey = this._getSigningKey(raw, opts.proposalSignature, copayer.requestPubKeys); } @@ -2943,7 +2944,7 @@ export class WalletService implements IWalletService { txp.status = 'pending'; ChainService.refreshTxData(this, txp, opts, (err, txp) => { if (err) return cb(err); - if (txp.isRepublishEnabled() && !txp.prePublishRaw) { + if (txp.hasMutableTxData() && !txp.prePublishRaw) { // We save the original raw transaction for verification on republish txp.prePublishRaw = raw; } @@ -3207,7 +3208,7 @@ export class WalletService implements IWalletService { try { const txps = await this.getPendingTxsPromise({}); for (const t of txps) { - if (t.id !== txp.id && t.nonce <= txp.nonce && t.status !== 'rejected') { + if (t.id !== txp.id && t.nonce != null && txp.nonce != null && t.nonce <= txp.nonce && t.status !== 'rejected') { return cb(Errors.TX_NONCE_CONFLICT); } } @@ -3268,6 +3269,67 @@ export class WalletService implements IWalletService { }); } + /** + * Assign a fresh nonce to a deferred-nonce transaction proposal. + * Called by the client just before signing. + * @param {Object} opts + * @param {string} opts.txProposalId - The identifier of the transaction. + */ + assignNonce(opts, cb) { + if (!checkRequired(opts, ['txProposalId'], cb)) return; + + this._runLocked(cb, cb => { + this.getWallet({}, (err, wallet) => { + if (err) return cb(err); + + this.storage.fetchTx(this.walletId, opts.txProposalId, async (err, txp) => { + if (err) return cb(err); + if (!txp) return cb(Errors.TX_NOT_FOUND); + if (!txp.isPending()) return cb(Errors.TX_NOT_PENDING); + + if (!txp.deferNonce) { + // Not a deferred-nonce txp. Return it as-is + return cb(null, txp); + } + + if (!Constants.EVM_CHAINS[wallet.chain.toUpperCase()]) { + return cb(null, txp); + } + + try { + // 1. Get confirmed nonce from blockchain + const confirmedNonce = await ChainService.getTransactionCount(this, wallet, txp.from); + + // 2. Get pending TXP nonces from BWS's own database + const pendingTxps = await this.getPendingTxsPromise({}); + const pendingNonces = pendingTxps + .filter(t => t.id !== txp.id && t.nonce != null && t.status !== 'rejected') + .map(t => Number(t.nonce)); + + // 3. Calculate gap-free nonce + let suggestedNonce = Number(confirmedNonce); + const allNonces = pendingNonces.sort((a, b) => a - b); + for (const n of allNonces) { + if (n === suggestedNonce) { + suggestedNonce++; + } + } + + txp.nonce = suggestedNonce; + + // 4. Store the updated txp + this.storage.storeTx(this.walletId, txp, err => { + if (err) return cb(err); + return cb(null, txp); + }); + } catch (err) { + return cb(err); + } + }); + }); + }); + } + _processBroadcast(txp, opts, cb) { $.checkState(txp.txid, 'Failed state: txp.txid undefined at <_processBroadcast()>'); opts = opts || {}; diff --git a/packages/bitcore-wallet-service/test/integration/server.test.ts b/packages/bitcore-wallet-service/test/integration/server.test.ts index db1c8c8130..9f141d3349 100644 --- a/packages/bitcore-wallet-service/test/integration/server.test.ts +++ b/packages/bitcore-wallet-service/test/integration/server.test.ts @@ -7801,6 +7801,329 @@ describe('Wallet service', function() { }); }); + describe('#assignNonce (deferred nonce)', function() { + const ETH_ADDR = '0x37d7B3bBD88EFdE6a93cF74D2F5b0385D3E3B08A'; + let server, wallet, fromAddr; + + beforeEach(async function() { + ({ server, wallet } = await helpers.createAndJoinWallet(1, 1, { coin: 'eth' })); + const address = await util.promisify(server.createAddress).call(server, {}); + fromAddr = address.address; + await helpers.stubUtxos(server, wallet, [1, 2], { coin: 'eth' }); + }); + + it('should create txp with nonce=null when deferNonce is true', async function() { + const txOpts = { + outputs: [{ toAddress: ETH_ADDR, amount: 8000 }], + feePerKb: 123e2, + from: fromAddr, + deferNonce: true + }; + const txp = await util.promisify(server.createTx).call(server, txOpts); + should.exist(txp); + txp.deferNonce.should.be.true; + should.not.exist(txp.nonce); + }); + + it('should create txp with nonce when deferNonce is absent', async function() { + const txOpts = { + outputs: [{ toAddress: ETH_ADDR, amount: 8000 }], + feePerKb: 123e2, + from: fromAddr + }; + const txp = await util.promisify(server.createTx).call(server, txOpts); + should.exist(txp); + should.not.exist(txp.deferNonce); + txp.nonce.should.equal('5'); + }); + + it('should publish a deferred-nonce txp and save prePublishRaw', async function() { + const txOpts = { + outputs: [{ toAddress: ETH_ADDR, amount: 8000 }], + feePerKb: 123e2, + from: fromAddr, + deferNonce: true + }; + const txp = await util.promisify(server.createTx).call(server, txOpts); + should.not.exist(txp.nonce); + + const publishOpts = helpers.getProposalSignatureOpts(txp, TestData.copayers[0].privKey_1H_0); + const published = await util.promisify(server.publishTx).call(server, publishOpts); + should.exist(published); + published.status.should.equal('pending'); + published.deferNonce.should.be.true; + should.exist(published.prePublishRaw); + }); + + it('should assign nonce to a deferred-nonce txp', async function() { + blockchainExplorer.getTransactionCount = sinon.stub().callsArgWith(1, null, '10'); + + const txp = await helpers.createAndPublishTx(server, { + outputs: [{ toAddress: ETH_ADDR, amount: 8000 }], + feePerKb: 123e2, + from: fromAddr, + deferNonce: true + }, TestData.copayers[0].privKey_1H_0); + + should.not.exist(txp.nonce); + + const result = await util.promisify(server.assignNonce).call(server, { + txProposalId: txp.id + }); + should.exist(result); + result.nonce.should.equal(10); + }); + + it('should return txp as-is if deferNonce is not set', async function() { + const txp = await helpers.createAndPublishTx(server, { + outputs: [{ toAddress: ETH_ADDR, amount: 8000 }], + feePerKb: 123e2, + from: fromAddr + }, TestData.copayers[0].privKey_1H_0); + + txp.nonce.should.equal('5'); + + const result = await util.promisify(server.assignNonce).call(server, { + txProposalId: txp.id + }); + result.nonce.should.equal('5'); + }); + + it('should fail for non-existent txp', function(done) { + server.assignNonce({ txProposalId: 'nonexistent' }, function(err) { + should.exist(err); + err.message.should.contain('not found'); + done(); + }); + }); + + it('should calculate gap-free nonce skipping pending txp nonces', async function() { + blockchainExplorer.getTransactionCount = sinon.stub().callsArgWith(1, null, '5'); + + const txp1 = await helpers.createAndPublishTx(server, { + outputs: [{ toAddress: ETH_ADDR, amount: 1000 }], + feePerKb: 123e2, + from: fromAddr + }, TestData.copayers[0].privKey_1H_0); + txp1.nonce.should.equal('5'); + + const txp2 = await helpers.createAndPublishTx(server, { + outputs: [{ toAddress: ETH_ADDR, amount: 2000 }], + feePerKb: 123e2, + from: fromAddr, + deferNonce: true + }, TestData.copayers[0].privKey_1H_0); + should.not.exist(txp2.nonce); + + const result = await util.promisify(server.assignNonce).call(server, { + txProposalId: txp2.id + }); + result.nonce.should.equal(6); + }); + + it('should assign sequential nonces for multiple deferred txps', async function() { + blockchainExplorer.getTransactionCount = sinon.stub().callsArgWith(1, null, '0'); + + const txps = []; + for (let i = 0; i < 3; i++) { + const txp = await helpers.createAndPublishTx(server, { + outputs: [{ toAddress: ETH_ADDR, amount: 1000 * (i + 1) }], + feePerKb: 123e2, + from: fromAddr, + deferNonce: true + }, TestData.copayers[0].privKey_1H_0); + txps.push(txp); + } + + const results = []; + for (const txp of txps) { + const result = await util.promisify(server.assignNonce).call(server, { + txProposalId: txp.id + }); + results.push(result); + } + + results[0].nonce.should.equal(0); + results[1].nonce.should.equal(1); + results[2].nonce.should.equal(2); + }); + + it('should handle mix of normal and deferred-nonce txps', async function() { + blockchainExplorer.getTransactionCount = sinon.stub().callsArgWith(1, null, '3'); + + const normalTxp = await helpers.createAndPublishTx(server, { + outputs: [{ toAddress: ETH_ADDR, amount: 1000 }], + feePerKb: 123e2, + from: fromAddr + }, TestData.copayers[0].privKey_1H_0); + normalTxp.nonce.should.equal('3'); + + const deferred1 = await helpers.createAndPublishTx(server, { + outputs: [{ toAddress: ETH_ADDR, amount: 2000 }], + feePerKb: 123e2, + from: fromAddr, + deferNonce: true + }, TestData.copayers[0].privKey_1H_0); + + const deferred2 = await helpers.createAndPublishTx(server, { + outputs: [{ toAddress: ETH_ADDR, amount: 3000 }], + feePerKb: 123e2, + from: fromAddr, + deferNonce: true + }, TestData.copayers[0].privKey_1H_0); + + const result1 = await util.promisify(server.assignNonce).call(server, { + txProposalId: deferred1.id + }); + result1.nonce.should.equal(4); + + const result2 = await util.promisify(server.assignNonce).call(server, { + txProposalId: deferred2.id + }); + result2.nonce.should.equal(5); + }); + + it('should sign a deferred-nonce txp after assignNonce', async function() { + blockchainExplorer.getTransactionCount = sinon.stub().callsArgWith(1, null, '7'); + helpers.stubBroadcast('txid123'); + + const txp = await helpers.createAndPublishTx(server, { + outputs: [{ toAddress: ETH_ADDR, amount: 8000 }], + feePerKb: 123e2, + from: fromAddr, + deferNonce: true + }, TestData.copayers[0].privKey_1H_0); + + const withNonce = await util.promisify(server.assignNonce).call(server, { + txProposalId: txp.id + }); + withNonce.nonce.should.equal(7); + + const fetched = await util.promisify(server.getTx).call(server, { txProposalId: txp.id }); + const signatures = helpers.clientSign(fetched, TestData.copayers[0].xPrivKey_44H_0H_0H); + const signed = await util.promisify(server.signTx).call(server, { + txProposalId: txp.id, + signatures + }); + signed.status.should.equal('accepted'); + }); + + it('should not trigger nonce conflict for deferred txps with null nonce', async function() { + blockchainExplorer.getTransactionCount = sinon.stub().callsArgWith(1, null, '5'); + + const normalTxp = await helpers.createAndPublishTx(server, { + outputs: [{ toAddress: ETH_ADDR, amount: 1000 }], + feePerKb: 123e2, + from: fromAddr + }, TestData.copayers[0].privKey_1H_0); + normalTxp.nonce.should.equal('5'); + + const deferredTxp = await helpers.createAndPublishTx(server, { + outputs: [{ toAddress: ETH_ADDR, amount: 2000 }], + feePerKb: 123e2, + from: fromAddr, + deferNonce: true + }, TestData.copayers[0].privKey_1H_0); + + const withNonce = await util.promisify(server.assignNonce).call(server, { + txProposalId: deferredTxp.id + }); + withNonce.nonce.should.equal(6); + + helpers.stubBroadcast('txid_normal'); + const sigs1 = helpers.clientSign(normalTxp, TestData.copayers[0].xPrivKey_44H_0H_0H); + const signed1 = await util.promisify(server.signTx).call(server, { + txProposalId: normalTxp.id, + signatures: sigs1 + }); + signed1.status.should.equal('accepted'); + + const fetchedDeferred = await util.promisify(server.getTx).call(server, { txProposalId: deferredTxp.id }); + helpers.stubBroadcast('txid_deferred'); + const sigs2 = helpers.clientSign(fetchedDeferred, TestData.copayers[0].xPrivKey_44H_0H_0H); + const signed2 = await util.promisify(server.signTx).call(server, { + txProposalId: deferredTxp.id, + signatures: sigs2 + }); + signed2.status.should.equal('accepted'); + }); + + it('should complete full lifecycle for a deferred-nonce txp', async function() { + blockchainExplorer.getTransactionCount = sinon.stub().callsArgWith(1, null, '42'); + helpers.stubBroadcast('0xabc123'); + + const txOpts = { + outputs: [{ toAddress: ETH_ADDR, amount: 8000 }], + feePerKb: 123e2, + from: fromAddr, + deferNonce: true + }; + const created = await util.promisify(server.createTx).call(server, txOpts); + created.isTemporary().should.be.true; + created.deferNonce.should.be.true; + should.not.exist(created.nonce); + + const publishOpts = helpers.getProposalSignatureOpts(created, TestData.copayers[0].privKey_1H_0); + const published = await util.promisify(server.publishTx).call(server, publishOpts); + published.status.should.equal('pending'); + should.exist(published.prePublishRaw); + + const withNonce = await util.promisify(server.assignNonce).call(server, { + txProposalId: created.id + }); + withNonce.nonce.should.equal(42); + + const fetched = await util.promisify(server.getTx).call(server, { txProposalId: created.id }); + const signatures = helpers.clientSign(fetched, TestData.copayers[0].xPrivKey_44H_0H_0H); + const signed = await util.promisify(server.signTx).call(server, { + txProposalId: created.id, + signatures + }); + signed.status.should.equal('accepted'); + + const broadcasted = await util.promisify(server.broadcastTx).call(server, { + txProposalId: created.id + }); + broadcasted.status.should.equal('broadcasted'); + should.exist(broadcasted.txid); + }); + + it('should handle bulk sign scenario (3 deferred txps signed sequentially)', async function() { + blockchainExplorer.getTransactionCount = sinon.stub().callsArgWith(1, null, '10'); + + const txps = []; + for (let i = 0; i < 3; i++) { + const txp = await helpers.createAndPublishTx(server, { + outputs: [{ toAddress: ETH_ADDR, amount: 1000 * (i + 1) }], + feePerKb: 123e2, + from: fromAddr, + deferNonce: true + }, TestData.copayers[0].privKey_1H_0); + txps.push(txp); + } + + for (let i = 0; i < txps.length; i++) { + const withNonce = await util.promisify(server.assignNonce).call(server, { + txProposalId: txps[i].id + }); + withNonce.nonce.should.equal(10 + i); + + const fetched = await util.promisify(server.getTx).call(server, { txProposalId: txps[i].id }); + helpers.stubBroadcast(`txid_${i}`); + const signatures = helpers.clientSign(fetched, TestData.copayers[0].xPrivKey_44H_0H_0H); + const signed = await util.promisify(server.signTx).call(server, { + txProposalId: txps[i].id, + signatures + }); + signed.status.should.equal('accepted'); + } + + const pending = await util.promisify(server.getPendingTxs).call(server, {}); + const nonces = pending.map(t => t.nonce).sort(); + nonces.should.deep.equal([10, 11, 12]); + }); + }); + describe('#broadcastTx & #broadcastRawTx', function() { let server: WalletService; let wallet: Model.Wallet;