From acf226f479d91a51d8962584be39149a6fe47d13 Mon Sep 17 00:00:00 2001 From: Kashif Jamil Date: Mon, 22 Dec 2025 13:25:11 +0530 Subject: [PATCH] feat(sdk-coin-flrp): implement recovery mode for txn builders Ticket: WIN-8407 --- .../lib/permissionlessValidatorTxBuilder.ts | 1 + .../src/lib/transactionBuilderFactory.ts | 33 ++++- .../test/unit/lib/exportInPTxBuilder.ts | 21 +++ .../test/unit/lib/importInCTxBuilder.ts | 19 +++ .../test/unit/lib/importInPTxBuilder.ts | 20 +++ .../test/unit/lib/recoverModeTestSuit.ts | 126 ++++++++++++++++++ 6 files changed, 216 insertions(+), 4 deletions(-) create mode 100644 modules/sdk-coin-flrp/test/unit/lib/recoverModeTestSuit.ts diff --git a/modules/sdk-coin-flrp/src/lib/permissionlessValidatorTxBuilder.ts b/modules/sdk-coin-flrp/src/lib/permissionlessValidatorTxBuilder.ts index ab0a4609c3..a43ce9f4c7 100644 --- a/modules/sdk-coin-flrp/src/lib/permissionlessValidatorTxBuilder.ts +++ b/modules/sdk-coin-flrp/src/lib/permissionlessValidatorTxBuilder.ts @@ -14,6 +14,7 @@ export class PermissionlessValidatorTxBuilder extends TransactionBuilder { protected _endTime: bigint; protected _stakeAmount: bigint; protected _delegationFeeRate: number; + protected recoverSigner = false; constructor(coinConfig: Readonly) { super(coinConfig); diff --git a/modules/sdk-coin-flrp/src/lib/transactionBuilderFactory.ts b/modules/sdk-coin-flrp/src/lib/transactionBuilderFactory.ts index df6640d3d5..f8378f6090 100644 --- a/modules/sdk-coin-flrp/src/lib/transactionBuilderFactory.ts +++ b/modules/sdk-coin-flrp/src/lib/transactionBuilderFactory.ts @@ -14,10 +14,23 @@ interface Codec { } export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { + protected recoverSigner = false; + constructor(_coinConfig: Readonly) { super(_coinConfig); } + /** + * Enables recovery mode for transaction building. + * When enabled, uses backup key (index 2) instead of user key (index 0) for signing. + * @param recoverSigner - Whether to use recovery signing (default: true) + * @returns this factory for chaining + */ + recoverMode(recoverSigner = true): this { + this.recoverSigner = recoverSigner; + return this; + } + /** * Extract credentials from remaining bytes after transaction using FlareJS codec. * This is the proper way to parse credentials - using the codec's UnpackPrefix method. @@ -73,6 +86,18 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { } } + /** + * Apply recovery mode setting to a builder if enabled on the factory. + * @param builder The transaction builder to configure + * @returns The configured builder + */ + private applyRecoverMode(builder: T): T { + if (this.recoverSigner) { + builder.recoverMode(true); + } + return builder; + } + /** * Create the appropriate transaction builder based on transaction type. * @param tx The parsed transaction @@ -91,23 +116,23 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { if (ExportInCTxBuilder.verifyTxType(tx._type)) { const builder = this.getExportInCBuilder(); builder.initBuilder(tx as evmSerial.ExportTx, rawBuffer, credentials); - return builder; + return this.applyRecoverMode(builder); } if (ImportInCTxBuilder.verifyTxType(tx._type)) { const builder = this.getImportInCBuilder(); builder.initBuilder(tx as evmSerial.ImportTx, rawBuffer, credentials); - return builder; + return this.applyRecoverMode(builder); } } else { if (ImportInPTxBuilder.verifyTxType(tx._type)) { const builder = this.getImportInPBuilder(); builder.initBuilder(tx as pvmSerial.ImportTx, rawBuffer, credentials); - return builder; + return this.applyRecoverMode(builder); } if (ExportInPTxBuilder.verifyTxType(tx._type)) { const builder = this.getExportInPBuilder(); builder.initBuilder(tx as pvmSerial.ExportTx, rawBuffer, credentials); - return builder; + return this.applyRecoverMode(builder); } } throw new NotSupported('Transaction type not supported'); diff --git a/modules/sdk-coin-flrp/test/unit/lib/exportInPTxBuilder.ts b/modules/sdk-coin-flrp/test/unit/lib/exportInPTxBuilder.ts index 3d85d64f72..9053d02965 100644 --- a/modules/sdk-coin-flrp/test/unit/lib/exportInPTxBuilder.ts +++ b/modules/sdk-coin-flrp/test/unit/lib/exportInPTxBuilder.ts @@ -8,6 +8,7 @@ import { import { TransactionBuilderFactory, DecodedUtxoObj, Transaction } from '../../../src/lib'; import { coins, FlareNetwork } from '@bitgo/statics'; import signFlowTest from './signFlowTestSuit'; +import recoverModeTestSuit from './recoverModeTestSuit'; describe('Flrp Export In P Tx Builder', () => { const coinConfig = coins.get('tflrp'); @@ -189,4 +190,24 @@ describe('Flrp Export In P Tx Builder', () => { err.message.should.be.equal('Private key cannot sign the transaction'); }); }); + + recoverModeTestSuit({ + transactionType: 'Export P (Recovery Mode)', + newTxFactory: () => new TransactionBuilderFactory(coins.get('tflrp')), + newTxBuilder: () => + new TransactionBuilderFactory(coins.get('tflrp')) + .getExportInPBuilder() + .threshold(testData.threshold) + .locktime(testData.locktime) + .fromPubKey(testData.pAddresses) + .amount(testData.amount) + .externalChainId(testData.sourceChainId) + .fee(testData.fee) + .utxos(testData.outputs), + privateKey: { + prv1: testData.privateKeys[0], + prv2: testData.privateKeys[1], + prv3: testData.privateKeys[2], + }, + }); }); diff --git a/modules/sdk-coin-flrp/test/unit/lib/importInCTxBuilder.ts b/modules/sdk-coin-flrp/test/unit/lib/importInCTxBuilder.ts index 0fb01cda2c..038fd2c46a 100644 --- a/modules/sdk-coin-flrp/test/unit/lib/importInCTxBuilder.ts +++ b/modules/sdk-coin-flrp/test/unit/lib/importInCTxBuilder.ts @@ -4,6 +4,7 @@ import { TransactionBuilderFactory, DecodedUtxoObj } from '../../../src/lib'; import { coins } from '@bitgo/statics'; import { IMPORT_IN_C as testData } from '../../resources/transactionData/importInC'; import signFlowTest from './signFlowTestSuit'; +import recoverModeTestSuit from './recoverModeTestSuit'; describe('Flrp Import In C Tx Builder', () => { const factory = new TransactionBuilderFactory(coins.get('tflrp')); @@ -49,4 +50,22 @@ describe('Flrp Import In C Tx Builder', () => { }, txHash: testData.txhash, }); + + recoverModeTestSuit({ + transactionType: 'Import C (Recovery Mode)', + newTxFactory: () => new TransactionBuilderFactory(coins.get('tflrp')), + newTxBuilder: () => + new TransactionBuilderFactory(coins.get('tflrp')) + .getImportInCBuilder() + .threshold(testData.threshold) + .fromPubKey(testData.pAddresses) + .utxos(testData.outputs) + .to(testData.to) + .feeRate(testData.fee), + privateKey: { + prv1: testData.privateKeys[0], + prv2: testData.privateKeys[1], + prv3: testData.privateKeys[2], + }, + }); }); diff --git a/modules/sdk-coin-flrp/test/unit/lib/importInPTxBuilder.ts b/modules/sdk-coin-flrp/test/unit/lib/importInPTxBuilder.ts index 0d5410666c..abaf987f92 100644 --- a/modules/sdk-coin-flrp/test/unit/lib/importInPTxBuilder.ts +++ b/modules/sdk-coin-flrp/test/unit/lib/importInPTxBuilder.ts @@ -4,6 +4,7 @@ import { IMPORT_IN_P as testData } from '../../resources/transactionData/importI import { TransactionBuilderFactory, DecodedUtxoObj, Transaction } from '../../../src/lib'; import { coins, FlareNetwork } from '@bitgo/statics'; import signFlowTest from './signFlowTestSuit'; +import recoverModeTestSuit from './recoverModeTestSuit'; describe('Flrp Import In P Tx Builder', () => { const coinConfig = coins.get('tflrp'); @@ -129,4 +130,23 @@ describe('Flrp Import In P Tx Builder', () => { err.message.should.be.equal('Private key cannot sign the transaction'); }); }); + + recoverModeTestSuit({ + transactionType: 'Import P (Recovery Mode)', + newTxFactory: () => new TransactionBuilderFactory(coins.get('tflrp')), + newTxBuilder: () => + new TransactionBuilderFactory(coins.get('tflrp')) + .getImportInPBuilder() + .threshold(testData.threshold) + .locktime(testData.locktime) + .fromPubKey(testData.pAddresses) + .externalChainId(testData.sourceChainId) + .fee(testData.fee) + .utxos(testData.outputs), + privateKey: { + prv1: testData.privateKeys[0], + prv2: testData.privateKeys[1], + prv3: testData.privateKeys[2], + }, + }); }); diff --git a/modules/sdk-coin-flrp/test/unit/lib/recoverModeTestSuit.ts b/modules/sdk-coin-flrp/test/unit/lib/recoverModeTestSuit.ts new file mode 100644 index 0000000000..e6f7da6a4e --- /dev/null +++ b/modules/sdk-coin-flrp/test/unit/lib/recoverModeTestSuit.ts @@ -0,0 +1,126 @@ +import { BaseTransactionBuilder, BaseTransactionBuilderFactory } from '@bitgo/sdk-core'; + +export interface RecoverModeTestSuitArgs { + transactionType: string; + newTxFactory: () => BaseTransactionBuilderFactory; + newTxBuilder: () => BaseTransactionBuilder; + privateKey: { prv1: string; prv2: string; prv3: string }; +} + +/** + * Test suite focusing on recovery mode signing. + * In recovery mode, the backup key (prv3) is used instead of user key (prv1) along with BitGo key (prv2). + * @param {RecoverModeTestSuitArgs} data with required info. + */ +export default function recoverModeTestSuit(data: RecoverModeTestSuitArgs): void { + describe(`should test recovery mode for ${data.transactionType}`, () => { + it('Should set recoverMode flag on builder', async () => { + const txBuilder = data.newTxBuilder(); + // @ts-expect-error - accessing protected property for testing + txBuilder.recoverSigner.should.equal(false); + + // @ts-expect-error - method exists on flrp TransactionBuilder + txBuilder.recoverMode(true); + // @ts-expect-error - accessing protected property for testing + txBuilder.recoverSigner.should.equal(true); + + // @ts-expect-error - method exists on flrp TransactionBuilder + txBuilder.recoverMode(false); + // @ts-expect-error - accessing protected property for testing + txBuilder.recoverSigner.should.equal(false); + }); + + it('Should default recoverMode to true when called without argument', async () => { + const txBuilder = data.newTxBuilder(); + // @ts-expect-error - method exists on flrp TransactionBuilder + txBuilder.recoverMode(); + // @ts-expect-error - accessing protected property for testing + txBuilder.recoverSigner.should.equal(true); + }); + + it('Should build unsigned tx in recovery mode', async () => { + const txBuilder = data.newTxBuilder(); + // @ts-expect-error - method exists on flrp TransactionBuilder + txBuilder.recoverMode(true); + const tx = await txBuilder.build(); + tx.toBroadcastFormat().should.be.a.String(); + }); + + it('Should half sign tx in recovery mode using backup key (prv3)', async () => { + const txBuilder = data.newTxBuilder(); + // @ts-expect-error - method exists on flrp TransactionBuilder + txBuilder.recoverMode(true); + + // In recovery mode, sign with backup key (prv3) instead of user key (prv1) + txBuilder.sign({ key: data.privateKey.prv3 }); + const tx = await txBuilder.build(); + const halfSignedHex = tx.toBroadcastFormat(); + halfSignedHex.should.be.a.String(); + halfSignedHex.length.should.be.greaterThan(0); + }); + + it('Should full sign tx in recovery mode using backup key (prv3) and bitgo key (prv2)', async () => { + const txBuilder = data.newTxBuilder(); + // @ts-expect-error - method exists on flrp TransactionBuilder + txBuilder.recoverMode(true); + + // In recovery mode: backup key (prv3) + bitgo key (prv2) + txBuilder.sign({ key: data.privateKey.prv3 }); + txBuilder.sign({ key: data.privateKey.prv2 }); + const tx = await txBuilder.build(); + const fullSignedHex = tx.toBroadcastFormat(); + fullSignedHex.should.be.a.String(); + fullSignedHex.length.should.be.greaterThan(0); + }); + + it('Should produce different signed tx in recovery mode vs regular mode', async () => { + // Build and sign in regular mode (user key prv1 + bitgo key prv2) + const regularTxBuilder = data.newTxBuilder(); + // @ts-expect-error - method exists on flrp TransactionBuilder + regularTxBuilder.recoverMode(false); + regularTxBuilder.sign({ key: data.privateKey.prv1 }); + regularTxBuilder.sign({ key: data.privateKey.prv2 }); + const regularTx = await regularTxBuilder.build(); + const regularHex = regularTx.toBroadcastFormat(); + + // Build and sign in recovery mode (backup key prv3 + bitgo key prv2) + const recoveryTxBuilder = data.newTxBuilder(); + // @ts-expect-error - method exists on flrp TransactionBuilder + recoveryTxBuilder.recoverMode(true); + recoveryTxBuilder.sign({ key: data.privateKey.prv3 }); + recoveryTxBuilder.sign({ key: data.privateKey.prv2 }); + const recoveryTx = await recoveryTxBuilder.build(); + const recoveryHex = recoveryTx.toBroadcastFormat(); + + // Both should be valid hex strings + regularHex.should.be.a.String(); + recoveryHex.should.be.a.String(); + + // The signed transactions should be different because different keys are used + regularHex.should.not.equal(recoveryHex); + + // Signatures should also be different + const regularSignatures = regularTx.signature; + const recoverySignatures = recoveryTx.signature; + regularSignatures.should.not.eql(recoverySignatures); + }); + + it('Should set recoverMode via factory and pass to builder from raw tx', async () => { + // First build an unsigned transaction + const txBuilder = data.newTxBuilder(); + const tx = await txBuilder.build(); + const unsignedHex = tx.toBroadcastFormat(); + + // Parse from raw with recovery mode enabled on factory + const factory = data.newTxFactory(); + // @ts-expect-error - accessing the method which may not be on base type + if (typeof factory.recoverMode === 'function') { + // @ts-expect-error - calling recoverMode on factory + factory.recoverMode(true); + const recoveredBuilder = factory.from(unsignedHex); + // Cast to any to access protected property for testing + (recoveredBuilder as any).recoverSigner.should.equal(true); + } + }); + }); +}