From 46b529a0f81a795a6d1e6936c785e1239768e353 Mon Sep 17 00:00:00 2001 From: Hrishikesh Jain Date: Wed, 18 Mar 2026 11:02:23 +0530 Subject: [PATCH] fix: add getAddressDetails method for canton TICKET: CHALO-77 --- modules/sdk-coin-canton/src/canton.ts | 64 ++++++++++++++++++++-- modules/sdk-coin-canton/test/unit/index.ts | 32 ++++++++++- 2 files changed, 91 insertions(+), 5 deletions(-) diff --git a/modules/sdk-coin-canton/src/canton.ts b/modules/sdk-coin-canton/src/canton.ts index 0a1d184d72..64d3d6391a 100644 --- a/modules/sdk-coin-canton/src/canton.ts +++ b/modules/sdk-coin-canton/src/canton.ts @@ -24,6 +24,7 @@ import { } from '@bitgo/sdk-core'; import { auditEddsaPrivateKey } from '@bitgo/sdk-lib-mpc'; import { BaseCoin as StaticsBaseCoin, coins } from '@bitgo/statics'; +import * as querystring from 'querystring'; import { TransactionBuilderFactory } from './lib'; import { KeyPair as CantonKeyPair } from './lib/keyPair'; import utils from './lib/utils'; @@ -36,6 +37,11 @@ export interface ExplainTransactionOptions { txHex: string; } +interface AddressDetails { + address: string; + memoId?: string; +} + export class Canton extends BaseCoin { protected readonly _staticsCoin: Readonly; @@ -119,10 +125,10 @@ export class Canton extends BaseCoin { case TransactionType.Send: if (txParams.recipients !== undefined) { const filteredRecipients = txParams.recipients?.map((recipient) => { - const { address, amount, tokenName } = recipient; - const [addressPart, memoId] = address.split('?memoId='); + const { amount, tokenName } = recipient; + const { address, memoId } = this.getAddressDetails(recipient.address); return { - address: addressPart, + address, amount, ...(memoId && { memo: memoId }), ...(tokenName && { tokenName }), @@ -153,7 +159,7 @@ export class Canton extends BaseCoin { // TODO: refactor this and use the `verifyEddsaMemoBasedWalletAddress` once published from sdk-core // https://bitgoinc.atlassian.net/browse/COIN-6347 const { keychains, address: newAddress, index } = params; - const [addressPart, memoId] = newAddress.split('?memoId='); + const { address: addressPart, memoId } = this.getAddressDetails(newAddress); if (!this.isValidAddress(addressPart)) { throw new InvalidAddressError(`invalid address: ${newAddress}`); } @@ -214,6 +220,56 @@ export class Canton extends BaseCoin { return utils.isValidAddress(address); } + /** + * Process address into address and optional memo id + * + * @param address the address + * @returns object containing base address and optional memo id + */ + getAddressDetails(address: string): AddressDetails { + const queryIndex = address.indexOf('?'); + const destinationAddress = queryIndex >= 0 ? address.slice(0, queryIndex) : address; + const query = queryIndex >= 0 ? address.slice(queryIndex + 1) : undefined; + + // Address without memoId query parameter. + if (query === undefined) { + return { + address, + memoId: undefined, + }; + } + + if (!query || destinationAddress.length === 0) { + throw new InvalidAddressError(`invalid address: ${address}`); + } + + const queryDetails = querystring.parse(query); + if (!queryDetails.memoId) { + throw new InvalidAddressError(`invalid address: ${address}`); + } + + if (Array.isArray(queryDetails.memoId)) { + throw new InvalidAddressError( + `memoId may only be given at most once, but found ${queryDetails.memoId.length} instances in address ${address}` + ); + } + + const queryKeys = Object.keys(queryDetails); + if (queryKeys.length !== 1) { + throw new InvalidAddressError(`invalid address: ${address}`); + } + + const [memoId] = [queryDetails.memoId].filter((value): value is string => typeof value === 'string'); + if (!memoId || memoId.trim().length === 0) { + throw new InvalidAddressError(`invalid address: ${address}`); + } + + return { + address: destinationAddress, + memoId, + }; + } + /** @inheritDoc */ getTokenEnablementConfig(): TokenEnablementConfig { return { diff --git a/modules/sdk-coin-canton/test/unit/index.ts b/modules/sdk-coin-canton/test/unit/index.ts index b401d09823..a70f7feef8 100644 --- a/modules/sdk-coin-canton/test/unit/index.ts +++ b/modules/sdk-coin-canton/test/unit/index.ts @@ -1,15 +1,45 @@ -import 'should'; +import should from 'should'; import { BitGoAPI } from '@bitgo/sdk-api'; +import { InvalidAddressError } from '@bitgo/sdk-core'; import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test'; import { Canton, Tcanton } from '../../src'; +import { CANTON_ADDRESSES } from '../resources'; describe('Canton:', function () { let bitgo: TestBitGoAPI; + let basecoin: Canton; before(function () { bitgo = TestBitGo.decorate(BitGoAPI, { env: 'mock' }); bitgo.safeRegister('canton', Canton.createInstance); bitgo.safeRegister('tcanton', Tcanton.createInstance); bitgo.initializeTestVars(); + basecoin = bitgo.coin('canton') as Canton; + }); + + describe('getAddressDetails', function () { + it('should get address details without memoId', function () { + const addressDetails = basecoin.getAddressDetails(CANTON_ADDRESSES.VALID_ADDRESS); + addressDetails.address.should.equal(CANTON_ADDRESSES.VALID_ADDRESS); + should.not.exist(addressDetails.memoId); + }); + + it('should get address details with memoId', function () { + const addressWithMemoId = CANTON_ADDRESSES.VALID_MEMO_ID; + const addressDetails = basecoin.getAddressDetails(addressWithMemoId); + addressDetails.address.should.equal(CANTON_ADDRESSES.VALID_ADDRESS); + should.exist(addressDetails.memoId); + addressDetails.memoId!.should.equal('1'); + }); + + it('should throw on multiple memoId query params', function () { + (() => basecoin.getAddressDetails(`${CANTON_ADDRESSES.VALID_ADDRESS}?memoId=1&memoId=2`)).should.throw( + InvalidAddressError + ); + }); + + it('should throw on unknown query params', function () { + (() => basecoin.getAddressDetails(`${CANTON_ADDRESSES.VALID_ADDRESS}?foo=bar`)).should.throw(InvalidAddressError); + }); }); });