diff --git a/modules/sdk-coin-flr/package.json b/modules/sdk-coin-flr/package.json index 1467849315..55df43defe 100644 --- a/modules/sdk-coin-flr/package.json +++ b/modules/sdk-coin-flr/package.json @@ -41,10 +41,18 @@ }, "dependencies": { "@bitgo/abstract-eth": "^24.19.2", + "@bitgo/sdk-coin-eth": "^25.4.3", + "@bitgo/sdk-coin-flrp": "^1.5.0", "@bitgo/sdk-core": "^36.23.2", + "@bitgo/secp256k1": "^1.7.0", "@bitgo/statics": "^58.17.0", "@ethereumjs/common": "^2.6.5", - "@ethereumjs/tx": "^3.3.0" + "@ethereumjs/tx": "^3.3.0", + "bignumber.js": "^9.0.0", + "ethereumjs-util": "7.1.5", + "keccak": "^3.0.0", + "lodash": "^4.17.21", + "secp256k1": "^5.0.0" }, "devDependencies": { "@bitgo/sdk-api": "^1.72.0", diff --git a/modules/sdk-coin-flr/src/flr.ts b/modules/sdk-coin-flr/src/flr.ts index f22b48572a..53ac128b92 100644 --- a/modules/sdk-coin-flr/src/flr.ts +++ b/modules/sdk-coin-flr/src/flr.ts @@ -7,16 +7,55 @@ * @coinWebsite https://flare-explorer.flare.network */ -import { BaseCoin, BitGoBase, common, MPCAlgorithm, MultisigType, multisigTypes } from '@bitgo/sdk-core'; -import { BaseCoin as StaticsBaseCoin, coins } from '@bitgo/statics'; +import { BigNumber } from 'bignumber.js'; +import { bip32 } from '@bitgo/secp256k1'; +import Keccak from 'keccak'; +import * as secp256k1 from 'secp256k1'; +import * as _ from 'lodash'; +import { + BaseCoin, + BaseTransaction, + BitGoBase, + common, + FeeEstimateOptions, + IWallet, + MPCAlgorithm, + MultisigType, + multisigTypes, + Recipient, + TransactionExplanation, + Entry, +} from '@bitgo/sdk-core'; +import { BaseCoin as StaticsBaseCoin, coins, FlareNetwork } from '@bitgo/statics'; import { AbstractEthLikeNewCoins, + optionalDeps, recoveryBlockchainExplorerQuery, UnsignedSweepTxMPCv2, RecoverOptions, OfflineVaultTxInfo, } from '@bitgo/abstract-eth'; import { TransactionBuilder } from './lib'; +import { FlrPLib } from '@bitgo/sdk-coin-flrp'; +import { pubToAddress } from 'ethereumjs-util'; +import { + BuildOptions, + ExplainTransactionOptions, + FeeEstimate, + HopParams, + HopPrebuild, + HopTransactionBuildOptions, + PresignTransactionOptions, + TransactionPrebuild, + VerifyFlrTransactionOptions, +} from './iface'; + +/** + * Extended TransactionExplanation interface with inputs for atomic transactions + */ +interface AtomicTransactionExplanation extends TransactionExplanation { + inputs: Entry[]; +} export class Flr extends AbstractEthLikeNewCoins { protected constructor(bitgo: BitGoBase, staticsCoin?: Readonly) { @@ -50,6 +89,452 @@ export class Flr extends AbstractEthLikeNewCoins { return this.buildUnsignedSweepTxnMPCv2(params); } + /** + * Validates if an address is valid for FLR + * Also validates P-chain addresses for cross-chain transactions + * @param {string} address - address to validate + * @returns {boolean} - validation result + */ + isValidAddress(address: string): boolean { + // also validate p-chain address for cross-chain txs + return !!address && (super.isValidAddress(address) || FlrPLib.Utils.isValidAddress(address)); + } + + /** + * Get the corresponding P-chain coin name + * @returns {string} flrp or tflrp depending on mainnet/testnet + */ + getFlrP(): string { + return this.getChain().toString() === 'flr' ? 'flrp' : 'tflrp'; + } + + /** + * Get the atomic transaction builder factory for cross-chain operations + * @returns {FlrPLib.TransactionBuilderFactory} the atomic builder factory + */ + protected getAtomicBuilder(): FlrPLib.TransactionBuilderFactory { + return new FlrPLib.TransactionBuilderFactory(coins.get(this.getFlrP())); + } + + /** + * Check if this coin is a token + * @returns {boolean} false for FLR (base chain) + */ + isToken(): boolean { + return false; + } + + /** + * Explains an atomic transaction using atomic builder. + * @param {string} txHex - the transaction hex + * @returns {Promise} the transaction explanation + * @private + */ + private async explainAtomicTransaction(txHex: string): Promise { + const txBuilder = this.getAtomicBuilder().from(txHex); + const tx = await txBuilder.build(); + return tx.explainTransaction() as AtomicTransactionExplanation; + } + + /** + * Verify signature for an atomic transaction using atomic builder. + * @param {string} txHex - the transaction hex + * @returns {Promise} true if signature is from the input address + * @private + */ + private async verifySignatureForAtomicTransaction(txHex: string): Promise { + const txBuilder = this.getAtomicBuilder().from(txHex); + const tx = await txBuilder.build(); + const payload = tx.signablePayload; + const signatures = tx.signature.map((s) => Buffer.from(FlrPLib.Utils.removeHexPrefix(s), 'hex')); + const network = _.get(tx, '_network'); + const recoverPubkey = signatures.map((s) => + FlrPLib.Utils.recoverySignature(network as unknown as FlareNetwork, payload, s) + ); + const expectedSenders = recoverPubkey.map((r) => pubToAddress(r, true)); + const senders = tx.inputs.map((i) => FlrPLib.Utils.parseAddress(i.address)); + return expectedSenders.every((e) => senders.some((sender) => e.equals(sender))); + } + + /** + * Explain a transaction from txHex, overriding BaseCoins + * transaction can be either atomic or eth txn. + * @param {ExplainTransactionOptions} params The options with which to explain the transaction + * @returns {Promise} the transaction explanation + */ + async explainTransaction(params: ExplainTransactionOptions): Promise { + const txHex = params.txHex || (params.halfSigned && params.halfSigned.txHex); + if (!txHex) { + throw new Error('missing txHex in explain tx parameters'); + } + if (params.crossChainType) { + return this.explainAtomicTransaction(txHex); + } + if (!params.feeInfo) { + throw new Error('missing feeInfo in explain tx parameters'); + } + const txBuilder = this.getTransactionBuilder(); + txBuilder.from(txHex); + const tx = await txBuilder.build(); + return Object.assign(this.explainEVMTransaction(tx), { fee: params.feeInfo }); + } + + /** + * Explains an EVM transaction using regular eth txn builder + * @param {BaseTransaction} tx - the transaction to explain + * @returns {Object} the transaction explanation + * @private + */ + private explainEVMTransaction(tx: BaseTransaction) { + const outputs = tx.outputs.map((output) => { + return { + address: output.address, + amount: output.value, + }; + }); + const displayOrder = ['id', 'outputAmount', 'changeAmount', 'outputs', 'changeOutputs', 'fee']; + return { + displayOrder, + id: tx.id, + outputs: outputs, + outputAmount: outputs + .reduce((accumulator, output) => accumulator.plus(output.amount), new BigNumber('0')) + .toFixed(0), + changeOutputs: [], // account based does not use change outputs + changeAmount: '0', // account base does not make change + }; + } + + /** + * Verify that a transaction prebuild complies with the original intention + * + * @param {VerifyFlrTransactionOptions} params + * @param params.txParams params object passed to send + * @param params.txPrebuild prebuild object returned by server + * @param params.wallet Wallet object to obtain keys to verify against + * @returns {Promise} + */ + async verifyTransaction(params: VerifyFlrTransactionOptions): Promise { + const { txParams, txPrebuild, wallet } = params; + if (!txParams?.recipients || !txPrebuild?.recipients || !wallet) { + throw new Error(`missing params`); + } + if (txParams.hop && txParams.recipients.length > 1) { + throw new Error(`tx cannot be both a batch and hop transaction`); + } + if (txPrebuild.recipients.length > 1) { + throw new Error( + `${this.getChain()} doesn't support sending to more than 1 destination address within a single transaction. Try again, using only a single recipient.` + ); + } + if (txParams.hop && txPrebuild.hopTransaction) { + // Check recipient amount for hop transaction + if (txParams.recipients.length !== 1) { + throw new Error(`hop transaction only supports 1 recipient but ${txParams.recipients.length} found`); + } + // Check tx sends to hop address + let expectedHopAddress; + if (txPrebuild.hopTransaction.type === 'Export') { + const decodedHopTx = await this.explainAtomicTransaction(txPrebuild.hopTransaction.tx); + expectedHopAddress = optionalDeps.ethUtil.stripHexPrefix(decodedHopTx.inputs[0].address); + } else { + const decodedHopTx = optionalDeps.EthTx.TransactionFactory.fromSerializedData( + optionalDeps.ethUtil.toBuffer(txPrebuild.hopTransaction.tx) + ); + expectedHopAddress = optionalDeps.ethUtil.stripHexPrefix(decodedHopTx.getSenderAddress().toString()); + } + const actualHopAddress = optionalDeps.ethUtil.stripHexPrefix(txPrebuild.recipients[0].address); + if (expectedHopAddress.toLowerCase() !== actualHopAddress.toLowerCase()) { + throw new Error('recipient address of txPrebuild does not match hop address'); + } + + // Convert TransactionRecipient array to Recipient array + const recipients: Recipient[] = txParams.recipients.map((r) => { + return { + address: r.address, + amount: typeof r.amount === 'number' ? r.amount.toString() : r.amount, + }; + }); + + // Check destination address and amount + await this.validateHopPrebuild(wallet, txPrebuild.hopTransaction, { recipients }); + } else if (txParams.recipients.length > 1) { + // Check total amount for batch transaction + let expectedTotalAmount = new BigNumber(0); + for (let i = 0; i < txParams.recipients.length; i++) { + expectedTotalAmount = expectedTotalAmount.plus(txParams.recipients[i].amount); + } + if (!expectedTotalAmount.isEqualTo(txPrebuild.recipients[0].amount)) { + throw new Error( + 'batch transaction amount in txPrebuild received from BitGo servers does not match txParams supplied by client' + ); + } + } else { + // Check recipient address and amount for normal transaction + if (txParams.recipients.length !== 1) { + throw new Error(`normal transaction only supports 1 recipient but ${txParams.recipients.length} found`); + } + const expectedAmount = new BigNumber(txParams.recipients[0].amount); + if (!expectedAmount.isEqualTo(txPrebuild.recipients[0].amount)) { + throw new Error( + 'normal transaction amount in txPrebuild received from BitGo servers does not match txParams supplied by client' + ); + } + if ( + Flr.isFLRAddress(txParams.recipients[0].address) && + txParams.recipients[0].address !== txPrebuild.recipients[0].address + ) { + throw new Error('destination address in normal txPrebuild does not match that in txParams supplied by client'); + } + } + // Check coin is correct for all transaction types + if (!this.verifyCoin(txPrebuild)) { + throw new Error(`coin in txPrebuild did not match that in txParams supplied by client`); + } + return true; + } + + /** + * Check if an address is a FLR C-chain address (0x format) + * @param {string} address - the address to check + * @returns {boolean} true if it's a C-chain address + * @private + */ + private static isFLRAddress(address: string): boolean { + return !!address.match(/0x[a-fA-F0-9]{40}/); + } + + /** + * Verify that the coin matches in the prebuild + * @param {TransactionPrebuild} txPrebuild - the transaction prebuild + * @returns {boolean} true if coin matches + */ + verifyCoin(txPrebuild: TransactionPrebuild): boolean { + return txPrebuild.coin === this.getChain(); + } + + /** + * Coin-specific things done before signing a transaction, i.e. verification + * @param {PresignTransactionOptions} params - the presign options + * @returns {Promise} + */ + async presignTransaction(params: PresignTransactionOptions): Promise { + if (!_.isUndefined(params.hopTransaction) && !_.isUndefined(params.wallet) && !_.isUndefined(params.buildParams)) { + await this.validateHopPrebuild(params.wallet, params.hopTransaction); + } + return params; + } + + /** + * Modify prebuild after receiving it from the server. Add things like nlocktime + * @param {TransactionPrebuild} params - the transaction prebuild + * @returns {Promise} + */ + async postProcessPrebuild(params: TransactionPrebuild): Promise { + if (!_.isUndefined(params.hopTransaction) && !_.isUndefined(params.wallet) && !_.isUndefined(params.buildParams)) { + await this.validateHopPrebuild(params.wallet, params.hopTransaction, params.buildParams); + } + return params; + } + + /** + * Validates that the hop prebuild from the HSM is valid and correct + * @param {IWallet} wallet The wallet that the prebuild is for + * @param {HopPrebuild} hopPrebuild The prebuild to validate + * @param {Object} originalParams The original parameters passed to prebuildTransaction + * @returns {Promise} + * @throws Error if The prebuild is invalid + */ + async validateHopPrebuild( + wallet: IWallet, + hopPrebuild: HopPrebuild, + originalParams?: { recipients: Recipient[] } + ): Promise { + const { tx, id, signature } = hopPrebuild; + + // first, validate the HSM signature + const serverXpub = common.Environments[this.bitgo.getEnv()].hsmXpub; + const serverPubkeyBuffer: Buffer = bip32.fromBase58(serverXpub).publicKey; + const signatureBuffer: Buffer = Buffer.from(optionalDeps.ethUtil.stripHexPrefix(signature), 'hex'); + const messageBuffer: Buffer = + hopPrebuild.type === 'Export' ? Flr.getTxHash(tx) : Buffer.from(optionalDeps.ethUtil.stripHexPrefix(id), 'hex'); + + const sig = new Uint8Array(signatureBuffer.length === 64 ? signatureBuffer : signatureBuffer.slice(1)); + const isValidSignature: boolean = secp256k1.ecdsaVerify(sig, messageBuffer, serverPubkeyBuffer); + if (!isValidSignature) { + throw new Error(`Hop txid signature invalid`); + } + + if (hopPrebuild.type === 'Export') { + const explainHopExportTx = await this.explainAtomicTransaction(tx); + // If original params are given, we can check them against the transaction prebuild params + if (!_.isNil(originalParams)) { + const { recipients } = originalParams; + + // Then validate that the tx params actually equal the requested params to nano flr plus import tx fee. + const originalAmount = new BigNumber(recipients[0].amount).div(1e9).plus(1e6).toFixed(0); + const originalDestination: string | undefined = recipients[0].address; + const hopAmount = explainHopExportTx.outputAmount; + const hopDestination = explainHopExportTx.outputs[0].address; + if (originalAmount !== hopAmount) { + throw new Error(`Hop amount: ${hopAmount} does not equal original amount: ${originalAmount}`); + } + if (originalDestination && hopDestination.toLowerCase() !== originalDestination.toLowerCase()) { + throw new Error( + `Hop destination: ${hopDestination} does not equal original recipient: ${originalDestination}` + ); + } + } + if (!(await this.verifySignatureForAtomicTransaction(tx))) { + throw new Error(`Invalid hop transaction signature, txid: ${id}`); + } + } else { + const builtHopTx = optionalDeps.EthTx.TransactionFactory.fromSerializedData(optionalDeps.ethUtil.toBuffer(tx)); + // If original params are given, we can check them against the transaction prebuild params + if (!_.isNil(originalParams)) { + const { recipients } = originalParams; + + // Then validate that the tx params actually equal the requested params + const originalAmount = new BigNumber(recipients[0].amount); + const originalDestination: string = recipients[0].address; + + const hopAmount = new BigNumber(optionalDeps.ethUtil.bufferToHex(builtHopTx.value as unknown as Buffer)); + if (!builtHopTx.to) { + throw new Error(`Transaction does not have a destination address`); + } + const hopDestination = builtHopTx.to.toString(); + if (!hopAmount.eq(originalAmount)) { + throw new Error(`Hop amount: ${hopAmount} does not equal original amount: ${originalAmount}`); + } + if (hopDestination.toLowerCase() !== originalDestination.toLowerCase()) { + throw new Error( + `Hop destination: ${hopDestination} does not equal original recipient: ${originalDestination}` + ); + } + } + + if (!builtHopTx.verifySignature()) { + // We don't want to continue at all in this case, at risk of FLR being stuck on the hop address + throw new Error(`Invalid hop transaction signature, txid: ${id}`); + } + if (optionalDeps.ethUtil.addHexPrefix(builtHopTx.hash().toString('hex')) !== id) { + throw new Error(`Signed hop txid does not equal actual txid`); + } + } + } + + /** + * Modify prebuild before sending it to the server. Add things like hop transaction params + * @param {BuildOptions} buildParams The whitelisted parameters for this prebuild + * @param {boolean} buildParams.hop True if this should prebuild a hop tx, else false + * @param {Recipient[]} buildParams.recipients The recipients array of this transaction + * @param {Wallet} buildParams.wallet The wallet sending this tx + * @param {string} buildParams.walletPassphrase the passphrase for this wallet + * @returns {Promise} + */ + async getExtraPrebuildParams(buildParams: BuildOptions): Promise { + if ( + !_.isUndefined(buildParams.hop) && + buildParams.hop && + !_.isUndefined(buildParams.wallet) && + !_.isUndefined(buildParams.recipients) + ) { + if (this.isToken()) { + throw new Error( + `Hop transactions are not enabled for FLR tokens, nor are they necessary. Please remove the 'hop' parameter and try again.` + ); + } + return (await this.createHopTransactionParams({ + recipients: buildParams.recipients, + type: buildParams.type as HopTransactionBuildOptions['type'], + })) as unknown as BuildOptions; + } + return {}; + } + + /** + * Creates the extra parameters needed to build a hop transaction + * @param {HopTransactionBuildOptions} params The original build parameters + * @returns {Promise} extra parameters object to merge with the original build parameters object and send to the platform + */ + async createHopTransactionParams({ recipients, type }: HopTransactionBuildOptions): Promise { + if (!recipients || !Array.isArray(recipients)) { + throw new Error('expecting array of recipients'); + } + + // Right now we only support 1 recipient + if (recipients.length !== 1) { + throw new Error('must send to exactly 1 recipient'); + } + const recipientAddress = recipients[0].address; + const recipientAmount = recipients[0].amount; + const feeEstimateParams = { + recipient: recipientAddress, + amount: recipientAmount, + hop: true, + type, + }; + const feeEstimate: FeeEstimate = await this.feeEstimate(feeEstimateParams); + + const gasLimit = feeEstimate.gasLimitEstimate; + // Even if `feeEstimate < gasLimit`, we shouldn't end up with `gasPrice === 0`. + const gasPrice = Math.max(Math.round(feeEstimate.feeEstimate / gasLimit), 1); + const gasPriceMax = gasPrice * 5; + // Payment id is a random number so its different for every tx + const paymentId = Math.floor(Math.random() * 10000000000).toString(); + + const userReqSig = '0x'; + + return { + hopParams: { + userReqSig, + gasPriceMax, + paymentId, + gasLimit, + }, + }; + } + + /** + * Fetch fee estimate information from the server + * @param {FeeEstimateOptions} params The params passed into the function + * @param {Boolean} [params.hop] True if we should estimate fee for a hop transaction + * @param {String} [params.recipient] The recipient of the transaction to estimate a send to + * @param {String} [params.data] The data to estimate a send for + * @returns {Promise} The fee info returned from the server + */ + async feeEstimate(params: FeeEstimateOptions): Promise { + const query: FeeEstimateOptions = {}; + if (params && params.hop) { + query.hop = params.hop; + } + if (params && params.recipient) { + query.recipient = params.recipient; + } + if (params && params.data) { + query.data = params.data; + } + if (params && params.amount) { + query.amount = params.amount; + } + if (params && params.type) { + query.type = params.type; + } + + return await this.bitgo.get(this.url('/tx/fee')).query(query).result(); + } + + /** + * Calculate tx hash like evm from tx hex. + * @param {string} tx - the transaction hex + * @returns {Buffer} tx hash + */ + static getTxHash(tx: string): Buffer { + const hash = Keccak('keccak256'); + hash.update(optionalDeps.ethUtil.stripHexPrefix(tx), 'hex'); + return hash.digest(); + } + /** * Make a query to Flare explorer for information such as balance, token balance, solidity calls * @param {Object} query key-value pairs of parameters to append after /api diff --git a/modules/sdk-coin-flr/src/iface.ts b/modules/sdk-coin-flr/src/iface.ts new file mode 100644 index 0000000000..90d2205778 --- /dev/null +++ b/modules/sdk-coin-flr/src/iface.ts @@ -0,0 +1,188 @@ +import { + FullySignedTransaction, + HalfSignedAccountTransaction, + PresignTransactionOptions as BasePresignTransactionOptions, + Recipient, + SignTransactionOptions as BaseSignTransactionOptions, + TransactionFee, + TransactionParams, + TransactionPrebuild as BaseTransactionPrebuild, + TransactionType, + VerifyTransactionOptions, + Wallet, +} from '@bitgo/sdk-core'; +import { TransactionPrebuild as EthTransactionPrebuild } from '@bitgo/sdk-coin-eth'; + +export interface PrecreateBitGoOptions { + enterprise?: string; + newFeeAddress?: string; +} + +// For explainTransaction +export interface ExplainTransactionOptions { + txHex?: string; + halfSigned?: { + txHex: string; + }; + feeInfo?: TransactionFee; + crossChainType?: string; +} + +export interface FlrTransactionParams extends TransactionParams { + gasPrice?: number; + gasLimit?: number; + hopParams?: HopParams; + hop?: boolean; +} + +export interface VerifyFlrTransactionOptions extends VerifyTransactionOptions { + txPrebuild: TransactionPrebuild; + txParams: FlrTransactionParams; +} + +// For preSign +export interface PresignTransactionOptions extends TransactionPrebuild, BasePresignTransactionOptions { + wallet: Wallet; +} + +export interface EIP1559 { + maxPriorityFeePerGas: number; + maxFeePerGas: number; +} + +// region Recovery +export interface UnformattedTxInfo { + recipient: Recipient; +} + +export interface OfflineVaultTxInfo { + nextContractSequenceId?: string; + contractSequenceId?: string; + txHex: string; + userKey: string; + backupKey: string; + coin: string; + gasPrice: number; + gasLimit: number; + recipients: Recipient[]; + walletContractAddress: string; + amount: string; + backupKeyNonce: number; + eip1559?: EIP1559; + token?: string; +} +// endregion + +// For createHopTransactionParams +export interface HopTransactionBuildOptions { + recipients: Recipient[]; + type?: keyof typeof TransactionType; +} + +// For getExtraPrebuildParams +export interface BuildOptions { + hop?: boolean; + wallet?: Wallet; + recipients?: Recipient[]; + walletPassphrase?: string; + type?: keyof typeof TransactionType; + [index: string]: unknown; +} + +// For FeeEstimate +export interface FeeEstimate { + gasLimitEstimate: number; + feeEstimate: number; +} + +/** + * The extra parameters to send to platform build route for hop transactions + */ +export interface HopParams { + hopParams: { + gasPriceMax: number; + userReqSig: string; + paymentId: string; + gasLimit: number; + }; +} + +/** + * The prebuilt hop transaction returned from the HSM + */ +export interface HopPrebuild { + tx: string; + id: string; + signature: string; + paymentId: string; + gasPrice: number; + gasLimit: number; + amount: number; + recipient: string; + nonce: number; + userReqSig: string; + gasPriceMax: number; + type?: keyof typeof TransactionType; +} + +// Replace Eth.HopPrebuild with Flr.HopPrebuild +export type TransactionPrebuild = Omit & { hopTransaction?: HopPrebuild }; + +// For txPreBuild +export interface TxInfo { + recipients: Recipient[]; + from: string; + txid: string; +} + +export interface EthTransactionFee { + fee: string; + gasLimit?: string; +} + +export interface TxPreBuild extends BaseTransactionPrebuild { + txHex: string; + txInfo: TxInfo; + feeInfo: EthTransactionFee; + source: string; + dataToSign: string; + nextContractSequenceId?: number; + expireTime?: number; + hopTransaction?: string; + eip1559?: EIP1559; + recipients?: Recipient[]; + txPrebuild?: { + halfSigned: { + txHex: string; + }; + }; +} + +// For signTransaction +export interface SignFinalOptions { + txPrebuild: { + halfSigned: { + txHex: string; + }; + }; + prv: string; +} + +export interface FlrSignTransactionOptions extends BaseSignTransactionOptions { + txPrebuild: TxPreBuild; + prv: string; + custodianTransactionId?: string; + isLastSignature?: boolean; + walletVersion?: number; +} + +export interface HalfSignedTransaction extends HalfSignedAccountTransaction { + halfSigned: { + txHex?: never; + recipients: Recipient[]; + expiration?: number; + eip1559?: EIP1559; + }; +} + +export type SignedTransaction = HalfSignedTransaction | FullySignedTransaction; diff --git a/modules/sdk-coin-flr/src/index.ts b/modules/sdk-coin-flr/src/index.ts index 3f92c8e3f7..a39a6dff8b 100644 --- a/modules/sdk-coin-flr/src/index.ts +++ b/modules/sdk-coin-flr/src/index.ts @@ -3,3 +3,4 @@ export * from './flr'; export * from './tflr'; export * from './register'; export * from './flrToken'; +export * from './iface'; diff --git a/modules/sdk-coin-flr/test/resources.ts b/modules/sdk-coin-flr/test/resources.ts index 40239dd231..228293540c 100644 --- a/modules/sdk-coin-flr/test/resources.ts +++ b/modules/sdk-coin-flr/test/resources.ts @@ -90,3 +90,41 @@ export const mockDataNonBitGoRecovery = { getBalanceRequest: getBalanceRequestNonBitGoRecovery, getBalanceResponse: getBalanceResponseNonBitGoRecovery, }; + +/** + * Test data for Export C to P transactions + * Based on actual FlrP ExportInC transaction format + */ +export const EXPORT_C_TEST_DATA = { + // P-chain address format for Flare (testnet uses costwo prefix) + pAddresses: [ + 'P-costwo1zt9n96hey4fsvnde35n3k4kt5pu7c784dzewzd', + 'P-costwo1cwrdtrgf4xh80ncu7palrjw7gn4mpj0n4dxghh', + 'P-costwo15msvr27szvhhpmah0c38gcml7vm29xjh7tcek8', + ], + // Multisig P-chain address (tilde-separated) + pMultisigAddress: + 'P-costwo1zt9n96hey4fsvnde35n3k4kt5pu7c784dzewzd~P-costwo1cwrdtrgf4xh80ncu7palrjw7gn4mpj0n4dxghh~P-costwo15msvr27szvhhpmah0c38gcml7vm29xjh7tcek8', + // C-chain hex address + cHexAddress: '0x28A05933dC76e4e6c25f35D5c9b2A58769700E76', + // Amount in nanoFLR (0.00005 FLR = 50000000 nanoFLR) + amount: '50000000', + // Transaction nonce + nonce: 9, + // Multisig threshold + threshold: 2, + // Fee in nanoFLR + fee: '281750', + // Target chain ID (P-chain) + targetChainId: '11111111111111111111111111111111LpoYY', + // Transaction hash + txhash: 'KELMR2gmYpRUeXRyuimp1xLNUoHSkwNUURwBn4v1D4aKircKR', + // Unsigned transaction hex (Export from C to P) + unsignedTxHex: + '0x0000000000010000007278db5c30bed04c05ce209179812850bbb3fe6d46d7eef3744d814c0da555247900000000000000000000000000000000000000000000000000000000000000000000000128a05933dc76e4e6c25f35d5c9b2a58769700e760000000002ff3d1658734f94af871c3d131b56131b6fb7a0291eacadd261e69dfb42a9cdf6f7fddd00000000000000090000000158734f94af871c3d131b56131b6fb7a0291eacadd261e69dfb42a9cdf6f7fddd000000070000000002faf0800000000000000000000000020000000312cb32eaf92553064db98d271b56cba079ec78f5a6e0c1abd0132f70efb77e2274637ff336a29a57c386d58d09a9ae77cf1cf07bf1c9de44ebb0c9f3', + // Signed transaction hex + fullsigntxHex: + '0x0000000000010000007278db5c30bed04c05ce209179812850bbb3fe6d46d7eef3744d814c0da555247900000000000000000000000000000000000000000000000000000000000000000000000128a05933dc76e4e6c25f35d5c9b2a58769700e760000000002ff3d1658734f94af871c3d131b56131b6fb7a0291eacadd261e69dfb42a9cdf6f7fddd00000000000000090000000158734f94af871c3d131b56131b6fb7a0291eacadd261e69dfb42a9cdf6f7fddd000000070000000002faf0800000000000000000000000020000000312cb32eaf92553064db98d271b56cba079ec78f5a6e0c1abd0132f70efb77e2274637ff336a29a57c386d58d09a9ae77cf1cf07bf1c9de44ebb0c9f300000001000000090000000133f126dee90108c473af9513ebd9eb1591a701b5dfc69041075b303b858fee0609ca9a60208b46f6836f0baf1a9fba740d97b65d45caae10470b5fa707eb45c900', + // Private key for signing + privKey: '14977929a4e00e4af1c33545240a6a5a08ca3034214618f6b04b72b80883be3a', +}; diff --git a/modules/sdk-coin-flr/test/unit/flr.ts b/modules/sdk-coin-flr/test/unit/flr.ts index a9358ca204..d69c36091e 100644 --- a/modules/sdk-coin-flr/test/unit/flr.ts +++ b/modules/sdk-coin-flr/test/unit/flr.ts @@ -4,22 +4,62 @@ import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test'; import { BitGoAPI } from '@bitgo/sdk-api'; import { Flr, Tflr } from '../../src/index'; +import { ExplainTransactionOptions } from '../../src/iface'; import { UnsignedSweepTxMPCv2 } from '@bitgo/abstract-eth'; -import { mockDataUnsignedSweep, mockDataNonBitGoRecovery } from '../resources'; +import { mockDataUnsignedSweep, mockDataNonBitGoRecovery, EXPORT_C_TEST_DATA } from '../resources'; import nock from 'nock'; -import { common } from '@bitgo/sdk-core'; +import { common, TransactionType, Wallet } from '@bitgo/sdk-core'; import { FeeMarketEIP1559Transaction } from '@ethereumjs/tx'; import { stripHexPrefix } from '@ethereumjs/util'; +import * as sinon from 'sinon'; +import { bip32 } from '@bitgo/secp256k1'; +import * as secp256k1 from 'secp256k1'; +import Keccak from 'keccak'; -const bitgo: TestBitGoAPI = TestBitGo.decorate(BitGoAPI, { env: 'test' }); +// Helper to calculate tx hash like Flr.getTxHash +function getTxHash(tx: string): Buffer { + const hash = Keccak('keccak256'); + hash.update(tx.startsWith('0x') ? tx.slice(2) : tx, 'hex'); + return hash.digest(); +} describe('flr', function () { + let bitgo: TestBitGoAPI; + let tflrCoin; + let flrCoin; + let hopExportTxBitgoSignature; + + const address2 = '0x7e85bdc27c050e3905ebf4b8e634d9ad6edd0de6'; + const hopExportTx = EXPORT_C_TEST_DATA.fullsigntxHex; + const hopExportTxId = '0x' + getTxHash(hopExportTx).toString('hex'); + before(function () { + const bitgoKeyXprv = + 'xprv9s21ZrQH143K3tpWBHWe31sLoXNRQ9AvRYJgitkKxQ4ATFQMwvr7hHNqYRUnS7PsjzB7aK1VxqHLuNQjj1sckJ2Jwo2qxmsvejwECSpFMfC'; + const bitgoKey = bip32.fromBase58(bitgoKeyXprv); + if (!bitgoKey.privateKey) { + throw new Error('no privateKey'); + } + const bitgoXpub = bitgoKey.neutered().toBase58(); + + hopExportTxBitgoSignature = + '0xaa' + + Buffer.from( + secp256k1.ecdsaSign(Buffer.from(hopExportTxId.slice(2), 'hex'), bitgoKey.privateKey).signature + ).toString('hex'); + + bitgo = TestBitGo.decorate(BitGoAPI, { env: 'test' }); bitgo.safeRegister('flr', Flr.createInstance); bitgo.safeRegister('tflr', Tflr.createInstance); + common.Environments[bitgo.getEnv()].hsmXpub = bitgoXpub; bitgo.initializeTestVars(); }); + beforeEach(function () { + tflrCoin = bitgo.coin('tflr') as Tflr; + flrCoin = bitgo.coin('flr') as Flr; + }); + describe('Basic Coin Info', function () { it('should return the right info for flr', function () { const flr = bitgo.coin('flr'); @@ -45,6 +85,764 @@ describe('flr', function () { tflr.allowsAccountConsolidations().should.equal(false); }); }); + + describe('P-Chain Methods', function () { + it('should return flrp for mainnet', function () { + (flrCoin as any).getFlrP().should.equal('flrp'); + }); + + it('should return tflrp for testnet', function () { + (tflrCoin as any).getFlrP().should.equal('tflrp'); + }); + }); + + describe('Address Validation', function () { + it('should validate valid eth address', function () { + const address = '0x1374a2046661f914d1687d85dbbceb9ac7910a29'; + tflrCoin.isValidAddress(address).should.be.true(); + flrCoin.isValidAddress(address).should.be.true(); + }); + + it('should validate a P-chain address', function () { + const pAddresses = EXPORT_C_TEST_DATA.pAddresses; + for (const addr of pAddresses) { + tflrCoin.isValidAddress(addr).should.be.true(); + flrCoin.isValidAddress(addr).should.be.true(); + } + }); + + it('should validate a P-chain multisig address string', function () { + const address = EXPORT_C_TEST_DATA.pMultisigAddress; + tflrCoin.isValidAddress(address).should.be.true(); + flrCoin.isValidAddress(address).should.be.true(); + }); + + it('should return false for empty address', function () { + tflrCoin.isValidAddress('').should.be.false(); + flrCoin.isValidAddress('').should.be.false(); + }); + }); + + describe('Token Check', function () { + it('should return false for isToken', function () { + tflrCoin.isToken().should.be.false(); + flrCoin.isToken().should.be.false(); + }); + }); + + describe('Hop Transaction Parameters', function () { + const sandbox = sinon.createSandbox(); + + afterEach(function () { + sandbox.restore(); + }); + + it('should create hop transaction parameters with gasPriceMax > 0', async function () { + const mockFeeEstimate = { + feeEstimate: 120000, + gasLimitEstimate: 500000, + }; + sandbox.stub(tflrCoin, 'feeEstimate').resolves(mockFeeEstimate); + const recipients = [ + { + address: EXPORT_C_TEST_DATA.pMultisigAddress, + amount: '100000000000000000', + }, + ]; + + const result = await (tflrCoin as any).createHopTransactionParams({ + recipients, + type: 'Export' as keyof typeof TransactionType, + }); + + result.should.have.property('hopParams'); + result.hopParams.should.have.properties(['userReqSig', 'gasPriceMax', 'paymentId', 'gasLimit']); + result.hopParams.userReqSig.should.equal('0x'); + result.hopParams.gasPriceMax.should.be.above(0); + result.hopParams.paymentId.should.be.a.String(); + result.hopParams.gasLimit.should.equal(500000); + }); + + it('should throw error if no recipients provided', async function () { + await (tflrCoin as any) + .createHopTransactionParams({ + recipients: [], + type: 'Export' as keyof typeof TransactionType, + }) + .should.be.rejectedWith('must send to exactly 1 recipient'); + }); + + it('should throw error if more than 1 recipient provided', async function () { + const recipients = [ + { + address: EXPORT_C_TEST_DATA.pMultisigAddress, + amount: '100000000000000000', + }, + { + address: EXPORT_C_TEST_DATA.cHexAddress, + amount: '50000000000000000', + }, + ]; + + await (tflrCoin as any) + .createHopTransactionParams({ + recipients, + type: 'Export' as keyof typeof TransactionType, + }) + .should.be.rejectedWith('must send to exactly 1 recipient'); + }); + }); + + describe('Explain Transaction', function () { + it('should explain an unsigned export in C transaction', async function () { + const testData = EXPORT_C_TEST_DATA; + const txExplain = await tflrCoin.explainTransaction({ + txHex: testData.unsignedTxHex, + crossChainType: 'export', + }); + txExplain.type.should.equal(TransactionType.Export); + txExplain.inputs[0].address.toLowerCase().should.equal(testData.cHexAddress.toLowerCase()); + // Output address is the sorted P-chain addresses joined with ~ + const sortedPAddresses = testData.pAddresses.slice().sort().join('~'); + txExplain.outputs[0].address.should.equal(sortedPAddresses); + txExplain.outputAmount.should.equal(testData.amount); + should.exist(txExplain.fee); + txExplain.fee.fee.should.equal(testData.fee); + txExplain.changeOutputs.should.be.empty(); + }); + + it('should explain a signed export in C transaction', async function () { + const testData = EXPORT_C_TEST_DATA; + const txExplain = await tflrCoin.explainTransaction({ + txHex: testData.fullsigntxHex, + crossChainType: 'export', + }); + txExplain.type.should.equal(TransactionType.Export); + txExplain.inputs[0].address.toLowerCase().should.equal(testData.cHexAddress.toLowerCase()); + // Output address is the sorted P-chain addresses joined with ~ + const sortedPAddresses = testData.pAddresses.slice().sort().join('~'); + txExplain.outputs[0].address.should.equal(sortedPAddresses); + txExplain.outputAmount.should.equal(testData.amount); + should.exist(txExplain.fee); + txExplain.fee.fee.should.equal(testData.fee); + txExplain.changeOutputs.should.be.empty(); + }); + + it('should throw error when missing txHex', async function () { + await tflrCoin + .explainTransaction({ crossChainType: 'export' } as ExplainTransactionOptions) + .should.be.rejectedWith('missing txHex in explain tx parameters'); + }); + + it('should throw error when missing feeInfo for non-crossChain transaction', async function () { + await tflrCoin + .explainTransaction({ txHex: '0x123' } as ExplainTransactionOptions) + .should.be.rejectedWith('missing feeInfo in explain tx parameters'); + }); + }); + + describe('Transaction Verification', function () { + it('should reject when client txParams are missing', async function () { + const wallet = new Wallet(bitgo, tflrCoin, {}); + + const txParams = null; + + const txPrebuild = { + recipients: [{ amount: '1000000000000', address: '0x1374a2046661f914d1687d85dbbceb9ac7910a29' }], + nextContractSequenceId: 0, + gasPrice: 20000000000, + gasLimit: 500000, + isBatch: false, + coin: 'tflr', + walletId: 'fakeWalletId', + walletContractAddress: 'fakeWalletContractAddress', + }; + + const verification = {}; + + await tflrCoin + .verifyTransaction({ txParams: txParams as any, txPrebuild: txPrebuild as any, wallet, verification }) + .should.be.rejectedWith('missing params'); + }); + + it('should reject txPrebuild that is both batch and hop', async function () { + const wallet = new Wallet(bitgo, tflrCoin, {}); + + const txParams = { + recipients: [ + { amount: '1000000000000', address: '0x1374a2046661f914d1687d85dbbceb9ac7910a29' }, + { amount: '2500000000000', address: '0x7e85bdc27c050e3905ebf4b8e634d9ad6edd0de6' }, + ], + wallet: wallet, + walletPassphrase: 'fakeWalletPassphrase', + hop: true, + }; + + const txPrebuild = { + recipients: [{ amount: '3500000000000', address: '0x1374a2046661f914d1687d85dbbceb9ac7910a29' }], + nextContractSequenceId: 0, + gasPrice: 20000000000, + gasLimit: 500000, + isBatch: true, + coin: 'tflr', + walletId: 'fakeWalletId', + walletContractAddress: 'fakeWalletContractAddress', + hopTransaction: { + tx: '0x0', + id: '0x0', + signature: '0x0', + paymentId: '0', + gasPrice: 20000000000, + gasLimit: 500000, + amount: 1000000000000000, + recipient: '0x1374a2046661f914d1687d85dbbceb9ac7910a29', + nonce: 0, + userReqSig: '0x0', + gasPriceMax: 500000000000, + }, + }; + + const verification = {}; + + await tflrCoin + .verifyTransaction({ txParams, txPrebuild: txPrebuild as any, wallet, verification }) + .should.be.rejectedWith('tx cannot be both a batch and hop transaction'); + }); + + it('should reject a txPrebuild with more than one recipient', async function () { + const wallet = new Wallet(bitgo, tflrCoin, {}); + + const txParams = { + recipients: [ + { amount: '1000000000000', address: '0x1374a2046661f914d1687d85dbbceb9ac7910a29' }, + { amount: '2500000000000', address: '0x7e85bdc27c050e3905ebf4b8e634d9ad6edd0de6' }, + ], + wallet: wallet, + walletPassphrase: 'fakeWalletPassphrase', + }; + + const txPrebuild = { + recipients: [ + { amount: '1000000000000', address: '0x1374a2046661f914d1687d85dbbceb9ac7910a29' }, + { amount: '2500000000000', address: '0x7e85bdc27c050e3905ebf4b8e634d9ad6edd0de6' }, + ], + nextContractSequenceId: 0, + gasPrice: 20000000000, + gasLimit: 500000, + isBatch: true, + coin: 'tflr', + walletId: 'fakeWalletId', + walletContractAddress: 'fakeWalletContractAddress', + }; + + const verification = {}; + + await tflrCoin + .verifyTransaction({ txParams, txPrebuild: txPrebuild as any, wallet, verification }) + .should.be.rejectedWith( + `tflr doesn't support sending to more than 1 destination address within a single transaction. Try again, using only a single recipient.` + ); + }); + + it('should reject a txPrebuild from the bitgo server with the wrong coin', async function () { + const wallet = new Wallet(bitgo, tflrCoin, {}); + + const txParams = { + recipients: [{ amount: '1000000000000', address: '0x1374a2046661f914d1687d85dbbceb9ac7910a29' }], + wallet: wallet, + walletPassphrase: 'fakeWalletPassphrase', + }; + + const txPrebuild = { + recipients: [{ amount: '1000000000000', address: '0x1374a2046661f914d1687d85dbbceb9ac7910a29' }], + nextContractSequenceId: 0, + gasPrice: 20000000000, + gasLimit: 500000, + isBatch: false, + coin: 'btc', + walletId: 'fakeWalletId', + walletContractAddress: 'fakeWalletContractAddress', + }; + + const verification = {}; + + await tflrCoin + .verifyTransaction({ txParams, txPrebuild: txPrebuild as any, wallet, verification }) + .should.be.rejectedWith('coin in txPrebuild did not match that in txParams supplied by client'); + }); + + describe('Hop export tx verify', () => { + const wallet = new Wallet(bitgo, tflrCoin, {}); + const hopDestinationAddress = + 'P-costwo15msvr27szvhhpmah0c38gcml7vm29xjh7tcek8~P-costwo1cwrdtrgf4xh80ncu7palrjw7gn4mpj0n4dxghh~P-costwo1zt9n96hey4fsvnde35n3k4kt5pu7c784dzewzd'; + const hopAddress = '0x28A05933dC76e4e6c25f35D5c9b2A58769700E76'; + const importTxFee = 1e6; + const amount = 49000000000000000; + const txParams = { + recipients: [{ amount, address: hopDestinationAddress }], + wallet: wallet, + walletPassphrase: 'fakeWalletPassphrase', + hop: true, + type: 'Export', + }; + + const txPrebuild = { + recipients: [{ amount: '51000050000000000', address: hopAddress }], + nextContractSequenceId: 0, + gasPrice: 20000000000, + gasLimit: 500000, + isBatch: false, + coin: 'tflr', + walletId: 'fakeWalletId', + walletContractAddress: 'fakeWalletContractAddress', + hopTransaction: { + tx: hopExportTx, + id: hopExportTxId, + signature: hopExportTxBitgoSignature, + paymentId: '4933349984', + gasPrice: '50', + gasLimit: 1, + amount: '50000000', + recipient: hopDestinationAddress, + nonce: 0, + userReqSig: + '0x06fd0b1f8859a40d9fb2d1a65d54da5d645a1d81bbb8c1c5b037051843ec0d3c22433ec7f50cc97fa041cbf8d9ff5ddf7ed41f72a08fa3f1983fd651a33a4441', + gasPriceMax: 7187500000, + type: 'Export', + }, + }; + + const verification = {}; + const sandbox = sinon.createSandbox(); + + before(() => { + txPrebuild.hopTransaction.signature = hopExportTxBitgoSignature; + }); + + beforeEach(() => { + sandbox.stub(tflrCoin as any, 'verifySignatureForAtomicTransaction').resolves(true); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it('should verify successfully', async function () { + const verifyFlrTransactionOptions = { txParams, txPrebuild, wallet, verification }; + const isTransactionVerified = await tflrCoin.verifyTransaction(verifyFlrTransactionOptions); + isTransactionVerified.should.equal(true); + }); + + it('should fail verify for amount plus 1', async function () { + const verifyFlrTransactionOptions = { + txParams: { ...txParams, recipients: [{ amount: amount + 1e9, address: hopDestinationAddress }] }, + txPrebuild, + wallet, + verification, + }; + + await tflrCoin + .verifyTransaction(verifyFlrTransactionOptions) + .should.be.rejectedWith( + `Hop amount: ${amount / 1e9 + importTxFee} does not equal original amount: ${ + amount / 1e9 + importTxFee + 1 + }` + ); + }); + + it('should fail verify for changed prebuild hop address', async function () { + const verifyFlrTransactionOptions = { + txParams, + txPrebuild: { ...txPrebuild, recipients: [{ address: address2, amount: '51000050000000000' }] }, + wallet, + verification, + }; + await tflrCoin + .verifyTransaction(verifyFlrTransactionOptions) + .should.be.rejectedWith(`recipient address of txPrebuild does not match hop address`); + }); + + it('should fail verify for changed address', async function () { + const hopDestinationAddressDiff = + 'P-costwo15msvr27szvhhpmah0c38gcml7vm29xjh7tcek9~P-costwo1cwrdtrgf4xh80ncu7palrjw7gn4mpj0n4dxghh~P-costwo1zt9n96hey4fsvnde35n3k4kt5pu7c784dzewzd'; + const verifyFlrTransactionOptions = { + txParams: { ...txParams, recipients: [{ amount: amount, address: hopDestinationAddressDiff }] }, + txPrebuild, + wallet, + verification, + }; + await tflrCoin + .verifyTransaction(verifyFlrTransactionOptions) + .should.be.rejectedWith( + `Hop destination: ${hopDestinationAddress} does not equal original recipient: ${hopDestinationAddressDiff}` + ); + }); + + it('should verify if walletId is used instead of address', async function () { + const verifyFlrTransactionOptions = { + txParams: { ...txParams, recipients: [{ amount: amount, walletId: 'same wallet' }] }, + txPrebuild, + wallet, + verification, + }; + const isTransactionVerified = await tflrCoin.verifyTransaction(verifyFlrTransactionOptions); + isTransactionVerified.should.equal(true); + }); + }); + }); + + describe('validateHopPrebuild', function () { + const validateHopSandbox = sinon.createSandbox(); + + afterEach(() => { + validateHopSandbox.restore(); + }); + + it('should throw error for invalid HSM signature', async function () { + const wallet = new Wallet(bitgo, tflrCoin, {}); + const hopPrebuild = { + tx: hopExportTx, + id: hopExportTxId, + signature: '0x' + 'aa'.repeat(65), // Invalid signature + paymentId: '12345', + gasPrice: 20000000000, + gasLimit: 500000, + amount: 1000000000000000, + recipient: EXPORT_C_TEST_DATA.pMultisigAddress, + nonce: 0, + userReqSig: '0x', + gasPriceMax: 500000000000, + type: 'Export' as const, + }; + + await (tflrCoin as any) + .validateHopPrebuild(wallet, hopPrebuild) + .should.be.rejectedWith('Hop txid signature invalid'); + }); + + it('should validate Export hop prebuild successfully', async function () { + validateHopSandbox.stub(tflrCoin as any, 'verifySignatureForAtomicTransaction').resolves(true); + + const wallet = new Wallet(bitgo, tflrCoin, {}); + const hopPrebuild = { + tx: hopExportTx, + id: hopExportTxId, + signature: hopExportTxBitgoSignature, + paymentId: '12345', + gasPrice: 20000000000, + gasLimit: 500000, + amount: 50000000, + recipient: EXPORT_C_TEST_DATA.pMultisigAddress, + nonce: 0, + userReqSig: '0x', + gasPriceMax: 500000000000, + type: 'Export' as const, + }; + + // Should not throw + await (tflrCoin as any).validateHopPrebuild(wallet, hopPrebuild); + }); + + it('should throw error for Export hop prebuild with mismatched amount', async function () { + const wallet = new Wallet(bitgo, tflrCoin, {}); + const hopPrebuild = { + tx: hopExportTx, + id: hopExportTxId, + signature: hopExportTxBitgoSignature, + paymentId: '12345', + gasPrice: 20000000000, + gasLimit: 500000, + amount: 50000000, + recipient: EXPORT_C_TEST_DATA.pMultisigAddress, + nonce: 0, + userReqSig: '0x', + gasPriceMax: 500000000000, + type: 'Export' as const, + }; + + const originalParams = { + recipients: [ + { + address: EXPORT_C_TEST_DATA.pMultisigAddress, + amount: '999999999999999999', // Wrong amount + }, + ], + }; + + await (tflrCoin as any) + .validateHopPrebuild(wallet, hopPrebuild, originalParams) + .should.be.rejectedWith(/Hop amount: .* does not equal original amount/); + }); + + it('should throw error for Export hop prebuild with mismatched destination', async function () { + const wallet = new Wallet(bitgo, tflrCoin, {}); + const hopPrebuild = { + tx: hopExportTx, + id: hopExportTxId, + signature: hopExportTxBitgoSignature, + paymentId: '12345', + gasPrice: 20000000000, + gasLimit: 500000, + amount: 50000000, + recipient: EXPORT_C_TEST_DATA.pMultisigAddress, + nonce: 0, + userReqSig: '0x', + gasPriceMax: 500000000000, + type: 'Export' as const, + }; + + const originalParams = { + recipients: [ + { + address: 'P-costwo1different~P-costwo1address~P-costwo1here', + amount: '49000000000000000', + }, + ], + }; + + await (tflrCoin as any) + .validateHopPrebuild(wallet, hopPrebuild, originalParams) + .should.be.rejectedWith(/Hop destination: .* does not equal original recipient/); + }); + }); + + describe('presignTransaction', function () { + it('should return params unchanged when no hopTransaction', async function () { + const params = { + txHex: '0x123', + wallet: new Wallet(bitgo, tflrCoin, {}), + buildParams: { recipients: [] }, + }; + + const result = await tflrCoin.presignTransaction(params as any); + result.should.equal(params); + }); + + it('should call validateHopPrebuild when hopTransaction is present', async function () { + const sandbox = sinon.createSandbox(); + const validateStub = sandbox.stub(tflrCoin as any, 'validateHopPrebuild').resolves(); + + const wallet = new Wallet(bitgo, tflrCoin, {}); + const hopTransaction = { + tx: hopExportTx, + id: hopExportTxId, + signature: hopExportTxBitgoSignature, + paymentId: '12345', + gasPrice: 20000000000, + gasLimit: 500000, + amount: 50000000, + recipient: EXPORT_C_TEST_DATA.pMultisigAddress, + nonce: 0, + userReqSig: '0x', + gasPriceMax: 500000000000, + type: 'Export' as const, + }; + + const params = { + txHex: '0x123', + wallet: wallet, + buildParams: { recipients: [] }, + hopTransaction: hopTransaction, + }; + + await tflrCoin.presignTransaction(params as any); + validateStub.calledOnce.should.be.true(); + sandbox.restore(); + }); + }); + + describe('postProcessPrebuild', function () { + it('should return params unchanged when no hopTransaction', async function () { + const params = { + txHex: '0x123', + coin: 'tflr', + }; + + const result = await tflrCoin.postProcessPrebuild(params as any); + result.should.equal(params); + }); + + it('should call validateHopPrebuild when hopTransaction is present', async function () { + const sandbox = sinon.createSandbox(); + const validateStub = sandbox.stub(tflrCoin as any, 'validateHopPrebuild').resolves(); + + const wallet = new Wallet(bitgo, tflrCoin, {}); + const hopTransaction = { + tx: hopExportTx, + id: hopExportTxId, + signature: hopExportTxBitgoSignature, + paymentId: '12345', + gasPrice: 20000000000, + gasLimit: 500000, + amount: 50000000, + recipient: EXPORT_C_TEST_DATA.pMultisigAddress, + nonce: 0, + userReqSig: '0x', + gasPriceMax: 500000000000, + type: 'Export' as const, + }; + + const params = { + txHex: '0x123', + wallet: wallet, + buildParams: { recipients: [{ address: EXPORT_C_TEST_DATA.pMultisigAddress, amount: '100000000' }] }, + hopTransaction: hopTransaction, + coin: 'tflr', + }; + + await tflrCoin.postProcessPrebuild(params as any); + validateStub.calledOnce.should.be.true(); + sandbox.restore(); + }); + }); + + describe('getExtraPrebuildParams', function () { + const sandbox = sinon.createSandbox(); + + afterEach(function () { + sandbox.restore(); + }); + + it('should return empty object when hop is not set', async function () { + const buildParams = { + recipients: [{ address: '0x1234', amount: '1000000' }], + }; + + const result = await tflrCoin.getExtraPrebuildParams(buildParams); + result.should.deepEqual({}); + }); + + it('should return empty object when hop is false', async function () { + const buildParams = { + hop: false, + recipients: [{ address: '0x1234', amount: '1000000' }], + wallet: new Wallet(bitgo, tflrCoin, {}), + }; + + const result = await tflrCoin.getExtraPrebuildParams(buildParams); + result.should.deepEqual({}); + }); + + it('should return empty object when wallet is missing', async function () { + const buildParams = { + hop: true, + recipients: [{ address: '0x1234', amount: '1000000' }], + }; + + const result = await tflrCoin.getExtraPrebuildParams(buildParams); + result.should.deepEqual({}); + }); + + it('should return empty object when recipients is missing', async function () { + const buildParams = { + hop: true, + wallet: new Wallet(bitgo, tflrCoin, {}), + }; + + const result = await tflrCoin.getExtraPrebuildParams(buildParams); + result.should.deepEqual({}); + }); + + it('should call createHopTransactionParams when hop is true with all required params', async function () { + const mockFeeEstimate = { + feeEstimate: 120000, + gasLimitEstimate: 500000, + }; + sandbox.stub(tflrCoin, 'feeEstimate').resolves(mockFeeEstimate); + + const buildParams = { + hop: true, + wallet: new Wallet(bitgo, tflrCoin, {}), + recipients: [{ address: EXPORT_C_TEST_DATA.pMultisigAddress, amount: '100000000000000000' }], + type: 'Export' as const, + }; + + const result = await tflrCoin.getExtraPrebuildParams(buildParams); + result.should.have.property('hopParams'); + }); + }); + + describe('feeEstimate', function () { + it('should make API call with correct query parameters', async function () { + const expectedFeeEstimate = { + feeEstimate: 120000, + gasLimitEstimate: 500000, + }; + + nock('https://app.bitgo-test.com') + .get('/api/v2/tflr/tx/fee') + .query({ + hop: true, + recipient: '0x1234', + amount: '1000000', + type: 'Export', + }) + .reply(200, expectedFeeEstimate); + + const result = await tflrCoin.feeEstimate({ + hop: true, + recipient: '0x1234', + amount: '1000000', + type: 'Export', + }); + + result.feeEstimate.should.equal(expectedFeeEstimate.feeEstimate); + result.gasLimitEstimate.should.equal(expectedFeeEstimate.gasLimitEstimate); + }); + + it('should make API call with data parameter', async function () { + const expectedFeeEstimate = { + feeEstimate: 150000, + gasLimitEstimate: 600000, + }; + + nock('https://app.bitgo-test.com') + .get('/api/v2/tflr/tx/fee') + .query({ + recipient: '0x5678', + data: '0xabcdef', + }) + .reply(200, expectedFeeEstimate); + + const result = await tflrCoin.feeEstimate({ + recipient: '0x5678', + data: '0xabcdef', + }); + + result.feeEstimate.should.equal(expectedFeeEstimate.feeEstimate); + result.gasLimitEstimate.should.equal(expectedFeeEstimate.gasLimitEstimate); + }); + }); + + describe('getTxHash', function () { + it('should calculate correct keccak256 hash for tx hex', function () { + const txHex = EXPORT_C_TEST_DATA.fullsigntxHex; + const hash = (tflrCoin.constructor as typeof Flr).getTxHash(txHex); + + hash.should.be.instanceOf(Buffer); + hash.length.should.equal(32); + + // Verify it matches our helper function + const expectedHash = getTxHash(txHex); + hash.toString('hex').should.equal(expectedHash.toString('hex')); + }); + + it('should handle tx hex with 0x prefix', function () { + const txHex = '0x' + 'abcd'.repeat(10); + const hash = (tflrCoin.constructor as typeof Flr).getTxHash(txHex); + + hash.should.be.instanceOf(Buffer); + hash.length.should.equal(32); + }); + + it('should handle tx hex without 0x prefix', function () { + const txHex = 'abcd'.repeat(10); + const hash = (tflrCoin.constructor as typeof Flr).getTxHash(txHex); + + hash.should.be.instanceOf(Buffer); + hash.length.should.equal(32); + }); + }); }); describe('Build Unsigned Sweep for Self-Custody Cold Wallets - (MPCv2)', function () {