diff --git a/modules/sdk-coin-dot/src/dot.ts b/modules/sdk-coin-dot/src/dot.ts index 5bf5b26165..3066f3fa26 100644 --- a/modules/sdk-coin-dot/src/dot.ts +++ b/modules/sdk-coin-dot/src/dot.ts @@ -7,15 +7,14 @@ import { Environments, ExplanationResult, KeyPair, - MethodNotImplementedError, MPCAlgorithm, ParsedTransaction, ParseTransactionOptions, SignedTransaction, SignTransactionOptions as BaseSignTransactionOptions, UnsignedTransaction, - VerifyAddressOptions, VerifyTransactionOptions, + TssVerifyAddressOptions, EDDSAMethods, EDDSAMethodTypes, MPCTx, @@ -29,6 +28,7 @@ import { MultisigType, multisigTypes, AuditDecryptedKeyParams, + verifyEddsaTssWalletAddress, } from '@bitgo/sdk-core'; import { BaseCoin as StaticsBaseCoin, coins, PolkadotSpecNameType } from '@bitgo/statics'; import { Interface, KeyPair as DotKeyPair, Transaction, TransactionBuilderFactory, Utils } from './lib'; @@ -642,8 +642,12 @@ export class Dot extends BaseCoin { return {}; } - async isWalletAddress(params: VerifyAddressOptions): Promise { - throw new MethodNotImplementedError(); + async isWalletAddress(params: TssVerifyAddressOptions): Promise { + return verifyEddsaTssWalletAddress( + params, + (address) => this.isValidAddress(address), + (publicKey) => this.getAddressFromPublicKey(publicKey) + ); } async verifyTransaction(params: VerifyTransactionOptions): Promise { diff --git a/modules/sdk-coin-dot/test/unit/dot.ts b/modules/sdk-coin-dot/test/unit/dot.ts index ee7f3c367b..21a618e538 100644 --- a/modules/sdk-coin-dot/test/unit/dot.ts +++ b/modules/sdk-coin-dot/test/unit/dot.ts @@ -2,6 +2,7 @@ import { BitGoAPI } from '@bitgo/sdk-api'; import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test'; import { randomBytes } from 'crypto'; import should = require('should'); +import assert = require('assert'); import { Dot, Tdot, KeyPair } from '../../src'; import * as testData from '../fixtures'; import { chainName, txVersion, genesisHash, specVersion } from '../resources'; @@ -670,4 +671,71 @@ describe('DOT:', function () { ); }); }); + + describe('isWalletAddress', () => { + it('should verify valid wallet address with correct keychain and index', async function () { + const address = '5DxD9nT16GQLrU6aB5pSS5VtxoZbVju3NHUCcawxZyZCTf74'; + const commonKeychain = + '6d2d5150f6e435dfd9b4f225f2cc29d95ec3b61b34e8bec98693b1a7ffe44cd764f99ee5058838d785c73360ad4f24d78e0255ab2c368c09060b29a9b27f040e'; + const index = '3'; + const keychains = [{ id: '1', type: 'tss' as const, commonKeychain }]; + + const result = await basecoin.isWalletAddress({ keychains, address, index }); + result.should.equal(true); + }); + + it('should return false for address with incorrect keychain', async function () { + const address = '5DxD9nT16GQLrU6aB5pSS5VtxoZbVju3NHUCcawxZyZCTf74'; + const wrongKeychain = + '0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000'; + const index = '3'; + const keychains = [{ id: '1', type: 'tss' as const, commonKeychain: wrongKeychain }]; + + const result = await basecoin.isWalletAddress({ keychains, address, index }); + result.should.equal(false); + }); + + it('should return false for address with incorrect index', async function () { + const address = '5DxD9nT16GQLrU6aB5pSS5VtxoZbVju3NHUCcawxZyZCTf74'; + const commonKeychain = + '6d2d5150f6e435dfd9b4f225f2cc29d95ec3b61b34e8bec98693b1a7ffe44cd764f99ee5058838d785c73360ad4f24d78e0255ab2c368c09060b29a9b27f040e'; + const wrongIndex = '999'; + const keychains = [{ id: '1', type: 'tss' as const, commonKeychain }]; + + const result = await basecoin.isWalletAddress({ keychains, address, index: wrongIndex }); + result.should.equal(false); + }); + + it('should throw error for invalid address', async function () { + const invalidAddress = 'invalidaddress'; + const commonKeychain = + '6d2d5150f6e435dfd9b4f225f2cc29d95ec3b61b34e8bec98693b1a7ffe44cd764f99ee5058838d785c73360ad4f24d78e0255ab2c368c09060b29a9b27f040e'; + const index = '3'; + const keychains = [{ id: '1', type: 'tss' as const, commonKeychain }]; + + await assert.rejects(async () => await basecoin.isWalletAddress({ keychains, address: invalidAddress, index }), { + message: `invalid address: ${invalidAddress}`, + }); + }); + }); + + describe('getAddressFromPublicKey', () => { + it('should convert public key to SS58 address for testnet', function () { + const publicKey = '53845d7b6a6e4a666fa2a0f500b88849b02926da5590993731d2b428b7643690'; + const expectedAddress = '5DxD9nT16GQLrU6aB5pSS5VtxoZbVju3NHUCcawxZyZCTf74'; + + const address = basecoin.getAddressFromPublicKey(publicKey); + address.should.equal(expectedAddress); + }); + + it('should convert public key to SS58 address for mainnet', function () { + const publicKey = '53845d7b6a6e4a666fa2a0f500b88849b02926da5590993731d2b428b7643690'; + // Mainnet uses different SS58 prefix (0) vs testnet (42) + const address = prodCoin.getAddressFromPublicKey(publicKey); + address.should.be.type('string'); + address.length.should.be.greaterThan(0); + // Should be different from testnet address + address.should.not.equal('5DxD9nT16GQLrU6aB5pSS5VtxoZbVju3NHUCcawxZyZCTf74'); + }); + }); }); diff --git a/modules/sdk-coin-iota/src/iota.ts b/modules/sdk-coin-iota/src/iota.ts index d1a223acfe..5a0d4f4891 100644 --- a/modules/sdk-coin-iota/src/iota.ts +++ b/modules/sdk-coin-iota/src/iota.ts @@ -11,10 +11,9 @@ import { MultisigType, multisigTypes, MPCAlgorithm, - InvalidAddressError, - EDDSAMethods, TssVerifyAddressOptions, MPCType, + verifyEddsaTssWalletAddress, } from '@bitgo/sdk-core'; import { BaseCoin as StaticsBaseCoin, CoinFamily } from '@bitgo/statics'; import utils from './lib/utils'; @@ -92,29 +91,11 @@ export class Iota extends BaseCoin { * @param params */ async isWalletAddress(params: TssVerifyAddressOptions): Promise { - const { keychains, address, index } = params; - - if (!this.isValidAddress(address)) { - throw new InvalidAddressError(`invalid address: ${address}`); - } - - if (!keychains) { - throw new Error('missing required param keychains'); - } - - for (const keychain of keychains) { - const MPC = await EDDSAMethods.getInitializedMpcInstance(); - const commonKeychain = keychain.commonKeychain as string; - - const derivationPath = 'm/' + index; - const derivedPublicKey = MPC.deriveUnhardened(commonKeychain, derivationPath).slice(0, 64); - const expectedAddress = utils.getAddressFromPublicKey(derivedPublicKey); - - if (address !== expectedAddress) { - return false; - } - } - return true; + return verifyEddsaTssWalletAddress( + params, + (address) => this.isValidAddress(address), + (publicKey) => utils.getAddressFromPublicKey(publicKey) + ); } /** diff --git a/modules/sdk-coin-sol/src/sol.ts b/modules/sdk-coin-sol/src/sol.ts index 9143ea90af..940ae2476b 100644 --- a/modules/sdk-coin-sol/src/sol.ts +++ b/modules/sdk-coin-sol/src/sol.ts @@ -23,7 +23,6 @@ import { ITokenEnablement, KeyPair, Memo, - MethodNotImplementedError, MPCAlgorithm, MPCConsolidationRecoveryOptions, MPCRecoveryOptions, @@ -50,8 +49,9 @@ import { TransactionExplanation, TransactionParams, TransactionRecipient, - VerifyAddressOptions, VerifyTransactionOptions, + TssVerifyAddressOptions, + verifyEddsaTssWalletAddress, } from '@bitgo/sdk-core'; import { auditEddsaPrivateKey, getDerivationPath } from '@bitgo/sdk-lib-mpc'; import { BaseNetwork, CoinFamily, coins, SolCoin, BaseCoin as StaticsBaseCoin } from '@bitgo/statics'; @@ -560,8 +560,22 @@ export class Sol extends BaseCoin { return true; } - async isWalletAddress(params: VerifyAddressOptions): Promise { - throw new MethodNotImplementedError(); + async isWalletAddress(params: TssVerifyAddressOptions): Promise { + return verifyEddsaTssWalletAddress( + params, + (address) => this.isValidAddress(address), + (publicKey) => this.getAddressFromPublicKey(publicKey) + ); + } + + /** + * Converts a Solana public key to an address + * @param publicKey Hex-encoded public key (64 hex characters = 32 bytes) + * @returns Base58-encoded Solana address + */ + getAddressFromPublicKey(publicKey: string): string { + const publicKeyBuffer = Buffer.from(publicKey, 'hex'); + return base58.encode(publicKeyBuffer); } /** diff --git a/modules/sdk-coin-sol/test/unit/sol.ts b/modules/sdk-coin-sol/test/unit/sol.ts index 01b6a39062..0306b4095e 100644 --- a/modules/sdk-coin-sol/test/unit/sol.ts +++ b/modules/sdk-coin-sol/test/unit/sol.ts @@ -3353,4 +3353,61 @@ describe('SOL:', function () { ); }); }); + + describe('isWalletAddress', () => { + it('should verify valid wallet address with correct keychain and index', async function () { + const address = '7YAesfwPk41VChUgr65bm8FEep7ymWqLSW5rpYB5zZPY'; + const commonKeychain = + '8ea32ecacfc83effbd2e2790ee44fa7c59b4d86c29a12f09fb613d8195f93f4e21875cad3b98adada40c040c54c3569467df41a020881a6184096378701862bd'; + const index = '1'; + const keychains = [{ id: '1', type: 'tss' as const, commonKeychain }]; + + const result = await basecoin.isWalletAddress({ keychains, address, index }); + result.should.equal(true); + }); + + it('should return false for address with incorrect keychain', async function () { + const address = '7YAesfwPk41VChUgr65bm8FEep7ymWqLSW5rpYB5zZPY'; + const wrongKeychain = + '0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000'; + const index = '1'; + const keychains = [{ id: '1', type: 'tss' as const, commonKeychain: wrongKeychain }]; + + const result = await basecoin.isWalletAddress({ keychains, address, index }); + result.should.equal(false); + }); + + it('should return false for address with incorrect index', async function () { + const address = '7YAesfwPk41VChUgr65bm8FEep7ymWqLSW5rpYB5zZPY'; + const commonKeychain = + '8ea32ecacfc83effbd2e2790ee44fa7c59b4d86c29a12f09fb613d8195f93f4e21875cad3b98adada40c040c54c3569467df41a020881a6184096378701862bd'; + const wrongIndex = '999'; + const keychains = [{ id: '1', type: 'tss' as const, commonKeychain }]; + + const result = await basecoin.isWalletAddress({ keychains, address, index: wrongIndex }); + result.should.equal(false); + }); + + it('should throw error for invalid address', async function () { + const invalidAddress = 'invalidaddress'; + const commonKeychain = + '8ea32ecacfc83effbd2e2790ee44fa7c59b4d86c29a12f09fb613d8195f93f4e21875cad3b98adada40c040c54c3569467df41a020881a6184096378701862bd'; + const index = '1'; + const keychains = [{ id: '1', type: 'tss' as const, commonKeychain }]; + + await assert.rejects(async () => await basecoin.isWalletAddress({ keychains, address: invalidAddress, index }), { + message: `invalid address: ${invalidAddress}`, + }); + }); + }); + + describe('getAddressFromPublicKey', () => { + it('should convert public key to base58 address', function () { + const publicKey = '61220a9394802b1d1df37b35f7a3197970f48081092cee011fc98f7b71b2bd43'; + const expectedAddress = '7YAesfwPk41VChUgr65bm8FEep7ymWqLSW5rpYB5zZPY'; + + const address = basecoin.getAddressFromPublicKey(publicKey); + address.should.equal(expectedAddress); + }); + }); }); diff --git a/modules/sdk-coin-sui/src/sui.ts b/modules/sdk-coin-sui/src/sui.ts index 896aa0871c..5c6fb054b2 100644 --- a/modules/sdk-coin-sui/src/sui.ts +++ b/modules/sdk-coin-sui/src/sui.ts @@ -7,7 +7,6 @@ import { EDDSAMethods, EDDSAMethodTypes, Environments, - InvalidAddressError, KeyPair, MPCAlgorithm, MPCRecoveryOptions, @@ -30,6 +29,7 @@ import { MultisigType, multisigTypes, AuditDecryptedKeyParams, + verifyEddsaTssWalletAddress, } from '@bitgo/sdk-core'; import { BaseCoin as StaticsBaseCoin, BaseNetwork, coins, SuiCoin } from '@bitgo/statics'; import BigNumber from 'bignumber.js'; @@ -188,12 +188,11 @@ export class Sui extends BaseCoin { } async isWalletAddress(params: TssVerifyAddressOptions): Promise { - const { address: newAddress } = params; - - if (!this.isValidAddress(newAddress)) { - throw new InvalidAddressError(`invalid address: ${newAddress}`); - } - return true; + return verifyEddsaTssWalletAddress( + params, + (address) => this.isValidAddress(address), + (publicKey) => this.getAddressFromPublicKey(publicKey) + ); } async parseTransaction(params: SuiParseTransactionOptions): Promise { diff --git a/modules/sdk-coin-ton/src/ton.ts b/modules/sdk-coin-ton/src/ton.ts index ffe7629120..6553e1f566 100644 --- a/modules/sdk-coin-ton/src/ton.ts +++ b/modules/sdk-coin-ton/src/ton.ts @@ -30,6 +30,7 @@ import { MPCTxs, MPCSweepRecoveryOptions, AuditDecryptedKeyParams, + extractCommonKeychain, } from '@bitgo/sdk-core'; import { auditEddsaPrivateKey, getDerivationPath } from '@bitgo/sdk-lib-mpc'; import { BaseCoin as StaticsBaseCoin, coins } from '@bitgo/statics'; @@ -159,29 +160,21 @@ export class Ton extends BaseCoin { throw new InvalidAddressError(`invalid address: ${newAddress}`); } - if (!keychains) { - throw new Error('missing required param keychains'); - } - - for (const keychain of keychains) { - const [address, memoId] = newAddress.split('?memoId='); - const MPC = await EDDSAMethods.getInitializedMpcInstance(); - const commonKeychain = keychain.commonKeychain as string; + const [address, memoId] = newAddress.split('?memoId='); - const derivationPath = 'm/' + index; - const derivedPublicKey = MPC.deriveUnhardened(commonKeychain, derivationPath).slice(0, 64); - const expectedAddress = await Utils.default.getAddressFromPublicKey(derivedPublicKey); + // TON supports memoId for address tagging - verify it matches the index + if (memoId) { + return memoId === `${index}`; + } - if (memoId) { - return memoId === `${index}`; - } + const commonKeychain = extractCommonKeychain(keychains); - if (address !== expectedAddress) { - return false; - } - } + const MPC = await EDDSAMethods.getInitializedMpcInstance(); + const derivationPath = 'm/' + index; + const derivedPublicKey = MPC.deriveUnhardened(commonKeychain, derivationPath).slice(0, 64); + const expectedAddress = await Utils.default.getAddressFromPublicKey(derivedPublicKey); - return true; + return address === expectedAddress; } async parseTransaction(params: TonParseTransactionOptions): Promise { diff --git a/modules/sdk-core/src/bitgo/baseCoin/baseCoin.ts b/modules/sdk-core/src/bitgo/baseCoin/baseCoin.ts index 45332efb46..14d615efb5 100644 --- a/modules/sdk-core/src/bitgo/baseCoin/baseCoin.ts +++ b/modules/sdk-core/src/bitgo/baseCoin/baseCoin.ts @@ -47,6 +47,7 @@ import { VerifyTransactionOptions, AuditKeyParams, AuditDecryptedKeyParams, + TssVerifyAddressOptions, } from './iBaseCoin'; import { IInscriptionBuilder } from '../inscriptionBuilder'; import { @@ -346,7 +347,7 @@ export abstract class BaseCoin implements IBaseCoin { * @param params * @return true iff address is a wallet address. Must return false if address is outside wallet. */ - abstract isWalletAddress(params: VerifyAddressOptions): Promise; + abstract isWalletAddress(params: VerifyAddressOptions | TssVerifyAddressOptions): Promise; /** * convert address into desired address format. diff --git a/modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts b/modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts index e22677c966..c4ad7f62f1 100644 --- a/modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts +++ b/modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts @@ -149,15 +149,30 @@ export interface VerifyAddressOptions { addressType?: string; keychains?: { pub: string; - commonKeychain?: string; }[]; error?: string; coinSpecific?: AddressCoinSpecific; impliedForwarderVersion?: number; } -export interface TssVerifyAddressOptions extends VerifyAddressOptions { - chain: string; +/** + * Options for verifying if an address belongs to a TSS/MPC wallet. + * Used for EdDSA-based MPC coins (SOL, DOT, SUI, TON, IOTA, NEAR, etc.) + * to cryptographically verify address derivation without trusting the platform. + */ +export interface TssVerifyAddressOptions { + /** The address to verify */ + address: string; + /** + * Keychains containing the commonKeychain for HD derivation. + * For MPC wallets, the commonKeychain (combined public key from MPC key generation) + * should be identical across all keychains (user, backup, bitgo). + */ + keychains: Keychain[]; + /** + * Derivation index for the address. + * Used to derive child addresses from the root keychain via HD derivation path: m/{index} + */ index: string; } @@ -552,7 +567,7 @@ export interface IBaseCoin { explainTransaction(options: Record): Promise | undefined>; verifyTransaction(params: VerifyTransactionOptions): Promise; verifyAddress(params: VerifyAddressOptions): Promise; - isWalletAddress(params: VerifyAddressOptions, wallet?: IWallet): Promise; + isWalletAddress(params: VerifyAddressOptions | TssVerifyAddressOptions, wallet?: IWallet): Promise; canonicalAddress(address: string, format: unknown): string; supportsBlockTarget(): boolean; supportsLightning(): boolean; diff --git a/modules/sdk-core/src/bitgo/utils/tss/addressVerification.ts b/modules/sdk-core/src/bitgo/utils/tss/addressVerification.ts new file mode 100644 index 0000000000..cc42b95f14 --- /dev/null +++ b/modules/sdk-core/src/bitgo/utils/tss/addressVerification.ts @@ -0,0 +1,60 @@ +import { TssVerifyAddressOptions } from '../../baseCoin/iBaseCoin'; +import { InvalidAddressError } from '../../errors'; +import { EDDSAMethods } from '../../tss'; + +/** + * Extracts and validates the commonKeychain from keychains array. + * For MPC wallets, all keychains should have the same commonKeychain. + * + * @param keychains - Array of keychains containing commonKeychain + * @returns The validated commonKeychain + * @throws {Error} if keychains are missing, empty, or have mismatched commonKeychains + */ +export function extractCommonKeychain(keychains: TssVerifyAddressOptions['keychains']): string { + if (!keychains?.length) { + throw new Error('missing required param keychains'); + } + + const commonKeychain = keychains[0].commonKeychain; + if (!commonKeychain) { + throw new Error('missing required param commonKeychain'); + } + + // Verify all keychains have the same commonKeychain + if (keychains.find((kc) => kc.commonKeychain !== commonKeychain)) + throw new Error('all keychains must have the same commonKeychain for MPC coins'); + + return commonKeychain; +} + +/** + * Verifies if an address belongs to a wallet using EdDSA TSS MPC derivation. + * This is a common implementation for EdDSA-based MPC coins (SOL, DOT, SUI, TON, IOTA, etc.) + * + * @param params - Verification options including keychains, address, and derivation index + * @param isValidAddress - Coin-specific function to validate address format + * @param getAddressFromPublicKey - Coin-specific function to convert public key to address + * @returns true if the address matches the derived address, false otherwise + * @throws {InvalidAddressError} if the address is invalid + * @throws {Error} if required parameters are missing or invalid + */ +export async function verifyEddsaTssWalletAddress( + params: TssVerifyAddressOptions, + isValidAddress: (address: string) => boolean, + getAddressFromPublicKey: (publicKey: string) => string +): Promise { + const { keychains, address, index } = params; + + if (!isValidAddress(address)) { + throw new InvalidAddressError(`invalid address: ${address}`); + } + + const commonKeychain = extractCommonKeychain(keychains); + + const MPC = await EDDSAMethods.getInitializedMpcInstance(); + const derivationPath = 'm/' + index; + const derivedPublicKey = MPC.deriveUnhardened(commonKeychain, derivationPath).slice(0, 64); + const expectedAddress = getAddressFromPublicKey(derivedPublicKey); + + return address === expectedAddress; +} diff --git a/modules/sdk-core/src/bitgo/utils/tss/index.ts b/modules/sdk-core/src/bitgo/utils/tss/index.ts index 04b749a5af..2276906b6c 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/index.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/index.ts @@ -14,3 +14,4 @@ export { ITssUtils, IEddsaUtils, TxRequest, EddsaUnsignedTransaction } from './e export * as BaseTssUtils from './baseTSSUtils'; export * from './baseTypes'; +export * from './addressVerification';