@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 +4 -4
- package/src/externalservices/moonpay.ts +2 -1
- package/src/lib/chain/btc/index.ts +7 -7
- package/src/lib/chain/eth/index.ts +1 -1
- package/src/lib/chain/sol/index.ts +45 -3
- package/src/lib/chain/xrp/index.ts +2 -2
- package/src/lib/common/defaults.ts +6 -0
- package/src/lib/errors/errordefinitions.ts +5 -0
- package/src/lib/model/txproposal.ts +39 -11
- package/src/lib/routes/context.ts +3 -1
- package/src/lib/routes/helpers/error.ts +2 -2
- package/src/lib/routes/transactions.ts +5 -5
- package/src/lib/routes/wallets.ts +2 -2
- package/src/lib/server.ts +5 -2
- package/src/types/expressapp.d.ts +1 -1
- package/test/chain/btc.test.ts +32 -0
- package/test/chain/eth.test.ts +43 -0
- package/test/chain/sol.test.ts +181 -0
- package/test/chain/xrp.test.ts +21 -0
- package/test/integration/externalservices/moonpay.test.ts +29 -0
- package/test/integration/server.test.ts +99 -0
- package/test/model/txproposal.test.ts +118 -0
- package/test/numberformat-routes.test.ts +88 -0
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.
|
|
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.
|
|
46
|
-
"@bitpay-labs/crypto-wallet-core": "^11.10.
|
|
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": "
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
410
|
+
satoshis: amount
|
|
411
411
|
})
|
|
412
412
|
);
|
|
413
413
|
} else {
|
|
414
|
-
t.to(o.toAddress,
|
|
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.
|
|
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
|
-
|
|
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.
|
|
253
|
+
if (totalAmount - minRentException < txp.getTotalAmountBigInt()) {
|
|
237
254
|
return cb(Errors.INSUFFICIENT_FUNDS);
|
|
238
|
-
} else if (availableAmount < txp.
|
|
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.
|
|
226
|
+
if (totalAmount - minXrpBalance < txp.getTotalAmountBigInt()) {
|
|
227
227
|
return cb(Errors.INSUFFICIENT_FUNDS);
|
|
228
|
-
} else if (availableAmount < txp.
|
|
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(
|
|
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
|
|
530
|
-
const
|
|
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
|
-
|
|
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 =
|
|
544
|
-
this.txid =
|
|
555
|
+
this.raw = clientTx.uncheckedSerialize();
|
|
556
|
+
this.txid = clientTx.id;
|
|
545
557
|
if (this.multiTx) {
|
|
546
|
-
this.txids =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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) <
|
|
3036
|
-
return next(new Error(`Insufficient token allowance. Allowed: ${BigInt(allowance)}, Want: ${
|
|
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) =>
|
|
16
|
+
export type CheckNumberFormatFn = (numberFormat: string | undefined, res: express.Response) => boolean;
|
package/test/chain/btc.test.ts
CHANGED
|
@@ -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
|
|
package/test/chain/eth.test.ts
CHANGED
|
@@ -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
|
+
|
package/test/chain/sol.test.ts
CHANGED
|
@@ -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
|
});
|
package/test/chain/xrp.test.ts
CHANGED
|
@@ -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
|
+
});
|