From 8a0d8e7ca8aec23e8288ad3467490665201fd4ea Mon Sep 17 00:00:00 2001 From: lyambo Date: Thu, 5 Mar 2026 12:14:00 -0500 Subject: [PATCH 01/16] 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 02/16] 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 03/16] 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 04/16] 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 05/16] 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 06/16] 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 07/16] 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 08/16] 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 09/16] 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; From f92693751e5b355ab5b813598e143498499bda6b Mon Sep 17 00:00:00 2001 From: lyambo Date: Thu, 19 Mar 2026 12:53:02 -0400 Subject: [PATCH 10/16] Fix NaN nonce crash in getBitcoreTx for deferred txps Number(undefined) produces NaN which BigInt cannot convert. Use 0 as placeholder when nonce is null so checkTx can still build the transaction during createTx. Also reset the getTransactionCount stub in test beforeEach to prevent cross-test contamination. --- packages/bitcore-wallet-service/src/lib/chain/eth/index.ts | 5 +++-- .../bitcore-wallet-service/test/integration/server.test.ts | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/bitcore-wallet-service/src/lib/chain/eth/index.ts b/packages/bitcore-wallet-service/src/lib/chain/eth/index.ts index 978dc5fca9f..531783bd622 100644 --- a/packages/bitcore-wallet-service/src/lib/chain/eth/index.ts +++ b/packages/bitcore-wallet-service/src/lib/chain/eth/index.ts @@ -317,9 +317,10 @@ export class EthChain implements IChain { } const unsignedTxs = []; + const nonceNum = txp.nonce != null ? Number(txp.nonce) : 0; if (multiSendContractAddress) { const multiSendParams = { - nonce: Number(txp.nonce), + nonce: nonceNum, recipients, contractAddress: multiSendContractAddress }; @@ -330,7 +331,7 @@ export class EthChain implements IChain { // Uses gas limit from the txp output level const params = { ...recipients[index], - nonce: Number(txp.nonce) + Number(index), + nonce: nonceNum + Number(index), recipients: [recipients[index]] }; unsignedTxs.push(Transactions.create({ ...txp, chain, ...params })); diff --git a/packages/bitcore-wallet-service/test/integration/server.test.ts b/packages/bitcore-wallet-service/test/integration/server.test.ts index 9f141d33496..77ecd9fe269 100644 --- a/packages/bitcore-wallet-service/test/integration/server.test.ts +++ b/packages/bitcore-wallet-service/test/integration/server.test.ts @@ -7810,6 +7810,7 @@ describe('Wallet service', function() { const address = await util.promisify(server.createAddress).call(server, {}); fromAddr = address.address; await helpers.stubUtxos(server, wallet, [1, 2], { coin: 'eth' }); + blockchainExplorer.getTransactionCount = sinon.stub().callsArgWith(1, null, '5'); }); it('should create txp with nonce=null when deferNonce is true', async function() { From b0f2a3c6d4030bfd64e833e89d940578febd5c04 Mon Sep 17 00:00:00 2001 From: lyambo Date: Thu, 19 Mar 2026 14:00:12 -0400 Subject: [PATCH 11/16] Fix test stubs for broadcast and nonce conflict stubBroadcast must be called after signTx with the actual txid so the returned txid matches txp.txid. Tests that sign multiple txps need to broadcast each one before signing the next, otherwise the accepted txp blocks with TX_NONCE_CONFLICT. --- .../test/integration/server.test.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/bitcore-wallet-service/test/integration/server.test.ts b/packages/bitcore-wallet-service/test/integration/server.test.ts index 77ecd9fe269..1ba6aa1c81a 100644 --- a/packages/bitcore-wallet-service/test/integration/server.test.ts +++ b/packages/bitcore-wallet-service/test/integration/server.test.ts @@ -8031,16 +8031,16 @@ describe('Wallet service', function() { }); 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'); + helpers.stubBroadcast(signed1.txid); + await util.promisify(server.broadcastTx).call(server, { txProposalId: normalTxp.id }); 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, @@ -8051,7 +8051,6 @@ describe('Wallet service', function() { 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 }], @@ -8082,6 +8081,7 @@ describe('Wallet service', function() { }); signed.status.should.equal('accepted'); + helpers.stubBroadcast(signed.txid); const broadcasted = await util.promisify(server.broadcastTx).call(server, { txProposalId: created.id }); @@ -8103,25 +8103,26 @@ describe('Wallet service', function() { txps.push(txp); } + const assignedNonces = []; 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); + assignedNonces.push(withNonce.nonce); 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'); + helpers.stubBroadcast(signed.txid); + await util.promisify(server.broadcastTx).call(server, { txProposalId: txps[i].id }); } - const pending = await util.promisify(server.getPendingTxs).call(server, {}); - const nonces = pending.map(t => t.nonce).sort(); - nonces.should.deep.equal([10, 11, 12]); + assignedNonces.should.deep.equal([10, 11, 12]); }); }); From 4e9a129ae2ab135a4a4b6cf530f64b2ff5cb3d5d Mon Sep 17 00:00:00 2001 From: lyambo Date: Thu, 19 Mar 2026 14:12:11 -0400 Subject: [PATCH 12/16] Simplify bulk nonce test to avoid stale stub The getTransactionCount stub is static so broadcasting resets the nonce calculation. The gap-free logic already handles pending txps so sequential assignNonce calls produce correct nonces without needing to sign and broadcast each one. --- .../test/integration/server.test.ts | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/packages/bitcore-wallet-service/test/integration/server.test.ts b/packages/bitcore-wallet-service/test/integration/server.test.ts index 1ba6aa1c81a..bcc8b3b5199 100644 --- a/packages/bitcore-wallet-service/test/integration/server.test.ts +++ b/packages/bitcore-wallet-service/test/integration/server.test.ts @@ -8110,16 +8110,6 @@ describe('Wallet service', function() { }); withNonce.nonce.should.equal(10 + i); assignedNonces.push(withNonce.nonce); - - const fetched = await util.promisify(server.getTx).call(server, { txProposalId: txps[i].id }); - 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'); - helpers.stubBroadcast(signed.txid); - await util.promisify(server.broadcastTx).call(server, { txProposalId: txps[i].id }); } assignedNonces.should.deep.equal([10, 11, 12]); From e9884e34fdef37abd3fee82e30f462b73500c695 Mon Sep 17 00:00:00 2001 From: lyambo Date: Mon, 23 Mar 2026 17:05:00 -0400 Subject: [PATCH 13/16] Rename assignNonce to prepareTx in BWS Generalize the endpoint name to reflect that it will handle more than just nonce assignment (fee, gas) in the future. Add /prepare/ route and drop the old /assign-nonce/ route since the branch hasn't shipped yet. --- packages/bitcore-wallet-service/src/lib/expressapp.ts | 6 ++++-- packages/bitcore-wallet-service/src/lib/server.ts | 5 +++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/bitcore-wallet-service/src/lib/expressapp.ts b/packages/bitcore-wallet-service/src/lib/expressapp.ts index 7e065494d85..04c4aeba2b3 100644 --- a/packages/bitcore-wallet-service/src/lib/expressapp.ts +++ b/packages/bitcore-wallet-service/src/lib/expressapp.ts @@ -1069,10 +1069,10 @@ export class ExpressApp { }); */ - router.post('/v1/txproposals/:id/assign-nonce/', (req, res) => { + router.post('/v1/txproposals/:id/prepare/', (req, res) => { getServerWithAuth(req, res, server => { req.body.txProposalId = req.params['id']; - server.assignNonce(req.body, (err, txp) => { + server.prepareTx(req.body, (err, txp) => { if (err) return returnError(err, res, req); res.json(txp); res.end(); @@ -1080,6 +1080,8 @@ export class ExpressApp { }); }); + + // 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 af75d3581c6..1ada23545e8 100644 --- a/packages/bitcore-wallet-service/src/lib/server.ts +++ b/packages/bitcore-wallet-service/src/lib/server.ts @@ -3270,12 +3270,13 @@ export class WalletService implements IWalletService { } /** - * Assign a fresh nonce to a deferred-nonce transaction proposal. + * Prepare a transaction proposal for signing. + * Assigns JIT values (nonce, and in the future: fee, gas) to a deferred txp. * Called by the client just before signing. * @param {Object} opts * @param {string} opts.txProposalId - The identifier of the transaction. */ - assignNonce(opts, cb) { + prepareTx(opts, cb) { if (!checkRequired(opts, ['txProposalId'], cb)) return; this._runLocked(cb, cb => { From 868e7ec09c8377e8a2397100e0f5cc7f77af4274 Mon Sep 17 00:00:00 2001 From: lyambo Date: Tue, 24 Mar 2026 09:40:00 -0400 Subject: [PATCH 14/16] Rename assignNonce to prepareTx in BWC Pure async, no callback support. Hits the new /prepare/ endpoint. --- packages/bitcore-wallet-client/src/lib/api.ts | 33 +++++-------------- 1 file changed, 9 insertions(+), 24 deletions(-) diff --git a/packages/bitcore-wallet-client/src/lib/api.ts b/packages/bitcore-wallet-client/src/lib/api.ts index cf6a1b6e014..a65943532b0 100644 --- a/packages/bitcore-wallet-client/src/lib/api.ts +++ b/packages/bitcore-wallet-client/src/lib/api.ts @@ -2063,33 +2063,18 @@ export class API extends EventEmitter { } /** - * Assign a fresh nonce to a deferred-nonce transaction proposal. + * Prepare a transaction proposal for signing. + * Assigns JIT values (nonce, and in the future: fee, gas) to a deferred txp. * 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 '); + async prepareTx(opts: { txp: Txp }): Promise { + $.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; - } + const url = '/v1/txproposals/' + opts.txp.id + '/prepare/'; + const { body: txp } = await this.request.post(url, {}); + this._processTxps(txp); + return txp; } /** From cd6b6603d4ee5e9843f58219263eca77d3a0bcc9 Mon Sep 17 00:00:00 2001 From: lyambo Date: Tue, 24 Mar 2026 11:20:00 -0400 Subject: [PATCH 15/16] Update tests for prepareTx rename Rename describe block and all method calls from assignNonce to prepareTx. Add extensibility baseline test. --- .../test/integration/server.test.ts | 48 ++++++++++++++----- 1 file changed, 36 insertions(+), 12 deletions(-) diff --git a/packages/bitcore-wallet-service/test/integration/server.test.ts b/packages/bitcore-wallet-service/test/integration/server.test.ts index bcc8b3b5199..c44370c96d4 100644 --- a/packages/bitcore-wallet-service/test/integration/server.test.ts +++ b/packages/bitcore-wallet-service/test/integration/server.test.ts @@ -7801,7 +7801,7 @@ describe('Wallet service', function() { }); }); - describe('#assignNonce (deferred nonce)', function() { + describe('#prepareTx (deferred nonce)', function() { const ETH_ADDR = '0x37d7B3bBD88EFdE6a93cF74D2F5b0385D3E3B08A'; let server, wallet, fromAddr; @@ -7868,7 +7868,7 @@ describe('Wallet service', function() { should.not.exist(txp.nonce); - const result = await util.promisify(server.assignNonce).call(server, { + const result = await util.promisify(server.prepareTx).call(server, { txProposalId: txp.id }); should.exist(result); @@ -7884,14 +7884,14 @@ describe('Wallet service', function() { txp.nonce.should.equal('5'); - const result = await util.promisify(server.assignNonce).call(server, { + const result = await util.promisify(server.prepareTx).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) { + server.prepareTx({ txProposalId: 'nonexistent' }, function(err) { should.exist(err); err.message.should.contain('not found'); done(); @@ -7916,7 +7916,7 @@ describe('Wallet service', function() { }, TestData.copayers[0].privKey_1H_0); should.not.exist(txp2.nonce); - const result = await util.promisify(server.assignNonce).call(server, { + const result = await util.promisify(server.prepareTx).call(server, { txProposalId: txp2.id }); result.nonce.should.equal(6); @@ -7938,7 +7938,7 @@ describe('Wallet service', function() { const results = []; for (const txp of txps) { - const result = await util.promisify(server.assignNonce).call(server, { + const result = await util.promisify(server.prepareTx).call(server, { txProposalId: txp.id }); results.push(result); @@ -7973,12 +7973,12 @@ describe('Wallet service', function() { deferNonce: true }, TestData.copayers[0].privKey_1H_0); - const result1 = await util.promisify(server.assignNonce).call(server, { + const result1 = await util.promisify(server.prepareTx).call(server, { txProposalId: deferred1.id }); result1.nonce.should.equal(4); - const result2 = await util.promisify(server.assignNonce).call(server, { + const result2 = await util.promisify(server.prepareTx).call(server, { txProposalId: deferred2.id }); result2.nonce.should.equal(5); @@ -7995,7 +7995,7 @@ describe('Wallet service', function() { deferNonce: true }, TestData.copayers[0].privKey_1H_0); - const withNonce = await util.promisify(server.assignNonce).call(server, { + const withNonce = await util.promisify(server.prepareTx).call(server, { txProposalId: txp.id }); withNonce.nonce.should.equal(7); @@ -8026,7 +8026,7 @@ describe('Wallet service', function() { deferNonce: true }, TestData.copayers[0].privKey_1H_0); - const withNonce = await util.promisify(server.assignNonce).call(server, { + const withNonce = await util.promisify(server.prepareTx).call(server, { txProposalId: deferredTxp.id }); withNonce.nonce.should.equal(6); @@ -8068,7 +8068,7 @@ describe('Wallet service', function() { published.status.should.equal('pending'); should.exist(published.prePublishRaw); - const withNonce = await util.promisify(server.assignNonce).call(server, { + const withNonce = await util.promisify(server.prepareTx).call(server, { txProposalId: created.id }); withNonce.nonce.should.equal(42); @@ -8105,7 +8105,7 @@ describe('Wallet service', function() { const assignedNonces = []; for (let i = 0; i < txps.length; i++) { - const withNonce = await util.promisify(server.assignNonce).call(server, { + const withNonce = await util.promisify(server.prepareTx).call(server, { txProposalId: txps[i].id }); withNonce.nonce.should.equal(10 + i); @@ -8114,6 +8114,30 @@ describe('Wallet service', function() { assignedNonces.should.deep.equal([10, 11, 12]); }); + + it('should return txp with nonce set (extensibility baseline)', async function() { + const txOpts = helpers.createSimpleProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 0.01, TestData.copayers[0].privKey_1H_0, { + chain: 'eth', + coin: 'eth', + from: fromAddr, + nonce: null, + deferNonce: true + }); + server.createTx(txOpts, function(err, txp) { + should.not.exist(err); + should.not.exist(txp.nonce); + const publishOpts = helpers.getProposalSignatureOpts(txp, TestData.copayers[0].privKey_1H_0); + server.publishTx(publishOpts, function(err, publishedTxp) { + should.not.exist(err); + server.prepareTx({ txProposalId: publishedTxp.id }, function(err, prepared) { + should.not.exist(err); + prepared.nonce.should.be.a('number'); + prepared.nonce.should.equal(5); + // future: prepared.gasPrice, prepared.maxFee would also be set here + }); + }); + }); + }); }); describe('#broadcastTx & #broadcastRawTx', function() { From ff3508f7a88f75d31624c15a6ac45838d1f35b0b Mon Sep 17 00:00:00 2001 From: lyambo Date: Tue, 24 Mar 2026 15:10:00 -0400 Subject: [PATCH 16/16] Fix extensibility test to use existing helpers --- .../test/integration/server.test.ts | 30 ++++++++----------- 1 file changed, 12 insertions(+), 18 deletions(-) diff --git a/packages/bitcore-wallet-service/test/integration/server.test.ts b/packages/bitcore-wallet-service/test/integration/server.test.ts index c44370c96d4..77f1913c9ee 100644 --- a/packages/bitcore-wallet-service/test/integration/server.test.ts +++ b/packages/bitcore-wallet-service/test/integration/server.test.ts @@ -8116,27 +8116,21 @@ describe('Wallet service', function() { }); it('should return txp with nonce set (extensibility baseline)', async function() { - const txOpts = helpers.createSimpleProposalOpts('18PzpUFkFZE8zKWUPvfykkTxmB9oMR8qP7', 0.01, TestData.copayers[0].privKey_1H_0, { - chain: 'eth', - coin: 'eth', + const txp = await helpers.createAndPublishTx(server, { + outputs: [{ toAddress: ETH_ADDR, amount: 8000 }], + feePerKb: 123e2, from: fromAddr, - nonce: null, deferNonce: true + }, TestData.copayers[0].privKey_1H_0); + + should.not.exist(txp.nonce); + + const result = await util.promisify(server.prepareTx).call(server, { + txProposalId: txp.id }); - server.createTx(txOpts, function(err, txp) { - should.not.exist(err); - should.not.exist(txp.nonce); - const publishOpts = helpers.getProposalSignatureOpts(txp, TestData.copayers[0].privKey_1H_0); - server.publishTx(publishOpts, function(err, publishedTxp) { - should.not.exist(err); - server.prepareTx({ txProposalId: publishedTxp.id }, function(err, prepared) { - should.not.exist(err); - prepared.nonce.should.be.a('number'); - prepared.nonce.should.equal(5); - // future: prepared.gasPrice, prepared.maxFee would also be set here - }); - }); - }); + result.nonce.should.be.a('number'); + result.nonce.should.equal(5); + // future: result.gasPrice, result.maxFee would also be set here }); });