From cf86dcc08b2c0a52d785645ed472a21614b6b19e Mon Sep 17 00:00:00 2001 From: Nayan Das Date: Wed, 4 Feb 2026 01:26:16 +0530 Subject: [PATCH] feat(sdk-coin-tempo): add memoId support for address validation Ticket: WIN-8847 --- modules/sdk-coin-tempo/src/tempo.ts | 101 +++++++++++++++++++- modules/sdk-coin-tempo/test/unit/index.ts | 111 +++++++++++++++++++++- 2 files changed, 210 insertions(+), 2 deletions(-) diff --git a/modules/sdk-coin-tempo/src/tempo.ts b/modules/sdk-coin-tempo/src/tempo.ts index 757995a69a..3ecff10cff 100644 --- a/modules/sdk-coin-tempo/src/tempo.ts +++ b/modules/sdk-coin-tempo/src/tempo.ts @@ -7,11 +7,14 @@ import { OfflineVaultTxInfo, UnsignedSweepTxMPCv2, TransactionBuilder, + optionalDeps, } from '@bitgo/abstract-eth'; import type * as EthLikeCommon from '@ethereumjs/common'; -import { BaseCoin, BitGoBase, MPCAlgorithm } from '@bitgo/sdk-core'; +import { BaseCoin, BitGoBase, InvalidAddressError, InvalidMemoIdError, MPCAlgorithm } from '@bitgo/sdk-core'; import { BaseCoin as StaticsBaseCoin, coins } from '@bitgo/statics'; import { Tip20TransactionBuilder } from './lib'; +import * as url from 'url'; +import * as querystring from 'querystring'; export class Tempo extends AbstractEthLikeNewCoins { protected constructor(bitgo: BitGoBase, staticsCoin?: Readonly) { @@ -75,6 +78,102 @@ export class Tempo extends AbstractEthLikeNewCoins { return true; } + /** + * Evaluates whether an address string is valid for Tempo + * Supports addresses with optional memoId query parameter (e.g., 0x...?memoId=123) + * @param address - The address to validate + * @returns true if address is valid + */ + isValidAddress(address: string): boolean { + if (typeof address !== 'string') { + return false; + } + + try { + const { baseAddress } = this.getAddressDetails(address); + return optionalDeps.ethUtil.isValidAddress(optionalDeps.ethUtil.addHexPrefix(baseAddress)); + } catch (e) { + return false; + } + } + + /** + * Parse address into base address and optional memoId + * Throws InvalidAddressError for invalid address formats + * @param address - Address string, potentially with ?memoId=X suffix + * @returns Object containing address, baseAddress, and memoId (null if not present) + * @throws InvalidAddressError if address format is invalid + */ + getAddressDetails(address: string): { address: string; baseAddress: string; memoId: string | null } { + if (typeof address !== 'string') { + throw new InvalidAddressError(`invalid address: ${address}`); + } + + const destinationDetails = url.parse(address); + const baseAddress = destinationDetails.pathname || ''; + + // No query string - just a plain address + if (destinationDetails.pathname === address) { + return { + address, + baseAddress: address, + memoId: null, + }; + } + + // Has query string - must contain memoId + if (!destinationDetails.query) { + throw new InvalidAddressError(`invalid address: ${address}`); + } + + const queryDetails = querystring.parse(destinationDetails.query); + + // Query string must contain memoId + if (!queryDetails.memoId) { + throw new InvalidAddressError(`invalid address: ${address}, unknown query parameters`); + } + + // Only one memoId allowed + 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}` + ); + } + + // Reject if there are other query parameters besides memoId + const queryKeys = Object.keys(queryDetails); + if (queryKeys.length !== 1) { + throw new InvalidAddressError(`invalid address: ${address}, only memoId query parameter is allowed`); + } + + // Validate memoId format + if (!this.isValidMemoId(queryDetails.memoId)) { + throw new InvalidMemoIdError(`invalid address: '${address}', memoId is not valid`); + } + + return { + address, + baseAddress, + memoId: queryDetails.memoId, + }; + } + + /** + * Validate that a memoId is a valid non-negative integer string + * @param memoId - The memoId to validate + * @returns true if valid + */ + isValidMemoId(memoId: string): boolean { + if (typeof memoId !== 'string' || memoId === '') { + return false; + } + // Must be a non-negative integer (no decimals, no negative, no leading zeros except for "0") + if (!/^(0|[1-9]\d*)$/.test(memoId)) { + return false; + } + return true; + } + /** * Check if typed data signing is supported (EIP-712) */ diff --git a/modules/sdk-coin-tempo/test/unit/index.ts b/modules/sdk-coin-tempo/test/unit/index.ts index b0ffb2c381..a60a2b67cf 100644 --- a/modules/sdk-coin-tempo/test/unit/index.ts +++ b/modules/sdk-coin-tempo/test/unit/index.ts @@ -2,8 +2,9 @@ import { Tempo } from '../../src/tempo'; import { Ttempo } from '../../src/ttempo'; import { BitGoAPI } from '@bitgo/sdk-api'; import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test'; -import { BitGoBase } from '@bitgo/sdk-core'; +import { BitGoBase, InvalidAddressError, InvalidMemoIdError } from '@bitgo/sdk-core'; import { BaseCoin as StaticsBaseCoin } from '@bitgo/statics'; +import * as should from 'should'; describe('Tempo Coin', function () { let bitgo: TestBitGoAPI; @@ -42,6 +43,110 @@ describe('Tempo Coin', function () { basecoin.getBaseFactor().should.equal(1e18); }); + describe('Address Validation', function () { + it('should validate plain EVM address', function () { + basecoin.isValidAddress('0x2476602c78e9a5e0563320c78878faa3952b256f').should.equal(true); + basecoin.isValidAddress('0x0000000000000000000000000000000000000000').should.equal(true); + }); + + it('should validate address with memoId', function () { + basecoin.isValidAddress('0x2476602c78e9a5e0563320c78878faa3952b256f?memoId=8').should.equal(true); + basecoin.isValidAddress('0x2476602c78e9a5e0563320c78878faa3952b256f?memoId=0').should.equal(true); + basecoin.isValidAddress('0x2476602c78e9a5e0563320c78878faa3952b256f?memoId=12345').should.equal(true); + }); + + it('should reject invalid addresses', function () { + basecoin.isValidAddress('invalid').should.equal(false); + basecoin.isValidAddress('').should.equal(false); + basecoin.isValidAddress('0x123').should.equal(false); // Too short + }); + + it('should reject address with invalid memoId', function () { + basecoin.isValidAddress('0x2476602c78e9a5e0563320c78878faa3952b256f?memoId=abc').should.equal(false); + basecoin.isValidAddress('0x2476602c78e9a5e0563320c78878faa3952b256f?memoId=-1').should.equal(false); + basecoin.isValidAddress('0x2476602c78e9a5e0563320c78878faa3952b256f?memoId=1.5').should.equal(false); + basecoin.isValidAddress('0x2476602c78e9a5e0563320c78878faa3952b256f?memoId=01').should.equal(false); // Leading zero + basecoin.isValidAddress('0x2476602c78e9a5e0563320c78878faa3952b256f?memoId=').should.equal(false); // Empty memoId + }); + + it('should reject address with unknown query parameters', function () { + basecoin.isValidAddress('0x2476602c78e9a5e0563320c78878faa3952b256f?invalid=123').should.equal(false); + basecoin.isValidAddress('0x2476602c78e9a5e0563320c78878faa3952b256f?foo=bar').should.equal(false); + }); + + it('should reject address with multiple memoId parameters', function () { + basecoin.isValidAddress('0x2476602c78e9a5e0563320c78878faa3952b256f?memoId=1&memoId=2').should.equal(false); + }); + + it('should reject address with extra query parameters besides memoId', function () { + basecoin.isValidAddress('0x2476602c78e9a5e0563320c78878faa3952b256f?memoId=1&foo=bar').should.equal(false); + }); + }); + + describe('getAddressDetails', function () { + it('should get address details without memoId', function () { + const addressDetails = basecoin.getAddressDetails('0x2476602c78e9a5e0563320c78878faa3952b256f'); + addressDetails.address.should.equal('0x2476602c78e9a5e0563320c78878faa3952b256f'); + addressDetails.baseAddress.should.equal('0x2476602c78e9a5e0563320c78878faa3952b256f'); + should.not.exist(addressDetails.memoId); + }); + + it('should get address details with memoId', function () { + const addressDetails = basecoin.getAddressDetails('0x2476602c78e9a5e0563320c78878faa3952b256f?memoId=8'); + addressDetails.address.should.equal('0x2476602c78e9a5e0563320c78878faa3952b256f?memoId=8'); + addressDetails.baseAddress.should.equal('0x2476602c78e9a5e0563320c78878faa3952b256f'); + addressDetails.memoId.should.equal('8'); + }); + + it('should throw on invalid memoId address', function () { + (() => basecoin.getAddressDetails('0x2476602c78e9a5e0563320c78878faa3952b256f?memoId=abc')).should.throw( + InvalidMemoIdError + ); + }); + + it('should throw on multiple memoId address', function () { + (() => basecoin.getAddressDetails('0x2476602c78e9a5e0563320c78878faa3952b256f?memoId=1&memoId=2')).should.throw( + InvalidAddressError + ); + }); + + it('should throw on unknown query parameters', function () { + (() => basecoin.getAddressDetails('0x2476602c78e9a5e0563320c78878faa3952b256f?invalid=8')).should.throw( + InvalidAddressError + ); + }); + + it('should throw on empty memoId', function () { + (() => basecoin.getAddressDetails('0x2476602c78e9a5e0563320c78878faa3952b256f?memoId=')).should.throw( + InvalidAddressError + ); + }); + + it('should throw on extra query parameters besides memoId', function () { + (() => basecoin.getAddressDetails('0x2476602c78e9a5e0563320c78878faa3952b256f?memoId=1&foo=bar')).should.throw( + InvalidAddressError + ); + }); + }); + + describe('isValidMemoId', function () { + it('should validate correct memoIds', function () { + basecoin.isValidMemoId('0').should.equal(true); + basecoin.isValidMemoId('1').should.equal(true); + basecoin.isValidMemoId('12345').should.equal(true); + basecoin.isValidMemoId('999999999999').should.equal(true); + }); + + it('should reject invalid memoIds', function () { + basecoin.isValidMemoId('').should.equal(false); + basecoin.isValidMemoId('-1').should.equal(false); + basecoin.isValidMemoId('1.5').should.equal(false); + basecoin.isValidMemoId('abc').should.equal(false); + basecoin.isValidMemoId('01').should.equal(false); // Leading zero + basecoin.isValidMemoId('00').should.equal(false); + }); + }); + describe('Testnet', function () { let testnetBasecoin; @@ -59,5 +164,9 @@ describe('Tempo Coin', function () { testnetBasecoin.getFullName().should.equal('Testnet Tempo'); testnetBasecoin.getBaseFactor().should.equal(1e18); }); + + it('should validate address with memoId on testnet', function () { + testnetBasecoin.isValidAddress('0x2476602c78e9a5e0563320c78878faa3952b256f?memoId=8').should.equal(true); + }); }); });