diff --git a/ci.sh b/ci.sh index 5b29ce28a33..ca20767d56d 100755 --- a/ci.sh +++ b/ci.sh @@ -16,6 +16,11 @@ for arg in "$@"; do fi done +DOCKER_UID=$(id -u) +DOCKER_GID=$(id -g) +export DOCKER_UID +export DOCKER_GID + if [ "$1" == "build" ]; then docker compose -f docker-compose.test.base.yml -f docker-compose.test.local.yml build elif [ "$1" = "run" ]; then diff --git a/docker-compose.test.local.yml b/docker-compose.test.local.yml index 46f163edac5..308bde222d0 100644 --- a/docker-compose.test.local.yml +++ b/docker-compose.test.local.yml @@ -5,6 +5,7 @@ services: build: context: . dockerfile: Dockerfile + user: "${DOCKER_UID}:${DOCKER_GID}" volumes: - .:/bitcore ports: diff --git a/packages/bitcore-cli/src/commands/transaction.ts b/packages/bitcore-cli/src/commands/transaction.ts index 342ae919af4..2c2f78eee2e 100755 --- a/packages/bitcore-cli/src/commands/transaction.ts +++ b/packages/bitcore-cli/src/commands/transaction.ts @@ -163,7 +163,7 @@ export async function createTransaction( return; // valid value, optional } const val = parseInt(value); - if (isNaN(val) || val < 0) { + if (isNaN(val) || val < 0 || !(/^\d+$/.test(value))) { return 'Please enter a valid destination tag'; } return; // valid value @@ -256,7 +256,7 @@ export async function createTransaction( throw new UserCancelled(); } if (BWCUtils.isUtxoChain(chain)) { - customFeeRate = (Number(customFeeRate) * 1000).toString(); // convert to sats/KB + customFeeRate = Math.round(Number(customFeeRate) * 1000).toString(); // convert to sats/KB } } @@ -267,8 +267,8 @@ export async function createTransaction( }], message: note, feeLevel: feeLevel === 'custom' ? undefined : feeLevel, - feePerKb: feeLevel === 'custom' ? parseFloat(customFeeRate) : undefined, - fee: opts.fee ? parseFloat(opts.fee) : undefined, + feePerKb: feeLevel === 'custom' ? BigInt(Math.ceil(Number(customFeeRate))) : undefined, + fee: opts.fee ? BigInt(Math.ceil(parseFloat(opts.fee))) : undefined, sendMax, tokenAddress: tokenObj?.contractAddress, flags: opts.flags, @@ -294,7 +294,7 @@ export async function createTransaction( : Utils.renderAmount(currency, BigInt(txp.amount) + BigInt(txp.fee)) }`); if (txp.nonce != null) { - lines.push(`Nonce: ${txp.nonce}`); + lines.push(`Nonce: ${BigInt(txp.nonce)}`); } if (note) { lines.push(`Note: ${txp.message}`); diff --git a/packages/bitcore-client/src/storage/level.ts b/packages/bitcore-client/src/storage/level.ts index 66359315549..342dfcbd76c 100644 --- a/packages/bitcore-client/src/storage/level.ts +++ b/packages/bitcore-client/src/storage/level.ts @@ -31,7 +31,7 @@ export class Level { const walletExists = fs.existsSync(this.path) && fs.existsSync(this.path + '/LOCK') && fs.existsSync(this.path + '/LOG'); if (!walletExists) { - throw new Error('Not a valid wallet path'); + throw new Error('Not a valid wallet path: ' + this.path); } } if (StorageCache[this.path]) { diff --git a/packages/bitcore-client/src/storage/textFile.ts b/packages/bitcore-client/src/storage/textFile.ts index d7c3ef6b906..54387aeafbb 100644 --- a/packages/bitcore-client/src/storage/textFile.ts +++ b/packages/bitcore-client/src/storage/textFile.ts @@ -30,7 +30,7 @@ export class TextFile { if (!createIfMissing) { const walletPath = fs.existsSync(this.db); if (!walletPath) { - throw new Error('Not a valid wallet path'); + throw new Error('Not a valid wallet path: ' + this.db); } } console.log('using wallets at', this.db); diff --git a/packages/bitcore-client/src/wallet.ts b/packages/bitcore-client/src/wallet.ts index 281540908c7..a82b1ccbe09 100644 --- a/packages/bitcore-client/src/wallet.ts +++ b/packages/bitcore-client/src/wallet.ts @@ -151,8 +151,15 @@ export class Wallet { static async deleteWallet(params: { name: string; path?: string; storage?: Storage; storageType?: StorageType }) { const { name, path, storageType } = params; let { storage } = params; - storage = storage || new Storage({ errorIfExists: false, createIfMissing: false, path, storageType }); - await storage.deleteWallet({ name }); + try { + storage = storage || new Storage({ errorIfExists: false, createIfMissing: false, path, storageType }); + await storage.deleteWallet({ name }); + } catch (e: any) { + // ignore error if default wallet path does not exist + if (!path && !e.message.includes('Not a valid wallet path')) { + throw e; + } + } } static async create(params: Partial) { diff --git a/packages/bitcore-node/test/integration/models/wallet.test.ts b/packages/bitcore-node/test/integration/models/wallet.test.ts index f60ac35fbd8..9b9690c5e1c 100644 --- a/packages/bitcore-node/test/integration/models/wallet.test.ts +++ b/packages/bitcore-node/test/integration/models/wallet.test.ts @@ -1,4 +1,4 @@ -import { Wallet, IWalletExt } from '@bitpay-labs/bitcore-client'; +import { Wallet, type IWalletExt } from '@bitpay-labs/bitcore-client'; import { expect } from 'chai'; import config from '../../../src/config'; import { WalletStorage } from '../../../src/models/wallet'; @@ -6,8 +6,8 @@ import { WalletAddressStorage } from '../../../src/models/walletAddress'; import { AsyncRPC } from '../../../src/rpc'; import { Api } from '../../../src/services/api'; import { Event } from '../../../src/services/event'; -import { IUtxoNetworkConfig } from '../../../src/types/Config'; import { intAfterHelper, intBeforeHelper } from '../../helpers/integration'; +import type { IUtxoNetworkConfig } from '../../../src/types/Config'; describe('Wallet Model', function() { // eslint-disable-next-line @typescript-eslint/no-this-alias @@ -24,23 +24,24 @@ describe('Wallet Model', function() { let rpc: AsyncRPC; before(async function() { - chainConfig = config.chains[chain][network] as IUtxoNetworkConfig; - creds = chainConfig.rpc; - rpc = new AsyncRPC(creds.username, creds.password, creds.host, creds.port); - await Wallet.deleteWallet({ name: walletName }); - await intBeforeHelper(); + try { + chainConfig = config.chains[chain][network] as IUtxoNetworkConfig; + creds = chainConfig.rpc; + rpc = new AsyncRPC(creds.username, creds.password, creds.host, creds.port); + await Wallet.deleteWallet({ name: walletName }); + await intBeforeHelper(); + await Event.start(); + await Api.start(); + } catch (e: any) { + console.error(e.stack ? 'ERROR STACK: ' + e.stack : e); + throw e; + } }); - - after(async () => intAfterHelper(suite)); - - before(async () => { - await Event.start(); - await Api.start(); - }); - + after(async () => { await Event.stop(); await Api.stop(); + intAfterHelper(suite); }); describe('Wallet Create', () => { diff --git a/packages/bitcore-wallet-client/src/lib/api.ts b/packages/bitcore-wallet-client/src/lib/api.ts index b68542eb550..54c2de6fa6a 100644 --- a/packages/bitcore-wallet-client/src/lib/api.ts +++ b/packages/bitcore-wallet-client/src/lib/api.ts @@ -21,6 +21,7 @@ import type { Address } from '../types/address'; import type { ServerAssistedImportEvents } from '../types/serverAssistedImportEvents'; const $ = singleton(); +const BigIntTry = CWC.Utils.BI.BigIntTry; const Bitcore = CWC.BitcoreLib; const Bitcore_ = { @@ -1613,7 +1614,7 @@ export class API extends EventEmitter { */ async createTxProposal( /** Txp object */ - opts: { + txOpts: { /** If provided it will be used as this TX proposal ID. Should be unique in the scope of the wallet. */ txProposalId?: string; /** Transaction outputs. */ @@ -1621,7 +1622,7 @@ export class API extends EventEmitter { /** Destination address. */ toAddress: string; /** Amount to transfer in satoshis. */ - amount: number | bigint; + amount: bigint; /** A message to attach to this output. */ message?: string; }>; @@ -1630,7 +1631,7 @@ export class API extends EventEmitter { /** Specify the fee level for this TX. Default: normal */ feeLevel?: 'priority' | 'normal' | 'economy' | 'superEconomy'; /** Specify the fee per kilobyte for this tx (in satoshis). */ - feePerKb?: number | bigint; + feePerKb?: bigint; /** Use this address as the change address for the tx. The address should belong to the wallet. In the case of singleAddress wallets, the first main address will be used. */ changeAddress?: string; /** Send maximum amount of funds that make sense under the specified fee/feePerKb conditions. */ @@ -1644,7 +1645,7 @@ export class API extends EventEmitter { /** Inputs for this TX */ inputs?: Array; // TODO /** Use a fixed fee for this TX (only when opts.inputs is specified). */ - fee?: number | bigint; + fee?: bigint; /** If set, TX outputs won't be shuffled. */ noShuffleOutputs?: boolean; /** Specify signing method (ecdsa or schnorr) otherwise use default for chain. Only applies to BCH */ @@ -1663,32 +1664,46 @@ export class API extends EventEmitter { flags?: string; /** (XRP only) Destination tag for the transaction */ destinationTag?: number | string; + /** (EVM only) Nonce for the transaction */ + nonce?: string | bigint; + }, + opts?: { + /** ONLY FOR TESTING */ + baseUrl?: string; + /** + * Number format for the tx-building numbers (e.g. amounts, nonce, etc.). Default: 'hex' + * Note: The given `txp` will be converted server-side and returned in the specified format. + */ + numberFormat?: 'hex' | 'number' | 'string'; }, /** @deprecated */ - cb?: (err?: Error, txp?: any) => void, - /** ONLY FOR TESTING */ - baseUrl?: string + cb?: (err?: Error, txp?: any) => void ) { + opts = opts || {}; + if (typeof opts === 'function') { + cb = opts; + opts = {}; + } if (typeof cb === 'function') { log.warn('DEPRECATED: createTxProposal will remove callback support in the future.'); - } else if (typeof cb === 'string') { - baseUrl = cb; } + try { $.checkState(this.credentials && this.credentials.isComplete(), 'Failed state: this.credentials at '); $.checkState(this.credentials.sharedEncryptingKey); - $.checkArgument(opts); + $.checkArgument(txOpts); // BCH schnorr deployment - if (!opts.signingMethod && this.credentials.coin == 'bch') { - opts.signingMethod = 'schnorr'; + if (!txOpts.signingMethod && this.credentials.coin == 'bch') { + txOpts.signingMethod = 'schnorr'; } - const args = this._getCreateTxProposalArgs(opts); - baseUrl = baseUrl || '/v3/txproposals/'; + const args = this._getCreateTxProposalArgs(txOpts); + const baseUrl = (typeof process !== 'undefined' && process.argv.some(arg => arg.includes('.test.js')) && opts.baseUrl) || '/v3/txproposals'; // baseUrl = baseUrl || '/v4/txproposals/'; // DISABLED 2020-04-07 + const qs = `?numberFormat=${opts.numberFormat || 'hex'}`; - const { body: txp } = await this.request.post(baseUrl, args); + const { body: txp } = await this.request.post(baseUrl + qs, args); this._processTxps(txp); if (!Verifier.checkProposalCreation(args, txp, this.credentials.sharedEncryptingKey)) { throw new Errors.SERVER_COMPROMISED(); @@ -2696,7 +2711,7 @@ export class API extends EventEmitter { try { $.checkState(this.credentials && this.credentials.isComplete(), 'Failed state: this.credentials at '); - const { body: txp } = await this.request.get(`/v1/txproposals/${txProposalId}`); + const { body: txp } = await this.request.get(`/v1/txproposals/${txProposalId}?numberFormat=hex`); this._processTxps(txp); if (cb) { cb(null, txp); } return txp; @@ -3043,15 +3058,15 @@ export class API extends EventEmitter { /** Specify the fee level. Default: normal */ feeLevel?: 'priority' | 'normal' | 'economy' | 'superEconomy'; /** Specify the fee per KB (in satoshi) */ - feePerKb?: number; + feePerKb?: bigint; /** Indicates it if should use (or not) the unconfirmed utxos */ excludeUnconfirmedUtxos?: boolean; /** Return the inputs used to build the tx */ returnInputs?: boolean; }, /** @deprecated */ - cb?: (err?: Error, result?: any) => void - ) { + cb?: (err?: Error, result?: SendMaxInfo) => void + ): Promise> { if (cb) { log.warn('DEPRECATED: getSendMaxInfo will remove callback support in the future.'); } @@ -3059,17 +3074,24 @@ export class API extends EventEmitter { opts = opts || {}; const args = []; + args.push('numberFormat=hex'); if (opts.feeLevel) args.push('feeLevel=' + opts.feeLevel); if (opts.feePerKb != null) args.push('feePerKb=' + opts.feePerKb); if (opts.excludeUnconfirmedUtxos) args.push('excludeUnconfirmedUtxos=1'); if (opts.returnInputs) args.push('returnInputs=1'); - let qs = ''; - if (args.length > 0) qs = '?' + args.join('&'); - - const { body: result } = await this.request.get('/v1/sendmaxinfo/' + qs); - if (cb) { cb(null, result); } - return result; + const { body: result } = await this.request.get>(`/v1/sendmaxinfo?${args.join('&')}`); + const resultWithBigInt: SendMaxInfo = { + ...result, + amount: BigIntTry(result.amount), + amountBelowFee: BigIntTry(result.amountBelowFee), + fee: BigIntTry(result.fee), + feePerKb: BigIntTry(result.feePerKb), + size: BigIntTry(result.size), + amountAboveMaxSize: BigIntTry(result.amountAboveMaxSize) + }; + if (cb) { cb(null, resultWithBigInt); } + return resultWithBigInt; } catch (err) { if (cb) cb(err); else throw err; @@ -4235,7 +4257,7 @@ export interface Txp { comment?: string; }>; // TODO addressType: string; - amount: number | string; + amount: string; chain: string; coin: string; changeAddress?: { @@ -4257,9 +4279,9 @@ export interface Txp { creatorId: string; creatorName?: string; // might be an encrypted object excludeUnconfirmedUtxos: boolean; - fee: number | string; + fee: string; feeLevel: string; - feePerKb: number | string; + feePerKb: string; from?: string; hasUnconfirmedInputs?: boolean; id: string; @@ -4281,12 +4303,12 @@ export interface Txp { message?: string; // might be an encrypted object encryptedMessage?: string; // is set equal to `message` before decryption in processTxps() network: string; - nonce?: number | string; + nonce?: string; deferNonce?: boolean; note?: Note; outputOrder: Array; outputs?: Array<{ - amount: number | string; + amount: string; toAddress: string; message?: string; // might be an encrypted object encryptedMessage?: string; // is set equal to `message` before decryption in processTxps() @@ -4314,20 +4336,31 @@ export interface PublishedTxp extends Txp { data?: string; // ? destinationTag?: string; // XRP enableRBF?: boolean; // Replace-By-Fee - gasLimit?: number; - gasPrice?: number; + gasLimit?: string; + gasPrice?: string; instantAcceptanceEscrow?: boolean; // BCH invoiceID?: string; - maxGasFee?: number; + maxGasFee?: string; multiSendContractAddress?: string; multisigContractAddress?: string; multiTx?: boolean; // nonceAddress?: string; // SOL - priorityFee?: number; - priorityGasFee?: number; + priorityFee?: string; + priorityGasFee?: string; proposalSignature: string; space?: any; // ? tokenAddress?: string; txType?: number; // or string? }; +export interface SendMaxInfo { + amount: NumberType; + amountBelowFee: NumberType; + fee: NumberType; + feePerKb: NumberType; + utxosBelowFee: number; + inputs?: Array; + size?: NumberType; + utxosAboveMaxSize?: number; + amountAboveMaxSize?: NumberType; +} \ No newline at end of file diff --git a/packages/bitcore-wallet-client/src/lib/common/utils.ts b/packages/bitcore-wallet-client/src/lib/common/utils.ts index ae9861a913c..5fb0eaedfd8 100644 --- a/packages/bitcore-wallet-client/src/lib/common/utils.ts +++ b/packages/bitcore-wallet-client/src/lib/common/utils.ts @@ -5,8 +5,9 @@ import { BitcoreLibCash, BitcoreLibDoge, BitcoreLibLtc, + Utils as CWCUtils, Deriver, - Transactions + Transactions, } from '@bitpay-labs/crypto-wallet-core'; import Stringify from 'json-stable-stringify'; import { singleton } from 'preconditions'; @@ -36,6 +37,8 @@ const crypto = BitcoreLib.crypto; const MAX_DECIMAL_ANY_CHAIN = 18; // more that 14 gives rounding errors export class Utils { + static CWCUtils = CWCUtils; + // only used for backwards compatibility static getChain(coin: string): string { try { diff --git a/packages/bitcore-wallet-client/src/lib/verifier.ts b/packages/bitcore-wallet-client/src/lib/verifier.ts index 394a2b2da55..38e0cd8871e 100644 --- a/packages/bitcore-wallet-client/src/lib/verifier.ts +++ b/packages/bitcore-wallet-client/src/lib/verifier.ts @@ -125,6 +125,8 @@ export class Verifier { const strEqual = (str1, str2) => { return (!str1 && !str2) || str1 === str2; }; + const numEqual = CWCUtils.BI.isEqual; + const objEqual = CWCUtils.isEqual; if (txp.outputs.length != args.outputs.length) return false; @@ -134,7 +136,7 @@ export class Verifier { if (!strEqual(o1.toAddress, o2.toAddress)) return false; if (!strEqual(o1.script, o2.script)) return false; // Amounts need to be equal OR sendMax arg is set and amount arg is omitted, otherwise return check failure - if (o1.amount != o2.amount && !(args.sendMax && o2.amount == null)) return false; + if (!((args.sendMax && o2.amount == null) || numEqual(o1.amount, o2.amount))) return false; let decryptedMessage: boolean | string = false; try { decryptedMessage = Utils.decryptMessage(o2.message, encryptingKey); @@ -148,7 +150,7 @@ export class Verifier { } if (args.changeAddress && !strEqual(changeAddress, args.changeAddress)) return false; - if (typeof args.feePerKb === 'number' && txp.feePerKb != args.feePerKb) + if (args.feePerKb && !numEqual(txp.feePerKb, args.feePerKb)) return false; if (!strEqual(txp.payProUrl, args.payProUrl)) return false; @@ -159,7 +161,7 @@ export class Verifier { if (!strEqual(txp.message, decryptedMessage)) return false; if ( (args.customData || txp.customData) && - !CWCUtils.isEqual(txp.customData, args.customData) + !objEqual(txp.customData, args.customData) ) return false; diff --git a/packages/bitcore-wallet-client/test/api.test.ts b/packages/bitcore-wallet-client/test/api.test.ts index 34376acc9b1..d6a1e5a1408 100644 --- a/packages/bitcore-wallet-client/test/api.test.ts +++ b/packages/bitcore-wallet-client/test/api.test.ts @@ -2886,41 +2886,41 @@ describe('client API', function() { should.not.exist(err); should.exist(result); result.inputs.length.should.be.equal(2); - result.amount.should.be.equal(balance.totalAmount - result.fee); + result.amount.should.be.equal(BigInt(balance.totalAmount) - result.fee); result.utxosBelowFee.should.be.equal(0); - result.amountBelowFee.should.be.equal(0); + result.amountBelowFee.should.be.equal(0n); result.utxosAboveMaxSize.should.be.equal(0); - result.amountAboveMaxSize.should.be.equal(0); + result.amountAboveMaxSize.should.be.equal(0n); done(); }); }); it('should return data excluding unconfirmed UTXOs', function(done) { const opts = { - feePerKb: 200, + feePerKb: 200n, excludeUnconfirmedUtxos: true, returnInputs: true }; clients[0].getSendMaxInfo(opts, (err, result) => { should.not.exist(err); - result.amount.should.be.equal(balance.availableConfirmedAmount - result.fee); + result.amount.should.be.equal(BigInt(balance.availableConfirmedAmount) - result.fee); done(); }); }); it('should return data including unconfirmed UTXOs', function(done) { const opts = { - feePerKb: 200, + feePerKb: 200n, excludeUnconfirmedUtxos: false, returnInputs: true }; clients[0].getSendMaxInfo(opts, (err, result) => { should.not.exist(err); - result.amount.should.be.equal(balance.totalAmount - result.fee); + result.amount.should.be.equal(BigInt(balance.totalAmount) - result.fee); done(); }); }); it('should return data without inputs', function(done) { const opts = { - feePerKb: 200, + feePerKb: 200n, excludeUnconfirmedUtxos: true, returnInputs: false }; @@ -2932,7 +2932,7 @@ describe('client API', function() { }); it('should return data with inputs', function(done) { const opts = { - feePerKb: 200, + feePerKb: 200n, excludeUnconfirmedUtxos: true, returnInputs: true }; @@ -2943,7 +2943,7 @@ describe('client API', function() { for (const i of result.inputs) { totalSatoshis = totalSatoshis + i.satoshis; } - result.amount.should.be.equal(totalSatoshis - result.fee); + result.amount.should.be.equal(BigInt(totalSatoshis) - result.fee); done(); }); }); @@ -3276,12 +3276,12 @@ describe('client API', function() { const opts = { outputs: [ { - amount: 1e8, + amount: BigInt(1e8), toAddress: toAddress, message: 'world' }, { - amount: 2e8, + amount: BigInt(2e8), toAddress: toAddress } ], @@ -3293,20 +3293,20 @@ describe('client API', function() { someStr: 'str' } }; - clients[0].createTxProposal(opts, (err, txp) => { + clients[0].createTxProposal(opts, null, (err, txp) => { should.not.exist(err); should.exist(txp); txp.status.should.equal('temporary'); txp.message.should.equal('hello'); txp.outputs.length.should.equal(2); - txp.outputs.reduce((sum, output) => sum += output.amount, 0).should.equal(3e8); + txp.outputs.reduce((sum, output) => sum += Number(output.amount), 0).should.equal(3e8); txp.outputs[0].message.should.equal('world'); new Set(txp.outputs.map(output => output.toAddress)).size.should.equal(1); Array.from(new Set(txp.outputs.map(o => o.toAddress)))[0].should.equal(toAddress); txp.hasUnconfirmedInputs.should.equal(false); txp.feeLevel.should.equal('normal'); - txp.feePerKb.should.equal(123e2); + Number(txp.feePerKb).should.equal(123e2); should.exist(txp.encryptedMessage); should.exist(txp.outputs[0].encryptedMessage); @@ -3315,32 +3315,27 @@ describe('client API', function() { should.not.exist(err); txps.should.be.empty; - clients[0].publishTxProposal( - { - txp: txp - }, - (err, publishedTxp) => { + clients[0].publishTxProposal({ txp }, (err, publishedTxp) => { + should.not.exist(err); + should.exist(publishedTxp); + publishedTxp.status.should.equal('pending'); + clients[0].getTxProposals({}, (err, txps) => { should.not.exist(err); - should.exist(publishedTxp); - publishedTxp.status.should.equal('pending'); - clients[0].getTxProposals({}, (err, txps) => { + txps.length.should.equal(1); + const x = txps[0]; + x.id.should.equal(txp.id); + should.exist(x.proposalSignature); + should.not.exist(x.proposalSignaturePubKey); + should.not.exist(x.proposalSignaturePubKeySig); + // Should be visible for other copayers as well + clients[1].getTxProposals({}, (err, txps) => { should.not.exist(err); txps.length.should.equal(1); - const x = txps[0]; - x.id.should.equal(txp.id); - should.exist(x.proposalSignature); - should.not.exist(x.proposalSignaturePubKey); - should.not.exist(x.proposalSignaturePubKeySig); - // Should be visible for other copayers as well - clients[1].getTxProposals({}, (err, txps) => { - should.not.exist(err); - txps.length.should.equal(1); - txps[0].id.should.equal(txp.id); - done(); - }); + txps[0].id.should.equal(txp.id); + done(); }); - } - ); + }); + }); }); }); }); @@ -3354,12 +3349,12 @@ describe('client API', function() { txProposalId: '1234', outputs: [ { - amount: 1e8, + amount: BigInt(1e8), toAddress: toAddress, message: 'world' }, { - amount: 2e8, + amount: BigInt(2e8), toAddress: toAddress } ], @@ -3372,66 +3367,56 @@ describe('client API', function() { someStr: 'str' } }; - clients[0].createTxProposal(opts, (err, txp) => { + clients[0].createTxProposal(opts, null, (err, txp) => { should.not.exist(err); should.exist(txp); txp.status.should.equal('temporary'); txp.feeLevel.should.equal('economy'); - txp.feePerKb.should.equal(123e2); - clients[0].publishTxProposal( - { - txp: txp - }, - (err, publishedTxp) => { + Number(txp.feePerKb).should.equal(123e2); + clients[0].publishTxProposal({ txp }, (err, publishedTxp) => { + should.not.exist(err); + should.exist(publishedTxp); + publishedTxp.status.should.equal('pending'); + clients[0].getTxProposals({}, (err, txps) => { should.not.exist(err); - should.exist(publishedTxp); - publishedTxp.status.should.equal('pending'); - clients[0].getTxProposals({}, (err, txps) => { + txps.length.should.equal(1); + // Try to republish from copayer 1 + clients[1].createTxProposal(opts, null, (err, txp) => { should.not.exist(err); - txps.length.should.equal(1); - // Try to republish from copayer 1 - clients[1].createTxProposal(opts, (err, txp) => { + should.exist(txp); + txp.status.should.equal('pending'); + clients[1].publishTxProposal({ txp }, (err, publishedTxp) => { should.not.exist(err); - should.exist(txp); - txp.status.should.equal('pending'); - clients[1].publishTxProposal( - { - txp: txp - }, - (err, publishedTxp) => { - should.not.exist(err); - should.exist(publishedTxp); - publishedTxp.status.should.equal('pending'); - done(); - } - ); + should.exist(publishedTxp); + publishedTxp.status.should.equal('pending'); + done(); }); }); - } - ); + }); + }); }); }); it('Should protect against tampering at proposal creation', function(done) { const opts = { outputs: [ { - amount: 1e8, + amount: BigInt(1e8), toAddress: 'n2TBMPzPECGUfcT2EByiTJ12TPZkhN2mN5', message: 'world' }, { - amount: 2e8, + amount: BigInt(2e8), toAddress: 'n2TBMPzPECGUfcT2EByiTJ12TPZkhN2mN5' } ], - feePerKb: 123e2, + feePerKb: BigInt(123e2), changeAddress: myAddress.address, message: 'hello' }; const tamperings = [ txp => { - txp.feePerKb = 45600; + txp.feePerKb = BigInt(45600); }, txp => { txp.message = 'dummy'; @@ -3449,10 +3434,10 @@ describe('client API', function() { txp.outputs[0].toAddress = 'mjfjcbuYwBUdEyq2m7AezjCAR4etUBqyiE'; }, txp => { - txp.outputs[0].amount = 2e8; + txp.outputs[0].amount = BigInt(2e8); }, txp => { - txp.outputs[1].amount = 3e8; + txp.outputs[1].amount = BigInt(3e8); }, txp => { txp.outputs[0].message = 'dummy'; @@ -3472,7 +3457,7 @@ describe('client API', function() { tamperings, (tamperFn, next) => { helpers.tamperResponse(clients[0], 'post', '/v2/txproposals/', args, tamperFn, () => { - clients[0].createTxProposal(opts, (err, txp) => { + clients[0].createTxProposal(opts, null, (err, txp) => { should.exist(err, 'For tamper function ' + tamperFn); err.should.be.an.instanceOf(Errors.SERVER_COMPROMISED); next(); @@ -3490,24 +3475,24 @@ describe('client API', function() { const opts = { outputs: [ { - amount: 3e8, + amount: BigInt(3e8), toAddress: 'n2TBMPzPECGUfcT2EByiTJ12TPZkhN2mN5' } ], - feePerKb: 100e2 + feePerKb: BigInt(100e2) }; let txp1, txp2; async.series( [ next => { - clients[0].createTxProposal(opts, (err, txp) => { + clients[0].createTxProposal(opts, null, (err, txp) => { txp1 = txp; next(err); }); }, next => { - clients[0].createTxProposal(opts, (err, txp) => { + clients[0].createTxProposal(opts, null, (err, txp) => { txp2 = txp; next(err); }); @@ -3555,7 +3540,7 @@ describe('client API', function() { }); it('Should create proposal with unconfirmed inputs', function(done) { const opts = { - amount: 4.5e8, + amount: BigInt(4.5e8), toAddress: 'n2TBMPzPECGUfcT2EByiTJ12TPZkhN2mN5', message: 'hello' }; @@ -3570,7 +3555,7 @@ describe('client API', function() { }); it('Should fail to create proposal with insufficient funds', function(done) { const opts = { - amount: 6e8, + amount: BigInt(6e8), toAddress: 'n2TBMPzPECGUfcT2EByiTJ12TPZkhN2mN5', message: 'hello 1-1' }; @@ -3582,7 +3567,7 @@ describe('client API', function() { }); it('Should fail to create proposal with insufficient funds for fee', function(done) { const opts = { - amount: 5e8 - 200e2, + amount: BigInt(5e8 - 200e2), toAddress: 'n2TBMPzPECGUfcT2EByiTJ12TPZkhN2mN5', message: 'hello 1-1', feePerKb: 800e2 @@ -3603,7 +3588,7 @@ describe('client API', function() { }); it('Should lock and release funds through rejection', function(done) { const opts = { - amount: 2.2e8, + amount: BigInt(2.2e8), toAddress: 'n2TBMPzPECGUfcT2EByiTJ12TPZkhN2mN5' }; helpers.createAndPublishTxProposal(clients[0], opts, (err, x) => { @@ -3628,7 +3613,7 @@ describe('client API', function() { }); it('Should lock and release funds through removal', function(done) { const opts = { - amount: 2.2e8, + amount: BigInt(2.2e8), toAddress: 'n2TBMPzPECGUfcT2EByiTJ12TPZkhN2mN5', message: 'hello 1-1' }; @@ -3651,7 +3636,7 @@ describe('client API', function() { }); it('Should keep message and refusal texts', function(done) { const opts = { - amount: 1e8, + amount: BigInt(1e8), toAddress: 'n2TBMPzPECGUfcT2EByiTJ12TPZkhN2mN5', message: 'some message' }; @@ -3672,7 +3657,7 @@ describe('client API', function() { }); it('Should hide message and refusal texts if not key is present', function(done) { const opts = { - amount: 1e8, + amount: BigInt(1e8), toAddress: 'n2TBMPzPECGUfcT2EByiTJ12TPZkhN2mN5', message: 'some message' }; @@ -3698,15 +3683,15 @@ describe('client API', function() { const opts = { outputs: [ { - amount: 1000e2, + amount: BigInt(1000e2), toAddress: 'n2TBMPzPECGUfcT2EByiTJ12TPZkhN2mN5' } ], message: 'some message', - feePerKb: 100e2 + feePerKb: BigInt(100e2) }; const spy = sandbox.spy(clients[0].request, 'post'); - clients[0].createTxProposal(opts, (err, x) => { + clients[0].createTxProposal(opts, null, (err, x) => { should.not.exist(err); spy.calledOnce.should.be.true; JSON.stringify(spy.getCall(0).args).should.not.contain('some message'); @@ -3715,7 +3700,7 @@ describe('client API', function() { }); it('Should encrypt proposal refusal comment', function(done) { const opts = { - amount: 1e8, + amount: BigInt(1e8), toAddress: 'n2TBMPzPECGUfcT2EByiTJ12TPZkhN2mN5' }; helpers.createAndPublishTxProposal(clients[0], opts, (err, x) => { @@ -3733,7 +3718,7 @@ describe('client API', function() { describe('Detecting tampered tx proposals', () => { it('should detect wrong signature', function(done) { const opts = { - amount: 1000e2, + amount: BigInt(1000e2), toAddress: 'n2TBMPzPECGUfcT2EByiTJ12TPZkhN2mN5', message: 'hello' }; @@ -3761,7 +3746,7 @@ describe('client API', function() { }); it('should detect tampered amount', function(done) { const opts = { - amount: 1000e2, + amount: BigInt(1000e2), toAddress: 'n2TBMPzPECGUfcT2EByiTJ12TPZkhN2mN5', message: 'hello' }; @@ -3774,7 +3759,7 @@ describe('client API', function() { '/v1/txproposals/', {}, txps => { - txps[0].outputs[0].amount = 1e8; + txps[0].outputs[0].amount = BigInt(1e8); }, () => { clients[0].getTxProposals({}, (err, txps) => { @@ -3788,7 +3773,7 @@ describe('client API', function() { }); it('should detect change address not it wallet', function(done) { const opts = { - amount: 1000e2, + amount: BigInt(1000e2), toAddress: 'n2TBMPzPECGUfcT2EByiTJ12TPZkhN2mN5', message: 'hello' }; @@ -3857,18 +3842,18 @@ describe('client API', function() { const opts = { outputs: [ { - amount: 1e8, + amount: BigInt(1e8), toAddress: toAddress }, { - amount: 2e8, + amount: BigInt(2e8), toAddress: toAddress } ], - feePerKb: 100e2, + feePerKb: BigInt(100e2), message: 'just some message' }; - clients[0].createTxProposal(opts, (err, txp) => { + clients[0].createTxProposal(opts, null, (err, txp) => { should.not.exist(err); should.exist(txp); clients[0].publishTxProposal( @@ -3900,19 +3885,19 @@ describe('client API', function() { const opts = { outputs: [ { - amount: 1e8, + amount: BigInt(1e8), toAddress: toAddress }, { - amount: 2e8, + amount: BigInt(2e8), toAddress: toAddress } ], - feePerKb: 100e2, + feePerKb: BigInt(100e2), message: 'just some message', enableRBF: true }; - clients[0].createTxProposal(opts, (err, txp) => { + clients[0].createTxProposal(opts, null, (err, txp) => { should.not.exist(err); should.exist(txp); clients[0].publishTxProposal( @@ -3944,14 +3929,14 @@ describe('client API', function() { const opts = { outputs: [ { - amount: 4e8 - 100, + amount: BigInt(4e8 - 100), toAddress: toAddress } ], excludeUnconfirmedUtxos: true, - feePerKb: 1 + feePerKb: 1n }; - clients[0].createTxProposal(opts, (err, txp) => { + clients[0].createTxProposal(opts, null, (err, txp) => { should.not.exist(err); should.exist(txp); const t = Utils.buildTx(txp); @@ -3982,7 +3967,7 @@ describe('client API', function() { const toAddress = 'n2TBMPzPECGUfcT2EByiTJ12TPZkhN2mN5'; clients[0].getSendMaxInfo( { - feePerKb: 100e2, + feePerKb: BigInt(100e2), returnInputs: true }, (err, info) => { @@ -3997,7 +3982,7 @@ describe('client API', function() { inputs: info.inputs, fee: info.fee }; - clients[0].createTxProposal(opts, (err, txp) => { + clients[0].createTxProposal(opts, null, (err, txp) => { should.not.exist(err); should.exist(txp); const t = Utils.buildTx(txp); @@ -4037,19 +4022,20 @@ describe('client API', function() { const opts = { outputs: [ { - amount: 1e8, + amount: BigInt(1e8), toAddress: toAddress }, { - amount: 2e8, + amount: BigInt(2e8), toAddress: toAddress } ], - feePerKb: 100e2, + feePerKb: BigInt(100e2), message: 'just some message' }; clients[0].createTxProposal( opts, + { baseUrl: '/v3/txproposals' }, (err, txp) => { should.not.exist(err); should.exist(txp); @@ -4075,8 +4061,7 @@ describe('client API', function() { }); } ); - }, - '/v3/txproposals' + } ); }); @@ -4085,18 +4070,18 @@ describe('client API', function() { const opts = { outputs: [ { - amount: 1e8, + amount: BigInt(1e8), toAddress: toAddress }, { - amount: 2e8, + amount: BigInt(2e8), toAddress: toAddress } ], - feePerKb: 100e2, + feePerKb: BigInt(100e2), message: 'just some message' }; - clients[0].createTxProposal(opts, (err, txp) => { + clients[0].createTxProposal(opts, null, (err, txp) => { should.not.exist(err); should.exist(txp); txp.version.should.equal(4); @@ -4128,18 +4113,18 @@ describe('client API', function() { const opts = { outputs: [ { - amount: 1e8, + amount: BigInt(1e8), toAddress: toAddress }, { - amount: 2e8, + amount: BigInt(2e8), toAddress: toAddress } ], - feePerKb: 100e2, + feePerKb: BigInt(100e2), message: 'just some message' }; - clients[0].createTxProposal(opts, (err, txp) => { + clients[0].createTxProposal(opts, null, (err, txp) => { should.not.exist(err); should.exist(txp); txp.version.should.equal(4); @@ -4178,18 +4163,18 @@ describe('client API', function() { const opts = { outputs: [ { - amount: 1e8, + amount: BigInt(1e8), toAddress: toAddress }, { - amount: 2e8, + amount: BigInt(2e8), toAddress: toAddress } ], - feePerKb: 100e2, + feePerKb: BigInt(100e2), message: 'just some message' }; - clients[0].createTxProposal(opts, (err, txp) => { + clients[0].createTxProposal(opts, null, (err, txp) => { should.not.exist(err); should.exist(txp); clients[0].publishTxProposal( @@ -4226,19 +4211,19 @@ describe('client API', function() { const opts = { outputs: [ { - amount: 1e8, + amount: BigInt(1e8), toAddress: toAddress }, { - amount: 2e8, + amount: BigInt(2e8), toAddress: toAddress } ], - feePerKb: 100e2, + feePerKb: BigInt(100e2), message: 'just some message', signingMethod: 'schnorr' // forcing schnorr on BCH/livenet }; - clients[0].createTxProposal(opts, (err, txp) => { + clients[0].createTxProposal(opts, null, (err, txp) => { should.not.exist(err); should.exist(txp); txp.signingMethod.should.equal('schnorr'); @@ -4288,19 +4273,19 @@ describe('client API', function() { const opts = { outputs: [ { - amount: 1e8, + amount: BigInt(1e8), toAddress: toAddress }, { - amount: 2e8, + amount: BigInt(2e8), toAddress: toAddress } ], - feePerKb: 100e2, + feePerKb: BigInt(100e2), message: 'just some message', signingMethod: 'schnorr' // forcing schnorr on BCH/livenet }; - clients[0].createTxProposal(opts, (err, txp) => { + clients[0].createTxProposal(opts, null, (err, txp) => { should.not.exist(err); should.exist(txp); txp.signingMethod.should.equal('schnorr'); @@ -4336,19 +4321,19 @@ describe('client API', function() { const opts = { outputs: [ { - amount: 1e8, + amount: BigInt(1e8), toAddress: toAddress }, { - amount: 2e8, + amount: BigInt(2e8), toAddress: toAddress } ], - feePerKb: 100e2, + feePerKb: BigInt(100e2), message: 'just some message', coin: 'bch' }; - clients[0].createTxProposal(opts, (err, txp) => { + clients[0].createTxProposal(opts, null, (err, txp) => { should.not.exist(err); should.exist(txp); clients[0].publishTxProposal( @@ -4375,21 +4360,22 @@ describe('client API', function() { const opts = { outputs: [ { - amount: 1e8, + amount: BigInt(1e8), toAddress: toAddress }, { - amount: 2e8, + amount: BigInt(2e8), toAddress: toAddress } ], - feePerKb: 100e2, + feePerKb: BigInt(100e2), message: 'just some message', txpVersion: 3, coin: 'bch' }; clients[0].createTxProposal( opts, + { baseUrl: '/v3/txproposals' }, (err, txp) => { should.not.exist(err); should.exist(txp); @@ -4413,8 +4399,7 @@ describe('client API', function() { ); } ); - }, - '/v3/txproposals' + } ); }); @@ -4423,21 +4408,22 @@ describe('client API', function() { const opts = { outputs: [ { - amount: 1e8, + amount: BigInt(1e8), toAddress: toAddress }, { - amount: 2e8, + amount: BigInt(2e8), toAddress: toAddress } ], - feePerKb: 100e2, + feePerKb: BigInt(100e2), message: 'just some message', txpVersion: 3, coin: 'bch' }; clients[0].createTxProposal( opts, + { baseUrl: '/v3/txproposals' }, (err, txp) => { should.not.exist(err); should.exist(txp); @@ -4462,8 +4448,7 @@ describe('client API', function() { ); } ); - }, - '/v3/txproposals' + } ); }); @@ -4472,20 +4457,21 @@ describe('client API', function() { const opts = { outputs: [ { - amount: 1e8, + amount: BigInt(1e8), toAddress: toAddress }, { - amount: 2e8, + amount: BigInt(2e8), toAddress: toAddress } ], - feePerKb: 100e2, + feePerKb: BigInt(100e2), message: 'just some message', coin: 'bch' }; clients[0].createTxProposal( opts, + { baseUrl: '/v3/txproposals' }, (err, txp) => { should.not.exist(err); should.exist(txp); @@ -4507,8 +4493,7 @@ describe('client API', function() { }); } ); - }, - '/v3/txproposals' + } ); }); @@ -4517,18 +4502,18 @@ describe('client API', function() { const opts = { outputs: [ { - amount: 1e8, + amount: BigInt(1e8), toAddress: toAddress }, { - amount: 2e8, + amount: BigInt(2e8), toAddress: toAddress } ], - feePerKb: 100e2, + feePerKb: BigInt(100e2), message: 'just some message' }; - clients[0].createTxProposal(opts, (err, txp) => { + clients[0].createTxProposal(opts, null, (err, txp) => { should.not.exist(err); should.exist(txp); txp.version.should.equal(4); @@ -4560,18 +4545,18 @@ describe('client API', function() { const opts = { outputs: [ { - amount: 1e8, + amount: BigInt(1e8), toAddress: toAddress }, { - amount: 2e8, + amount: BigInt(2e8), toAddress: toAddress } ], - feePerKb: 100e2, + feePerKb: BigInt(100e2), message: 'just some message' }; - clients[0].createTxProposal(opts, (err, txp) => { + clients[0].createTxProposal(opts, null, (err, txp) => { should.not.exist(err); should.exist(txp); txp.version.should.equal(4); @@ -5041,7 +5026,7 @@ describe('client API', function() { const signatures = await keys[0].sign(clients[0].getRootPath(), txps[0]); clients[0].pushSignatures(txps[0], signatures, async (err, xx) => { should.not.exist(err); - xx.feePerKb = Number(xx.feePerKb) / 2; + xx.feePerKb = CWC.Utils.toHex((Number(xx.feePerKb) / 2).toFixed(0)); const signatures2 = await keys[1].sign(clients[1].getRootPath(), xx); clients[1].pushSignatures(xx, signatures2, (err, yy) => { should.not.exist(err); @@ -5215,8 +5200,9 @@ describe('client API', function() { ], message: DATA.memo, payProUrl: opts.paymentUrl, - feePerKb: 100e2 + feePerKb: BigInt(100e2) }, + null, (err, txp) => { should.not.exist(err); clients[0].publishTxProposal( @@ -5263,14 +5249,14 @@ describe('client API', function() { const opts = { outputs: [ { - amount: 40000, + amount: BigInt(40000), toAddress: toAddress } ], - feePerKb: 100e2, + feePerKb: BigInt(100e2), txProposalId: id }; - clients[0].createTxProposal(opts, (err, txp) => { + clients[0].createTxProposal(opts, null, (err, txp) => { should.not.exist(err); should.exist(txp); clients[0].publishTxProposal( @@ -5282,7 +5268,7 @@ describe('client API', function() { publishedTxp.id.should.equal(id); clients[0].removeTxProposal(publishedTxp, err => { opts.txProposalId = null; - clients[0].createTxProposal(opts, (err, txp) => { + clients[0].createTxProposal(opts, null, (err, txp) => { should.not.exist(err); should.exist(txp); txp.id.should.not.equal(id); @@ -5311,7 +5297,7 @@ describe('client API', function() { message: 'world' } ], - feePerKb: 100e2 + feePerKb: BigInt(100e2) }; const http = sandbox.stub(); @@ -5334,7 +5320,7 @@ describe('client API', function() { x2.creatorName.should.equal('creator'); x2.message.should.equal('hello'); x2.outputs[0].toAddress.should.equal(toAddress); - x2.outputs[0].amount.should.equal(10000); + x2.outputs[0].amount.should.equal('0x2710'); // 10000 in hex x2.outputs[0].message.should.equal('world'); clients[0].doNotVerifyPayPro = doNotVerifyPayPro; const signatures = await keys[0].sign(clients[0].getRootPath(), x2); @@ -5394,7 +5380,7 @@ describe('client API', function() { } ], message: 'hello', - feePerKb: 100e2 + feePerKb: BigInt(100e2) }; helpers.createAndPublishTxProposal(clients[0], opts, async (err, txp) => { should.not.exist(err); @@ -5440,7 +5426,7 @@ describe('client API', function() { } ], message: 'hello', - feePerKb: 100e2, + feePerKb: BigInt(100e2), txType: 2 }; helpers.createAndPublishTxProposal(clients[0], opts, async (err, txp) => { @@ -5487,7 +5473,7 @@ describe('client API', function() { } ], message: 'hello', - feePerKb: 100e2 + feePerKb: BigInt(100e2) }; const opts1 = { ...opts, nonce: 1 }; helpers.createAndPublishTxProposal(clients[0], opts1, (err, txp1) => { @@ -5535,7 +5521,7 @@ describe('client API', function() { } ], message: 'hello', - feePerKb: 100e2 + feePerKb: BigInt(100e2) }; helpers.createAndPublishTxProposal(clients[0], opts, async (err, txp) => { should.not.exist(err); @@ -9107,13 +9093,13 @@ describe('client API', function() { const opts = { outputs: [ { - amount: 1e8, + amount: BigInt(1e8), toAddress: toAddress } ], - feePerKb: 100e2 + feePerKb: BigInt(100e2) }; - clients[0].createTxProposal(opts, (err, txp) => { + clients[0].createTxProposal(opts, null, (err, txp) => { should.not.exist(err); should.exist(txp); should.exist(txp.changeAddress); @@ -9213,4 +9199,214 @@ describe('client API', function() { }); }); }); + + describe('Transaction proposal round trip', function() { + let txp; + let client: Client; + const keys = []; + const signedTxs = []; + + const setup = async function(coin, network, m, n) { + const w = await helpers.createAndJoinWallet( + [client], + keys, + m, + n, + { + coin: coin, + network: network + }, + ); + const address = await client.createAddress(null); + + if (CWC.Utils.isUtxoChain(coin)) { + blockchainExplorerMock.setUtxo(address, 2, m); + blockchainExplorerMock.setUtxo(address, 2, m); + blockchainExplorerMock.setUtxo(address, 1, m, 0); + } + }; + + describe('ETH', function() { + let nonce = 0n; + beforeEach(function(done) { + signedTxs.splice(0); + keys.splice(0); + const expressApp = new ExpressApp(); + expressApp.start( + { + ignoreRateLimiter: true, + storage: storage, + blockchainExplorer: blockchainExplorerMock, + disableLogs: true, + doNotCheckV8: true + }, + () => { + client = helpers.newClient(expressApp.app); + blockchainExplorerMock.reset(); + setup('eth', 'testnet', 1, 1) + .then(done) + .catch(done); + } + ); + }); + + + it('should send specific amount', async function() { + txp = await client.createTxProposal({ + outputs: [{ + toAddress: '0x1234567890123456789012345678901234567890', + // eslint-disable-next-line no-loss-of-precision + amount: 12345678901234567890 as any, // large number to test big number handling with precision loss. + }], + nonce: nonce++, + feePerKb: 1n + }); + should.exist(txp); + txp.status.should.equal('temporary'); + + // should publish txp + txp = await client.publishTxProposal({ txp }); + txp.status.should.equal('pending'); + + // should sign txp + const rootPath = client.getRootPath(); + const signed = await keys[0].sign(rootPath, txp); + Array.isArray(signed).should.equal(true); + signed.length.should.equal(1); + CWC.Utils.isHexString(signed[0]).should.equal(true); + signedTxs.push(...signed); + + // should push signature to txp + txp = await client.pushSignatures(txp, signedTxs); + txp.status.should.equal('accepted'); + + ({ txp } = await client.broadcastTxProposal(txp)); + txp.status.should.equal('broadcasted'); + }); + + + it('should send max', async function() { + const info = await client.getSendMaxInfo({ feePerKb: 1n }); + txp = await client.createTxProposal({ + outputs: [{ + toAddress: '0x1234567890123456789012345678901234567890', + amount: info.amount, + }], + nonce: nonce++, + feePerKb: 1n + }); + should.exist(txp); + txp.status.should.equal('temporary'); + + // should publish txp + txp = await client.publishTxProposal({ txp }); + txp.status.should.equal('pending'); + + // should sign txp + const rootPath = client.getRootPath(); + const signed = await keys[0].sign(rootPath, txp); + Array.isArray(signed).should.equal(true); + signed.length.should.equal(1); + CWC.Utils.isHexString(signed[0]).should.equal(true); + signedTxs.push(...signed); + + // should push signature to txp + txp = await client.pushSignatures(txp, signedTxs); + txp.status.should.equal('accepted'); + + ({ txp } = await client.broadcastTxProposal(txp)); + txp.status.should.equal('broadcasted'); + }); + }); + + describe('BTC', function() { + + beforeEach(function(done) { + signedTxs.splice(0); + keys.splice(0); + const expressApp = new ExpressApp(); + expressApp.start( + { + ignoreRateLimiter: true, + storage: storage, + blockchainExplorer: blockchainExplorerMock, + disableLogs: true, + doNotCheckV8: true + }, + () => { + client = helpers.newClient(expressApp.app); + blockchainExplorerMock.reset(); + setup('btc', 'testnet', 1, 1) + .then(done) + .catch(done); + } + ); + }); + + + it('should send specific amount', async function() { + txp = await client.createTxProposal({ + outputs: [{ + toAddress: 'n2TBMPzPECGUfcT2EByiTJ12TPZkhN2mN5', + amount: 123456798 as any, + }], + feePerKb: 1n + }); + should.exist(txp); + txp.status.should.equal('temporary'); + + // should publish txp + txp = await client.publishTxProposal({ txp }); + txp.status.should.equal('pending'); + + // should sign txp + const rootPath = client.getRootPath(); + const signed = await keys[0].sign(rootPath, txp); + Array.isArray(signed).should.equal(true); + signed.length.should.equal(1); + CWC.Utils.isHexString(signed[0]).should.equal(true); + signedTxs.push(...signed); + + // should push signature to txp + txp = await client.pushSignatures(txp, signedTxs); + txp.status.should.equal('accepted'); + + ({ txp } = await client.broadcastTxProposal(txp)); + txp.status.should.equal('broadcasted'); + }); + + + it('should send max', async function() { + const info = await client.getSendMaxInfo({ feePerKb: 1n, excludeUnconfirmedUtxos: true }); + txp = await client.createTxProposal({ + outputs: [{ + toAddress: 'n2TBMPzPECGUfcT2EByiTJ12TPZkhN2mN5', + amount: info.amount, + }], + feePerKb: 1n + }); + should.exist(txp); + txp.status.should.equal('temporary'); + + // should publish txp + txp = await client.publishTxProposal({ txp }); + txp.status.should.equal('pending'); + + // should sign txp + const rootPath = client.getRootPath(); + const signed = await keys[0].sign(rootPath, txp); + Array.isArray(signed).should.equal(true); + signed.length.should.equal(2); // 2 utxos + CWC.Utils.isHexString(signed[0]).should.equal(true); + signedTxs.push(...signed); + + // should push signature to txp + txp = await client.pushSignatures(txp, signedTxs); + txp.status.should.equal('accepted'); + + ({ txp } = await client.broadcastTxProposal(txp)); + txp.status.should.equal('broadcasted'); + }); + }); + }); }); diff --git a/packages/bitcore-wallet-client/test/helpers.ts b/packages/bitcore-wallet-client/test/helpers.ts index ecedd79b8ae..37ca95961fb 100644 --- a/packages/bitcore-wallet-client/test/helpers.ts +++ b/packages/bitcore-wallet-client/test/helpers.ts @@ -333,10 +333,20 @@ export const blockchainExplorerMock = { estimatePriorityFee: (opts, cb) => { return cb(null, 5000); }, - estimateGas: (nbBlocks, cb) => { + estimateGas: (nbBlocksOrOpts, cb) => { + if (typeof nbBlocksOrOpts === 'object' && Utils.isEvmChain(nbBlocksOrOpts.chain)) { + return cb(null, '21000'); + } return cb(null, '20000000000'); }, - getBalance: (nbBlocks, cb) => { + getBalance: (nbBlocksOrWallet, cb) => { + if (typeof nbBlocksOrWallet === 'object' && Utils.isEvmChain(nbBlocksOrWallet.chain)) { + return cb(null, { + unconfirmed: 0, + confirmed: 20e18, // 20 ETH + balance: 20e18 + }); + } return cb(null, { unconfirmed: 0, confirmed: 20000000000 * 5, diff --git a/packages/bitcore-wallet-service/src/lib/chain/btc/index.ts b/packages/bitcore-wallet-service/src/lib/chain/btc/index.ts index a132fad1562..eb9ee103156 100644 --- a/packages/bitcore-wallet-service/src/lib/chain/btc/index.ts +++ b/packages/bitcore-wallet-service/src/lib/chain/btc/index.ts @@ -3,13 +3,15 @@ import * as async from 'async'; import _ from 'lodash'; import { singleton } from 'preconditions'; import config from '../../../config'; -import { IChain } from '../../../types/chain'; import { Common } from '../../common'; import { ClientError } from '../../errors/clienterror'; import { Errors } from '../../errors/errordefinitions'; import logger from '../../logger'; -import { IWallet, TxProposal } from '../../model'; -import { WalletService } from '../../server'; +import { TxProposal } from '../../model'; +import type { IChain } from '../../../types/chain'; +import type { GetSendMaxInfoOpts } from '../../../types/server'; +import type { IWallet } from '../../model'; +import type { WalletService } from '../../server'; const $ = singleton(); const { Constants, Utils, Defaults } = Common; @@ -72,7 +74,7 @@ export class BtcChain implements IChain { } // opts.payProUrl => only to use different safety margin or not - getWalletSendMaxInfo(server: WalletService, wallet, opts, cb) { + getWalletSendMaxInfo(server: WalletService, wallet: IWallet, opts: GetSendMaxInfoOpts, cb) { server.getUtxosForCurrentWallet({}, (err, utxos) => { if (err) return cb(err); diff --git a/packages/bitcore-wallet-service/src/lib/chain/doge/index.ts b/packages/bitcore-wallet-service/src/lib/chain/doge/index.ts index aa822e79b50..c5db95a67de 100644 --- a/packages/bitcore-wallet-service/src/lib/chain/doge/index.ts +++ b/packages/bitcore-wallet-service/src/lib/chain/doge/index.ts @@ -1,13 +1,16 @@ import { BitcoreLibDoge } from '@bitpay-labs/crypto-wallet-core'; import * as async from 'async'; import _ from 'lodash'; -import { IChain } from '../../../types/chain'; import { BtcChain } from '../../chain/btc'; import { Common } from '../../common'; import { ClientError } from '../../errors/clienterror'; import { Errors } from '../../errors/errordefinitions'; import logger from '../../logger'; import { TxProposal } from '../../model'; +import type { IChain } from '../../../types/chain'; +import type { GetSendMaxInfoOpts } from '../../../types/server'; +import type { IWallet } from '../../model/wallet'; +import type { WalletService } from '../../server'; const { Utils, Defaults } = Common; @@ -298,7 +301,7 @@ export class DogeChain extends BtcChain implements IChain { }); } - getWalletSendMaxInfo(server, wallet, opts, cb) { + getWalletSendMaxInfo(server: WalletService, wallet: IWallet, opts: GetSendMaxInfoOpts, cb) { server.getUtxosForCurrentWallet({}, (err, utxos) => { if (err) return cb(err); diff --git a/packages/bitcore-wallet-service/src/lib/chain/eth/index.ts b/packages/bitcore-wallet-service/src/lib/chain/eth/index.ts index 7296a662e96..1baba700bae 100644 --- a/packages/bitcore-wallet-service/src/lib/chain/eth/index.ts +++ b/packages/bitcore-wallet-service/src/lib/chain/eth/index.ts @@ -1,8 +1,5 @@ -import { Transactions, Utils, Validation, Web3 } from '@bitpay-labs/crypto-wallet-core'; +import { Utils as CWCUtils, Transactions, Validation, Web3 } from '@bitpay-labs/crypto-wallet-core'; import _ from 'lodash'; -import { IWallet } from 'src/lib/model'; -import { IAddress } from 'src/lib/model/address'; -import { WalletService } from 'src/lib/server'; import { Common } from '../../common'; import { ClientError } from '../../errors/clienterror'; import { Errors } from '../../errors/errordefinitions'; @@ -10,7 +7,11 @@ import logger from '../../logger'; import { ERC20Abi } from './abi-erc20'; import { InvoiceAbi } from './abi-invoice'; import type { IChain } from '../../../types/chain'; +import type { GetSendMaxInfoOpts } from '../../../types/server'; +import type { IAddress } from '../../model/address'; import type { TxProposal } from '../../model/txproposal'; +import type { IWallet } from '../../model/wallet'; +import type { WalletService } from '../../server'; const { Constants, @@ -136,7 +137,7 @@ export class EthChain implements IChain { }); } - getWalletSendMaxInfo(server, wallet, opts, cb) { + getWalletSendMaxInfo(server: WalletService, wallet: IWallet, opts: GetSendMaxInfoOpts, cb) { server.getBalance({}, (err, balance) => { if (err) return cb(err); const { availableAmount } = balance; @@ -170,7 +171,7 @@ export class EthChain implements IChain { checkScriptOutput(_output) { } - getFee(server, wallet, opts) { + getFee(server: WalletService, wallet: IWallet, opts) { return new Promise(resolve => { server._getFeePerKb(wallet, opts, async (err, inFeePerKb) => { let feePerKb = inFeePerKb; @@ -228,7 +229,7 @@ export class EthChain implements IChain { output.gasLimit = defaultGasLimit; } } - inGasLimit += output.gasLimit; + inGasLimit += Number(output.gasLimit); logger.info(`[${from}][${output?.toAddress || opts?.tokenAddress}] Output level gas limit: ${output.gasLimit}`); // Add gas Limit buffer to output level gasLimit if (gasLimitBuffer) { @@ -237,7 +238,7 @@ export class EthChain implements IChain { inGasLimit += gasBuffer; logger.info(`[${from}][${output?.toAddress || opts?.tokenAddress}] Output gas limit with buffer: ${output.gasLimit}`); } - if (_.isNumber(opts.fee)) { + if (!isNaN(opts.fee)) { // This is used for sendmax gasPrice = feePerKb = Number((opts.fee / (inGasLimit || defaultGasLimit)).toFixed()); } @@ -530,7 +531,7 @@ export class EthChain implements IChain { output.amount == null || output.amount < 0 || isNaN(output.amount) || - Utils.toHex(output.amount) !== '0x' + BigInt(output.amount).toString(16) + !CWCUtils.toHex(output.amount) // ensure toHex doesn't throw ) { throw new Error('output.amount is not a valid value: ' + output.amount); } diff --git a/packages/bitcore-wallet-service/src/lib/chain/index.ts b/packages/bitcore-wallet-service/src/lib/chain/index.ts index 76995c274ae..b133dd9457b 100644 --- a/packages/bitcore-wallet-service/src/lib/chain/index.ts +++ b/packages/bitcore-wallet-service/src/lib/chain/index.ts @@ -1,7 +1,4 @@ -import { IChain } from '../../types/chain'; import { Common } from '../common'; -import { IWallet, TxProposal } from '../model'; -import { WalletService } from '../server'; import logger from './../logger'; import { ArbChain } from './arb'; import { BaseChain } from './base'; @@ -14,6 +11,10 @@ import { MaticChain } from './matic'; import { OpChain } from './op'; import { SolChain } from './sol'; import { XrpChain } from './xrp'; +import type { IChain } from '../../types/chain'; +import type { GetSendMaxInfoOpts } from '../../types/server'; +import type { IWallet, TxProposal } from '../model'; +import type { WalletService } from '../server'; const Constants = Common.Constants; const Defaults = Common.Defaults; @@ -66,7 +67,7 @@ class ChainProxy { return this.get(wallet.chain).getWalletBalance(server, wallet, opts, cb); } - getWalletSendMaxInfo(server, wallet, opts, cb) { + getWalletSendMaxInfo(server: WalletService, wallet: IWallet, opts: GetSendMaxInfoOpts, cb) { return this.get(wallet.chain).getWalletSendMaxInfo(server, wallet, opts, cb); } diff --git a/packages/bitcore-wallet-service/src/lib/chain/sol/index.ts b/packages/bitcore-wallet-service/src/lib/chain/sol/index.ts index cb2e00f94f1..28c53d6183f 100644 --- a/packages/bitcore-wallet-service/src/lib/chain/sol/index.ts +++ b/packages/bitcore-wallet-service/src/lib/chain/sol/index.ts @@ -1,13 +1,15 @@ import { Transactions, Utils, Validation } from '@bitpay-labs/crypto-wallet-core'; import _ from 'lodash'; -import { IChain } from '../../../types/chain'; -import { WalletWithOpts } from '../../blockchainexplorers/v8'; import { Defaults } from '../../common/defaults'; import { Errors } from '../../errors/errordefinitions'; import logger from '../../logger'; -import { IWallet, TxProposal, Wallet } from '../../model'; -import { IAddress } from '../../model/address'; -import { WalletService } from '../../server'; +import { TxProposal } from '../../model'; +import type { IChain } from '../../../types/chain'; +import type { GetSendMaxInfoOpts } from '../../../types/server'; +import type { WalletWithOpts } from '../../blockchainexplorers/v8'; +import type { IWallet, Wallet } from '../../model'; +import type { IAddress } from '../../model/address'; +import type { WalletService } from '../../server'; export class SolChain implements IChain { chain: string; @@ -176,7 +178,7 @@ export class SolChain implements IChain { }); } - getWalletSendMaxInfo(server: WalletService, wallet, opts, cb) { + getWalletSendMaxInfo(server: WalletService, wallet: IWallet, opts: GetSendMaxInfoOpts, cb) { server.getBalance({}, (err, balance) => { if (err) return cb(err); const { availableAmount } = balance; @@ -198,7 +200,7 @@ export class SolChain implements IChain { output.amount == null || output.amount < 0 || isNaN(output.amount) || - Utils.toHex(output.amount) !== '0x' + BigInt(output.amount).toString(16) + !Utils.toHex(output.amount) // ensure toHex doesn't throw ) { logger.warn('output.amount is not a valid value: ' + output.amount); return false; diff --git a/packages/bitcore-wallet-service/src/lib/chain/xrp/index.ts b/packages/bitcore-wallet-service/src/lib/chain/xrp/index.ts index deae334310f..adaea4bf003 100644 --- a/packages/bitcore-wallet-service/src/lib/chain/xrp/index.ts +++ b/packages/bitcore-wallet-service/src/lib/chain/xrp/index.ts @@ -1,12 +1,13 @@ import { BitcoreLib, Deriver, Transactions, Validation, xrpl } from '@bitpay-labs/crypto-wallet-core'; import _ from 'lodash'; -import { IWallet } from 'src/lib/model'; -import { IAddress } from 'src/lib/model/address'; -import { IChain } from '../../../types/chain'; import { Common } from '../../common'; import { Errors } from '../../errors/errordefinitions'; import logger from '../../logger'; -import { WalletService } from '../../server'; +import type { IChain } from '../../../types/chain'; +import type { GetSendMaxInfoOpts } from '../../../types/server'; +import type { IWallet } from '../../model'; +import type { IAddress } from '../../model/address'; +import type { WalletService } from '../../server'; const { Defaults, Utils } = Common; @@ -87,7 +88,7 @@ export class XrpChain implements IChain { }); } - getWalletSendMaxInfo(server, wallet, opts, cb) { + getWalletSendMaxInfo(server: WalletService, wallet: IWallet, opts: GetSendMaxInfoOpts, cb) { server.getBalance({}, (err, balance) => { if (err) return cb(err); const { availableAmount } = balance; diff --git a/packages/bitcore-wallet-service/src/lib/common/utils.ts b/packages/bitcore-wallet-service/src/lib/common/utils.ts index c49bd14716f..a844f2fe4ed 100644 --- a/packages/bitcore-wallet-service/src/lib/common/utils.ts +++ b/packages/bitcore-wallet-service/src/lib/common/utils.ts @@ -3,7 +3,8 @@ import { BitcoreLibCash, BitcoreLibDoge, BitcoreLibLtc, - Constants as CWConstants + Constants as CWCConstants, + Utils as CWCUtils, } from '@bitpay-labs/crypto-wallet-core'; import _ from 'lodash'; import { singleton } from 'preconditions'; @@ -157,8 +158,43 @@ export const Utils = { } }, + getNumberConverter(numberFormat) { + let convertFn; + switch (numberFormat) { + case 'number': + convertFn = parseInt; + break; + case 'string': + convertFn = (n) => typeof n === 'string' && n.startsWith('0x') ? BigInt(n).toString() : n.toString(); + break; + case 'hex': + convertFn = (n) => CWCUtils.toHex(n); + break; + case 'bigint': + convertFn = (n) => BigInt(n); + break; + default: + logger.warn(`Invalid numberFormat: ${numberFormat}`); + return; + } + + const primitiveTypes = new Set(['number', 'string', 'bigint']); + const convert = (key, value) => { + if ((numberFormat === 'hex' || typeof value !== numberFormat) && primitiveTypes.has(typeof value)) { + try { + value = convertFn(value); + } catch (e) { + logger.warn(`Failed to convert key ${key} with value ${value} to ${numberFormat}: ${e.message}`); + } + } + return value; + }; + + return convert; + }, + formatAmount(satoshis, unit, opts) { - const UNITS = Object.entries(CWConstants.UNITS).reduce((units, [currency, currencyConfig]) => { + const UNITS = Object.entries(CWCConstants.UNITS).reduce((units, [currency, currencyConfig]) => { units[currency] = { toSatoshis: currencyConfig.toSatoshis, maxDecimals: currencyConfig.short.maxDecimals, diff --git a/packages/bitcore-wallet-service/src/lib/routes/transactions.ts b/packages/bitcore-wallet-service/src/lib/routes/transactions.ts index 2985e72d931..5f672a80bf1 100644 --- a/packages/bitcore-wallet-service/src/lib/routes/transactions.ts +++ b/packages/bitcore-wallet-service/src/lib/routes/transactions.ts @@ -163,6 +163,7 @@ export function registerTransactionRoutes(router: express.Router, context: Route router.get('/v1/txproposals/:id/', (req, res) => { getServerWithAuth(req, res, server => { req.body.txProposalId = req.params['id']; + req.body.numberFormat = req.query.numberFormat; server.getTx(req.body, (err, tx) => { if (err) return returnError(err, res, req); res.json(tx); diff --git a/packages/bitcore-wallet-service/src/lib/routes/walletdata.ts b/packages/bitcore-wallet-service/src/lib/routes/walletdata.ts index 07bac956d6f..3bfb2bac8ed 100644 --- a/packages/bitcore-wallet-service/src/lib/routes/walletdata.ts +++ b/packages/bitcore-wallet-service/src/lib/routes/walletdata.ts @@ -1,7 +1,7 @@ import express from 'express'; import { Common } from '../common'; import type * as Types from '../../types/expressapp'; -import type { GetAddressesOpts } from '../../types/server'; +import type { GetAddressesOpts, GetSendMaxInfoOpts, NumberFormatOpts } from '../../types/server'; const Utils = Common.Utils; @@ -111,16 +111,12 @@ export function registerWalletDataRoutes(router: express.Router, context: RouteC router.get('/v1/sendmaxinfo/', (req, res) => { getServerWithAuth(req, res, server => { const query = req.query; - const opts: { - feePerKb?: number; - feeLevel?: number; - returnInputs?: boolean; - excludeUnconfirmedUtxos?: boolean; - } = {}; + const opts: GetSendMaxInfoOpts & NumberFormatOpts = {}; if (query.feePerKb) opts.feePerKb = +query.feePerKb; - if (query.feeLevel) opts.feeLevel = Number(query.feeLevel); + if (query.feeLevel) opts.feeLevel = query.feeLevel; if (query.excludeUnconfirmedUtxos == '1') opts.excludeUnconfirmedUtxos = true; if (query.returnInputs == '1') opts.returnInputs = true; + if (query.numberFormat) opts.numberFormat = query.numberFormat as 'hex' | 'number' | 'string'; server.getSendMaxInfo(opts, (err, info) => { if (err) return returnError(err, res, req); diff --git a/packages/bitcore-wallet-service/src/lib/server.ts b/packages/bitcore-wallet-service/src/lib/server.ts index cf7b2dbd6bc..9a525ff16ce 100644 --- a/packages/bitcore-wallet-service/src/lib/server.ts +++ b/packages/bitcore-wallet-service/src/lib/server.ts @@ -4,7 +4,7 @@ import { BitcoreLibCash as BitcoreCash, BitcoreLibDoge as BitcoreDoge, BitcoreLibLtc as BitcoreLtc, - Validation + Validation, } from '@bitpay-labs/crypto-wallet-core'; import * as async from 'async'; import EmailValidator from 'email-validator'; @@ -57,7 +57,7 @@ import { } from './model'; import { Storage } from './storage'; import type { ExternalServicesConfig } from '../types/externalservices'; -import type { GetAddressesOpts, UpgradeCheckOpts } from '../types/server'; +import type { GetAddressesOpts, GetSendMaxInfoOpts, NumberFormatOpts, UpgradeCheckOpts } from '../types/server'; type BwsLogger = typeof logger; @@ -2073,24 +2073,34 @@ export class WalletService implements IWalletService { }); } + getSendMaxInfo(opts: GetSendMaxInfoOpts & NumberFormatOpts, cb) { + this._getSendMaxInfo(opts, (err, sendMaxInfo) => { + if (err) return cb(err); + if (opts.numberFormat) { + const convertFn = Utils.getNumberConverter(opts.numberFormat); + if (!convertFn) { + logger.warn(`Invalid numberFormat: ${opts.numberFormat}, no conversion will be applied to sendMaxInfo for wallet ${this.walletId}`); + } else { + for (const key of ['amount', 'amountBelowFee', 'fee', 'feePerKb', 'amountAboveMaxSize', 'size']) { + sendMaxInfo[key] = convertFn(key, sendMaxInfo[key]); + } + } + } + return cb(null, sendMaxInfo); + }); + } + + /** * Return info needed to send all funds in the wallet - * @param {Object} opts - * @param {number} opts.feeLevel[='normal'] - Optional. Specify the fee level for this TX ('priority', 'normal', 'economy', 'superEconomy') as defined in Defaults.FEE_LEVELS. - * @param {number} opts.feePerKb - Optional. Specify the fee per KB for this TX (in satoshi). - * @param {string} opts.excludeUnconfirmedUtxos[=false] - Optional. Do not use UTXOs of unconfirmed transactions as inputs - * @param {string} opts.returnInputs[=false] - Optional. Return the list of UTXOs that would be included in the tx. - * @param {string} opts.usePayPro[=false] - Optional. Use fee estimation for paypro - * @param {string} opts.from - Optional. Specify the sender ETH address. - * @returns {Object} sendMaxInfo */ - getSendMaxInfo(opts, cb) { + private _getSendMaxInfo(opts: GetSendMaxInfoOpts, cb) { opts = opts || {}; this.getWallet({}, (err, wallet) => { if (err) return cb(err); - const feeArgs = boolToNum(!!opts.feeLevel) + boolToNum(_.isNumber(opts.feePerKb)); + const feeArgs = boolToNum(!!opts.feeLevel) + boolToNum(!isNaN(opts.feePerKb) && opts.feePerKb != null); if (feeArgs > 1) return cb(new ClientError('Only one of feeLevel/feePerKb can be specified')); if (feeArgs == 0) { @@ -2461,8 +2471,8 @@ export class WalletService implements IWalletService { ); } - _getFeePerKb(wallet, opts, cb) { - if (Utils.isNumber(opts.feePerKb)) return cb(null, opts.feePerKb); + _getFeePerKb(wallet: IWallet, opts: { feePerKb?: number; feeLevel?: string }, cb) { + if (Utils.isNumber(opts.feePerKb)) return cb(null, Number(opts.feePerKb)); this.getFeeLevels( { chain: wallet.chain, @@ -2509,7 +2519,7 @@ export class WalletService implements IWalletService { estimateGas(opts) { const bc = this._getBlockchainExplorer(opts.chain || opts.coin || Defaults.EVM_CHAIN, opts.network); - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { if (!bc) return reject(new Error('Could not get blockchain explorer instance')); bc.estimateGas(opts, (err, gasLimit) => { if (err) { @@ -2726,6 +2736,7 @@ export class WalletService implements IWalletService { * @param {Boolean} opts.noShuffleOutputs - Optional. If set, TX outputs won't be shuffled. Defaults to false * @param {Boolean} opts.noCashAddr - do not use cashaddress for bch * @param {Boolean} opts.signingMethod[=ecdsa] - do not use cashaddress for bch + * @param {number} opts.nonce - Nonce for this TX (only for EVM chains) * @param {string} opts.tokenAddress - optional. ERC20 Token Contract Address * @param {string} opts.multisigContractAddress - optional. MULTISIG ETH Contract Address * @param {Boolean} opts.isTokenSwap - Optional. To specify if we are trying to make a token swap @@ -3108,13 +3119,24 @@ export class WalletService implements IWalletService { }); } + + getTx(opts, cb: (err: any, txp?: TxProposal) => void) { + this._getTx(opts, (err, txp: TxProposal) => { + if (err) return cb(err); + if (opts.numberFormat) { + txp = TxProposal.formatNumbers(txp, opts.numberFormat); + } + return cb(null, txp); + }); + } + /** * Retrieves a tx from storage. * @param {Object} opts * @param {string} opts.txProposalId - The tx proposal id. * @returns {Object} txProposal */ - getTx(opts, cb: (err: any, txp?: TxProposal) => void) { + private _getTx(opts, cb: (err: any, txp?: TxProposal) => void) { this.storage.fetchTx(this.walletId, opts.txProposalId, (err, txp) => { if (err) return cb(err); if (!txp) return cb(Errors.TX_NOT_FOUND); diff --git a/packages/bitcore-wallet-service/src/types/server.d.ts b/packages/bitcore-wallet-service/src/types/server.d.ts index 23fd2553749..75dfc006893 100644 --- a/packages/bitcore-wallet-service/src/types/server.d.ts +++ b/packages/bitcore-wallet-service/src/types/server.d.ts @@ -21,4 +21,41 @@ export interface UpgradeCheckOpts { version?: number | string; signingMethod?: string; supportBchSchnorr?: boolean; +} + +export interface GetSendMaxInfoOpts { + /** + * Specify the fee level for this TX ('priority', 'normal', 'economy', 'superEconomy') as defined in Defaults.FEE_LEVELS. + * @default 'normal' + */ + feeLevel?: string; + /** + * Specify the fee per KB for this TX (in satoshi). + */ + feePerKb?: number; + /** + * Do not use UTXOs of unconfirmed transactions as inputs + */ + excludeUnconfirmedUtxos?: boolean; + /** + * Return the list of UTXOs that would be included in the tx. + */ + returnInputs?: boolean; + /** + * Use fee estimation for paypro + */ + usePayPro?: boolean; + /** + * Specify the sender ETH address. + */ + from?: string; + /** + * SOL only: Specify the number of signatures + */ + numSignatures?: number; +} + +export interface NumberFormatOpts { + /** Specify format of numbers to return that could potentially be large or otherwise tx-building numbers */ + numberFormat?: 'hex' | 'number' | 'string'; } \ No newline at end of file diff --git a/packages/crypto-wallet-core/src/transactions/index.ts b/packages/crypto-wallet-core/src/transactions/index.ts index 47fcbe0076f..ae5e6a46ba2 100644 --- a/packages/crypto-wallet-core/src/transactions/index.ts +++ b/packages/crypto-wallet-core/src/transactions/index.ts @@ -22,6 +22,7 @@ import { OPERC20TxProvider, OPTxProvider } from './op'; import { SOLTxProvider } from './sol'; import { SPLTxProvider } from './spl'; import { XRPTxProvider } from './xrp'; +import type { Key } from '../types/derivation'; const providers = { BTC: new BTCTxProvider(), @@ -47,8 +48,10 @@ const providers = { SOLSPL: new SPLTxProvider(), }; +type Chain = keyof typeof providers; + export class TransactionsProxy { - get({ chain }) { + get({ chain }: { chain: Chain }) { const normalizedChain = chain.toUpperCase(); return providers[normalizedChain]; } @@ -57,7 +60,7 @@ export class TransactionsProxy { return this.get(params).create(params); } - sign(params): string { + sign(params: { chain: Chain; tx: any; key: Key } & any): string { return this.get(params).sign(params); } diff --git a/packages/crypto-wallet-core/src/utils/bigint.ts b/packages/crypto-wallet-core/src/utils/bigint.ts index 9a712213288..55b316f508d 100644 --- a/packages/crypto-wallet-core/src/utils/bigint.ts +++ b/packages/crypto-wallet-core/src/utils/bigint.ts @@ -235,4 +235,27 @@ export function scrubBigIntsInObject(obj: T, destType: 'number' | 'string' | } } return obj; +} + +/** + * Checks if two potentially different formatted values are equal + * e.g.: 1n === '0x1' === 1 === '1.0' + */ +export function isEqual(int1: BigIntLike, int2: BigIntLike): boolean { + if (int1 == int2) return true; + if (!isBigIntLike(int1) || !isBigIntLike(int2)) return false; + return BigInt(int1) === BigInt(int2); +} + +/** + * Attempts to convert a value to a BigInt, returning the original value if it fails + */ +export function BigIntTry(x: any): bigint | any { + if (x === '') return x; + + try { + return BigInt(x); + } catch { + return x; + } } \ No newline at end of file diff --git a/packages/crypto-wallet-core/src/utils/index.ts b/packages/crypto-wallet-core/src/utils/index.ts index ff0e3e24049..c8bb486ad48 100644 --- a/packages/crypto-wallet-core/src/utils/index.ts +++ b/packages/crypto-wallet-core/src/utils/index.ts @@ -84,6 +84,16 @@ export function toHex(input: number | string | bigint): string { throw new Error(`Input for toHex must be a number, string (non-empty), or bigint. Got ${!input ? JSON.stringify(input) : `typeof ${typeof input}`}`); } try { + if (typeof input === 'number') { + // Ensure consistent rounding behavior for large Numbers + // e.g.: + // (12345678901234567890123).toString() => '1.2345678901234568e+22' => ✗ Produces hard-to-parse scientific notation that cannot be directly converted to BigInt + // BigInt(12345678901234567890123) => 12345678901234567741440n => ✗ Produces rounded value that is not the same as the original number + // (12345678901234567890123) + // .toLocaleString('fullwide', { useGrouping: false }) => '12345678901234568000000' => ✔ Produces a string representation of the 32bit-rounded number that can be directly converted to BigInt + + input = input.toLocaleString('fullwide', { useGrouping: false }); + } return '0x' + BigInt(input).toString(16); // eslint-disable-next-line @typescript-eslint/no-unused-vars } catch (e) { diff --git a/packages/crypto-wallet-core/test/utils.test.ts b/packages/crypto-wallet-core/test/utils.test.ts index 547a9f4a954..8c061df67d0 100644 --- a/packages/crypto-wallet-core/test/utils.test.ts +++ b/packages/crypto-wallet-core/test/utils.test.ts @@ -124,6 +124,19 @@ describe('Utils', function() { const input = NaN; expect(() => utils.toHex(input)).to.throw('Invalid input for toHex: NaN'); }); + + it('should maintain number precision loss for large numbers', function() { + // eslint-disable-next-line no-loss-of-precision + const input = 12345678901234567890123; // This number exceeds JS safe integer range + const result = utils.toHex(input); + expect(result).to.equal('0x29d42b64e767143f200'); // This is the hex representation of the 32-bit rounded number + + // Another test with large number additions + const a1 = utils.toHex(53361793000000000000); + const a2 = utils.toHex(64034152000000010000); + const sum = BigInt(a1) + BigInt(a2); + expect(sum).to.equal(117395945000000010000n); // without toHex's internal toLocaleString conversion, this would end up being 117395945000000004096n since BigInt(input) would round the numbers inconsistently + }); }); describe('difference', function() {