@bitpay-labs/bitcore-wallet-service 11.10.12 → 11.10.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@bitpay-labs/bitcore-wallet-service",
3
3
  "description": "A service for Mutisig HD Bitcoin Wallets",
4
4
  "author": "BitPay Inc",
5
- "version": "11.10.12",
5
+ "version": "11.10.13",
6
6
  "license": "MIT",
7
7
  "main": "ts_build/src/index.js",
8
8
  "types": "./ts_build/src/index.d.ts",
@@ -42,8 +42,8 @@
42
42
  },
43
43
  "dependencies": {
44
44
  "@bitpay-labs/bitcore-logging": "^11.10.9",
45
- "@bitpay-labs/bitcore-tss": "^11.10.12",
46
- "@bitpay-labs/crypto-wallet-core": "^11.10.12",
45
+ "@bitpay-labs/bitcore-tss": "^11.10.13",
46
+ "@bitpay-labs/crypto-wallet-core": "^11.10.13",
47
47
  "@sendgrid/mail": "6.5.4",
48
48
  "abi-decoder": "2.4.0",
49
49
  "async": "0.9.2",
@@ -107,5 +107,5 @@
107
107
  "email": "kjoseph@bitpay.com"
108
108
  }
109
109
  ],
110
- "gitHead": "d1992fd744501a1b69b8cb97f07b0896df7223fd"
110
+ "gitHead": "c51c900f0009f1237ef30dca2c35922381864491"
111
111
  }
@@ -560,7 +560,8 @@ export class MoonpayService {
560
560
  if (err) {
561
561
  return reject(err.body ? err.body : err);
562
562
  } else {
563
- return resolve(data.body ? data.body : data);
563
+ const success = data.statusCode >= 200 && data.statusCode < 300;
564
+ return resolve(data.body ? data.body : { success });
564
565
  }
565
566
  }
566
567
  );
@@ -327,8 +327,8 @@ export class BtcChain implements IChain {
327
327
 
328
328
  // if TX is ready? no estimation is needed.
329
329
  if (txp.inputs.length && !txp.changeAddress && txp.outputs.length) {
330
- const totalInputs = txp.inputs.reduce((sum, x) => sum + x.satoshis, 0);
331
- const totalOutputs = txp.outputs.reduce((sum, x) => sum + x.amount, 0);
330
+ const totalInputs = txp.inputs.reduce((sum, x) => sum + Number(x.satoshis), 0);
331
+ const totalOutputs = txp.outputs.reduce((sum, x) => sum + Number(x.amount), 0);
332
332
  if (totalInputs && totalOutputs) {
333
333
  fee = totalInputs - totalOutputs;
334
334
  }
@@ -401,24 +401,24 @@ export class BtcChain implements IChain {
401
401
  }
402
402
 
403
403
  for (const o of txp.outputs || []) {
404
- o.amount = parseInt(o.amount);
404
+ const amount = parseInt(o.amount);
405
405
  $.checkState(o.script || o.toAddress, 'Failed state: Output should have either toAddress or script specified at <getBitcoreTx()>');
406
406
  if (o.script) {
407
407
  t.addOutput(
408
408
  new this.bitcoreLib.Transaction.Output({
409
409
  script: o.script,
410
- satoshis: o.amount
410
+ satoshis: amount
411
411
  })
412
412
  );
413
413
  } else {
414
- t.to(o.toAddress, o.amount);
414
+ t.to(o.toAddress, amount);
415
415
  }
416
416
  }
417
417
 
418
418
  t.fee(parseInt(txp.fee));
419
419
 
420
420
  if (txp.instantAcceptanceEscrow && txp.escrowAddress) {
421
- t.escrow(txp.escrowAddress.address, txp.instantAcceptanceEscrow + txp.fee);
421
+ t.escrow(txp.escrowAddress.address, Number(txp.instantAcceptanceEscrow) + Number(txp.fee));
422
422
  }
423
423
 
424
424
  if (txp.enableRBF) t.enableRBF();
@@ -568,7 +568,7 @@ export class BtcChain implements IChain {
568
568
  conservativeEstimation: opts.payProUrl ? true : false,
569
569
  instantAcceptanceEscrow: opts.instantAcceptanceEscrow
570
570
  };
571
- const escrowAmount = opts.instantAcceptanceEscrow || 0;
571
+ const escrowAmount = Number(opts.instantAcceptanceEscrow) || 0;
572
572
  const txpAmount = txp.getTotalAmount() + escrowAmount;
573
573
  const baseTxpSize = this.getEstimatedSize(txp, feeOpts);
574
574
  const baseTxpFee = (baseTxpSize * txp.feePerKb) / 1000;
@@ -468,7 +468,7 @@ export class EthChain implements IChain {
468
468
  const txpTotalAmount =
469
469
  (opts.multisigContractAddress || opts.tokenAddress) && txp.payProUrl
470
470
  ? getInvoiceValue(txp)
471
- : txp.getTotalAmount();
471
+ : txp.getTotalAmountBigInt();
472
472
 
473
473
  if (totalAmount < txpTotalAmount) {
474
474
  return cb(Errors.INSUFFICIENT_FUNDS);
@@ -4,6 +4,7 @@ import { IChain } from '../../../types/chain';
4
4
  import { WalletWithOpts } from '../../blockchainexplorers/v8';
5
5
  import { Common } from '../../common';
6
6
  import { Defaults } from '../../common/defaults';
7
+ import { ClientError } from '../../errors/clienterror';
7
8
  import { Errors } from '../../errors/errordefinitions';
8
9
  import logger from '../../logger';
9
10
  import { IWallet, TxProposal, Wallet } from '../../model';
@@ -95,11 +96,27 @@ export class SolChain implements IChain {
95
96
  return new Promise(resolve => {
96
97
  const numSignatures = opts.numSignatures || 1;
97
98
  const feePerKb = Defaults.SOL_BASE_FEE; // Fee per signature in lamports
98
- const fee = feePerKb * numSignatures;
99
+ let fee = feePerKb * numSignatures;
100
+ if (opts.priorityFee) {
101
+ fee += this.getPriorityFee(opts);
102
+ }
99
103
  return resolve({ fee, feePerKb });
100
104
  });
101
105
  }
102
106
 
107
+ /**
108
+ * Lamports charged on top of the signature fee when a priority fee is set:
109
+ * ceil(micro-lamports per compute unit * compute unit limit / 1e6).
110
+ * Without computeUnits the runtime reserves a fixed limit per instruction, so count the
111
+ * instructions CWC builds for a transfer: one per recipient, plus one for a memo.
112
+ */
113
+ private getPriorityFee({ priorityFee, computeUnits, outputs = [], memo }) {
114
+ const microLamportsPerUnit = Math.max(Defaults.SOL_MIN_PRIORITY_FEE, priorityFee);
115
+ const instructions = Math.max(outputs.length, 1) + (memo ? 1 : 0);
116
+ const units = computeUnits || Math.min(Defaults.SOL_MAX_COMPUTE_UNITS, Defaults.SOL_COMPUTE_UNITS_PER_INSTRUCTION * instructions);
117
+ return Math.ceil(microLamportsPerUnit * units / 1e6);
118
+ }
119
+
103
120
  isPrePublishRawBound(txp) {
104
121
  return Common.Utils.isPrePublishRawBound(this, txp);
105
122
  }
@@ -233,16 +250,41 @@ export class SolChain implements IChain {
233
250
  const { totalAmount, availableAmount } = balance;
234
251
  // calculate how much space is needed to find rent amount
235
252
  const minRentException = opts.tokenAddress ? 0 : Defaults.MIN_SOL_BALANCE;
236
- if (totalAmount - minRentException < txp.getTotalAmount()) {
253
+ if (totalAmount - minRentException < txp.getTotalAmountBigInt()) {
237
254
  return cb(Errors.INSUFFICIENT_FUNDS);
238
- } else if (availableAmount < txp.getTotalAmount()) {
255
+ } else if (availableAmount < txp.getTotalAmountBigInt()) {
239
256
  return cb(Errors.LOCKED_FUNDS);
257
+ } else if (opts.tokenAddress) {
258
+ // SPL fees are paid in SOL. Fetch the native balance without opts.wallet,
259
+ // since getWalletBalance leaves tokenAddress set on that object.
260
+ server.getBalance({}, (err, solBalance) => {
261
+ if (err) return cb(err);
262
+ if (solBalance.totalAmount < txp.fee) {
263
+ return cb(this.getFeeError('INSUFFICIENT_SOL_FEE', txp));
264
+ } else if (solBalance.availableAmount < txp.fee) {
265
+ return cb(this.getFeeError('LOCKED_SOL_FEE', txp));
266
+ } else {
267
+ return cb(this.checkTx(txp));
268
+ }
269
+ });
270
+ } else if (availableAmount - txp.fee < txp.getTotalAmountBigInt()) {
271
+ return cb(this.getFeeError('INSUFFICIENT_FUNDS_FOR_FEE', txp));
240
272
  } else {
241
273
  return cb(this.checkTx(txp));
242
274
  }
243
275
  });
244
276
  }
245
277
 
278
+ private getFeeError(code: 'INSUFFICIENT_SOL_FEE' | 'LOCKED_SOL_FEE' | 'INSUFFICIENT_FUNDS_FOR_FEE', txp: TxProposal) {
279
+ return new ClientError(
280
+ Errors.codes[code],
281
+ `${Errors[code].message}. RequiredFee: ${txp.fee}`,
282
+ {
283
+ requiredFee: txp.fee
284
+ }
285
+ );
286
+ }
287
+
246
288
  validateAddress(wallet, inaddr, opts) {
247
289
  const chain = this.chain.toLowerCase();
248
290
  const isValidTo = Validation.validateAddress(chain, wallet.network, inaddr);
@@ -223,9 +223,9 @@ export class XrpChain implements IChain {
223
223
  if (err) return cb(err);
224
224
  const { totalAmount, availableAmount } = balance;
225
225
  const minXrpBalance = Defaults.MIN_XRP_BALANCE;
226
- if (totalAmount - minXrpBalance < txp.getTotalAmount()) {
226
+ if (totalAmount - minXrpBalance < txp.getTotalAmountBigInt()) {
227
227
  return cb(Errors.INSUFFICIENT_FUNDS);
228
- } else if (availableAmount < txp.getTotalAmount()) {
228
+ } else if (availableAmount < txp.getTotalAmountBigInt()) {
229
229
  return cb(Errors.LOCKED_FUNDS);
230
230
  } else {
231
231
  return cb(this.checkTx(txp));
@@ -414,6 +414,12 @@ export const Defaults = {
414
414
 
415
415
  SOL_BASE_FEE: 5000,
416
416
 
417
+ // SOL prioritization fee: CWC raises any priority fee to at least this many micro-lamports per compute unit
418
+ SOL_MIN_PRIORITY_FEE: 1000,
419
+ // Compute unit limit the runtime reserves per instruction when no limit is set, and the per-tx maximum
420
+ SOL_COMPUTE_UNITS_PER_INSTRUCTION: 200000,
421
+ SOL_MAX_COMPUTE_UNITS: 1400000,
422
+
417
423
  // Time to get the latest push notification subscriptions. In ms.
418
424
  PUSH_NOTIFICATION_SUBS_TIME: 10 * 60 * 1000, // 10 min.
419
425
 
@@ -34,6 +34,9 @@ interface Errors<T> {
34
34
  // Optimisim Errors
35
35
  INSUFFICIENT_OP_FEE: T;
36
36
  LOCKED_OP_FEE: T;
37
+ // Solana Errors
38
+ INSUFFICIENT_SOL_FEE: T;
39
+ LOCKED_SOL_FEE: T;
37
40
  HISTORY_LIMIT_EXCEEDED: T;
38
41
  MAIN_ADDRESS_GAP_REACHED: T;
39
42
  MULTI_TX_UNSUPPORTED: T;
@@ -105,6 +108,8 @@ const errors: Errors<string> = {
105
108
  LOCKED_BASE_FEE: 'Your linked BASE wallet does not have enough ETH for fee',
106
109
  INSUFFICIENT_OP_FEE: 'Your linked OP wallet does not have enough ETH for fee',
107
110
  LOCKED_OP_FEE: 'Your linked OP wallet does not have enough ETH for fee',
111
+ INSUFFICIENT_SOL_FEE: 'Your linked SOL wallet does not have enough SOL for fee',
112
+ LOCKED_SOL_FEE: 'Your linked SOL wallet does not have enough SOL for fee',
108
113
  INVALID_ADDRESS: 'Invalid address',
109
114
  INVALID_CHANGE_ADDRESS: 'Invalid change address',
110
115
  KEY_IN_COPAYER: 'Key already registered',
@@ -478,7 +478,11 @@ export class TxProposal<NumberType = number> implements ITxProposal<NumberType>
478
478
  * @return {Number} total amount of all outputs excluding change output
479
479
  */
480
480
  getTotalAmount() {
481
- return Number(((this as TxProposal<number>).outputs || []).reduce((total, o) => total += BigInt(o.amount), 0n));
481
+ return Number(this.getTotalAmountBigInt());
482
+ }
483
+
484
+ getTotalAmountBigInt() {
485
+ return ((this as TxProposal<number>).outputs || []).reduce((total, o) => total += BigInt(o.amount), 0n);
482
486
  }
483
487
 
484
488
  /**
@@ -526,11 +530,19 @@ export class TxProposal<NumberType = number> implements ITxProposal<NumberType>
526
530
  sign(copayerId, signatures, xpub, numberFormat?: NumberFormat) {
527
531
  try {
528
532
  // numberFormat as 'number' is to sidestep TS errors, but is not necessarily true.
529
- const txp = numberFormat ? TxProposal.formatNumbers(this, numberFormat as 'number') : this as TxProposal<number>;
530
- const tx = ChainService.getBitcoreTx(txp);
533
+ const clientTxp = numberFormat ? TxProposal.formatNumbers(this, numberFormat as 'number') : this as TxProposal<number>;
534
+ const clientTx = ChainService.getBitcoreTx(clientTxp);
535
+ if (numberFormat) {
536
+ // Signatures only bind the bytes the copayer signed, and every later rebuild starts from
537
+ // the stored values, so a format that rebuilds other bytes signs a different transaction.
538
+ const storedTx = ChainService.getBitcoreTx(this as TxProposal<number>);
539
+ if (JSON.stringify(clientTx.uncheckedSerialize()) !== JSON.stringify(storedTx.uncheckedSerialize())) {
540
+ throw new Error(`numberFormat ${numberFormat} rebuilds a different tx for proposal ${this.id}`);
541
+ }
542
+ }
531
543
  ChainService.addSignaturesToBitcoreTx(
532
544
  this.chain,
533
- tx,
545
+ clientTx,
534
546
  this.inputs,
535
547
  this.inputPaths,
536
548
  signatures,
@@ -540,10 +552,10 @@ export class TxProposal<NumberType = number> implements ITxProposal<NumberType>
540
552
  this.addAction(copayerId, 'accept', null, signatures, xpub);
541
553
 
542
554
  if (this.status == 'accepted') {
543
- this.raw = tx.uncheckedSerialize();
544
- this.txid = tx.id;
555
+ this.raw = clientTx.uncheckedSerialize();
556
+ this.txid = clientTx.id;
545
557
  if (this.multiTx) {
546
- this.txids = tx?.txids && tx.txids() || [tx.id];
558
+ this.txids = clientTx?.txids && clientTx.txids() || [clientTx.id];
547
559
  }
548
560
  }
549
561
 
@@ -604,19 +616,35 @@ export class TxProposal<NumberType = number> implements ITxProposal<NumberType>
604
616
  static formatNumbers<T>(txp: TxProposal<T>, numberFormat: 'string'): TxProposal<string>;
605
617
  static formatNumbers<T>(txp: TxProposal<T>, numberFormat: 'hex'): TxProposal<string>;
606
618
  static formatNumbers<T>(txp: TxProposal<T>, numberFormat: 'bigint'): TxProposal<bigint>;
607
- static formatNumbers<T>(txp: TxProposal<T>, numberFormat: 'number'): TxProposal<number>;
619
+ static formatNumbers<T>(txp: TxProposal<T>, numberFormat: 'number'): TxProposal<number | T>;
608
620
  static formatNumbers<T>(txp: ITxProposal<T>, numberFormat: 'string'): ITxProposal<string>;
609
621
  static formatNumbers<T>(txp: ITxProposal<T>, numberFormat: 'hex'): ITxProposal<string>;
610
622
  static formatNumbers<T>(txp: ITxProposal<T>, numberFormat: 'bigint'): ITxProposal<bigint>;
611
- static formatNumbers<T>(txp: ITxProposal<T>, numberFormat: 'number'): ITxProposal<number>;
623
+ static formatNumbers<T>(txp: ITxProposal<T>, numberFormat: 'number'): ITxProposal<number | T>;
612
624
  static formatNumbers<T>(txp: TxProposal<T> | ITxProposal<T>, numberFormat: NumberFormat = 'number') {
613
625
  let convertFn;
614
626
  switch (numberFormat) {
615
627
  case 'number':
616
- convertFn = parseInt;
628
+ // Clients rebuild the raw tx from these values to check the proposal signature, so a value
629
+ // parseInt cannot round-trip must be returned as stored rather than rounded.
630
+ convertFn = (n) => {
631
+ const parsed = parseInt(n);
632
+ const value = typeof n === 'string' ? n.trim() : n;
633
+ try {
634
+ return BigInt(parsed) === BigInt(value) ? parsed : n;
635
+ } catch {
636
+ return Number.isSafeInteger(parsed) && !/e/i.test(String(value)) ? parsed : n;
637
+ }
638
+ };
617
639
  break;
618
640
  case 'string':
619
- convertFn = (n) => typeof n === 'string' && n.startsWith('0x') ? BigInt(n).toString() : n.toString();
641
+ // toString() renders large integers in exponential notation, which no tx builder can parse.
642
+ convertFn = (n) => {
643
+ if (typeof n === 'string') return n.startsWith('0x') ? BigInt(n).toString() : n;
644
+ return typeof n === 'number' && Number.isInteger(n)
645
+ ? n.toLocaleString('fullwide', { useGrouping: false, maximumFractionDigits: 0 })
646
+ : n.toString();
647
+ };
620
648
  break;
621
649
  case 'hex':
622
650
  convertFn = (n) => CWCUtils.toHex(n);
@@ -143,8 +143,10 @@ export function createRouteHelpers(returnError: Types.ReturnErrorFn): RouteHelpe
143
143
  const checkNumberFormat = (numberFormat: string | undefined, res: express.Response) => {
144
144
  const validFormats = ['hex', 'string', 'number']; // bigint cannot be serialized to JSON, so it's not supported in the API response
145
145
  if (numberFormat && !validFormats.includes(numberFormat)) {
146
- return returnError(Errors.INVALID_NUMBER_FORMAT, res, null);
146
+ returnError(Errors.INVALID_NUMBER_FORMAT, res, null);
147
+ return false;
147
148
  }
149
+ return true;
148
150
  };
149
151
 
150
152
  return {
@@ -18,7 +18,7 @@ export class ApiErrorHelper {
18
18
  }
19
19
  if (err instanceof ClientError) {
20
20
  const status = err.code == 'NOT_AUTHORIZED' ? 401 : 400;
21
- if (!this.disableLogs) logger.info('Client Err: ' + status + ' ' + req.url + ' ' + JSON.stringify(err));
21
+ if (!this.disableLogs) logger.info('Client Err: ' + status + ' ' + req?.url + ' ' + JSON.stringify(err));
22
22
 
23
23
  const clientError: { code: string; message: string; messageData?: object } = {
24
24
  code: err.code,
@@ -39,7 +39,7 @@ export class ApiErrorHelper {
39
39
 
40
40
  const m = message || err.toString();
41
41
 
42
- if (!this.disableLogs) logger.error(req.url + ' :' + code + ':' + m);
42
+ if (!this.disableLogs) logger.error(req?.url + ' :' + code + ':' + m);
43
43
 
44
44
  res
45
45
  .status(code || 500)
@@ -22,7 +22,7 @@ export function registerTransactionRoutes(router: express.Router, context: Route
22
22
 
23
23
  router.get('/v2/txproposals/', (req, res) => {
24
24
  getServerWithAuth(req, res, server => {
25
- checkNumberFormat(req.query.numberFormat, res);
25
+ if (!checkNumberFormat(req.query.numberFormat, res)) return;
26
26
 
27
27
  const opts = {
28
28
  numberFormat: req.query.numberFormat,
@@ -52,7 +52,7 @@ export function registerTransactionRoutes(router: express.Router, context: Route
52
52
 
53
53
  router.post('/v3/txproposals/', (req, res) => {
54
54
  getServerWithAuth(req, res, server => {
55
- checkNumberFormat(req.query.numberFormat, res);
55
+ if (!checkNumberFormat(req.query.numberFormat, res)) return;
56
56
  req.body.txpVersion = 3;
57
57
  req.body.numberFormat = req.query.numberFormat;
58
58
  server.createTx(req.body, (err, txp) => {
@@ -76,7 +76,7 @@ export function registerTransactionRoutes(router: express.Router, context: Route
76
76
 
77
77
  router.post('/v2/txproposals/:id/signatures/', (req, res) => {
78
78
  getServerWithAuth(req, res, server => {
79
- checkNumberFormat(req.query.numberFormat, res);
79
+ if (!checkNumberFormat(req.query.numberFormat, res)) return;
80
80
  req.body.txProposalId = req.params['id'];
81
81
  req.body.maxTxpVersion = 3;
82
82
  req.body.supportBchSchnorr = true;
@@ -91,7 +91,7 @@ export function registerTransactionRoutes(router: express.Router, context: Route
91
91
 
92
92
  router.post('/v1/txproposals/:id/prepare/', (req, res) => {
93
93
  getServerWithAuth(req, res, server => {
94
- checkNumberFormat(req.query.numberFormat, res);
94
+ if (!checkNumberFormat(req.query.numberFormat, res)) return;
95
95
  req.body.txProposalId = req.params['id'];
96
96
  req.body.numberFormat = req.query.numberFormat;
97
97
  server.prepareTx(req.body, (err, txp) => {
@@ -116,7 +116,7 @@ export function registerTransactionRoutes(router: express.Router, context: Route
116
116
 
117
117
  router.post('/v2/txproposals/:id/publish/', (req, res) => {
118
118
  getServerWithAuth(req, res, server => {
119
- checkNumberFormat(req.query.numberFormat, res);
119
+ if (!checkNumberFormat(req.query.numberFormat, res)) return;
120
120
  req.body.txProposalId = req.params['id'];
121
121
  req.body.numberFormat = req.query.numberFormat;
122
122
  server.publishTx(req.body, (err, txp) => {
@@ -97,7 +97,7 @@ export function registerWalletRoutes(router: express.Router, context: RouteConte
97
97
 
98
98
  router.get('/v3/wallets/', (req, res) => {
99
99
  getServerWithAuth(req, res, server => {
100
- checkNumberFormat(req.query.numberFormat, res);
100
+ if (!checkNumberFormat(req.query.numberFormat, res)) return;
101
101
  const opts = {
102
102
  includeExtendedInfo: false,
103
103
  twoStep: false,
@@ -125,7 +125,7 @@ export function registerWalletRoutes(router: express.Router, context: RouteConte
125
125
  const silentFailure = req.query.silentFailure == '1';
126
126
  const includeServerMessages = req.query.serverMessageArray == '1';
127
127
  const numberFormat = req.query.numberFormat;
128
- checkNumberFormat(numberFormat, res);
128
+ if (!checkNumberFormat(numberFormat, res)) return;
129
129
 
130
130
  const buildOpts = (request, copayerId) => {
131
131
  const getParam = (param, returnArray = false) => {
package/src/lib/server.ts CHANGED
@@ -3032,8 +3032,8 @@ export class WalletService implements IWalletService {
3032
3032
  spenderAddress: txp.multiSendContractAddress
3033
3033
  }, (err, allowance) => {
3034
3034
  if (err) { return next(err); }
3035
- if (BigInt(allowance) < BigInt(txp.getTotalAmount())) {
3036
- return next(new Error(`Insufficient token allowance. Allowed: ${BigInt(allowance)}, Want: ${BigInt(txp.getTotalAmount())}`));
3035
+ if (BigInt(allowance) < txp.getTotalAmountBigInt()) {
3036
+ return next(new Error(`Insufficient token allowance. Allowed: ${BigInt(allowance)}, Want: ${txp.getTotalAmountBigInt()}`));
3037
3037
  }
3038
3038
  return next();
3039
3039
  });
@@ -3108,6 +3108,9 @@ export class WalletService implements IWalletService {
3108
3108
  if (err) return cb(err);
3109
3109
  if (!txp) return cb(Errors.TX_NOT_FOUND);
3110
3110
  if (!txp.isTemporary() && !txp.hasMutableTxData()) return cb(null, txp);
3111
+ // Broadcasted proposals should not be published again. Only temporary or pending proposals can be published.
3112
+ if (txp.isBroadcasted()) return cb(Errors.TX_ALREADY_BROADCASTED);
3113
+ if (!txp.isTemporary() && txp.status !== 'pending') return cb(Errors.TX_NOT_PENDING);
3111
3114
 
3112
3115
  const copayer = wallet.getCopayer(this.copayerId);
3113
3116
 
@@ -13,4 +13,4 @@ export type ServerCallback = (server: WalletService, err?: Error) => void;
13
13
  export type GetServerWithAuthFn = (req: express.Request, res: express.Response, opts?: ServerOpts | ServerCallback, cb?: ServerCallback) => Promise<WalletService | void>;
14
14
  export type GetServerWithMultiAuthFn = (req: express.Request, res: express.Response, opts?: ServerOpts) => Array<Promise<WalletService | void>>;
15
15
  export type CreateWalletLimiterFn = (req: express.Request, res: express.Response, next: express.NextFunction) => void;
16
- export type CheckNumberFormatFn = (numberFormat: string | undefined, res: express.Response) => void;
16
+ export type CheckNumberFormatFn = (numberFormat: string | undefined, res: express.Response) => boolean;
@@ -15,6 +15,26 @@ const { Constants } = Common;
15
15
  const segWitToAddress = 'BC1QW508D6QEJXTDG4Y5R3ZARVARY0C5XW7KV8F3T4'; // 'bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq';
16
16
 
17
17
  describe('Chain BTC', function() {
18
+ describe('#getEstimatedFee', function() {
19
+ const btcChain = ChainService.get('btc') as BtcChain;
20
+ const readyTxp = (satoshis, amounts) => ({
21
+ inputs: satoshis.map(s => ({ satoshis: s })),
22
+ outputs: amounts.map(amount => ({ amount })),
23
+ changeAddress: null,
24
+ feePerKb: 10000
25
+ });
26
+ const feeOf = txp => btcChain.getEstimatedFee(txp as any, { conservativeEstimation: false });
27
+
28
+ it('should derive the same fee from string and number amounts', function() {
29
+ const fromNumbers = feeOf(readyTxp([2000, 1000], [1000, 1000]));
30
+
31
+ fromNumbers.should.equal(1000);
32
+ feeOf(readyTxp([2000, 1000], ['1000', '1000'])).should.equal(fromNumbers);
33
+ feeOf(readyTxp(['2000', '1000'], [1000, 1000])).should.equal(fromNumbers);
34
+ feeOf(readyTxp(['2000', '1000'], ['1000', '1000'])).should.equal(fromNumbers);
35
+ });
36
+ });
37
+
18
38
  describe('#getBitcoreTx', function() {
19
39
  it('should create a valid bitcore TX', function() {
20
40
  const txp = TxProposal.fromObj(aTXP()) as TxProposal;
@@ -22,6 +42,18 @@ describe('Chain BTC', function() {
22
42
  should.exist(t);
23
43
  });
24
44
 
45
+ it('should build the same tx from string and number amounts without mutating the proposal', function() {
46
+ const asStrings: any = aTXP();
47
+ asStrings.outputs = asStrings.outputs.map(o => ({ ...o, amount: String(o.amount) }));
48
+ const fromStrings = TxProposal.fromObj(asStrings) as TxProposal;
49
+ const fromNumbers = TxProposal.fromObj(aTXP()) as TxProposal;
50
+
51
+ const built = ChainService.getBitcoreTx(fromStrings);
52
+
53
+ built.uncheckedSerialize().should.equal(ChainService.getBitcoreTx(fromNumbers).uncheckedSerialize());
54
+ fromStrings.outputs[0].amount.should.equal(String(aTXP().outputs[0].amount));
55
+ });
56
+
25
57
  it('should order outputs as specified by outputOrder', function() {
26
58
  const txp = TxProposal.fromObj(aTXP()) as TxProposal;
27
59
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  import * as chai from 'chai';
4
4
  import { ChainService } from '../../src/lib/chain';
5
+ import { TxProposal } from '../../src/lib/model/txproposal';
5
6
 
6
7
  const should = chai.should();
7
8
 
@@ -17,5 +18,47 @@ describe('Chain ETH', function() {
17
18
  ChainService.addressFromStorageTransform('eth', 'dfg', x);
18
19
  x.address.should.equal('0x01');
19
20
  });
21
+
22
+ describe('#selectTxInputs', function() {
23
+ it('should reject a total that a number rounds down to the balance', function(done) {
24
+ const server = {
25
+ getBalance: (_opts, cb) => cb(null, { totalAmount: 9007199254740992, availableAmount: 9007199254740992 })
26
+ };
27
+ const txp = TxProposal.fromObj({
28
+ ...aTXP(),
29
+ outputs: [
30
+ { ...aTXP().outputs[0], amount: '9007199254740992' },
31
+ { ...aTXP().outputs[0], amount: '1' }
32
+ ]
33
+ } as any);
34
+
35
+ ChainService.get('eth').selectTxInputs(server as any, txp, {} as any, {}, err => {
36
+ should.exist(err);
37
+ err.code.should.equal('INSUFFICIENT_FUNDS');
38
+ done();
39
+ });
40
+ });
41
+ });
20
42
  });
21
43
 
44
+ const aTXP = function() {
45
+ return {
46
+ version: 3,
47
+ createdOn: 1423146231,
48
+ id: '75c34f49-1ed6-255f-e9fd-0c71ae75ed1e',
49
+ walletId: '1',
50
+ creatorId: '1',
51
+ coin: 'eth',
52
+ chain: 'eth',
53
+ network: 'livenet',
54
+ from: '0x9858EfFD232B4033E47d90003D41EC34EcaEda94',
55
+ nonce: 5,
56
+ gasPrice: 25000000000,
57
+ gasLimit: 60000,
58
+ fee: 1500000000000000,
59
+ outputs: [{ toAddress: '0xc27eD3DF0DE776246cdAD5a052A9982473FceaB8', amount: 1000, gasLimit: 60000 }],
60
+ status: 'pending' as const,
61
+ actions: []
62
+ };
63
+ };
64
+
@@ -1,8 +1,11 @@
1
1
  'use strict';
2
2
 
3
3
  import * as chai from 'chai';
4
+ import sinon from 'sinon';
4
5
  import { ChainService } from '../../src/lib/chain';
5
6
  import { Defaults } from '../../src/lib/common/defaults';
7
+ import { ClientError } from '../../src/lib/errors/clienterror';
8
+ import { TxProposal } from '../../src/lib/model/txproposal';
6
9
 
7
10
  const should = chai.should();
8
11
 
@@ -45,4 +48,182 @@ describe('Chain SOL', function() {
45
48
  });
46
49
  }
47
50
  });
51
+
52
+ describe('#getFee', function() {
53
+ const sol = ChainService.get('sol');
54
+ const getFee = async opts => (await sol.getFee(null, null, opts)).fee;
55
+ const outputs = n => Array.from({ length: n }, () => ({ toAddress: 'addr', amount: 1 }));
56
+
57
+ it('should be the signature fee when no priority fee is set', async function() {
58
+ (await getFee({ outputs: outputs(1) })).should.equal(5000);
59
+ (await getFee({ outputs: outputs(1), numSignatures: 2 })).should.equal(10000);
60
+ (await getFee({ outputs: outputs(1), computeUnits: 300000 })).should.equal(5000);
61
+ });
62
+
63
+ it('should add the priority fee for the given compute unit limit', async function() {
64
+ // 50,000 micro-lamports/CU * 300,000 CU = 15,000 lamports
65
+ (await getFee({ outputs: outputs(1), priorityFee: 50000, computeUnits: 300000 })).should.equal(5000 + 15000);
66
+ });
67
+
68
+ it('should round the priority fee up to a whole lamport', async function() {
69
+ // 1,001 micro-lamports/CU * 1,000 CU = 1,001,000 micro-lamports = 1.001 lamports
70
+ (await getFee({ outputs: outputs(1), priorityFee: 1001, computeUnits: 1000 })).should.equal(5000 + 2);
71
+ });
72
+
73
+ it('should raise the priority fee to the minimum CWC applies', async function() {
74
+ // 1 micro-lamport/CU is raised to 1,000; 1,000 * 200,000 CU = 200 lamports
75
+ (await getFee({ outputs: outputs(1), priorityFee: 1, computeUnits: 200000 })).should.equal(5000 + 200);
76
+ });
77
+
78
+ it('should use the default compute unit limit per instruction without computeUnits', async function() {
79
+ // 200,000 CU per transfer instruction
80
+ (await getFee({ outputs: outputs(1), priorityFee: 10000 })).should.equal(5000 + 2000);
81
+ (await getFee({ outputs: outputs(3), priorityFee: 10000 })).should.equal(5000 + 6000);
82
+ // the memo is one more instruction
83
+ (await getFee({ outputs: outputs(1), memo: 'refund', priorityFee: 10000 })).should.equal(5000 + 4000);
84
+ });
85
+
86
+ it('should cap the default compute unit limit at the per-transaction maximum', async function() {
87
+ // 12 transfers would be 2.4M CU, capped at 1.4M
88
+ (await getFee({ outputs: outputs(12), priorityFee: 10000 })).should.equal(5000 + 14000);
89
+ });
90
+ });
91
+
92
+ describe('#selectTxInputs', function() {
93
+ const sol = ChainService.get('sol');
94
+ const wallet = { chain: 'sol', network: 'livenet' };
95
+ const fee = 5000;
96
+ let checkTxCalled;
97
+
98
+ beforeEach(function() {
99
+ checkTxCalled = false;
100
+ sinon.stub(sol, 'checkTx').callsFake(() => {
101
+ checkTxCalled = true;
102
+ return null;
103
+ });
104
+ });
105
+
106
+ afterEach(function() {
107
+ sinon.restore();
108
+ });
109
+
110
+ it('should reject a total that a number rounds down to the balance', function(done) {
111
+ const balance = 9007199254740992 + Defaults.MIN_SOL_BALANCE;
112
+ const server = {
113
+ getBalance: (_opts, cb) => cb(null, { totalAmount: balance, availableAmount: balance })
114
+ };
115
+ const txp = TxProposal.fromObj({
116
+ version: 3,
117
+ id: '75c34f49-1ed6-255f-e9fd-0c71ae75ed1e',
118
+ walletId: '1',
119
+ creatorId: '1',
120
+ coin: 'sol',
121
+ chain: 'sol',
122
+ network: 'livenet',
123
+ from: '8WyoNvKsmfdG6zrbzNBVN8DETyLra3ond61saU9C52YR',
124
+ fee: 5000,
125
+ status: 'pending',
126
+ actions: [],
127
+ outputs: [
128
+ { toAddress: 'F7FknkRckx4yvA3Gexnx1H3nwPxndMxVt58BwAzEQhcY', amount: '9007199254740992' },
129
+ { toAddress: 'F7FknkRckx4yvA3Gexnx1H3nwPxndMxVt58BwAzEQhcY', amount: '1' }
130
+ ]
131
+ } as any);
132
+
133
+ ChainService.get('sol').selectTxInputs(server as any, txp, {} as any, {}, err => {
134
+ should.exist(err);
135
+ err.code.should.equal('INSUFFICIENT_FUNDS');
136
+ done();
137
+ });
138
+ });
139
+
140
+ // Token balance is returned when tokenAddress is passed, native SOL balance otherwise
141
+ const makeServer = ({ native, token }: { native: any; token?: any }) => ({
142
+ getBalance: (opts, cb) => cb(null, opts.tokenAddress ? token : native)
143
+ });
144
+ const balance = (total, available) => ({ totalAmount: total, availableAmount: available });
145
+ const makeTxp = amount => ({ fee, getTotalAmount: () => amount, getTotalAmountBigInt: () => BigInt(amount) });
146
+
147
+ describe('SPL token', function() {
148
+ const opts = { tokenAddress: 'mint' };
149
+
150
+ it('should fail with INSUFFICIENT_SOL_FEE if the wallet has no SOL for the fee', function(done) {
151
+ const server = makeServer({ token: balance(1e6, 1e6), native: balance(0, 0) });
152
+ sol.selectTxInputs(server as any, makeTxp(5e5) as any, wallet as any, opts, err => {
153
+ should.exist(err);
154
+ err.should.be.instanceof(ClientError);
155
+ err.code.should.equal('INSUFFICIENT_SOL_FEE');
156
+ err.message.should.equal('Your linked SOL wallet does not have enough SOL for fee. RequiredFee: 5000');
157
+ err.messageData.should.deep.equal({ requiredFee: fee });
158
+ checkTxCalled.should.equal(false);
159
+ done();
160
+ });
161
+ });
162
+
163
+ it('should fail with LOCKED_SOL_FEE if the SOL for the fee is locked', function(done) {
164
+ const server = makeServer({ token: balance(1e6, 1e6), native: balance(1e7, 1000) });
165
+ sol.selectTxInputs(server as any, makeTxp(5e5) as any, wallet as any, opts, err => {
166
+ should.exist(err);
167
+ err.should.be.instanceof(ClientError);
168
+ err.code.should.equal('LOCKED_SOL_FEE');
169
+ err.messageData.should.deep.equal({ requiredFee: fee });
170
+ checkTxCalled.should.equal(false);
171
+ done();
172
+ });
173
+ });
174
+
175
+ it('should still fail with INSUFFICIENT_FUNDS when the token balance is short', function(done) {
176
+ const server = makeServer({ token: balance(1e5, 1e5), native: balance(1e9, 1e9) });
177
+ sol.selectTxInputs(server as any, makeTxp(5e5) as any, wallet as any, opts, err => {
178
+ should.exist(err);
179
+ err.code.should.equal('INSUFFICIENT_FUNDS');
180
+ done();
181
+ });
182
+ });
183
+
184
+ it('should pass when both the token and the SOL fee are covered', function(done) {
185
+ const server = makeServer({ token: balance(1e6, 1e6), native: balance(1e9, 1e9) });
186
+ sol.selectTxInputs(server as any, makeTxp(5e5) as any, wallet as any, opts, err => {
187
+ should.not.exist(err);
188
+ checkTxCalled.should.equal(true);
189
+ done();
190
+ });
191
+ });
192
+ });
193
+
194
+ describe('native SOL', function() {
195
+ it('should fail with INSUFFICIENT_FUNDS_FOR_FEE if the amount fits but the fee does not', function(done) {
196
+ const amount = 1e9;
197
+ const server = makeServer({ native: balance(amount + Defaults.MIN_SOL_BALANCE, amount) });
198
+ sol.selectTxInputs(server as any, makeTxp(amount) as any, wallet as any, {}, err => {
199
+ should.exist(err);
200
+ err.should.be.instanceof(ClientError);
201
+ err.code.should.equal('INSUFFICIENT_FUNDS_FOR_FEE');
202
+ err.message.should.equal('Insufficient funds for fee. RequiredFee: 5000');
203
+ err.messageData.should.deep.equal({ requiredFee: fee });
204
+ checkTxCalled.should.equal(false);
205
+ done();
206
+ });
207
+ });
208
+
209
+ it('should still fail with INSUFFICIENT_FUNDS when the amount does not fit', function(done) {
210
+ const server = makeServer({ native: balance(1e9, 1e9) });
211
+ sol.selectTxInputs(server as any, makeTxp(2e9) as any, wallet as any, {}, err => {
212
+ should.exist(err);
213
+ err.code.should.equal('INSUFFICIENT_FUNDS');
214
+ done();
215
+ });
216
+ });
217
+
218
+ it('should pass when the amount and fee are covered', function(done) {
219
+ const amount = 1e9;
220
+ const server = makeServer({ native: balance(2e9, amount + fee) });
221
+ sol.selectTxInputs(server as any, makeTxp(amount) as any, wallet as any, {}, err => {
222
+ should.not.exist(err);
223
+ checkTxCalled.should.equal(true);
224
+ done();
225
+ });
226
+ });
227
+ });
228
+ });
48
229
  });
@@ -4,6 +4,7 @@ import * as chai from 'chai';
4
4
  import 'chai/register-should';
5
5
  import { ChainService } from '../../src/lib/chain';
6
6
  import { ITxProposal, TxProposal } from '../../src/lib/model/txproposal';
7
+ import { Defaults } from '../../src/lib/common/defaults';
7
8
 
8
9
  const should = chai.should();
9
10
 
@@ -61,6 +62,26 @@ describe('Chain XRP', function() {
61
62
  });
62
63
  });
63
64
 
65
+ describe('#selectTxInputs', function() {
66
+ const balanceOf = amount => ({
67
+ getBalance: (_opts, cb) => cb(null, { totalAmount: amount, availableAmount: amount })
68
+ });
69
+ const txpOf = amounts => TxProposal.fromObj({
70
+ ...aTXP(),
71
+ outputs: amounts.map(amount => ({ ...aTXP().outputs[0], amount }))
72
+ } as any);
73
+
74
+ it('should reject a total that a number rounds down to the balance', function(done) {
75
+ const balance = 9007199254740992 + Defaults.MIN_XRP_BALANCE;
76
+
77
+ ChainService.get('xrp').selectTxInputs(balanceOf(balance) as any, txpOf(['9007199254740992', '1']), {} as any, {}, err => {
78
+ should.exist(err);
79
+ err.code.should.equal('INSUFFICIENT_FUNDS');
80
+ done();
81
+ });
82
+ });
83
+ });
84
+
64
85
  describe('#getBitcoreTx', function() {
65
86
  it('should create a valid bitcore TX', function() {
66
87
  const txp = TxProposal.fromObj(aTXP()) as TxProposal;
@@ -723,6 +723,35 @@ describe('Moonpay integration', () => {
723
723
  await server.externalServices.moonpay.moonpayRevokeActiveSession(req);
724
724
  });
725
725
 
726
+ it('should return the response body if there is one', async () => {
727
+ const data = await server.externalServices.moonpay.moonpayRevokeActiveSession(req);
728
+ data.should.equal('data');
729
+ });
730
+
731
+ it('should return success true without leaking the response if there is no body', async () => {
732
+ const fakeRequest2 = {
733
+ delete: (_url, opts, _cb) => {
734
+ return _cb(null, { statusCode: 204, body: undefined, headers: {}, request: { headers: opts.headers } });
735
+ },
736
+ };
737
+
738
+ server.externalServices.moonpay.request = fakeRequest2;
739
+ const data = await server.externalServices.moonpay.moonpayRevokeActiveSession(req);
740
+ data.should.deep.equal({ success: true });
741
+ });
742
+
743
+ it('should return success false if there is no body and status is not 2xx', async () => {
744
+ const fakeRequest2 = {
745
+ delete: (_url, opts, _cb) => {
746
+ return _cb(null, { statusCode: 404, body: '', headers: {}, request: { headers: opts.headers } });
747
+ },
748
+ };
749
+
750
+ server.externalServices.moonpay.request = fakeRequest2;
751
+ const data = await server.externalServices.moonpay.moonpayRevokeActiveSession(req);
752
+ data.should.deep.equal({ success: false });
753
+ });
754
+
726
755
  it('should return error if delete returns error', async () => {
727
756
  const fakeRequest2 = {
728
757
  delete: (_url, _opts, _cb) => { return _cb(new Error('Error'), null); },
@@ -3935,6 +3935,45 @@ describe('Wallet service', function() {
3935
3935
  });
3936
3936
  });
3937
3937
 
3938
+ describe('#createTx token allowance', function() {
3939
+ let server: WalletService;
3940
+ let wallet: Model.Wallet;
3941
+
3942
+ beforeEach(async function() {
3943
+ ({ server, wallet } = await helpers.createAndJoinWallet(1, 1, { coin: 'eth' }));
3944
+ await util.promisify(server.createAddress).call(server, {});
3945
+ });
3946
+
3947
+ it('should reject a total that a number rounds down to the allowance', async function() {
3948
+ const be = helpers.getBlockchainExplorer();
3949
+ be.getBalance = sinon.stub().callsArgWith(1, null, { unconfirmed: 0, confirmed: '1' + '0'.repeat(20), balance: '1' + '0'.repeat(20) });
3950
+ be.getTokenAllowance = sinon.stub().callsArgWith(1, null, '9007199254740992');
3951
+
3952
+ const opts = {
3953
+ outputs: [
3954
+ { toAddress: '0x37d7B3bBD88EFdE6a93cF74D2F5b0385D3E3B08A', amount: '9007199254740992' },
3955
+ { toAddress: '0x37d7B3bBD88EFdE6a93cF74D2F5b0385D3E3B08A', amount: '1' }
3956
+ ],
3957
+ from: '0xE299d49C2cf9BfaFb7C6E861E80bb8c83f961622',
3958
+ tokenAddress: '0x6B175474E89094C44Da98b954EedeAC495271d0F',
3959
+ multiSendContractAddress: '0x30e5bfa1e83c9c6f0a4c4bd4a1f0e0d7d0c4d3a2',
3960
+ feePerKb: 25000000000,
3961
+ nonce: 5
3962
+ };
3963
+
3964
+ let error;
3965
+ try {
3966
+ await util.promisify(server.createTx).call(server, opts);
3967
+ } catch (e) {
3968
+ error = e;
3969
+ }
3970
+
3971
+ should.exist(error);
3972
+ error.message.should.contain('Insufficient token allowance');
3973
+ error.message.should.contain('Want: 9007199254740993');
3974
+ });
3975
+ });
3976
+
3938
3977
  const testSet = [
3939
3978
  {
3940
3979
  coin: 'btc',
@@ -8390,6 +8429,66 @@ describe('Wallet service', function() {
8390
8429
  txp.nonce.should.equal('5');
8391
8430
  });
8392
8431
 
8432
+ describe('republishing a txp with mutable tx data', function() {
8433
+ let txp, publishOpts;
8434
+
8435
+ beforeEach(async function() {
8436
+ txp = await util.promisify(server.createTx).call(server, {
8437
+ outputs: [{ toAddress: ETH_ADDR, amount: 8000 }],
8438
+ feePerKb: 123e2,
8439
+ from: fromAddr,
8440
+ deferNonce: true
8441
+ });
8442
+ publishOpts = helpers.getProposalSignatureOpts(txp, TestData.copayers[0].privKey_1H_0);
8443
+ await util.promisify(server.publishTx).call(server, publishOpts);
8444
+ });
8445
+
8446
+ const setStatus = async status => {
8447
+ const stored = await util.promisify(server.storage.fetchTx).call(server.storage, wallet.id, txp.id);
8448
+ stored.status = status;
8449
+ await util.promisify(server.storage.storeTx).call(server.storage, wallet.id, stored);
8450
+ };
8451
+
8452
+ const republishError = async () => {
8453
+ try {
8454
+ await util.promisify(server.publishTx).call(server, publishOpts);
8455
+ } catch (err) {
8456
+ return err;
8457
+ }
8458
+ };
8459
+
8460
+ it('should republish a pending txp', async function() {
8461
+ should.not.exist(await republishError());
8462
+ const stored = await util.promisify(server.storage.fetchTx).call(server.storage, wallet.id, txp.id);
8463
+ stored.status.should.equal('pending');
8464
+ });
8465
+
8466
+ it('should not republish a broadcasted txp back to pending', async function() {
8467
+ await setStatus('broadcasted');
8468
+ const err = await republishError();
8469
+ should.exist(err);
8470
+ err.code.should.equal('TX_ALREADY_BROADCASTED');
8471
+ const stored = await util.promisify(server.storage.fetchTx).call(server.storage, wallet.id, txp.id);
8472
+ stored.status.should.equal('broadcasted');
8473
+ });
8474
+
8475
+ it('should not republish an accepted txp back to pending', async function() {
8476
+ await setStatus('accepted');
8477
+ const err = await republishError();
8478
+ should.exist(err);
8479
+ err.code.should.equal('TX_NOT_PENDING');
8480
+ const stored = await util.promisify(server.storage.fetchTx).call(server.storage, wallet.id, txp.id);
8481
+ stored.status.should.equal('accepted');
8482
+ });
8483
+
8484
+ it('should not republish a rejected txp back to pending', async function() {
8485
+ await setStatus('rejected');
8486
+ const err = await republishError();
8487
+ should.exist(err);
8488
+ err.code.should.equal('TX_NOT_PENDING');
8489
+ });
8490
+ });
8491
+
8393
8492
  it('should publish a deferred-nonce txp and save prePublishRaw', async function() {
8394
8493
  const txOpts = {
8395
8494
  outputs: [{ toAddress: ETH_ADDR, amount: 8000 }],
@@ -53,6 +53,28 @@ describe('TxProposal', function() {
53
53
  });
54
54
  });
55
55
 
56
+ describe('#fromObj', function() {
57
+ it('should keep the stored output amount type untouched', function() {
58
+ const stored: any = { ...aTXP(), outputs: [{ ...aTXP().outputs[0], amount: 70000000000000010 }] };
59
+
60
+ const txp = TxProposal.fromObj(stored);
61
+
62
+ txp.outputs[0].amount.should.equal(70000000000000010);
63
+ });
64
+ });
65
+
66
+ describe('#getTotalAmountBigInt', function() {
67
+ it('should keep a total that a number cannot represent', function() {
68
+ const txp = TxProposal.fromObj({
69
+ ...aTXP(),
70
+ outputs: [{ ...aTXP().outputs[0], amount: '9007199254740992' }, { ...aTXP().outputs[1], amount: '1' }]
71
+ } as any);
72
+
73
+ txp.getTotalAmountBigInt().should.equal(9007199254740993n);
74
+ txp.getTotalAmount().should.equal(9007199254740992);
75
+ });
76
+ });
77
+
56
78
  describe('#getTotalAmount', function() {
57
79
  it('should compute total amount', function() {
58
80
  const x = TxProposal.fromObj(aTXP());
@@ -81,6 +103,21 @@ describe('TxProposal', function() {
81
103
  });
82
104
  });
83
105
 
106
+ describe('#sign with a number format', function() {
107
+ it('should keep the signatures in the persisted raw tx', function() {
108
+ const plain = TxProposal.fromObj(aTXP());
109
+ plain.sign('1', theSignatures, theXPub);
110
+ plain.sign('2', theSignatures, theXPub);
111
+
112
+ const formatted = TxProposal.fromObj(aTXP());
113
+ formatted.sign('1', theSignatures, theXPub, 'number');
114
+ formatted.sign('2', theSignatures, theXPub, 'number');
115
+
116
+ formatted.raw.should.deep.equal(plain.raw);
117
+ formatted.txid.should.equal(plain.txid);
118
+ });
119
+ });
120
+
84
121
  describe('#reject', function() {
85
122
  it('should reject 2-2', function() {
86
123
  const txp = TxProposal.fromObj(aTXP());
@@ -142,6 +179,13 @@ describe('TxProposal', function() {
142
179
  txp.outputs[0].amount.should.be.a('number');
143
180
  });
144
181
 
182
+ it('should keep a base-unit amount that a number cannot represent exactly', function() {
183
+ const amount = '70000000000000010';
184
+ const t = TxProposal.fromObj({ ...aTXP(), amount, outputs: [{ ...aTXP().outputs[0], amount }] } as any);
185
+ const txp = TxProposal.formatNumbers(t, 'number');
186
+ txp.outputs[0].amount.should.equal(amount);
187
+ });
188
+
145
189
  it('should format string numbers as hex', function() {
146
190
  const t1 = TxProposal.fromObj(aTXP());
147
191
  const t2 = TxProposal.formatNumbers(t1, 'string');
@@ -156,6 +200,80 @@ describe('TxProposal', function() {
156
200
  txp.outputs[0].amount.should.be.a('string');
157
201
  CWCUtils.isHexString(txp.outputs[0].amount).should.equal(true);
158
202
  });
203
+
204
+
205
+ describe('legacy format contracts', function() {
206
+ const cases: Array<[string, any, any]> = [
207
+ ['safe integer boundary', '9007199254740991', 9007199254740991],
208
+ ['first representable unsafe integer', '9007199254740992', 9007199254740992],
209
+ ['first unrepresentable integer', '9007199254740993', '9007199254740993'],
210
+ ['representable amount', '70000000000000008', Number('70000000000000008')],
211
+ ['inexact amount', '70000000000000010', '70000000000000010'],
212
+ ['representable hex', '0x20000000000000', 9007199254740992],
213
+ ['unrepresentable hex', '0x20000000000001', '0x20000000000001'],
214
+ ['exponential string', '1e+21', '1e+21'],
215
+ ['exponential number', 1e21, 1e21],
216
+ ['overflow string', '9'.repeat(400), '9'.repeat(400)],
217
+ ['empty string', '', ''],
218
+ ['nonnumeric string', 'abc', 'abc'],
219
+ ['null', null, null],
220
+ ['undefined', undefined, undefined],
221
+ ['NaN', NaN, NaN],
222
+ ['Infinity', Infinity, Infinity],
223
+ ['small bigint', 5n, 5],
224
+ ['inexact bigint', 9007199254740993n, 9007199254740993n],
225
+ ['positive sign', '+5', 5],
226
+ ['negative zero', '-0', -0],
227
+ ['leading zeros', '007', 7],
228
+ ['whitespace', ' 42 ', 42],
229
+ ['fractional prefix', '1.5', 1],
230
+ ['malformed prefix', '12xyz', 12],
231
+ ['SOL nonce prefix', '2' + 'A'.repeat(43), 2]
232
+ ];
233
+
234
+ for (const [label, value, expected] of cases) {
235
+ it(`should leave the legacy number behavior unchanged for ${label}`, function() {
236
+ const source: any = TxProposal.fromObj(aTXP());
237
+ source.amount = value;
238
+ source.outputs[0].amount = value;
239
+ const formatted = TxProposal.formatNumbers(source, 'number');
240
+
241
+ chai.expect(Object.is(formatted.amount, expected)).to.equal(true);
242
+ chai.expect(Object.is(formatted.outputs[0].amount, expected)).to.equal(true);
243
+ chai.expect(Object.is(source.outputs[0].amount, value)).to.equal(true);
244
+ });
245
+ }
246
+
247
+ for (const format of ['string', 'hex', 'bigint'] as const) {
248
+ it(`should leave the legacy ${format} behavior unchanged`, function() {
249
+ const source: any = TxProposal.fromObj(aTXP());
250
+ source.amount = 42;
251
+ source.outputs[0].amount = '70000000000000010';
252
+ const formatted = TxProposal.formatNumbers(source, format as 'string') as any;
253
+ const expected = {
254
+ string: ['42', '70000000000000010'],
255
+ hex: ['0x2a', '0xf8b0a10e47000a'],
256
+ bigint: [42n, 70000000000000010n]
257
+ }[format];
258
+
259
+ formatted.amount.should.equal(expected[0]);
260
+ formatted.outputs[0].amount.should.equal(expected[1]);
261
+ source.amount.should.equal(42);
262
+ source.outputs[0].amount.should.equal('70000000000000010');
263
+ });
264
+ }
265
+
266
+ it('should expand large integers and keep existing hex strings in string mode', function() {
267
+ const source: any = TxProposal.fromObj(aTXP());
268
+ source.amount = 1e21;
269
+ source.outputs[0].amount = '0x2a';
270
+ const formatted = TxProposal.formatNumbers(source, 'string');
271
+
272
+ formatted.amount.should.equal('1000000000000000000000');
273
+ BigInt(formatted.amount).should.equal(1000000000000000000000n);
274
+ formatted.outputs[0].amount.should.equal('0x2a');
275
+ });
276
+ });
159
277
  });
160
278
  });
161
279
 
@@ -0,0 +1,88 @@
1
+ 'use strict';
2
+
3
+ import * as chai from 'chai';
4
+ import 'chai/register-should';
5
+ import express from 'express';
6
+ import sinon from 'sinon';
7
+ import { Errors } from '../src/lib/errors/errordefinitions';
8
+ import { createRouteHelpers } from '../src/lib/routes/context';
9
+ import { ApiErrorHelper } from '../src/lib/routes/helpers/error';
10
+ import { registerTransactionRoutes } from '../src/lib/routes/transactions';
11
+ import { registerWalletRoutes } from '../src/lib/routes/wallets';
12
+
13
+ const should = chai.should();
14
+
15
+ describe('Transaction number format routes', function() {
16
+ const routes = [
17
+ ['get', '/v2/txproposals/', 'getPendingTxs'],
18
+ ['post', '/v3/txproposals/', 'createTx'],
19
+ ['post', '/v2/txproposals/:id/signatures/', 'signTx'],
20
+ ['post', '/v1/txproposals/:id/prepare/', 'prepareTx'],
21
+ ['post', '/v2/txproposals/:id/publish/', 'publishTx'],
22
+ ['get', '/v3/wallets/', 'getStatus'],
23
+ ['get', '/v1/wallets/all/', 'getStatus']
24
+ ];
25
+
26
+ function setup() {
27
+ const router = express.Router();
28
+ const error = new ApiErrorHelper();
29
+ const returnError = error.returnError.bind(error);
30
+ const helpers = createRouteHelpers(returnError);
31
+ const server: any = { copayerId: 'copayer', walletId: 'wallet' };
32
+ for (const method of new Set(routes.map(route => route[2]))) {
33
+ server[method] = sinon.stub().callsArgWith(1, null, { outputs: [{ amount: '70000000000000010' }] });
34
+ }
35
+ const context = {
36
+ ...helpers,
37
+ returnError,
38
+ getServerWithAuth: sinon.stub().callsFake((_req, _res, cb) => cb(server)),
39
+ getServerWithMultiAuth: sinon.stub().returns([Promise.resolve(server)]),
40
+ createWalletLimiter: sinon.stub().callsArg(2)
41
+ };
42
+ registerTransactionRoutes(router, context);
43
+ registerWalletRoutes(router, context);
44
+ const response: any = {
45
+ status: sinon.stub().returnsThis(),
46
+ json: sinon.stub().returnsThis(),
47
+ end: sinon.stub().returnsThis()
48
+ };
49
+ return { router, server, response, context };
50
+ }
51
+
52
+ for (const [method, path, serverMethod] of routes) {
53
+ for (const format of ['number', 'string', 'hex', undefined]) {
54
+ it(`should accept ${format ?? 'unspecified'} on ${method.toUpperCase()} ${path}`, async function() {
55
+ const { router, server, response } = setup();
56
+ const route = router.stack.find(layer => layer.route?.path === path && layer.route.methods[method]);
57
+ const request: any = { query: { numberFormat: format }, body: {}, params: { id: 'proposal' }, headers: {} };
58
+
59
+ await route.route.stack[0].handle(request, response);
60
+
61
+ server[serverMethod].callCount.should.equal(1);
62
+ chai.expect(server[serverMethod].firstCall.args[0].numberFormat).to.equal(format);
63
+ response.status.called.should.equal(false);
64
+ response.json.callCount.should.equal(1);
65
+ });
66
+ }
67
+
68
+ for (const format of ['invalid', 'bigint', 'raw']) {
69
+ it(`should return 400 without executing ${method.toUpperCase()} ${path} for ${format}`, async function() {
70
+ const { router, server, response, context } = setup();
71
+ const route = router.stack.find(layer => layer.route?.path === path && layer.route.methods[method]);
72
+ const request: any = { query: { numberFormat: format }, body: {}, params: { id: 'proposal' }, headers: {} };
73
+
74
+ await route.route.stack[0].handle(request, response);
75
+
76
+ response.status.calledOnceWithExactly(400).should.equal(true);
77
+ response.json.callCount.should.equal(1);
78
+ response.json.firstCall.args[0].code.should.equal('INVALID_NUMBER_FORMAT');
79
+ server[serverMethod].called.should.equal(false);
80
+ if (path === '/v1/wallets/all/') context.getServerWithMultiAuth.called.should.equal(false);
81
+ });
82
+ }
83
+ }
84
+
85
+ it('should list the supported formats in the validation error', function() {
86
+ Errors.INVALID_NUMBER_FORMAT.message.should.include('number, string, hex');
87
+ });
88
+ });