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. Skip it so the static coin remains
// the authoritative entry for that address.
if (token && !coins.hasTokenAddressConflict(token)) {
BaseCoins.set(token.name, token);
}
} catch (e) {
Expand Down
19 changes: 19 additions & 0 deletions modules/statics/src/map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,25 @@ export class CoinMap {
// Do not instantiate
}


/**
* 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. Callers merging externally-sourced
* tokens use this to skip a colliding token rather than silently overwriting the existing entry.
*/
public hasTokenAddressConflict(coin: Readonly<BaseCoin>): boolean {
if (coin instanceof ContractAddressDefinedToken) {
return this._coinByContractAddress.has(`${coin.family}:${coin.contractAddress}`);
}
if (coin instanceof NFTCollectionIdDefinedToken) {
return this._coinByNftCollectionID.has(`${coin.prefix}${coin.family}:${coin.nftCollectionId}`);
}
return false;
}


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

it('should skip an ams token that reuses an existing static contract address under a different name', () => {
// Find any static ERC20 token to collide with. The merge only fails when the colliding
// AMS token's contract address already lives in the static coin map under a different name --
// the failure observed via syncAmsCoinsToPresenter in bitgo-retail.
let staticToken: Readonly<Erc20Coin> | undefined;
for (const [, coin] of coins) {
if (coin instanceof Erc20Coin) {
staticToken = coin;
break;
}
}
if (!staticToken) {
throw new Error('expected at least one static ERC20 token in the coin map');
}

const collidingName = 'eth:cshld976colliding';
const collidingId = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee';
// Sanity: the colliding identity is not already in statics, so only the contract address collides.
coins.has(collidingName).should.eql(false);
coins.has(collidingId).should.eql(false);

const baseConfig = amsTokenConfigWithCustomToken['hteth:faketoken'][0];
const tokenConfig = {
[collidingName]: [
{
...baseConfig,
id: collidingId,
name: collidingName,
asset: collidingName,
family: staticToken.family,
contractAddress: staticToken.contractAddress,
network: staticToken.network,
},
],
} as unknown as Parameters<typeof createTokenMapUsingConfigDetails>[0];

let tokenMap: ReturnType<typeof createTokenMapUsingConfigDetails> | undefined;
(() => {
tokenMap = createTokenMapUsingConfigDetails(tokenConfig);
}).should.not.throw();

// The colliding AMS token is dropped; the static token at that contract address is preserved.
tokenMap!.has(collidingName).should.eql(false);
tokenMap!.has(staticToken.name).should.eql(true);
});

it('should create a coin map using reduced token config details', () => {
const coinMap1 = createTokenMapUsingTrimmedConfigDetails(reducedAmsTokenConfig);
const amsToken1 = coinMap1.get('hteth:faketoken');
Expand Down
Loading