From c04f75f083ad3db5c7451fe8f6845c1b4a9d1b13 Mon Sep 17 00:00:00 2001 From: Sakshi Joshi Date: Thu, 5 Feb 2026 16:22:19 +0530 Subject: [PATCH] feat(sdk-coin-flrp): add TxBuilder for P-chain staking TICKET: SC-5233 --- modules/sdk-coin-flrp/src/flrp.ts | 50 +++ modules/sdk-coin-flrp/src/lib/iface.ts | 21 +- modules/sdk-coin-flrp/src/lib/index.ts | 1 + .../lib/permissionlessDelegatorTxBuilder.ts | 320 ++++++++++++++++++ modules/sdk-coin-flrp/src/lib/transaction.ts | 46 ++- .../src/lib/transactionBuilderFactory.ts | 14 + modules/sdk-coin-flrp/src/lib/utils.ts | 24 ++ .../resources/transactionData/delegatorTx.ts | 76 +++++ modules/sdk-coin-flrp/test/unit/flrp.ts | 95 ++++++ .../lib/permissionlessDelegatorTxBuilder.ts | 287 ++++++++++++++++ modules/sdk-coin-flrp/test/unit/lib/utils.ts | 12 + 11 files changed, 937 insertions(+), 9 deletions(-) create mode 100644 modules/sdk-coin-flrp/src/lib/permissionlessDelegatorTxBuilder.ts create mode 100644 modules/sdk-coin-flrp/test/resources/transactionData/delegatorTx.ts create mode 100644 modules/sdk-coin-flrp/test/unit/lib/permissionlessDelegatorTxBuilder.ts diff --git a/modules/sdk-coin-flrp/src/flrp.ts b/modules/sdk-coin-flrp/src/flrp.ts index 6dcdaf713a..554db37e92 100644 --- a/modules/sdk-coin-flrp/src/flrp.ts +++ b/modules/sdk-coin-flrp/src/flrp.ts @@ -111,6 +111,10 @@ export class Flrp extends BaseCoin { this.validateImportTx(explainedTx.inputs, params.txParams); } break; + case TransactionType.AddPermissionlessDelegator: + // Validate delegation transaction against both txParams and explainedTx + this.validateDelegationTx(params.txParams, explainedTx); + break; default: throw new Error('Tx type is not supported yet'); } @@ -166,6 +170,52 @@ export class Flrp extends BaseCoin { } } + /** + * Validate AddPermissionlessDelegator transaction parameters. + * Validates both expected txParams and the parsed explainedTx for consistency. + * + * @param {FlrpTransactionParams} txParams - Expected transaction parameters + * @param {FlrpLib.TransactionExplanation} explainedTx - Parsed transaction explanation + */ + validateDelegationTx(txParams: FlrpTransactionParams, explainedTx: FlrpLib.TransactionExplanation): void { + if (!txParams.stakingOptions) { + throw new Error('Delegation transaction requires stakingOptions'); + } + + const { nodeID, amount, durationSeconds, rewardAddress } = txParams.stakingOptions; + + if (!nodeID) { + throw new Error('Delegation transaction requires nodeID'); + } + + if (!amount) { + throw new Error('Delegation transaction requires amount'); + } + + if (!durationSeconds) { + throw new Error('Delegation transaction requires durationSeconds'); + } + + if (!rewardAddress) { + throw new Error('Delegation transaction requires rewardAddress'); + } + + // Validate nodeID format using utility method + if (!utils.isValidNodeID(nodeID)) { + throw new Error(`Invalid nodeID format: ${nodeID}`); + } + + // Validate that the parsed transaction's output amount matches the expected staking amount + // The outputAmount in explainedTx represents the total stake amount in the transaction + if (explainedTx.outputAmount) { + const expectedAmount = new BigNumber(amount); + const actualAmount = new BigNumber(explainedTx.outputAmount); + if (!expectedAmount.isEqualTo(actualAmount)) { + throw new Error(`Delegation amount mismatch: expected ${amount}, transaction has ${explainedTx.outputAmount}`); + } + } + } + private getBuilder(): FlrpLib.TransactionBuilderFactory { return new FlrpLib.TransactionBuilderFactory(coins.get(this.getChain())); } diff --git a/modules/sdk-coin-flrp/src/lib/iface.ts b/modules/sdk-coin-flrp/src/lib/iface.ts index 5900767a81..83f4ae49f4 100644 --- a/modules/sdk-coin-flrp/src/lib/iface.ts +++ b/modules/sdk-coin-flrp/src/lib/iface.ts @@ -79,20 +79,37 @@ export type Tx = | evmSerial.ExportTx | evmSerial.ImportTx | pvmSerial.ExportTx - | pvmSerial.ImportTx; + | pvmSerial.ImportTx + | pvmSerial.AddPermissionlessDelegatorTx; -export type SerializedTx = evmSerial.ExportTx | evmSerial.ImportTx | pvmSerial.ExportTx | pvmSerial.ImportTx; +export type SerializedTx = + | evmSerial.ExportTx + | evmSerial.ImportTx + | pvmSerial.ExportTx + | pvmSerial.ImportTx + | pvmSerial.AddPermissionlessDelegatorTx; export type BaseTx = pvmSerial.BaseTx; export type Output = TransferableOutput; export interface FlrpVerifyTransactionOptions extends VerifyTransactionOptions { txParams: FlrpTransactionParams; } +/** + * Staking options for AddPermissionlessDelegator transactions + */ +export interface FlrpStakingOptions { + nodeID: string; + amount: string | number; + durationSeconds: string | number; + rewardAddress: string; +} + export interface FlrpTransactionParams extends TransactionParams { type: string; locktime?: number; unspents?: string[]; sourceChain?: string; + stakingOptions?: FlrpStakingOptions; } export interface FlrpEntry extends Entry { diff --git a/modules/sdk-coin-flrp/src/lib/index.ts b/modules/sdk-coin-flrp/src/lib/index.ts index 3e4f20552d..7e3edbfcc7 100644 --- a/modules/sdk-coin-flrp/src/lib/index.ts +++ b/modules/sdk-coin-flrp/src/lib/index.ts @@ -9,3 +9,4 @@ export { AtomicTransactionBuilder } from './atomicTransactionBuilder'; export { AtomicInCTransactionBuilder } from './atomicInCTransactionBuilder'; export { ImportInCTxBuilder } from './ImportInCTxBuilder'; export { ImportInPTxBuilder } from './ImportInPTxBuilder'; +export { PermissionlessDelegatorTxBuilder } from './permissionlessDelegatorTxBuilder'; diff --git a/modules/sdk-coin-flrp/src/lib/permissionlessDelegatorTxBuilder.ts b/modules/sdk-coin-flrp/src/lib/permissionlessDelegatorTxBuilder.ts new file mode 100644 index 0000000000..95e3658680 --- /dev/null +++ b/modules/sdk-coin-flrp/src/lib/permissionlessDelegatorTxBuilder.ts @@ -0,0 +1,320 @@ +import { + utils as FlareUtils, + TypeSymbols, + pvm, + networkIDs, + UnsignedTx, + pvmSerial, + Credential, + TransferOutput, +} from '@flarenetwork/flarejs'; +import { BuildTransactionError, NotSupported, TransactionType } from '@bitgo/sdk-core'; +import { BaseCoin as CoinConfig } from '@bitgo/statics'; +import { Transaction } from './transaction'; +import { TransactionBuilder } from './transactionBuilder'; +import utils from './utils'; +import { FlrpFeeState } from '@bitgo/public-types'; +import { Tx } from './iface'; + +/** + * Builder for AddPermissionlessDelegator transactions on Flare P-Chain. + * + * This builder creates delegation transactions where a user delegates stake + * to an existing validator. Unlike AddPermissionlessValidator: + * - No BLS keys required + * - Delegates to an existing validator's nodeID + * - Rewards go to corresponding C-chain address + */ +export class PermissionlessDelegatorTxBuilder extends TransactionBuilder { + protected _nodeID: string; + protected _startTime: bigint; + protected _endTime: bigint; + protected _stakeAmount: bigint; + protected _feeState: FlrpFeeState | undefined; + + constructor(coinConfig: Readonly) { + super(coinConfig); + this.transaction._fee.fee = this.transaction._network.txFee; + } + + protected get transactionType(): TransactionType { + return TransactionType.AddPermissionlessDelegator; + } + + // Validation methods + validateNodeID(nodeID: string): void { + if (!nodeID) { + throw new BuildTransactionError('Invalid transaction: missing nodeID'); + } + if (!nodeID.startsWith('NodeID-')) { + throw new BuildTransactionError('Invalid transaction: invalid NodeID tag'); + } + if (!utils.isValidNodeID(nodeID)) { + throw new BuildTransactionError('Invalid transaction: NodeID is not in cb58 format'); + } + } + + validateStakeDuration(startTime: bigint, endTime: bigint): void { + if (endTime < startTime) { + throw new BuildTransactionError('End date cannot be less than start date'); + } + } + + validateStakeAmount(amount: bigint): void { + const minDelegationStake = BigInt( + this.transaction._network.minDelegationStake || this.transaction._network.minStake + ); + if (amount < minDelegationStake) { + throw new BuildTransactionError( + 'Minimum delegation amount is ' + Number(minDelegationStake) / 1000000000 + ' FLR.' + ); + } + } + + // Builder methods + /** + * + * NOTE: On Flare, staking rewards are distributed on C-chain (not P-chain). + * Rewards go to the C-chain address derived from the same public key as + * the P-chain staking address. This parameter is passed to the FlareJS API + * but has NO on-chain effect on reward distribution. + * + * @param address P-chain address in Bech32 format (e.g., "P-flare1..." or "P-costwo1...") + */ + rewardAddress(address: string): this { + this.transaction._rewardAddresses = [utils.parseAddress(address)]; + return this; + } + + /** + * Set the validator node ID to delegate to + * @param nodeID Validator node ID in format "NodeID-..." + */ + nodeID(nodeID: string): this { + this.validateNodeID(nodeID); + this._nodeID = nodeID; + return this; + } + + /** + * Set the start time for the delegation period + * @param value Unix timestamp in seconds + */ + startTime(value: string | number): this { + this._startTime = BigInt(value); + return this; + } + + /** + * Set the end time for the delegation period + * @param value Unix timestamp in seconds + */ + endTime(value: string | number): this { + this._endTime = BigInt(value); + return this; + } + + /** + * Set the amount to stake/delegate + * @param value Amount in nanoFLR (wei) + */ + stakeAmount(value: bigint | string): this { + const valueBigInt = typeof value === 'bigint' ? value : BigInt(value); + this.validateStakeAmount(valueBigInt); + this._stakeAmount = valueBigInt; + return this; + } + + /** + * @param feeState Fee state from P-chain + */ + feeState(feeState: FlrpFeeState): this { + this._feeState = feeState; + this.transaction._feeState = feeState; + return this; + } + + /** + * Initialize builder from a parsed AddPermissionlessDelegatorTx. + * Used to reconstruct the builder state from a raw transaction for signing. + * + * @param tx The parsed transaction + * @param rawBytes Optional raw transaction bytes + * @param parsedCredentials Optional credentials from the parsed transaction + */ + initBuilder(tx: Tx, rawBytes?: Buffer, parsedCredentials?: Credential[]): this { + const delegatorTx = tx as pvmSerial.AddPermissionlessDelegatorTx; + + if (!this.verifyTxType(delegatorTx._type)) { + throw new NotSupported('Transaction cannot be parsed or has an unsupported transaction type'); + } + + // Extract validator info (nodeID, startTime, endTime, weight/stakeAmount) + const validator = delegatorTx.subnetValidator.validator; + this._nodeID = validator.nodeId.toString(); + this._startTime = validator.startTime.value(); + this._endTime = validator.endTime.value(); + this._stakeAmount = validator.weight.value(); + + // Extract from addresses from stake outputs + // In delegation transactions, stake outputs contain the owner addresses + const stakeOutputs = delegatorTx.stake; + if (stakeOutputs.length > 0) { + const firstStakeOutput = stakeOutputs[0]; + // Stake outputs use TransferOutput type with outputOwners property + const transferOutput = firstStakeOutput.output as TransferOutput; + const outputOwners = transferOutput.outputOwners; + if (outputOwners) { + this.transaction._threshold = outputOwners.threshold.value(); + this.transaction._fromAddresses = outputOwners.addrs.map((addr) => Buffer.from(addr.toBytes())); + } + } + + // Extract reward addresses from delegatorRewardsOwner + const rewardsOwner = delegatorTx.getDelegatorRewardsOwner(); + if (rewardsOwner && rewardsOwner.addrs && rewardsOwner.addrs.length > 0) { + this.transaction._rewardAddresses = rewardsOwner.addrs.map((addr) => Buffer.from(addr.toBytes())); + } + + const credentials = parsedCredentials || []; + + if (rawBytes && credentials.length > 0) { + this.transaction._rawSignedBytes = rawBytes; + } + + // Create the UnsignedTx with parsed credentials + // AddressMaps will be empty as they're computed during signing + const unsignedTx = new UnsignedTx(delegatorTx, [], new FlareUtils.AddressMaps([]), credentials); + + this.transaction.setTransaction(unsignedTx); + return this; + } + + /** + * Instance method to verify transaction type + * @param type TypeSymbol from parsed transaction + */ + verifyTxType(type: TypeSymbols): boolean { + return PermissionlessDelegatorTxBuilder.verifyTxType(type); + } + + protected async buildImplementation(): Promise { + this.buildFlareTransaction(); + this.transaction.setTransactionType(this.transactionType); + if (this.hasSigner()) { + for (const keyPair of this._signer) { + await this.transaction.sign(keyPair); + } + } + return this.transaction; + } + + /** + * Get the user's address (index 0) for delegation. + * + * For delegation transactions, we use only the user key because: + * 1. On-chain rewards go to the C-chain address derived from the delegator's public key + * 2. Using the user key ensures rewards go to the user's corresponding C-chain address + * 3. The user key is at index 0 in the fromAddresses array (BitGo convention: [user, bitgo, backup]) + * + * @returns Buffer containing the user's address + * @protected + */ + protected getUserAddress(): Buffer { + const userIndex = 0; + if (!this.transaction._fromAddresses || this.transaction._fromAddresses.length <= userIndex) { + throw new BuildTransactionError('User address (index 0) is required for delegation'); + } + const userAddress = Buffer.from(this.transaction._fromAddresses[userIndex]); + if (userAddress.length !== 20) { + throw new BuildTransactionError(`Invalid user address length: expected 20 bytes, got ${userAddress.length}`); + } + return userAddress; + } + + /** + * Build the permissionless delegator transaction using FlareJS. + * Uses pvm.e.newAddPermissionlessDelegatorTx (post-Etna API). + * + * Note: The rewardAddresses parameter is accepted by the API but does NOT affect + * where rewards are sent on-chain - rewards always go to the C-chain address + * derived from the delegator's public key (user key at index 0). + * @protected + */ + protected buildFlareTransaction(): void { + if (this.transaction.hasCredentials) return; + + // Validate required fields + if (!this._nodeID) { + throw new BuildTransactionError('NodeID is required for delegation'); + } + if (!this._startTime) { + throw new BuildTransactionError('Start time is required for delegation'); + } + if (!this._endTime) { + throw new BuildTransactionError('End time is required for delegation'); + } + if (!this._stakeAmount) { + throw new BuildTransactionError('Stake amount is required for delegation'); + } + if (!this.transaction._context) { + throw new BuildTransactionError('Context is required for delegation'); + } + if (!this.transaction._fromAddresses || this.transaction._fromAddresses.length === 0) { + throw new BuildTransactionError('From addresses are required for delegation'); + } + if (!this._feeState) { + throw new BuildTransactionError('Fee state is required for delegation'); + } + + this.validateStakeDuration(this._startTime, this._endTime); + + // Convert decoded UTXOs to FlareJS Utxo objects + if (!this.transaction._utxos || this.transaction._utxos.length === 0) { + throw new BuildTransactionError('UTXOs are required for delegation'); + } + const utxos = utils.decodedToUtxos(this.transaction._utxos, this.transaction._network.assetId); + + // Use only the user key (index 0) for fromAddressesBytes + // This ensures the C-chain reward address is derived from the user's public key + const userAddress = this.getUserAddress(); + + const rewardAddresses = + this.transaction._rewardAddresses.length > 0 ? this.transaction._rewardAddresses : [userAddress]; + + // Use Etna (post-fork) API - pvm.e.newAddPermissionlessDelegatorTx + const delegatorTx = pvm.e.newAddPermissionlessDelegatorTx( + { + end: this._endTime, + feeState: this._feeState, + fromAddressesBytes: [userAddress], + nodeId: this._nodeID, + rewardAddresses: rewardAddresses, + start: this._startTime, + subnetId: networkIDs.PrimaryNetworkID.toString(), + utxos, + weight: this._stakeAmount, + }, + this.transaction._context + ); + + this.transaction.setTransaction(delegatorTx as UnsignedTx); + } + + /** + * Verify if the transaction type matches AddPermissionlessDelegatorTx + * @param type TypeSymbol from parsed transaction + */ + static verifyTxType(type: TypeSymbols): boolean { + return type === TypeSymbols.AddPermissionlessDelegatorTx; + } + + /** @inheritdoc */ + protected get transaction(): Transaction { + return this._transaction; + } + + protected set transaction(transaction: Transaction) { + this._transaction = transaction; + } +} diff --git a/modules/sdk-coin-flrp/src/lib/transaction.ts b/modules/sdk-coin-flrp/src/lib/transaction.ts index 10ba77e2b5..9e3697b31b 100644 --- a/modules/sdk-coin-flrp/src/lib/transaction.ts +++ b/modules/sdk-coin-flrp/src/lib/transaction.ts @@ -376,7 +376,11 @@ export class Transaction extends BaseTransaction { } setTransactionType(transactionType: TransactionType): void { - if (![TransactionType.AddPermissionlessValidator].includes(transactionType)) { + if ( + ![TransactionType.AddPermissionlessValidator, TransactionType.AddPermissionlessDelegator].includes( + transactionType + ) + ) { throw new Error(`Transaction type ${transactionType} is not supported`); } this._type = transactionType; @@ -451,6 +455,14 @@ export class Transaction extends BaseTransaction { }, ]; + case TransactionType.AddPermissionlessDelegator: + return [ + { + address: (tx as pvmSerial.AddPermissionlessDelegatorTx).subnetValidator.validator.nodeId.toString(), + value: (tx as pvmSerial.AddPermissionlessDelegatorTx).subnetValidator.validator.weight.toJSON(), + }, + ]; + default: return []; } @@ -472,6 +484,9 @@ export class Transaction extends BaseTransaction { case TransactionType.AddPermissionlessValidator: return (tx as pvmSerial.AddPermissionlessValidatorTx).baseTx.outputs.map(utils.mapOutputToEntry(this._network)); + case TransactionType.AddPermissionlessDelegator: + return (tx as pvmSerial.AddPermissionlessDelegatorTx).baseTx.outputs.map(utils.mapOutputToEntry(this._network)); + default: return []; } @@ -519,17 +534,32 @@ export class Transaction extends BaseTransaction { })); } - case TransactionType.AddPermissionlessValidator: - default: - const baseTx = tx as pvmSerial.AddPermissionlessValidatorTx; - if (baseTx.baseTx?.inputs) { - return baseTx.baseTx.inputs.map((input) => ({ + case TransactionType.AddPermissionlessValidator: { + const validatorTx = tx as pvmSerial.AddPermissionlessValidatorTx; + if (validatorTx.baseTx?.inputs) { + return validatorTx.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 []; + } + + case TransactionType.AddPermissionlessDelegator: { + const delegatorTx = tx as pvmSerial.AddPermissionlessDelegatorTx; + if (delegatorTx.baseTx?.inputs) { + return delegatorTx.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 []; + } + + default: + return []; } } @@ -541,7 +571,9 @@ export class Transaction extends BaseTransaction { const changeAmount = txJson.changeOutputs.reduce((p, n) => p + BigInt(n.value), BigInt(0)).toString(); let rewardAddresses; - if ([TransactionType.AddPermissionlessValidator].includes(txJson.type)) { + if ( + [TransactionType.AddPermissionlessValidator, TransactionType.AddPermissionlessDelegator].includes(txJson.type) + ) { rewardAddresses = this.rewardAddresses; displayOrder.splice(6, 0, 'rewardAddresses'); } diff --git a/modules/sdk-coin-flrp/src/lib/transactionBuilderFactory.ts b/modules/sdk-coin-flrp/src/lib/transactionBuilderFactory.ts index df6640d3d5..df3b902fb1 100644 --- a/modules/sdk-coin-flrp/src/lib/transactionBuilderFactory.ts +++ b/modules/sdk-coin-flrp/src/lib/transactionBuilderFactory.ts @@ -6,6 +6,7 @@ import { ExportInPTxBuilder } from './ExportInPTxBuilder'; import { ImportInPTxBuilder } from './ImportInPTxBuilder'; import { ExportInCTxBuilder } from './ExportInCTxBuilder'; import { ImportInCTxBuilder } from './ImportInCTxBuilder'; +import { PermissionlessDelegatorTxBuilder } from './permissionlessDelegatorTxBuilder'; import { SerializedTx } from './iface'; import utils from './utils'; @@ -109,6 +110,11 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { builder.initBuilder(tx as pvmSerial.ExportTx, rawBuffer, credentials); return builder; } + if (PermissionlessDelegatorTxBuilder.verifyTxType(tx._type)) { + const builder = this.getDelegatorBuilder(); + builder.initBuilder(tx as pvmSerial.AddPermissionlessDelegatorTx, rawBuffer, credentials); + return builder; + } } throw new NotSupported('Transaction type not supported'); } @@ -165,6 +171,14 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { return new ExportInCTxBuilder(this._coinConfig); } + /** + * Get builder for AddPermissionlessDelegator transactions. + * Used for delegating stake to existing validators. + */ + getDelegatorBuilder(): PermissionlessDelegatorTxBuilder { + return new PermissionlessDelegatorTxBuilder(this._coinConfig); + } + /** @inheritdoc */ getWalletInitializationBuilder(): TransactionBuilder { throw new NotSupported('Wallet initialization is not needed'); diff --git a/modules/sdk-coin-flrp/src/lib/utils.ts b/modules/sdk-coin-flrp/src/lib/utils.ts index 3c507066dc..772c40d270 100644 --- a/modules/sdk-coin-flrp/src/lib/utils.ts +++ b/modules/sdk-coin-flrp/src/lib/utils.ts @@ -25,6 +25,10 @@ import { ADDRESS_SEPARATOR, DecodedUtxoObj, Output, SECP256K1_Transfer_Output, T import bs58 from 'bs58'; import { bech32 } from 'bech32'; +// NodeID format constants +const NODE_ID_PREFIX = 'NodeID-'; +const NODE_ID_DECODED_LENGTH = 24; + export class Utils implements BaseUtils { isValidTransactionId(txId: string): boolean { throw new Error('Method not implemented.'); @@ -127,6 +131,26 @@ export class Utils implements BaseUtils { return /^(0x){0,1}([0-9a-f])+$/i.test(str); } + /** + * Validates a NodeID format + * @param {string} nodeID - NodeID to validate (e.g., "NodeID-xxx") + * @returns {boolean} - true if valid, false otherwise + */ + isValidNodeID(nodeID: string): boolean { + if (!nodeID) { + return false; + } + if (!nodeID.startsWith(NODE_ID_PREFIX)) { + return false; + } + try { + const decoded = bs58.decode(nodeID.slice(NODE_ID_PREFIX.length)); + return decoded.length === NODE_ID_DECODED_LENGTH; + } catch { + return false; + } + } + /** * Creates a signature using the Flare network parameters * Returns a 65-byte signature (64 bytes signature + 1 byte recovery parameter) diff --git a/modules/sdk-coin-flrp/test/resources/transactionData/delegatorTx.ts b/modules/sdk-coin-flrp/test/resources/transactionData/delegatorTx.ts new file mode 100644 index 0000000000..62ce4a3c11 --- /dev/null +++ b/modules/sdk-coin-flrp/test/resources/transactionData/delegatorTx.ts @@ -0,0 +1,76 @@ +export const DELEGATION_TX_1 = { + txhash: '22Uff2JJeULyrtrqpcwbXHi5Kk3RyDf4yWBeKE6iEJNN9mSft', + signedHex: + '0x00000000001a0000007200000000000000000000000000000000000000000000000000000000000000000000000158734f94af871c3d131b56131b6fb7a0291eacadd261e69dfb42a9cdf6f7fddd0000000700000000515b482e000000000000000000000001000000010c65bde03c2f0d7129db88b5af46f3984e6eb06f0000000d01f7dd0e5da53081c6099d8f0afa210155ad458a901526f72674f4d3526fee8e0000000058734f94af871c3d131b56131b6fb7a0291eacadd261e69dfb42a9cdf6f7fddd000000050000000002ebae40000000010000000019b7e281d137b2e514433fede0a9ff8c86e5a4b6c44ca4dd118ec6c68dfc3c2e0000000058734f94af871c3d131b56131b6fb7a0291eacadd261e69dfb42a9cdf6f7fddd000000050000000002ebae40000000010000000083faa4bd5f31b3f8341222a058a07b65fc00a8a40729d2cdec6bac4c551a45e80000000058734f94af871c3d131b56131b6fb7a0291eacadd261e69dfb42a9cdf6f7fddd000000050000000002eb2a6a000000010000000092c456b05e78c3e3c68a237b4496062e57f342552ad24b6806828f2a0e45d06b0000000058734f94af871c3d131b56131b6fb7a0291eacadd261e69dfb42a9cdf6f7fddd00000005000000003b8b03ea0000000100000000b67a44c2ce746c9169a91945d6ba9810d91f492949e96724996d5a6e1e7c90830000000058734f94af871c3d131b56131b6fb7a0291eacadd261e69dfb42a9cdf6f7fddd00000005000000003b8b87c00000000100000000c2458a918382b12cec5f2fb3831a21af064348fbd111c72436e187e2f061a3510000000058734f94af871c3d131b56131b6fb7a0291eacadd261e69dfb42a9cdf6f7fddd0000000500000017485863800000000100000000c63bca17b55213b6daaddb79e5a4ca70e1e339a091d919a13e509626bed1fcd20000000058734f94af871c3d131b56131b6fb7a0291eacadd261e69dfb42a9cdf6f7fddd00000005000001b5b8aba9ea0000000100000000cc2be401e345098f2ff0bed49defa523fe3a3b2885ff8b923b7aaff79d5242dc0000000058734f94af871c3d131b56131b6fb7a0291eacadd261e69dfb42a9cdf6f7fddd0000000500000000026351120000000100000000d018ee6fdd5b7b62a76221835906971f691e8a6827e76895ada9749d282c7d3a0000000058734f94af871c3d131b56131b6fb7a0291eacadd261e69dfb42a9cdf6f7fddd00000005000000003b8b87c00000000100000000d9da0a8661e56097be7e039458fd78d1acf7c8f483311320cf4a961eae2df5cc0000000058734f94af871c3d131b56131b6fb7a0291eacadd261e69dfb42a9cdf6f7fddd0000000500002ba7dee339ea0000000100000000f7df027fbffbf24b9340580f5c157a908a7e54622714355385e416d9b313d9600000000058734f94af871c3d131b56131b6fb7a0291eacadd261e69dfb42a9cdf6f7fddd000000050000000253ed5f800000000100000000f938b9085f48102bbe5a037eab4391939b2dae590bb13c981fe863a93179be8d0000000058734f94af871c3d131b56131b6fb7a0291eacadd261e69dfb42a9cdf6f7fddd000000050000000002eb2a6a0000000100000000fe1dff2fd7db1696e05afe3f96123d9d70fbb5ca80104b2a9e322beb74bacd520000000058734f94af871c3d131b56131b6fb7a0291eacadd261e69dfb42a9cdf6f7fddd0000000500000001e5b7df40000000010000000000000000664b4924a25af8be5f07052b2c2e582f7c10a654000000006926d93d0000000069394e3d00002d79883d200000000000000000000000000000000000000000000000000000000000000000000000000158734f94af871c3d131b56131b6fb7a0291eacadd261e69dfb42a9cdf6f7fddd0000000700002d79883d2000000000000000000000000001000000010c65bde03c2f0d7129db88b5af46f3984e6eb06f0000000b000000000000000000000001000000010c65bde03c2f0d7129db88b5af46f3984e6eb06f0000000d00000009000000012d4872c17946adf5fd4e3094001610c30218857f30c649ac08a9f0d1f27ff84c190bce972096de6f14b1b72622319892302daa97841ec99af713bcf9c729c9bd0100000009000000012d4872c17946adf5fd4e3094001610c30218857f30c649ac08a9f0d1f27ff84c190bce972096de6f14b1b72622319892302daa97841ec99af713bcf9c729c9bd0100000009000000012d4872c17946adf5fd4e3094001610c30218857f30c649ac08a9f0d1f27ff84c190bce972096de6f14b1b72622319892302daa97841ec99af713bcf9c729c9bd0100000009000000012d4872c17946adf5fd4e3094001610c30218857f30c649ac08a9f0d1f27ff84c190bce972096de6f14b1b72622319892302daa97841ec99af713bcf9c729c9bd0100000009000000012d4872c17946adf5fd4e3094001610c30218857f30c649ac08a9f0d1f27ff84c190bce972096de6f14b1b72622319892302daa97841ec99af713bcf9c729c9bd0100000009000000012d4872c17946adf5fd4e3094001610c30218857f30c649ac08a9f0d1f27ff84c190bce972096de6f14b1b72622319892302daa97841ec99af713bcf9c729c9bd0100000009000000012d4872c17946adf5fd4e3094001610c30218857f30c649ac08a9f0d1f27ff84c190bce972096de6f14b1b72622319892302daa97841ec99af713bcf9c729c9bd0100000009000000012d4872c17946adf5fd4e3094001610c30218857f30c649ac08a9f0d1f27ff84c190bce972096de6f14b1b72622319892302daa97841ec99af713bcf9c729c9bd0100000009000000012d4872c17946adf5fd4e3094001610c30218857f30c649ac08a9f0d1f27ff84c190bce972096de6f14b1b72622319892302daa97841ec99af713bcf9c729c9bd0100000009000000012d4872c17946adf5fd4e3094001610c30218857f30c649ac08a9f0d1f27ff84c190bce972096de6f14b1b72622319892302daa97841ec99af713bcf9c729c9bd0100000009000000012d4872c17946adf5fd4e3094001610c30218857f30c649ac08a9f0d1f27ff84c190bce972096de6f14b1b72622319892302daa97841ec99af713bcf9c729c9bd0100000009000000012d4872c17946adf5fd4e3094001610c30218857f30c649ac08a9f0d1f27ff84c190bce972096de6f14b1b72622319892302daa97841ec99af713bcf9c729c9bd0100000009000000012d4872c17946adf5fd4e3094001610c30218857f30c649ac08a9f0d1f27ff84c190bce972096de6f14b1b72622319892302daa97841ec99af713bcf9c729c9bd01c161eee9', + nodeID: 'NodeID-AKt7WaK6ozEy5K8azKNacZXLzxZ9xFgC7', + stakeAmount: '50000000000000', // 50,000 FLR in nanoFLR + startTime: 1764280637, + endTime: 1765490237, // ~14 days later + pAddresses: ['P-costwo1p3jmmcpu9uxhz2wm3z6673hnnp8xavr0vy6vqn'], + threshold: 1, + locktime: 0, + context: { + xBlockchainID: 'FJuSwZuP85eyBpuBrKECnpPedGyXoDy2hP9q4JD8qBTZGxYbJ', + pBlockchainID: '11111111111111111111111111111111LpoYY', + cBlockchainID: 'vE8M98mEQH6wk56sStD1ML8HApTgSqfJZLk9gQ3Fsd4i6m3Bi', + avaxAssetID: 'fxMAKpBQQpFedrUhWMsDYfCUJxdUw4mneTczKBzNg3rc2JUub', + baseTxFee: 1000000n, + createAssetTxFee: 1000000n, + createSubnetTxFee: 100000000n, + transformSubnetTxFee: 100000000n, + createBlockchainTxFee: 100000000n, + addPrimaryNetworkValidatorFee: 0n, + addPrimaryNetworkDelegatorFee: 0n, + addSubnetValidatorFee: 1000000n, + addSubnetDelegatorFee: 1000000n, + networkID: 114, + hrp: 'costwo', + platformFeeConfig: { + weights: { 0: 1, 1: 1000, 2: 1000, 3: 4 }, + maxCapacity: 1000000n, + maxPerSecond: 100000n, + targetPerSecond: 50000n, + minPrice: 250n, + excessConversionConstant: 2164043n, + }, + }, +}; + +// Second delegation transaction - simpler with 3 inputs (used for round-trip testing) +export const DELEGATION_TX_2 = { + txhash: '2YvHZngWoddX8vS8ZR5izJJmdhRnkhqQHqpJSEiW66Mtf3CB28', + signedHex: + '0x00000000001a0000007200000000000000000000000000000000000000000000000000000000000000000000000158734f94af871c3d131b56131b6fb7a0291eacadd261e69dfb42a9cdf6f7fddd000000070000000002635112000000000000000000000001000000010c65bde03c2f0d7129db88b5af46f3984e6eb06f00000003048351567748c8fbc3ce9f638b16b1fc324ea52620d020f0881ec49968a7f1420000000058734f94af871c3d131b56131b6fb7a0291eacadd261e69dfb42a9cdf6f7fddd0000000500002d7984ff54440000000100000000a27213128a72837d0711284e38980649d5d98d98c1d50385db69fabc181cd99d0000000058734f94af871c3d131b56131b6fb7a0291eacadd261e69dfb42a9cdf6f7fddd000000050000000002ebae400000000100000000e918bf6dceb24368264cb6b9c5d67e9f94dd06021b484836363378467fcc89900000000058734f94af871c3d131b56131b6fb7a0291eacadd261e69dfb42a9cdf6f7fddd000000050000000002ebae40000000010000000000000000664b4924a25af8be5f07052b2c2e582f7c10a654000000006926ca570000000069393f5700002d79883d200000000000000000000000000000000000000000000000000000000000000000000000000158734f94af871c3d131b56131b6fb7a0291eacadd261e69dfb42a9cdf6f7fddd0000000700002d79883d2000000000000000000000000001000000010c65bde03c2f0d7129db88b5af46f3984e6eb06f0000000b000000000000000000000001000000010c65bde03c2f0d7129db88b5af46f3984e6eb06f00000003000000090000000145ba455f2766e6b9c8aa913aceab2c512db29efa855b2303a0dba72cb90ac12e5d9a942419d6854f0549d43248a4ef22331665f78ee46b8a5655eb61c941641b01000000090000000145ba455f2766e6b9c8aa913aceab2c512db29efa855b2303a0dba72cb90ac12e5d9a942419d6854f0549d43248a4ef22331665f78ee46b8a5655eb61c941641b01000000090000000145ba455f2766e6b9c8aa913aceab2c512db29efa855b2303a0dba72cb90ac12e5d9a942419d6854f0549d43248a4ef22331665f78ee46b8a5655eb61c941641b019d5242dc', + nodeID: 'NodeID-AKt7WaK6ozEy5K8azKNacZXLzxZ9xFgC7', + stakeAmount: '50000000000000', // 50,000 FLR in nanoFLR + startTime: 1764276823, + endTime: 1765486423, // ~14 days later + pAddresses: ['P-costwo1p3jmmcpu9uxhz2wm3z6673hnnp8xavr0vy6vqn'], + threshold: 1, + locktime: 0, + context: { + xBlockchainID: 'FJuSwZuP85eyBpuBrKECnpPedGyXoDy2hP9q4JD8qBTZGxYbJ', + pBlockchainID: '11111111111111111111111111111111LpoYY', + cBlockchainID: 'vE8M98mEQH6wk56sStD1ML8HApTgSqfJZLk9gQ3Fsd4i6m3Bi', + avaxAssetID: 'fxMAKpBQQpFedrUhWMsDYfCUJxdUw4mneTczKBzNg3rc2JUub', + baseTxFee: 1000000n, + createAssetTxFee: 1000000n, + createSubnetTxFee: 100000000n, + transformSubnetTxFee: 100000000n, + createBlockchainTxFee: 100000000n, + addPrimaryNetworkValidatorFee: 0n, + addPrimaryNetworkDelegatorFee: 0n, + addSubnetValidatorFee: 1000000n, + addSubnetDelegatorFee: 1000000n, + networkID: 114, + hrp: 'costwo', + platformFeeConfig: { + weights: { 0: 1, 1: 1000, 2: 1000, 3: 4 }, + maxCapacity: 1000000n, + maxPerSecond: 100000n, + targetPerSecond: 50000n, + minPrice: 250n, + excessConversionConstant: 2164043n, + }, + }, +}; diff --git a/modules/sdk-coin-flrp/test/unit/flrp.ts b/modules/sdk-coin-flrp/test/unit/flrp.ts index 9a2e702349..21855074d4 100644 --- a/modules/sdk-coin-flrp/test/unit/flrp.ts +++ b/modules/sdk-coin-flrp/test/unit/flrp.ts @@ -559,4 +559,99 @@ describe('Flrp test cases', function () { recoveredPubKey.length.should.equal(33); }); }); + + describe('Delegation Transaction Validation', () => { + describe('validateDelegationTx', () => { + const validStakingOptions = { + nodeID: 'NodeID-AK7sPBsZM9rQwse23aLhEEBPHZD5gkLrL', + amount: '50000000000000', // 50,000 FLR in nFLR + durationSeconds: 86400 * 14, // 14 days + rewardAddress: 'C-costwo1uyp5n76gjqltrddur7qlrsmt3kyh8fnrmwhqk7', + }; + + const validExplainedTx = { + outputAmount: '50000000000000', + type: TransactionType.AddPermissionlessDelegator, + inputs: [], + outputs: [], + changeOutputs: [], + changeAmount: '0', + fee: { fee: '1000' }, + }; + + it('should validate a correct delegation transaction', () => { + const txParams = { stakingOptions: validStakingOptions }; + assert.doesNotThrow(() => basecoin.validateDelegationTx(txParams, validExplainedTx)); + }); + + it('should throw when stakingOptions is missing', () => { + const txParams = {}; + assert.throws( + () => basecoin.validateDelegationTx(txParams, validExplainedTx), + /Delegation transaction requires stakingOptions/ + ); + }); + + it('should throw when nodeID has invalid format', () => { + const txParams = { + stakingOptions: { + ...validStakingOptions, + nodeID: 'InvalidPrefix-123', + }, + }; + assert.throws(() => basecoin.validateDelegationTx(txParams, validExplainedTx), /Invalid nodeID format/); + }); + + it('should throw when amount is missing', () => { + const txParams = { + stakingOptions: { + ...validStakingOptions, + amount: undefined, + }, + }; + assert.throws( + () => basecoin.validateDelegationTx(txParams, validExplainedTx), + /Delegation transaction requires amount/ + ); + }); + + it('should throw when durationSeconds is missing', () => { + const txParams = { + stakingOptions: { + ...validStakingOptions, + durationSeconds: undefined, + }, + }; + assert.throws( + () => basecoin.validateDelegationTx(txParams, validExplainedTx), + /Delegation transaction requires durationSeconds/ + ); + }); + + it('should throw when rewardAddress is missing', () => { + const txParams = { + stakingOptions: { + ...validStakingOptions, + rewardAddress: undefined, + }, + }; + assert.throws( + () => basecoin.validateDelegationTx(txParams, validExplainedTx), + /Delegation transaction requires rewardAddress/ + ); + }); + + it('should throw when outputAmount does not match expected amount', () => { + const txParams = { stakingOptions: validStakingOptions }; + const mismatchedExplainedTx = { + ...validExplainedTx, + outputAmount: '60000000000000', // Different from expected 50000000000000 + }; + assert.throws( + () => basecoin.validateDelegationTx(txParams, mismatchedExplainedTx), + /Delegation amount mismatch/ + ); + }); + }); + }); }); diff --git a/modules/sdk-coin-flrp/test/unit/lib/permissionlessDelegatorTxBuilder.ts b/modules/sdk-coin-flrp/test/unit/lib/permissionlessDelegatorTxBuilder.ts new file mode 100644 index 0000000000..c5a853935b --- /dev/null +++ b/modules/sdk-coin-flrp/test/unit/lib/permissionlessDelegatorTxBuilder.ts @@ -0,0 +1,287 @@ +import assert from 'assert'; +import 'should'; +import { coins } from '@bitgo/statics'; +import { TransactionType } from '@bitgo/sdk-core'; +import { TransactionBuilderFactory, PermissionlessDelegatorTxBuilder, Transaction } from '../../../src/lib'; +import { SEED_ACCOUNT, ACCOUNT_1, CONTEXT } from '../../resources/account'; +import utils from '../../../src/lib/utils'; +import { DELEGATION_TX_2 } from '../../resources/transactionData/delegatorTx'; + +describe('Flrp PermissionlessDelegatorTxBuilder', () => { + const coinConfig = coins.get('tflrp'); + const factory = new TransactionBuilderFactory(coinConfig); + + describe('getDelegatorBuilder', () => { + it('should return a PermissionlessDelegatorTxBuilder instance', () => { + const builder = factory.getDelegatorBuilder(); + builder.should.be.instanceOf(PermissionlessDelegatorTxBuilder); + }); + }); + + describe('validate nodeID', () => { + it('should fail when nodeID is missing', () => { + const builder = factory.getDelegatorBuilder(); + assert.throws( + () => { + builder.nodeID(''); + }, + (e: any) => e.message === 'Invalid transaction: missing nodeID' + ); + }); + + it('should fail when nodeID has invalid prefix', () => { + const builder = factory.getDelegatorBuilder(); + assert.throws( + () => { + builder.nodeID('InvalidPrefix-123456789'); + }, + (e: any) => e.message === 'Invalid transaction: invalid NodeID tag' + ); + }); + + it('should accept valid nodeID format', () => { + const builder = factory.getDelegatorBuilder(); + const validNodeID = 'NodeID-AK7sPBsZM9rQwse23aLhEEBPHZD5gkLrL'; + (() => builder.nodeID(validNodeID)).should.not.throw(); + }); + }); + + describe('validate stake duration', () => { + it('should fail when end time is less than start time', () => { + const builder = factory.getDelegatorBuilder(); + const startTime = BigInt(Math.floor(Date.now() / 1000) + 60); + const endTime = BigInt(Math.floor(Date.now() / 1000) - 60); + + builder.startTime(startTime.toString()); + builder.endTime(endTime.toString()); + + // The validation happens in buildFlareTransaction, but we can test validateStakeDuration directly + assert.throws( + () => { + builder.validateStakeDuration(startTime, endTime); + }, + (e: any) => e.message === 'End date cannot be less than start date' + ); + }); + + it('should accept valid stake duration', () => { + const builder = factory.getDelegatorBuilder(); + const startTime = BigInt(Math.floor(Date.now() / 1000) + 60); + const endTime = BigInt(Math.floor(Date.now() / 1000) + 86400 * 14); // 14 days later + + (() => { + builder.validateStakeDuration(startTime, endTime); + }).should.not.throw(); + }); + }); + + describe('builder methods', () => { + it('should set startTime correctly', () => { + const builder = factory.getDelegatorBuilder(); + const startTime = Math.floor(Date.now() / 1000) + 60; + (() => builder.startTime(startTime)).should.not.throw(); + }); + + it('should set endTime correctly', () => { + const builder = factory.getDelegatorBuilder(); + const endTime = Math.floor(Date.now() / 1000) + 86400 * 14; + (() => builder.endTime(endTime)).should.not.throw(); + }); + + it('should set rewardAddress with P-chain address', () => { + const builder = factory.getDelegatorBuilder(); + // P-chain Bech32 address + (() => builder.rewardAddress(SEED_ACCOUNT.addressTestnet)).should.not.throw(); + }); + }); + + describe('build validation', () => { + it('should fail build when nodeID is not set', async () => { + const builder = factory.getDelegatorBuilder(); + const now = Math.floor(Date.now() / 1000); + + builder + .startTime(now + 60) + .endTime(now + 86400 * 14) + .stakeAmount(BigInt(50000 * 1e9)) + .fromPubKey([SEED_ACCOUNT.addressTestnet, ACCOUNT_1.addressTestnet]) + .context(CONTEXT as any); + + await builder.build().should.be.rejectedWith('NodeID is required for delegation'); + }); + + it('should fail build when start time is not set', async () => { + const builder = factory.getDelegatorBuilder(); + const now = Math.floor(Date.now() / 1000); + const validNodeID = 'NodeID-AK7sPBsZM9rQwse23aLhEEBPHZD5gkLrL'; + + builder + .nodeID(validNodeID) + .endTime(now + 86400 * 14) + .stakeAmount(BigInt(50000 * 1e9)) + .fromPubKey([SEED_ACCOUNT.addressTestnet, ACCOUNT_1.addressTestnet]) + .context(CONTEXT as any); + + await builder.build().should.be.rejectedWith('Start time is required for delegation'); + }); + + it('should fail build when end time is not set', async () => { + const builder = factory.getDelegatorBuilder(); + const now = Math.floor(Date.now() / 1000); + const validNodeID = 'NodeID-AK7sPBsZM9rQwse23aLhEEBPHZD5gkLrL'; + + builder + .nodeID(validNodeID) + .startTime(now + 60) + .stakeAmount(BigInt(50000 * 1e9)) + .fromPubKey([SEED_ACCOUNT.addressTestnet, ACCOUNT_1.addressTestnet]) + .context(CONTEXT as any); + + await builder.build().should.be.rejectedWith('End time is required for delegation'); + }); + + it('should fail build when stake amount is not set', async () => { + const builder = factory.getDelegatorBuilder(); + const now = Math.floor(Date.now() / 1000); + const validNodeID = 'NodeID-AK7sPBsZM9rQwse23aLhEEBPHZD5gkLrL'; + + builder + .nodeID(validNodeID) + .startTime(now + 60) + .endTime(now + 86400 * 14) + .fromPubKey([SEED_ACCOUNT.addressTestnet, ACCOUNT_1.addressTestnet]) + .context(CONTEXT as any); + + await builder.build().should.be.rejectedWith('Stake amount is required for delegation'); + }); + + it('should fail build when context is not set', async () => { + const builder = factory.getDelegatorBuilder(); + const now = Math.floor(Date.now() / 1000); + const validNodeID = 'NodeID-AK7sPBsZM9rQwse23aLhEEBPHZD5gkLrL'; + + builder + .nodeID(validNodeID) + .startTime(now + 60) + .endTime(now + 86400 * 14) + .stakeAmount(BigInt(50000 * 1e9)) + .fromPubKey([SEED_ACCOUNT.addressTestnet, ACCOUNT_1.addressTestnet]); + + await builder.build().should.be.rejectedWith('Context is required for delegation'); + }); + + it('should fail build when from addresses are not set', async () => { + const builder = factory.getDelegatorBuilder(); + const now = Math.floor(Date.now() / 1000); + const validNodeID = 'NodeID-AK7sPBsZM9rQwse23aLhEEBPHZD5gkLrL'; + + builder + .nodeID(validNodeID) + .startTime(now + 60) + .endTime(now + 86400 * 14) + .stakeAmount(BigInt(50000 * 1e9)) + .context(CONTEXT as any); + + await builder.build().should.be.rejectedWith('From addresses are required for delegation'); + }); + + it('should fail build when fee state is not set', async () => { + const builder = factory.getDelegatorBuilder(); + const now = Math.floor(Date.now() / 1000); + const validNodeID = 'NodeID-AK7sPBsZM9rQwse23aLhEEBPHZD5gkLrL'; + + builder + .nodeID(validNodeID) + .startTime(now + 60) + .endTime(now + 86400 * 14) + .stakeAmount(BigInt(50000 * 1e9)) + .fromPubKey([SEED_ACCOUNT.addressTestnet, ACCOUNT_1.addressTestnet]) + .context(CONTEXT as any); + + await builder.build().should.be.rejectedWith('Fee state is required for delegation'); + }); + + it('should fail build when UTXOs are not set', async () => { + const builder = factory.getDelegatorBuilder(); + const now = Math.floor(Date.now() / 1000); + const validNodeID = 'NodeID-AK7sPBsZM9rQwse23aLhEEBPHZD5gkLrL'; + const mockFeeState = { capacity: 100000n, excess: 0n, price: 1000n, timestamp: '1234567890' }; + + builder + .nodeID(validNodeID) + .startTime(now + 60) + .endTime(now + 86400 * 14) + .stakeAmount(BigInt(50000 * 1e9)) + .fromPubKey([SEED_ACCOUNT.addressTestnet, ACCOUNT_1.addressTestnet]) + .context(CONTEXT as any) + .feeState(mockFeeState); + + await builder.build().should.be.rejectedWith('UTXOs are required for delegation'); + }); + }); + + describe('validate stake amount', () => { + it('should fail when stake amount is below minimum', () => { + const builder = factory.getDelegatorBuilder(); + // Minimum is 50000 FLR, so 1000 should fail + assert.throws( + () => { + builder.stakeAmount(BigInt(1000 * 1e9)); + }, + (e: any) => e.message.includes('Minimum delegation amount') + ); + }); + + it('should accept stake amount at or above minimum', () => { + const builder = factory.getDelegatorBuilder(); + const stakeAmount = BigInt(50000 * 1e9); + (() => builder.stakeAmount(stakeAmount)).should.not.throw(); + }); + }); + + describe('transaction type support', () => { + it('should allow setting AddPermissionlessDelegator transaction type', () => { + const builder = factory.getDelegatorBuilder(); + // The builder should successfully configure with AddPermissionlessDelegator type + // This verifies transaction.ts setTransactionType accepts the delegation type + builder.should.be.instanceof(PermissionlessDelegatorTxBuilder); + }); + + it('should have correct minimum stake amount constant', () => { + // Verify the minimum stake amount is 50000 FLR (in nanoFLR) + const minStakeAmount = BigInt(50000 * 1e9); + minStakeAmount.should.equal(BigInt('50000000000000')); + }); + }); + + describe('round-trip deserialization', () => { + it('should deserialize a signed delegation transaction from hex', async () => { + const rawTx = utils.removeHexPrefix(DELEGATION_TX_2.signedHex); + const tx = (await factory.from(rawTx).build()) as Transaction; + tx.type.should.equal(TransactionType.AddPermissionlessDelegator); + }); + + it('should correctly extract delegation parameters from deserialized transaction', async () => { + const rawTx = utils.removeHexPrefix(DELEGATION_TX_2.signedHex); + const tx = (await factory.from(rawTx).build()) as Transaction; + const explanation = tx.explainTransaction(); + explanation.outputs.length.should.be.above(0); + explanation.outputAmount.should.equal(DELEGATION_TX_2.stakeAmount); + }); + + it('should re-serialize to the same hex (round-trip)', async () => { + const rawTx = utils.removeHexPrefix(DELEGATION_TX_2.signedHex); + const tx = (await factory.from(rawTx).build()) as Transaction; + const reEncodedTx = utils.removeHexPrefix(tx.toBroadcastFormat()); + + reEncodedTx.should.equal(rawTx); + }); + + it('should have correct input/output counts', async () => { + const rawTx = utils.removeHexPrefix(DELEGATION_TX_2.signedHex); + const tx = (await factory.from(rawTx).build()) as Transaction; + tx.inputs.should.have.length(3); + tx.outputs.length.should.be.above(0); + }); + }); +}); diff --git a/modules/sdk-coin-flrp/test/unit/lib/utils.ts b/modules/sdk-coin-flrp/test/unit/lib/utils.ts index 57916a3d36..52a65775f7 100644 --- a/modules/sdk-coin-flrp/test/unit/lib/utils.ts +++ b/modules/sdk-coin-flrp/test/unit/lib/utils.ts @@ -25,6 +25,18 @@ describe('Utils', function () { utils = new Utils(); }); + describe('isValidNodeID', function () { + it('should return true for valid NodeID', function () { + assert.strictEqual(utils.isValidNodeID('NodeID-AK7sPBsZM9rQwse23aLhEEBPHZD5gkLrL'), true); + }); + + it('should return false for invalid NodeID formats', function () { + assert.strictEqual(utils.isValidNodeID(''), false); + assert.strictEqual(utils.isValidNodeID('AK7sPBsZM9rQwse23aLhEEBPHZD5gkLrL'), false); // missing prefix + assert.strictEqual(utils.isValidNodeID('NodeID-invalid!!!'), false); // invalid cb58 + }); + }); + describe('decodedToUtxo', function () { const assetId = 'fxMAKpBQQpFedrUhWMsDYfCUJxdUw4mneTczKBzNg3rc2JUub';