diff --git a/modules/statics/src/account.ts b/modules/statics/src/account.ts index 5cbc9929d3..2ecb4d1ae8 100644 --- a/modules/statics/src/account.ts +++ b/modules/statics/src/account.ts @@ -203,6 +203,14 @@ export interface Erc721ConstructorOptions extends AccountConstructorOptions { export interface Erc7984ConstructorOptions extends AccountConstructorOptions { contractAddress: string; + /** Plain ERC-20 locked in the wrapper as escrow. */ + underlyingErc20Address: string; + /** On-chain `rate()` as a decimal string (uint256). */ + rate: string; + /** When true, non-zero→non-zero approve may revert (USDT-style); WP must approve(0) first. */ + requiresApprovalReset: boolean; + /** When true, pair is approved for shield/unshield + indexer publish filters. */ + isVetted: boolean; } export interface NFTCollectionIdConstructorOptions extends AccountConstructorOptions { @@ -415,11 +423,23 @@ export class Erc1155Coin extends ContractAddressDefinedToken {} * Token balances are stored as FHE-encrypted ciphertexts; transfers use confidentialTransfer() * instead of the standard ERC-20 transfer(). Balance reads require delegated decryption via ACL. * + * Wrapper metadata (`underlyingErc20Address`, `rate`, `requiresApprovalReset`, `isVetted`) lives + * on the coin — same pattern as `Nep141Token.storageDepositAmount` / `XrpMptCoin.canTransfer`. + * * {@link https://eips.ethereum.org/EIPS/eip-7984 EIP-7984} */ export class Erc7984Coin extends ContractAddressDefinedToken { + public underlyingErc20Address: string; + public rate: string; + public requiresApprovalReset: boolean; + public isVetted: boolean; + constructor(options: Erc7984ConstructorOptions) { super(options); + this.underlyingErc20Address = options.underlyingErc20Address.toLowerCase(); + this.rate = options.rate; + this.requiresApprovalReset = options.requiresApprovalReset; + this.isVetted = options.isVetted; } } @@ -1259,13 +1279,17 @@ export function terc20( * @param name unique identifier of the token (e.g. 'eth:ctkn') * @param fullName Complete human-readable name of the token * @param decimalPlaces Number of decimal places this token supports (divisibility exponent) - * @param contractAddress Contract address of this token + * @param contractAddress Contract address of this token (wrapper) + * @param underlyingErc20Address Plain ERC-20 locked in the wrapper as escrow + * @param rate On-chain `rate()` as a decimal string (uint256) + * @param requiresApprovalReset USDT-style approve(0) reset required before approve(amount) * @param asset Asset which this coin represents. This is the same for both mainnet and testnet variants of a coin. * @param features? Features of this coin. Defaults to ERC7984_TOKEN_FEATURES * @param prefix? Optional token prefix. Defaults to empty string * @param suffix? Optional token suffix. Defaults to token name. * @param network? Optional token network. Defaults to Ethereum main network. * @param primaryKeyCurve The elliptic curve for this chain/token + * @param isVetted? When true, approved for shield/unshield + indexer publish. Defaults to true. */ export function erc7984( id: string, @@ -1273,12 +1297,16 @@ export function erc7984( fullName: string, decimalPlaces: number, contractAddress: string, + underlyingErc20Address: string, + rate: string, + requiresApprovalReset: boolean, asset: UnderlyingAsset, features: CoinFeature[] = ERC7984_TOKEN_FEATURES, prefix = '', suffix: string = name.toUpperCase(), network: EthereumNetwork = Networks.main.ethereum, - primaryKeyCurve: KeyCurve = KeyCurve.Secp256k1 + primaryKeyCurve: KeyCurve = KeyCurve.Secp256k1, + isVetted = true ) { return Object.freeze( new Erc7984Coin({ @@ -1287,6 +1315,10 @@ export function erc7984( fullName, network, contractAddress, + underlyingErc20Address, + rate, + requiresApprovalReset, + isVetted, prefix, suffix, features, @@ -1306,13 +1338,17 @@ export function erc7984( * @param name unique identifier of the token (e.g. 'hteth:ctkn') * @param fullName Complete human-readable name of the token * @param decimalPlaces Number of decimal places this token supports (divisibility exponent) - * @param contractAddress Contract address of this token + * @param contractAddress Contract address of this token (wrapper) + * @param underlyingErc20Address Plain ERC-20 locked in the wrapper as escrow + * @param rate On-chain `rate()` as a decimal string (uint256) + * @param requiresApprovalReset USDT-style approve(0) reset required before approve(amount) * @param asset Asset which this coin represents. This is the same for both mainnet and testnet variants of a coin. * @param features? Features of this coin. Defaults to ERC7984_TOKEN_FEATURES * @param prefix? Optional token prefix. Defaults to empty string * @param suffix? Optional token suffix. Defaults to token name. * @param network? Optional token network. Defaults to Hoodi test network. * @param primaryKeyCurve The elliptic curve for this chain/token + * @param isVetted? When true, approved for shield/unshield + indexer publish. Defaults to true. */ export function terc7984( id: string, @@ -1320,12 +1356,16 @@ export function terc7984( fullName: string, decimalPlaces: number, contractAddress: string, + underlyingErc20Address: string, + rate: string, + requiresApprovalReset: boolean, asset: UnderlyingAsset, features: CoinFeature[] = ERC7984_TOKEN_FEATURES, prefix = '', suffix: string = name.toUpperCase(), network: EthereumNetwork = Networks.test.hoodi, - primaryKeyCurve: KeyCurve = KeyCurve.Secp256k1 + primaryKeyCurve: KeyCurve = KeyCurve.Secp256k1, + isVetted = true ) { return erc7984( id, @@ -1333,12 +1373,16 @@ export function terc7984( fullName, decimalPlaces, contractAddress, + underlyingErc20Address, + rate, + requiresApprovalReset, asset, features, prefix, suffix, network, - primaryKeyCurve + primaryKeyCurve, + isVetted ); } diff --git a/modules/statics/src/coins/erc7984Tokens.ts b/modules/statics/src/coins/erc7984Tokens.ts index 3862419496..b0acfad672 100644 --- a/modules/statics/src/coins/erc7984Tokens.ts +++ b/modules/statics/src/coins/erc7984Tokens.ts @@ -8,13 +8,12 @@ import { UnderlyingAsset } from '../base'; * Balances are stored as encrypted ciphertexts; plaintext amounts require ACL-delegated * decryption via the Zama Gateway before they can be displayed. * + * Each entry is a wrapper↔underlying pair. `rate`, `requiresApprovalReset`, and `isVetted` + * are taken from on-chain `rate()` / known approve semantics / vetting status. + * * Testnet tokens (hteth:*) are deployed on Hoodi using Zama's cleartext FHE stack. * Hoodi ACL: 0x6D3FAf6f86e1fF9F3B0831Dda920AbA1cBd5bd68 (Networks.test.hoodi.zamaAclContractAddress) * Hoodi RPC: https://rpc.hoodi.ethpandaops.io (chain ID 560048) - * - * Sandbox development contracts (Sepolia, real FHE stack): - * CTKN: 0x94167129172A35ab093B44b8b96213DDbc3cD387 - * cUSDT: 0x4E7B06D78965594eB5EF5414c357ca21E1554491 */ export const erc7984Tokens = [ // Mainnet tokens @@ -24,6 +23,9 @@ export const erc7984Tokens = [ 'Confidential Zama', 6, '0x80cb147fd86dc6dee3eee7e4cee33d1397d98071', // https://etherscan.io/token/0x80CB147Fd86dC6dEe3Eee7e4Cee33d1397d98071 + '0xa12cc123ba206d4031d1c7f6223d1c2ec249f4f3', + '1000000000000', // 1e12 + false, UnderlyingAsset['eth:czama'] ), erc7984( @@ -32,6 +34,9 @@ export const erc7984Tokens = [ 'Zama Confidential XAUT', 6, '0x73cc9af9d6befdb3c3faf8a5e8c05cb95fdaeef1', // https://etherscan.io/token/0x73cc9aF9d6BEFdb3c3fAf8a5E8c05Cb95FdaEEf1 + '0x68749665ff8d2d112fa859aa293f07a622782f38', + '1', + false, UnderlyingAsset['eth:cxaut'] ), erc7984( @@ -40,6 +45,9 @@ export const erc7984Tokens = [ 'Zama Confidential TGBP', 6, '0xa873750ccbafd5ec7dd13bfd5237d7129832edd9', // https://etherscan.io/token/0xa873750ccBafD5ec7Dd13bfD5237d7129832eDD9 + '0x27f6c8289550fce67f6b50bed1f519966afe5287', + '1000000000000', // 1e12 + false, UnderlyingAsset['eth:ctgbp'] ), erc7984( @@ -48,6 +56,9 @@ export const erc7984Tokens = [ 'Zama Confidential WETH', 6, '0xda9396b82634ea99243ce51258b6a5ae512d4893', // https://etherscan.io/token/0xda9396b82634Ea99243cE51258B6A5Ae512D4893 + '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', // WETH + '1000000000000', // 1e12 + false, UnderlyingAsset['eth:cweth'] ), erc7984( @@ -56,6 +67,9 @@ export const erc7984Tokens = [ 'Zama USDT', 6, '0xae0207c757aa2b4019ad96edd0092ddc63ef0c50', // https://etherscan.io/token/0xAe0207C757Aa2B4019Ad96edD0092ddc63EF0c50 + '0xdac17f958d2ee523a2206206994597c13d831ec7', // USDT + '1', + true, UnderlyingAsset['eth:cusdt'] ), erc7984( @@ -64,6 +78,9 @@ export const erc7984Tokens = [ 'Zama USDC', 6, '0xe978f22157048e5db8e5d07971376e86671672b2', // https://etherscan.io/token/0xe978f22157048e5db8e5d07971376e86671672b2 + '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', // USDC + '1', + false, UnderlyingAsset['eth:cusdc'] ), @@ -74,6 +91,9 @@ export const erc7984Tokens = [ 'Zama Token Test 1', 6, '0x7b1d59bbcd291daa59cb6c8c5bc04de1afc4aba1', + '0x7740f913dc24d4f9e1a72531372c3170452b2f87', + '1000000000000', // 1e12 + false, UnderlyingAsset['hteth:ctest1'] ), terc7984( @@ -82,6 +102,9 @@ export const erc7984Tokens = [ 'Zama USDT', 6, '0x2debbe0487ef921df4457f9e36ed05be2df1ac75', + '0x51a63b5621d78de54d2f4d098a23a5a69e76f30b', + '1', + false, UnderlyingAsset['hteth:cusdt'] ), ]; diff --git a/modules/statics/src/tokenConfig.ts b/modules/statics/src/tokenConfig.ts index e49ad7eeb3..8874d624d3 100644 --- a/modules/statics/src/tokenConfig.ts +++ b/modules/statics/src/tokenConfig.ts @@ -70,7 +70,12 @@ export type EosTokenConfig = BaseContractAddressConfig & { contractAddress: string; }; export type Erc20TokenConfig = BaseContractAddressConfig; -export type Erc7984TokenConfig = BaseContractAddressConfig; +export type Erc7984TokenConfig = BaseContractAddressConfig & { + underlyingErc20Address: string; + rate: string; + requiresApprovalReset: boolean; + isVetted: boolean; +}; export type TrxTokenConfig = BaseContractAddressConfig; export type StellarTokenConfig = BaseNetworkConfig; @@ -386,6 +391,10 @@ function getErc7984TokenConfig(coin: Erc7984Coin): Erc7984TokenConfig { name: coin.fullName, tokenContractAddress: coin.contractAddress.toString().toLowerCase(), decimalPlaces: coin.decimalPlaces, + underlyingErc20Address: coin.underlyingErc20Address.toLowerCase(), + rate: coin.rate, + requiresApprovalReset: coin.requiresApprovalReset, + isVetted: coin.isVetted, }; } diff --git a/modules/statics/test/unit/erc7984Tokens.ts b/modules/statics/test/unit/erc7984Tokens.ts new file mode 100644 index 0000000000..ca0b9752e9 --- /dev/null +++ b/modules/statics/test/unit/erc7984Tokens.ts @@ -0,0 +1,44 @@ +import 'should'; +import { coins, Erc7984Coin, Networks } from '../../src'; + +describe('ERC-7984 wrapper metadata on Erc7984Coin', function () { + function getErc7984(name: string): Erc7984Coin { + const coin = coins.get(name); + should.exist(coin); + (coin instanceof Erc7984Coin).should.equal(true); + return coin as Erc7984Coin; + } + + it('includes a vetted Hoodi test pair', function () { + const hoodi = getErc7984('hteth:cusdt'); + hoodi.network.name.should.equal(Networks.test.hoodi.name); + hoodi.isVetted.should.equal(true); + }); + + it('stores on-chain underlying and rate for Hoodi cUSDT', function () { + const coin = getErc7984('hteth:cusdt'); + coin.contractAddress.should.equal('0x2debbe0487ef921df4457f9e36ed05be2df1ac75'); + coin.underlyingErc20Address.should.equal('0x51a63b5621d78de54d2f4d098a23a5a69e76f30b'); + coin.rate.should.equal('1'); + coin.requiresApprovalReset.should.equal(false); + }); + + it('marks mainnet USDT wrapper as requiring approval reset', function () { + const coin = getErc7984('eth:cusdt'); + coin.underlyingErc20Address.should.equal('0xdac17f958d2ee523a2206206994597c13d831ec7'); + coin.requiresApprovalReset.should.equal(true); + coin.rate.should.equal('1'); + }); + + it('exposes non-1 rates for wrappers that scale decimals', function () { + const coin = getErc7984('eth:cweth'); + coin.rate.should.equal('1000000000000'); + coin.underlyingErc20Address.should.equal('0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2'); + }); + + it('lowercases addresses on construction', function () { + const coin = getErc7984('eth:cusdc'); + coin.contractAddress.should.equal(coin.contractAddress.toLowerCase()); + coin.underlyingErc20Address.should.equal(coin.underlyingErc20Address.toLowerCase()); + }); +});