Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 100 additions & 1 deletion modules/sdk-coin-tempo/src/tempo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<StaticsBaseCoin>) {
Expand Down Expand Up @@ -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)
*/
Expand Down
111 changes: 110 additions & 1 deletion modules/sdk-coin-tempo/test/unit/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;

Expand All @@ -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);
});
});
});