Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions modules/sdk-coin-flr/src/flr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,11 @@ export class Flr extends AbstractEthLikeNewCoins {
* @returns {Promise<BuildOptions>}
*/
async getExtraPrebuildParams(buildParams: BuildOptions): Promise<BuildOptions> {
// MPC/TSS wallets don't use hop transactions — atomic tx is signed directly
if (buildParams.wallet?.multisigType() === 'tss') {
return {};
}

if (
!_.isUndefined(buildParams.hop) &&
buildParams.hop &&
Expand Down
13 changes: 13 additions & 0 deletions modules/sdk-coin-flr/test/unit/flr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -993,6 +993,19 @@ describe('flr', function () {
const result = await tflrCoin.getExtraPrebuildParams(buildParams);
result.should.have.property('hopParams');
});

it('should return empty object for TSS wallets even when hop is true', async function () {
const tssWallet = new Wallet(bitgo, tflrCoin, { multisigType: 'tss' });
const buildParams = {
hop: true,
wallet: tssWallet,
recipients: [{ address: EXPORT_C_TEST_DATA.pMultisigAddress, amount: '100000000000000000' }],
type: 'Export' as const,
};

const result = await tflrCoin.getExtraPrebuildParams(buildParams);
result.should.deepEqual({});
});
});

describe('feeEstimate', function () {
Expand Down
50 changes: 47 additions & 3 deletions modules/sdk-coin-flrp/src/flrp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
BaseCoin,
BitGoBase,
KeyPair,
MPCAlgorithm,
MultisigType,
multisigTypes,
ParsedTransaction,
Expand Down Expand Up @@ -66,6 +67,16 @@ export class Flrp extends BaseCoin {
return multisigTypes.onchain;
}

/** @inheritdoc */
supportsTss(): boolean {
return true;
}

/** @inheritdoc */
getMPCAlgorithm(): MPCAlgorithm {
return 'ecdsa';
}

async verifyTransaction(params: FlrpVerifyTransactionOptions): Promise<boolean> {
const txHex = params.txPrebuild && params.txPrebuild.txHex;
if (!txHex) {
Expand Down Expand Up @@ -232,13 +243,23 @@ export class Flrp extends BaseCoin {
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('~');

// MPC/TSS: single address derived from common keychain
if (splitAddresses.length === 1 && keychains?.length === 1) {
const expectedAddr = new FlrpLib.KeyPair({ pub: keychains[0].pub }).getAddress(this._staticsCoin.network.type);
if (expectedAddr !== address) {
throw new UnexpectedAddressError(`address validation failure: ${address} is not of this wallet`);
}
return true;
}

if (!keychains || keychains.length !== 3) {
throw new Error('Invalid keychains');
}

// derive addresses from keychain
const unlockAddresses = keychains.map((keychain) =>
new FlrpLib.KeyPair({ pub: keychain.pub }).getAddress(this._staticsCoin.network.type)
Expand Down Expand Up @@ -329,6 +350,29 @@ export class Flrp extends BaseCoin {
return FlrpLib.Utils.isValidAddress(address);
}

/**
* Get the raw bytes that need to be signed by the MPC ceremony.
* MPC.sign() internally SHA-256 hashes, so return raw unsigned tx bytes.
*/
async getSignablePayload(txHex: string): Promise<Buffer> {
const txBuilder = this.getBuilder().from(txHex);
const tx = (await txBuilder.build()) as FlrpLib.Transaction;
return Buffer.from(tx.signablePayload);
}

/**
* Inject an MPC-produced signature into an unsigned transaction.
* @param txHex - Unsigned transaction hex
* @param signature - 65-byte ECDSA signature (r || s || recovery)
* @returns Signed transaction hex
*/
async addSignatureToTransaction(txHex: string, signature: Buffer): Promise<string> {
const txBuilder = this.getBuilder().from(txHex);
const tx = await txBuilder.build();
(tx as FlrpLib.Transaction).addExternalSignature(new Uint8Array(signature));
return tx.toBroadcastFormat();
}

/**
* Signs Avaxp transaction
*/
Expand Down
12 changes: 12 additions & 0 deletions modules/sdk-coin-flrp/src/lib/atomicTransactionBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,18 @@ export abstract class AtomicTransactionBuilder extends TransactionBuilder {
const firstIndex = this.recoverSigner ? 2 : 0;
const bitgoIndex = 1;

// MPC (threshold=1): single signing address
if (this.transaction._threshold === 1) {
if (this.transaction._fromAddresses.length < 1) {
throw new BuildTransactionError('Insufficient fromAddresses for MPC signing');
}
const addr = Buffer.from(this.transaction._fromAddresses[0]);
if (addr.length !== 20) {
throw new BuildTransactionError(`Invalid signing address length: expected 20 bytes, got ${addr.length}`);
}
return [addr];
}

if (this.transaction._fromAddresses.length < Math.max(firstIndex, bitgoIndex) + 1) {
throw new BuildTransactionError(
`Insufficient fromAddresses: need at least ${Math.max(firstIndex, bitgoIndex) + 1} addresses`
Expand Down
32 changes: 32 additions & 0 deletions modules/sdk-coin-flrp/src/lib/transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,38 @@ export class Transaction extends BaseTransaction {
}
}

/**
* Apply an externally-produced signature (from MPC/TSS) to this transaction.
* Fills the first empty signature slot in each credential.
* @param signature - 65-byte Uint8Array (r[32] + s[32] + recovery[1])
*/
addExternalSignature(signature: Uint8Array): void {
if (!this._flareTransaction) {
throw new InvalidTransactionError('empty transaction to sign');
}
if (!this.hasCredentials) {
throw new InvalidTransactionError('empty credentials to sign');
}
const unsignedTx = this._flareTransaction as UnsignedTx;

let signatureSet = false;
for (const credential of unsignedTx.credentials) {
const signatures = credential.getSignatures();
for (let i = 0; i < signatures.length; i++) {
if (isEmptySignature(signatures[i])) {
credential.setSignature(i, signature);
signatureSet = true;
break;
}
}
}

if (!signatureSet) {
throw new SigningError('No empty signature slot found');
}
this._rawSignedBytes = undefined;
}

toBroadcastFormat(): string {
if (!this._flareTransaction) {
throw new InvalidTransactionError('Empty transaction data');
Expand Down
4 changes: 2 additions & 2 deletions modules/sdk-coin-flrp/src/lib/transactionBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,8 @@ export abstract class TransactionBuilder extends BaseTransactionBuilder {
* @param threshold - Number of required signatures
*/
validateThreshold(threshold: number): void {
if (!threshold || threshold !== 2) {
throw new BuildTransactionError('Invalid transaction: threshold must be set to 2');
if (!threshold || (threshold !== 1 && threshold !== 2)) {
throw new BuildTransactionError('Invalid transaction: threshold must be 1 or 2');
}
}

Expand Down
39 changes: 36 additions & 3 deletions modules/sdk-coin-flrp/test/unit/flrp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ 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 { HalfSignedAccountTransaction, TransactionType, MPCAlgorithm } from '@bitgo/sdk-core';
import assert from 'assert';

describe('Flrp test cases', function () {
Expand Down Expand Up @@ -57,6 +57,14 @@ describe('Flrp test cases', function () {
basecoin.getDefaultMultisigType().should.equal('onchain');
});

it('should support TSS', function () {
basecoin.supportsTss().should.equal(true);
});

it('should return ecdsa as MPC algorithm', function () {
(basecoin.getMPCAlgorithm() as MPCAlgorithm).should.equal('ecdsa');
});

describe('Keypairs:', () => {
it('should generate a keypair from random seed', function () {
const keyPair = basecoin.generateKeyPair();
Expand Down Expand Up @@ -499,14 +507,39 @@ describe('Flrp test cases', function () {
isValid.should.be.true();
});

it('should throw for address with wrong number of keychains', async () => {
it('should verify MPC wallet address with single keychain', async () => {
const address = SEED_ACCOUNT.addressTestnet;

const isValid = await basecoin.isWalletAddress({
address,
keychains: [{ pub: SEED_ACCOUNT.publicKey }],
});

isValid.should.be.true();
});

it('should reject MPC wallet address that does not match keychain', async () => {
const address = SEED_ACCOUNT.addressTestnet;

await assert.rejects(
async () =>
basecoin.isWalletAddress({
address,
keychains: [{ pub: SEED_ACCOUNT.publicKey }],
keychains: [{ pub: ACCOUNT_1.publicKey }],
}),
/address validation failure/
);
});

it('should throw for multisig address with wrong number of keychains', async () => {
// Two tilde-separated addresses but only 2 keychains
const address = SEED_ACCOUNT.addressTestnet + '~' + ACCOUNT_1.addressTestnet;

await assert.rejects(
async () =>
basecoin.isWalletAddress({
address,
keychains: [{ pub: SEED_ACCOUNT.publicKey }, { pub: ACCOUNT_1.publicKey }],
}),
/Invalid keychains/
);
Expand Down
Loading
Loading