Skip to content
Closed
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
18 changes: 18 additions & 0 deletions modules/statics/src/map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,24 @@ export class CoinMap {
return `${coin.prefix}${coin.family}:${coin.network.type}:${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:network.type: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) {
return this._coinByContractAddress.has(CoinMap.contractAddressKey(coin));
}
if (coin instanceof NFTCollectionIdDefinedToken) {
return this._coinByNftCollectionID.has(CoinMap.nftCollectionIdKey(coin));
}
return false;
}

static fromCoins(coins: Readonly<BaseCoin>[]): CoinMap {
const coinMap = new CoinMap();
coins.forEach((coin) => {
Expand Down
66 changes: 66 additions & 0 deletions modules/statics/test/unit/coins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1515,6 +1515,72 @@ describe('create token map using config details', () => {
});
});

describe('create token map contract address de-duplication', () => {
// Locate any static ERC20 token to collide with. The merge path only fails when the
// colliding token's contract address already lives in the static coin map.
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');
}

// Build an AMS token config that reuses an existing static token's contract address
// (same family + network) but under a brand-new name and id.
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();

// The colliding AMS token is dropped...
(tokenMap as CoinMap).has(collidingName).should.eql(false);
// ...and the original static token at that contract address is preserved.
(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