Skip to content

Commit acf226f

Browse files
committed
feat(sdk-coin-flrp): implement recovery mode for txn builders
Ticket: WIN-8407
1 parent 82d9eca commit acf226f

6 files changed

Lines changed: 216 additions & 4 deletions

File tree

modules/sdk-coin-flrp/src/lib/permissionlessValidatorTxBuilder.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export class PermissionlessValidatorTxBuilder extends TransactionBuilder {
1414
protected _endTime: bigint;
1515
protected _stakeAmount: bigint;
1616
protected _delegationFeeRate: number;
17+
protected recoverSigner = false;
1718

1819
constructor(coinConfig: Readonly<CoinConfig>) {
1920
super(coinConfig);

modules/sdk-coin-flrp/src/lib/transactionBuilderFactory.ts

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,23 @@ interface Codec {
1414
}
1515

1616
export class TransactionBuilderFactory extends BaseTransactionBuilderFactory {
17+
protected recoverSigner = false;
18+
1719
constructor(_coinConfig: Readonly<CoinConfig>) {
1820
super(_coinConfig);
1921
}
2022

23+
/**
24+
* Enables recovery mode for transaction building.
25+
* When enabled, uses backup key (index 2) instead of user key (index 0) for signing.
26+
* @param recoverSigner - Whether to use recovery signing (default: true)
27+
* @returns this factory for chaining
28+
*/
29+
recoverMode(recoverSigner = true): this {
30+
this.recoverSigner = recoverSigner;
31+
return this;
32+
}
33+
2134
/**
2235
* Extract credentials from remaining bytes after transaction using FlareJS codec.
2336
* This is the proper way to parse credentials - using the codec's UnpackPrefix method.
@@ -73,6 +86,18 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory {
7386
}
7487
}
7588

89+
/**
90+
* Apply recovery mode setting to a builder if enabled on the factory.
91+
* @param builder The transaction builder to configure
92+
* @returns The configured builder
93+
*/
94+
private applyRecoverMode<T extends TransactionBuilder>(builder: T): T {
95+
if (this.recoverSigner) {
96+
builder.recoverMode(true);
97+
}
98+
return builder;
99+
}
100+
76101
/**
77102
* Create the appropriate transaction builder based on transaction type.
78103
* @param tx The parsed transaction
@@ -91,23 +116,23 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory {
91116
if (ExportInCTxBuilder.verifyTxType(tx._type)) {
92117
const builder = this.getExportInCBuilder();
93118
builder.initBuilder(tx as evmSerial.ExportTx, rawBuffer, credentials);
94-
return builder;
119+
return this.applyRecoverMode(builder);
95120
}
96121
if (ImportInCTxBuilder.verifyTxType(tx._type)) {
97122
const builder = this.getImportInCBuilder();
98123
builder.initBuilder(tx as evmSerial.ImportTx, rawBuffer, credentials);
99-
return builder;
124+
return this.applyRecoverMode(builder);
100125
}
101126
} else {
102127
if (ImportInPTxBuilder.verifyTxType(tx._type)) {
103128
const builder = this.getImportInPBuilder();
104129
builder.initBuilder(tx as pvmSerial.ImportTx, rawBuffer, credentials);
105-
return builder;
130+
return this.applyRecoverMode(builder);
106131
}
107132
if (ExportInPTxBuilder.verifyTxType(tx._type)) {
108133
const builder = this.getExportInPBuilder();
109134
builder.initBuilder(tx as pvmSerial.ExportTx, rawBuffer, credentials);
110-
return builder;
135+
return this.applyRecoverMode(builder);
111136
}
112137
}
113138
throw new NotSupported('Transaction type not supported');

modules/sdk-coin-flrp/test/unit/lib/exportInPTxBuilder.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
import { TransactionBuilderFactory, DecodedUtxoObj, Transaction } from '../../../src/lib';
99
import { coins, FlareNetwork } from '@bitgo/statics';
1010
import signFlowTest from './signFlowTestSuit';
11+
import recoverModeTestSuit from './recoverModeTestSuit';
1112

1213
describe('Flrp Export In P Tx Builder', () => {
1314
const coinConfig = coins.get('tflrp');
@@ -189,4 +190,24 @@ describe('Flrp Export In P Tx Builder', () => {
189190
err.message.should.be.equal('Private key cannot sign the transaction');
190191
});
191192
});
193+
194+
recoverModeTestSuit({
195+
transactionType: 'Export P (Recovery Mode)',
196+
newTxFactory: () => new TransactionBuilderFactory(coins.get('tflrp')),
197+
newTxBuilder: () =>
198+
new TransactionBuilderFactory(coins.get('tflrp'))
199+
.getExportInPBuilder()
200+
.threshold(testData.threshold)
201+
.locktime(testData.locktime)
202+
.fromPubKey(testData.pAddresses)
203+
.amount(testData.amount)
204+
.externalChainId(testData.sourceChainId)
205+
.fee(testData.fee)
206+
.utxos(testData.outputs),
207+
privateKey: {
208+
prv1: testData.privateKeys[0],
209+
prv2: testData.privateKeys[1],
210+
prv3: testData.privateKeys[2],
211+
},
212+
});
192213
});

modules/sdk-coin-flrp/test/unit/lib/importInCTxBuilder.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { TransactionBuilderFactory, DecodedUtxoObj } from '../../../src/lib';
44
import { coins } from '@bitgo/statics';
55
import { IMPORT_IN_C as testData } from '../../resources/transactionData/importInC';
66
import signFlowTest from './signFlowTestSuit';
7+
import recoverModeTestSuit from './recoverModeTestSuit';
78

89
describe('Flrp Import In C Tx Builder', () => {
910
const factory = new TransactionBuilderFactory(coins.get('tflrp'));
@@ -49,4 +50,22 @@ describe('Flrp Import In C Tx Builder', () => {
4950
},
5051
txHash: testData.txhash,
5152
});
53+
54+
recoverModeTestSuit({
55+
transactionType: 'Import C (Recovery Mode)',
56+
newTxFactory: () => new TransactionBuilderFactory(coins.get('tflrp')),
57+
newTxBuilder: () =>
58+
new TransactionBuilderFactory(coins.get('tflrp'))
59+
.getImportInCBuilder()
60+
.threshold(testData.threshold)
61+
.fromPubKey(testData.pAddresses)
62+
.utxos(testData.outputs)
63+
.to(testData.to)
64+
.feeRate(testData.fee),
65+
privateKey: {
66+
prv1: testData.privateKeys[0],
67+
prv2: testData.privateKeys[1],
68+
prv3: testData.privateKeys[2],
69+
},
70+
});
5271
});

modules/sdk-coin-flrp/test/unit/lib/importInPTxBuilder.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { IMPORT_IN_P as testData } from '../../resources/transactionData/importI
44
import { TransactionBuilderFactory, DecodedUtxoObj, Transaction } from '../../../src/lib';
55
import { coins, FlareNetwork } from '@bitgo/statics';
66
import signFlowTest from './signFlowTestSuit';
7+
import recoverModeTestSuit from './recoverModeTestSuit';
78

89
describe('Flrp Import In P Tx Builder', () => {
910
const coinConfig = coins.get('tflrp');
@@ -129,4 +130,23 @@ describe('Flrp Import In P Tx Builder', () => {
129130
err.message.should.be.equal('Private key cannot sign the transaction');
130131
});
131132
});
133+
134+
recoverModeTestSuit({
135+
transactionType: 'Import P (Recovery Mode)',
136+
newTxFactory: () => new TransactionBuilderFactory(coins.get('tflrp')),
137+
newTxBuilder: () =>
138+
new TransactionBuilderFactory(coins.get('tflrp'))
139+
.getImportInPBuilder()
140+
.threshold(testData.threshold)
141+
.locktime(testData.locktime)
142+
.fromPubKey(testData.pAddresses)
143+
.externalChainId(testData.sourceChainId)
144+
.fee(testData.fee)
145+
.utxos(testData.outputs),
146+
privateKey: {
147+
prv1: testData.privateKeys[0],
148+
prv2: testData.privateKeys[1],
149+
prv3: testData.privateKeys[2],
150+
},
151+
});
132152
});
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { BaseTransactionBuilder, BaseTransactionBuilderFactory } from '@bitgo/sdk-core';
2+
3+
export interface RecoverModeTestSuitArgs {
4+
transactionType: string;
5+
newTxFactory: () => BaseTransactionBuilderFactory;
6+
newTxBuilder: () => BaseTransactionBuilder;
7+
privateKey: { prv1: string; prv2: string; prv3: string };
8+
}
9+
10+
/**
11+
* Test suite focusing on recovery mode signing.
12+
* In recovery mode, the backup key (prv3) is used instead of user key (prv1) along with BitGo key (prv2).
13+
* @param {RecoverModeTestSuitArgs} data with required info.
14+
*/
15+
export default function recoverModeTestSuit(data: RecoverModeTestSuitArgs): void {
16+
describe(`should test recovery mode for ${data.transactionType}`, () => {
17+
it('Should set recoverMode flag on builder', async () => {
18+
const txBuilder = data.newTxBuilder();
19+
// @ts-expect-error - accessing protected property for testing
20+
txBuilder.recoverSigner.should.equal(false);
21+
22+
// @ts-expect-error - method exists on flrp TransactionBuilder
23+
txBuilder.recoverMode(true);
24+
// @ts-expect-error - accessing protected property for testing
25+
txBuilder.recoverSigner.should.equal(true);
26+
27+
// @ts-expect-error - method exists on flrp TransactionBuilder
28+
txBuilder.recoverMode(false);
29+
// @ts-expect-error - accessing protected property for testing
30+
txBuilder.recoverSigner.should.equal(false);
31+
});
32+
33+
it('Should default recoverMode to true when called without argument', async () => {
34+
const txBuilder = data.newTxBuilder();
35+
// @ts-expect-error - method exists on flrp TransactionBuilder
36+
txBuilder.recoverMode();
37+
// @ts-expect-error - accessing protected property for testing
38+
txBuilder.recoverSigner.should.equal(true);
39+
});
40+
41+
it('Should build unsigned tx in recovery mode', async () => {
42+
const txBuilder = data.newTxBuilder();
43+
// @ts-expect-error - method exists on flrp TransactionBuilder
44+
txBuilder.recoverMode(true);
45+
const tx = await txBuilder.build();
46+
tx.toBroadcastFormat().should.be.a.String();
47+
});
48+
49+
it('Should half sign tx in recovery mode using backup key (prv3)', async () => {
50+
const txBuilder = data.newTxBuilder();
51+
// @ts-expect-error - method exists on flrp TransactionBuilder
52+
txBuilder.recoverMode(true);
53+
54+
// In recovery mode, sign with backup key (prv3) instead of user key (prv1)
55+
txBuilder.sign({ key: data.privateKey.prv3 });
56+
const tx = await txBuilder.build();
57+
const halfSignedHex = tx.toBroadcastFormat();
58+
halfSignedHex.should.be.a.String();
59+
halfSignedHex.length.should.be.greaterThan(0);
60+
});
61+
62+
it('Should full sign tx in recovery mode using backup key (prv3) and bitgo key (prv2)', async () => {
63+
const txBuilder = data.newTxBuilder();
64+
// @ts-expect-error - method exists on flrp TransactionBuilder
65+
txBuilder.recoverMode(true);
66+
67+
// In recovery mode: backup key (prv3) + bitgo key (prv2)
68+
txBuilder.sign({ key: data.privateKey.prv3 });
69+
txBuilder.sign({ key: data.privateKey.prv2 });
70+
const tx = await txBuilder.build();
71+
const fullSignedHex = tx.toBroadcastFormat();
72+
fullSignedHex.should.be.a.String();
73+
fullSignedHex.length.should.be.greaterThan(0);
74+
});
75+
76+
it('Should produce different signed tx in recovery mode vs regular mode', async () => {
77+
// Build and sign in regular mode (user key prv1 + bitgo key prv2)
78+
const regularTxBuilder = data.newTxBuilder();
79+
// @ts-expect-error - method exists on flrp TransactionBuilder
80+
regularTxBuilder.recoverMode(false);
81+
regularTxBuilder.sign({ key: data.privateKey.prv1 });
82+
regularTxBuilder.sign({ key: data.privateKey.prv2 });
83+
const regularTx = await regularTxBuilder.build();
84+
const regularHex = regularTx.toBroadcastFormat();
85+
86+
// Build and sign in recovery mode (backup key prv3 + bitgo key prv2)
87+
const recoveryTxBuilder = data.newTxBuilder();
88+
// @ts-expect-error - method exists on flrp TransactionBuilder
89+
recoveryTxBuilder.recoverMode(true);
90+
recoveryTxBuilder.sign({ key: data.privateKey.prv3 });
91+
recoveryTxBuilder.sign({ key: data.privateKey.prv2 });
92+
const recoveryTx = await recoveryTxBuilder.build();
93+
const recoveryHex = recoveryTx.toBroadcastFormat();
94+
95+
// Both should be valid hex strings
96+
regularHex.should.be.a.String();
97+
recoveryHex.should.be.a.String();
98+
99+
// The signed transactions should be different because different keys are used
100+
regularHex.should.not.equal(recoveryHex);
101+
102+
// Signatures should also be different
103+
const regularSignatures = regularTx.signature;
104+
const recoverySignatures = recoveryTx.signature;
105+
regularSignatures.should.not.eql(recoverySignatures);
106+
});
107+
108+
it('Should set recoverMode via factory and pass to builder from raw tx', async () => {
109+
// First build an unsigned transaction
110+
const txBuilder = data.newTxBuilder();
111+
const tx = await txBuilder.build();
112+
const unsignedHex = tx.toBroadcastFormat();
113+
114+
// Parse from raw with recovery mode enabled on factory
115+
const factory = data.newTxFactory();
116+
// @ts-expect-error - accessing the method which may not be on base type
117+
if (typeof factory.recoverMode === 'function') {
118+
// @ts-expect-error - calling recoverMode on factory
119+
factory.recoverMode(true);
120+
const recoveredBuilder = factory.from(unsignedHex);
121+
// Cast to any to access protected property for testing
122+
(recoveredBuilder as any).recoverSigner.should.equal(true);
123+
}
124+
});
125+
});
126+
}

0 commit comments

Comments
 (0)