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
14 changes: 13 additions & 1 deletion modules/statics/src/coins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -468,6 +468,10 @@ export function createTokenMapUsingConfigDetails(tokenConfigMap: Record<string,
BaseCoins.set(coinName, coin);
});

// Accumulates both static and already-accepted AMS tokens so AMS-vs-AMS contract address
// conflicts are caught in addition to static-vs-AMS conflicts.
const accumulatedMap = CoinMap.fromCoins(Array.from(BaseCoins.values()));

// add the tokens not present in the static coin map
for (const tokenConfigs of Object.values(tokenConfigMap)) {
if (!tokenConfigs.length) continue;
Expand All @@ -476,8 +480,16 @@ 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 accumulated map can still reuse a contract address
// (or NFT collection id) already claimed by a static or previously-accepted AMS token.
// Adding it would make the final CoinMap.fromCoins throw, so skip it instead.
if (token && !accumulatedMap.hasTokenAddressConflict(token)) {
BaseCoins.set(token.name, token);
Comment thread
nvjsr marked this conversation as resolved.
accumulatedMap.addCoin(token);
} else if (token) {
console.warn(
`Skipping token with conflicting contract address or NFT collection id: name="${tokenConfig.name}" id="${tokenConfig.id}"`
);
}
} catch (e) {
console.warn(
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
172 changes: 171 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,112 @@ 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);
});

it('skips second AMS token that reuses a contract address already claimed by a first AMS token', () => {
const staticToken = firstStaticErc20();
const sharedContractAddress = '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef';
const amsNameA = 'eth:ams-only-a-976';
const amsIdA = 'cccccccc-dddd-4eee-8fff-000000000001';
const amsNameB = 'eth:ams-only-b-976';
const amsIdB = 'cccccccc-dddd-4eee-8fff-000000000002';

const makeConfig = (name: string, id: string): Parameters<typeof createTokenMapUsingConfigDetails>[0] =>
({
[name]: [
{
id,
fullName: 'AMS-only 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: sharedContractAddress,
},
],
} as unknown as Parameters<typeof createTokenMapUsingConfigDetails>[0]);

const config = { ...makeConfig(amsNameA, amsIdA), ...makeConfig(amsNameB, amsIdB) };

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

const map = tokenMap as CoinMap;
const hasA = map.has(amsNameA);
const hasB = map.has(amsNameB);
// Exactly one of the two AMS tokens wins; the second with the same address is skipped.
(hasA || hasB).should.eql(true, 'first AMS token should be accepted');
(hasA && hasB).should.eql(false, 'second AMS token with duplicate address should be skipped');
});
});

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