Skip to content
Open
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
5 changes: 4 additions & 1 deletion modules/statics/src/coins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -476,7 +476,10 @@ export function createTokenMapUsingConfigDetails(tokenConfigMap: Record<string,
if (!isCoinPresentInCoinMap({ ...tokenConfig }) && !nftAndOtherTokens.has(tokenConfig.name)) {
try {
const token = createToken(tokenConfig);
if (token) {
// A token whose name is absent from the static map can still reuse a contract address (or
// NFT collection id) that a static token already claims. Adding it would make the final
// CoinMap.fromCoins throw DuplicateContractAddressDefinitionError, so skip it instead.
if (token && !coins.hasTokenAddressConflict(token)) {
BaseCoins.set(token.name, token);
}
} catch (e) {
Expand Down
4 changes: 2 additions & 2 deletions modules/statics/src/coins/erc7984Tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,15 @@ export const erc7984Tokens = [
'eth:ctkn',
'Confidential Test Token',
6,
'0x0000000000000000000000000000000000000000', // TODO: update with mainnet contract address
'0x0000000000000000000000000000000000000001', // TODO: update with mainnet contract address
UnderlyingAsset['eth:ctkn']
),
erc7984(
'f47ac10b-58cc-4372-a567-0e02b2c3d480',
'eth:cusdt',
'Confidential USDT',
6,
'0x0000000000000000000000000000000000000000', // TODO: update with mainnet contract address
'0x0000000000000000000000000000000000000002', // TODO: update with mainnet contract address
UnderlyingAsset['eth:cusdt']
),

Expand Down
14 changes: 14 additions & 0 deletions modules/statics/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,20 @@ export class DuplicateCoinIdDefinitionError extends BitGoStaticsError {
}
}

export class DuplicateContractAddressDefinitionError extends BitGoStaticsError {
public constructor(contractAddressKey: string, existingCoinName: string) {
super(`token with contract address '${contractAddressKey}' is already defined as '${existingCoinName}'`);
Object.setPrototypeOf(this, DuplicateContractAddressDefinitionError.prototype);
}
}

export class DuplicateNftCollectionIdDefinitionError extends BitGoStaticsError {
public constructor(nftCollectionKey: string, existingCoinName: string) {
super(`token with NFT collection id '${nftCollectionKey}' is already defined as '${existingCoinName}'`);
Object.setPrototypeOf(this, DuplicateNftCollectionIdDefinitionError.prototype);
}
}

export class DisallowedCoinFeatureError extends BitGoStaticsError {
public constructor(coinName: string, feature: CoinFeature) {
super(`coin feature '${feature}' is disallowed for coin ${coinName}.`);
Expand Down
61 changes: 56 additions & 5 deletions modules/statics/src/map.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { BaseCoin } from './base';
import { DuplicateCoinDefinitionError, CoinNotDefinedError, DuplicateCoinIdDefinitionError } from './errors';
import {
DuplicateCoinDefinitionError,
CoinNotDefinedError,
DuplicateCoinIdDefinitionError,
DuplicateContractAddressDefinitionError,
DuplicateNftCollectionIdDefinitionError,
} from './errors';
import { ContractAddressDefinedToken, NFTCollectionIdDefinedToken } from './account';
import { EthereumNetwork } from './networks';

Expand All @@ -20,6 +26,35 @@ export class CoinMap {
// Do not instantiate
}

private static contractAddressKey(coin: ContractAddressDefinedToken): string {
return `${coin.family}:${coin.contractAddress}`;
}

private static nftCollectionIdKey(coin: NFTCollectionIdDefinedToken): string {
return `${coin.prefix}${coin.family}:${coin.nftCollectionId}`;
}

/**
* Whether a different token with the same contract address (or NFT collection id) is already
* registered. Token identity in the map is keyed by name/id/alias, but a token also claims a
* contract-address key (`family:contractAddress`) and, for NFTs, a collection-id key. Two tokens
* that share such a key but differ in name cannot coexist — `addCoin` throws on the second.
* Callers merging externally-sourced tokens use this to skip a colliding token rather than crash.
*/
public hasTokenAddressConflict(coin: Readonly<BaseCoin>): boolean {
if (coin instanceof ContractAddressDefinedToken) {
const key = CoinMap.contractAddressKey(coin);
const existing = this._coinByContractAddress.get(key);
return existing !== undefined && existing.network.type === coin.network.type;
}
if (coin instanceof NFTCollectionIdDefinedToken) {
const key = CoinMap.nftCollectionIdKey(coin);
const existing = this._coinByNftCollectionID.get(key);
return existing !== undefined && existing.network.type === coin.network.type;
}
return false;
}

static fromCoins(coins: Readonly<BaseCoin>[]): CoinMap {
const coinMap = new CoinMap();
coins.forEach((coin) => {
Expand Down Expand Up @@ -47,9 +82,25 @@ export class CoinMap {

if (coin.isToken) {
if (coin instanceof ContractAddressDefinedToken) {
this._coinByContractAddress.set(`${coin.family}:${coin.contractAddress}`, coin);
const contractAddressKey = CoinMap.contractAddressKey(coin);
const existingByContractAddress = this._coinByContractAddress.get(contractAddressKey);
if (existingByContractAddress) {
if (existingByContractAddress.network.type === coin.network.type) {
throw new DuplicateContractAddressDefinitionError(contractAddressKey, existingByContractAddress.name);
}
} else {
this._coinByContractAddress.set(contractAddressKey, coin);
}
} else if (coin instanceof NFTCollectionIdDefinedToken) {
this._coinByNftCollectionID.set(`${coin.prefix}${coin.family}:${coin.nftCollectionId}`, coin);
const nftCollectionKey = CoinMap.nftCollectionIdKey(coin);
const existingByNftCollectionId = this._coinByNftCollectionID.get(nftCollectionKey);
if (existingByNftCollectionId) {
if (existingByNftCollectionId.network.type === coin.network.type) {
throw new DuplicateNftCollectionIdDefinitionError(nftCollectionKey, existingByNftCollectionId.name);
}
} else {
this._coinByNftCollectionID.set(nftCollectionKey, coin);
}
}
}
}
Expand All @@ -69,9 +120,9 @@ export class CoinMap {
}
if (oldCoin.isToken) {
if (oldCoin instanceof ContractAddressDefinedToken) {
this._coinByContractAddress.delete(`${oldCoin.family}:${oldCoin.contractAddress}`);
this._coinByContractAddress.delete(CoinMap.contractAddressKey(oldCoin));
} else if (oldCoin instanceof NFTCollectionIdDefinedToken) {
this._coinByNftCollectionID.delete(`${oldCoin.prefix}${oldCoin.family}:${oldCoin.nftCollectionId}`);
this._coinByNftCollectionID.delete(CoinMap.nftCollectionIdKey(oldCoin));
}
}
}
Expand Down
126 changes: 125 additions & 1 deletion modules/statics/test/unit/coins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ import {
trimmedDynamicBaseChainConfig,
} from './resources/amsTokenConfig';
import { EthLikeErc20Token } from '../../../sdk-coin-evm/src';
import { ProgramID } from '../../src/account';
import { ProgramID, taptNFTCollection, terc20 } from '../../src/account';
import { allCoinsAndTokens } from '../../src/allCoinsAndTokens';

interface DuplicateCoinObject {
Expand Down Expand Up @@ -753,6 +753,70 @@ describe('CoinMap', function () {
(() => CoinMap.fromCoins([btc, btc2])).should.throw(`coin with id '${btc.id}' is already defined`);
});

it('should fail to map tokens with duplicated contract address for the same family', () => {
const template = coins.get('tusdc') as Erc20Coin;
const contractAddress = template.contractAddress.toString();
const tokenA = terc20(
'11111111-1111-4111-8111-111111111111',
'token-a',
'Token A',
6,
contractAddress,
template.asset,
template.features,
template.prefix,
template.suffix,
template.network as EthereumNetwork
);
const tokenB = terc20(
'22222222-2222-4222-8222-222222222222',
'token-b',
'Token B',
18,
contractAddress,
template.asset,
template.features,
template.prefix,
template.suffix,
template.network as EthereumNetwork
);
const contractAddressKey = `${tokenA.family}:${contractAddress}`;
(() => CoinMap.fromCoins([tokenA, tokenB])).should.throw(
`token with contract address '${contractAddressKey}' is already defined as 'token-a'`
);
});

it('should fail to map tokens with duplicated NFT collection id for the same family', () => {
const template = coins.get('tapt:nftcollection1');
const nftCollectionId = '0xbbc561fbfa5d105efd8dfb06ae3e7e5be46331165b99d518f094c701e40603b5';
const tokenA = taptNFTCollection(
'11111111-1111-4111-8111-111111111111',
'tapt:nftcollection-a',
'NFT Collection A',
nftCollectionId,
template.asset,
template.features,
template.prefix,
template.suffix,
template.network
);
const tokenB = taptNFTCollection(
'22222222-2222-4222-8222-222222222222',
'tapt:nftcollection-b',
'NFT Collection B',
nftCollectionId,
template.asset,
template.features,
template.prefix,
template.suffix,
template.network
);
const nftCollectionKey = `${tokenA.prefix}${tokenA.family}:${nftCollectionId}`;
(() => CoinMap.fromCoins([tokenA, tokenB])).should.throw(
`token with NFT collection id '${nftCollectionKey}' is already defined as 'tapt:nftcollection-a'`
);
});

it('should have iterator', function () {
[...coins].length.should.be.greaterThan(100);
});
Expand Down Expand Up @@ -1447,6 +1511,66 @@ describe('create token map using config details', () => {
});
});

describe('create token map contract address de-duplication', () => {
function firstStaticErc20(): Readonly<Erc20Coin> {
for (const [, coin] of coins) {
if (coin instanceof Erc20Coin) {
return coin as Readonly<Erc20Coin>;
}
}
throw new Error('expected at least one static ERC20 token in the coin map');
}

function collidingAmsConfig(
staticToken: Readonly<Erc20Coin>,
name: string,
id: string
): Parameters<typeof createTokenMapUsingConfigDetails>[0] {
return {
[name]: [
{
id,
fullName: 'Colliding AMS Token',
name,
prefix: '',
suffix: name.toUpperCase(),
baseUnit: 'wei',
kind: 'crypto',
family: staticToken.family,
isToken: true,
features: [...staticToken.features],
decimalPlaces: staticToken.decimalPlaces,
asset: name,
network: staticToken.network,
primaryKeyCurve: 'secp256k1',
contractAddress: staticToken.contractAddress,
},
],
} as unknown as Parameters<typeof createTokenMapUsingConfigDetails>[0];
}

const collidingName = 'eth:cshld976colliding';
const collidingId = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee';

it('uses a name and id not already in the static coin map', () => {
coins.has(collidingName).should.eql(false);
coins.has(collidingId).should.eql(false);
});

it('skips an AMS token that reuses an existing static contract address under a different name', () => {
const staticToken = firstStaticErc20();
const config = collidingAmsConfig(staticToken, collidingName, collidingId);

let tokenMap: CoinMap | undefined;
(() => {
tokenMap = createTokenMapUsingConfigDetails(config);
}).should.not.throw();

(tokenMap as CoinMap).has(collidingName).should.eql(false);
(tokenMap as CoinMap).has(staticToken.name).should.eql(true);
});
});

describe('DynamicCoin and dynamic base chain support', function () {
describe('createToken with dynamic base chain', function () {
it('should return a DynamicCoin when isToken is false with a BaseNetwork instance', function () {
Expand Down
Loading