diff --git a/modules/sdk-coin-flr/src/flr.ts b/modules/sdk-coin-flr/src/flr.ts index b13fd54472..8ac281eace 100644 --- a/modules/sdk-coin-flr/src/flr.ts +++ b/modules/sdk-coin-flr/src/flr.ts @@ -440,6 +440,11 @@ export class Flr extends AbstractEthLikeNewCoins { * @returns {Promise} */ async getExtraPrebuildParams(buildParams: BuildOptions): Promise { + // 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 && diff --git a/modules/sdk-coin-flr/test/unit/flr.ts b/modules/sdk-coin-flr/test/unit/flr.ts index 1b13103db5..8e964e3fe3 100644 --- a/modules/sdk-coin-flr/test/unit/flr.ts +++ b/modules/sdk-coin-flr/test/unit/flr.ts @@ -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 () { diff --git a/modules/sdk-coin-flrp/src/flrp.ts b/modules/sdk-coin-flrp/src/flrp.ts index 554db37e92..3f5ad08638 100644 --- a/modules/sdk-coin-flrp/src/flrp.ts +++ b/modules/sdk-coin-flrp/src/flrp.ts @@ -4,6 +4,7 @@ import { BaseCoin, BitGoBase, KeyPair, + MPCAlgorithm, MultisigType, multisigTypes, ParsedTransaction, @@ -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 { const txHex = params.txPrebuild && params.txPrebuild.txHex; if (!txHex) { @@ -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) @@ -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 { + 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 { + 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 */ diff --git a/modules/sdk-coin-flrp/src/lib/atomicTransactionBuilder.ts b/modules/sdk-coin-flrp/src/lib/atomicTransactionBuilder.ts index 0478012815..391da28d12 100644 --- a/modules/sdk-coin-flrp/src/lib/atomicTransactionBuilder.ts +++ b/modules/sdk-coin-flrp/src/lib/atomicTransactionBuilder.ts @@ -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` diff --git a/modules/sdk-coin-flrp/src/lib/transaction.ts b/modules/sdk-coin-flrp/src/lib/transaction.ts index 9e3697b31b..959d964342 100644 --- a/modules/sdk-coin-flrp/src/lib/transaction.ts +++ b/modules/sdk-coin-flrp/src/lib/transaction.ts @@ -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'); diff --git a/modules/sdk-coin-flrp/src/lib/transactionBuilder.ts b/modules/sdk-coin-flrp/src/lib/transactionBuilder.ts index cbab24c189..15e0dde9d6 100644 --- a/modules/sdk-coin-flrp/src/lib/transactionBuilder.ts +++ b/modules/sdk-coin-flrp/src/lib/transactionBuilder.ts @@ -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'); } } diff --git a/modules/sdk-coin-flrp/test/unit/flrp.ts b/modules/sdk-coin-flrp/test/unit/flrp.ts index 21855074d4..0ed7e68034 100644 --- a/modules/sdk-coin-flrp/test/unit/flrp.ts +++ b/modules/sdk-coin-flrp/test/unit/flrp.ts @@ -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 () { @@ -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(); @@ -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/ ); diff --git a/modules/sdk-coin-flrp/test/unit/lib/exportInCTxBuilder.ts b/modules/sdk-coin-flrp/test/unit/lib/exportInCTxBuilder.ts index 2869bfd25d..63d0028448 100644 --- a/modules/sdk-coin-flrp/test/unit/lib/exportInCTxBuilder.ts +++ b/modules/sdk-coin-flrp/test/unit/lib/exportInCTxBuilder.ts @@ -1,10 +1,11 @@ import { coins } from '@bitgo/statics'; import { BuildTransactionError, TransactionType } from '@bitgo/sdk-core'; import * as assert from 'assert'; -import { TransactionBuilderFactory } from '../../../src/lib/transactionBuilderFactory'; +import { TransactionBuilderFactory, Transaction } from '../../../src/lib'; import { EXPORT_IN_C as testData } from '../../resources/transactionData/exportInC'; -import { CONTEXT } from '../../resources/account'; +import { CONTEXT, ON_CHAIN_TEST_WALLET } from '../../resources/account'; import { FlrpContext } from '@bitgo/public-types'; +import { secp256k1, UnsignedTx } from '@flarenetwork/flarejs'; describe('ExportInCTxBuilder', function () { const coinConfig = coins.get('tflrp'); @@ -188,4 +189,142 @@ describe('ExportInCTxBuilder', function () { tx.id.should.equal('3kXUsHix1bZRQ9hqUc24cp7sXFiy2LbPn6Eh2HQCAaMUi75s9'); }); }); + + describe('MPC signing (threshold=1)', () => { + it('should build and sign export from C-chain with threshold=1 using sign()', async () => { + const mpcAddress = ON_CHAIN_TEST_WALLET.user.pChainAddress; + + const txBuilder = factory + .getExportInCBuilder() + .fromPubKey(testData.cHexAddress) + .nonce(testData.nonce) + .amount(testData.amount) + .threshold(1) + .locktime(0) + .to(mpcAddress) + .fee(testData.fee) + .context(CONTEXT as FlrpContext); + + txBuilder.sign({ key: ON_CHAIN_TEST_WALLET.user.privateKey }); + const tx = await txBuilder.build(); + + tx.signature.length.should.equal(1); + tx.signature[0].should.startWith('0x'); + + const json = tx.toJson(); + json.type.should.equal(TransactionType.Export); + json.threshold.should.equal(1); + json.sourceChain!.should.equal('C'); + json.destinationChain!.should.equal('P'); + }); + + it('should build and sign export from C-chain with threshold=1 using addExternalSignature()', async () => { + const mpcAddress = ON_CHAIN_TEST_WALLET.user.pChainAddress; + + const txBuilder = factory + .getExportInCBuilder() + .fromPubKey(testData.cHexAddress) + .nonce(testData.nonce) + .amount(testData.amount) + .threshold(1) + .locktime(0) + .to(mpcAddress) + .fee(testData.fee) + .context(CONTEXT as FlrpContext); + + // Build unsigned + const tx = (await txBuilder.build()) as Transaction; + const unsignedHex = tx.toBroadcastFormat(); + + // Simulate MPC: sign the unsigned bytes externally + const unsignedTx = tx.getFlareTransaction() as UnsignedTx; + const unsignedBytes = unsignedTx.toBytes(); + const signature = await secp256k1.sign(unsignedBytes, Buffer.from(ON_CHAIN_TEST_WALLET.user.privateKey, 'hex')); + + tx.addExternalSignature(signature); + const signedHex = tx.toBroadcastFormat(); + signedHex.should.not.equal(unsignedHex); + + // Verify roundtrip + const parsedBuilder = factory.from(signedHex); + const parsedTx = await parsedBuilder.build(); + parsedTx.toBroadcastFormat().should.equal(signedHex); + + // Verify signature + tx.signature.length.should.equal(1); + }); + + it('should produce same signed tx via sign() and addExternalSignature()', async () => { + const mpcAddress = ON_CHAIN_TEST_WALLET.user.pChainAddress; + const mpcPrivateKey = ON_CHAIN_TEST_WALLET.user.privateKey; + + // Build and sign via sign() + const txBuilder1 = factory + .getExportInCBuilder() + .fromPubKey(testData.cHexAddress) + .nonce(testData.nonce) + .amount(testData.amount) + .threshold(1) + .locktime(0) + .to(mpcAddress) + .fee(testData.fee) + .context(CONTEXT as FlrpContext); + + txBuilder1.sign({ key: mpcPrivateKey }); + const tx1 = await txBuilder1.build(); + const signedHex1 = tx1.toBroadcastFormat(); + + // Build and sign via addExternalSignature() + const txBuilder2 = factory + .getExportInCBuilder() + .fromPubKey(testData.cHexAddress) + .nonce(testData.nonce) + .amount(testData.amount) + .threshold(1) + .locktime(0) + .to(mpcAddress) + .fee(testData.fee) + .context(CONTEXT as FlrpContext); + + const tx2 = (await txBuilder2.build()) as Transaction; + const unsignedTx = tx2.getFlareTransaction() as UnsignedTx; + const signature = await secp256k1.sign(unsignedTx.toBytes(), Buffer.from(mpcPrivateKey, 'hex')); + tx2.addExternalSignature(signature); + const signedHex2 = tx2.toBroadcastFormat(); + + signedHex1.should.equal(signedHex2); + tx1.id.should.equal(tx2.id); + }); + + it('should deserialize unsigned MPC tx and sign it', async () => { + const mpcAddress = ON_CHAIN_TEST_WALLET.user.pChainAddress; + + const txBuilder = factory + .getExportInCBuilder() + .fromPubKey(testData.cHexAddress) + .nonce(testData.nonce) + .amount(testData.amount) + .threshold(1) + .locktime(0) + .to(mpcAddress) + .fee(testData.fee) + .context(CONTEXT as FlrpContext); + + const unsignedTx = await txBuilder.build(); + const unsignedHex = unsignedTx.toBroadcastFormat(); + + // Deserialize and sign + const txBuilder2 = factory.from(unsignedHex); + txBuilder2.sign({ key: ON_CHAIN_TEST_WALLET.user.privateKey }); + const signedTx = await txBuilder2.build(); + + signedTx.signature.length.should.equal(1); + + // Verify roundtrip of signed tx + const txBuilder3 = factory.from(signedTx.toBroadcastFormat()); + const parsedTx = await txBuilder3.build(); + parsedTx.toBroadcastFormat().should.equal(signedTx.toBroadcastFormat()); + parsedTx.id.should.equal(signedTx.id); + }); + }); }); diff --git a/modules/sdk-coin-flrp/test/unit/lib/exportInPTxBuilder.ts b/modules/sdk-coin-flrp/test/unit/lib/exportInPTxBuilder.ts index 5e699aa065..69ab74417a 100644 --- a/modules/sdk-coin-flrp/test/unit/lib/exportInPTxBuilder.ts +++ b/modules/sdk-coin-flrp/test/unit/lib/exportInPTxBuilder.ts @@ -2,11 +2,11 @@ import assert from 'assert'; import 'should'; import { EXPORT_IN_P as testData } from '../../resources/transactionData/exportInP'; import { ON_CHAIN_TEST_WALLET } from '../../resources/account'; -import { TransactionBuilderFactory } from '../../../src/lib'; +import { TransactionBuilderFactory, Transaction } from '../../../src/lib'; import utils from '../../../src/lib/utils'; import { coins } from '@bitgo/statics'; import signFlowTest from './signFlowTestSuit'; -import { pvmSerial, UnsignedTx, TransferOutput } from '@flarenetwork/flarejs'; +import { pvmSerial, UnsignedTx, TransferOutput, secp256k1 } from '@flarenetwork/flarejs'; describe('Flrp Export In P Tx Builder', () => { const coinConfig = coins.get('tflrp'); @@ -377,4 +377,186 @@ describe('Flrp Export In P Tx Builder', () => { actualAddressHexes.should.deepEqual(expectedAddressHexes); }); }); + + describe('MPC signing (threshold=1)', () => { + const mpcUtxo = { + outputID: 7, + amount: '50000000', + txid: 'bgHnEJ64td8u31aZrGDaWcDqxZ8vDV5qGd7bmSifgvUnUW8v2', + threshold: 1, + addresses: [ON_CHAIN_TEST_WALLET.user.pChainAddress], + outputidx: '0', + locktime: '0', + }; + + it('should build and sign P-chain export with threshold=1 using sign()', async () => { + const txBuilder = factory + .getExportInPBuilder() + .threshold(1) + .locktime(0) + .fromPubKey([ON_CHAIN_TEST_WALLET.user.pChainAddress]) + .amount('30000000') + .externalChainId(testData.sourceChainId) + .decodedUtxos([mpcUtxo]) + .context(testData.context) + .feeState(testData.feeState); + + txBuilder.sign({ key: ON_CHAIN_TEST_WALLET.user.privateKey }); + const tx = await txBuilder.build(); + + tx.signature.length.should.equal(1); + + const json = tx.toJson(); + json.threshold.should.equal(1); + json.sourceChain!.should.equal('P'); + json.destinationChain!.should.equal('C'); + }); + + it('should build and sign P-chain export with threshold=1 using addExternalSignature()', async () => { + const txBuilder = factory + .getExportInPBuilder() + .threshold(1) + .locktime(0) + .fromPubKey([ON_CHAIN_TEST_WALLET.user.pChainAddress]) + .amount('30000000') + .externalChainId(testData.sourceChainId) + .decodedUtxos([mpcUtxo]) + .context(testData.context) + .feeState(testData.feeState); + + const tx = (await txBuilder.build()) as Transaction; + const unsignedHex = tx.toBroadcastFormat(); + + // Simulate MPC signing + const flareUnsignedTx = tx.getFlareTransaction() as UnsignedTx; + const signature = await secp256k1.sign( + flareUnsignedTx.toBytes(), + Buffer.from(ON_CHAIN_TEST_WALLET.user.privateKey, 'hex') + ); + + tx.addExternalSignature(signature); + const signedHex = tx.toBroadcastFormat(); + signedHex.should.not.equal(unsignedHex); + + // Verify roundtrip + const parsedBuilder = factory.from(signedHex); + const parsedTx = await parsedBuilder.build(); + parsedTx.toBroadcastFormat().should.equal(signedHex); + + tx.signature.length.should.equal(1); + }); + + it('should produce same signed tx via sign() and addExternalSignature()', async () => { + const buildArgs = () => + factory + .getExportInPBuilder() + .threshold(1) + .locktime(0) + .fromPubKey([ON_CHAIN_TEST_WALLET.user.pChainAddress]) + .amount('30000000') + .externalChainId(testData.sourceChainId) + .decodedUtxos([mpcUtxo]) + .context(testData.context) + .feeState(testData.feeState); + + // sign() + const txBuilder1 = buildArgs(); + txBuilder1.sign({ key: ON_CHAIN_TEST_WALLET.user.privateKey }); + const tx1 = await txBuilder1.build(); + const signedHex1 = tx1.toBroadcastFormat(); + + // addExternalSignature() + const txBuilder2 = buildArgs(); + const tx2 = (await txBuilder2.build()) as Transaction; + const flareUnsignedTx = tx2.getFlareTransaction() as UnsignedTx; + const signature = await secp256k1.sign( + flareUnsignedTx.toBytes(), + Buffer.from(ON_CHAIN_TEST_WALLET.user.privateKey, 'hex') + ); + tx2.addExternalSignature(signature); + const signedHex2 = tx2.toBroadcastFormat(); + + signedHex1.should.equal(signedHex2); + tx1.id.should.equal(tx2.id); + }); + + it('should deserialize unsigned MPC tx and sign it', async () => { + const txBuilder = factory + .getExportInPBuilder() + .threshold(1) + .locktime(0) + .fromPubKey([ON_CHAIN_TEST_WALLET.user.pChainAddress]) + .amount('30000000') + .externalChainId(testData.sourceChainId) + .decodedUtxos([mpcUtxo]) + .context(testData.context) + .feeState(testData.feeState); + + const unsignedTx = await txBuilder.build(); + const unsignedHex = unsignedTx.toBroadcastFormat(); + + // Deserialize and sign + const txBuilder2 = factory.from(unsignedHex); + txBuilder2.sign({ key: ON_CHAIN_TEST_WALLET.user.privateKey }); + const signedTx = await txBuilder2.build(); + + signedTx.signature.length.should.equal(1); + + // Verify roundtrip + const parsedTx = await factory.from(signedTx.toBroadcastFormat()).build(); + parsedTx.toBroadcastFormat().should.equal(signedTx.toBroadcastFormat()); + parsedTx.id.should.equal(signedTx.id); + }); + + it('should have change output with threshold=1 for MPC wallets', async () => { + const txBuilder = factory + .getExportInPBuilder() + .threshold(1) + .locktime(0) + .fromPubKey([ON_CHAIN_TEST_WALLET.user.pChainAddress]) + .amount('30000000') + .externalChainId(testData.sourceChainId) + .decodedUtxos([mpcUtxo]) + .context(testData.context) + .feeState(testData.feeState); + + const tx = await txBuilder.build(); + + const flareTransaction = (tx as any)._flareTransaction as UnsignedTx; + const innerTx = flareTransaction.getTx() as pvmSerial.ExportTx; + const changeOutputs = innerTx.baseTx.outputs; + + if (changeOutputs.length > 0) { + changeOutputs.forEach((output) => { + const transferOut = output.output as TransferOutput; + const threshold = transferOut.outputOwners.threshold.value(); + threshold.should.equal(1, 'Change output should have threshold=1 for MPC'); + }); + } + }); + + it('should have single sigIndex for MPC signed tx', async () => { + const txBuilder = factory + .getExportInPBuilder() + .threshold(1) + .locktime(0) + .fromPubKey([ON_CHAIN_TEST_WALLET.user.pChainAddress]) + .amount('30000000') + .externalChainId(testData.sourceChainId) + .decodedUtxos([mpcUtxo]) + .context(testData.context) + .feeState(testData.feeState); + + txBuilder.sign({ key: ON_CHAIN_TEST_WALLET.user.privateKey }); + const tx = await txBuilder.build(); + + // Verify credentials have 1 signature slot + const flareTransaction = (tx as any)._flareTransaction as UnsignedTx; + flareTransaction.credentials.length.should.be.greaterThan(0); + for (const cred of flareTransaction.credentials) { + const sigs = cred.getSignatures(); + sigs.length.should.equal(1); + } + }); + }); }); diff --git a/modules/sdk-coin-flrp/test/unit/lib/importInCTxBuilder.ts b/modules/sdk-coin-flrp/test/unit/lib/importInCTxBuilder.ts index 710b312508..651fe10421 100644 --- a/modules/sdk-coin-flrp/test/unit/lib/importInCTxBuilder.ts +++ b/modules/sdk-coin-flrp/test/unit/lib/importInCTxBuilder.ts @@ -1,10 +1,11 @@ import assert from 'assert'; import 'should'; -import { TransactionBuilderFactory } from '../../../src/lib'; +import { TransactionBuilderFactory, Transaction } from '../../../src/lib'; import { coins } from '@bitgo/statics'; import { IMPORT_IN_C as testData } from '../../resources/transactionData/importInC'; import { ON_CHAIN_TEST_WALLET } from '../../resources/account'; import signFlowTest from './signFlowTestSuit'; +import { secp256k1, UnsignedTx } from '@flarenetwork/flarejs'; describe('Flrp Import In C Tx Builder', () => { const factory = new TransactionBuilderFactory(coins.get('tflrp')); @@ -219,4 +220,131 @@ describe('Flrp Import In C Tx Builder', () => { hex.should.containEql(outputHex); }); }); + + describe('MPC signing (threshold=1)', () => { + const mpcUtxo = { + outputID: 7, + amount: '30000000', + txid: 'nSBwNcgfLbk5S425b1qaYaqTTCiMCV75KU4Fbnq8SPUUqLq2', + threshold: 1, + addresses: [ON_CHAIN_TEST_WALLET.user.pChainAddress], + outputidx: '1', + locktime: '0', + }; + + it('should build and sign import to C-chain with threshold=1 using sign()', async () => { + const txBuilder = factory + .getImportInCBuilder() + .threshold(1) + .locktime(0) + .fromPubKey([ON_CHAIN_TEST_WALLET.user.pChainAddress]) + .to('0x96993BAEb6AaE2e06BF95F144e2775D4f8efbD35') + .fee('1000000') + .decodedUtxos([mpcUtxo]) + .context(testData.context); + + txBuilder.sign({ key: ON_CHAIN_TEST_WALLET.user.privateKey }); + const tx = await txBuilder.build(); + + tx.signature.length.should.equal(1); + + const json = tx.toJson(); + json.threshold.should.equal(1); + json.sourceChain!.should.equal('P'); + json.destinationChain!.should.equal('C'); + }); + + it('should build and sign import to C-chain with threshold=1 using addExternalSignature()', async () => { + const txBuilder = factory + .getImportInCBuilder() + .threshold(1) + .locktime(0) + .fromPubKey([ON_CHAIN_TEST_WALLET.user.pChainAddress]) + .to('0x96993BAEb6AaE2e06BF95F144e2775D4f8efbD35') + .fee('1000000') + .decodedUtxos([mpcUtxo]) + .context(testData.context); + + const tx = (await txBuilder.build()) as Transaction; + const unsignedHex = tx.toBroadcastFormat(); + + // Simulate MPC signing + const flareUnsignedTx = tx.getFlareTransaction() as UnsignedTx; + const signature = await secp256k1.sign( + flareUnsignedTx.toBytes(), + Buffer.from(ON_CHAIN_TEST_WALLET.user.privateKey, 'hex') + ); + + tx.addExternalSignature(signature); + const signedHex = tx.toBroadcastFormat(); + signedHex.should.not.equal(unsignedHex); + + // Verify roundtrip + const parsedBuilder = factory.from(signedHex); + const parsedTx = await parsedBuilder.build(); + parsedTx.toBroadcastFormat().should.equal(signedHex); + + tx.signature.length.should.equal(1); + }); + + it('should produce same signed tx via sign() and addExternalSignature()', async () => { + const buildArgs = () => + factory + .getImportInCBuilder() + .threshold(1) + .locktime(0) + .fromPubKey([ON_CHAIN_TEST_WALLET.user.pChainAddress]) + .to('0x96993BAEb6AaE2e06BF95F144e2775D4f8efbD35') + .fee('1000000') + .decodedUtxos([mpcUtxo]) + .context(testData.context); + + // sign() + const txBuilder1 = buildArgs(); + txBuilder1.sign({ key: ON_CHAIN_TEST_WALLET.user.privateKey }); + const tx1 = await txBuilder1.build(); + const signedHex1 = tx1.toBroadcastFormat(); + + // addExternalSignature() + const txBuilder2 = buildArgs(); + const tx2 = (await txBuilder2.build()) as Transaction; + const flareUnsignedTx = tx2.getFlareTransaction() as UnsignedTx; + const signature = await secp256k1.sign( + flareUnsignedTx.toBytes(), + Buffer.from(ON_CHAIN_TEST_WALLET.user.privateKey, 'hex') + ); + tx2.addExternalSignature(signature); + const signedHex2 = tx2.toBroadcastFormat(); + + signedHex1.should.equal(signedHex2); + tx1.id.should.equal(tx2.id); + }); + + it('should deserialize unsigned MPC tx and sign it', async () => { + const txBuilder = factory + .getImportInCBuilder() + .threshold(1) + .locktime(0) + .fromPubKey([ON_CHAIN_TEST_WALLET.user.pChainAddress]) + .to('0x96993BAEb6AaE2e06BF95F144e2775D4f8efbD35') + .fee('1000000') + .decodedUtxos([mpcUtxo]) + .context(testData.context); + + const unsignedTx = await txBuilder.build(); + const unsignedHex = unsignedTx.toBroadcastFormat(); + + // Deserialize and sign + const txBuilder2 = factory.from(unsignedHex); + txBuilder2.sign({ key: ON_CHAIN_TEST_WALLET.user.privateKey }); + const signedTx = await txBuilder2.build(); + + signedTx.signature.length.should.equal(1); + + // Verify roundtrip + const parsedTx = await factory.from(signedTx.toBroadcastFormat()).build(); + parsedTx.toBroadcastFormat().should.equal(signedTx.toBroadcastFormat()); + parsedTx.id.should.equal(signedTx.id); + }); + }); }); diff --git a/modules/sdk-coin-flrp/test/unit/lib/importInPTxBuilder.ts b/modules/sdk-coin-flrp/test/unit/lib/importInPTxBuilder.ts index 9d7c3412f3..2c6c5c2b7d 100644 --- a/modules/sdk-coin-flrp/test/unit/lib/importInPTxBuilder.ts +++ b/modules/sdk-coin-flrp/test/unit/lib/importInPTxBuilder.ts @@ -2,9 +2,10 @@ import assert from 'assert'; import 'should'; import { IMPORT_IN_P as testData } from '../../resources/transactionData/importInP'; import { ON_CHAIN_TEST_WALLET } from '../../resources/account'; -import { TransactionBuilderFactory } from '../../../src/lib'; +import { TransactionBuilderFactory, Transaction } from '../../../src/lib'; import { coins } from '@bitgo/statics'; import signFlowTest from './signFlowTestSuit'; +import { secp256k1, UnsignedTx } from '@flarenetwork/flarejs'; describe('Flrp Import In P Tx Builder', () => { const coinConfig = coins.get('tflrp'); @@ -212,4 +213,135 @@ describe('Flrp Import In P Tx Builder', () => { sigIdx1.should.equal(2); }); }); + + describe('MPC signing (threshold=1)', () => { + const mpcUtxo = { + outputID: 7, + amount: '50000000', + txid: 'aLwVQequmbhhjfhL6SvfM6MGWAB8wHwQfJ67eowEbAEUpkueN', + threshold: 1, + addresses: [ON_CHAIN_TEST_WALLET.user.pChainAddress], + outputidx: '0', + locktime: '0', + }; + + it('should build and sign import to P-chain with threshold=1 using sign()', async () => { + const txBuilder = factory + .getImportInPBuilder() + .threshold(1) + .locktime(0) + .fromPubKey([ON_CHAIN_TEST_WALLET.user.corethAddress]) + .to([ON_CHAIN_TEST_WALLET.user.pChainAddress]) + .externalChainId(testData.sourceChainId) + .decodedUtxos([mpcUtxo]) + .context(testData.context) + .feeState(testData.feeState); + + txBuilder.sign({ key: ON_CHAIN_TEST_WALLET.user.privateKey }); + const tx = await txBuilder.build(); + + tx.signature.length.should.equal(1); + + const json = tx.toJson(); + json.threshold.should.equal(1); + json.sourceChain!.should.equal('C'); + json.destinationChain!.should.equal('P'); + }); + + it('should build and sign import to P-chain with threshold=1 using addExternalSignature()', async () => { + const txBuilder = factory + .getImportInPBuilder() + .threshold(1) + .locktime(0) + .fromPubKey([ON_CHAIN_TEST_WALLET.user.corethAddress]) + .to([ON_CHAIN_TEST_WALLET.user.pChainAddress]) + .externalChainId(testData.sourceChainId) + .decodedUtxos([mpcUtxo]) + .context(testData.context) + .feeState(testData.feeState); + + const tx = (await txBuilder.build()) as Transaction; + const unsignedHex = tx.toBroadcastFormat(); + + // Simulate MPC signing + const flareUnsignedTx = tx.getFlareTransaction() as UnsignedTx; + const signature = await secp256k1.sign( + flareUnsignedTx.toBytes(), + Buffer.from(ON_CHAIN_TEST_WALLET.user.privateKey, 'hex') + ); + + tx.addExternalSignature(signature); + const signedHex = tx.toBroadcastFormat(); + signedHex.should.not.equal(unsignedHex); + + // Verify roundtrip + const parsedBuilder = factory.from(signedHex); + const parsedTx = await parsedBuilder.build(); + parsedTx.toBroadcastFormat().should.equal(signedHex); + + tx.signature.length.should.equal(1); + }); + + it('should produce same signed tx via sign() and addExternalSignature()', async () => { + const buildArgs = () => + factory + .getImportInPBuilder() + .threshold(1) + .locktime(0) + .fromPubKey([ON_CHAIN_TEST_WALLET.user.corethAddress]) + .to([ON_CHAIN_TEST_WALLET.user.pChainAddress]) + .externalChainId(testData.sourceChainId) + .decodedUtxos([mpcUtxo]) + .context(testData.context) + .feeState(testData.feeState); + + // sign() + const txBuilder1 = buildArgs(); + txBuilder1.sign({ key: ON_CHAIN_TEST_WALLET.user.privateKey }); + const tx1 = await txBuilder1.build(); + const signedHex1 = tx1.toBroadcastFormat(); + + // addExternalSignature() + const txBuilder2 = buildArgs(); + const tx2 = (await txBuilder2.build()) as Transaction; + const flareUnsignedTx = tx2.getFlareTransaction() as UnsignedTx; + const signature = await secp256k1.sign( + flareUnsignedTx.toBytes(), + Buffer.from(ON_CHAIN_TEST_WALLET.user.privateKey, 'hex') + ); + tx2.addExternalSignature(signature); + const signedHex2 = tx2.toBroadcastFormat(); + + signedHex1.should.equal(signedHex2); + tx1.id.should.equal(tx2.id); + }); + + it('should deserialize unsigned MPC tx and sign it', async () => { + const txBuilder = factory + .getImportInPBuilder() + .threshold(1) + .locktime(0) + .fromPubKey([ON_CHAIN_TEST_WALLET.user.corethAddress]) + .to([ON_CHAIN_TEST_WALLET.user.pChainAddress]) + .externalChainId(testData.sourceChainId) + .decodedUtxos([mpcUtxo]) + .context(testData.context) + .feeState(testData.feeState); + + const unsignedTx = await txBuilder.build(); + const unsignedHex = unsignedTx.toBroadcastFormat(); + + // Deserialize and sign + const txBuilder2 = factory.from(unsignedHex); + txBuilder2.sign({ key: ON_CHAIN_TEST_WALLET.user.privateKey }); + const signedTx = await txBuilder2.build(); + + signedTx.signature.length.should.equal(1); + + // Verify roundtrip + const parsedTx = await factory.from(signedTx.toBroadcastFormat()).build(); + parsedTx.toBroadcastFormat().should.equal(signedTx.toBroadcastFormat()); + parsedTx.id.should.equal(signedTx.id); + }); + }); }); diff --git a/modules/sdk-coin-flrp/test/unit/lib/transactionBuilderFactory.ts b/modules/sdk-coin-flrp/test/unit/lib/transactionBuilderFactory.ts index 402982de21..57e18ebf65 100644 --- a/modules/sdk-coin-flrp/test/unit/lib/transactionBuilderFactory.ts +++ b/modules/sdk-coin-flrp/test/unit/lib/transactionBuilderFactory.ts @@ -1,4 +1,5 @@ import 'should'; +import assert from 'assert'; import { coins } from '@bitgo/statics'; import { TransactionBuilderFactory, TxData } from '../../../src/lib'; import { EXPORT_IN_P } from '../../resources/transactionData/exportInP'; @@ -9,6 +10,42 @@ import { EXPORT_IN_C } from '../../resources/transactionData/exportInC'; describe('Flrp Transaction Builder Factory', () => { const factory = new TransactionBuilderFactory(coins.get('tflrp')); + describe('Threshold validation for MPC (threshold=1)', () => { + it('should accept threshold=1 for MPC wallets', () => { + const txBuilder = factory.getExportInCBuilder(); + assert.doesNotThrow(() => { + txBuilder.threshold(1); + }); + }); + + it('should accept threshold=2 for multisig wallets', () => { + const txBuilder = factory.getExportInCBuilder(); + assert.doesNotThrow(() => { + txBuilder.threshold(2); + }); + }); + + it('should reject threshold=0', () => { + const txBuilder = factory.getExportInCBuilder(); + assert.throws( + () => { + txBuilder.threshold(0); + }, + (e: any) => e.message === 'Invalid transaction: threshold must be 1 or 2' + ); + }); + + it('should reject threshold=3', () => { + const txBuilder = factory.getExportInCBuilder(); + assert.throws( + () => { + txBuilder.threshold(3); + }, + (e: any) => e.message === 'Invalid transaction: threshold must be 1 or 2' + ); + }); + }); + describe('Cross chain transfer has source and destination chains', () => { // P-chain Export to C-chain: source is P, destination is C const p2cExportTxs = [EXPORT_IN_P.unsignedHex, EXPORT_IN_P.halfSigntxHex, EXPORT_IN_P.fullSigntxHex];