diff --git a/commitlint.config.js b/commitlint.config.js index 9e69f2af05..8aa9ec1b32 100644 --- a/commitlint.config.js +++ b/commitlint.config.js @@ -59,6 +59,7 @@ module.exports = { 'RA-', 'SO-', 'SC-', + 'SI-', 'ST-', 'STLX-', 'TMS-', diff --git a/modules/abstract-eth/src/abstractEthLikeNewCoins.ts b/modules/abstract-eth/src/abstractEthLikeNewCoins.ts index 10ee40ee17..f48a440d69 100644 --- a/modules/abstract-eth/src/abstractEthLikeNewCoins.ts +++ b/modules/abstract-eth/src/abstractEthLikeNewCoins.ts @@ -271,6 +271,8 @@ export type RecoverOptions = { derivationSeed?: string; apiKey?: string; // optional API key to use instead of the one from the environment isUnsignedSweep?: boolean; // specify if this is an unsigned recovery + /** For FLR P-derived wallets: the derivation path for the base address (e.g. 'm'). Defaults to 'm/0'. */ + baseAddressDerivationPath?: string; } & TSSRecoverOptions; export type GetBatchExecutionInfoRT = { @@ -2255,12 +2257,19 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin { const { gasLimit, gasPrice } = await this.getGasValues(params); const MPC = new Ecdsa(); - const derivedCommonKeyChain = MPC.deriveUnhardened(commonKeyChain, 'm/0'); + const derivationPath = params.baseAddressDerivationPath ?? 'm/0'; + const derivedCommonKeyChain = MPC.deriveUnhardened(commonKeyChain, derivationPath); const backupKeyPair = new KeyPairLib({ pub: derivedCommonKeyChain.slice(0, 66) }); const baseAddress = backupKeyPair.getAddress(); const unsignedTx = (await this.buildTssRecoveryTxn(baseAddress, gasPrice, gasLimit, params)).tx; const messageHash = unsignedTx.getMessageToSign(true); - const signature = await ECDSAUtils.signRecoveryMpcV2(messageHash, userKeyShare, backupKeyShare, commonKeyChain); + const signature = await ECDSAUtils.signRecoveryMpcV2( + messageHash, + userKeyShare, + backupKeyShare, + commonKeyChain, + derivationPath + ); const ethCommmon = AbstractEthLikeNewCoins.getEthLikeCommon(params.eip1559, params.replayProtectionOptions); const signedTx = this.getSignedTxFromSignature(ethCommmon, unsignedTx, signature); diff --git a/modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts b/modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts index 936d4edd3d..e5e0903c6c 100644 --- a/modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts +++ b/modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts @@ -187,6 +187,12 @@ export interface TssVerifyAddressOptions { * The derivation path becomes {computedPrefix}/{index} instead of m/{index}. */ derivedFromParentWithSeed?: string; + /** + * Optional derivation path for the base address, from wallet.coinSpecific.baseAddressDerivationPath. + * When set to 'm', the base address is at the root (index 0 maps to path 'm' instead of 'm/0'). + * Used for FLR P-derived wallets. + */ + baseAddressDerivationPath?: string; } export function isTssVerifyAddressOptions( @@ -262,6 +268,8 @@ export interface SupplementGenerateWalletOptions { subType?: 'lightningCustody' | 'lightningSelfCustody' | 'onPrem'; coinSpecific?: { [coinName: string]: unknown }; evmKeyRingReferenceWalletId?: string; + /** For FLR C wallet creation: the source FLR P wallet ID to derive from. */ + sourceFlrpWalletId?: string; lightningProvider?: 'voltage' | 'amboss'; } diff --git a/modules/sdk-core/src/bitgo/utils/tss/addressVerification.ts b/modules/sdk-core/src/bitgo/utils/tss/addressVerification.ts index 67e41d1ade..d30a584a57 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/addressVerification.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/addressVerification.ts @@ -66,7 +66,7 @@ export async function verifyMPCWalletAddress( isValidAddress: (address: string) => boolean, getAddressFromPublicKey: (publicKey: string) => string ): Promise { - const { keychains, address, index, derivedFromParentWithSeed } = params; + const { keychains, address, index, derivedFromParentWithSeed, baseAddressDerivationPath } = params; if (!isValidAddress(address)) { throw new InvalidAddressError(`invalid address: ${address}`); @@ -77,9 +77,15 @@ export async function verifyMPCWalletAddress( // Compute derivation path: // - For SMC wallets with derivedFromParentWithSeed, compute prefix and use: {prefix}/{index} + // - For FLR P-derived wallets where baseAddressDerivationPath is 'm', use 'm' for index 0 // - For other wallets, use simple path: m/{index} const prefix = derivedFromParentWithSeed ? getDerivationPath(derivedFromParentWithSeed.toString()) : undefined; - const derivationPath = prefix ? `${prefix}/${index}` : `m/${index}`; + const numericIndex = typeof index === 'string' ? parseInt(index, 10) : index; + const derivationPath = prefix + ? `${prefix}/${index}` + : numericIndex === 0 && baseAddressDerivationPath === 'm' + ? 'm' + : `m/${index}`; const derivedPublicKey = MPC.deriveUnhardened(commonKeychain, derivationPath); // secp256k1 expects 33 bytes; ed25519 expects 32 bytes diff --git a/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts b/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts index 49980335e3..38178e9c61 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts @@ -756,7 +756,8 @@ export class EcdsaMPCv2Utils extends BaseEcdsaUtils { bufferContent = Buffer.from(txOrMessageToSign, 'hex'); } else if (requestType === RequestType.message) { txOrMessageToSign = txRequest.messages![0].messageEncoded; - derivationPath = txRequest.messages![0].derivationPath || 'm/0'; + derivationPath = + txRequest.messages![0].derivationPath || this.wallet.coinSpecific()?.baseAddressDerivationPath || 'm/0'; bufferContent = Buffer.from(txOrMessageToSign, 'hex'); } else { throw new Error('Invalid request type'); @@ -1317,21 +1318,22 @@ export async function signRecoveryMpcV2( messageHash: Buffer, userKeyShare: Buffer, backupKeyShare: Buffer, - commonKeyChain: string + commonKeyChain: string, + derivationPath = 'm/0' ): Promise<{ recid: number; r: string; s: string; y: string; }> { - const userDsg = new DklsDsg.Dsg(userKeyShare, 0, 'm/0', messageHash); - const backupDsg = new DklsDsg.Dsg(backupKeyShare, 1, 'm/0', messageHash); + const userDsg = new DklsDsg.Dsg(userKeyShare, 0, derivationPath, messageHash); + const backupDsg = new DklsDsg.Dsg(backupKeyShare, 1, derivationPath, messageHash); const signatureString = DklsUtils.verifyAndConvertDklsSignature( messageHash, (await DklsUtils.executeTillRound(5, userDsg, backupDsg)) as DklsTypes.DeserializedDklsSignature, commonKeyChain, - 'm/0', + derivationPath, undefined, false ); diff --git a/modules/sdk-core/src/bitgo/wallet/iWallet.ts b/modules/sdk-core/src/bitgo/wallet/iWallet.ts index fe336d7269..43ef93c47d 100644 --- a/modules/sdk-core/src/bitgo/wallet/iWallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/iWallet.ts @@ -352,6 +352,12 @@ export interface WalletCoinSpecific { /** * Lightning coin specific data ends */ + /** + * For FLR P-derived wallets, the derivation path for the base address. + * When set to 'm', the base address is at the root (no child derivation). + * Defaults to 'm/0' if absent. + */ + baseAddressDerivationPath?: string; } export interface PaginationOptions { diff --git a/modules/sdk-core/src/bitgo/wallet/iWallets.ts b/modules/sdk-core/src/bitgo/wallet/iWallets.ts index 02645ebc67..2b03eff9de 100644 --- a/modules/sdk-core/src/bitgo/wallet/iWallets.ts +++ b/modules/sdk-core/src/bitgo/wallet/iWallets.ts @@ -40,6 +40,8 @@ export interface GenerateBaseMpcWalletOptions { label: string; enterprise: string; walletVersion?: number; + /** For FLR C wallet creation: the source FLR P wallet ID to derive from. */ + sourceFlrpWalletId?: string; } export interface GenerateMpcWalletOptions extends GenerateBaseMpcWalletOptions { @@ -80,6 +82,8 @@ export interface GenerateWalletOptions { type?: 'hot' | 'cold' | 'custodial' | 'trading'; subType?: 'lightningCustody' | 'lightningSelfCustody'; evmKeyRingReferenceWalletId?: string; + /** For FLR C wallet creation: the source FLR P wallet ID to derive from. */ + sourceFlrpWalletId?: string; } export const GenerateLightningWalletOptionsCodec = t.intersection( diff --git a/modules/sdk-core/src/bitgo/wallet/wallets.ts b/modules/sdk-core/src/bitgo/wallet/wallets.ts index 152a200bef..f4d7eda4ac 100644 --- a/modules/sdk-core/src/bitgo/wallet/wallets.ts +++ b/modules/sdk-core/src/bitgo/wallet/wallets.ts @@ -437,6 +437,7 @@ export class Wallets implements IWallets { originalPasscodeEncryptionCode: params.passcodeEncryptionCode, enterprise, walletVersion: params.walletVersion, + sourceFlrpWalletId: params.sourceFlrpWalletId, }); if (params.passcodeEncryptionCode) { walletData.encryptedWalletPassphrase = this.bitgo.encrypt({ @@ -1485,6 +1486,7 @@ export class Wallets implements IWallets { enterprise, walletVersion, originalPasscodeEncryptionCode, + sourceFlrpWalletId, }: GenerateMpcWalletOptions): Promise { if (multisigType === 'tss' && this.baseCoin.getMPCAlgorithm() === 'ecdsa') { const tssSettings: TssSettings = await this.bitgo @@ -1517,6 +1519,7 @@ export class Wallets implements IWallets { multisigType, enterprise, walletVersion, + sourceFlrpWalletId, }; const finalWalletParams = await this.baseCoin.supplementGenerateWallet(walletParams, keychains); const newWallet = await this.bitgo.post(this.baseCoin.url('/wallet/add')).send(finalWalletParams).result(); diff --git a/modules/sdk-core/test/unit/bitgo/utils/tss/addressVerification.ts b/modules/sdk-core/test/unit/bitgo/utils/tss/addressVerification.ts index c9ce76bc84..4f21c6ea68 100644 --- a/modules/sdk-core/test/unit/bitgo/utils/tss/addressVerification.ts +++ b/modules/sdk-core/test/unit/bitgo/utils/tss/addressVerification.ts @@ -60,4 +60,91 @@ describe('TSS Address Verification - Derivation Path with Prefix', function () { }); }); }); + + describe('Derivation Path Logic for FLR P-derived wallets (baseAddressDerivationPath)', function () { + // Tests the path selection logic that mirrors verifyMPCWalletAddress. + // Written as pure logic tests to avoid the transitive module-load failures + // (BaseTssUtils circular init) that affect tests using getAddressVerificationModule(). + + function computeDerivationPath( + index: number | string, + baseAddressDerivationPath?: string, + derivedFromParentWithSeed?: string + ): string { + const numericIndex = typeof index === 'string' ? parseInt(index, 10) : index; + const prefix = derivedFromParentWithSeed ? getDerivationPath(derivedFromParentWithSeed.toString()) : undefined; + return prefix + ? `${prefix}/${index}` + : numericIndex === 0 && baseAddressDerivationPath === 'm' + ? 'm' + : `m/${index}`; + } + + it('should use m/0 for index 0 when baseAddressDerivationPath is absent', function () { + computeDerivationPath(0).should.equal('m/0'); + }); + + it('should use m for index 0 when baseAddressDerivationPath is m', function () { + computeDerivationPath(0, 'm').should.equal('m'); + }); + + it('should use m/0 for index 0 when baseAddressDerivationPath is m/0', function () { + computeDerivationPath(0, 'm/0').should.equal('m/0'); + }); + + it('should use m/1 for index 1 even when baseAddressDerivationPath is m', function () { + computeDerivationPath(1, 'm').should.equal('m/1'); + }); + + it('should use m/2 for index 2 regardless of baseAddressDerivationPath', function () { + computeDerivationPath(2, 'm').should.equal('m/2'); + computeDerivationPath(2).should.equal('m/2'); + }); + + it('should use prefix path for SMC wallets, ignoring baseAddressDerivationPath', function () { + const seed = 'test-seed'; + const expectedPrefix = getDerivationPath(seed); + computeDerivationPath(0, 'm', seed).should.equal(`${expectedPrefix}/0`); + }); + + it('should handle string index correctly', function () { + computeDerivationPath('0', 'm').should.equal('m'); + computeDerivationPath('1', 'm').should.equal('m/1'); + }); + + it('should simulate existing coin behavior: ETH, BTC, SOL without baseAddressDerivationPath always use m/N', function () { + // Existing coins (ETH, BTC, SOL, etc.) never set baseAddressDerivationPath. + // They must continue using the standard m/{index} path at every index. + computeDerivationPath(0, undefined).should.equal('m/0'); + computeDerivationPath(1, undefined).should.equal('m/1'); + computeDerivationPath(5, undefined).should.equal('m/5'); + }); + + it('should simulate FLR C derived wallet: only index 0 uses m when baseAddressDerivationPath is m', function () { + // FLR C wallets derived from FLR P set baseAddressDerivationPath='m' in coinSpecific. + // The base address (index 0) must derive from root path 'm' (not 'm/0') so it matches + // the FLR P staking reward address. + computeDerivationPath(0, 'm').should.equal('m'); + // All receive addresses (index > 0) still follow the standard m/{index} path. + computeDerivationPath(1, 'm').should.equal('m/1'); + computeDerivationPath(2, 'm').should.equal('m/2'); + computeDerivationPath(10, 'm').should.equal('m/10'); + }); + + it('should confirm baseAddressDerivationPath must be exactly m to change behavior', function () { + // Only the exact value 'm' triggers the special path — no other value should change behavior. + computeDerivationPath(0, 'm/0').should.equal('m/0'); // explicit m/0 → no change + computeDerivationPath(0, 'M').should.equal('m/0'); // uppercase M → no change + computeDerivationPath(0, '').should.equal('m/0'); // empty string → no change + }); + + it('should not affect SMC/cold wallet derivation even if baseAddressDerivationPath is set', function () { + // When derivedFromParentWithSeed is present (SMC wallets), the prefix path takes full precedence + // and baseAddressDerivationPath is completely ignored. + const seed = 'smc-wallet-seed'; + const expectedPrefix = getDerivationPath(seed); + computeDerivationPath(0, 'm', seed).should.equal(`${expectedPrefix}/0`); + computeDerivationPath(1, 'm', seed).should.equal(`${expectedPrefix}/1`); + }); + }); }); diff --git a/modules/sdk-core/test/unit/bitgo/utils/tss/ecdsa/ecdsaMPCv2DerivationPath.ts b/modules/sdk-core/test/unit/bitgo/utils/tss/ecdsa/ecdsaMPCv2DerivationPath.ts new file mode 100644 index 0000000000..386d5f4c49 --- /dev/null +++ b/modules/sdk-core/test/unit/bitgo/utils/tss/ecdsa/ecdsaMPCv2DerivationPath.ts @@ -0,0 +1,124 @@ +import 'should'; + +/** + * Tests for the derivation-path selection logic added to EcdsaMPCv2.signTssTransaction + * for message signing. + * + * The logic in the source file is: + * derivationPath = + * txRequest.messages[0].derivationPath + * || wallet.coinSpecific()?.baseAddressDerivationPath + * || 'm/0'; + * + * These tests verify: + * 1. The priority order (message path > coinSpecific > default) + * 2. Existing coins (ETH, SOL, …) are completely unaffected — they never set + * baseAddressDerivationPath, so they always get 'm/0'. + * 3. Only FLR C wallets derived from FLR P set baseAddressDerivationPath='m', + * and only when the message has no explicit derivationPath. + */ +describe('EcdsaMPCv2 - Message Signing Derivation Path', function () { + /** + * Pure helper that mirrors the production fallback chain without requiring + * a full EcdsaMPCv2 instance (which needs heavy mocking). + */ + function resolveDerivationPath(opts: { + messageTxDerivationPath?: string; + walletCoinSpecificBaseAddressDerivationPath?: string; + }): string { + return opts.messageTxDerivationPath || opts.walletCoinSpecificBaseAddressDerivationPath || 'm/0'; + } + + describe('priority order: message path > coinSpecific > default', function () { + it('should use txRequest message derivation path when present, ignoring coinSpecific', function () { + resolveDerivationPath({ + messageTxDerivationPath: 'm/1', + walletCoinSpecificBaseAddressDerivationPath: 'm', + }).should.equal('m/1'); + }); + + it('should fall back to coinSpecific baseAddressDerivationPath when message path is absent', function () { + resolveDerivationPath({ + messageTxDerivationPath: undefined, + walletCoinSpecificBaseAddressDerivationPath: 'm', + }).should.equal('m'); + }); + + it('should fall back to m/0 when both message path and coinSpecific are absent', function () { + resolveDerivationPath({ + messageTxDerivationPath: undefined, + walletCoinSpecificBaseAddressDerivationPath: undefined, + }).should.equal('m/0'); + }); + + it('should use message path even when coinSpecific is absent', function () { + resolveDerivationPath({ + messageTxDerivationPath: 'm/3', + walletCoinSpecificBaseAddressDerivationPath: undefined, + }).should.equal('m/3'); + }); + }); + + describe('existing coins are unaffected (no baseAddressDerivationPath in coinSpecific)', function () { + it('ETH wallet: no baseAddressDerivationPath → always uses m/0', function () { + // ETH coinSpecific does not include baseAddressDerivationPath + resolveDerivationPath({ + messageTxDerivationPath: undefined, + walletCoinSpecificBaseAddressDerivationPath: undefined, + }).should.equal('m/0'); + }); + + it('SOL wallet: no baseAddressDerivationPath → always uses m/0', function () { + resolveDerivationPath({ + messageTxDerivationPath: undefined, + walletCoinSpecificBaseAddressDerivationPath: undefined, + }).should.equal('m/0'); + }); + + it('BTC wallet: no baseAddressDerivationPath → always uses m/0', function () { + resolveDerivationPath({ + messageTxDerivationPath: undefined, + walletCoinSpecificBaseAddressDerivationPath: undefined, + }).should.equal('m/0'); + }); + + it('any coin that does not set baseAddressDerivationPath → always uses m/0', function () { + // Covers HBAR, DOT, AVAXC, POLYGON, and any future EVM-like coin + const coins = [undefined, null as any, '']; + coins.forEach((v) => { + resolveDerivationPath({ + messageTxDerivationPath: undefined, + walletCoinSpecificBaseAddressDerivationPath: v, + }).should.equal('m/0'); + }); + }); + }); + + describe('FLR C derived wallet (baseAddressDerivationPath = m)', function () { + it('should use m for FLR C derived wallet when no message derivation path is set', function () { + // FLR C wallets derived from FLR P store baseAddressDerivationPath='m' in coinSpecific. + // The base address must sign against root path 'm' to match the FLR P staking reward address. + resolveDerivationPath({ + messageTxDerivationPath: undefined, + walletCoinSpecificBaseAddressDerivationPath: 'm', + }).should.equal('m'); + }); + + it('should allow an explicit message derivation path to override the FLR C coinSpecific path', function () { + // If the tx engine provides an explicit path (e.g. for a receive address), it wins. + resolveDerivationPath({ + messageTxDerivationPath: 'm/2', + walletCoinSpecificBaseAddressDerivationPath: 'm', + }).should.equal('m/2'); + }); + + it('should still use m/0 if baseAddressDerivationPath is any value other than m', function () { + // The special-case is strictly the value 'm'. + // A value of 'm/0' or any other string falls through to the default. + resolveDerivationPath({ + messageTxDerivationPath: undefined, + walletCoinSpecificBaseAddressDerivationPath: 'm/0', + }).should.equal('m/0'); + }); + }); +}); diff --git a/modules/sdk-core/test/unit/bitgo/wallet/walletsEvmKeyring.ts b/modules/sdk-core/test/unit/bitgo/wallet/walletsEvmKeyring.ts index 8e929bc779..fce22467fb 100644 --- a/modules/sdk-core/test/unit/bitgo/wallet/walletsEvmKeyring.ts +++ b/modules/sdk-core/test/unit/bitgo/wallet/walletsEvmKeyring.ts @@ -172,6 +172,71 @@ describe('Wallets', function () { }); }); + describe('FLR C wallet creation - sourceFlrpWalletId', function () { + let generateMpcWalletStub: sinon.SinonStub; + const mockWalletResult = { + wallet: { id: 'new-flrc-wallet-id' }, + userKeychain: { id: 'user-key-id' }, + backupKeychain: { id: 'backup-key-id' }, + bitgoKeychain: { id: 'bitgo-key-id' }, + }; + + beforeEach(function () { + mockBaseCoin.isEVM.returns(true); + mockBaseCoin.supportsTss.returns(true); + mockBaseCoin.getDefaultMultisigType.returns('tss'); + // Stub the private generateMpcWallet to avoid needing full TSS key-generation mocks + generateMpcWalletStub = sinon.stub(wallets as any, 'generateMpcWallet').resolves(mockWalletResult); + }); + + it('should pass sourceFlrpWalletId to generateMpcWallet when provided (FLR C derived wallet)', async function () { + await wallets.generateWallet({ + label: 'FLR C Wallet', + passphrase: 'test-passphrase', + multisigType: 'tss', + walletVersion: 3, + enterprise: 'test-enterprise-id', + sourceFlrpWalletId: 'flrp-wallet-id-123', + }); + + generateMpcWalletStub.calledOnce.should.be.true(); + const callArgs = generateMpcWalletStub.getCall(0).args[0]; + callArgs.should.have.property('sourceFlrpWalletId', 'flrp-wallet-id-123'); + }); + + it('should NOT pass sourceFlrpWalletId for existing coins (ETH, SOL, etc.) that omit it', async function () { + // Existing coins like ETH and SOL never pass sourceFlrpWalletId, so it should remain + // undefined in the params sent to generateMpcWallet — confirming no behaviour change. + await wallets.generateWallet({ + label: 'ETH TSS Wallet', + passphrase: 'test-passphrase', + multisigType: 'tss', + walletVersion: 3, + enterprise: 'test-enterprise-id', + // no sourceFlrpWalletId + }); + + generateMpcWalletStub.calledOnce.should.be.true(); + const callArgs = generateMpcWalletStub.getCall(0).args[0]; + assert.strictEqual(callArgs.sourceFlrpWalletId, undefined); + }); + + it('should pass sourceFlrpWalletId as undefined when not provided (no accidental injection)', async function () { + // Belt-and-suspenders: even if called with an explicit undefined, the field must stay absent + await wallets.generateWallet({ + label: 'ETH TSS Wallet 2', + passphrase: 'test-passphrase', + multisigType: 'tss', + walletVersion: 3, + enterprise: 'test-enterprise-id', + sourceFlrpWalletId: undefined, + }); + + const callArgs = generateMpcWalletStub.getCall(0).args[0]; + assert.strictEqual(callArgs.sourceFlrpWalletId, undefined); + }); + }); + describe('Non-EVM chains', function () { beforeEach(function () { mockBaseCoin.isEVM.returns(false);