Skip to content
Draft
30 changes: 30 additions & 0 deletions packages/bitcore-wallet-client/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <assignNonce()>');

const url = '/v1/txproposals/' + opts.txp.id + '/assign-nonce/';
const { body: txp } = await this.request.post<object, Txp>(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
Expand Down
11 changes: 11 additions & 0 deletions packages/bitcore-wallet-service/src/lib/expressapp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down
8 changes: 8 additions & 0 deletions packages/bitcore-wallet-service/src/lib/model/txproposal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export interface ITxProposal {
signingMethod: string;
lowFees?: boolean;
nonce?: number | string;
deferNonce?: boolean;
gasPrice?: number;
maxGasFee?: number;
priorityGasFee?: number;
Expand Down Expand Up @@ -150,6 +151,7 @@ export class TxProposal implements ITxProposal {
lowFees?: boolean;
raw?: Array<string> | string;
nonce?: number | string;
deferNonce?: boolean;
gasPrice?: number;
maxGasFee?: number;
priorityGasFee?: number;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -531,6 +535,10 @@ export class TxProposal implements ITxProposal {
return !!this.refreshOnPublish;
}

hasMutableTxData() {
return this.isRepublishEnabled() || !!this.deferNonce;
}

isTemporary() {
return this.status === 'temporary';
}
Expand Down
74 changes: 68 additions & 6 deletions packages/bitcore-wallet-service/src/lib/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -2906,7 +2907,7 @@ export class WalletService implements IWalletService {
this.storage.fetchTx(this.walletId, opts.txProposalId, (err, txp) => {
if (err) return cb(err);
if (!txp) return cb(Errors.TX_NOT_FOUND);
if (!txp.isTemporary() && !txp.isRepublishEnabled()) return cb(null, txp);
if (!txp.isTemporary() && !txp.hasMutableTxData()) return cb(null, txp);

const copayer = wallet.getCopayer(this.copayerId);

Expand All @@ -2920,7 +2921,7 @@ export class WalletService implements IWalletService {
let signingKey = this._getSigningKey(raw, opts.proposalSignature, copayer.requestPubKeys);
if (!signingKey) {
// If the txp has been published previously, we will verify the signature against the previously published raw tx
if (txp.isRepublishEnabled() && txp.prePublishRaw) {
if (txp.hasMutableTxData() && txp.prePublishRaw) {
raw = txp.prePublishRaw;
signingKey = this._getSigningKey(raw, opts.proposalSignature, copayer.requestPubKeys);
}
Expand All @@ -2943,7 +2944,7 @@ export class WalletService implements IWalletService {
txp.status = 'pending';
ChainService.refreshTxData(this, txp, opts, (err, txp) => {
if (err) return cb(err);
if (txp.isRepublishEnabled() && !txp.prePublishRaw) {
if (txp.hasMutableTxData() && !txp.prePublishRaw) {
// We save the original raw transaction for verification on republish
txp.prePublishRaw = raw;
}
Expand Down Expand Up @@ -3207,7 +3208,7 @@ export class WalletService implements IWalletService {
try {
const txps = await this.getPendingTxsPromise({});
for (const t of txps) {
if (t.id !== txp.id && t.nonce <= txp.nonce && t.status !== 'rejected') {
if (t.id !== txp.id && t.nonce != null && txp.nonce != null && t.nonce <= txp.nonce && t.status !== 'rejected') {
return cb(Errors.TX_NONCE_CONFLICT);
}
}
Expand Down Expand Up @@ -3268,6 +3269,67 @@ export class WalletService implements IWalletService {
});
}

/**
* Assign a fresh nonce to a deferred-nonce transaction proposal.
* Called by the client just before signing.
* @param {Object} opts
* @param {string} opts.txProposalId - The identifier of the transaction.
*/
assignNonce(opts, cb) {
if (!checkRequired(opts, ['txProposalId'], cb)) return;

this._runLocked(cb, cb => {
this.getWallet({}, (err, wallet) => {
if (err) return cb(err);

this.storage.fetchTx(this.walletId, opts.txProposalId, async (err, txp) => {
if (err) return cb(err);
if (!txp) return cb(Errors.TX_NOT_FOUND);
if (!txp.isPending()) return cb(Errors.TX_NOT_PENDING);

if (!txp.deferNonce) {
// Not a deferred-nonce txp. Return it as-is
return cb(null, txp);
}

if (!Constants.EVM_CHAINS[wallet.chain.toUpperCase()]) {
return cb(null, txp);
}

try {
// 1. Get confirmed nonce from blockchain
const confirmedNonce = await ChainService.getTransactionCount(this, wallet, txp.from);

// 2. Get pending TXP nonces from BWS's own database
const pendingTxps = await this.getPendingTxsPromise({});
const pendingNonces = pendingTxps
.filter(t => t.id !== txp.id && t.nonce != null && t.status !== 'rejected')
.map(t => Number(t.nonce));

// 3. Calculate gap-free nonce
let suggestedNonce = Number(confirmedNonce);
const allNonces = pendingNonces.sort((a, b) => a - b);
for (const n of allNonces) {
if (n === suggestedNonce) {
suggestedNonce++;
}
}

txp.nonce = suggestedNonce;

// 4. Store the updated txp
this.storage.storeTx(this.walletId, txp, err => {
if (err) return cb(err);
return cb(null, txp);
});
} catch (err) {
return cb(err);
}
});
});
});
}

_processBroadcast(txp, opts, cb) {
$.checkState(txp.txid, 'Failed state: txp.txid undefined at <_processBroadcast()>');
opts = opts || {};
Expand Down
Loading