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 ac10a350b060eb01835479781cd8d22427d69549 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 1dc3163584080675bc19b18648ecdd693f39085e 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 df0517c0e3e6afbedaeceb58bc6312e53e0631ed 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 46359600651ff141c1650c97da74e5504117f1e2 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 b6e9584f950cc1c22e1d8d816d0ce8d13770be20 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 6a692aac0579671a04c72bad8a35c430cf03ef62 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 822372696c4d3c6444e8d1f040735be97b1494b7 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 20083bfc8dd76a1b3b0627d88dbb42e537b9e5be 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 ff3f461e1bfbf76eaafb6e44cd9db19651edeb73 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 5eb1613379d768e0908d34e56e71d5c102da4ba1 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 efa9f2f20c07185ae3b6eac99288942abc4a3b8f 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 9283cb0773d952ec30b1e15bac7aeaef24e1720c 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 5a8241b0def92359827e951f9b33460bde2e46c8 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 ff742416a3db472f1c7451a66300a0967afe0e2f 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 16e20f28f25cebc7ed8baea3d1e0e8a2420042d4 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 }); });