From 8a0d8e7ca8aec23e8288ad3467490665201fd4ea Mon Sep 17 00:00:00 2001 From: lyambo Date: Thu, 5 Mar 2026 12:14:00 -0500 Subject: [PATCH 1/9] Add deferNonce field to TxProposal model EVM nonces assigned at txp creation time go stale when proposals sit waiting for signing. Add an opt-in deferNonce boolean so callers can signal that nonce assignment should happen later. Field added to ITxProposal interface, TxProposal class, create(), and fromObj() for persistence round-tripping. --- packages/bitcore-wallet-service/src/lib/model/txproposal.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/bitcore-wallet-service/src/lib/model/txproposal.ts b/packages/bitcore-wallet-service/src/lib/model/txproposal.ts index 2113b4e3062..adf156c68fa 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; From 61c6d5f4de78ded216ca010db2e8510927f422d4 Mon Sep 17 00:00:00 2001 From: lyambo Date: Thu, 5 Mar 2026 16:42:00 -0500 Subject: [PATCH 2/9] Skip nonce fetch in createTx for deferred proposals When deferNonce is true, skip the getTransactionCount call during createTx and pass the flag through to TxProposal.create() so it persists on the proposal object. --- packages/bitcore-wallet-service/src/lib/server.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/bitcore-wallet-service/src/lib/server.ts b/packages/bitcore-wallet-service/src/lib/server.ts index a53a98ada93..a96561b3e40 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(); From b446688d629c5f91cce1eda8e5c53c8ffca2c2de Mon Sep 17 00:00:00 2001 From: lyambo Date: Fri, 6 Mar 2026 10:37:00 -0500 Subject: [PATCH 3/9] Support deferred-nonce proposals in publishTx Allow deferred-nonce txps to be published and re-published. Use prePublishRaw to store the original unsigned tx so proposal signature verification still works after nonce is assigned later. --- packages/bitcore-wallet-service/src/lib/server.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/bitcore-wallet-service/src/lib/server.ts b/packages/bitcore-wallet-service/src/lib/server.ts index a96561b3e40..8f2223a1614 100644 --- a/packages/bitcore-wallet-service/src/lib/server.ts +++ b/packages/bitcore-wallet-service/src/lib/server.ts @@ -2907,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.isRepublishEnabled() && !txp.deferNonce) return cb(null, txp); const copayer = wallet.getCopayer(this.copayerId); @@ -2921,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.isRepublishEnabled() || txp.deferNonce) && txp.prePublishRaw) { raw = txp.prePublishRaw; signingKey = this._getSigningKey(raw, opts.proposalSignature, copayer.requestPubKeys); } @@ -2944,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.isRepublishEnabled() || txp.deferNonce) && !txp.prePublishRaw) { // We save the original raw transaction for verification on republish txp.prePublishRaw = raw; } From 9c36bb6baeac40343648f86dff0a9d89c24dc25c Mon Sep 17 00:00:00 2001 From: lyambo Date: Fri, 6 Mar 2026 15:08:00 -0500 Subject: [PATCH 4/9] Guard signTx nonce conflict check for null nonces Deferred-nonce proposals have nonce=null until assignNonce is called. Without null guards, the nonce comparison would coerce null to 0 and falsely trigger TX_NONCE_CONFLICT. --- packages/bitcore-wallet-service/src/lib/server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/bitcore-wallet-service/src/lib/server.ts b/packages/bitcore-wallet-service/src/lib/server.ts index 8f2223a1614..7dad791f540 100644 --- a/packages/bitcore-wallet-service/src/lib/server.ts +++ b/packages/bitcore-wallet-service/src/lib/server.ts @@ -3208,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); } } From 278352abcc2fc69341832c3055191be586b90660 Mon Sep 17 00:00:00 2001 From: lyambo Date: Mon, 9 Mar 2026 11:22:00 -0400 Subject: [PATCH 5/9] Add assignNonce endpoint for JIT nonce assignment New endpoint lets the client request a fresh nonce just before signing. The handler fetches the confirmed nonce from the blockchain, skips past any pending proposal nonces in the database, and stores the gap-free result on the txp. Runs under _runLocked to prevent concurrent calls from receiving the same nonce for the same wallet. --- .../src/lib/expressapp.ts | 11 ++++ .../bitcore-wallet-service/src/lib/server.ts | 61 +++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/packages/bitcore-wallet-service/src/lib/expressapp.ts b/packages/bitcore-wallet-service/src/lib/expressapp.ts index 349ca34ef9c..7e065494d85 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/server.ts b/packages/bitcore-wallet-service/src/lib/server.ts index 7dad791f540..33967f96194 100644 --- a/packages/bitcore-wallet-service/src/lib/server.ts +++ b/packages/bitcore-wallet-service/src/lib/server.ts @@ -3269,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 || {}; From a7eb39e6500859e5b1852982dd16214e1201305d Mon Sep 17 00:00:00 2001 From: lyambo Date: Tue, 10 Mar 2026 10:51:00 -0400 Subject: [PATCH 6/9] Add assignNonce client method to BWC Calls POST /v1/txproposals/{id}/assign-nonce/ so the app can request a fresh nonce from BWS just before signing a deferred-nonce transaction proposal. --- packages/bitcore-wallet-client/src/lib/api.ts | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/packages/bitcore-wallet-client/src/lib/api.ts b/packages/bitcore-wallet-client/src/lib/api.ts index d43d3458623..cf6a1b6e014 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 From d835188d5a9af9a762ef4b7d04df2dda848596f8 Mon Sep 17 00:00:00 2001 From: lyambo Date: Wed, 11 Mar 2026 15:33:00 -0400 Subject: [PATCH 7/9] Add integration tests for deferred nonce flow Cover the full lifecycle: createTx with deferNonce, publishTx with prePublishRaw, assignNonce with gap-free calculation, signTx without false nonce conflicts, and broadcast. Includes a bulk-sign scenario that assigns sequential nonces to three deferred proposals signed one after another. --- .../test/integration/deferNonce.test.ts | 420 ++++++++++++++++++ 1 file changed, 420 insertions(+) create mode 100644 packages/bitcore-wallet-service/test/integration/deferNonce.test.ts diff --git a/packages/bitcore-wallet-service/test/integration/deferNonce.test.ts b/packages/bitcore-wallet-service/test/integration/deferNonce.test.ts new file mode 100644 index 00000000000..b388e3f1ac5 --- /dev/null +++ b/packages/bitcore-wallet-service/test/integration/deferNonce.test.ts @@ -0,0 +1,420 @@ +'use strict'; + +import * as chai from 'chai'; +import 'chai/register-should'; +import util from 'util'; +import sinon from 'sinon'; +import * as TestData from '../testdata'; +import helpers from './helpers'; + +const should = chai.should(); + +describe('Deferred Nonce (JIT EVM Nonce)', function() { + let blockchainExplorer; + const ETH_ADDR = '0x37d7B3bBD88EFdE6a93cF74D2F5b0385D3E3B08A'; + + before(async function() { + const res = await helpers.before(); + blockchainExplorer = res.blockchainExplorer; + }); + + beforeEach(async function() { + await helpers.beforeEach(); + }); + + after(async function() { + await helpers.after(); + }); + + describe('#createTx with deferNonce', function() { + 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 false/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'); // from default mock + }); + }); + + describe('#publishTx with deferNonce', function() { + 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 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); + }); + }); + + describe('#assignNonce', function() { + 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 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'); // unchanged + }); + + 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'); + + // Create and publish first txp with normal nonce (nonce=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'); + + // Create second deferred-nonce txp + 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); + + // assignNonce should skip nonce 5 (taken by txp1) + 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'); + + // Create 3 deferred-nonce txps + 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); + } + + // Assign nonces sequentially (simulates bulk sign) + 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'); + + // Normal txp gets nonce 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'); + + // Two deferred txps + 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); + + // First deferred should skip nonce 3 (used by normalTxp) + const result1 = await util.promisify(server.assignNonce).call(server, { + txProposalId: deferred1.id + }); + result1.nonce.should.equal(4); + + // Second deferred should skip 3 and 4 + const result2 = await util.promisify(server.assignNonce).call(server, { + txProposalId: deferred2.id + }); + result2.nonce.should.equal(5); + }); + }); + + describe('#signTx with deferNonce', function() { + 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 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); + + // Assign nonce + const withNonce = await util.promisify(server.assignNonce).call(server, { + txProposalId: txp.id + }); + withNonce.nonce.should.equal(7); + + // Re-fetch to get the stored txp with nonce (as client would receive) + const fetched = await util.promisify(server.getTx).call(server, { txProposalId: txp.id }); + + // Sign + 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'); + + // Normal txp with nonce 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'); + + // Deferred txp (nonce=null). Should not conflict with normalTxp + const deferredTxp = await helpers.createAndPublishTx(server, { + outputs: [{ toAddress: ETH_ADDR, amount: 2000 }], + feePerKb: 123e2, + from: fromAddr, + deferNonce: true + }, TestData.copayers[0].privKey_1H_0); + + // Assign nonce. Should get 6 (skipping 5) + const withNonce = await util.promisify(server.assignNonce).call(server, { + txProposalId: deferredTxp.id + }); + withNonce.nonce.should.equal(6); + + // Sign both. Neither should fail with TX_NONCE_CONFLICT + 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'); + + // Re-fetch deferred txp for signing + 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'); + }); + }); + + describe('Full flow: create → publish → assignNonce → sign → broadcast', function() { + 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 complete full lifecycle for a deferred-nonce txp', async function() { + blockchainExplorer.getTransactionCount = sinon.stub().callsArgWith(1, null, '42'); + helpers.stubBroadcast('0xabc123'); + + // 1. Create + 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); + + // 2. Publish + 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); + + // 3. Assign nonce + const withNonce = await util.promisify(server.assignNonce).call(server, { + txProposalId: created.id + }); + withNonce.nonce.should.equal(42); + + // 4. Sign + 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'); + + // 5. Broadcast + 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'); + + // Create and publish 3 deferred-nonce txps + 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); + } + + // Sign each sequentially: assignNonce → sign → next + 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'); + } + + // Verify all 3 have sequential nonces + const pending = await util.promisify(server.getPendingTxs).call(server, {}); + const nonces = pending.map(t => t.nonce).sort(); + nonces.should.deep.equal([10, 11, 12]); + }); + }); +}); From af990d79b8dff1e1d0a4bfaa30f7ee9089aa95ab Mon Sep 17 00:00:00 2001 From: lyambo Date: Fri, 13 Mar 2026 16:11:00 -0400 Subject: [PATCH 8/9] Extract hasMutableTxData to simplify publishTx guards The repeated isRepublishEnabled() || deferNonce pattern in publishTx exists because both Solana blockhash refresh and EVM deferred nonce share the same need: preserve prePublishRaw for signature verification after the raw tx changes. Also remove unnecessary spread-copy before sorting pendingNonces in assignNonce since filter/map already produces a new array. --- .../bitcore-wallet-service/src/lib/model/txproposal.ts | 4 ++++ packages/bitcore-wallet-service/src/lib/server.ts | 8 ++++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/bitcore-wallet-service/src/lib/model/txproposal.ts b/packages/bitcore-wallet-service/src/lib/model/txproposal.ts index adf156c68fa..bc5e7c24a24 100644 --- a/packages/bitcore-wallet-service/src/lib/model/txproposal.ts +++ b/packages/bitcore-wallet-service/src/lib/model/txproposal.ts @@ -535,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 33967f96194..af75d3581c6 100644 --- a/packages/bitcore-wallet-service/src/lib/server.ts +++ b/packages/bitcore-wallet-service/src/lib/server.ts @@ -2907,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() && !txp.deferNonce) return cb(null, txp); + if (!txp.isTemporary() && !txp.hasMutableTxData()) return cb(null, txp); const copayer = wallet.getCopayer(this.copayerId); @@ -2921,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.deferNonce) && txp.prePublishRaw) { + if (txp.hasMutableTxData() && txp.prePublishRaw) { raw = txp.prePublishRaw; signingKey = this._getSigningKey(raw, opts.proposalSignature, copayer.requestPubKeys); } @@ -2944,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.deferNonce) && !txp.prePublishRaw) { + if (txp.hasMutableTxData() && !txp.prePublishRaw) { // We save the original raw transaction for verification on republish txp.prePublishRaw = raw; } @@ -3308,7 +3308,7 @@ export class WalletService implements IWalletService { // 3. Calculate gap-free nonce let suggestedNonce = Number(confirmedNonce); - const allNonces = [...pendingNonces].sort((a, b) => a - b); + const allNonces = pendingNonces.sort((a, b) => a - b); for (const n of allNonces) { if (n === suggestedNonce) { suggestedNonce++; From a86f3817cf94929eab0e07b0a37f6a86d77012e5 Mon Sep 17 00:00:00 2001 From: lyambo Date: Fri, 13 Mar 2026 16:24:00 -0400 Subject: [PATCH 9/9] Move deferred nonce tests into server.test.ts Consolidate into the existing integration test file rather than maintaining a separate file for one feature. Tests are placed under a new #assignNonce describe block between #signTx and #broadcastTx to match the transaction lifecycle order. --- .../test/integration/deferNonce.test.ts | 420 ------------------ .../test/integration/server.test.ts | 323 ++++++++++++++ 2 files changed, 323 insertions(+), 420 deletions(-) delete mode 100644 packages/bitcore-wallet-service/test/integration/deferNonce.test.ts diff --git a/packages/bitcore-wallet-service/test/integration/deferNonce.test.ts b/packages/bitcore-wallet-service/test/integration/deferNonce.test.ts deleted file mode 100644 index b388e3f1ac5..00000000000 --- a/packages/bitcore-wallet-service/test/integration/deferNonce.test.ts +++ /dev/null @@ -1,420 +0,0 @@ -'use strict'; - -import * as chai from 'chai'; -import 'chai/register-should'; -import util from 'util'; -import sinon from 'sinon'; -import * as TestData from '../testdata'; -import helpers from './helpers'; - -const should = chai.should(); - -describe('Deferred Nonce (JIT EVM Nonce)', function() { - let blockchainExplorer; - const ETH_ADDR = '0x37d7B3bBD88EFdE6a93cF74D2F5b0385D3E3B08A'; - - before(async function() { - const res = await helpers.before(); - blockchainExplorer = res.blockchainExplorer; - }); - - beforeEach(async function() { - await helpers.beforeEach(); - }); - - after(async function() { - await helpers.after(); - }); - - describe('#createTx with deferNonce', function() { - 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 false/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'); // from default mock - }); - }); - - describe('#publishTx with deferNonce', function() { - 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 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); - }); - }); - - describe('#assignNonce', function() { - 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 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'); // unchanged - }); - - 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'); - - // Create and publish first txp with normal nonce (nonce=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'); - - // Create second deferred-nonce txp - 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); - - // assignNonce should skip nonce 5 (taken by txp1) - 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'); - - // Create 3 deferred-nonce txps - 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); - } - - // Assign nonces sequentially (simulates bulk sign) - 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'); - - // Normal txp gets nonce 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'); - - // Two deferred txps - 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); - - // First deferred should skip nonce 3 (used by normalTxp) - const result1 = await util.promisify(server.assignNonce).call(server, { - txProposalId: deferred1.id - }); - result1.nonce.should.equal(4); - - // Second deferred should skip 3 and 4 - const result2 = await util.promisify(server.assignNonce).call(server, { - txProposalId: deferred2.id - }); - result2.nonce.should.equal(5); - }); - }); - - describe('#signTx with deferNonce', function() { - 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 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); - - // Assign nonce - const withNonce = await util.promisify(server.assignNonce).call(server, { - txProposalId: txp.id - }); - withNonce.nonce.should.equal(7); - - // Re-fetch to get the stored txp with nonce (as client would receive) - const fetched = await util.promisify(server.getTx).call(server, { txProposalId: txp.id }); - - // Sign - 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'); - - // Normal txp with nonce 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'); - - // Deferred txp (nonce=null). Should not conflict with normalTxp - const deferredTxp = await helpers.createAndPublishTx(server, { - outputs: [{ toAddress: ETH_ADDR, amount: 2000 }], - feePerKb: 123e2, - from: fromAddr, - deferNonce: true - }, TestData.copayers[0].privKey_1H_0); - - // Assign nonce. Should get 6 (skipping 5) - const withNonce = await util.promisify(server.assignNonce).call(server, { - txProposalId: deferredTxp.id - }); - withNonce.nonce.should.equal(6); - - // Sign both. Neither should fail with TX_NONCE_CONFLICT - 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'); - - // Re-fetch deferred txp for signing - 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'); - }); - }); - - describe('Full flow: create → publish → assignNonce → sign → broadcast', function() { - 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 complete full lifecycle for a deferred-nonce txp', async function() { - blockchainExplorer.getTransactionCount = sinon.stub().callsArgWith(1, null, '42'); - helpers.stubBroadcast('0xabc123'); - - // 1. Create - 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); - - // 2. Publish - 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); - - // 3. Assign nonce - const withNonce = await util.promisify(server.assignNonce).call(server, { - txProposalId: created.id - }); - withNonce.nonce.should.equal(42); - - // 4. Sign - 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'); - - // 5. Broadcast - 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'); - - // Create and publish 3 deferred-nonce txps - 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); - } - - // Sign each sequentially: assignNonce → sign → next - 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'); - } - - // Verify all 3 have sequential nonces - const pending = await util.promisify(server.getPendingTxs).call(server, {}); - const nonces = pending.map(t => t.nonce).sort(); - nonces.should.deep.equal([10, 11, 12]); - }); - }); -}); diff --git a/packages/bitcore-wallet-service/test/integration/server.test.ts b/packages/bitcore-wallet-service/test/integration/server.test.ts index db1c8c81304..9f141d33496 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;