Skip to content

Commit 306b6b9

Browse files
committed
feat(sdk-coin-tempo): add memoId support for address validation
Ticket: WIN-8847
1 parent 76711a2 commit 306b6b9

2 files changed

Lines changed: 215 additions & 2 deletions

File tree

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

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

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)