Skip to content

Commit 09ef6fa

Browse files
committed
feat(sdk-coin-tempo): add memoId support for address validation
Ticket: WIN-8847
1 parent 8a2810a commit 09ef6fa

File tree

2 files changed

+210
-2
lines changed

2 files changed

+210
-2
lines changed

modules/sdk-coin-tempo/src/tempo.ts

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,14 @@ import {
77
OfflineVaultTxInfo,
88
UnsignedSweepTxMPCv2,
99
TransactionBuilder,
10+
optionalDeps,
1011
} from '@bitgo/abstract-eth';
1112
import type * as EthLikeCommon from '@ethereumjs/common';
12-
import { BaseCoin, BitGoBase, MPCAlgorithm } from '@bitgo/sdk-core';
13+
import { BaseCoin, BitGoBase, InvalidAddressError, InvalidMemoIdError, MPCAlgorithm } from '@bitgo/sdk-core';
1314
import { BaseCoin as StaticsBaseCoin, coins } from '@bitgo/statics';
1415
import { Tip20TransactionBuilder } from './lib';
16+
import * as url from 'url';
17+
import * as querystring from 'querystring';
1518

1619
export class Tempo extends AbstractEthLikeNewCoins {
1720
protected constructor(bitgo: BitGoBase, staticsCoin?: Readonly<StaticsBaseCoin>) {
@@ -75,6 +78,102 @@ export class Tempo extends AbstractEthLikeNewCoins {
7578
return true;
7679
}
7780

81+
/**
82+
* Evaluates whether an address string is valid for Tempo
83+
* Supports addresses with optional memoId query parameter (e.g., 0x...?memoId=123)
84+
* @param address - The address to validate
85+
* @returns true if address is valid
86+
*/
87+
isValidAddress(address: string): boolean {
88+
if (typeof address !== 'string') {
89+
return false;
90+
}
91+
92+
try {
93+
const { baseAddress } = this.getAddressDetails(address);
94+
return optionalDeps.ethUtil.isValidAddress(optionalDeps.ethUtil.addHexPrefix(baseAddress));
95+
} catch (e) {
96+
return false;
97+
}
98+
}
99+
100+
/**
101+
* Parse address into base address and optional memoId
102+
* Throws InvalidAddressError for invalid address formats
103+
* @param address - Address string, potentially with ?memoId=X suffix
104+
* @returns Object containing address, baseAddress, and memoId (null if not present)
105+
* @throws InvalidAddressError if address format is invalid
106+
*/
107+
getAddressDetails(address: string): { address: string; baseAddress: string; memoId: string | null } {
108+
if (typeof address !== 'string') {
109+
throw new InvalidAddressError(`invalid address: ${address}`);
110+
}
111+
112+
const destinationDetails = url.parse(address);
113+
const baseAddress = destinationDetails.pathname || '';
114+
115+
// No query string - just a plain address
116+
if (destinationDetails.pathname === address) {
117+
return {
118+
address,
119+
baseAddress: address,
120+
memoId: null,
121+
};
122+
}
123+
124+
// Has query string - must contain memoId
125+
if (!destinationDetails.query) {
126+
throw new InvalidAddressError(`invalid address: ${address}`);
127+
}
128+
129+
const queryDetails = querystring.parse(destinationDetails.query);
130+
131+
// Query string must contain memoId
132+
if (!queryDetails.memoId) {
133+
throw new InvalidAddressError(`invalid address: ${address}, unknown query parameters`);
134+
}
135+
136+
// Only one memoId allowed
137+
if (Array.isArray(queryDetails.memoId)) {
138+
throw new InvalidAddressError(
139+
`memoId may only be given at most once, but found ${queryDetails.memoId.length} instances in address ${address}`
140+
);
141+
}
142+
143+
// Reject if there are other query parameters besides memoId
144+
const queryKeys = Object.keys(queryDetails);
145+
if (queryKeys.length !== 1) {
146+
throw new InvalidAddressError(`invalid address: ${address}, only memoId query parameter is allowed`);
147+
}
148+
149+
// Validate memoId format
150+
if (!this.isValidMemoId(queryDetails.memoId)) {
151+
throw new InvalidMemoIdError(`invalid address: '${address}', memoId is not valid`);
152+
}
153+
154+
return {
155+
address,
156+
baseAddress,
157+
memoId: queryDetails.memoId,
158+
};
159+
}
160+
161+
/**
162+
* Validate that a memoId is a valid non-negative integer string
163+
* @param memoId - The memoId to validate
164+
* @returns true if valid
165+
*/
166+
isValidMemoId(memoId: string): boolean {
167+
if (typeof memoId !== 'string' || memoId === '') {
168+
return false;
169+
}
170+
// Must be a non-negative integer (no decimals, no negative, no leading zeros except for "0")
171+
if (!/^(0|[1-9]\d*)$/.test(memoId)) {
172+
return false;
173+
}
174+
return true;
175+
}
176+
78177
/**
79178
* Check if typed data signing is supported (EIP-712)
80179
*/

modules/sdk-coin-tempo/test/unit/index.ts

Lines changed: 110 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@ import { Tempo } from '../../src/tempo';
22
import { Ttempo } from '../../src/ttempo';
33
import { BitGoAPI } from '@bitgo/sdk-api';
44
import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test';
5-
import { BitGoBase } from '@bitgo/sdk-core';
5+
import { BitGoBase, InvalidAddressError, InvalidMemoIdError } from '@bitgo/sdk-core';
66
import { BaseCoin as StaticsBaseCoin } from '@bitgo/statics';
7+
import * as should from 'should';
78

89
describe('Tempo Coin', function () {
910
let bitgo: TestBitGoAPI;
@@ -42,6 +43,110 @@ describe('Tempo Coin', function () {
4243
basecoin.getBaseFactor().should.equal(1e18);
4344
});
4445

46+
describe('Address Validation', function () {
47+
it('should validate plain EVM address', function () {
48+
basecoin.isValidAddress('0x2476602c78e9a5e0563320c78878faa3952b256f').should.equal(true);
49+
basecoin.isValidAddress('0x0000000000000000000000000000000000000000').should.equal(true);
50+
});
51+
52+
it('should validate address with memoId', function () {
53+
basecoin.isValidAddress('0x2476602c78e9a5e0563320c78878faa3952b256f?memoId=8').should.equal(true);
54+
basecoin.isValidAddress('0x2476602c78e9a5e0563320c78878faa3952b256f?memoId=0').should.equal(true);
55+
basecoin.isValidAddress('0x2476602c78e9a5e0563320c78878faa3952b256f?memoId=12345').should.equal(true);
56+
});
57+
58+
it('should reject invalid addresses', function () {
59+
basecoin.isValidAddress('invalid').should.equal(false);
60+
basecoin.isValidAddress('').should.equal(false);
61+
basecoin.isValidAddress('0x123').should.equal(false); // Too short
62+
});
63+
64+
it('should reject address with invalid memoId', function () {
65+
basecoin.isValidAddress('0x2476602c78e9a5e0563320c78878faa3952b256f?memoId=abc').should.equal(false);
66+
basecoin.isValidAddress('0x2476602c78e9a5e0563320c78878faa3952b256f?memoId=-1').should.equal(false);
67+
basecoin.isValidAddress('0x2476602c78e9a5e0563320c78878faa3952b256f?memoId=1.5').should.equal(false);
68+
basecoin.isValidAddress('0x2476602c78e9a5e0563320c78878faa3952b256f?memoId=01').should.equal(false); // Leading zero
69+
basecoin.isValidAddress('0x2476602c78e9a5e0563320c78878faa3952b256f?memoId=').should.equal(false); // Empty memoId
70+
});
71+
72+
it('should reject address with unknown query parameters', function () {
73+
basecoin.isValidAddress('0x2476602c78e9a5e0563320c78878faa3952b256f?invalid=123').should.equal(false);
74+
basecoin.isValidAddress('0x2476602c78e9a5e0563320c78878faa3952b256f?foo=bar').should.equal(false);
75+
});
76+
77+
it('should reject address with multiple memoId parameters', function () {
78+
basecoin.isValidAddress('0x2476602c78e9a5e0563320c78878faa3952b256f?memoId=1&memoId=2').should.equal(false);
79+
});
80+
81+
it('should reject address with extra query parameters besides memoId', function () {
82+
basecoin.isValidAddress('0x2476602c78e9a5e0563320c78878faa3952b256f?memoId=1&foo=bar').should.equal(false);
83+
});
84+
});
85+
86+
describe('getAddressDetails', function () {
87+
it('should get address details without memoId', function () {
88+
const addressDetails = basecoin.getAddressDetails('0x2476602c78e9a5e0563320c78878faa3952b256f');
89+
addressDetails.address.should.equal('0x2476602c78e9a5e0563320c78878faa3952b256f');
90+
addressDetails.baseAddress.should.equal('0x2476602c78e9a5e0563320c78878faa3952b256f');
91+
should.not.exist(addressDetails.memoId);
92+
});
93+
94+
it('should get address details with memoId', function () {
95+
const addressDetails = basecoin.getAddressDetails('0x2476602c78e9a5e0563320c78878faa3952b256f?memoId=8');
96+
addressDetails.address.should.equal('0x2476602c78e9a5e0563320c78878faa3952b256f?memoId=8');
97+
addressDetails.baseAddress.should.equal('0x2476602c78e9a5e0563320c78878faa3952b256f');
98+
addressDetails.memoId.should.equal('8');
99+
});
100+
101+
it('should throw on invalid memoId address', function () {
102+
(() => basecoin.getAddressDetails('0x2476602c78e9a5e0563320c78878faa3952b256f?memoId=abc')).should.throw(
103+
InvalidMemoIdError
104+
);
105+
});
106+
107+
it('should throw on multiple memoId address', function () {
108+
(() => basecoin.getAddressDetails('0x2476602c78e9a5e0563320c78878faa3952b256f?memoId=1&memoId=2')).should.throw(
109+
InvalidAddressError
110+
);
111+
});
112+
113+
it('should throw on unknown query parameters', function () {
114+
(() => basecoin.getAddressDetails('0x2476602c78e9a5e0563320c78878faa3952b256f?invalid=8')).should.throw(
115+
InvalidAddressError
116+
);
117+
});
118+
119+
it('should throw on empty memoId', function () {
120+
(() => basecoin.getAddressDetails('0x2476602c78e9a5e0563320c78878faa3952b256f?memoId=')).should.throw(
121+
InvalidAddressError
122+
);
123+
});
124+
125+
it('should throw on extra query parameters besides memoId', function () {
126+
(() => basecoin.getAddressDetails('0x2476602c78e9a5e0563320c78878faa3952b256f?memoId=1&foo=bar')).should.throw(
127+
InvalidAddressError
128+
);
129+
});
130+
});
131+
132+
describe('isValidMemoId', function () {
133+
it('should validate correct memoIds', function () {
134+
basecoin.isValidMemoId('0').should.equal(true);
135+
basecoin.isValidMemoId('1').should.equal(true);
136+
basecoin.isValidMemoId('12345').should.equal(true);
137+
basecoin.isValidMemoId('999999999999').should.equal(true);
138+
});
139+
140+
it('should reject invalid memoIds', function () {
141+
basecoin.isValidMemoId('').should.equal(false);
142+
basecoin.isValidMemoId('-1').should.equal(false);
143+
basecoin.isValidMemoId('1.5').should.equal(false);
144+
basecoin.isValidMemoId('abc').should.equal(false);
145+
basecoin.isValidMemoId('01').should.equal(false); // Leading zero
146+
basecoin.isValidMemoId('00').should.equal(false);
147+
});
148+
});
149+
45150
describe('Testnet', function () {
46151
let testnetBasecoin;
47152

@@ -59,5 +164,9 @@ describe('Tempo Coin', function () {
59164
testnetBasecoin.getFullName().should.equal('Testnet Tempo');
60165
testnetBasecoin.getBaseFactor().should.equal(1e18);
61166
});
167+
168+
it('should validate address with memoId on testnet', function () {
169+
testnetBasecoin.isValidAddress('0x2476602c78e9a5e0563320c78878faa3952b256f?memoId=8').should.equal(true);
170+
});
62171
});
63172
});

0 commit comments

Comments
 (0)