diff --git a/modules/sdk-coin-flrp/package.json b/modules/sdk-coin-flrp/package.json index 77c91efe93..c884e496be 100644 --- a/modules/sdk-coin-flrp/package.json +++ b/modules/sdk-coin-flrp/package.json @@ -43,7 +43,6 @@ ] }, "devDependencies": { - "@types/bn.js": "^5.2.0", "@bitgo/sdk-test": "^9.0.9", "@bitgo/sdk-api": "^1.68.3" }, @@ -52,9 +51,7 @@ "@bitgo/secp256k1": "^1.5.0", "@bitgo/statics": "^57.8.0", "@flarenetwork/flarejs": "4.1.0-rc0", - "@noble/curves": "1.8.1", - "create-hash": "^1.2.0", - "safe-buffer": "^5.2.1" + "bignumber.js": "9.0.0" }, "gitHead": "18e460ddf02de2dbf13c2aa243478188fb539f0c" } diff --git a/modules/sdk-coin-flrp/src/flrp.ts b/modules/sdk-coin-flrp/src/flrp.ts index 8c299d9631..f42b7e434b 100644 --- a/modules/sdk-coin-flrp/src/flrp.ts +++ b/modules/sdk-coin-flrp/src/flrp.ts @@ -1,50 +1,45 @@ +import { FlareNetwork, BaseCoin as StaticsBaseCoin, CoinFamily, coins } from '@bitgo/statics'; import { BaseCoin, BitGoBase, - VerifyAddressOptions, - ParsedTransaction, - ParseTransactionOptions, KeyPair, - SignTransactionOptions, + VerifyAddressOptions, SignedTransaction, - InitiateRecoveryOptions, - SupplementGenerateWalletOptions, - KeychainsTriplet, - TransactionPrebuild, - PresignTransactionOptions, - FeeEstimateOptions, - DeriveKeyWithSeedOptions, - AuditKeyParams, - PopulatedIntent, - PrebuildTransactionWithIntentOptions, - TokenTransferRecipientParams, - BuildNftTransferDataOptions, - BaseBroadcastTransactionOptions, - BaseBroadcastTransactionResult, - MPCAlgorithm, - Wallet, - IInscriptionBuilder, - ExtraPrebuildParamsOptions, - ValidMofNOptions, - VerifyTransactionOptions, - ITransactionExplanation, - RecoverTokenTransaction, - RecoverWalletTokenOptions, - PrecreateBitGoOptions, - IWallet, + ParseTransactionOptions, + BaseTransaction, + InvalidTransactionError, + SigningError, + TransactionType, + InvalidAddressError, + UnexpectedAddressError, + ITransactionRecipient, + ParsedTransaction, + MultisigType, + multisigTypes, + AuditDecryptedKeyParams, + MethodNotImplementedError, } from '@bitgo/sdk-core'; -import { BaseCoin as StaticsBaseCoin, CoinFamily } from '@bitgo/statics'; -import { Hash } from 'crypto'; -import { KeyPair as FlrpKeyPair } from './lib/keyPair'; +import * as FlrpLib from './lib'; +import { + FlrpSignTransactionOptions, + ExplainTransactionOptions, + FlrpVerifyTransactionOptions, + FlrpTransactionStakingOptions, + FlrpTransactionParams, +} from './lib/iface'; +import utils from './lib/utils'; +import BigNumber from 'bignumber.js'; export class Flrp extends BaseCoin { protected readonly _staticsCoin: Readonly; - protected constructor(bitgo: BitGoBase, staticsCoin?: Readonly) { + constructor(bitgo: BitGoBase, staticsCoin?: Readonly) { super(bitgo); + if (!staticsCoin) { throw new Error('missing required constructor parameter staticsCoin'); } + this._staticsCoin = staticsCoin; } @@ -56,183 +51,322 @@ export class Flrp extends BaseCoin { return this._staticsCoin.name; } getFamily(): CoinFamily { - return this._staticsCoin.family as CoinFamily; + return this._staticsCoin.family; } getFullName(): string { return this._staticsCoin.fullName; } - getBaseFactor(): number | string { + getBaseFactor(): string | number { return Math.pow(10, this._staticsCoin.decimalPlaces); } - // TODO WIN-6321, 6322, 6318: All below methods will be implemented in coming ticket - // Feature flags - supportsTss(): boolean { - return false; + /** inherited doc */ + getDefaultMultisigType(): MultisigType { + return multisigTypes.onchain; } - supportsMessageSigning(): boolean { - return false; - } - supportsSigningTypedData(): boolean { - return false; + + /** + * Check if staking txn is valid, based on expected tx params. + * + * @param {FlrpTransactionStakingOptions} stakingOptions expected staking params to check against + * @param {FlrpLib.TransactionExplanation} explainedTx explained staking transaction + */ + validateStakingTx(stakingOptions: FlrpTransactionStakingOptions, explainedTx: FlrpLib.TransactionExplanation): void { + const filteredRecipients = [{ address: stakingOptions.nodeID, amount: stakingOptions.amount }]; + const filteredOutputs = explainedTx.outputs.map((output) => utils.pick(output, ['address', 'amount'])); + + if (!utils.isEqual(filteredOutputs, filteredRecipients)) { + throw new Error('Tx outputs does not match with expected txParams'); + } + if (stakingOptions?.amount !== explainedTx.outputAmount) { + throw new Error('Tx total amount does not match with expected total amount field'); + } } - supportsBlockTarget(): boolean { - return false; + + /** + * Check if export txn is valid, based on expected tx params. + * + * @param {ITransactionRecipient[]} recipients expected recipients and info + * @param {FlrpLib.TransactionExplanation} explainedTx explained export transaction + */ + validateExportTx(recipients: ITransactionRecipient[], explainedTx: FlrpLib.TransactionExplanation): void { + if (recipients.length !== 1 || explainedTx.outputs.length !== 1) { + throw new Error('Export Tx requires one recipient'); + } + + const maxImportFee = (this._staticsCoin.network as FlareNetwork).maxImportFee || '0'; + const recipientAmount = new BigNumber(recipients[0].amount); + if ( + recipientAmount.isGreaterThan(explainedTx.outputAmount) || + recipientAmount.plus(maxImportFee).isLessThan(explainedTx.outputAmount) + ) { + throw new Error( + `Tx total amount ${explainedTx.outputAmount} does not match with expected total amount field ${recipientAmount} and max import fee ${maxImportFee}` + ); + } + + if (explainedTx.outputs && !utils.isValidAddress(explainedTx.outputs[0].address)) { + throw new Error(`Invalid P-chain address ${explainedTx.outputs[0].address}`); + } } - supportsLightning(): boolean { - return false; + + /** + * Check if import txn into P is valid, based on expected tx params. + * + * @param {FlrpLib.FlrpEntry[]} explainedTxInputs tx inputs (unspents to be imported) + * @param {FlrpTransactionParams} txParams expected tx info to check against + */ + validateImportTx(explainedTxInputs: FlrpLib.FlrpEntry[], txParams: FlrpTransactionParams): void { + if (txParams.unspents) { + if (explainedTxInputs.length !== txParams.unspents.length) { + throw new Error(`Expected ${txParams.unspents.length} UTXOs, transaction had ${explainedTxInputs.length}`); + } + + const unspents = new Set(txParams.unspents); + + for (const unspent of explainedTxInputs) { + if (!unspents.has(unspent.id)) { + throw new Error(`Transaction should not contain the UTXO: ${unspent.id}`); + } + } + } } - supportsBlsDkg(): boolean { - return false; + + async verifyTransaction(params: FlrpVerifyTransactionOptions): Promise { + const txHex = params.txPrebuild && params.txPrebuild.txHex; + if (!txHex) { + throw new Error('missing required tx prebuild property txHex'); + } + let tx; + try { + const txBuilder = this.getBuilder().from(txHex); + tx = await txBuilder.build(); + } catch (error) { + throw new Error('Invalid transaction'); + } + const explainedTx = tx.explainTransaction(); + + const { type, stakingOptions } = params.txParams; + // TODO(BG-62112): change ImportToC type to Import + if (!type || (type !== 'ImportToC' && explainedTx.type !== TransactionType[type])) { + throw new Error('Tx type does not match with expected txParams type'); + } + + switch (explainedTx.type) { + // @deprecated + case TransactionType.AddDelegator: + case TransactionType.AddValidator: + case TransactionType.AddPermissionlessDelegator: + case TransactionType.AddPermissionlessValidator: + if (stakingOptions) { + this.validateStakingTx(stakingOptions, explainedTx); + } + break; + case TransactionType.Export: + if (!params.txParams.recipients || params.txParams.recipients?.length !== 1) { + throw new Error('Export Tx requires a recipient'); + } else { + this.validateExportTx(params.txParams.recipients, explainedTx); + } + break; + case TransactionType.Import: + if (tx.isTransactionForCChain) { + // Import to C-chain + if (explainedTx.outputs.length !== 1) { + throw new Error('Expected 1 output in import transaction'); + } + if (!params.txParams.recipients || params.txParams.recipients.length !== 1) { + throw new Error('Expected 1 recipient in import transaction'); + } + } else { + // Import to P-chain + if (explainedTx.outputs.length !== 1) { + throw new Error('Expected 1 output in import transaction'); + } + this.validateImportTx(explainedTx.inputs, params.txParams); + } + break; + default: + throw new Error('Tx type is not supported yet'); + } + return true; } - isEVM(): boolean { - return false; + + /** + * Check if address is valid, then make sure it matches the root address. + * + * @param params.address address to validate + * @param params.keychains public keys to generate the wallet + */ + async isWalletAddress(params: VerifyAddressOptions): Promise { + const { address, keychains } = params; + + if (!this.isValidAddress(address)) { + throw new InvalidAddressError(`invalid address: ${address}`); + } + if (!keychains || keychains.length !== 3) { + throw new Error('Invalid keychains'); + } + + // multisig addresses are separated by ~ + const splitAddresses = address.split('~'); + + // derive addresses from keychain + const unlockAddresses = keychains.map((keychain) => + new FlrpLib.KeyPair({ pub: keychain.pub }).getAddress(this._staticsCoin.network.type) + ); + + if (splitAddresses.length !== unlockAddresses.length) { + throw new UnexpectedAddressError(`address validation failure: multisig address length does not match`); + } + + if (!this.adressesArraysMatch(splitAddresses, unlockAddresses)) { + throw new UnexpectedAddressError(`address validation failure: ${address} is not of this wallet`); + } + + return true; } - // Conversions (placeholder) - // Use BaseCoin default conversions (baseUnitsToBigUnits / bigUnitsToBaseUnits) + /** + * Validate that two multisig address arrays have the same elements, order doesnt matter + * @param addressArray1 + * @param addressArray2 + * @returns true if address arrays have the same addresses + * @private + */ + private adressesArraysMatch(addressArray1: string[], addressArray2: string[]) { + return JSON.stringify(addressArray1.sort()) === JSON.stringify(addressArray2.sort()); + } - // Key methods (stubs) - generateKeyPair(): KeyPair { - const keyPair = new FlrpKeyPair(); + /** + * Generate Flrp key pair + * + * @param {Buffer} seed - Seed from which the new keypair should be generated, otherwise a random seed is used + * @returns {Object} object with generated pub and prv + */ + generateKeyPair(seed?: Buffer): KeyPair { + const keyPair = seed ? new FlrpLib.KeyPair({ seed }) : new FlrpLib.KeyPair(); const keys = keyPair.getKeys(); + if (!keys.prv) { - throw new Error('Failed to generate private key'); + throw new Error('Missing prv in key generation.'); } + return { pub: keys.pub, prv: keys.prv, }; } - generateRootKeyPair(): KeyPair { - throw new Error('generateRootKeyPair not implemented'); - } - keyIdsForSigning(): number[] { - return [0, 1, 2]; - } - isValidPub(_pub: string): boolean { - return false; - } - isValidAddress(_address: string): boolean { - return false; - } - isValidMofNSetup(_params: ValidMofNOptions): boolean { - return false; - } - canonicalAddress(address: string): string { - return address; - } - checkRecipient(_recipient: { address: string; amount: string | number }): void { - /* no-op */ - } - // Verification - async verifyAddress(_params: VerifyAddressOptions): Promise { - throw new Error('verifyAddress not implemented'); - } - async isWalletAddress(_params: VerifyAddressOptions): Promise { - throw new Error('isWalletAddress not implemented'); - } - async verifyTransaction(_params: VerifyTransactionOptions): Promise { - throw new Error('verifyTransaction not implemented'); + /** + * Return boolean indicating whether input is valid public key for the coin + * + * @param {string} pub the prv to be checked + * @returns is it valid? + */ + isValidPub(pub: string): boolean { + try { + new FlrpLib.KeyPair({ pub }); + return true; + } catch (e) { + return false; + } } - // Tx lifecycle - async signTransaction(_params: SignTransactionOptions): Promise { - // TODO WIN-6320: implement signTransaction - throw new Error('signTransaction not implemented'); - } - async explainTransaction( - _options: Record - ): Promise | undefined> { - // TODO WIN-6320: implement signTransaction - throw new Error('explainTransaction not implemented'); - } - async parseTransaction(_params: ParseTransactionOptions): Promise { - // TODO WIN-6320: implement signTransaction - throw new Error('parseTransaction not implemented'); - } - async presignTransaction(_params: PresignTransactionOptions): Promise { - // TODO WIN-6320: implement signTransaction - throw new Error('presignTransaction not implemented'); - } - async postProcessPrebuild(prebuild: TransactionPrebuild): Promise { - // TODO WIN-6320: implement signTransaction - return prebuild; - } - async getExtraPrebuildParams(_buildParams: ExtraPrebuildParamsOptions): Promise> { - // TODO WIN-6320: implement signTransaction - return {}; + /** + * Return boolean indicating whether input is valid private key for the coin + * + * @param {string} prv the prv to be checked + * @returns is it valid? + */ + isValidPrv(prv: string): boolean { + try { + new FlrpLib.KeyPair({ prv }); + return true; + } catch (e) { + return false; + } } - async feeEstimate(_params: FeeEstimateOptions): Promise { - // TODO WIN-6320: implement signTransaction - throw new Error('feeEstimate not implemented'); + + isValidAddress(address: string | string[]): boolean { + if (address === undefined) { + return false; + } + + // validate eth address for cross-chain txs to c-chain + if (typeof address === 'string' && utils.isValidEthereumAddress(address)) { + return true; + } + + return FlrpLib.Utils.isValidAddress(address); } - async broadcastTransaction(_params: BaseBroadcastTransactionOptions): Promise { - // TODO WIN-6320: implement signTransaction - throw new Error('broadcastTransaction not implemented'); + + /** + * Signs Flrp transaction + */ + async signTransaction(params: FlrpSignTransactionOptions): Promise { + // deserialize raw transaction (note: fromAddress has onchain order) + const txBuilder = this.getBuilder().from(params.txPrebuild.txHex); + const key = params.prv; + + // push the keypair to signer array + txBuilder.sign({ key }); + + // build the transaction + const transaction: BaseTransaction = await txBuilder.build(); + if (!transaction) { + throw new InvalidTransactionError('Error while trying to build transaction'); + } + return transaction.signature.length >= 2 + ? { txHex: transaction.toBroadcastFormat() } + : { halfSigned: { txHex: transaction.toBroadcastFormat() } }; } - // Wallet helpers - async supplementGenerateWallet( - _walletParams: SupplementGenerateWalletOptions, - _keychains: KeychainsTriplet - ): Promise> { + async parseTransaction(params: ParseTransactionOptions): Promise { return {}; } - newWalletObject(walletParams: unknown): IWallet { - return walletParams as IWallet; - } - preCreateBitGo(_params: PrecreateBitGoOptions): void { - /* no-op */ - } - initiateRecovery(_params: InitiateRecoveryOptions): never { - throw new Error('initiateRecovery not implemented'); - } - // Signing helpers - async signMessage(_key: { prv: string }, _message: string): Promise { - throw new Error('signMessage not implemented'); - } - async createKeySignatures( - _prv: string, - _backup: { pub: string }, - _bitgo: { pub: string } - ): Promise<{ backup: string; bitgo: string }> { - throw new Error('createKeySignatures not implemented'); - } - async getSignablePayload(_serializedTx: string): Promise { - throw new Error('getSignablePayload not implemented'); - } - getMPCAlgorithm(): MPCAlgorithm { - return 'ecdsa'; + /** + * Explain a Flrp transaction from txHex + * @param params + * @param callback + */ + async explainTransaction(params: ExplainTransactionOptions): Promise { + const txHex = params.txHex ?? params?.halfSigned?.txHex; + if (!txHex) { + throw new Error('missing transaction hex'); + } + try { + const txBuilder = this.getBuilder().from(txHex); + const tx = await txBuilder.build(); + return tx.explainTransaction(); + } catch (e) { + throw new Error(`Invalid transaction: ${e.message}`); + } } - // Token / NFT / inscription / recovery placeholders - recoverToken(_params: RecoverWalletTokenOptions): Promise { - throw new Error('recoverToken not implemented'); - } - buildNftTransferData(_params: BuildNftTransferDataOptions): string | TokenTransferRecipientParams { - throw new Error('buildNftTransferData not implemented'); - } - getInscriptionBuilder(_wallet: Wallet): IInscriptionBuilder { - throw new Error('getInscriptionBuilder not implemented'); + recoverySignature(message: Buffer, signature: Buffer): Buffer { + return FlrpLib.Utils.recoverySignature(this._staticsCoin.network as FlareNetwork, message, signature); } - // Misc - getHashFunction(): Hash { - throw new Error('getHashFunction not implemented'); - } - deriveKeyWithSeed(_params: DeriveKeyWithSeedOptions): { key: string; derivationPath: string } { - throw new Error('deriveKeyWithSeed not implemented'); - } - setCoinSpecificFieldsInIntent(_intent: PopulatedIntent, _params: PrebuildTransactionWithIntentOptions): void { - /* no-op */ + async signMessage(key: KeyPair, message: string | Buffer): Promise { + const prv = new FlrpLib.KeyPair(key).getPrivateKey(); + if (!prv) { + throw new SigningError('Invalid key pair options'); + } + if (typeof message === 'string') { + message = Buffer.from(message, 'hex'); + } + return FlrpLib.Utils.createSignature(this._staticsCoin.network as FlareNetwork, message, prv); } - assertIsValidKey(_params: AuditKeyParams): void { - /* no-op */ + + private getBuilder(): FlrpLib.TransactionBuilderFactory { + return new FlrpLib.TransactionBuilderFactory(coins.get(this.getChain())); } - auditDecryptedKey(): void { - /* no-op */ + + /** @inheritDoc */ + auditDecryptedKey(params: AuditDecryptedKeyParams): void { + /** https://bitgoinc.atlassian.net/browse/COIN-4213 */ + throw new MethodNotImplementedError(); } } diff --git a/modules/sdk-coin-flrp/src/lib/atomicTransactionBuilder.ts b/modules/sdk-coin-flrp/src/lib/atomicTransactionBuilder.ts index e5e0e9e227..441e60f64e 100644 --- a/modules/sdk-coin-flrp/src/lib/atomicTransactionBuilder.ts +++ b/modules/sdk-coin-flrp/src/lib/atomicTransactionBuilder.ts @@ -1,6 +1,7 @@ import { BaseCoin as CoinConfig } from '@bitgo/statics'; -import { BuildTransactionError, TransactionType } from '@bitgo/sdk-core'; -import { Credential, Signature } from '@flarenetwork/flarejs'; +import { BuildTransactionError, TransactionType, BaseTransaction } from '@bitgo/sdk-core'; +import { Credential, Signature, TransferableInput, TransferableOutput } from '@flarenetwork/flarejs'; +import { TransactionExplanation, DecodedUtxoObj } from './iface'; // Constants for signature handling const SECP256K1_SIGNATURE_LENGTH = 65; @@ -15,8 +16,8 @@ export abstract class AtomicTransactionBuilder { // External chain id (destination) for export transactions protected _externalChainId: Buffer | undefined; - // Simplified internal transaction state (mirrors shape expected by existing builders) - // Simplified internal transaction state + protected _utxos: DecodedUtxoObj[] = []; + protected transaction: { _network: Record; _networkID: number; @@ -75,10 +76,95 @@ export abstract class AtomicTransactionBuilder { } /** - * Placeholder that should assemble inputs/outputs and credentials once UTXO + key logic is implemented. + * Creates inputs, outputs, and credentials for Flare P-chain atomic transactions. + * Based on AVAX P-chain implementation adapted for FlareJS. + * + * Note: This is a simplified implementation that creates the core structure. + * The FlareJS type system integration will be refined in future iterations. + * + * @param total - Total amount needed including fees + * @returns Object containing TransferableInput[], TransferableOutput[], and Credential[] + */ + protected createInputOutput(total: bigint): { + inputs: TransferableInput[]; + outputs: TransferableOutput[]; + credentials: Credential[]; + } { + if (!this._utxos || this._utxos.length === 0) { + throw new BuildTransactionError('UTXOs are required for creating inputs and outputs'); + } + + const inputs: TransferableInput[] = []; + const outputs: TransferableOutput[] = []; + const credentials: Credential[] = []; + + let inputSum = 0n; + const addressIndices: { [address: string]: number } = {}; + let nextAddressIndex = 0; + + // Sort UTXOs by amount in descending order for optimal coin selection + const sortedUtxos = [...this._utxos].sort((a, b) => { + const amountA = BigInt(a.amount); + const amountB = BigInt(b.amount); + if (amountA > amountB) return -1; + if (amountA < amountB) return 1; + return 0; + }); + + // Process UTXOs to create inputs and credentials + for (const utxo of sortedUtxos) { + const utxoAmount = BigInt(utxo.amount); + + if (inputSum >= total) { + break; // We have enough inputs + } + + // TODO: Create proper FlareJS TransferableInput once type issues are resolved + // For now, we create a placeholder that demonstrates the structure + // The actual FlareJS integration will need proper UTXOID handling + + // Track input sum + inputSum += utxoAmount; + + // Track address indices for signature ordering (mimics AVAX pattern) + const addressIndexArray: number[] = []; + for (const address of utxo.addresses) { + if (!(address in addressIndices)) { + addressIndices[address] = nextAddressIndex++; + } + addressIndexArray.push(addressIndices[address]); + } + + // Store address indices on the UTXO for credential creation + utxo.addressesIndex = addressIndexArray; + + // Create credential with placeholder signatures + // In a real implementation, these would be actual signatures + const signatures = Array.from({ length: utxo.threshold }, () => ''); + const credential = this.createFlareCredential(0, signatures); + credentials.push(credential); + } + + // Verify we have enough inputs + if (inputSum < total) { + throw new BuildTransactionError(`Insufficient funds: need ${total}, have ${inputSum}`); + } + + // TODO: Create change output if we have excess input + // The TransferableOutput creation will be implemented once FlareJS types are resolved + + return { inputs, outputs, credentials }; + } + + /** + * Set UTXOs for the transaction. This is required for creating inputs and outputs. + * + * @param utxos - Array of decoded UTXO objects + * @returns this builder instance for chaining */ - protected createInputOutput(_total: bigint): { inputs: unknown[]; outputs: unknown[]; credentials: Credential[] } { - return { inputs: [], outputs: [], credentials: [] }; + utxos(utxos: DecodedUtxoObj[]): this { + this._utxos = utxos; + return this; } /** @@ -146,4 +232,78 @@ export abstract class AtomicTransactionBuilder { initBuilder(_tx: unknown): this { return this; } + + /** + * Sign transaction with private key (placeholder implementation) + * TODO: Implement proper FlareJS signing + */ + sign(_params: { key: string }): this { + // TODO: Implement FlareJS signing + // For now, just mark as having credentials + this.transaction.hasCredentials = true; + return this; + } + + /** + * Build the transaction (placeholder implementation) + * TODO: Implement proper FlareJS transaction building + */ + async build(): Promise { + // TODO: Create actual FlareJS UnsignedTx + // For now, return a mock transaction that satisfies the interface + const mockTransaction = { + _id: 'mock-transaction-id', + _inputs: [], + _outputs: [], + _type: this.transactionType, + signature: [] as string[], + toBroadcastFormat: () => 'mock-tx-hex', + toJson: () => ({}), + explainTransaction: (): TransactionExplanation => ({ + type: this.transactionType, + inputs: [], + outputs: [], + outputAmount: '0', + rewardAddresses: [], + id: 'mock-transaction-id', + changeOutputs: [], + changeAmount: '0', + fee: { fee: '0' }, + }), + isTransactionForCChain: false, + fromAddresses: [], + validationErrors: [], + loadInputsAndOutputs: () => { + /* placeholder */ + }, + inputs: () => [], + outputs: () => [], + fee: () => ({ fee: '0' }), + feeRate: () => 0, + id: () => 'mock-transaction-id', + type: this.transactionType, + } as unknown as BaseTransaction; + + return mockTransaction; + } + + /** + * Parse and explain a transaction from hex (placeholder implementation) + * TODO: Implement proper FlareJS transaction parsing + */ + explainTransaction(): TransactionExplanation { + // TODO: Parse actual FlareJS transaction + // For now, return basic explanation + return { + type: this.transactionType, + inputs: [], + outputs: [], + outputAmount: '0', + rewardAddresses: [], + id: 'mock-transaction-id', + changeOutputs: [], + changeAmount: '0', + fee: { fee: '0' }, + }; + } } diff --git a/modules/sdk-coin-flrp/src/lib/iface.ts b/modules/sdk-coin-flrp/src/lib/iface.ts index 5db3f8fdd6..b94370295a 100644 --- a/modules/sdk-coin-flrp/src/lib/iface.ts +++ b/modules/sdk-coin-flrp/src/lib/iface.ts @@ -1,4 +1,11 @@ -import { TransactionExplanation as BaseTransactionExplanation, Entry, TransactionType } from '@bitgo/sdk-core'; +import { + TransactionExplanation as BaseTransactionExplanation, + Entry, + TransactionType, + SignTransactionOptions, + VerifyTransactionOptions, + TransactionParams, +} from '@bitgo/sdk-core'; import { UnsignedTx, TransferableOutput, avaxSerial } from '@flarenetwork/flarejs'; export interface FlrpEntry extends Entry { id: string; @@ -32,6 +39,7 @@ export interface TxData { changeOutputs: Entry[]; sourceChain?: string; destinationChain?: string; + memo?: string; // Memo field for transaction metadata } /** @@ -72,3 +80,61 @@ export type BaseTx = avaxSerial.BaseTx; // FlareJS BaseTx export type AvaxTx = avaxSerial.AvaxTx; // FlareJS AvaxTx export type DeprecatedOutput = unknown; // Placeholder for backward compatibility export type Output = TransferableOutput; // FlareJS TransferableOutput (unified type in 4.0.5) + +// FLRP-specific interfaces extending SDK-core interfaces +export interface FlrpSignTransactionOptions extends SignTransactionOptions { + txPrebuild: { + txHex: string; + }; + prv: string; +} + +export interface FlrpVerifyTransactionOptions extends VerifyTransactionOptions { + txParams: FlrpTransactionParams; +} + +export interface FlrpTransactionStakingOptions { + nodeID: string; + amount: string; + startTime?: string; + endTime?: string; + delegationFeeRate?: number; +} + +export interface FlrpTransactionParams extends TransactionParams { + type: string; + recipients?: Array<{ + address: string; + amount: string; + }>; + stakingOptions?: FlrpTransactionStakingOptions; + unspents?: string[]; +} + +export interface ExplainTransactionOptions { + txHex?: string; + halfSigned?: { + txHex: string; + }; +} + +/** + * Memo utility interfaces for FlareJS integration + */ +export interface MemoData { + text?: string; // UTF-8 string memo + bytes?: Uint8Array; // Raw byte array memo + json?: Record; // JSON object memo (will be stringified) +} + +/** + * SpendOptions interface matching FlareJS patterns + * Based on FlareJS SpendOptions with memo support + */ +export interface FlrpSpendOptions { + minIssuanceTime?: bigint; + changeAddresses?: string[]; // BitGo uses string addresses, FlareJS uses Uint8Array[] + threshold?: number; + memo?: Uint8Array; // FlareJS memo format (byte array) + locktime?: bigint; +} diff --git a/modules/sdk-coin-flrp/src/lib/index.ts b/modules/sdk-coin-flrp/src/lib/index.ts index bd7399f348..5da7048da9 100644 --- a/modules/sdk-coin-flrp/src/lib/index.ts +++ b/modules/sdk-coin-flrp/src/lib/index.ts @@ -2,3 +2,5 @@ import Utils from './utils'; export * from './iface'; export { KeyPair } from './keyPair'; export { Utils }; +export { TransactionBuilderFactory } from './transactionBuilderFactory'; +export { Transaction } from './transaction'; diff --git a/modules/sdk-coin-flrp/src/lib/keyPair.ts b/modules/sdk-coin-flrp/src/lib/keyPair.ts index f6f0129990..0c48e56090 100644 --- a/modules/sdk-coin-flrp/src/lib/keyPair.ts +++ b/modules/sdk-coin-flrp/src/lib/keyPair.ts @@ -8,10 +8,8 @@ import { KeyPairOptions, Secp256k1ExtendedKeyPair, } from '@bitgo/sdk-core'; -import createHash from 'create-hash'; -import { Buffer as SafeBuffer } from 'safe-buffer'; import { bip32, ECPair } from '@bitgo/secp256k1'; -import { randomBytes } from 'crypto'; +import { randomBytes, createHash } from 'crypto'; import utils from './utils'; const DEFAULT_SEED_SIZE_BYTES = 16; @@ -137,8 +135,8 @@ export class KeyPair extends Secp256k1ExtendedKeyPair { * @returns {Buffer} */ private getAddressSafeBuffer(): Buffer { - const publicKeySafe = SafeBuffer.from(this.keyPair.publicKey.toString('hex'), 'hex'); - const sha256 = SafeBuffer.from(createHash('sha256').update(publicKeySafe).digest()); - return Buffer.from(createHash('ripemd160').update(sha256).digest()); + const publicKeyHex = this.keyPair.publicKey.toString('hex'); + const sha256 = createHash('sha256').update(publicKeyHex, 'hex').digest(); + return createHash('ripemd160').update(sha256).digest(); } } diff --git a/modules/sdk-coin-flrp/src/lib/transaction.ts b/modules/sdk-coin-flrp/src/lib/transaction.ts new file mode 100644 index 0000000000..2794de0c41 --- /dev/null +++ b/modules/sdk-coin-flrp/src/lib/transaction.ts @@ -0,0 +1,393 @@ +import { UnsignedTx, Credential } from '@flarenetwork/flarejs'; +import { + BaseKey, + BaseTransaction, + Entry, + InvalidTransactionError, + SigningError, + TransactionFee, + TransactionType, +} from '@bitgo/sdk-core'; +import { FlareNetwork, BaseCoin as CoinConfig } from '@bitgo/statics'; +import { Buffer } from 'buffer'; +import { + ADDRESS_SEPARATOR, + DecodedUtxoObj, + INPUT_SEPARATOR, + TransactionExplanation, + Tx, + TxData, + FlrpEntry, +} from './iface'; +import { KeyPair } from './keyPair'; +import utils from './utils'; + +/** + * Flare P-chain transaction implementation using FlareJS + * Based on AVAX transaction patterns adapted for Flare network + */ +export class Transaction extends BaseTransaction { + protected _flareTransaction: Tx; + public _type: TransactionType; + public _network: FlareNetwork; + public _networkID: number; + public _assetId: string; + public _blockchainID: string; + public _nodeID: string; + public _startTime: bigint; + public _endTime: bigint; + public _stakeAmount: bigint; + public _threshold = 2; + public _locktime = BigInt(0); + public _fromAddresses: string[] = []; + public _rewardAddresses: string[] = []; + public _utxos: DecodedUtxoObj[] = []; + public _to: string[]; + public _fee: Partial = {}; + public _blsPublicKey: string; + public _blsSignature: string; + public _memo: Uint8Array = new Uint8Array(); // FlareJS memo field + + constructor(coinConfig: Readonly) { + super(coinConfig); + this._network = coinConfig.network as FlareNetwork; + this._assetId = 'FLR'; // Default FLR asset + this._blockchainID = this._network.blockchainID || ''; + this._networkID = this._network.networkID || 0; + } + + /** + * Get the base transaction from FlareJS UnsignedTx + * TODO: Implement proper FlareJS transaction extraction + */ + get flareTransaction(): UnsignedTx { + return this._flareTransaction as UnsignedTx; + } + + get signature(): string[] { + if (this.credentials.length === 0) { + return []; + } + // TODO: Extract signatures from FlareJS credentials + // For now, return placeholder + return []; + } + + get credentials(): Credential[] { + // TODO: Extract credentials from FlareJS transaction + // For now, return empty array + return []; + } + + get hasCredentials(): boolean { + return this.credentials !== undefined && this.credentials.length > 0; + } + + /** @inheritdoc */ + canSign({ key }: BaseKey): boolean { + // TODO: Implement proper signing validation for FlareJS + return true; + } + + /** + * Sign a Flare transaction using FlareJS + * @param {KeyPair} keyPair + */ + async sign(keyPair: KeyPair): Promise { + const prv = keyPair.getPrivateKey() as Buffer; + + if (!prv) { + throw new SigningError('Missing private key'); + } + + if (!this.flareTransaction) { + throw new InvalidTransactionError('empty transaction to sign'); + } + + if (!this.hasCredentials) { + throw new InvalidTransactionError('empty credentials to sign'); + } + + // TODO: Implement FlareJS signing process + // This will involve: + // 1. Creating FlareJS signature using private key + // 2. Attaching signature to appropriate credential + // 3. Updating transaction with signed credentials + + throw new Error('FlareJS signing not yet implemented - placeholder'); + } + + /** + * Set memo from string + * @param {string} memo - Memo text + */ + setMemo(memo: string): void { + this._memo = utils.stringToBytes(memo); + } + + /** + * Set memo from various formats + * @param {string | Record | Uint8Array} memo - Memo data + */ + setMemoData(memo: string | Record | Uint8Array): void { + this._memo = utils.createMemoBytes(memo); + } + + /** + * Get memo as bytes (FlareJS format) + * @returns {Uint8Array} Memo bytes + */ + getMemoBytes(): Uint8Array { + return this._memo; + } + + /** + * Get memo as string + * @returns {string} Memo string + */ + getMemoString(): string { + return utils.parseMemoBytes(this._memo); + } + + /** + * Check if transaction has memo + * @returns {boolean} Whether memo exists and is not empty + */ + hasMemo(): boolean { + return this._memo.length > 0; + } + + toHexString(byteArray: Uint8Array): string { + return Buffer.from(byteArray).toString('hex'); + } + + /** @inheritdoc */ + toBroadcastFormat(): string { + if (!this.flareTransaction) { + throw new InvalidTransactionError('Empty transaction data'); + } + + // TODO: Implement FlareJS transaction serialization + // For now, return placeholder + return 'flare-tx-hex-placeholder'; + } + + toJson(): TxData { + if (!this.flareTransaction) { + throw new InvalidTransactionError('Empty transaction data'); + } + + return { + id: this.id, + inputs: this.inputs, + fromAddresses: this.fromAddresses, + threshold: this._threshold, + locktime: this._locktime.toString(), + type: this.type, + signatures: this.signature, + outputs: this.outputs, + changeOutputs: this.changeOutputs, + sourceChain: this._network.blockchainID, + destinationChain: this._network.cChainBlockchainID, + memo: this.getMemoString(), // Include memo in JSON representation + }; + } + + setTransaction(tx: Tx): void { + this._flareTransaction = tx; + } + + /** + * Set the transaction type + * @param {TransactionType} transactionType The transaction type to be set + */ + setTransactionType(transactionType: TransactionType): void { + const supportedTypes = [ + TransactionType.Export, + TransactionType.Import, + TransactionType.AddValidator, + TransactionType.AddDelegator, + TransactionType.AddPermissionlessValidator, + TransactionType.AddPermissionlessDelegator, + ]; + + if (!supportedTypes.includes(transactionType)) { + throw new Error(`Transaction type ${transactionType} is not supported`); + } + this._type = transactionType; + } + + /** + * Returns the portion of the transaction that needs to be signed in Buffer format. + * Only needed for coins that support adding signatures directly (e.g. TSS). + */ + get signablePayload(): Buffer { + if (!this.flareTransaction) { + throw new InvalidTransactionError('Empty transaction for signing'); + } + + // TODO: Implement FlareJS signable payload extraction + // For now, return placeholder + return Buffer.from('flare-signable-payload'); + } + + get id(): string { + if (!this.flareTransaction) { + throw new InvalidTransactionError('Empty transaction for ID generation'); + } + + // TODO: Implement FlareJS transaction ID generation + // For now, return placeholder + return 'flare-transaction-id-placeholder'; + } + + get fromAddresses(): string[] { + return this._fromAddresses.map((address) => { + // TODO: Format addresses using FlareJS utilities + return address; + }); + } + + get rewardAddresses(): string[] { + return this._rewardAddresses.map((address) => { + // TODO: Format addresses using FlareJS utilities + return address; + }); + } + + /** + * Get the list of outputs. Amounts are expressed in absolute value. + */ + get outputs(): Entry[] { + switch (this.type) { + case TransactionType.Export: + // TODO: Extract export outputs from FlareJS transaction + return []; + case TransactionType.Import: + // TODO: Extract import outputs from FlareJS transaction + return []; + case TransactionType.AddValidator: + case TransactionType.AddPermissionlessValidator: + // TODO: Extract validator outputs from FlareJS transaction + return [ + { + address: this._nodeID || 'placeholder-node-id', + value: this._stakeAmount?.toString() || '0', + }, + ]; + case TransactionType.AddDelegator: + case TransactionType.AddPermissionlessDelegator: + // TODO: Extract delegator outputs from FlareJS transaction + return [ + { + address: this._nodeID || 'placeholder-node-id', + value: this._stakeAmount?.toString() || '0', + }, + ]; + default: + return []; + } + } + + get fee(): TransactionFee { + return { fee: '0', ...this._fee }; + } + + get changeOutputs(): Entry[] { + // TODO: Extract change outputs from FlareJS transaction + // For now, return empty array + return []; + } + + get inputs(): FlrpEntry[] { + // TODO: Extract inputs from FlareJS transaction + // For now, return placeholder based on UTXOs + return this._utxos.map((utxo) => ({ + id: utxo.txid + INPUT_SEPARATOR + utxo.outputidx, + address: this.fromAddresses.sort().join(ADDRESS_SEPARATOR), + value: utxo.amount, + })); + } + + /** + * Flare wrapper to create signature and return it for credentials + * @param prv + * @return hexstring + */ + createSignature(prv: Buffer): string { + // TODO: Implement FlareJS signature creation + // This should use FlareJS signing utilities + const signval = utils.createSignature(this._network, this.signablePayload, prv); + return signval.toString('hex'); + } + + /** + * Check if transaction is for C-chain (cross-chain) + */ + get isTransactionForCChain(): boolean { + return this.type === TransactionType.Export || this.type === TransactionType.Import; + } + + /** @inheritdoc */ + explainTransaction(): TransactionExplanation { + const txJson = this.toJson(); + const displayOrder = ['id', 'inputs', 'outputAmount', 'changeAmount', 'outputs', 'changeOutputs', 'fee', 'type']; + + // Add memo to display order if present + if (this.hasMemo()) { + displayOrder.push('memo'); + } + + // Calculate total output amount + const outputAmount = txJson.outputs + .reduce((sum, output) => { + return sum + BigInt(output.value || '0'); + }, BigInt(0)) + .toString(); + + // Calculate total change amount + const changeAmount = txJson.changeOutputs + .reduce((sum, output) => { + return sum + BigInt(output.value || '0'); + }, BigInt(0)) + .toString(); + + let rewardAddresses; + const stakingTypes = [ + TransactionType.AddValidator, + TransactionType.AddDelegator, + TransactionType.AddPermissionlessValidator, + TransactionType.AddPermissionlessDelegator, + ]; + + if (stakingTypes.includes(txJson.type)) { + rewardAddresses = this.rewardAddresses; + displayOrder.splice(6, 0, 'rewardAddresses'); + } + + // Add cross-chain information for export/import + if (this.isTransactionForCChain) { + displayOrder.push('sourceChain', 'destinationChain'); + } + + const explanation: TransactionExplanation & { memo?: string } = { + displayOrder, + id: txJson.id, + inputs: txJson.inputs, + outputs: txJson.outputs.map((o) => ({ address: o.address, amount: o.value })), + outputAmount, + changeOutputs: txJson.changeOutputs.map((o) => ({ address: o.address, amount: o.value })), + changeAmount, + rewardAddresses, + fee: this.fee, + type: txJson.type, + }; + + // Add memo to explanation if present + if (this.hasMemo()) { + explanation.memo = this.getMemoString(); + } + + return explanation; + } +} diff --git a/modules/sdk-coin-flrp/src/lib/transactionBuilderFactory.ts b/modules/sdk-coin-flrp/src/lib/transactionBuilderFactory.ts new file mode 100644 index 0000000000..73066ffc28 --- /dev/null +++ b/modules/sdk-coin-flrp/src/lib/transactionBuilderFactory.ts @@ -0,0 +1,99 @@ +import { BaseCoin as CoinConfig } from '@bitgo/statics'; +import { NotImplementedError, TransactionType } from '@bitgo/sdk-core'; +import { AtomicTransactionBuilder } from './atomicTransactionBuilder'; + +// Placeholder builders - basic implementations for testing +export class ExportTxBuilder extends AtomicTransactionBuilder { + protected get transactionType(): TransactionType { + return TransactionType.Export; + } + + constructor(coinConfig: Readonly) { + super(coinConfig); + // Don't throw error, allow placeholder functionality + } +} + +export class ImportTxBuilder extends AtomicTransactionBuilder { + protected get transactionType(): TransactionType { + return TransactionType.Import; + } + + constructor(coinConfig: Readonly) { + super(coinConfig); + // Don't throw error, allow placeholder functionality + } +} + +export class ValidatorTxBuilder extends AtomicTransactionBuilder { + protected get transactionType(): TransactionType { + return TransactionType.AddValidator; + } + + constructor(coinConfig: Readonly) { + super(coinConfig); + // Don't throw error, allow placeholder functionality + } +} + +export class DelegatorTxBuilder extends AtomicTransactionBuilder { + protected get transactionType(): TransactionType { + return TransactionType.AddDelegator; + } + + constructor(coinConfig: Readonly) { + super(coinConfig); + // Don't throw error, allow placeholder functionality + } +} + +/** + * Factory for Flare P-chain transaction builders + */ +export class TransactionBuilderFactory { + protected _coinConfig: Readonly; + + constructor(coinConfig: Readonly) { + this._coinConfig = coinConfig; + } + + /** + * Create a transaction builder from a hex string + * @param txHex - Transaction hex string + */ + from(txHex: string): AtomicTransactionBuilder { + // TODO: Parse the hex and determine transaction type, then return appropriate builder + // For now, return a basic export builder as that's the most common use case + if (!txHex) { + throw new Error('Transaction hex is required'); + } + + // Create a mock export builder for now + // In the future, this will parse the transaction and determine the correct type + const builder = new ExportTxBuilder(this._coinConfig); + + // Initialize with the hex data (placeholder) + builder.initBuilder({ txHex }); + + return builder; + } + + /** + * Create a transaction builder for a specific type + * @param type - Transaction type + */ + getBuilder(type: TransactionType): AtomicTransactionBuilder { + switch (type) { + case TransactionType.Export: + return new ExportTxBuilder(this._coinConfig); + case TransactionType.Import: + return new ImportTxBuilder(this._coinConfig); + case TransactionType.AddValidator: + return new ValidatorTxBuilder(this._coinConfig); + case TransactionType.AddDelegator: + return new DelegatorTxBuilder(this._coinConfig); + default: + throw new NotImplementedError(`Transaction type ${type} not supported`); + } + } +} diff --git a/modules/sdk-coin-flrp/src/lib/utils.ts b/modules/sdk-coin-flrp/src/lib/utils.ts index 08bcc8e549..0ca164a185 100644 --- a/modules/sdk-coin-flrp/src/lib/utils.ts +++ b/modules/sdk-coin-flrp/src/lib/utils.ts @@ -9,8 +9,8 @@ import { ParseTransactionError, } from '@bitgo/sdk-core'; import { FlareNetwork } from '@bitgo/statics'; -import * as createHash from 'create-hash'; -import { secp256k1 } from '@noble/curves/secp256k1'; +import { ecc } from '@bitgo/secp256k1'; +import { createHash } from 'crypto'; import { DeprecatedOutput, DeprecatedTx, Output } from './iface'; import { DECODED_BLOCK_ID_LENGTH, @@ -108,9 +108,9 @@ export class Utils implements BaseUtils { if (!this.allHexChars(pub)) return false; pubBuf = Buffer.from(pub, 'hex'); } - // validate the public key using noble secp256k1 + // validate the public key using BitGo secp256k1 try { - secp256k1.ProjectivePoint.fromHex(pubBuf.toString('hex')); + ecc.isPoint(pubBuf); // Check if it's a valid point return true; } catch (e) { return false; @@ -160,6 +160,84 @@ export class Utils implements BaseUtils { return HEX_REGEX.test(maybe); } + /** + * Lightweight Ethereum address validation + * Validates that an address is a 40-character hex string (optionally prefixed with 0x) + * + * @param {string} address - the Ethereum address to validate + * @returns {boolean} - true if valid Ethereum address format + */ + isValidEthereumAddress(address: string): boolean { + if (!address || typeof address !== 'string') { + return false; + } + + // Remove 0x prefix if present + const cleanAddress = address.startsWith('0x') ? address.slice(2) : address; + + // Check if it's exactly 40 hex characters + return cleanAddress.length === 40 && /^[0-9a-fA-F]{40}$/.test(cleanAddress); + } + + /** + * Pick specific properties from an object (replaces lodash.pick) + * + * @param {T} obj - the source object + * @param {K[]} keys - array of property keys to pick + * @returns {Pick} - new object with only the specified properties + */ + pick(obj: T, keys: K[]): Pick { + const result = {} as Pick; + for (const key of keys) { + if (Object.prototype.hasOwnProperty.call(obj, key)) { + result[key] = obj[key]; + } + } + return result; + } + + /** + * Deep equality comparison (replaces lodash.isEqual) + * + * @param {unknown} a - first value to compare + * @param {unknown} b - second value to compare + * @returns {boolean} - true if values are deeply equal + */ + isEqual(a: unknown, b: unknown): boolean { + if (a === b) return true; + + if (a === null || a === undefined || b === null || b === undefined) return a === b; + + if (typeof a !== typeof b) return false; + + if (typeof a === 'object') { + if (Array.isArray(a) !== Array.isArray(b)) return false; + + if (Array.isArray(a)) { + const arrB = b as unknown[]; + if (a.length !== arrB.length) return false; + for (let i = 0; i < a.length; i++) { + if (!this.isEqual(a[i], arrB[i])) return false; + } + return true; + } + + const objA = a as Record; + const objB = b as Record; + const keysA = Object.keys(objA); + const keysB = Object.keys(objB); + if (keysA.length !== keysB.length) return false; + + for (const key of keysA) { + if (!keysB.includes(key)) return false; + if (!this.isEqual(objA[key], objB[key])) return false; + } + return true; + } + + return false; + } + /** @inheritdoc */ isValidSignature(signature: string): boolean { throw new NotImplementedError('isValidSignature not implemented'); @@ -178,10 +256,10 @@ export class Utils implements BaseUtils { * @return signature */ createSignature(network: FlareNetwork, message: Buffer, prv: Buffer): Buffer { - // Use secp256k1 directly since FlareJS may not expose KeyPair in the same way + // Use BitGo secp256k1 since FlareJS may not expose KeyPair in the same way try { - const signature = secp256k1.sign(message, prv); - return Buffer.from(signature.toCompactRawBytes()); + const signature = ecc.sign(message, prv); + return Buffer.from(signature); } catch (error) { throw new Error(`Failed to create signature: ${error}`); } @@ -197,7 +275,7 @@ export class Utils implements BaseUtils { */ verifySignature(network: FlareNetwork, message: Buffer, signature: Buffer, publicKey: Buffer): boolean { try { - return secp256k1.verify(signature, message, publicKey); + return ecc.verify(message, publicKey, signature); } catch (error) { return false; } @@ -221,7 +299,7 @@ export class Utils implements BaseUtils { } sha256(buf: Uint8Array): Buffer { - return createHash.default('sha256').update(buf).digest(); + return createHash('sha256').update(buf).digest(); } /** @@ -393,6 +471,69 @@ export class Utils implements BaseUtils { // Simple implementation - in practice this would use bech32 encoding return `${chainid}-${addressBuffer.toString('hex')}`; } + + /** + * Convert string to bytes for FlareJS memo + * Follows FlareJS utils.stringToBytes pattern + * @param {string} text - Text to convert + * @returns {Uint8Array} Byte array + */ + stringToBytes(text: string): Uint8Array { + return new TextEncoder().encode(text); + } + + /** + * Convert bytes to string from FlareJS memo + * @param {Uint8Array} bytes - Bytes to convert + * @returns {string} Decoded string + */ + bytesToString(bytes: Uint8Array): string { + return new TextDecoder().decode(bytes); + } + + /** + * Create memo bytes from various input formats + * Supports string, JSON object, or raw bytes + * @param {string | Record | Uint8Array} memo - Memo data + * @returns {Uint8Array} Memo bytes for FlareJS + */ + createMemoBytes(memo: string | Record | Uint8Array): Uint8Array { + if (memo instanceof Uint8Array) { + return memo; + } + + if (typeof memo === 'string') { + return this.stringToBytes(memo); + } + + if (typeof memo === 'object') { + return this.stringToBytes(JSON.stringify(memo)); + } + + throw new InvalidTransactionError('Invalid memo format'); + } + + /** + * Parse memo bytes to string + * @param {Uint8Array} memoBytes - Memo bytes from FlareJS transaction + * @returns {string} Decoded memo string + */ + parseMemoBytes(memoBytes: Uint8Array): string { + if (memoBytes.length === 0) { + return ''; + } + return this.bytesToString(memoBytes); + } + + /** + * Validate memo size (FlareJS has transaction size limits) + * @param {Uint8Array} memoBytes - Memo bytes + * @param {number} maxSize - Maximum size in bytes (default 4KB) + * @returns {boolean} Whether memo is within size limits + */ + validateMemoSize(memoBytes: Uint8Array, maxSize = 4096): boolean { + return memoBytes.length <= maxSize; + } } const utils = new Utils(); diff --git a/modules/sdk-coin-flrp/test/unit/lib/atomicTransactionBuilder.ts b/modules/sdk-coin-flrp/test/unit/lib/atomicTransactionBuilder.ts index 37ea464292..07c4be1a10 100644 --- a/modules/sdk-coin-flrp/test/unit/lib/atomicTransactionBuilder.ts +++ b/modules/sdk-coin-flrp/test/unit/lib/atomicTransactionBuilder.ts @@ -181,27 +181,59 @@ describe('AtomicTransactionBuilder', function () { }); describe('createInputOutput', function () { - it('should return placeholder structure', function () { - const result = builder.testCreateInputOutput(100n); + const sampleUtxos = [ + { + outputID: 7, + amount: '1000000', + txid: '1234567890abcdef1234567890abcdef12345678', + outputidx: '0', + threshold: 2, + addresses: ['P-test1234567890abcdef', 'P-test567890abcdef1234'], + }, + { + outputID: 7, + amount: '500000', + txid: 'abcdef1234567890abcdef1234567890abcdef12', + outputidx: '1', + threshold: 2, + addresses: ['P-test1234567890abcdef', 'P-test567890abcdef1234'], + }, + ]; + + it('should return empty structure when no UTXOs set', function () { + assert.throws(() => builder.testCreateInputOutput(100n), /UTXOs are required for creating inputs and outputs/); + }); + + it('should process UTXOs and return structured output', function () { + // Set UTXOs first + builder.utxos(sampleUtxos); + + const result = builder.testCreateInputOutput(100000n); assert.ok('inputs' in result); assert.ok('outputs' in result); assert.ok('credentials' in result); assert.ok(Array.isArray(result.inputs)); - assert.strictEqual(result.inputs.length, 0); assert.ok(Array.isArray(result.outputs)); - assert.strictEqual(result.outputs.length, 0); assert.ok(Array.isArray(result.credentials)); - assert.strictEqual(result.credentials.length, 0); + assert.strictEqual(result.credentials.length, 1); // Should create credential for first UTXO }); - it('should handle different amounts', function () { - const result1 = builder.testCreateInputOutput(1n); - const result2 = builder.testCreateInputOutput(BigInt('1000000000')); + it('should handle insufficient funds', function () { + builder.utxos(sampleUtxos); - // Both should return same placeholder structure regardless of amount - assert.deepStrictEqual(result1, result2); + // Request more than available (total available is 1,500,000) + assert.throws(() => builder.testCreateInputOutput(2000000n), /Insufficient funds: need 2000000, have 1500000/); + }); + + it('should use multiple UTXOs when needed', function () { + builder.utxos(sampleUtxos); + + // Request amount that requires both UTXOs + const result = builder.testCreateInputOutput(1200000n); + + assert.strictEqual(result.credentials.length, 2); // Should use both UTXOs }); }); diff --git a/modules/sdk-coin-flrp/test/unit/lib/transaction.ts b/modules/sdk-coin-flrp/test/unit/lib/transaction.ts new file mode 100644 index 0000000000..76422eb16c --- /dev/null +++ b/modules/sdk-coin-flrp/test/unit/lib/transaction.ts @@ -0,0 +1,509 @@ +import { coins } from '@bitgo/statics'; +import { TransactionType, InvalidTransactionError } from '@bitgo/sdk-core'; +import * as assert from 'assert'; +import { Transaction } from '../../../src/lib/transaction'; +import { KeyPair } from '../../../src/lib/keyPair'; + +// Mock transaction for testing +interface MockTx { + test: string; +} + +describe('FLRP Transaction', function () { + let transaction: Transaction; + const coinConfig = coins.get('tflrp'); + + // Helper to create a mock transaction for testing + const createMockTx = (): MockTx => ({ test: 'transaction' }); + + beforeEach(function () { + transaction = new Transaction(coinConfig); + }); + + describe('Constructor', function () { + it('should initialize with correct network configuration', function () { + assert.strictEqual(transaction._assetId, 'FLR'); + assert.strictEqual(transaction._blockchainID, '11111111111111111111111111111111LpoYY'); + assert.strictEqual(transaction._networkID, 114); + assert.strictEqual(transaction._threshold, 2); + assert.strictEqual(transaction._locktime, BigInt(0)); + }); + + it('should initialize empty arrays and default values', function () { + assert.deepStrictEqual(transaction._fromAddresses, []); + assert.deepStrictEqual(transaction._rewardAddresses, []); + assert.deepStrictEqual(transaction._utxos, []); + assert.deepStrictEqual(transaction._fee, {}); + }); + }); + + describe('Transaction Type Management', function () { + it('should set supported transaction types', function () { + const supportedTypes = [ + TransactionType.Export, + TransactionType.Import, + TransactionType.AddValidator, + TransactionType.AddDelegator, + TransactionType.AddPermissionlessValidator, + TransactionType.AddPermissionlessDelegator, + ]; + + supportedTypes.forEach((type) => { + assert.doesNotThrow(() => { + transaction.setTransactionType(type); + assert.strictEqual(transaction._type, type); + }); + }); + }); + + it('should throw error for unsupported transaction types', function () { + assert.throws(() => { + transaction.setTransactionType(TransactionType.Send); + }, /Transaction type .* is not supported/); + }); + }); + + describe('Getters', function () { + it('should return correct flareTransaction', function () { + const mockTx = createMockTx(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + transaction.setTransaction(mockTx as any); + assert.strictEqual(transaction.flareTransaction, mockTx); + }); + + it('should return empty signature array when no credentials', function () { + assert.deepStrictEqual(transaction.signature, []); + }); + + it('should return empty credentials array', function () { + assert.deepStrictEqual(transaction.credentials, []); + }); + + it('should return false for hasCredentials when no credentials', function () { + assert.strictEqual(transaction.hasCredentials, false); + }); + + it('should return placeholder ID when no transaction set', function () { + // This should throw error when no transaction is set + assert.throws(() => { + transaction.id; + }, InvalidTransactionError); + }); + + it('should return placeholder signablePayload when no transaction set', function () { + // This should throw error when no transaction is set + assert.throws(() => { + void transaction.signablePayload; + }, InvalidTransactionError); + }); + + it('should return formatted fromAddresses', function () { + transaction._fromAddresses = ['address1', 'address2']; + const addresses = transaction.fromAddresses; + assert.deepStrictEqual(addresses, ['address1', 'address2']); + }); + + it('should return formatted rewardAddresses', function () { + transaction._rewardAddresses = ['reward1', 'reward2']; + const addresses = transaction.rewardAddresses; + assert.deepStrictEqual(addresses, ['reward1', 'reward2']); + }); + }); + + describe('Transaction Outputs', function () { + beforeEach(function () { + transaction._nodeID = 'test-node-id'; + transaction._stakeAmount = BigInt('1000000000000000'); // 1M FLR + }); + + it('should return empty outputs for Export type', function () { + transaction.setTransactionType(TransactionType.Export); + const outputs = transaction.outputs; + assert.deepStrictEqual(outputs, []); + }); + + it('should return empty outputs for Import type', function () { + transaction.setTransactionType(TransactionType.Import); + const outputs = transaction.outputs; + assert.deepStrictEqual(outputs, []); + }); + + it('should return staking outputs for AddValidator type', function () { + transaction.setTransactionType(TransactionType.AddValidator); + const outputs = transaction.outputs; + assert.strictEqual(outputs.length, 1); + assert.strictEqual(outputs[0].address, 'test-node-id'); + assert.strictEqual(outputs[0].value, '1000000000000000'); + }); + + it('should return staking outputs for AddPermissionlessValidator type', function () { + transaction.setTransactionType(TransactionType.AddPermissionlessValidator); + const outputs = transaction.outputs; + assert.strictEqual(outputs.length, 1); + assert.strictEqual(outputs[0].address, 'test-node-id'); + assert.strictEqual(outputs[0].value, '1000000000000000'); + }); + + it('should return staking outputs for AddDelegator type', function () { + transaction.setTransactionType(TransactionType.AddDelegator); + const outputs = transaction.outputs; + assert.strictEqual(outputs.length, 1); + assert.strictEqual(outputs[0].address, 'test-node-id'); + assert.strictEqual(outputs[0].value, '1000000000000000'); + }); + + it('should return staking outputs for AddPermissionlessDelegator type', function () { + transaction.setTransactionType(TransactionType.AddPermissionlessDelegator); + const outputs = transaction.outputs; + assert.strictEqual(outputs.length, 1); + assert.strictEqual(outputs[0].address, 'test-node-id'); + assert.strictEqual(outputs[0].value, '1000000000000000'); + }); + + it('should return empty outputs for unknown type', function () { + // Don't set type, should default to empty + const outputs = transaction.outputs; + assert.deepStrictEqual(outputs, []); + }); + }); + + describe('Transaction Inputs', function () { + it('should return inputs from UTXOs', function () { + transaction._utxos = [ + { + outputID: 1, + amount: '1000000', + txid: 'test-txid-1', + outputidx: '0', + threshold: 2, + addresses: ['addr1', 'addr2'], + }, + { + outputID: 2, + amount: '2000000', + txid: 'test-txid-2', + outputidx: '1', + threshold: 2, + addresses: ['addr3', 'addr4'], + }, + ]; + transaction._fromAddresses = ['addr1', 'addr2', 'addr3', 'addr4']; + + const inputs = transaction.inputs; + assert.strictEqual(inputs.length, 2); + assert.strictEqual(inputs[0].id, 'test-txid-1:0'); + assert.strictEqual(inputs[0].value, '1000000'); + assert.strictEqual(inputs[0].address, 'addr1~addr2~addr3~addr4'); + assert.strictEqual(inputs[1].id, 'test-txid-2:1'); + assert.strictEqual(inputs[1].value, '2000000'); + }); + + it('should return empty inputs when no UTXOs', function () { + const inputs = transaction.inputs; + assert.deepStrictEqual(inputs, []); + }); + }); + + describe('Change Outputs', function () { + it('should return empty change outputs', function () { + const changeOutputs = transaction.changeOutputs; + assert.deepStrictEqual(changeOutputs, []); + }); + }); + + describe('Fee Handling', function () { + it('should return default fee when no fee set', function () { + const fee = transaction.fee; + assert.deepStrictEqual(fee, { fee: '0' }); + }); + + it('should return custom fee when set', function () { + transaction._fee = { fee: '1000000' }; + const fee = transaction.fee; + assert.deepStrictEqual(fee, { fee: '1000000' }); + }); + }); + + describe('Signing', function () { + let keyPair: KeyPair; + + beforeEach(function () { + // Create a test key pair with a private key + keyPair = new KeyPair({ prv: '01'.repeat(32) }); + }); + + it('should throw error when no private key', async function () { + const emptyKeyPair = new KeyPair(); + + await assert.rejects(async () => { + await transaction.sign(emptyKeyPair); + }, InvalidTransactionError); // Will throw InvalidTransactionError for empty transaction first + }); + + it('should throw error when no transaction to sign', async function () { + await assert.rejects(async () => { + await transaction.sign(keyPair); + }, InvalidTransactionError); + }); + + it('should throw error for FlareJS signing not implemented', async function () { + // Set a mock transaction and mock credentials to pass validation + const mockTx = createMockTx(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + transaction.setTransaction(mockTx as any); + + // Mock hasCredentials to return true + Object.defineProperty(transaction, 'hasCredentials', { + get: () => true, + }); + + await assert.rejects(async () => { + await transaction.sign(keyPair); + }, /FlareJS signing not yet implemented/); + }); + }); + + describe('Serialization', function () { + it('should throw error for toBroadcastFormat when no transaction', function () { + assert.throws(() => { + transaction.toBroadcastFormat(); + }, InvalidTransactionError); + }); + + it('should return placeholder for toBroadcastFormat when transaction set', function () { + const mockTx = createMockTx(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + transaction.setTransaction(mockTx as any); + const broadcastFormat = transaction.toBroadcastFormat(); + assert.strictEqual(broadcastFormat, 'flare-tx-hex-placeholder'); + }); + + it('should throw error for toJson when no transaction', function () { + assert.throws(() => { + transaction.toJson(); + }, InvalidTransactionError); + }); + + it('should return transaction data for toJson when transaction set', function () { + const mockTx = createMockTx(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + transaction.setTransaction(mockTx as any); + transaction._fromAddresses = ['addr1']; + transaction._threshold = 2; + transaction._locktime = BigInt(100); + + const jsonData = transaction.toJson(); + assert.strictEqual(jsonData.id, 'flare-transaction-id-placeholder'); + assert.deepStrictEqual(jsonData.fromAddresses, ['addr1']); + assert.strictEqual(jsonData.threshold, 2); + assert.strictEqual(jsonData.locktime, '100'); + }); + }); + + describe('Cross-chain Properties', function () { + it('should identify Export as cross-chain transaction', function () { + transaction.setTransactionType(TransactionType.Export); + assert.strictEqual(transaction.isTransactionForCChain, true); + }); + + it('should identify Import as cross-chain transaction', function () { + transaction.setTransactionType(TransactionType.Import); + assert.strictEqual(transaction.isTransactionForCChain, true); + }); + + it('should identify AddValidator as non-cross-chain transaction', function () { + transaction.setTransactionType(TransactionType.AddValidator); + assert.strictEqual(transaction.isTransactionForCChain, false); + }); + }); + + describe('Transaction Explanation', function () { + beforeEach(function () { + const mockTx = createMockTx(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + transaction.setTransaction(mockTx as any); + transaction._fromAddresses = ['test-address']; + transaction._fee = { fee: '1000000' }; + }); + + it('should explain a basic transaction', function () { + transaction.setTransactionType(TransactionType.Export); + + const explanation = transaction.explainTransaction(); + assert.ok(explanation.displayOrder); + assert.ok(Array.isArray(explanation.displayOrder)); + assert.strictEqual(explanation.id, 'flare-transaction-id-placeholder'); + assert.deepStrictEqual(explanation.fee, { fee: '1000000' }); + assert.strictEqual(explanation.type, TransactionType.Export); + }); + + it('should include reward addresses for staking transactions', function () { + transaction.setTransactionType(TransactionType.AddValidator); + transaction._rewardAddresses = ['reward-addr-1']; + + const explanation = transaction.explainTransaction(); + assert.ok(explanation.displayOrder); + assert.ok(explanation.displayOrder.includes('rewardAddresses')); + assert.deepStrictEqual(explanation.rewardAddresses, ['reward-addr-1']); + }); + + it('should include cross-chain information for export/import', function () { + transaction.setTransactionType(TransactionType.Export); + + const explanation = transaction.explainTransaction(); + assert.ok(explanation.displayOrder); + assert.ok(explanation.displayOrder.includes('sourceChain')); + assert.ok(explanation.displayOrder.includes('destinationChain')); + }); + + it('should calculate output amounts correctly', function () { + transaction._nodeID = 'test-node'; + transaction._stakeAmount = BigInt('5000000000000000'); + transaction.setTransactionType(TransactionType.AddValidator); + + const explanation = transaction.explainTransaction(); + assert.strictEqual(explanation.outputAmount, '5000000000000000'); + assert.strictEqual(explanation.changeAmount, '0'); + }); + }); + + describe('Signature Creation', function () { + it('should create signature placeholder', function () { + const mockPrivateKey = Buffer.from('1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', 'hex'); + const mockTx = createMockTx(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + transaction.setTransaction(mockTx as any); + + const signature = transaction.createSignature(mockPrivateKey); + assert.ok(typeof signature === 'string'); + assert.ok(signature.length > 0); + }); + }); + + describe('Validation', function () { + it('should allow signing with valid key', function () { + const keyPair = new KeyPair({ prv: '01'.repeat(32) }); + + const canSign = transaction.canSign({ key: keyPair }); + assert.strictEqual(canSign, true); + }); + }); + + describe('Transaction Setting', function () { + it('should set transaction correctly', function () { + const mockTx = createMockTx(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + transaction.setTransaction(mockTx as any); + // Can't directly access _flareTransaction (protected), but can test via getter + assert.strictEqual(transaction.flareTransaction, mockTx); + }); + }); + + describe('Hex Conversion', function () { + it('should convert byte array to hex string', function () { + const byteArray = new Uint8Array([0x01, 0x23, 0xab, 0xcd]); + const hexString = transaction.toHexString(byteArray); + assert.strictEqual(hexString, '0123abcd'); + }); + + it('should handle empty byte array', function () { + const byteArray = new Uint8Array([]); + const hexString = transaction.toHexString(byteArray); + assert.strictEqual(hexString, ''); + }); + }); + + describe('Memo Functionality', function () { + it('should initialize with empty memo', function () { + assert.strictEqual(transaction.hasMemo(), false); + assert.strictEqual(transaction.getMemoString(), ''); + assert.deepStrictEqual(transaction.getMemoBytes(), new Uint8Array()); + }); + + it('should set memo from string', function () { + const memoText = 'Test transaction memo'; + transaction.setMemo(memoText); + + assert.strictEqual(transaction.hasMemo(), true); + assert.strictEqual(transaction.getMemoString(), memoText); + assert.deepStrictEqual(transaction.getMemoBytes(), new TextEncoder().encode(memoText)); + }); + + it('should set memo from JSON object', function () { + const memoObj = { user: 'alice', amount: 1000 }; + transaction.setMemoData(memoObj); + + assert.strictEqual(transaction.hasMemo(), true); + assert.strictEqual(transaction.getMemoString(), JSON.stringify(memoObj)); + }); + + it('should set memo from bytes', function () { + const memoBytes = new TextEncoder().encode('Binary memo data'); + transaction.setMemoData(memoBytes); + + assert.strictEqual(transaction.hasMemo(), true); + assert.deepStrictEqual(transaction.getMemoBytes(), memoBytes); + assert.strictEqual(transaction.getMemoString(), 'Binary memo data'); + }); + + it('should handle empty string memo', function () { + transaction.setMemo(''); + assert.strictEqual(transaction.hasMemo(), false); + assert.strictEqual(transaction.getMemoString(), ''); + }); + + it('should handle UTF-8 memo', function () { + const utf8Memo = 'Hello δΈ–η•Œ 🌍 Flare!'; + transaction.setMemo(utf8Memo); + + assert.strictEqual(transaction.hasMemo(), true); + assert.strictEqual(transaction.getMemoString(), utf8Memo); + }); + + it('should include memo in transaction JSON when present', function () { + const memoText = 'Transaction metadata'; + transaction.setMemo(memoText); + + // Mock the FlareJS transaction + const mockTx = createMockTx(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + transaction.setTransaction(mockTx as any); + transaction.setTransactionType(TransactionType.Export); + + const txData = transaction.toJson(); + assert.strictEqual(txData.memo, memoText); + }); + + it('should not include memo in JSON when empty', function () { + // Mock the FlareJS transaction + const mockTx = createMockTx(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + transaction.setTransaction(mockTx as any); + transaction.setTransactionType(TransactionType.Export); + + const txData = transaction.toJson(); + assert.strictEqual(txData.memo, ''); + }); + + it('should validate memo size limits', function () { + // Test large memo - this should be validated by utils + const largeMemo = 'x'.repeat(5000); // 5KB memo + transaction.setMemo(largeMemo); + assert.strictEqual(transaction.getMemoString(), largeMemo); + }); + + it('should handle special characters in memo', function () { + const specialMemo = 'Special chars: \n\t\r\0\x01\xff'; + transaction.setMemo(specialMemo); + assert.strictEqual(transaction.getMemoString(), specialMemo); + }); + + it('should throw error for invalid memo format in setMemoData', function () { + // This should be tested in utils, but we can check basic behavior + assert.throws(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + transaction.setMemoData(123 as any); + }); + }); + }); +}); diff --git a/modules/sdk-coin-flrp/test/unit/lib/utils.ts b/modules/sdk-coin-flrp/test/unit/lib/utils.ts index 0c457af39c..2aa26540b4 100644 --- a/modules/sdk-coin-flrp/test/unit/lib/utils.ts +++ b/modules/sdk-coin-flrp/test/unit/lib/utils.ts @@ -243,4 +243,75 @@ describe('Utils', function () { assert.ok(constants.HEX_REGEX instanceof RegExp); }); }); + + describe('Memo Utilities', function () { + it('should convert string to bytes', function () { + const text = 'Hello Flare'; + const bytes = utils.stringToBytes(text); + + assert.ok(bytes instanceof Uint8Array); + assert.strictEqual(utils.bytesToString(bytes), text); + }); + + it('should handle UTF-8 strings', function () { + const text = 'Hello δΈ–η•Œ 🌍'; + const bytes = utils.stringToBytes(text); + + assert.strictEqual(utils.bytesToString(bytes), text); + }); + + it('should create memo bytes from string', function () { + const text = 'Memo text'; + const bytes = utils.createMemoBytes(text); + + assert.ok(bytes instanceof Uint8Array); + assert.strictEqual(utils.parseMemoBytes(bytes), text); + }); + + it('should create memo bytes from JSON object', function () { + const obj = { type: 'payment', amount: 1000 }; + const bytes = utils.createMemoBytes(obj); + const parsed = utils.parseMemoBytes(bytes); + + assert.strictEqual(parsed, JSON.stringify(obj)); + }); + + it('should handle Uint8Array input', function () { + const originalBytes = new Uint8Array([1, 2, 3, 4]); + const bytes = utils.createMemoBytes(originalBytes); + + assert.deepStrictEqual(bytes, originalBytes); + }); + + it('should throw error for invalid memo type', function () { + assert.throws(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + utils.createMemoBytes(123 as any); + }, /Invalid memo format/); + }); + + it('should parse empty memo', function () { + const emptyBytes = new Uint8Array([]); + const parsed = utils.parseMemoBytes(emptyBytes); + + assert.strictEqual(parsed, ''); + }); + + it('should validate memo size', function () { + const smallMemo = new Uint8Array([1, 2, 3]); + const largeMemo = new Uint8Array(5000); + + assert.strictEqual(utils.validateMemoSize(smallMemo), true); + assert.strictEqual(utils.validateMemoSize(largeMemo), false); + assert.strictEqual(utils.validateMemoSize(largeMemo, 6000), true); + }); + + it('should handle special characters in memo', function () { + const specialText = 'Special: \n\t\r\0'; + const bytes = utils.createMemoBytes(specialText); + const parsed = utils.parseMemoBytes(bytes); + + assert.strictEqual(parsed, specialText); + }); + }); }); diff --git a/yarn.lock b/yarn.lock index a3a0231429..fd605f93e0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5756,7 +5756,7 @@ resolved "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz" integrity sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw== -"@types/bn.js@*", "@types/bn.js@^5.1.0", "@types/bn.js@^5.1.6", "@types/bn.js@^5.2.0": +"@types/bn.js@*", "@types/bn.js@^5.1.0", "@types/bn.js@^5.1.6": version "5.2.0" resolved "https://registry.npmjs.org/@types/bn.js/-/bn.js-5.2.0.tgz#4349b9710e98f9ab3cdc50f1c5e4dcbd8ef29c80" integrity sha512-DLbJ1BPqxvQhIGbeu8VbUC1DiAiahHtAYvA0ZEAa4P31F7IaArc8z3C3BRQdWX4mtLQuABG4yzp76ZrS02Ui1Q== @@ -7707,7 +7707,7 @@ bignumber.js@4.1.0, bignumber.js@^4.0.0: resolved "https://registry.npmjs.org/bignumber.js/-/bignumber.js-4.1.0.tgz#db6f14067c140bd46624815a7916c92d9b6c24b1" integrity sha512-eJzYkFYy9L4JzXsbymsFn3p54D+llV27oTQ+ziJG7WFRheJcNZilgVXMG0LoZtlQSKBsJdWtLFqOD0u+U0jZKA== -bignumber.js@9.1.2, bignumber.js@^9.0.0, bignumber.js@^9.0.1, bignumber.js@^9.0.2, bignumber.js@^9.1.1, bignumber.js@^9.1.2: +bignumber.js@9.0.0, bignumber.js@9.1.2, bignumber.js@^9.0.0, bignumber.js@^9.0.1, bignumber.js@^9.0.2, bignumber.js@^9.1.1, bignumber.js@^9.1.2: version "9.1.2" resolved "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.1.2.tgz#b7c4242259c008903b13707983b5f4bbd31eda0c" integrity sha512-2/mKyZH9K85bzOEfhXDBFZTGd1CTs+5IHpeFQo9luiBG7hghdC851Pj2WAhb6E3R6b9tZj/XKhbg4fum+Kepug==