Skip to content

Commit d51ecb3

Browse files
committed
feat(sdk-coin-flrp): implement MPC support and enhance transaction signing methods
Ticket: CECHO-674
1 parent 37c6763 commit d51ecb3

12 files changed

Lines changed: 771 additions & 14 deletions

File tree

modules/sdk-coin-flr/src/flr.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,11 @@ export class Flr extends AbstractEthLikeNewCoins {
440440
* @returns {Promise<BuildOptions>}
441441
*/
442442
async getExtraPrebuildParams(buildParams: BuildOptions): Promise<BuildOptions> {
443+
// MPC/TSS wallets don't use hop transactions — atomic tx is signed directly
444+
if (buildParams.wallet?.multisigType() === 'tss') {
445+
return {};
446+
}
447+
443448
if (
444449
!_.isUndefined(buildParams.hop) &&
445450
buildParams.hop &&

modules/sdk-coin-flr/test/unit/flr.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -993,6 +993,19 @@ describe('flr', function () {
993993
const result = await tflrCoin.getExtraPrebuildParams(buildParams);
994994
result.should.have.property('hopParams');
995995
});
996+
997+
it('should return empty object for TSS wallets even when hop is true', async function () {
998+
const tssWallet = new Wallet(bitgo, tflrCoin, { multisigType: 'tss' });
999+
const buildParams = {
1000+
hop: true,
1001+
wallet: tssWallet,
1002+
recipients: [{ address: EXPORT_C_TEST_DATA.pMultisigAddress, amount: '100000000000000000' }],
1003+
type: 'Export' as const,
1004+
};
1005+
1006+
const result = await tflrCoin.getExtraPrebuildParams(buildParams);
1007+
result.should.deepEqual({});
1008+
});
9961009
});
9971010

9981011
describe('feeEstimate', function () {

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

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
BaseCoin,
55
BitGoBase,
66
KeyPair,
7+
MPCAlgorithm,
78
MultisigType,
89
multisigTypes,
910
ParsedTransaction,
@@ -66,6 +67,16 @@ export class Flrp extends BaseCoin {
6667
return multisigTypes.onchain;
6768
}
6869

70+
/** @inheritdoc */
71+
supportsTss(): boolean {
72+
return true;
73+
}
74+
75+
/** @inheritdoc */
76+
getMPCAlgorithm(): MPCAlgorithm {
77+
return 'ecdsa';
78+
}
79+
6980
async verifyTransaction(params: FlrpVerifyTransactionOptions): Promise<boolean> {
7081
const txHex = params.txPrebuild && params.txPrebuild.txHex;
7182
if (!txHex) {
@@ -232,13 +243,23 @@ export class Flrp extends BaseCoin {
232243
if (!this.isValidAddress(address)) {
233244
throw new InvalidAddressError(`invalid address: ${address}`);
234245
}
235-
if (!keychains || keychains.length !== 3) {
236-
throw new Error('Invalid keychains');
237-
}
238246

239247
// multisig addresses are separated by ~
240248
const splitAddresses = address.split('~');
241249

250+
// MPC/TSS: single address derived from common keychain
251+
if (splitAddresses.length === 1 && keychains?.length === 1) {
252+
const expectedAddr = new FlrpLib.KeyPair({ pub: keychains[0].pub }).getAddress(this._staticsCoin.network.type);
253+
if (expectedAddr !== address) {
254+
throw new UnexpectedAddressError(`address validation failure: ${address} is not of this wallet`);
255+
}
256+
return true;
257+
}
258+
259+
if (!keychains || keychains.length !== 3) {
260+
throw new Error('Invalid keychains');
261+
}
262+
242263
// derive addresses from keychain
243264
const unlockAddresses = keychains.map((keychain) =>
244265
new FlrpLib.KeyPair({ pub: keychain.pub }).getAddress(this._staticsCoin.network.type)
@@ -329,6 +350,29 @@ export class Flrp extends BaseCoin {
329350
return FlrpLib.Utils.isValidAddress(address);
330351
}
331352

353+
/**
354+
* Get the raw bytes that need to be signed by the MPC ceremony.
355+
* MPC.sign() internally SHA-256 hashes, so return raw unsigned tx bytes.
356+
*/
357+
async getSignablePayload(txHex: string): Promise<Buffer> {
358+
const txBuilder = this.getBuilder().from(txHex);
359+
const tx = (await txBuilder.build()) as FlrpLib.Transaction;
360+
return Buffer.from(tx.signablePayload);
361+
}
362+
363+
/**
364+
* Inject an MPC-produced signature into an unsigned transaction.
365+
* @param txHex - Unsigned transaction hex
366+
* @param signature - 65-byte ECDSA signature (r || s || recovery)
367+
* @returns Signed transaction hex
368+
*/
369+
async addSignatureToTransaction(txHex: string, signature: Buffer): Promise<string> {
370+
const txBuilder = this.getBuilder().from(txHex);
371+
const tx = await txBuilder.build();
372+
(tx as FlrpLib.Transaction).addExternalSignature(new Uint8Array(signature));
373+
return tx.toBroadcastFormat();
374+
}
375+
332376
/**
333377
* Signs Avaxp transaction
334378
*/

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,18 @@ export abstract class AtomicTransactionBuilder extends TransactionBuilder {
136136
const firstIndex = this.recoverSigner ? 2 : 0;
137137
const bitgoIndex = 1;
138138

139+
// MPC (threshold=1): single signing address
140+
if (this.transaction._threshold === 1) {
141+
if (this.transaction._fromAddresses.length < 1) {
142+
throw new BuildTransactionError('Insufficient fromAddresses for MPC signing');
143+
}
144+
const addr = Buffer.from(this.transaction._fromAddresses[0]);
145+
if (addr.length !== 20) {
146+
throw new BuildTransactionError(`Invalid signing address length: expected 20 bytes, got ${addr.length}`);
147+
}
148+
return [addr];
149+
}
150+
139151
if (this.transaction._fromAddresses.length < Math.max(firstIndex, bitgoIndex) + 1) {
140152
throw new BuildTransactionError(
141153
`Insufficient fromAddresses: need at least ${Math.max(firstIndex, bitgoIndex) + 1} addresses`

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

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,38 @@ export class Transaction extends BaseTransaction {
241241
}
242242
}
243243

244+
/**
245+
* Apply an externally-produced signature (from MPC/TSS) to this transaction.
246+
* Fills the first empty signature slot in each credential.
247+
* @param signature - 65-byte Uint8Array (r[32] + s[32] + recovery[1])
248+
*/
249+
addExternalSignature(signature: Uint8Array): void {
250+
if (!this._flareTransaction) {
251+
throw new InvalidTransactionError('empty transaction to sign');
252+
}
253+
if (!this.hasCredentials) {
254+
throw new InvalidTransactionError('empty credentials to sign');
255+
}
256+
const unsignedTx = this._flareTransaction as UnsignedTx;
257+
258+
let signatureSet = false;
259+
for (const credential of unsignedTx.credentials) {
260+
const signatures = credential.getSignatures();
261+
for (let i = 0; i < signatures.length; i++) {
262+
if (isEmptySignature(signatures[i])) {
263+
credential.setSignature(i, signature);
264+
signatureSet = true;
265+
break;
266+
}
267+
}
268+
}
269+
270+
if (!signatureSet) {
271+
throw new SigningError('No empty signature slot found');
272+
}
273+
this._rawSignedBytes = undefined;
274+
}
275+
244276
toBroadcastFormat(): string {
245277
if (!this._flareTransaction) {
246278
throw new InvalidTransactionError('Empty transaction data');

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,8 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder {
4646
* @param threshold - Number of required signatures
4747
*/
4848
validateThreshold(threshold: number): void {
49-
if (!threshold || threshold !== 2) {
50-
throw new BuildTransactionError('Invalid transaction: threshold must be set to 2');
49+
if (!threshold || (threshold !== 1 && threshold !== 2)) {
50+
throw new BuildTransactionError('Invalid transaction: threshold must be 1 or 2');
5151
}
5252
}
5353

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

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { EXPORT_IN_C } from '../resources/transactionData/exportInC';
88
import { EXPORT_IN_P } from '../resources/transactionData/exportInP';
99
import { IMPORT_IN_P } from '../resources/transactionData/importInP';
1010
import { IMPORT_IN_C } from '../resources/transactionData/importInC';
11-
import { HalfSignedAccountTransaction, TransactionType } from '@bitgo/sdk-core';
11+
import { HalfSignedAccountTransaction, TransactionType, MPCAlgorithm } from '@bitgo/sdk-core';
1212
import assert from 'assert';
1313

1414
describe('Flrp test cases', function () {
@@ -57,6 +57,14 @@ describe('Flrp test cases', function () {
5757
basecoin.getDefaultMultisigType().should.equal('onchain');
5858
});
5959

60+
it('should support TSS', function () {
61+
basecoin.supportsTss().should.equal(true);
62+
});
63+
64+
it('should return ecdsa as MPC algorithm', function () {
65+
(basecoin.getMPCAlgorithm() as MPCAlgorithm).should.equal('ecdsa');
66+
});
67+
6068
describe('Keypairs:', () => {
6169
it('should generate a keypair from random seed', function () {
6270
const keyPair = basecoin.generateKeyPair();
@@ -499,14 +507,39 @@ describe('Flrp test cases', function () {
499507
isValid.should.be.true();
500508
});
501509

502-
it('should throw for address with wrong number of keychains', async () => {
510+
it('should verify MPC wallet address with single keychain', async () => {
511+
const address = SEED_ACCOUNT.addressTestnet;
512+
513+
const isValid = await basecoin.isWalletAddress({
514+
address,
515+
keychains: [{ pub: SEED_ACCOUNT.publicKey }],
516+
});
517+
518+
isValid.should.be.true();
519+
});
520+
521+
it('should reject MPC wallet address that does not match keychain', async () => {
503522
const address = SEED_ACCOUNT.addressTestnet;
504523

505524
await assert.rejects(
506525
async () =>
507526
basecoin.isWalletAddress({
508527
address,
509-
keychains: [{ pub: SEED_ACCOUNT.publicKey }],
528+
keychains: [{ pub: ACCOUNT_1.publicKey }],
529+
}),
530+
/address validation failure/
531+
);
532+
});
533+
534+
it('should throw for multisig address with wrong number of keychains', async () => {
535+
// Two tilde-separated addresses but only 2 keychains
536+
const address = SEED_ACCOUNT.addressTestnet + '~' + ACCOUNT_1.addressTestnet;
537+
538+
await assert.rejects(
539+
async () =>
540+
basecoin.isWalletAddress({
541+
address,
542+
keychains: [{ pub: SEED_ACCOUNT.publicKey }, { pub: ACCOUNT_1.publicKey }],
510543
}),
511544
/Invalid keychains/
512545
);

0 commit comments

Comments
 (0)