Skip to content
Draft
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
1 change: 1 addition & 0 deletions commitlint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ module.exports = {
'RA-',
'SO-',
'SC-',
'SI-',
'ST-',
'STLX-',
'TMS-',
Expand Down
13 changes: 11 additions & 2 deletions modules/abstract-eth/src/abstractEthLikeNewCoins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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);

Expand Down
8 changes: 8 additions & 0 deletions modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T extends VerifyAddressOptions | TssVerifyAddressOptions>(
Expand Down Expand Up @@ -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';
}

Expand Down
10 changes: 8 additions & 2 deletions modules/sdk-core/src/bitgo/utils/tss/addressVerification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ export async function verifyMPCWalletAddress(
isValidAddress: (address: string) => boolean,
getAddressFromPublicKey: (publicKey: string) => string
): Promise<boolean> {
const { keychains, address, index, derivedFromParentWithSeed } = params;
const { keychains, address, index, derivedFromParentWithSeed, baseAddressDerivationPath } = params;

if (!isValidAddress(address)) {
throw new InvalidAddressError(`invalid address: ${address}`);
Expand All @@ -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
Expand Down
12 changes: 7 additions & 5 deletions modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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
);
Expand Down
6 changes: 6 additions & 0 deletions modules/sdk-core/src/bitgo/wallet/iWallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 4 additions & 0 deletions modules/sdk-core/src/bitgo/wallet/iWallets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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(
Expand Down
3 changes: 3 additions & 0 deletions modules/sdk-core/src/bitgo/wallet/wallets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -1485,6 +1486,7 @@ export class Wallets implements IWallets {
enterprise,
walletVersion,
originalPasscodeEncryptionCode,
sourceFlrpWalletId,
}: GenerateMpcWalletOptions): Promise<WalletWithKeychains> {
if (multisigType === 'tss' && this.baseCoin.getMPCAlgorithm() === 'ecdsa') {
const tssSettings: TssSettings = await this.bitgo
Expand Down Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
});
});
});
Original file line number Diff line number Diff line change
@@ -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');
});
});
});
Loading
Loading