diff --git a/modules/statics/src/erc7984Registry.ts b/modules/statics/src/erc7984Registry.ts new file mode 100644 index 0000000000..a897c53fff --- /dev/null +++ b/modules/statics/src/erc7984Registry.ts @@ -0,0 +1,169 @@ +import { Networks } from './networks'; + +/** + * Per-network static configuration for an ERC-7984 wrapper↔underlying pair. + * + * wrapperAddress — the ERC-7984 confidential token contract (0x-prefixed lowercase hex) + * underlyingErc20Address — the plaintext ERC-20 that the wrapper holds in escrow + * (0x-prefixed lowercase hex; zero address means no real on-chain + * underlying, e.g. Hoodi test tokens) + * rate — underlying base units per 1 wrapper base unit (must be ≥ 1n). + * e.g. rate=1n means 1 underlying wei → 1 wrapper wei. + * requiresApprovalReset — true when the underlying ERC-20 follows the USDT pattern + * (must reset allowance to 0 before granting a new amount). + * isVetted — address pair has been reviewed and confirmed on-chain. + * isActive — pair is live and eligible for shield/unshield operations. + */ +export interface Erc7984WrapperPair { + readonly wrapperAddress: string; + readonly underlyingErc20Address: string; + readonly rate: bigint; + readonly requiresApprovalReset: boolean; + readonly isVetted: boolean; + readonly isActive: boolean; +} + +/** + * Registry keyed by network name → immutable list of wrapper pairs on that network. + */ +export type Erc7984Registry = Readonly>>; + +/** + * Known ERC-7984 wrapper↔underlying pairs. + * + * Keys are the network name strings from Networks (e.g. Networks.test.hoodi.name). + * All addresses are 0x-prefixed lowercase hex (42 chars total). + * + * Hoodi testnet pairs use the Zama cleartext FHE stack (chain ID 560048). + * Hoodi ACL: 0x6d3faf6f86e1ff9f3b0831dda920aba1cbd5bd68 + * + * Mainnet pairs use the production Zama fhEVM stack (Ethereum mainnet, chain ID 1). + */ +export const erc7984Registry: Erc7984Registry = Object.freeze({ + // Hoodi testnet — Zama cleartext FHE stack, chain ID 560048 + [Networks.test.hoodi.name]: Object.freeze([ + Object.freeze({ + // hteth:ctest1 ← wraps a Hoodi-only test ERC-20 (no real on-chain underlying) + wrapperAddress: '0x7b1d59bbcd291daa59cb6c8c5bc04de1afc4aba1', + underlyingErc20Address: '0x0000000000000000000000000000000000000000', + rate: 1n, + requiresApprovalReset: false, + isVetted: true, + isActive: true, + }), + Object.freeze({ + // hteth:cusdt ← wraps a Hoodi USDT-like test token (no real on-chain underlying) + wrapperAddress: '0x2debbe0487ef921df4457f9e36ed05be2df1ac75', + underlyingErc20Address: '0x0000000000000000000000000000000000000000', + rate: 1n, + requiresApprovalReset: true, + isVetted: true, + isActive: true, + }), + ]), + + // Ethereum mainnet — production Zama fhEVM stack, chain ID 1 + [Networks.main.ethereum.name]: Object.freeze([ + Object.freeze({ + // eth:czama ← wraps ZAMA + wrapperAddress: '0x80cb147fd86dc6dee3eee7e4cee33d1397d98071', + underlyingErc20Address: '0x33f06f4e13c2b3c47c1bee8b4b79d0c3bd35b7e8', + rate: 1n, + requiresApprovalReset: false, + isVetted: true, + isActive: true, + }), + Object.freeze({ + // eth:cxaut ← wraps XAUT (Tether Gold) + wrapperAddress: '0x73cc9af9d6befdb3c3faf8a5e8c05cb95fdaeef1', + underlyingErc20Address: '0x68749665ff8041ef9e851f1d30c9ead1e2e0d8fb', + rate: 1n, + requiresApprovalReset: false, + isVetted: true, + isActive: true, + }), + Object.freeze({ + // eth:ctgbp ← wraps TGBP (Tether GBP) + wrapperAddress: '0xa873750ccbafd5ec7dd13bfd5237d7129832edd9', + underlyingErc20Address: '0x00000000441378008ea67f4284a57932b1c000a5', + rate: 1n, + requiresApprovalReset: false, + isVetted: true, + isActive: true, + }), + Object.freeze({ + // eth:cweth ← wraps WETH + wrapperAddress: '0xda9396b82634ea99243ce51258b6a5ae512d4893', + underlyingErc20Address: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', + rate: 1n, + requiresApprovalReset: false, + isVetted: true, + isActive: true, + }), + Object.freeze({ + // eth:cusdt ← wraps USDT (requiresApprovalReset: USDT disallows non-zero → non-zero) + wrapperAddress: '0xae0207c757aa2b4019ad96edd0092ddc63ef0c50', + underlyingErc20Address: '0xdac17f958d2ee523a2206206994597c13d831ec7', + rate: 1n, + requiresApprovalReset: true, + isVetted: true, + isActive: true, + }), + Object.freeze({ + // eth:cusdc ← wraps USDC + wrapperAddress: '0xe978f22157048e5db8e5d07971376e86671672b2', + underlyingErc20Address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + rate: 1n, + requiresApprovalReset: false, + isVetted: true, + isActive: true, + }), + ]), +}); + +// Validate invariants at module load: catch bad data before any caller sees it. +(function validateRegistry() { + const addrRe = /^0x[0-9a-f]{40}$/; + for (const [networkName, pairs] of Object.entries(erc7984Registry)) { + const seen = new Set(); + for (const p of pairs) { + if (!addrRe.test(p.wrapperAddress)) { + throw new Error(`erc7984Registry[${networkName}]: invalid wrapperAddress '${p.wrapperAddress}'`); + } + if (!addrRe.test(p.underlyingErc20Address)) { + throw new Error( + `erc7984Registry[${networkName}]: invalid underlyingErc20Address '${p.underlyingErc20Address}'` + ); + } + if (p.rate < 1n) { + throw new Error(`erc7984Registry[${networkName}]: rate must be ≥ 1n for '${p.wrapperAddress}'`); + } + if (seen.has(p.wrapperAddress)) { + throw new Error(`erc7984Registry[${networkName}]: duplicate wrapperAddress '${p.wrapperAddress}'`); + } + seen.add(p.wrapperAddress); + } + } +})(); + +/** + * Look up the static configuration for a specific wrapper contract on a given network. + * + * @param networkName - value of EthereumNetwork.name (e.g. Networks.test.hoodi.name) + * @param wrapperAddress - ERC-7984 contract address; must be 0x-prefixed hex (case-insensitive) + * @returns the pair config, or undefined if not found + */ +export function getWrapperPair(networkName: string, wrapperAddress: string): Erc7984WrapperPair | undefined { + if (!wrapperAddress) return undefined; + const pairs = erc7984Registry[networkName]; + if (!pairs) return undefined; + const lower = wrapperAddress.toLowerCase(); + return pairs.find((p) => p.wrapperAddress === lower); +} + +/** + * Return all active, vetted wrapper pairs for a given network. + */ +export function getActiveWrapperPairs(networkName: string): ReadonlyArray { + return (erc7984Registry[networkName] ?? []).filter((p) => p.isVetted && p.isActive); +} diff --git a/modules/statics/src/index.ts b/modules/statics/src/index.ts index 3394ab8a39..59f0881269 100644 --- a/modules/statics/src/index.ts +++ b/modules/statics/src/index.ts @@ -62,3 +62,10 @@ export { generateErc20Token, generateTestErc20Token, } from './coins/generateERC20'; +export { + Erc7984WrapperPair, + Erc7984Registry, + erc7984Registry, + getWrapperPair, + getActiveWrapperPairs, +} from './erc7984Registry'; diff --git a/modules/statics/test/unit/erc7984Registry.ts b/modules/statics/test/unit/erc7984Registry.ts new file mode 100644 index 0000000000..cb2ef91205 --- /dev/null +++ b/modules/statics/test/unit/erc7984Registry.ts @@ -0,0 +1,178 @@ +import 'should'; +import { Networks } from '../../src'; +import { erc7984Registry, getWrapperPair, getActiveWrapperPairs, Erc7984WrapperPair } from '../../src/erc7984Registry'; + +describe('ERC-7984 Statics Registry', function () { + describe('erc7984Registry shape', function () { + it('should have an entry for Hoodi testnet', function () { + const pairs = erc7984Registry[Networks.test.hoodi.name]; + pairs.should.be.an.Array().and.not.be.empty(); + }); + + it('should have an entry for Ethereum mainnet', function () { + const pairs = erc7984Registry[Networks.main.ethereum.name]; + pairs.should.be.an.Array().and.not.be.empty(); + }); + + it('every pair has required fields with correct types', function () { + Object.values(erc7984Registry).forEach((pairs) => { + pairs.forEach((pair: Erc7984WrapperPair) => { + pair.wrapperAddress.should.be.a.String().and.match(/^0x[0-9a-f]{40}$/); + pair.underlyingErc20Address.should.be.a.String().and.match(/^0x[0-9a-f]{40}$/); + (typeof pair.rate).should.equal('bigint'); + pair.rate.should.be.greaterThanOrEqual(1n); + pair.requiresApprovalReset.should.be.a.Boolean(); + pair.isVetted.should.be.a.Boolean(); + pair.isActive.should.be.a.Boolean(); + }); + }); + }); + + it('wrapper addresses are unique within each network', function () { + Object.entries(erc7984Registry).forEach(([networkName, pairs]) => { + const addresses = pairs.map((p) => p.wrapperAddress); + const unique = new Set(addresses); + unique.size.should.equal(addresses.length, `duplicate wrapper address on ${networkName}`); + }); + }); + + it('registry is frozen (immutable at all levels)', function () { + Object.isFrozen(erc7984Registry).should.be.true(); + const hoodiPairs = erc7984Registry[Networks.test.hoodi.name]; + Object.isFrozen(hoodiPairs).should.be.true(); + Object.isFrozen(hoodiPairs[0]).should.be.true(); + const mainnetPairs = erc7984Registry[Networks.main.ethereum.name]; + Object.isFrozen(mainnetPairs).should.be.true(); + Object.isFrozen(mainnetPairs[0]).should.be.true(); + }); + }); + + describe('getWrapperPair', function () { + it('returns the correct Hoodi testnet pair for hteth:ctest1', function () { + const pair = getWrapperPair(Networks.test.hoodi.name, '0x7b1d59bbcd291daa59cb6c8c5bc04de1afc4aba1'); + pair.should.not.be.undefined(); + pair!.wrapperAddress.should.equal('0x7b1d59bbcd291daa59cb6c8c5bc04de1afc4aba1'); + pair!.rate.should.equal(1n); + pair!.isVetted.should.be.true(); + pair!.isActive.should.be.true(); + pair!.requiresApprovalReset.should.be.false(); + }); + + it('returns the correct Hoodi testnet pair for hteth:cusdt', function () { + const pair = getWrapperPair(Networks.test.hoodi.name, '0x2debbe0487ef921df4457f9e36ed05be2df1ac75'); + pair.should.not.be.undefined(); + pair!.requiresApprovalReset.should.be.true(); + }); + + it('is case-insensitive for the wrapper address', function () { + const lower = getWrapperPair(Networks.test.hoodi.name, '0x7b1d59bbcd291daa59cb6c8c5bc04de1afc4aba1'); + const mixed = getWrapperPair(Networks.test.hoodi.name, '0x7B1D59BbCD291daA59CB6C8C5bC04DE1aFC4ABA1'); + lower.should.not.be.undefined(); + mixed.should.not.be.undefined(); + lower!.wrapperAddress.should.equal(mixed!.wrapperAddress); + }); + + it('returns undefined for an unknown network', function () { + const pair = getWrapperPair('unknown-network', '0x7b1d59bbcd291daa59cb6c8c5bc04de1afc4aba1'); + (pair === undefined).should.be.true(); + }); + + it('returns undefined for an address not in the registry', function () { + const pair = getWrapperPair(Networks.test.hoodi.name, '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef'); + (pair === undefined).should.be.true(); + }); + + it('returns undefined for an empty wrapperAddress', function () { + const pair = getWrapperPair(Networks.test.hoodi.name, ''); + (pair === undefined).should.be.true(); + }); + + it('returns undefined for a null-like wrapperAddress without crashing', function () { + // Simulate JS caller passing null (bypasses TypeScript) + const pair = getWrapperPair(Networks.test.hoodi.name, null as unknown as string); + (pair === undefined).should.be.true(); + }); + + it('returns the correct mainnet pair for eth:cusdt', function () { + const pair = getWrapperPair(Networks.main.ethereum.name, '0xae0207c757aa2b4019ad96edd0092ddc63ef0c50'); + pair.should.not.be.undefined(); + pair!.underlyingErc20Address.should.equal('0xdac17f958d2ee523a2206206994597c13d831ec7'); + pair!.requiresApprovalReset.should.be.true(); + }); + + it('returns the correct mainnet pair for eth:cusdc', function () { + const pair = getWrapperPair(Networks.main.ethereum.name, '0xe978f22157048e5db8e5d07971376e86671672b2'); + pair.should.not.be.undefined(); + pair!.underlyingErc20Address.should.equal('0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'); + pair!.requiresApprovalReset.should.be.false(); + }); + }); + + describe('getActiveWrapperPairs', function () { + it('returns only vetted and active pairs for Hoodi', function () { + const pairs = getActiveWrapperPairs(Networks.test.hoodi.name); + pairs.should.be.an.Array().and.not.be.empty(); + pairs.every((p) => p.isVetted && p.isActive).should.be.true(); + }); + + it('returns only vetted and active pairs for Ethereum mainnet', function () { + const pairs = getActiveWrapperPairs(Networks.main.ethereum.name); + pairs.should.be.an.Array().and.not.be.empty(); + pairs.every((p) => p.isVetted && p.isActive).should.be.true(); + }); + + it('returns empty array for an unknown network', function () { + const pairs = getActiveWrapperPairs('no-such-network'); + pairs.should.be.an.Array().and.be.empty(); + }); + + it('excludes pairs with isVetted=false', function () { + // Build an in-memory registry with a non-vetted pair alongside a vetted one + const testPairs: ReadonlyArray = [ + { + wrapperAddress: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + underlyingErc20Address: '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + rate: 1n, + requiresApprovalReset: false, + isVetted: false, + isActive: true, + }, + { + wrapperAddress: '0xcccccccccccccccccccccccccccccccccccccccc', + underlyingErc20Address: '0xdddddddddddddddddddddddddddddddddddddddd', + rate: 1n, + requiresApprovalReset: false, + isVetted: true, + isActive: true, + }, + ]; + const result = testPairs.filter((p) => p.isVetted && p.isActive); + result.length.should.equal(1); + result[0].wrapperAddress.should.equal('0xcccccccccccccccccccccccccccccccccccccccc'); + }); + + it('excludes pairs with isActive=false', function () { + const testPairs: ReadonlyArray = [ + { + wrapperAddress: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + underlyingErc20Address: '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + rate: 1n, + requiresApprovalReset: false, + isVetted: true, + isActive: false, + }, + { + wrapperAddress: '0xcccccccccccccccccccccccccccccccccccccccc', + underlyingErc20Address: '0xdddddddddddddddddddddddddddddddddddddddd', + rate: 1n, + requiresApprovalReset: false, + isVetted: true, + isActive: true, + }, + ]; + const result = testPairs.filter((p) => p.isVetted && p.isActive); + result.length.should.equal(1); + result[0].wrapperAddress.should.equal('0xcccccccccccccccccccccccccccccccccccccccc'); + }); + }); +});