diff --git a/modules/sdk-coin-flrp/package.json b/modules/sdk-coin-flrp/package.json index 40a7ac6bf6..fe61039fea 100644 --- a/modules/sdk-coin-flrp/package.json +++ b/modules/sdk-coin-flrp/package.json @@ -42,6 +42,10 @@ ".ts" ] }, + "devDependencies": { + "@bitgo/sdk-api": "^1.71.8", + "@bitgo/sdk-test": "^9.1.16" + }, "dependencies": { "@bitgo/sdk-core": "^36.23.0", "@bitgo/secp256k1": "^1.7.0", @@ -51,6 +55,7 @@ "bignumber.js": "9.0.0", "bs58": "^6.0.0", "create-hash": "^1.2.0", + "ethereumjs-util": "^7.1.5", "safe-buffer": "^5.2.1" }, "gitHead": "18e460ddf02de2dbf13c2aa243478188fb539f0c", diff --git a/modules/sdk-coin-flrp/src/flrp.ts b/modules/sdk-coin-flrp/src/flrp.ts index 408254ad96..653608558e 100644 --- a/modules/sdk-coin-flrp/src/flrp.ts +++ b/modules/sdk-coin-flrp/src/flrp.ts @@ -1,4 +1,4 @@ -import { BaseCoin as StaticsBaseCoin, CoinFamily } from '@bitgo/statics'; +import { BaseCoin as StaticsBaseCoin, CoinFamily, coins, FlareNetwork } from '@bitgo/statics'; import { AuditDecryptedKeyParams, BaseCoin, @@ -9,11 +9,27 @@ import { ParsedTransaction, ParseTransactionOptions, SignedTransaction, - SignTransactionOptions, - TssVerifyAddressOptions, VerifyAddressOptions, - VerifyTransactionOptions, + TransactionType, + ITransactionRecipient, + InvalidAddressError, + UnexpectedAddressError, + InvalidTransactionError, + BaseTransaction, + SigningError, + MethodNotImplementedError, } from '@bitgo/sdk-core'; +import * as FlrpLib from './lib'; +import { + FlrpEntry, + FlrpExplainTransactionOptions, + FlrpSignTransactionOptions, + FlrpTransactionParams, + FlrpVerifyTransactionOptions, +} from './lib/iface'; +import utils from './lib/utils'; +import BigNumber from 'bignumber.js'; +import { isValidAddress as isValidEthAddress } from 'ethereumjs-util'; export class Flrp extends BaseCoin { protected readonly _staticsCoin: Readonly; @@ -50,28 +66,280 @@ export class Flrp extends BaseCoin { return multisigTypes.onchain; } - verifyTransaction(params: VerifyTransactionOptions): Promise { - throw new Error('Method not implemented.'); + 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: ${error.message}`); + } + const explainedTx = tx.explainTransaction(); + + const type = params.txParams.type; + + if (!type || (type !== 'ImportToC' && explainedTx.type !== TransactionType[type])) { + throw new Error('Tx type does not match with expected txParams type'); + } + + switch (explainedTx.type) { + 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; } - isWalletAddress(params: VerifyAddressOptions | TssVerifyAddressOptions): Promise { - throw new Error('Method not implemented.'); + + /** + * 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; + 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}`); + } } - parseTransaction(params: ParseTransactionOptions): Promise { - throw new Error('Method not implemented.'); + + /** + * Check if import txn into P is valid, based on expected tx params. + * + * @param {FlrpEntry[]} explainedTxInputs tx inputs (unspents to be imported) + * @param {FlrpTransactionParams} txParams expected tx info to check against + */ + validateImportTx(explainedTxInputs: 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}`); + } + } + } + } + + private getBuilder(): FlrpLib.TransactionBuilderFactory { + return new FlrpLib.TransactionBuilderFactory(coins.get(this.getChain())); + } + + /** + * 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; } + + /** + * 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()); + } + + /** + * 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 { - throw new Error('Method not implemented.'); + const keyPair = seed ? new FlrpLib.KeyPair({ seed }) : new FlrpLib.KeyPair(); + const keys = keyPair.getKeys(); + + if (!keys.prv) { + throw new Error('Missing prv in key generation.'); + } + + return { + pub: keys.pub, + prv: keys.prv, + }; } + + /** + * 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 { - throw new Error('Method not implemented.'); + try { + new FlrpLib.KeyPair({ pub }); + return true; + } catch (e) { + return false; + } + } + + /** + * 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; + } + } + + isValidAddress(address: string | string[]): boolean { + if (address === undefined) { + return false; + } + + // validate eth address for cross-chain txs to c-chain + if (typeof address === 'string' && isValidEthAddress(address)) { + return true; + } + + return FlrpLib.Utils.isValidAddress(address); } - isValidAddress(address: string): boolean { - throw new Error('Method not implemented.'); + + /** + * Signs Avaxp 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() } }; + } + + async parseTransaction(params: ParseTransactionOptions): Promise { + return {}; + } + + /** + * Explain a Avaxp transaction from txHex + * @param params + * @param callback + */ + async explainTransaction(params: FlrpExplainTransactionOptions): 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}`); + } + } + + recoverySignature(message: Buffer, signature: Buffer): Buffer { + return FlrpLib.Utils.recoverySignature(this._staticsCoin.network as FlareNetwork, message, signature); } - signTransaction(params: SignTransactionOptions): Promise { - throw new Error('Method not implemented.'); + + 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); } + + /** @inheritDoc */ auditDecryptedKey(params: AuditDecryptedKeyParams): void { - throw new Error('Method not implemented.'); + throw new MethodNotImplementedError(); } } diff --git a/modules/sdk-coin-flrp/src/lib/ExportInCTxBuilder.ts b/modules/sdk-coin-flrp/src/lib/ExportInCTxBuilder.ts index bbec4940a3..05c1081df2 100644 --- a/modules/sdk-coin-flrp/src/lib/ExportInCTxBuilder.ts +++ b/modules/sdk-coin-flrp/src/lib/ExportInCTxBuilder.ts @@ -89,7 +89,9 @@ export class ExportInCTxBuilder extends AtomicInCTransactionBuilder { } const output = outputs[0]; - // TODO validate assetId + if (Buffer.from(output.assetId.toBytes()).toString('hex') !== this.transaction._assetId) { + throw new BuildTransactionError('AssetID mismatch'); + } // The inputs is not an utxo. // It's expected to have only one input from C-Chain address. diff --git a/modules/sdk-coin-flrp/src/lib/atomicTransactionBuilder.ts b/modules/sdk-coin-flrp/src/lib/atomicTransactionBuilder.ts index 470206f1eb..8f69a14058 100644 --- a/modules/sdk-coin-flrp/src/lib/atomicTransactionBuilder.ts +++ b/modules/sdk-coin-flrp/src/lib/atomicTransactionBuilder.ts @@ -166,7 +166,7 @@ export abstract class AtomicTransactionBuilder extends TransactionBuilder { } /** - * Builds the avax transaction. transaction field is changed. + * Builds the Flare transaction. Transaction field is changed. */ protected abstract buildFlareTransaction(): void; diff --git a/modules/sdk-coin-flrp/src/lib/iface.ts b/modules/sdk-coin-flrp/src/lib/iface.ts index 6a4c42c492..fe04b6bfc8 100644 --- a/modules/sdk-coin-flrp/src/lib/iface.ts +++ b/modules/sdk-coin-flrp/src/lib/iface.ts @@ -1,4 +1,13 @@ -import { TransactionExplanation as BaseTransactionExplanation, Entry, TransactionType } from '@bitgo/sdk-core'; +import { + TransactionExplanation as BaseTransactionExplanation, + Entry, + SignTransactionOptions, + TransactionParams, + TransactionPrebuild as BaseTransactionPrebuild, + TransactionType, + VerifyTransactionOptions, + TransactionRecipient, +} from '@bitgo/sdk-core'; import { pvmSerial, UnsignedTx, TransferableOutput, evmSerial } from '@flarenetwork/flarejs'; /** @@ -11,24 +20,12 @@ export enum FlareTransactionType { PvmImportTx = 'pvm.ImportTx', } -export interface FlrpEntry extends Entry { - id: string; -} - export interface TransactionExplanation extends BaseTransactionExplanation { type: TransactionType; rewardAddresses: string[]; inputs: Entry[]; } -/** - * Method names for the transaction method. Names change based on the type of transaction - */ -export enum MethodNames { - addPermissionlessValidator, - addPermissionlessDelegator, -} - /** * The transaction data returned from the toJson() function of a transaction */ @@ -72,13 +69,7 @@ export type DecodedUtxoObj = { */ export const SECP256K1_Transfer_Output = 7; -/** - * TypeId value for Stakeable Lock Output - */ -export const SECP256K1_STAKEABLE_LOCK_OUT = 22; - export const ADDRESS_SEPARATOR = '~'; -export const INPUT_SEPARATOR = ':'; // Simplified type definitions for Flare export type Tx = @@ -88,50 +79,47 @@ export type Tx = | evmSerial.ImportTx | pvmSerial.ExportTx | pvmSerial.ImportTx; + +export type SerializedTx = evmSerial.ExportTx | evmSerial.ImportTx | pvmSerial.ExportTx | pvmSerial.ImportTx; export type BaseTx = pvmSerial.BaseTx; export type Output = TransferableOutput; -export type DeprecatedTx = unknown; -/** - * Interface for staking options - */ -export interface FlrpTransactionStakingOptions { - nodeID: string; - startTime: string; - endTime: string; - amount: string; - rewardAddress?: string; - delegationFee?: number; +export interface FlrpVerifyTransactionOptions extends VerifyTransactionOptions { + txParams: FlrpTransactionParams; } -/** - * Interface for transaction parameters - */ -export interface FlrpTransactionParams { - recipients?: { - address: string; - amount: string; - }[]; - stakingOptions?: FlrpTransactionStakingOptions; +export interface FlrpTransactionParams extends TransactionParams { + type: string; + locktime?: number; unspents?: string[]; - type?: string; + sourceChain?: string; } -/** - * Interface for transaction verification options - */ -export interface FlrpVerifyTransactionOptions { - txPrebuild: { - txHex: string; - }; - txParams: FlrpTransactionParams; +export interface FlrpEntry extends Entry { + id: string; } -/** - * Interface for explaining transaction options - */ -export interface ExplainTransactionOptions { +export interface FlrpSignTransactionOptions extends SignTransactionOptions { + txPrebuild: TransactionPrebuild; + prv: string | string[]; + pubKeys?: string[]; +} + +export interface TransactionPrebuild extends BaseTransactionPrebuild { + txHex: string; + txInfo: TxInfo; + source: string; +} + +export interface TxInfo { + recipients: TransactionRecipient[]; + from: string; + txid: string; +} + +export interface FlrpExplainTransactionOptions { txHex?: string; halfSigned?: { txHex: string; }; + publicKeys?: string[]; } diff --git a/modules/sdk-coin-flrp/src/lib/permissionlessValidatorTxBuilder.ts b/modules/sdk-coin-flrp/src/lib/permissionlessValidatorTxBuilder.ts index 1174d35914..ab0a4609c3 100644 --- a/modules/sdk-coin-flrp/src/lib/permissionlessValidatorTxBuilder.ts +++ b/modules/sdk-coin-flrp/src/lib/permissionlessValidatorTxBuilder.ts @@ -2,20 +2,17 @@ import { utils as FlareUtils, TypeSymbols } from '@flarenetwork/flarejs'; import { BuildTransactionError, isValidBLSPublicKey, isValidBLSSignature, TransactionType } from '@bitgo/sdk-core'; import { BaseCoin as CoinConfig } from '@bitgo/statics'; import { DecodedUtxoObj } from './iface'; -import { KeyPair } from './keyPair'; import { Transaction } from './transaction'; import { TransactionBuilder } from './transactionBuilder'; import utils from './utils'; export class PermissionlessValidatorTxBuilder extends TransactionBuilder { - public _signer: KeyPair[] = []; protected _nodeID: string; protected _blsPublicKey: string; protected _blsSignature: string; protected _startTime: bigint; protected _endTime: bigint; protected _stakeAmount: bigint; - protected recoverSigner = false; protected _delegationFeeRate: number; constructor(coinConfig: Readonly) { diff --git a/modules/sdk-coin-flrp/src/lib/transaction.ts b/modules/sdk-coin-flrp/src/lib/transaction.ts index 04098649bd..b34612b2eb 100644 --- a/modules/sdk-coin-flrp/src/lib/transaction.ts +++ b/modules/sdk-coin-flrp/src/lib/transaction.ts @@ -12,6 +12,7 @@ import { utils as FlareUtils, Credential, pvmSerial, + evmSerial, UnsignedTx, secp256k1, EVMUnsignedTx, @@ -19,7 +20,7 @@ import { } from '@flarenetwork/flarejs'; import { Buffer } from 'buffer'; import { createHash } from 'crypto'; -import { DecodedUtxoObj, TransactionExplanation, Tx, TxData } from './iface'; +import { DecodedUtxoObj, TransactionExplanation, Tx, TxData, ADDRESS_SEPARATOR, FlareTransactionType } from './iface'; import { KeyPair } from './keyPair'; import utils from './utils'; @@ -288,28 +289,132 @@ export class Transaction extends BaseTransaction { return { fee: '0', ...this._fee }; } + /** + * Check if this transaction is for C-chain (EVM transactions) + */ + get isTransactionForCChain(): boolean { + const tx = (this._flareTransaction as UnsignedTx).getTx(); + const txType = (tx as { _type?: string })._type; + return txType === FlareTransactionType.EvmExportTx || txType === FlareTransactionType.EvmImportTx; + } + get outputs(): Entry[] { + const tx = (this._flareTransaction as UnsignedTx).getTx(); + switch (this.type) { + case TransactionType.Import: + if (this.isTransactionForCChain) { + // C-chain Import: output is to a C-chain address + const importTx = tx as evmSerial.ImportTx; + return importTx.Outs.map((out) => ({ + address: '0x' + Buffer.from(out.address.toBytes()).toString('hex'), + value: out.amount.value().toString(), + })); + } else { + // P-chain Import: outputs are the baseTx.outputs (destination on P-chain) + const pvmImportTx = tx as pvmSerial.ImportTx; + return pvmImportTx.baseTx.outputs.map(utils.mapOutputToEntry(this._network)); + } + + case TransactionType.Export: + if (this.isTransactionForCChain) { + // C-chain Export: exported outputs go to P-chain + const exportTx = tx as evmSerial.ExportTx; + return exportTx.exportedOutputs.map(utils.mapOutputToEntry(this._network)); + } else { + // P-chain Export: exported outputs go to C-chain + const pvmExportTx = tx as pvmSerial.ExportTx; + return pvmExportTx.outs.map(utils.mapOutputToEntry(this._network)); + } + case TransactionType.AddPermissionlessValidator: return [ { - address: ( - (this._flareTransaction as UnsignedTx).getTx() as pvmSerial.AddPermissionlessValidatorTx - ).subnetValidator.validator.nodeId.toString(), - value: ( - (this._flareTransaction as UnsignedTx).getTx() as pvmSerial.AddPermissionlessValidatorTx - ).subnetValidator.validator.weight.toJSON(), + address: (tx as pvmSerial.AddPermissionlessValidatorTx).subnetValidator.validator.nodeId.toString(), + value: (tx as pvmSerial.AddPermissionlessValidatorTx).subnetValidator.validator.weight.toJSON(), }, ]; + default: return []; } } get changeOutputs(): Entry[] { - return ( - (this._flareTransaction as UnsignedTx).getTx() as pvmSerial.AddPermissionlessValidatorTx - ).baseTx.outputs.map(utils.mapOutputToEntry(this._network)); + const tx = (this._flareTransaction as UnsignedTx).getTx(); + + // C-chain transactions and Import transactions don't have change outputs + if (this.isTransactionForCChain || this.type === TransactionType.Import) { + return []; + } + + switch (this.type) { + case TransactionType.Export: + // P-chain Export: change outputs are in baseTx.outputs + return (tx as pvmSerial.ExportTx).baseTx.outputs.map(utils.mapOutputToEntry(this._network)); + + case TransactionType.AddPermissionlessValidator: + return (tx as pvmSerial.AddPermissionlessValidatorTx).baseTx.outputs.map(utils.mapOutputToEntry(this._network)); + + default: + return []; + } + } + + get inputs(): Entry[] { + const tx = (this._flareTransaction as UnsignedTx).getTx(); + + switch (this.type) { + case TransactionType.Import: + if (this.isTransactionForCChain) { + // C-chain Import: inputs come from P-chain (importedInputs) + const importTx = tx as evmSerial.ImportTx; + return importTx.importedInputs.map((input) => ({ + id: utils.cb58Encode(Buffer.from(input.utxoID.txID.toBytes())) + ':' + input.utxoID.outputIdx.value(), + address: this.fromAddresses.sort().join(ADDRESS_SEPARATOR), + value: input.amount().toString(), + })); + } else { + // P-chain Import: inputs are ins (imported from C-chain) + const pvmImportTx = tx as pvmSerial.ImportTx; + return pvmImportTx.ins.map((input) => ({ + id: utils.cb58Encode(Buffer.from(input.utxoID.txID.toBytes())) + ':' + input.utxoID.outputIdx.value(), + address: this.fromAddresses.sort().join(ADDRESS_SEPARATOR), + value: input.amount().toString(), + })); + } + + case TransactionType.Export: + if (this.isTransactionForCChain) { + // C-chain Export: inputs are from C-chain (EVM inputs) + const exportTx = tx as evmSerial.ExportTx; + return exportTx.ins.map((evmInput) => ({ + address: '0x' + Buffer.from(evmInput.address.toBytes()).toString('hex'), + value: evmInput.amount.value().toString(), + nonce: Number(evmInput.nonce.value()), + })); + } else { + // P-chain Export: inputs are from baseTx.inputs + const pvmExportTx = tx as pvmSerial.ExportTx; + return pvmExportTx.baseTx.inputs.map((input) => ({ + id: utils.cb58Encode(Buffer.from(input.utxoID.txID.toBytes())) + ':' + input.utxoID.outputIdx.value(), + address: this.fromAddresses.sort().join(ADDRESS_SEPARATOR), + value: input.amount().toString(), + })); + } + + case TransactionType.AddPermissionlessValidator: + default: + const baseTx = tx as pvmSerial.AddPermissionlessValidatorTx; + if (baseTx.baseTx?.inputs) { + return baseTx.baseTx.inputs.map((input) => ({ + id: utils.cb58Encode(Buffer.from(input.utxoID.txID.toBytes())) + ':' + input.utxoID.outputIdx.value(), + address: this.fromAddresses.sort().join(ADDRESS_SEPARATOR), + value: input.amount().toString(), + })); + } + return []; + } } explainTransaction(): TransactionExplanation { diff --git a/modules/sdk-coin-flrp/src/lib/transactionBuilderFactory.ts b/modules/sdk-coin-flrp/src/lib/transactionBuilderFactory.ts index 86dd56b6e1..df6640d3d5 100644 --- a/modules/sdk-coin-flrp/src/lib/transactionBuilderFactory.ts +++ b/modules/sdk-coin-flrp/src/lib/transactionBuilderFactory.ts @@ -1,16 +1,19 @@ import { utils as FlareUtils, evmSerial, pvmSerial, Credential } from '@flarenetwork/flarejs'; import { BaseTransactionBuilderFactory, NotSupported } from '@bitgo/sdk-core'; -import { FlareNetwork, BaseCoin as CoinConfig } from '@bitgo/statics'; +import { BaseCoin as CoinConfig } from '@bitgo/statics'; import { TransactionBuilder } from './transactionBuilder'; import { ExportInPTxBuilder } from './ExportInPTxBuilder'; import { ImportInPTxBuilder } from './ImportInPTxBuilder'; import { ExportInCTxBuilder } from './ExportInCTxBuilder'; import { ImportInCTxBuilder } from './ImportInCTxBuilder'; +import { SerializedTx } from './iface'; import utils from './utils'; -export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { - protected recoverSigner = false; +interface Codec { + UnpackPrefix(bytes: Uint8Array): [T, Uint8Array]; +} +export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { constructor(_coinConfig: Readonly) { super(_coinConfig); } @@ -22,7 +25,7 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { * @param codec The FlareJS codec to use for unpacking * @returns Array of parsed credentials */ - private extractCredentialsWithCodec(credentialBytes: Uint8Array, codec: any): Credential[] { + private extractCredentialsWithCodec(credentialBytes: Uint8Array, codec: Codec): Credential[] { const credentials: Credential[] = []; if (credentialBytes.length < 4) { return credentials; @@ -35,10 +38,10 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { do { try { - const unpacked = codec.UnpackPrefix(remainingBytes); - credentials.push(unpacked[0] as Credential); - remainingBytes = unpacked[1] as Uint8Array; - } catch (e) { + const [credential, rest] = codec.UnpackPrefix(remainingBytes); + credentials.push(credential); + remainingBytes = rest; + } catch { moreCredentials = false; } } while (remainingBytes.length > 0 && moreCredentials); @@ -46,81 +49,89 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { return credentials; } - /** @inheritdoc */ - from(raw: string): TransactionBuilder { - utils.validateRawTransaction(raw); - const rawNoHex = utils.removeHexPrefix(raw); - const rawBuffer = Buffer.from(rawNoHex, 'hex'); - let txSource: 'EVM' | 'PVM'; - - const network = this._coinConfig.network as FlareNetwork; - let tx: any; - let credentials: Credential[] = []; - + /** + * Parse a raw transaction buffer using the specified VM manager. + * @param rawBuffer The raw transaction buffer + * @param vmType The VM type to use for parsing ('EVM' or 'PVM') + * @returns Parsed transaction and credentials, or null if parsing fails + */ + private parseWithVM( + rawBuffer: Buffer, + vmType: 'EVM' | 'PVM' + ): { tx: SerializedTx; credentials: Credential[] } | null { try { - txSource = 'EVM'; - const evmManager = FlareUtils.getManagerForVM('EVM'); - - // Use getCodecFromBuffer to get both codec and remaining bytes - const [codec, txBytes] = evmManager.getCodecFromBuffer(rawBuffer); - // UnpackPrefix returns [transaction, remainingBytes] - const [transaction, credentialBytes] = codec.UnpackPrefix(txBytes) as [any, Uint8Array]; - tx = transaction; - - // Extract credentials from remaining bytes using codec - if (credentialBytes.length > 4) { - credentials = this.extractCredentialsWithCodec(credentialBytes, codec); - } + const manager = FlareUtils.getManagerForVM(vmType); + const [codec, txBytes] = manager.getCodecFromBuffer(rawBuffer); + const [tx, credentialBytes] = (codec as Codec).UnpackPrefix(txBytes); - const blockchainId = tx.getBlockchainId(); - if (blockchainId === network.cChainBlockchainID) { - console.log('Parsed as EVM transaction on C-Chain'); - } - } catch (e) { - txSource = 'PVM'; - const pvmManager = FlareUtils.getManagerForVM('PVM'); - - // Use getCodecFromBuffer to get both codec and remaining bytes - const [codec, txBytes] = pvmManager.getCodecFromBuffer(rawBuffer); - // UnpackPrefix returns [transaction, remainingBytes] - const [transaction, credentialBytes] = codec.UnpackPrefix(txBytes) as [any, Uint8Array]; - tx = transaction; - - // Extract credentials from remaining bytes using codec - if (credentialBytes.length > 4) { - credentials = this.extractCredentialsWithCodec(credentialBytes, codec); - } + const credentials = + credentialBytes.length > 4 ? this.extractCredentialsWithCodec(credentialBytes, codec as Codec) : []; - const blockchainId = tx.getBlockchainId(); - if (blockchainId === network.blockchainID) { - console.log('Parsed as PVM transaction on P-Chain'); - } + return { tx, credentials }; + } catch { + return null; } + } - if (txSource === 'EVM') { + /** + * Create the appropriate transaction builder based on transaction type. + * @param tx The parsed transaction + * @param rawBuffer The raw transaction buffer + * @param credentials The extracted credentials + * @param isEVM Whether this is an EVM transaction + * @returns The appropriate transaction builder + */ + private createBuilder( + tx: SerializedTx, + rawBuffer: Buffer, + credentials: Credential[], + isEVM: boolean + ): TransactionBuilder { + if (isEVM) { if (ExportInCTxBuilder.verifyTxType(tx._type)) { - const exportBuilder = this.getExportInCBuilder(); - exportBuilder.initBuilder(tx as evmSerial.ExportTx, rawBuffer, credentials); - return exportBuilder; - } else if (ImportInCTxBuilder.verifyTxType(tx._type)) { - const importBuilder = this.getImportInCBuilder(); - importBuilder.initBuilder(tx as evmSerial.ImportTx, rawBuffer, credentials); - return importBuilder; + const builder = this.getExportInCBuilder(); + builder.initBuilder(tx as evmSerial.ExportTx, rawBuffer, credentials); + return builder; } - } else if (txSource === 'PVM') { + if (ImportInCTxBuilder.verifyTxType(tx._type)) { + const builder = this.getImportInCBuilder(); + builder.initBuilder(tx as evmSerial.ImportTx, rawBuffer, credentials); + return builder; + } + } else { if (ImportInPTxBuilder.verifyTxType(tx._type)) { - const importBuilder = this.getImportInPBuilder(); - importBuilder.initBuilder(tx as pvmSerial.ImportTx, rawBuffer, credentials); - return importBuilder; - } else if (ExportInPTxBuilder.verifyTxType(tx._type)) { - const exportBuilder = this.getExportInPBuilder(); - exportBuilder.initBuilder(tx as pvmSerial.ExportTx, rawBuffer, credentials); - return exportBuilder; + const builder = this.getImportInPBuilder(); + builder.initBuilder(tx as pvmSerial.ImportTx, rawBuffer, credentials); + return builder; + } + if (ExportInPTxBuilder.verifyTxType(tx._type)) { + const builder = this.getExportInPBuilder(); + builder.initBuilder(tx as pvmSerial.ExportTx, rawBuffer, credentials); + return builder; } } throw new NotSupported('Transaction type not supported'); } + /** @inheritdoc */ + from(raw: string): TransactionBuilder { + utils.validateRawTransaction(raw); + const rawBuffer = Buffer.from(utils.removeHexPrefix(raw), 'hex'); + + // Try EVM first, then fall back to PVM + const evmResult = this.parseWithVM(rawBuffer, 'EVM'); + if (evmResult) { + return this.createBuilder(evmResult.tx, rawBuffer, evmResult.credentials, true); + } + + const pvmResult = this.parseWithVM(rawBuffer, 'PVM'); + if (pvmResult) { + return this.createBuilder(pvmResult.tx, rawBuffer, pvmResult.credentials, false); + } + + throw new NotSupported('Transaction type not supported'); + } + /** @inheritdoc */ getTransferBuilder(): TransactionBuilder { throw new NotSupported('Transfer is not supported in P Chain'); diff --git a/modules/sdk-coin-flrp/src/lib/utils.ts b/modules/sdk-coin-flrp/src/lib/utils.ts index 3281bba55a..efc94f2298 100644 --- a/modules/sdk-coin-flrp/src/lib/utils.ts +++ b/modules/sdk-coin-flrp/src/lib/utils.ts @@ -5,18 +5,23 @@ import { InvalidTransactionError, isValidXprv, isValidXpub, - NotImplementedError, ParseTransactionError, } from '@bitgo/sdk-core'; import { FlareNetwork } from '@bitgo/statics'; import { Buffer } from 'buffer'; import { createHash } from 'crypto'; import { ecc } from '@bitgo/secp256k1'; -import { ADDRESS_SEPARATOR, Output, DeprecatedTx } from './iface'; +import { ADDRESS_SEPARATOR, Output } from './iface'; import bs58 from 'bs58'; import { bech32 } from 'bech32'; export class Utils implements BaseUtils { + isValidTransactionId(txId: string): boolean { + throw new Error('Method not implemented.'); + } + isValidSignature(signature: string): boolean { + throw new Error('Method not implemented.'); + } /** * Check if addresses in wallet match UTXO output addresses */ @@ -41,10 +46,6 @@ export class Utils implements BaseUtils { return true; } - // Regex patterns - // export const ADDRESS_REGEX = /^(^P||NodeID)-[a-zA-Z0-9]+$/; - // export const HEX_REGEX = /^(0x){0,1}([0-9a-f])+$/i; - private isValidAddressRegex(address: string): boolean { return /^(^P||NodeID)-[a-zA-Z0-9]+$/.test(address); } @@ -154,7 +155,7 @@ export class Utils implements BaseUtils { verifySignature(network: FlareNetwork, message: Buffer, signature: Buffer, publicKey: Buffer): boolean { try { const messageHash = this.sha256(message); - return ecc.verify(signature, messageHash, publicKey); + return ecc.verify(messageHash, publicKey, signature); } catch (e) { return false; } @@ -264,15 +265,6 @@ export class Utils implements BaseUtils { return parseInt(outputidx.toString('hex'), 16).toString(); } - // Required by BaseUtils interface but not implemented - isValidSignature(signature: string): boolean { - throw new NotImplementedError('isValidSignature not implemented'); - } - - isValidTransactionId(txId: string): boolean { - throw new NotImplementedError('isValidTransactionId not implemented'); - } - /** * Helper method to convert address components to string */ @@ -329,7 +321,6 @@ export class Utils implements BaseUtils { * @param address - The address to parse * @returns Buffer containing the parsed address */ - //TODO: need check and validate this method public parseAddress = (address: string): Buffer => { return this.stringToAddress(address); }; @@ -364,27 +355,6 @@ export class Utils implements BaseUtils { return Buffer.from(bech32.fromWords(bech32.decode(parts[1]).words)); }; - /** - * Check if tx is for the blockchainId - * - * @param {DeprecatedTx} tx - * @param {string} blockchainId - * @returns true if tx is for blockchainId - */ - // TODO: remove DeprecatedTx usage - isTransactionOf(tx: DeprecatedTx, blockchainId: string): boolean { - // FlareJS equivalent - this would need proper CB58 encoding implementation - try { - const txRecord = tx as unknown as Record; - const unsignedTx = (txRecord.getUnsignedTx as () => Record)(); - const transaction = (unsignedTx.getTransaction as () => Record)(); - const txBlockchainId = (transaction.getBlockchainID as () => unknown)(); - return Buffer.from(txBlockchainId as string).toString('hex') === blockchainId; - } catch (error) { - return false; - } - } - flareIdString(value: string): Id { return new Id(Buffer.from(value, 'hex')); } @@ -417,7 +387,7 @@ export class Utils implements BaseUtils { return Buffer.from(recovered); } catch (error) { - throw new Error(`Failed to recover signature: ${error}`); + throw new Error(`Failed to recover signature: ${error.message}`); } } } diff --git a/modules/sdk-coin-flrp/test/resources/account.ts b/modules/sdk-coin-flrp/test/resources/account.ts index 311f88561c..7cd03c6dea 100644 --- a/modules/sdk-coin-flrp/test/resources/account.ts +++ b/modules/sdk-coin-flrp/test/resources/account.ts @@ -34,6 +34,8 @@ export const ACCOUNT_2 = { 'xprv9s21ZrQH143K2KjD8ytSfLWDfe2585pBJNdadLgwsEKoGLbGdHCKSK5yDnfcmToazd3oPLDXprtXnCvsn9T6MDJz1qwMPaq22oTrzqvyeDQ', xPublicKey: 'xprv9s21ZrQH143K2KjD8ytSfLWDfe2585pBJNdadLgwsEKoGLbGdHCKSK5yDnfcmToazd3oPLDXprtXnCvsn9T6MDJz1qwMPaq22oTrzqvyeDQ', + addressMainnet: 'P-flare1xvngrrmqzldj50kpvqx7mhkx3htvh5x87nkl9j', + addressTestnet: 'P-costwo1xvngrrmqzldj50kpvqx7mhkx3htvh5x8mhgsqy', }; export const ACCOUNT_3 = { diff --git a/modules/sdk-coin-flrp/test/unit/flrp.ts b/modules/sdk-coin-flrp/test/unit/flrp.ts new file mode 100644 index 0000000000..fa38e76249 --- /dev/null +++ b/modules/sdk-coin-flrp/test/unit/flrp.ts @@ -0,0 +1,565 @@ +import * as FlrpLib from '../../src/lib'; +import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test'; +import { Flrp, TflrP } from '../../src/'; +import { randomBytes } from 'crypto'; +import { BitGoAPI } from '@bitgo/sdk-api'; +import { SEED_ACCOUNT, ACCOUNT_1, ACCOUNT_2 } from '../resources/account'; +import { EXPORT_IN_C } from '../resources/transactionData/exportInC'; +import { EXPORT_IN_P } from '../resources/transactionData/exportInP'; +import { IMPORT_IN_P } from '../resources/transactionData/importInP'; +import { IMPORT_IN_C } from '../resources/transactionData/importInC'; +import { HalfSignedAccountTransaction, TransactionType } from '@bitgo/sdk-core'; +import assert from 'assert'; + +describe('Flrp test cases', function () { + const coinName = 'flrp'; + const tcoinName = 't' + coinName; + let bitgo: TestBitGoAPI; + let basecoin; + + const keychains = [{ pub: SEED_ACCOUNT.publicKey }, { pub: ACCOUNT_1.publicKey }, { pub: ACCOUNT_2.publicKey }]; + + before(function () { + bitgo = TestBitGo.decorate(BitGoAPI, { + env: 'mock', + }); + bitgo.initializeTestVars(); + bitgo.safeRegister(coinName, Flrp.createInstance); + bitgo.safeRegister(tcoinName, TflrP.createInstance); + basecoin = bitgo.coin(tcoinName); + }); + + it('should instantiate the coin', function () { + let localBasecoin = bitgo.coin(tcoinName); + localBasecoin.should.be.an.instanceof(TflrP); + + localBasecoin = bitgo.coin(coinName); + localBasecoin.should.be.an.instanceof(Flrp); + }); + + it('should return ' + tcoinName, function () { + basecoin.getChain().should.equal(tcoinName); + }); + + it('should return full name', function () { + basecoin.getFullName().should.equal('Testnet Flare P-Chain'); + }); + + it('should return base factor', function () { + basecoin.getBaseFactor().should.equal(1e9); + }); + + it('should return coin family', function () { + basecoin.getFamily().should.equal('flrp'); + }); + + it('should return default multisig type', function () { + basecoin.getDefaultMultisigType().should.equal('onchain'); + }); + + describe('Keypairs:', () => { + it('should generate a keypair from random seed', function () { + const keyPair = basecoin.generateKeyPair(); + keyPair.should.have.property('pub'); + keyPair.should.have.property('prv'); + }); + + it('should generate a keypair from a seed', function () { + const seed = Buffer.from(SEED_ACCOUNT.seed, 'hex'); + const keyPair = basecoin.generateKeyPair(seed); + keyPair.pub.should.equal(SEED_ACCOUNT.publicKey); + keyPair.prv.should.equal(SEED_ACCOUNT.privateKey); + }); + + it('should validate a public key', function () { + basecoin.isValidPub(SEED_ACCOUNT.publicKey).should.equal(true); + basecoin.isValidPub(ACCOUNT_1.publicKey).should.equal(true); + }); + + it('should fail to validate an invalid public key', function () { + basecoin.isValidPub('invalid').should.equal(false); + }); + + it('should validate a private key', function () { + basecoin.isValidPrv(SEED_ACCOUNT.privateKey).should.equal(true); + basecoin.isValidPrv(ACCOUNT_1.privateKey).should.equal(true); + }); + + it('should fail to validate an invalid private key', function () { + basecoin.isValidPrv('invalid').should.equal(false); + }); + }); + + describe('Sign Transaction', () => { + it('should sign an export from C-chain transaction', async () => { + const params = { + txPrebuild: { + txHex: EXPORT_IN_C.unsignedHex, + }, + prv: EXPORT_IN_C.privateKey, + }; + + const signedTx = await basecoin.signTransaction(params); + signedTx.should.have.property('halfSigned'); + const halfSigned = (signedTx as HalfSignedAccountTransaction).halfSigned; + assert(halfSigned, 'halfSigned should be defined'); + assert(halfSigned.txHex, 'txHex should be defined'); + halfSigned.txHex.should.equal(EXPORT_IN_C.signedHex); + }); + + it('should sign an export from P-chain transaction', async () => { + const params = { + txPrebuild: { + txHex: EXPORT_IN_P.unsignedHex, + }, + prv: EXPORT_IN_P.privateKeys[0], + }; + + const signedTx = await basecoin.signTransaction(params); + signedTx.should.have.property('halfSigned'); + const halfSigned = (signedTx as HalfSignedAccountTransaction).halfSigned; + assert(halfSigned, 'halfSigned should be defined'); + assert(halfSigned.txHex, 'txHex should be defined'); + halfSigned.txHex.should.equal(EXPORT_IN_P.halfSigntxHex); + }); + + it('should sign an import to P-chain transaction', async () => { + const params = { + txPrebuild: { + txHex: IMPORT_IN_P.unsignedHex, + }, + prv: IMPORT_IN_P.privateKeys[0], + }; + + const signedTx = await basecoin.signTransaction(params); + signedTx.should.have.property('halfSigned'); + const halfSigned = (signedTx as HalfSignedAccountTransaction).halfSigned; + assert(halfSigned, 'halfSigned should be defined'); + assert(halfSigned.txHex, 'txHex should be defined'); + halfSigned.txHex.should.equal(IMPORT_IN_P.halfSigntxHex); + }); + + it('should sign an import to C-chain transaction', async () => { + const params = { + txPrebuild: { + txHex: IMPORT_IN_C.unsignedHex, + }, + prv: IMPORT_IN_C.privateKeys[0], + }; + + const signedTx = await basecoin.signTransaction(params); + signedTx.should.have.property('halfSigned'); + const halfSigned = (signedTx as HalfSignedAccountTransaction).halfSigned; + assert(halfSigned, 'halfSigned should be defined'); + assert(halfSigned.txHex, 'txHex should be defined'); + halfSigned.txHex.should.equal(IMPORT_IN_C.halfSigntxHex); + }); + + it('should reject signing with an invalid key', async () => { + const params = { + txPrebuild: { + txHex: EXPORT_IN_C.unsignedHex, + }, + prv: 'invalid-key', + }; + + await basecoin.signTransaction(params).should.be.rejected(); + }); + }); + + describe('Sign Message', () => { + it('should sign a message', async () => { + const keyPair = new FlrpLib.KeyPair({ prv: SEED_ACCOUNT.privateKey }); + const keys = keyPair.getKeys(); + const messageToSign = Buffer.from(SEED_ACCOUNT.message, 'utf8'); + const signature = await basecoin.signMessage(keys, messageToSign.toString('hex')); + + signature.should.be.instanceOf(Buffer); + signature.length.should.equal(65); + }); + + it('should sign a random message', async () => { + const keyPair = new FlrpLib.KeyPair(); + const pubKey = keyPair.getKeys().pub; + const keys = keyPair.getKeys(); + const messageToSign = Buffer.from(randomBytes(32)); + const signature = await basecoin.signMessage(keys, messageToSign.toString('hex')); + + const verify = FlrpLib.Utils.verifySignature( + basecoin._staticsCoin.network, + messageToSign, + signature.slice(0, 64), // Remove recovery byte for verification + Buffer.from(pubKey, 'hex') + ); + verify.should.be.true(); + }); + + it('should fail to sign with missing private key', async () => { + const keyPair = new FlrpLib.KeyPair({ pub: SEED_ACCOUNT.publicKey }); + const keys = keyPair.getKeys(); + const messageToSign = Buffer.from(SEED_ACCOUNT.message, 'utf8'); + + await basecoin + .signMessage(keys, messageToSign.toString('hex')) + .should.be.rejectedWith('Invalid key pair options'); + }); + }); + + describe('Explain Transaction', () => { + it('should explain a half signed export from P-chain transaction', async () => { + const txExplain = await basecoin.explainTransaction({ + halfSigned: { txHex: EXPORT_IN_P.halfSigntxHex }, + }); + + txExplain.type.should.equal(TransactionType.Export); + txExplain.id.should.equal(EXPORT_IN_P.txhash); + txExplain.fee.fee.should.equal(EXPORT_IN_P.fee); + txExplain.inputs.should.be.an.Array(); + txExplain.changeAmount.should.equal('498459568'); + txExplain.changeOutputs.should.be.an.Array(); + txExplain.changeOutputs[0].address.should.equal( + 'P-costwo106gc5h5qswhye8e0pmthq4wzf0ekv5qppsrvpu~P-costwo1cueygd7fd37g56s49k3rshqakhp6k8u3adzt6m~P-costwo1xv5mulgpe5lt4tnx2ntnylwe79azu9vpja6lut' + ); + }); + + it('should explain a signed export from P-chain transaction', async () => { + const txExplain = await basecoin.explainTransaction({ txHex: EXPORT_IN_P.fullSigntxHex }); + + txExplain.type.should.equal(TransactionType.Export); + txExplain.id.should.equal(EXPORT_IN_P.txhash); + txExplain.fee.fee.should.equal(EXPORT_IN_P.fee); + txExplain.inputs.should.be.an.Array(); + txExplain.changeAmount.should.equal('498459568'); + txExplain.changeOutputs.should.be.an.Array(); + txExplain.changeOutputs[0].address.should.equal( + 'P-costwo106gc5h5qswhye8e0pmthq4wzf0ekv5qppsrvpu~P-costwo1cueygd7fd37g56s49k3rshqakhp6k8u3adzt6m~P-costwo1xv5mulgpe5lt4tnx2ntnylwe79azu9vpja6lut' + ); + }); + + it('should explain a half signed import to P-chain transaction', async () => { + const txExplain = await basecoin.explainTransaction({ + halfSigned: { txHex: IMPORT_IN_P.halfSigntxHex }, + }); + + txExplain.type.should.equal(TransactionType.Import); + txExplain.id.should.equal(IMPORT_IN_P.txhash); + txExplain.fee.fee.should.equal(IMPORT_IN_P.fee); + txExplain.inputs.should.be.an.Array(); + txExplain.outputAmount.should.equal('48739000'); + txExplain.outputs.should.be.an.Array(); + txExplain.outputs.length.should.equal(1); + txExplain.changeOutputs.should.be.empty(); + }); + + it('should explain a signed import to P-chain transaction', async () => { + const txExplain = await basecoin.explainTransaction({ txHex: IMPORT_IN_P.fullSigntxHex }); + + txExplain.type.should.equal(TransactionType.Import); + txExplain.id.should.equal(IMPORT_IN_P.txhash); + txExplain.fee.fee.should.equal(IMPORT_IN_P.fee); + txExplain.inputs.should.be.an.Array(); + txExplain.outputAmount.should.equal('48739000'); + txExplain.outputs.should.be.an.Array(); + txExplain.outputs.length.should.equal(1); + txExplain.changeOutputs.should.be.empty(); + }); + + it('should fail when transaction hex is not provided', async () => { + await basecoin.explainTransaction({}).should.be.rejectedWith('missing transaction hex'); + }); + + it('should fail for invalid transaction hex', async () => { + await basecoin.explainTransaction({ txHex: 'invalid' }).should.be.rejected(); + }); + }); + + describe('Verify Transaction', () => { + it('should verify an export from C-chain transaction', async () => { + const txPrebuild = { + txHex: EXPORT_IN_C.signedHex, + txInfo: {}, + }; + const txParams = { + recipients: [ + { + address: '', + amount: EXPORT_IN_C.amount, + }, + ], + type: 'Export', + locktime: 0, + }; + + const isVerified = await basecoin.verifyTransaction({ txParams, txPrebuild }); + isVerified.should.equal(true); + }); + + it('should verify an export from P-chain transaction', async () => { + const txPrebuild = { + txHex: EXPORT_IN_P.fullSigntxHex, + txInfo: {}, + }; + const txParams = { + recipients: [ + { + address: '', + amount: EXPORT_IN_P.amount, + }, + ], + type: 'Export', + locktime: 0, + }; + + const isVerified = await basecoin.verifyTransaction({ txParams, txPrebuild }); + isVerified.should.equal(true); + }); + + it('should verify an import to C-chain transaction', async () => { + const txPrebuild = { + txHex: IMPORT_IN_C.fullSigntxHex, + txInfo: {}, + }; + const txParams = { + recipients: [ + { + address: IMPORT_IN_C.to, + amount: '1', + }, + ], + type: 'ImportToC', + locktime: 0, + }; + + const isVerified = await basecoin.verifyTransaction({ txParams, txPrebuild }); + isVerified.should.equal(true); + }); + + it('should verify an import to P-chain transaction', async () => { + const txPrebuild = { + txHex: IMPORT_IN_P.fullSigntxHex, + txInfo: {}, + }; + const txParams = { + recipients: [], + type: 'Import', + locktime: 0, + }; + + const isVerified = await basecoin.verifyTransaction({ txParams, txPrebuild }); + isVerified.should.equal(true); + }); + + it('should fail to verify export transaction with wrong amount', async () => { + const txPrebuild = { + txHex: EXPORT_IN_C.signedHex, + txInfo: {}, + }; + const txParams = { + recipients: [ + { + address: '', + amount: '999999999999', + }, + ], + type: 'Export', + locktime: 0, + }; + + await basecoin + .verifyTransaction({ txParams, txPrebuild }) + .should.be.rejectedWith(/Tx total amount .* does not match with expected total amount/); + }); + + it('should fail to verify transaction with wrong type', async () => { + const txPrebuild = { + txHex: EXPORT_IN_C.signedHex, + txInfo: {}, + }; + const txParams = { + recipients: [ + { + address: '', + amount: EXPORT_IN_C.amount, + }, + ], + type: 'Import', + locktime: 0, + }; + + await basecoin + .verifyTransaction({ txParams, txPrebuild }) + .should.be.rejectedWith('Tx type does not match with expected txParams type'); + }); + + it('should fail to verify transaction without txHex', async () => { + const txPrebuild = { + txInfo: {}, + }; + const txParams = { + recipients: [], + type: 'Export', + locktime: 0, + }; + + await basecoin + .verifyTransaction({ txParams, txPrebuild }) + .should.be.rejectedWith('missing required tx prebuild property txHex'); + }); + + it('should fail to verify transaction with invalid txHex', async () => { + const txPrebuild = { + txHex: 'invalidhex', + txInfo: {}, + }; + const txParams = { + recipients: [], + type: 'Export', + locktime: 0, + }; + + await basecoin + .verifyTransaction({ txParams, txPrebuild }) + .should.be.rejectedWith('Invalid transaction: Raw transaction is not hex string'); + }); + + it('should fail to verify import to C-chain without recipients', async () => { + const txPrebuild = { + txHex: IMPORT_IN_C.fullSigntxHex, + txInfo: {}, + }; + const txParams = { + recipients: [], + type: 'ImportToC', + locktime: 0, + }; + + await basecoin + .verifyTransaction({ txParams, txPrebuild }) + .should.be.rejectedWith('Expected 1 recipient in import transaction'); + }); + }); + + describe('Address Validation', () => { + it('should validate mainnet P-chain address', function () { + basecoin.isValidAddress(SEED_ACCOUNT.addressMainnet).should.be.true(); + }); + + it('should validate testnet P-chain address', function () { + basecoin.isValidAddress(SEED_ACCOUNT.addressTestnet).should.be.true(); + }); + + it('should validate array of P-chain addresses', function () { + basecoin.isValidAddress(EXPORT_IN_C.pAddresses).should.be.true(); + }); + + it('should validate tilde-separated multisig address', function () { + const multiSigAddress = EXPORT_IN_C.pAddresses.join('~'); + basecoin.isValidAddress(multiSigAddress).should.be.true(); + }); + + it('should validate C-chain hex address', function () { + basecoin.isValidAddress(EXPORT_IN_C.cHexAddress).should.be.true(); + }); + + it('should validate lowercase C-chain address', function () { + basecoin.isValidAddress(IMPORT_IN_C.to.toLowerCase()).should.be.true(); + }); + + it('should fail to validate undefined address', function () { + basecoin.isValidAddress(undefined).should.be.false(); + }); + + it('should fail to validate empty string', function () { + basecoin.isValidAddress('').should.be.false(); + }); + + it('should fail to validate invalid address', function () { + basecoin.isValidAddress('invalid-address').should.be.false(); + }); + + it('should fail to validate array with invalid address', function () { + const addresses = [...EXPORT_IN_C.pAddresses, 'invalid']; + basecoin.isValidAddress(addresses).should.be.false(); + }); + }); + + describe('Wallet Address Verification', () => { + it('should verify wallet address with matching keychains', async () => { + const keyPairs = [{ pub: SEED_ACCOUNT.publicKey }, { pub: ACCOUNT_1.publicKey }, { pub: ACCOUNT_2.publicKey }]; + + // Derive addresses from public keys to ensure they match + const derivedAddresses = keyPairs.map((kp) => new FlrpLib.KeyPair({ pub: kp.pub }).getAddress('testnet')); + const address = derivedAddresses.join('~'); + + const isValid = await basecoin.isWalletAddress({ + address, + keychains: keyPairs, + }); + + isValid.should.be.true(); + }); + + it('should throw for address with wrong number of keychains', async () => { + const address = SEED_ACCOUNT.addressTestnet; + + await assert.rejects( + async () => + basecoin.isWalletAddress({ + address, + keychains: [{ pub: SEED_ACCOUNT.publicKey }], + }), + /Invalid keychains/ + ); + }); + + it('should throw for invalid address', async () => { + await assert.rejects( + async () => + basecoin.isWalletAddress({ + address: 'invalid', + keychains, + }), + /invalid address/ + ); + }); + + it('should throw when address length does not match keychain length', async () => { + const address = SEED_ACCOUNT.addressTestnet; + + await assert.rejects(async () => + basecoin.isWalletAddress({ + address, + keychains, + }) + ); + }); + + it('should throw when addresses do not match keychains', async () => { + // Use addresses that don't match the keychains + const address = EXPORT_IN_C.pAddresses.join('~'); + + await assert.rejects(async () => + basecoin.isWalletAddress({ + address, + keychains, + }) + ); + }); + }); + + describe('Recovery Signature', () => { + it('should recover signature from signed message', async () => { + const message = Buffer.from(SEED_ACCOUNT.message, 'utf8'); + const privateKey = Buffer.from(SEED_ACCOUNT.privateKey, 'hex'); + + // Create signature + const signature = FlrpLib.Utils.createSignature(basecoin._staticsCoin.network, message, privateKey); + + // Recover public key from signature + const recoveredPubKey = basecoin.recoverySignature(message, signature); + + recoveredPubKey.should.be.instanceOf(Buffer); + recoveredPubKey.length.should.equal(33); + }); + }); +}); diff --git a/modules/sdk-coin-flrp/test/unit/lib/utils.ts b/modules/sdk-coin-flrp/test/unit/lib/utils.ts index e2d152b678..d302be6815 100644 --- a/modules/sdk-coin-flrp/test/unit/lib/utils.ts +++ b/modules/sdk-coin-flrp/test/unit/lib/utils.ts @@ -1,19 +1,481 @@ import { coins, FlareNetwork } from '@bitgo/statics'; import * as assert from 'assert'; import { Utils } from '../../../src/lib/utils'; +import { + SEED_ACCOUNT, + ACCOUNT_1, + ACCOUNT_2, + ACCOUNT_3, + INVALID_SHORT_KEYPAIR_KEY, + INVALID_LONG_KEYPAIR_PRV, +} from '../../resources/account'; +import { ecc } from '@bitgo/secp256k1'; +import { EXPORT_IN_C } from '../../resources/transactionData/exportInC'; +import { IMPORT_IN_P } from '../../resources/transactionData/importInP'; describe('Utils', function () { let utils: Utils; + const network = coins.get('flrp').network as FlareNetwork; beforeEach(function () { utils = new Utils(); }); + describe('includeIn', function () { + it('should return true when all wallet addresses are in UTXO output addresses', function () { + const walletAddresses = [EXPORT_IN_C.pAddresses[0], EXPORT_IN_C.pAddresses[1]]; + const utxoOutputAddresses = [...EXPORT_IN_C.pAddresses]; + assert.strictEqual(utils.includeIn(walletAddresses, utxoOutputAddresses), true); + }); + + it('should return false when some wallet addresses are not in UTXO output addresses', function () { + const walletAddresses = [EXPORT_IN_C.pAddresses[0], ACCOUNT_3.address]; + const utxoOutputAddresses = [EXPORT_IN_C.pAddresses[0], EXPORT_IN_C.pAddresses[1]]; + assert.strictEqual(utils.includeIn(walletAddresses, utxoOutputAddresses), false); + }); + + it('should return true for empty wallet addresses', function () { + const walletAddresses: string[] = []; + const utxoOutputAddresses = [EXPORT_IN_C.pAddresses[0]]; + assert.strictEqual(utils.includeIn(walletAddresses, utxoOutputAddresses), true); + }); + }); + + describe('isValidAddress', function () { + it('should return true for valid mainnet P-chain address', function () { + assert.strictEqual(utils.isValidAddress(SEED_ACCOUNT.addressMainnet), true); + }); + + it('should return true for valid testnet P-chain address', function () { + assert.strictEqual(utils.isValidAddress(SEED_ACCOUNT.addressTestnet), true); + }); + + it('should return true for valid NodeID address', function () { + assert.strictEqual(utils.isValidAddress('NodeID-abc123xyz'), true); + }); + + it('should return true for array of valid addresses', function () { + assert.strictEqual(utils.isValidAddress(EXPORT_IN_C.pAddresses), true); + }); + + it('should return true for tilde-separated addresses', function () { + const combined = EXPORT_IN_C.pAddresses.join('~'); + assert.strictEqual(utils.isValidAddress(combined), true); + }); + + it('should return false for invalid address format', function () { + assert.strictEqual(utils.isValidAddress('invalid'), false); + }); + + it('should return false for address without prefix', function () { + assert.strictEqual(utils.isValidAddress('flare1abc123'), false); + }); + }); + + describe('isValidBlockId', function () { + it('should return true for valid 32-byte hex block ID', function () { + assert.strictEqual(utils.isValidBlockId(SEED_ACCOUNT.privateKey), true); // 64 hex chars = 32 bytes + }); + + it('should return false for invalid length block ID', function () { + assert.strictEqual(utils.isValidBlockId(INVALID_SHORT_KEYPAIR_KEY), false); + }); + + it('should return false for empty block ID', function () { + assert.strictEqual(utils.isValidBlockId(''), false); + }); + }); + + describe('isValidPublicKey', function () { + it('should return true for valid compressed public key starting with 03', function () { + assert.strictEqual(utils.isValidPublicKey(SEED_ACCOUNT.publicKey), true); + }); + + it('should return true for valid compressed public key starting with 02', function () { + assert.strictEqual(utils.isValidPublicKey(ACCOUNT_1.publicKey), true); + }); + + it('should return true for another valid compressed public key', function () { + assert.strictEqual(utils.isValidPublicKey(ACCOUNT_2.publicKey), true); + }); + + it('should return true for valid xpub', function () { + assert.strictEqual(utils.isValidPublicKey(SEED_ACCOUNT.xPublicKey), true); + }); + + it('should return true for ACCOUNT_1 xpub', function () { + assert.strictEqual(utils.isValidPublicKey(ACCOUNT_1.xPublicKey), true); + }); + + it('should return false for invalid short public key', function () { + assert.strictEqual(utils.isValidPublicKey(INVALID_SHORT_KEYPAIR_KEY), false); + }); + + it('should return false for compressed key with wrong prefix', function () { + const invalidKey = '05' + SEED_ACCOUNT.privateKey; + assert.strictEqual(utils.isValidPublicKey(invalidKey), false); + }); + + it('should return false for non-hex public key', function () { + const invalidKey = 'zz' + SEED_ACCOUNT.privateKey; + assert.strictEqual(utils.isValidPublicKey(invalidKey), false); + }); + }); + + describe('isValidPrivateKey', function () { + it('should return true for valid 64-char hex private key', function () { + assert.strictEqual(utils.isValidPrivateKey(SEED_ACCOUNT.privateKey), true); + }); + + it('should return true for ACCOUNT_1 private key', function () { + assert.strictEqual(utils.isValidPrivateKey(ACCOUNT_1.privateKey), true); + }); + + it('should return true for ACCOUNT_2 private key', function () { + assert.strictEqual(utils.isValidPrivateKey(ACCOUNT_2.privateKey), true); + }); + + it('should return true for valid xprv', function () { + assert.strictEqual(utils.isValidPrivateKey(SEED_ACCOUNT.xPrivateKey), true); + }); + + it('should return true for 66-char private key ending with 01', function () { + const extendedKey = SEED_ACCOUNT.privateKey + '01'; + assert.strictEqual(utils.isValidPrivateKey(extendedKey), true); + }); + + it('should return false for 66-char private key not ending with 01', function () { + assert.strictEqual(utils.isValidPrivateKey(INVALID_LONG_KEYPAIR_PRV), false); + }); + + it('should return false for invalid short private key', function () { + assert.strictEqual(utils.isValidPrivateKey(INVALID_SHORT_KEYPAIR_KEY), false); + }); + + it('should return false for non-hex private key', function () { + const invalidKey = 'zz' + SEED_ACCOUNT.privateKey.slice(2); + assert.strictEqual(utils.isValidPrivateKey(invalidKey), false); + }); + }); + + describe('allHexChars', function () { + it('should return true for valid private key hex', function () { + assert.strictEqual(utils.allHexChars(SEED_ACCOUNT.privateKey), true); + }); + + it('should return true for valid public key hex', function () { + assert.strictEqual(utils.allHexChars(SEED_ACCOUNT.publicKey), true); + }); + + it('should return true for hex string with 0x prefix', function () { + assert.strictEqual(utils.allHexChars(EXPORT_IN_C.cHexAddress), true); + }); + + it('should return true for valid signature hex', function () { + assert.strictEqual(utils.allHexChars(SEED_ACCOUNT.signature), true); + }); + + it('should return false for non-hex characters', function () { + assert.strictEqual(utils.allHexChars('ghijkl'), false); + }); + + it('should return false for empty string', function () { + assert.strictEqual(utils.allHexChars(''), false); + }); + }); + + describe('createSignature and verifySignature', function () { + it('should create a valid 65-byte signature', function () { + const message = Buffer.from(SEED_ACCOUNT.message, 'utf8'); + const privateKey = Buffer.from(SEED_ACCOUNT.privateKey, 'hex'); + + const signature = utils.createSignature(network, message, privateKey); + + assert.ok(signature instanceof Buffer); + assert.strictEqual(signature.length, 65); + }); + + it('should verify a valid signature', function () { + const message = Buffer.from(SEED_ACCOUNT.message, 'utf8'); + const privateKey = Buffer.from(SEED_ACCOUNT.privateKey, 'hex'); + const publicKey = Buffer.from(SEED_ACCOUNT.publicKey, 'hex'); + + const signature = utils.createSignature(network, message, privateKey); + const sigOnly = signature.slice(0, 64); + + const isValid = utils.verifySignature(network, message, sigOnly, publicKey); + assert.strictEqual(isValid, true); + }); + + it('should return false for invalid signature', function () { + const message = Buffer.from(SEED_ACCOUNT.message, 'utf8'); + const publicKey = Buffer.from(SEED_ACCOUNT.publicKey, 'hex'); + const invalidSignature = Buffer.alloc(64); + + const isValid = utils.verifySignature(network, message, invalidSignature, publicKey); + assert.strictEqual(isValid, false); + }); + + it('should create signature with ACCOUNT_1 keys', function () { + const message = Buffer.from(SEED_ACCOUNT.message, 'utf8'); + const privateKey = Buffer.from(ACCOUNT_1.privateKey, 'hex'); + + const signature = utils.createSignature(network, message, privateKey); + + assert.ok(signature instanceof Buffer); + assert.strictEqual(signature.length, 65); + }); + }); + + describe('createNewSig', function () { + it('should create a signature from valid signature hex string', function () { + const sig = utils.createNewSig(SEED_ACCOUNT.signature); + assert.ok(sig); + }); + + it('should create a signature from export signature hex', function () { + const sigHex = utils.removeHexPrefix(EXPORT_IN_C.signature[0]); + const sig = utils.createNewSig(sigHex); + assert.ok(sig); + }); + + it('should pad short hex strings', function () { + const sigHex = INVALID_SHORT_KEYPAIR_KEY; + const sig = utils.createNewSig(sigHex); + assert.ok(sig); + }); + }); + + describe('createEmptySigWithAddress and getAddressFromEmptySig', function () { + it('should create empty signature with embedded C-chain address', function () { + const addressHex = utils.removeHexPrefix(EXPORT_IN_C.cHexAddress); + const sig = utils.createEmptySigWithAddress(addressHex); + assert.ok(sig); + }); + + it('should extract embedded address from empty signature', function () { + const addressHex = utils.removeHexPrefix(EXPORT_IN_C.cHexAddress).toLowerCase(); + const sig = utils.createEmptySigWithAddress(addressHex); + + // Get signature bytes and convert to hex + const sigBytes = sig.toBytes(); + const sigHex = Buffer.from(sigBytes).toString('hex'); + + const extractedAddress = utils.getAddressFromEmptySig(sigHex); + assert.strictEqual(extractedAddress, addressHex); + }); + + it('should handle 0x prefixed address', function () { + const sig = utils.createEmptySigWithAddress(EXPORT_IN_C.cHexAddress); + assert.ok(sig); + }); + + it('should return empty string for short signature', function () { + const shortSig = SEED_ACCOUNT.privateKey; // 64 chars, less than 130 + assert.strictEqual(utils.getAddressFromEmptySig(shortSig), ''); + }); + }); + + describe('sha256', function () { + it('should compute SHA256 hash of message', function () { + const data = Buffer.from(SEED_ACCOUNT.message, 'utf8'); + const hash = utils.sha256(data); + + assert.ok(hash instanceof Buffer); + assert.strictEqual(hash.length, 32); + }); + + it('should produce consistent hash for same input', function () { + const data = Buffer.from(SEED_ACCOUNT.message, 'utf8'); + const hash1 = utils.sha256(data); + const hash2 = utils.sha256(data); + + assert.deepStrictEqual(hash1, hash2); + }); + + it('should produce different hash for different input', function () { + const data1 = Buffer.from(SEED_ACCOUNT.message, 'utf8'); + const data2 = Buffer.from(SEED_ACCOUNT.privateKey, 'utf8'); + const hash1 = utils.sha256(data1); + const hash2 = utils.sha256(data2); + + assert.notDeepStrictEqual(hash1, hash2); + }); + }); + + describe('validateRawTransaction', function () { + it('should not throw for valid unsigned hex transaction', function () { + const rawTx = utils.removeHexPrefix(EXPORT_IN_C.unsignedHex); + assert.doesNotThrow(() => utils.validateRawTransaction(rawTx)); + }); + + it('should not throw for valid signed hex transaction', function () { + const rawTx = utils.removeHexPrefix(EXPORT_IN_C.signedHex); + assert.doesNotThrow(() => utils.validateRawTransaction(rawTx)); + }); + + it('should not throw for import transaction hex', function () { + const rawTx = utils.removeHexPrefix(IMPORT_IN_P.unsignedHex); + assert.doesNotThrow(() => utils.validateRawTransaction(rawTx)); + }); + + it('should throw for empty transaction', function () { + assert.throws(() => utils.validateRawTransaction(''), /Raw transaction is empty/); + }); + + it('should throw for non-hex transaction', function () { + assert.throws(() => utils.validateRawTransaction('xyz123'), /Raw transaction is not hex string/); + }); + }); + + describe('removeHexPrefix', function () { + it('should remove 0x prefix from C-chain address', function () { + assert.strictEqual(utils.removeHexPrefix(EXPORT_IN_C.cHexAddress), EXPORT_IN_C.cHexAddress.slice(2)); + }); + + it('should remove 0x prefix from unsigned hex', function () { + assert.strictEqual(utils.removeHexPrefix(EXPORT_IN_C.unsignedHex), EXPORT_IN_C.unsignedHex.slice(2)); + }); + + it('should return string unchanged if no prefix', function () { + assert.strictEqual(utils.removeHexPrefix(SEED_ACCOUNT.privateKey), SEED_ACCOUNT.privateKey); + }); + + it('should handle empty string', function () { + assert.strictEqual(utils.removeHexPrefix(''), ''); + }); + }); + + describe('outputidxNumberToBuffer and outputidxBufferToNumber', function () { + it('should convert output index to buffer and back', function () { + const outputIdx = IMPORT_IN_P.outputs[0].outputidx; + const buffer = utils.outputidxNumberToBuffer(outputIdx); + const result = utils.outputidxBufferToNumber(buffer); + + assert.strictEqual(result, outputIdx); + }); + + it('should handle nonce value', function () { + const nonceStr = EXPORT_IN_C.nonce.toString(); + const buffer = utils.outputidxNumberToBuffer(nonceStr); + const result = utils.outputidxBufferToNumber(buffer); + + assert.strictEqual(result, nonceStr); + }); + + it('should produce 4-byte buffer', function () { + const buffer = utils.outputidxNumberToBuffer('255'); + assert.strictEqual(buffer.length, 4); + }); + }); + + describe('addressToString', function () { + it('should convert address buffer to mainnet bech32 string', function () { + const address = SEED_ACCOUNT.addressMainnet; + const addressBuffer = utils.parseAddress(address); + const result = utils.addressToString('flare', 'P', addressBuffer); + + assert.ok(result.startsWith('P-')); + assert.ok(result.includes('flare')); + }); + + it('should convert address buffer to testnet bech32 string', function () { + const address = SEED_ACCOUNT.addressTestnet; + const addressBuffer = utils.parseAddress(address); + const result = utils.addressToString('costwo', 'P', addressBuffer); + + assert.ok(result.startsWith('P-')); + assert.ok(result.includes('costwo')); + }); + }); + + describe('cb58Encode and cb58Decode', function () { + it('should encode and decode target chain ID correctly', function () { + const encoded = EXPORT_IN_C.targetChainId; + const decoded = utils.cb58Decode(encoded); + const reEncoded = utils.cb58Encode(decoded); + + assert.strictEqual(reEncoded, encoded); + }); + + it('should encode and decode source chain ID correctly', function () { + const encoded = IMPORT_IN_P.sourceChainId; + const decoded = utils.cb58Decode(encoded); + const reEncoded = utils.cb58Encode(decoded); + + assert.strictEqual(reEncoded, encoded); + }); + + it('should throw for invalid checksum', function () { + assert.throws(() => utils.cb58Decode('1111111111111'), /Invalid checksum/); + }); + }); + + describe('parseAddress and stringToAddress', function () { + it('should parse hex address with 0x prefix', function () { + const buffer = utils.parseAddress(EXPORT_IN_C.cHexAddress); + + assert.ok(buffer instanceof Buffer); + assert.strictEqual(buffer.length, 20); + }); + + it('should parse raw hex address from outputs', function () { + const address = IMPORT_IN_P.outputs[0].addresses[0]; + const buffer = utils.parseAddress(address); + + assert.ok(buffer instanceof Buffer); + assert.strictEqual(buffer.length, 20); + }); + + it('should parse mainnet bech32 address', function () { + const buffer = utils.parseAddress(SEED_ACCOUNT.addressMainnet); + + assert.ok(buffer instanceof Buffer); + assert.strictEqual(buffer.length, 20); + }); + + it('should parse testnet bech32 address', function () { + const buffer = utils.parseAddress(SEED_ACCOUNT.addressTestnet); + + assert.ok(buffer instanceof Buffer); + assert.strictEqual(buffer.length, 20); + }); + + it('should parse P-chain addresses from export data', function () { + EXPORT_IN_C.pAddresses.forEach((address) => { + const buffer = utils.parseAddress(address); + assert.ok(buffer instanceof Buffer); + assert.strictEqual(buffer.length, 20); + }); + }); + + it('should throw for address without dash separator', function () { + assert.throws(() => utils.parseAddress('flare1abc'), /Valid address should include -/); + }); + + it('should throw for invalid HRP', function () { + assert.throws(() => utils.parseAddress('P-invalid1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq5evzmy'), /Invalid HRP/); + }); + }); + + describe('flareIdString', function () { + it('should create Id from private key hex string', function () { + const id = utils.flareIdString(SEED_ACCOUNT.privateKey); + assert.ok(id); + }); + + it('should create Id from asset ID hex', function () { + // Asset ID is typically 32 bytes (64 hex chars) + const assetIdHex = SEED_ACCOUNT.privateKey; // Using as a 32-byte hex + const id = utils.flareIdString(assetIdHex); + assert.ok(id); + }); + }); + describe('recoverySignature', function () { it('should recover public key from valid signature', function () { - const network = coins.get('flrp').network as FlareNetwork; - const message = Buffer.from('hello world', 'utf8'); - const privateKey = Buffer.from('0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', 'hex'); + const message = Buffer.from(SEED_ACCOUNT.message, 'utf8'); + const privateKey = Buffer.from(SEED_ACCOUNT.privateKey, 'hex'); // Create signature using the same private key const signature = utils.createSignature(network, message, privateKey); @@ -26,9 +488,8 @@ describe('Utils', function () { }); it('should recover same public key for same message and signature', function () { - const network = coins.get('flrp').network as FlareNetwork; - const message = Buffer.from('hello world', 'utf8'); - const privateKey = Buffer.from('0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', 'hex'); + const message = Buffer.from(SEED_ACCOUNT.message, 'utf8'); + const privateKey = Buffer.from(SEED_ACCOUNT.privateKey, 'hex'); const signature = utils.createSignature(network, message, privateKey); const pubKey1 = utils.recoverySignature(network, message, signature); @@ -38,12 +499,10 @@ describe('Utils', function () { }); it('should recover public key that matches original key', function () { - const network = coins.get('flrp').network as FlareNetwork; - const message = Buffer.from('hello world', 'utf8'); - const privateKey = Buffer.from('0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', 'hex'); + const message = Buffer.from(SEED_ACCOUNT.message, 'utf8'); + const privateKey = Buffer.from(SEED_ACCOUNT.privateKey, 'hex'); // Get original public key - const { ecc } = require('@bitgo/secp256k1'); const originalPubKey = Buffer.from(ecc.pointFromScalar(privateKey, true) as Uint8Array); // Create signature and recover public key @@ -54,18 +513,28 @@ describe('Utils', function () { assert.strictEqual(recoveredPubKey.toString('hex'), originalPubKey.toString('hex')); }); - it('should throw error for invalid signature', function () { - const network = coins.get('flrp').network as FlareNetwork; - const message = Buffer.from('hello world', 'utf8'); - const invalidSignature = Buffer.from('invalid signature', 'utf8'); + it('should recover public key using ACCOUNT_1 keys', function () { + const message = Buffer.from(SEED_ACCOUNT.message, 'utf8'); + const privateKey = Buffer.from(ACCOUNT_1.privateKey, 'hex'); + + const originalPubKey = Buffer.from(ecc.pointFromScalar(privateKey, true) as Uint8Array); + + const signature = utils.createSignature(network, message, privateKey); + const recoveredPubKey = utils.recoverySignature(network, message, signature); + + assert.strictEqual(recoveredPubKey.toString('hex'), originalPubKey.toString('hex')); + }); + + it('should throw error for invalid signature length', function () { + const message = Buffer.from(SEED_ACCOUNT.message, 'utf8'); + const invalidSignature = Buffer.from(INVALID_SHORT_KEYPAIR_KEY, 'hex'); assert.throws(() => utils.recoverySignature(network, message, invalidSignature), /Failed to recover signature/); }); - it('should throw error for empty message', function () { - const network = coins.get('flrp').network as FlareNetwork; - const message = Buffer.alloc(0); - const signature = Buffer.alloc(65); // Empty but valid length signature (65 bytes: 64 signature + 1 recovery param) + it('should throw error for signature with invalid recovery parameter', function () { + const message = Buffer.from(SEED_ACCOUNT.message, 'utf8'); + const signature = Buffer.alloc(65); // Valid length but all zeros - invalid signature assert.throws(() => utils.recoverySignature(network, message, signature), /Failed to recover signature/); }); diff --git a/modules/statics/src/networks.ts b/modules/statics/src/networks.ts index c8f53f8922..1b65f7e6e2 100644 --- a/modules/statics/src/networks.ts +++ b/modules/statics/src/networks.ts @@ -17,7 +17,7 @@ export interface FlareNetwork extends BaseNetwork { assetId: string; vm?: string; txFee: string; - maxImportFee?: string; + maxImportFee: string; createSubnetTx?: string; createChainTx?: string; creationTxFee?: string;