Skip to content

Commit 3b2cdbc

Browse files
committed
feat: erc721 token support for evm coins
ticket: win-8154
1 parent 871ed17 commit 3b2cdbc

File tree

10 files changed

+230
-5
lines changed

10 files changed

+230
-5
lines changed

modules/bitgo/src/v2/coinFactory.ts

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ import { Near, TNear, Nep141Token } from '@bitgo/sdk-coin-near';
99
import { SolToken } from '@bitgo/sdk-coin-sol';
1010
import { TrxToken } from '@bitgo/sdk-coin-trx';
1111
import { CoinFactory, CoinConstructor } from '@bitgo/sdk-core';
12-
import { EthLikeErc20Token } from '@bitgo/sdk-coin-evm';
12+
import { EthLikeErc20Token, EthLikeErc721Token } from '@bitgo/sdk-coin-evm';
13+
1314
import {
1415
CoinMap,
1516
coins,
@@ -565,6 +566,20 @@ export function registerCoinConstructors(coinFactory: CoinFactory, coinMap: Coin
565566
coinFactory.register(name, coinConstructor);
566567
});
567568
});
569+
570+
// Generic ERC721 token registration for coins with SUPPORTS_ERC721 feature
571+
coins
572+
.filter((coin) => coin.features.includes(CoinFeature.SUPPORTS_ERC721) && !coin.isToken)
573+
.forEach((coin) => {
574+
const coinNames = {
575+
Mainnet: `${coin.name}`,
576+
Testnet: `t${coin.name}`,
577+
};
578+
579+
EthLikeErc721Token.createTokenConstructors(coinNames).forEach(({ name, coinConstructor }) => {
580+
coinFactory.register(name, coinConstructor);
581+
});
582+
});
568583
}
569584

570585
export function getCoinConstructor(coinName: string): CoinConstructor | undefined {
@@ -926,8 +941,30 @@ export const buildEthLikeChainToTestnetMap = (): {
926941
return { mainnetToTestnetMap, testnetToMainnetMap };
927942
};
928943

944+
const buildEthLikeErc721ChainToTestnetMap = (): {
945+
mainnetToTestnetMapForErc721Tokens: Record<string, string>;
946+
testnetToMainnetMapForErc721Tokens: Record<string, string>;
947+
} => {
948+
const testnetToMainnetMapForErc721Tokens: Record<string, string> = {};
949+
const mainnetToTestnetMapForErc721Tokens: Record<string, string> = {};
950+
951+
coins.forEach((coin) => {
952+
if (coin.network.type === NetworkType.TESTNET && !coin.isToken) {
953+
if (coins.get(coin.family)?.features.includes(CoinFeature.SUPPORTS_ERC721)) {
954+
mainnetToTestnetMapForErc721Tokens[coin.family] = `${coin.name}`;
955+
testnetToMainnetMapForErc721Tokens[coin.name] = `${coin.family}`;
956+
}
957+
}
958+
});
959+
960+
return { mainnetToTestnetMapForErc721Tokens, testnetToMainnetMapForErc721Tokens };
961+
};
962+
929963
const { mainnetToTestnetMap, testnetToMainnetMap } = buildEthLikeChainToTestnetMap();
930964

965+
const { mainnetToTestnetMapForErc721Tokens, testnetToMainnetMapForErc721Tokens } =
966+
buildEthLikeErc721ChainToTestnetMap();
967+
931968
export function getTokenConstructor(tokenConfig: TokenConfig): CoinConstructor | undefined {
932969
const testnetCoin = mainnetToTestnetMap[tokenConfig.coin];
933970
if (testnetCoin) {
@@ -943,6 +980,21 @@ export function getTokenConstructor(tokenConfig: TokenConfig): CoinConstructor |
943980
Testnet: tokenConfig.coin,
944981
});
945982
}
983+
984+
if (mainnetToTestnetMapForErc721Tokens[tokenConfig.coin]) {
985+
return EthLikeErc721Token.createTokenConstructor(tokenConfig as EthLikeTokenConfig, {
986+
Mainnet: tokenConfig.coin,
987+
Testnet: mainnetToTestnetMapForErc721Tokens[tokenConfig.coin],
988+
});
989+
}
990+
991+
if (testnetToMainnetMapForErc721Tokens[tokenConfig.coin]) {
992+
return EthLikeErc721Token.createTokenConstructor(tokenConfig as EthLikeTokenConfig, {
993+
Mainnet: testnetToMainnetMapForErc721Tokens[tokenConfig.coin],
994+
Testnet: tokenConfig.coin,
995+
});
996+
}
997+
946998
switch (tokenConfig.coin) {
947999
case 'eth':
9481000
case 'hteth':

modules/bitgo/src/v2/coins/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ import { Dot, Tdot } from '@bitgo/sdk-coin-dot';
3131
import { Eos, EosToken, Teos } from '@bitgo/sdk-coin-eos';
3232
import { Etc, Tetc } from '@bitgo/sdk-coin-etc';
3333
import { Erc20Token, Erc721Token, Eth, Gteth, Hteth, Teth } from '@bitgo/sdk-coin-eth';
34-
import { EvmCoin, EthLikeErc20Token } from '@bitgo/sdk-coin-evm';
34+
import { EvmCoin, EthLikeErc20Token, EthLikeErc721Token } from '@bitgo/sdk-coin-evm';
3535
import { Flr, Tflr, FlrToken } from '@bitgo/sdk-coin-flr';
3636
import { Flrp } from '@bitgo/sdk-coin-flrp';
3737
import { Ethw } from '@bitgo/sdk-coin-ethw';
@@ -109,7 +109,7 @@ export { Erc20Token, Erc721Token, Eth, Gteth, Hteth, Teth };
109109
export { Ethw };
110110
export { EthLikeCoin, TethLikeCoin };
111111
export { Etc, Tetc };
112-
export { EvmCoin, EthLikeErc20Token };
112+
export { EvmCoin, EthLikeErc20Token, EthLikeErc721Token };
113113
export { Flr, Tflr, FlrToken };
114114
export { Flrp };
115115
export { Hash, Thash, HashToken };

modules/bitgo/test/browser/browser.spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ describe('Coins', () => {
5555
CosmosSharedCoin: 1,
5656
VetToken: 1,
5757
EthLikeErc20Token: 1,
58+
EthLikeErc721Token: 1,
5859
HashToken: 1,
5960
FlrToken: 1,
6061
JettonToken: 1,
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/**
2+
* @prettier
3+
*/
4+
import { coins, EthLikeTokenConfig } from '@bitgo/statics';
5+
import { BitGoBase, CoinConstructor, common, MPCAlgorithm, NamedCoinConstructor } from '@bitgo/sdk-core';
6+
import { CoinNames, EthLikeToken, recoveryBlockchainExplorerQuery } from '@bitgo/abstract-eth';
7+
import { TransactionBuilder } from './lib';
8+
import assert from 'assert';
9+
10+
export class EthLikeErc721Token extends EthLikeToken {
11+
public readonly tokenConfig: EthLikeTokenConfig;
12+
private readonly coinNames: CoinNames;
13+
14+
constructor(bitgo: BitGoBase, tokenConfig: EthLikeTokenConfig, coinNames: CoinNames) {
15+
super(bitgo, tokenConfig, coinNames);
16+
this.coinNames = coinNames;
17+
}
18+
19+
static createTokenConstructor(config: EthLikeTokenConfig, coinNames: CoinNames): CoinConstructor {
20+
return (bitgo: BitGoBase) => new this(bitgo, config, coinNames);
21+
}
22+
23+
static createTokenConstructors(coinNames: CoinNames): NamedCoinConstructor[] {
24+
return super.createTokenConstructors(coinNames);
25+
}
26+
27+
protected getTransactionBuilder(): TransactionBuilder {
28+
return new TransactionBuilder(coins.get(this.getBaseChain()));
29+
}
30+
31+
getMPCAlgorithm(): MPCAlgorithm {
32+
return 'ecdsa';
33+
}
34+
35+
supportsTss(): boolean {
36+
return true;
37+
}
38+
39+
async recoveryBlockchainExplorerQuery(query: Record<string, string>): Promise<Record<string, unknown>> {
40+
const family = this.getFamily();
41+
const evmConfig = common.Environments[this.bitgo.getEnv()].evm;
42+
assert(
43+
evmConfig && this.getFamily() in evmConfig,
44+
`env config is missing for ${this.getFamily()} in ${this.bitgo.getEnv()}`
45+
);
46+
const explorerUrl = evmConfig[family].baseUrl;
47+
const apiToken = evmConfig[family].apiToken;
48+
return await recoveryBlockchainExplorerQuery(query, explorerUrl as string, apiToken);
49+
}
50+
51+
getFullName(): string {
52+
return 'ERC721 Token';
53+
}
54+
}

modules/sdk-coin-evm/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ export * from './evmCoin';
22
export * from './lib';
33
export * from './register';
44
export * from './ethLikeErc20Token';
5+
export * from './ethLikeErc721Token';

modules/sdk-coin-evm/src/register.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { BitGoBase } from '@bitgo/sdk-core';
22
import { CoinFeature, coins, NetworkType } from '@bitgo/statics';
33
import { EvmCoin } from './evmCoin';
44
import { EthLikeErc20Token } from './ethLikeErc20Token';
5+
import { EthLikeErc721Token } from './ethLikeErc721Token';
56

67
export const registerAll = (sdk: BitGoBase): void => {
78
coins
@@ -35,5 +36,17 @@ export const register = (coinFamily: string, sdk: BitGoBase): void => {
3536
sdk.register(name, coinConstructor);
3637
});
3738
}
39+
40+
// Handle SUPPORTS_ERC721 registration
41+
if (coinFeatures.includes(CoinFeature.SUPPORTS_ERC721)) {
42+
const coinNames = {
43+
Mainnet: `${coin.name}`,
44+
Testnet: `${coin.name}`,
45+
};
46+
47+
EthLikeErc721Token.createTokenConstructors(coinNames).forEach(({ name, coinConstructor }) => {
48+
sdk.register(name, coinConstructor);
49+
});
50+
}
3851
});
3952
};

modules/statics/src/account.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,10 @@ export interface Erc20ConstructorOptions extends AccountConstructorOptions {
9090
contractAddress: string;
9191
}
9292

93+
export interface Erc721ConstructorOptions extends AccountConstructorOptions {
94+
contractAddress: string;
95+
}
96+
9397
export interface NFTCollectionIdConstructorOptions extends AccountConstructorOptions {
9498
nftCollectionId: string;
9599
}
@@ -430,6 +434,12 @@ export class EthLikeERC20Token extends ContractAddressDefinedToken {
430434
}
431435
}
432436

437+
export class EthLikeERC721Token extends ContractAddressDefinedToken {
438+
constructor(options: Erc721ConstructorOptions) {
439+
super(options);
440+
}
441+
}
442+
433443
/**
434444
* The AVAX C Chain network support tokens
435445
* AVAX C Chain Tokens are ERC20 coins
@@ -895,6 +905,51 @@ export function erc20Token(
895905
);
896906
}
897907

908+
/**
909+
* Factory function for erc721 token instances.
910+
*
911+
* @param id uuid v4
912+
* @param name unique identifier of the token
913+
* @param fullName Complete human-readable name of the token
914+
* @param contractAddress Contract address of this token
915+
* @param asset Asset which this token represents. This is the same for both mainnet and testnet variants of a coin.
916+
* @param network network
917+
* @param features Features of this coin. Defaults to the DEFAULT_FEATURES defined in `AccountCoin`
918+
* @param prefix Optional token prefix
919+
* @param suffix Optional token suffix
920+
* @param primaryKeyCurve The elliptic curve for this chain/token
921+
*/
922+
export function erc721Token(
923+
id: string,
924+
name: string,
925+
fullName: string,
926+
contractAddress: string,
927+
asset: UnderlyingAsset,
928+
network: AccountNetwork,
929+
features: CoinFeature[] = [...AccountCoin.DEFAULT_FEATURES, CoinFeature.EIP1559],
930+
prefix = '',
931+
suffix: string = name.toUpperCase(),
932+
primaryKeyCurve: KeyCurve = KeyCurve.Secp256k1
933+
): Readonly<EthLikeERC721Token> {
934+
return Object.freeze(
935+
new EthLikeERC721Token({
936+
id,
937+
name,
938+
fullName,
939+
network,
940+
contractAddress,
941+
decimalPlaces: 0, // ERC721 tokens are non-divisible
942+
asset,
943+
features,
944+
prefix,
945+
suffix,
946+
primaryKeyCurve,
947+
isToken: true,
948+
baseUnit: BaseUnit.ETH,
949+
})
950+
);
951+
}
952+
898953
/**
899954
* Factory function for erc20 token instances.
900955
*

modules/statics/src/allCoinsAndTokens.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2136,7 +2136,7 @@ export const allCoinsAndTokens = [
21362136
Networks.main.hederaEVM,
21372137
8,
21382138
UnderlyingAsset.HBAREVM,
2139-
BaseUnit.HBAR,
2139+
BaseUnit.ETH,
21402140
[
21412141
...EVM_FEATURES,
21422142
CoinFeature.SHARED_EVM_SIGNING,
@@ -2147,6 +2147,7 @@ export const allCoinsAndTokens = [
21472147
CoinFeature.EVM_NON_BITGO_RECOVERY,
21482148
CoinFeature.EVM_UNSIGNED_SWEEP_RECOVERY,
21492149
CoinFeature.SUPPORTS_ERC20,
2150+
CoinFeature.SUPPORTS_ERC721,
21502151
]
21512152
),
21522153
account(
@@ -2156,7 +2157,7 @@ export const allCoinsAndTokens = [
21562157
Networks.test.hederaEVM,
21572158
8,
21582159
UnderlyingAsset.HBAREVM,
2159-
BaseUnit.HBAR,
2160+
BaseUnit.ETH,
21602161
[
21612162
...EVM_FEATURES,
21622163
CoinFeature.SHARED_EVM_SIGNING,
@@ -2166,6 +2167,7 @@ export const allCoinsAndTokens = [
21662167
CoinFeature.EVM_COMPATIBLE_WP,
21672168
CoinFeature.EVM_NON_BITGO_RECOVERY,
21682169
CoinFeature.EVM_UNSIGNED_SWEEP_RECOVERY,
2170+
CoinFeature.SUPPORTS_ERC721,
21692171
]
21702172
),
21712173
account(

modules/statics/src/base.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,6 +421,11 @@ export enum CoinFeature {
421421
*/
422422
SUPPORTS_ERC20 = 'supports-erc20-token',
423423

424+
/**
425+
* This coin supports erc721 tokens
426+
*/
427+
SUPPORTS_ERC721 = 'supports-erc721-token',
428+
424429
/**
425430
* This coin is a Cosmos coin and should use shared Cosmos SDK module
426431
*/

modules/statics/src/tokenConfig.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
Erc20Coin,
1515
Erc721Coin,
1616
EthLikeERC20Token,
17+
EthLikeERC721Token,
1718
FlrERC20Token,
1819
HederaToken,
1920
Nep141Token,
@@ -1088,6 +1089,43 @@ export const getFormattedEthLikeTokenConfig = (customCoinMap = coins): EthLikeTo
10881089
return acc;
10891090
}, []);
10901091

1092+
function getEthLikeERC721TokenConfig(coin: EthLikeERC721Token): EthLikeTokenConfig {
1093+
return {
1094+
type: coin.name,
1095+
coin: coin.name.split(':')[0].toLowerCase(),
1096+
network: coin.network.type === NetworkType.MAINNET ? 'Mainnet' : 'Testnet',
1097+
name: coin.fullName,
1098+
tokenContractAddress: coin.contractAddress.toString().toLowerCase(),
1099+
decimalPlaces: coin.decimalPlaces,
1100+
};
1101+
}
1102+
1103+
const getFormattedEthLikeERC721Tokens = (customCoinMap = coins) =>
1104+
customCoinMap.reduce((acc: EthLikeTokenConfig[], coin) => {
1105+
if (coin instanceof EthLikeERC721Token) {
1106+
acc.push(getEthLikeERC721TokenConfig(coin));
1107+
}
1108+
return acc;
1109+
}, []);
1110+
1111+
export const getEthLikeErc721Tokens = (network: 'Mainnet' | 'Testnet'): EthLikeTokenMap => {
1112+
const networkTokens = getFormattedEthLikeERC721Tokens().filter((token) => token.network === network);
1113+
const ethLikeTokenMap = {} as EthLikeTokenMap;
1114+
1115+
coins.forEach((coin) => {
1116+
if (coin instanceof AccountCoin && coin.features.includes(CoinFeature.SUPPORTS_ERC721)) {
1117+
const coinName = coin.family;
1118+
const coinNameForNetwork = network === 'Testnet' ? `t${coinName}` : coinName;
1119+
1120+
ethLikeTokenMap[coin.family] = {
1121+
tokens: networkTokens.filter((token) => token.coin === coinNameForNetwork),
1122+
};
1123+
}
1124+
});
1125+
1126+
return ethLikeTokenMap;
1127+
};
1128+
10911129
type EthLikeTokenMap = {
10921130
[K in CoinFamily]: { tokens: EthLikeTokenConfig[] };
10931131
};
@@ -1121,8 +1159,10 @@ export const getEthLikeTokens = (network: 'Mainnet' | 'Testnet'): EthLikeTokenMa
11211159
const getFormattedTokensByNetwork = (network: 'Mainnet' | 'Testnet', coinMap: typeof coins) => {
11221160
const networkType = network === 'Mainnet' ? NetworkType.MAINNET : NetworkType.TESTNET;
11231161
const ethLikeTokenMap = getEthLikeTokens(network);
1162+
const ethLikeErc721TokenMap = getEthLikeErc721Tokens(network);
11241163
return {
11251164
...ethLikeTokenMap,
1165+
...ethLikeErc721TokenMap,
11261166
eth: {
11271167
tokens: getFormattedErc20Tokens(coinMap).filter((token) => token.network === network),
11281168
nfts: getFormattedErc721Tokens(coinMap).filter((token) => token.network === network),
@@ -1387,6 +1427,8 @@ export function getFormattedTokenConfigForCoin(coin: Readonly<BaseCoin>): TokenC
13871427
return getFlrTokenConfig(coin);
13881428
} else if (coin instanceof EthLikeERC20Token) {
13891429
return getEthLikeTokenConfig(coin);
1430+
} else if (coin instanceof EthLikeERC721Token) {
1431+
return getEthLikeERC721TokenConfig(coin);
13901432
}
13911433
return undefined;
13921434
}

0 commit comments

Comments
 (0)