diff --git a/modules/sdk-coin-icp/src/icp.ts b/modules/sdk-coin-icp/src/icp.ts index cb22caab29..bbf403d0cf 100644 --- a/modules/sdk-coin-icp/src/icp.ts +++ b/modules/sdk-coin-icp/src/icp.ts @@ -8,6 +8,7 @@ import { Ecdsa, ECDSAUtils, Environments, + InvalidAddressError, KeyPair, MPCAlgorithm, MultisigType, @@ -17,8 +18,9 @@ import { SignedTransaction, SigningError, SignTransactionOptions, - TssVerifyAddressOptions, VerifyTransactionOptions, + verifyMPCWalletAddress, + UnexpectedAddressError, } from '@bitgo/sdk-core'; import { coins, NetworkType, BaseCoin as StaticsBaseCoin } from '@bitgo/statics'; import { Principal } from '@dfinity/principal'; @@ -41,6 +43,7 @@ import { SigningPayload, IcpTransactionExplanation, TransactionHexParams, + TssVerifyIcpAddressOptions, UnsignedSweepRecoveryTransaction, } from './lib/iface'; import { TransactionBuilderFactory } from './lib/transactionBuilderFactory'; @@ -141,8 +144,52 @@ export class Icp extends BaseCoin { return true; } - async isWalletAddress(params: TssVerifyAddressOptions): Promise { - return this.isValidAddress(params.address); + /** + * Verify that an address belongs to this wallet. + * + * @param {TssVerifyIcpAddressOptions} params - Verification parameters + * @returns {Promise} True if address belongs to wallet + * @throws {InvalidAddressError} If address format is invalid or doesn't match derived address + * @throws {Error} If invalid wallet version or missing parameters + */ + async isWalletAddress(params: TssVerifyIcpAddressOptions): Promise { + const { address, rootAddress, walletVersion } = params; + + if (!this.isValidAddress(address)) { + throw new InvalidAddressError(`invalid address: ${address}`); + } + + let addressToVerify = address; + if (walletVersion === 1) { + if (!rootAddress) { + throw new Error('rootAddress is required for wallet version 1'); + } + const extractedRootAddress = utils.validateMemoAndReturnRootAddress(address); + if (!extractedRootAddress || extractedRootAddress === address) { + throw new Error('memoId is required for wallet version 1 addresses'); + } + if (extractedRootAddress.toLowerCase() !== rootAddress.toLowerCase()) { + throw new UnexpectedAddressError( + `address validation failure: expected ${rootAddress} but got ${extractedRootAddress}` + ); + } + addressToVerify = rootAddress; + } + + const indexToVerify = walletVersion === 1 ? 0 : params.index; + const result = await verifyMPCWalletAddress( + { ...params, address: addressToVerify, index: indexToVerify, keyCurve: 'secp256k1' }, + this.isValidAddress.bind(this), + (pubKey) => utils.getAddressFromPublicKey(pubKey) + ); + + if (!result) { + throw new UnexpectedAddressError( + `address validation failure: address ${addressToVerify} is not a wallet address` + ); + } + + return true; } async parseTransaction(params: ParseTransactionOptions): Promise { @@ -210,7 +257,7 @@ export class Icp extends BaseCoin { return createHash('sha256'); } - private async getAddressFromPublicKey(hexEncodedPublicKey: string) { + private getAddressFromPublicKey(hexEncodedPublicKey: string): string { return utils.getAddressFromPublicKey(hexEncodedPublicKey); } @@ -388,7 +435,7 @@ export class Icp extends BaseCoin { throw new Error('failed to derive public key'); } - const senderAddress = await this.getAddressFromPublicKey(publicKey); + const senderAddress = this.getAddressFromPublicKey(publicKey); const balance = await this.getAccountBalance(publicKey); const feeData = await this.getFeeData(); const actualBalance = balance.minus(feeData); diff --git a/modules/sdk-coin-icp/src/lib/iface.ts b/modules/sdk-coin-icp/src/lib/iface.ts index fc38858523..f1a83621d6 100644 --- a/modules/sdk-coin-icp/src/lib/iface.ts +++ b/modules/sdk-coin-icp/src/lib/iface.ts @@ -1,6 +1,7 @@ import { TransactionExplanation as BaseTransactionExplanation, TransactionType as BitGoTransactionType, + TssVerifyAddressOptions, } from '@bitgo/sdk-core'; export const MAX_INGRESS_TTL = 5 * 60 * 1000_000_000; // 5 minutes in nanoseconds @@ -216,3 +217,8 @@ export interface TransactionHexParams { transactionHex: string; signableHex?: string; } + +export interface TssVerifyIcpAddressOptions extends TssVerifyAddressOptions { + rootAddress?: string; + walletVersion?: number; +} diff --git a/modules/sdk-coin-icp/src/lib/utils.ts b/modules/sdk-coin-icp/src/lib/utils.ts index 43a61d71de..3cb81f0dc8 100644 --- a/modules/sdk-coin-icp/src/lib/utils.ts +++ b/modules/sdk-coin-icp/src/lib/utils.ts @@ -73,8 +73,14 @@ export class Utils implements BaseUtils { return undefined; } const [rootAddress, memoId] = address.split('?memoId='); - if (memoId && this.validateMemo(BigInt(memoId))) { - return rootAddress; + if (memoId) { + try { + if (this.validateMemo(BigInt(memoId))) { + return rootAddress; + } + } catch { + return undefined; + } } return address; } @@ -210,8 +216,14 @@ export class Utils implements BaseUtils { const publicKeyBuffer = Buffer.from(publicKeyHex, 'hex'); const ellipticKey = secp256k1.ProjectivePoint.fromHex(publicKeyBuffer.toString('hex')); const uncompressedPublicKeyHex = ellipticKey.toHex(false); - const derEncodedKey = agent.wrapDER(Buffer.from(uncompressedPublicKeyHex, 'hex'), agent.SECP256K1_OID); - return derEncodedKey; + const uncompressedKeyBuffer = Buffer.from(uncompressedPublicKeyHex, 'hex'); + return agent.wrapDER( + uncompressedKeyBuffer.buffer.slice( + uncompressedKeyBuffer.byteOffset, + uncompressedKeyBuffer.byteOffset + uncompressedKeyBuffer.byteLength + ), + agent.SECP256K1_OID + ); } /** @@ -273,10 +285,10 @@ export class Utils implements BaseUtils { * Retrieves the address associated with a given hex-encoded public key. * * @param {string} hexEncodedPublicKey - The public key in hex-encoded format. - * @returns {Promise} A promise that resolves to the address derived from the provided public key. + * @returns {string} The address derived from the provided public key. * @throws {Error} Throws an error if the provided public key is not in a valid hex-encoded format. */ - async getAddressFromPublicKey(hexEncodedPublicKey: string): Promise { + getAddressFromPublicKey(hexEncodedPublicKey: string): string { if (!this.isValidPublicKey(hexEncodedPublicKey)) { throw new Error('Invalid hex-encoded public key format.'); } diff --git a/modules/sdk-coin-icp/test/unit/icp.ts b/modules/sdk-coin-icp/test/unit/icp.ts index 7dc8ca8a86..614c1f9985 100644 --- a/modules/sdk-coin-icp/test/unit/icp.ts +++ b/modules/sdk-coin-icp/test/unit/icp.ts @@ -87,21 +87,17 @@ describe('Internet computer', function () { accountID.should.deepEqual(validAccountID2); }); - it('should throw an error when invalid public key is provided', async function () { - await basecoin - .getAddressFromPublicKey(invalidPublicKey) - .should.be.rejectedWith(`Invalid hex-encoded public key format.`); + it('should throw an error when invalid public key is provided', function () { + (() => basecoin.getAddressFromPublicKey(invalidPublicKey)).should.throw('Invalid hex-encoded public key format.'); }); - it('should return valid address from a valid hex encoded public key', async function () { - const accountID = await utils.getAddressFromPublicKey(hexEncodedPublicKey); + it('should return valid address from a valid hex encoded public key', function () { + const accountID = utils.getAddressFromPublicKey(hexEncodedPublicKey); accountID.should.deepEqual(validAccountID); }); - it('should throw an error when invalid public key is provided', async function () { - await utils - .getAddressFromPublicKey(invalidPublicKey) - .should.be.rejectedWith(`Invalid hex-encoded public key format.`); + it('should throw an error when invalid public key is provided', function () { + (() => utils.getAddressFromPublicKey(invalidPublicKey)).should.throw('Invalid hex-encoded public key format.'); }); }); @@ -233,4 +229,219 @@ describe('Internet computer', function () { .should.rejectedWith('generated signableHex is not equal to params.signableHex'); }); }); + + describe('Address Verification', () => { + const addressVerificationData = { + commonKeychain: + '036b38ca5e63e9800b5040af498eb6e9a9c77e244ac2858edafa4bd0926a635731c3fabde9007a5771e93621d9fcb1c879660208dc79cc609fe8ddd189f7a955ab', + rootAddress: 'fd3eaed3e2064bd30ab497e22e8ac5a0dcadd81fa5353879dbab64e259ec70c0', + }; + + describe('Wallet VersionKey 1', () => { + let keychains; + + before(function () { + keychains = [ + { commonKeychain: addressVerificationData.commonKeychain }, + { commonKeychain: addressVerificationData.commonKeychain }, + { commonKeychain: addressVerificationData.commonKeychain }, + ]; + }); + + it('should verify a valid memo-based address', async function () { + const rootAddress = addressVerificationData.rootAddress; + const addressWithMemo = `${rootAddress}?memoId=123`; + + const params = { + address: addressWithMemo, + rootAddress: rootAddress, + walletVersion: 1, + keychains: keychains, + index: 0, + }; + + const result = await basecoin.isWalletAddress(params); + result.should.equal(true); + }); + + it('should verify address with memoId=0', async function () { + const rootAddress = addressVerificationData.rootAddress; + const addressWithMemo = `${rootAddress}?memoId=0`; + + const params = { + address: addressWithMemo, + rootAddress: rootAddress, + walletVersion: 1, + keychains: keychains, + index: 0, + }; + + const result = await basecoin.isWalletAddress(params); + result.should.equal(true); + }); + + it('should fail when extracted root does not match provided rootAddress param', async function () { + const rootAddress = addressVerificationData.rootAddress; + const differentAddress = testData.Accounts.account2.address; + const addressWithMemo = `${differentAddress}?memoId=123`; + + const params = { + address: addressWithMemo, + rootAddress: rootAddress, + walletVersion: 1, + keychains: keychains, + index: 0, + }; + + // The extracted root (differentAddress) doesn't match provided rootAddress + await basecoin + .isWalletAddress(params) + .should.be.rejectedWith(`address validation failure: expected ${rootAddress} but got ${differentAddress}`); + }); + + it('should throw error when rootAddress is missing for wallet version 1', async function () { + const address = `${addressVerificationData.rootAddress}?memoId=123`; + + const params = { + address: address, + walletVersion: 1, + keychains: keychains, + index: 0, + }; + + await basecoin.isWalletAddress(params).should.be.rejectedWith('rootAddress is required for wallet version 1'); + }); + + it('should throw error when memoId is missing for wallet version 1', async function () { + const rootAddress = addressVerificationData.rootAddress; + + const params = { + address: rootAddress, + rootAddress: rootAddress, + walletVersion: 1, + keychains: keychains, + index: 0, + }; + + await basecoin + .isWalletAddress(params) + .should.be.rejectedWith('memoId is required for wallet version 1 addresses'); + }); + + it('should handle large memoId values', async function () { + const rootAddress = addressVerificationData.rootAddress; + const largeMemoId = '9007199254740991'; + const addressWithMemo = `${rootAddress}?memoId=${largeMemoId}`; + + const params = { + address: addressWithMemo, + rootAddress: rootAddress, + walletVersion: 1, + keychains: keychains, + index: 0, + }; + + const result = await basecoin.isWalletAddress(params); + result.should.equal(true); + }); + + it('should fail when rootAddress does not match commonKeychain derivation', async function () { + // Use a rootAddress that doesn't match what's derived from commonKeychain + const invalidRootAddress = testData.Accounts.account1.address; + const addressWithMemo = `${invalidRootAddress}?memoId=123`; + + const params = { + address: addressWithMemo, + rootAddress: invalidRootAddress, + walletVersion: 1, + keychains: keychains, + index: 0, + }; + + // rootAddress is cryptographically verified against commonKeychain + await basecoin + .isWalletAddress(params) + .should.be.rejectedWith(`address validation failure: address ${invalidRootAddress} is not a wallet address`); + }); + }); + + describe('Wallet VersionKey 2+', () => { + let keychains; + + before(function () { + keychains = [ + { commonKeychain: addressVerificationData.commonKeychain }, + { commonKeychain: addressVerificationData.commonKeychain }, + { commonKeychain: addressVerificationData.commonKeychain }, + ]; + }); + + it('should verify a valid TSS root address (index 0)', async function () { + const params = { + address: addressVerificationData.rootAddress, + rootAddress: addressVerificationData.rootAddress, + keychains: keychains, + index: 0, + walletVersion: 2, + }; + + const result = await basecoin.isWalletAddress(params); + result.should.equal(true); + }); + + it('should throw error for invalid TSS address', async function () { + const invalidAddress = testData.Accounts.account2.address; + + const params = { + address: invalidAddress, + keychains: keychains, + index: 0, + walletVersion: 2, + }; + + await basecoin + .isWalletAddress(params) + .should.be.rejectedWith(`address validation failure: address ${invalidAddress} is not a wallet address`); + }); + + it('should throw error when keychains is missing', async function () { + const params = { + address: addressVerificationData.rootAddress, + keychains: [], + index: 0, + walletVersion: 2, + }; + + await basecoin.isWalletAddress(params).should.be.rejectedWith('missing required param keychains'); + }); + + it('should handle string index', async function () { + const params = { + address: addressVerificationData.rootAddress, + rootAddress: addressVerificationData.rootAddress, + keychains: keychains, + index: '0', + walletVersion: 2, + }; + + const result = await basecoin.isWalletAddress(params); + result.should.equal(true); + }); + }); + + describe('Address validation', () => { + it('should throw error for invalid address format', async function () { + const invalidAddress = 'invalid-address'; + + const params = { + address: invalidAddress, + walletVersion: 2, + keychains: [{ commonKeychain: addressVerificationData.commonKeychain }], + index: 0, + }; + + await basecoin.isWalletAddress(params).should.be.rejectedWith(`invalid address: ${invalidAddress}`); + }); + }); + }); }); diff --git a/modules/sdk-coin-icp/test/unit/utils.ts b/modules/sdk-coin-icp/test/unit/utils.ts index 363b74ecca..4b62ce2950 100644 --- a/modules/sdk-coin-icp/test/unit/utils.ts +++ b/modules/sdk-coin-icp/test/unit/utils.ts @@ -132,23 +132,23 @@ describe('utils', () => { }); describe('getAddressFromPublicKey()', () => { - it('should return the correct address for a valid public key', async () => { - const address1 = await utils.getAddressFromPublicKey(Accounts.account1.publicKey); + it('should return the correct address for a valid public key', () => { + const address1 = utils.getAddressFromPublicKey(Accounts.account1.publicKey); should.equal(address1, Accounts.account1.address); - const address2 = await utils.getAddressFromPublicKey(Accounts.account1.publicKey); + const address2 = utils.getAddressFromPublicKey(Accounts.account1.publicKey); should.equal(address2, Accounts.account1.address); - const address3 = await utils.getAddressFromPublicKey(Accounts.account1.publicKey); + const address3 = utils.getAddressFromPublicKey(Accounts.account1.publicKey); should.equal(address3, Accounts.account1.address); - const address4 = await utils.getAddressFromPublicKey(Accounts.account1.publicKey); + const address4 = utils.getAddressFromPublicKey(Accounts.account1.publicKey); should.equal(address4, Accounts.account1.address); - const address5 = await utils.getAddressFromPublicKey(Accounts.account1.publicKey); + const address5 = utils.getAddressFromPublicKey(Accounts.account1.publicKey); should.equal(address5, Accounts.account1.address); - const address6 = await utils.getAddressFromPublicKey(Accounts.account1.publicKey); + const address6 = utils.getAddressFromPublicKey(Accounts.account1.publicKey); should.equal(address6, Accounts.account1.address); }); - it('should throw an error for an invalid public key', async () => { - await should(utils.getAddressFromPublicKey(Accounts.errorsAccounts.account1.publicKey)).be.rejectedWith( + it('should throw an error for an invalid public key', () => { + (() => utils.getAddressFromPublicKey(Accounts.errorsAccounts.account1.publicKey)).should.throw( 'Invalid hex-encoded public key format.' ); });