diff --git a/docs/flows/chain-onboarding.md b/docs/flows/chain-onboarding.md index ec4ea51..2113152 100644 --- a/docs/flows/chain-onboarding.md +++ b/docs/flows/chain-onboarding.md @@ -10,8 +10,10 @@ No dedicated sequence diagram for this flow. The architecture diagram in the roo ## Steps 1. **protocol-core — chain config.** Create `packages/protocol-core/src/chains/.ts` - exporting a `ChainConfig` with chain id, name, native token, wrapped native, supported - protocol versions, schema variant, and stablecoin list. + exporting a `ChainConfig` with chain id, name, native symbol, wrapped native, supported + protocol versions, and stablecoin list. Add the chain-level `multicall` address and one + `deployments` entry per protocol version the chain runs. Both fields are optional, but a + chain without `deployments` resolves to `undefined` from `getDeployment`. 2. **protocol-core — registry.** Import and register the new config in `packages/protocol-core/src/chains/registry.ts` (both `_registry` and `CHAIN_ID`). 3. **protocol-core — barrel.** Re-export the new chain config from @@ -26,7 +28,9 @@ No dedicated sequence diagram for this flow. The architecture diagram in the roo ## Inputs -- Chain id, factory deployment address, native + wrapped native metadata, stablecoin list. +- Chain id, native + wrapped native metadata, stablecoin list, multicall address, and the + contract addresses each protocol version deploys — factory and swap router for every family, + plus quoter, position manager and pool deployer where that family declares them. ## Outputs diff --git a/packages/protocol-core/README.md b/packages/protocol-core/README.md index 5a090e7..9de187c 100644 --- a/packages/protocol-core/README.md +++ b/packages/protocol-core/README.md @@ -29,11 +29,32 @@ const stables = getStablecoins(137) const feeAmount = computeV2Fee(1_000_000n) // V2 swap fee in raw units ``` +## Contract deployments + +A chain config may carry a version-agnostic `multicall` address and a `deployments` +list. Each entry is discriminated by protocol version, so `getDeployment` returns +exactly the contracts that family deploys. + +```ts +import { getDeployment, PROTOCOL_VERSIONS } from '@quickswap-defi/protocol-core' + +const algebra = getDeployment(137, PROTOCOL_VERSIONS.V3) +algebra?.poolDeployer // Algebra derives pools from a pool deployer + +const uniswapFork = getDeployment(169, PROTOCOL_VERSIONS.UNIV3) +uniswapFork?.factory // the Uniswap-V3 fork derives pools from the factory +``` + +Both fields are optional: a chain config without them is still valid, and +`getDeployment` yields `undefined` for any version a chain does not deploy. + ## Public API - **Registry** — `CHAIN_REGISTRY`, `CHAIN_ID`, `getChain`, `getChainOrThrow`, `getSupportedChainIds` +- **Deployments** — `getDeployment` - **Per-chain configs** — `POLYGON`, `BASE`, `MANTRA`, `MANTA`, `SONEIUM`, `SOMNIA`, `IMX`, `XLAYER`, `ETHEREUM` - **Types** — `ChainConfig`, `ChainProtocolEntry`, `ProtocolVersion`, `SchemaVariant`, `TokenInfo` +- **Deployment types** — `ProtocolDeployment`, `V2Deployment`, `V3Deployment`, `V4Deployment`, `UniV3Deployment`, `DeploymentFor` - **Constants** — `PROTOCOL_VERSIONS`, `SCHEMA_VARIANTS` - **Protocol helpers** — `getSchemaVariant`, `getSupportedVersions`, `getProtocolVersionLabel` - **Fees** — `V2_FEE_BPS`, `V2_FEE_RATE`, `computeV2Fee` diff --git a/packages/protocol-core/scripts/validate-addresses.ts b/packages/protocol-core/scripts/validate-addresses.ts index 0746ee7..7d00425 100644 --- a/packages/protocol-core/scripts/validate-addresses.ts +++ b/packages/protocol-core/scripts/validate-addresses.ts @@ -1,5 +1,6 @@ import { keccak_256 } from '@noble/hashes/sha3' import { getSupportedChainIds, getChain } from '../src/chains/registry' +import { checkDeploymentCoherence } from '../src/chains/deploymentCoherence' function toChecksumAddress(address: string): string { const addr = address.toLowerCase().replace('0x', '') @@ -68,10 +69,12 @@ for (const chainId of chainIds) { ) } } + + errors.push(...checkDeploymentCoherence(chain)) } if (errors.length > 0) { - console.error(`\n❌ ${errors.length} invalid EIP-55 checksum(s) found:\n`) + console.error(`\n❌ ${errors.length} address validation error(s) found:\n`) errors.forEach((e) => console.error(` • ${e}`)) console.error('') process.exit(1) diff --git a/packages/protocol-core/src/__tests__/chains/chain-data.test.ts b/packages/protocol-core/src/__tests__/chains/chain-data.test.ts index 9f0bf1b..2057137 100644 --- a/packages/protocol-core/src/__tests__/chains/chain-data.test.ts +++ b/packages/protocol-core/src/__tests__/chains/chain-data.test.ts @@ -8,6 +8,8 @@ import { SOMNIA } from '../../chains/somnia' import { IMX } from '../../chains/imx' import { XLAYER } from '../../chains/xlayer' import { ETHEREUM } from '../../chains/ethereum' +import { CHAIN_REGISTRY } from '../../chains/registry' +import { checkDeploymentCoherence } from '../../chains/deploymentCoherence' import type { ChainConfig } from '../../chains/types' const ALL_CHAINS: ChainConfig[] = [ @@ -22,6 +24,23 @@ const ALL_CHAINS: ChainConfig[] = [ ETHEREUM, ] +const CHAINS_WITH_DEPLOYMENTS = ALL_CHAINS.filter((chain) => chain.deployments !== undefined) + +const CHAINS_WITH_MULTICALL = ALL_CHAINS.filter((chain) => chain.multicall !== undefined) + +const UNISWAP_FORK_ADDRESSES = [ + { + chain: MANTA, + factory: '0x56c2162254b0E4417288786eE402c2B41d4e181e', + positionManager: '0xa5E0829CaCEd8fFDD4De3c43696c57F7D7A678ff', + }, + { + chain: IMX, + factory: '0x56c2162254b0E4417288786eE402c2B41d4e181e', + positionManager: '0xa5E0829CaCEd8fFDD4De3c43696c57F7D7A678ff', + }, +] + describe('The chain configuration data', () => { describe('basic fields', () => { it.each(ALL_CHAINS)('$name has positive chainId', (chain) => { @@ -119,6 +138,81 @@ describe('The chain configuration data', () => { }) }) + describe('The deployment fixtures', () => { + it('populates deployment data on more than one chain', () => { + expect(CHAINS_WITH_DEPLOYMENTS.length).toBeGreaterThan(1) + }) + + it('lists the Polygon entries in the order its protocols declare them', () => { + const declaredOrder = POLYGON.protocols.map((protocol) => protocol.version) + const deployedOrder = POLYGON.deployments!.map((deployment) => deployment.version) + + expect(deployedOrder).toEqual(declaredOrder) + }) + + it.each([MANTA, IMX])('gives $name the single Uniswap-V3 fork entry it declares', (chain) => { + const versions = chain.deployments!.map((deployment) => deployment.version) + + expect(versions).toEqual(['univ3']) + }) + + it('limits the Polygon v2 entry to the contracts that family deploys', () => { + const v2 = POLYGON.deployments!.find((deployment) => deployment.version === 'v2')! + + expect(Object.keys(v2).sort()).toEqual(['factory', 'swapRouter', 'version']) + }) + + it('gives the Polygon Algebra entry a pool deployer', () => { + const v3 = POLYGON.deployments!.find((deployment) => deployment.version === 'v3')! + + expect(v3).toHaveProperty('poolDeployer') + }) + + it.each([MANTA, IMX])('leaves the $name entry without a pool deployer', (chain) => { + const univ3 = chain.deployments!.find((deployment) => deployment.version === 'univ3')! + + expect(Object.keys(univ3)).not.toContain('poolDeployer') + }) + + // Manta Pacific and Immutable zkEVM deploy the same factory and the same + // position manager. Both values match the contracts deployed on each chain. + it.each(UNISWAP_FORK_ADDRESSES)( + 'pins the $chain.name factory and position manager', + ({ chain, factory, positionManager }) => { + const univ3 = chain.deployments!.find((deployment) => deployment.version === 'univ3')! + + expect(univ3.factory).toBe(factory) + expect(univ3).toHaveProperty('positionManager', positionManager) + }, + ) + }) + + describe('The multicall addresses', () => { + it('populates a multicall address on more than one chain', () => { + expect(CHAINS_WITH_MULTICALL.length).toBeGreaterThan(1) + }) + + it.each(CHAINS_WITH_MULTICALL)('gives $name a multicall address', (chain) => { + expect(chain.multicall).toMatch(/^0x[0-9a-fA-F]{40}$/) + }) + }) + + describe('The registry deployment coherence', () => { + const REGISTERED_CHAINS = Object.values(CHAIN_REGISTRY) + + it('exercises the coherence rules against real deployment data', () => { + const carrying = REGISTERED_CHAINS.filter((chain) => chain.deployments !== undefined) + + expect(carrying.length).toBeGreaterThan(1) + }) + + it.each(REGISTERED_CHAINS)('keeps $name coherent with the protocols it declares', (chain) => { + const violations = checkDeploymentCoherence(chain) + + expect(violations).toEqual([]) + }) + }) + describe('Ethereum stablecoins', () => { it('Ethereum includes USDC', () => { const symbols = ETHEREUM.stablecoins.map((s) => s.symbol) diff --git a/packages/protocol-core/src/__tests__/chains/deploymentCoherence.test.ts b/packages/protocol-core/src/__tests__/chains/deploymentCoherence.test.ts new file mode 100644 index 0000000..cd5810a --- /dev/null +++ b/packages/protocol-core/src/__tests__/chains/deploymentCoherence.test.ts @@ -0,0 +1,193 @@ +import { describe, it, expect } from 'vitest' +import { checkDeploymentCoherence } from '../../chains/deploymentCoherence' +import { + PROTOCOL_VERSIONS, + type ChainConfig, + type ProtocolDeployment, +} from '../../chains/types' + +const FACTORY = '0x411b0fAcC3489691f28ad58c47006AF5E3Ab3A28' +const ROUTER = '0xa5E0829CaCEd8fFDD4De3c43696c57F7D7A678ff' +const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000' +const NATIVE_SENTINEL_LOWERCASE = '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee' +const NATIVE_SENTINEL_CHECKSUMMED = '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE' + +function buildChain(overrides: Partial = {}): ChainConfig { + return { + chainId: 137, + name: 'Test Chain', + nativeSymbol: 'POL', + wrappedNative: { address: FACTORY, symbol: 'WPOL', decimals: 18 }, + protocols: [{ version: PROTOCOL_VERSIONS.V2, exposeDynamicFee: false }], + stablecoins: [], + ...overrides, + } +} + +const COHERENT_V2_ENTRY = { + version: PROTOCOL_VERSIONS.V2, + factory: FACTORY, + swapRouter: ROUTER, +} as const + +/** One entry per family with every address slot filled by a sentinel. */ +const ALL_SENTINEL_ENTRIES: ReadonlyArray = [ + { version: PROTOCOL_VERSIONS.V2, factory: ZERO_ADDRESS, swapRouter: ZERO_ADDRESS }, + { + version: PROTOCOL_VERSIONS.V3, + factory: ZERO_ADDRESS, + swapRouter: ZERO_ADDRESS, + quoter: ZERO_ADDRESS, + positionManager: ZERO_ADDRESS, + poolDeployer: ZERO_ADDRESS, + }, + { + version: PROTOCOL_VERSIONS.V4, + factory: ZERO_ADDRESS, + swapRouter: ZERO_ADDRESS, + quoter: ZERO_ADDRESS, + positionManager: ZERO_ADDRESS, + poolDeployer: ZERO_ADDRESS, + }, + { + version: PROTOCOL_VERSIONS.UNIV3, + factory: ZERO_ADDRESS, + swapRouter: ZERO_ADDRESS, + quoter: ZERO_ADDRESS, + positionManager: ZERO_ADDRESS, + }, +] + +describe('The deployment coherence check', () => { + describe('when a chain is coherent', () => { + it('accepts a chain that declares no deployments', () => { + const chain = buildChain() + + expect(checkDeploymentCoherence(chain)).toEqual([]) + }) + + it('accepts a deployment whose version the chain declares', () => { + const chain = buildChain({ deployments: [COHERENT_V2_ENTRY] }) + + expect(checkDeploymentCoherence(chain)).toEqual([]) + }) + + it('accepts a real multicall address', () => { + const chain = buildChain({ multicall: ROUTER }) + + expect(checkDeploymentCoherence(chain)).toEqual([]) + }) + }) + + describe('when a deployment contradicts the chain', () => { + it('rejects a version the chain never declares', () => { + const chain = buildChain({ + deployments: [ + { + version: PROTOCOL_VERSIONS.V3, + factory: FACTORY, + swapRouter: ROUTER, + quoter: FACTORY, + positionManager: ROUTER, + poolDeployer: FACTORY, + }, + ], + }) + + const violations = checkDeploymentCoherence(chain) + + expect(violations).toHaveLength(1) + expect(violations[0]).toContain('Test Chain (137)') + expect(violations[0]).toContain('deployments[0]') + expect(violations[0]).toContain(PROTOCOL_VERSIONS.V3) + }) + + it('rejects the same version declared twice on one chain', () => { + const chain = buildChain({ deployments: [COHERENT_V2_ENTRY, COHERENT_V2_ENTRY] }) + + const violations = checkDeploymentCoherence(chain) + + expect(violations).toHaveLength(1) + expect(violations[0]).toContain('deployments[1]') + expect(violations[0]).toContain(PROTOCOL_VERSIONS.V2) + }) + }) + + describe('when an address slot holds a placeholder', () => { + it('rejects the all-zero address and names the slot that holds it', () => { + const chain = buildChain({ + deployments: [{ version: PROTOCOL_VERSIONS.V2, factory: ZERO_ADDRESS, swapRouter: ROUTER }], + }) + + const violations = checkDeploymentCoherence(chain) + + expect(violations).toHaveLength(1) + expect(violations[0]).toContain('deployments[0].factory') + expect(violations[0]).toContain(ZERO_ADDRESS) + }) + + it.each([NATIVE_SENTINEL_LOWERCASE, NATIVE_SENTINEL_CHECKSUMMED])( + 'rejects the native-token placeholder written as %s', + (sentinel) => { + const chain = buildChain({ + deployments: [{ version: PROTOCOL_VERSIONS.V2, factory: FACTORY, swapRouter: sentinel }], + }) + + const violations = checkDeploymentCoherence(chain) + + expect(violations).toHaveLength(1) + expect(violations[0]).toContain('deployments[0].swapRouter') + }, + ) + + it('rejects a placeholder multicall address', () => { + const chain = buildChain({ multicall: ZERO_ADDRESS }) + + const violations = checkDeploymentCoherence(chain) + + expect(violations).toHaveLength(1) + expect(violations[0]).toContain('multicall') + expect(violations[0]).toContain(ZERO_ADDRESS) + }) + + it('rejects a placeholder multicall even when the chain declares no deployments', () => { + const chain = buildChain({ multicall: NATIVE_SENTINEL_CHECKSUMMED, deployments: undefined }) + + const violations = checkDeploymentCoherence(chain) + + expect(violations).toHaveLength(1) + expect(violations[0]).toContain('multicall') + }) + + it.each(ALL_SENTINEL_ENTRIES)( + 'inspects every address slot the $version family declares', + (entry) => { + const chain = buildChain({ + protocols: [{ version: entry.version, exposeDynamicFee: false }], + deployments: [entry], + }) + const addressSlotCount = Object.keys(entry).length - 1 + + const violations = checkDeploymentCoherence(chain) + + expect(violations).toHaveLength(addressSlotCount) + }, + ) + }) + + describe('when a chain breaks several rules at once', () => { + it('reports one message per violation', () => { + const chain = buildChain({ + multicall: ZERO_ADDRESS, + deployments: [ + COHERENT_V2_ENTRY, + { version: PROTOCOL_VERSIONS.V2, factory: ZERO_ADDRESS, swapRouter: ROUTER }, + ], + }) + + const violations = checkDeploymentCoherence(chain) + + expect(violations).toHaveLength(3) + }) + }) +}) diff --git a/packages/protocol-core/src/__tests__/chains/deployments.test-d.ts b/packages/protocol-core/src/__tests__/chains/deployments.test-d.ts new file mode 100644 index 0000000..7de4013 --- /dev/null +++ b/packages/protocol-core/src/__tests__/chains/deployments.test-d.ts @@ -0,0 +1,12 @@ +import { describe, it, expectTypeOf } from 'vitest' +import { getDeployment } from '../../chains/deployments' +import { PROTOCOL_VERSIONS, type V3Deployment, type UniV3Deployment } from '../../chains/types' + +describe('The deployment lookup', () => { + it('narrows its result to the family of the version it was given', () => { + expectTypeOf(getDeployment(137, PROTOCOL_VERSIONS.V3)).toEqualTypeOf() + expectTypeOf(getDeployment(169, PROTOCOL_VERSIONS.UNIV3)).toEqualTypeOf< + UniV3Deployment | undefined + >() + }) +}) diff --git a/packages/protocol-core/src/__tests__/chains/deployments.test.ts b/packages/protocol-core/src/__tests__/chains/deployments.test.ts new file mode 100644 index 0000000..62e4020 --- /dev/null +++ b/packages/protocol-core/src/__tests__/chains/deployments.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect } from 'vitest' +import { getDeployment } from '../../chains/deployments' +import { PROTOCOL_VERSIONS } from '../../chains/types' + +const POLYGON_ID = 137 +const MANTA_ID = 169 +const IMX_ID = 13371 +const ETHEREUM_ID = 1 +const UNREGISTERED_ID = 99999 + +describe('The deployment lookup', () => { + describe('when no entry can satisfy the request', () => { + it('finds nothing on an unregistered chain', () => { + const deployment = getDeployment(UNREGISTERED_ID, PROTOCOL_VERSIONS.V3) + + expect(deployment).toBeUndefined() + }) + + it('finds nothing on a chain that declares no deployments', () => { + const deployment = getDeployment(ETHEREUM_ID, PROTOCOL_VERSIONS.V3) + + expect(deployment).toBeUndefined() + }) + + it.each(Object.values(PROTOCOL_VERSIONS))( + 'finds no %s entry on a chain that declares no deployments', + (version) => { + const deployment = getDeployment(ETHEREUM_ID, version) + + expect(deployment).toBeUndefined() + }, + ) + + it.each([PROTOCOL_VERSIONS.V4, PROTOCOL_VERSIONS.UNIV3])( + 'finds no %s entry on Polygon, which does not deploy that family', + (version) => { + const deployment = getDeployment(POLYGON_ID, version) + + expect(deployment).toBeUndefined() + }, + ) + }) + + describe('when the chain declares the requested version', () => { + it('hands back the Algebra v3 entry Polygon declares', () => { + const deployment = getDeployment(POLYGON_ID, PROTOCOL_VERSIONS.V3) + + expect(deployment?.version).toBe(PROTOCOL_VERSIONS.V3) + expect(deployment?.factory).toBe('0x411b0fAcC3489691f28ad58c47006AF5E3Ab3A28') + }) + + it('hands back the v2 entry Polygon declares', () => { + const deployment = getDeployment(POLYGON_ID, PROTOCOL_VERSIONS.V2) + + expect(deployment?.version).toBe(PROTOCOL_VERSIONS.V2) + expect(deployment?.factory).toBe('0x5757371414417b8C6CAad45bAeF941aBc7d3Ab32') + }) + + it('reaches the Algebra pool deployer through a v3 lookup', () => { + const deployment = getDeployment(POLYGON_ID, PROTOCOL_VERSIONS.V3) + + expect(deployment?.poolDeployer).toBe('0x2D98E2FA9da15aa6dC9581AB097Ced7af697CB92') + }) + }) + + describe('when two chains run different protocol families', () => { + it('hands back the Uniswap-V3 fork entry Manta Pacific declares', () => { + const deployment = getDeployment(MANTA_ID, PROTOCOL_VERSIONS.UNIV3) + + expect(deployment?.version).toBe(PROTOCOL_VERSIONS.UNIV3) + expect(deployment?.quoter).toBe('0x3005827fB92A0cb7D0f65738D6D645d98A4Ad96b') + }) + + it('hands back the Uniswap-V3 fork entry Immutable zkEVM declares', () => { + const deployment = getDeployment(IMX_ID, PROTOCOL_VERSIONS.UNIV3) + + expect(deployment?.version).toBe(PROTOCOL_VERSIONS.UNIV3) + expect(deployment?.quoter).toBe('0xE9CC37904875B459Fa5D0FE37680d36F1ED55e38') + }) + + it('keeps the Algebra and Uniswap-V3 fork quoters apart across chains', () => { + const algebra = getDeployment(POLYGON_ID, PROTOCOL_VERSIONS.V3) + const uniswapFork = getDeployment(MANTA_ID, PROTOCOL_VERSIONS.UNIV3) + + expect(algebra?.quoter).toBe('0xa15F0D7377B2A0C0c10db057f641beD21028FC89') + expect(uniswapFork?.quoter).toBe('0x3005827fB92A0cb7D0f65738D6D645d98A4Ad96b') + }) + }) +}) diff --git a/packages/protocol-core/src/__tests__/chains/registry.test.ts b/packages/protocol-core/src/__tests__/chains/registry.test.ts index 95ec4bf..e3a499d 100644 --- a/packages/protocol-core/src/__tests__/chains/registry.test.ts +++ b/packages/protocol-core/src/__tests__/chains/registry.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest' import { CHAIN_REGISTRY, CHAIN_ID, getChain, getChainOrThrow, getSupportedChainIds } from '../../chains/registry' -import type { ChainConfig, TokenInfo } from '../../chains/types' +import type { ChainConfig, TokenInfo, ProtocolDeployment } from '../../chains/types' import * as publicApi from '../../index' import { SUPPORTED_CHAINS, @@ -153,6 +153,40 @@ describe('The chain registry', () => { const polygon = getChain(137)! expect(Object.isFrozen(polygon.wrappedNative)).toBe(true) }) + + it('freezes the deployments array', () => { + const polygon = getChain(137)! + + expect(polygon.deployments).toBeDefined() + expect(Object.isFrozen(polygon.deployments)).toBe(true) + }) + + it('freezes every deployment entry', () => { + const polygon = getChain(137)! + + expect(polygon.deployments).toBeDefined() + expect(polygon.deployments!.length).toBeGreaterThan(0) + for (const deployment of polygon.deployments!) { + expect(Object.isFrozen(deployment)).toBe(true) + } + }) + + it('refuses a deployment appended to a published chain', () => { + const polygon = getChain(137)! + const lengthBefore = polygon.deployments!.length + + try { + ;(polygon.deployments as ProtocolDeployment[]).push({ + version: 'v2', + factory: '0x0000000000000000000000000000000000000000', + swapRouter: '0x0000000000000000000000000000000000000000', + }) + } catch { + // strict mode throws — acceptable + } + + expect(polygon.deployments).toHaveLength(lengthBefore) + }) }) describe('The chains withdrawn from this registry', () => { diff --git a/packages/protocol-core/src/__tests__/chains/types.test-d.ts b/packages/protocol-core/src/__tests__/chains/types.test-d.ts new file mode 100644 index 0000000..f4f5d94 --- /dev/null +++ b/packages/protocol-core/src/__tests__/chains/types.test-d.ts @@ -0,0 +1,74 @@ +import { describe, it, expectTypeOf } from 'vitest' +import { + PROTOCOL_VERSIONS, + type ProtocolDeployment, + type V2Deployment, + type V3Deployment, + type V4Deployment, + type UniV3Deployment, + type DeploymentFor, +} from '../../chains/types' + +const ADDRESS = '0x0000000000000000000000000000000000000001' + +describe('The deployment family contract', () => { + it('gives every family a factory and a swap router', () => { + expectTypeOf().toHaveProperty('factory') + expectTypeOf().toHaveProperty('swapRouter') + }) + + it('hides the quoter until the union is narrowed to one family', () => { + expectTypeOf().not.toHaveProperty('quoter') + }) + + it('limits the v2 family to the contracts a constant-product AMM deploys', () => { + expectTypeOf().toHaveProperty('factory') + expectTypeOf().toHaveProperty('swapRouter') + expectTypeOf().not.toHaveProperty('quoter') + expectTypeOf().not.toHaveProperty('positionManager') + expectTypeOf().not.toHaveProperty('poolDeployer') + }) + + it('requires a pool deployer on both Algebra families', () => { + expectTypeOf().toHaveProperty('poolDeployer') + expectTypeOf().toHaveProperty('poolDeployer') + }) + + it('omits the pool deployer from the Uniswap-V3 fork family', () => { + expectTypeOf().toHaveProperty('quoter') + expectTypeOf().toHaveProperty('positionManager') + expectTypeOf().not.toHaveProperty('poolDeployer') + }) + + it('resolves each protocol version to exactly one family', () => { + expectTypeOf>().toEqualTypeOf() + expectTypeOf>().toEqualTypeOf() + expectTypeOf>().toEqualTypeOf() + expectTypeOf>().toEqualTypeOf() + }) + + it('rejects a v3 entry that omits the pool deployer', () => { + // @ts-expect-error poolDeployer is required on the Algebra v3 family + const entry: V3Deployment = { + version: PROTOCOL_VERSIONS.V3, + factory: ADDRESS, + swapRouter: ADDRESS, + quoter: ADDRESS, + positionManager: ADDRESS, + } + + expectTypeOf(entry).toEqualTypeOf() + }) + + it('rejects a v2 entry that carries a quoter', () => { + const entry: V2Deployment = { + version: PROTOCOL_VERSIONS.V2, + factory: ADDRESS, + swapRouter: ADDRESS, + // @ts-expect-error the v2 family deploys no quoter + quoter: ADDRESS, + } + + expectTypeOf(entry).toEqualTypeOf() + }) +}) diff --git a/packages/protocol-core/src/__tests__/index.test.ts b/packages/protocol-core/src/__tests__/index.test.ts index 3196bcb..7efb053 100644 --- a/packages/protocol-core/src/__tests__/index.test.ts +++ b/packages/protocol-core/src/__tests__/index.test.ts @@ -7,15 +7,7 @@ import { getChain, getChainOrThrow, getSupportedChainIds, - POLYGON, - BASE, - MANTRA, - MANTA, - SONEIUM, - SOMNIA, - IMX, - XLAYER, - ETHEREUM, + getDeployment, getSchemaVariant, getSupportedVersions, getProtocolVersionLabel, @@ -29,7 +21,12 @@ import { getWrappedNative, } from '../index' import * as publicApi from '../index' -import { SUPPORTED_CHAIN_IDS } from './fixtures/supported-chains' +import type { ChainConfig } from '../chains/types' +import { SUPPORTED_CHAIN_IDS, sortedChainIds } from './fixtures/supported-chains' + +function isChainConfig(value: unknown): value is ChainConfig { + return typeof value === 'object' && value !== null && 'chainId' in value +} describe('The public API surface', () => { it('publishes the protocol and schema vocabularies', () => { @@ -46,9 +43,16 @@ describe('The public API surface', () => { }) it('publishes one config per supported chain', () => { - const published = [POLYGON, BASE, MANTRA, MANTA, SONEIUM, SOMNIA, IMX, XLAYER, ETHEREUM] + const publishedChainIds = Object.values(publicApi) + .filter(isChainConfig) + .map((chain) => chain.chainId) + + expect(sortedChainIds(publishedChainIds)).toEqual(sortedChainIds(SUPPORTED_CHAIN_IDS)) + expect(sortedChainIds(publishedChainIds)).toEqual(sortedChainIds(getSupportedChainIds())) + }) - expect(published.map(({ chainId }) => chainId)).toEqual([...SUPPORTED_CHAIN_IDS]) + it('publishes the deployment lookup', () => { + expect(typeof getDeployment).toBe('function') }) it('publishes the protocol version helpers', () => { @@ -71,9 +75,9 @@ describe('The public API surface', () => { expect(typeof getWrappedNative).toBe('function') }) - it('publishes exactly 27 runtime members (types excluded)', () => { + it('publishes exactly 28 runtime members (types excluded)', () => { const runtimeExports = Object.keys(publicApi) - expect(runtimeExports).toHaveLength(27) + expect(runtimeExports).toHaveLength(28) }) }) diff --git a/packages/protocol-core/src/chains/deploymentCoherence.ts b/packages/protocol-core/src/chains/deploymentCoherence.ts new file mode 100644 index 0000000..41930bc --- /dev/null +++ b/packages/protocol-core/src/chains/deploymentCoherence.ts @@ -0,0 +1,81 @@ +import type { ChainConfig, ProtocolDeployment, ProtocolVersion } from './types' + +/** Placeholder values rejected wherever a real contract address is required. */ +const SENTINEL_ADDRESSES: ReadonlyArray = [ + '0x0000000000000000000000000000000000000000', + '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', +] + +function isSentinel(value: string): boolean { + const normalised = value.toLowerCase() + return SENTINEL_ADDRESSES.some((sentinel) => sentinel.toLowerCase() === normalised) +} + +/** Pairs every address field of a deployment with its value, narrowed by family. */ +function addressEntries(deployment: ProtocolDeployment): ReadonlyArray { + switch (deployment.version) { + case 'v2': + return [ + ['factory', deployment.factory], + ['swapRouter', deployment.swapRouter], + ] + case 'v3': + case 'v4': + return [ + ['factory', deployment.factory], + ['swapRouter', deployment.swapRouter], + ['quoter', deployment.quoter], + ['positionManager', deployment.positionManager], + ['poolDeployer', deployment.poolDeployer], + ] + case 'univ3': + return [ + ['factory', deployment.factory], + ['swapRouter', deployment.swapRouter], + ['quoter', deployment.quoter], + ['positionManager', deployment.positionManager], + ] + } +} + +/** + * Validates one chain's deployment data against the protocol versions it declares. + * + * Returns one message per violation, or an empty array when the chain is coherent. + * Paths in the messages follow the shape used by the address checksum pass. + */ +export function checkDeploymentCoherence(chain: ChainConfig): string[] { + const errors: string[] = [] + const label = `${chain.name} (${chain.chainId})` + + if (chain.multicall !== undefined && isSentinel(chain.multicall)) { + errors.push(`${label} multicall is a sentinel address: ${chain.multicall}`) + } + + if (!chain.deployments) return errors + + const declaredVersions = new Set(chain.protocols.map((p) => p.version)) + const seenVersions = new Set() + + chain.deployments.forEach((deployment, index) => { + const { version } = deployment + const path = `deployments[${index}]` + + if (!declaredVersions.has(version)) { + errors.push(`${label} ${path} declares ${version}, which is absent from protocols`) + } + + if (seenVersions.has(version)) { + errors.push(`${label} ${path} declares ${version} more than once`) + } + seenVersions.add(version) + + for (const [field, value] of addressEntries(deployment)) { + if (isSentinel(value)) { + errors.push(`${label} ${path}.${field} is a sentinel address: ${value}`) + } + } + }) + + return errors +} diff --git a/packages/protocol-core/src/chains/deployments.ts b/packages/protocol-core/src/chains/deployments.ts new file mode 100644 index 0000000..2883cb7 --- /dev/null +++ b/packages/protocol-core/src/chains/deployments.ts @@ -0,0 +1,16 @@ +import type { ProtocolVersion, DeploymentFor } from './types' +import { getChain } from './registry' + +/** + * Returns the contract deployment a chain declares for one protocol version, + * or `undefined` when the chain, the `deployments` list, or the entry is absent. + * + * The result narrows from the `version` argument, so a caller passing a literal + * version reaches exactly the fields that family deploys. + */ +export function getDeployment( + chainId: number, + version: V, +): DeploymentFor | undefined { + return getChain(chainId)?.deployments?.find((d): d is DeploymentFor => d.version === version) +} diff --git a/packages/protocol-core/src/chains/imx.ts b/packages/protocol-core/src/chains/imx.ts index cffdd04..c4d1c05 100644 --- a/packages/protocol-core/src/chains/imx.ts +++ b/packages/protocol-core/src/chains/imx.ts @@ -18,4 +18,14 @@ export const IMX: ChainConfig = { { address: '0x00000000eFE302BEAA2b3e6e1b18d08D69a9012a', symbol: 'AUSD', decimals: 6 }, { address: '0xEB466342C4d449BC9f53A865D5Cb90586f405215', symbol: 'axlUSDC', decimals: 6 }, ], + multicall: '0xc7efb32470dEE601959B15f1f923e017C6A918cA', + deployments: [ + { + version: 'univ3', + factory: '0x56c2162254b0E4417288786eE402c2B41d4e181e', + swapRouter: '0x6c28AeF8977c9B773996d0e8376d2EE379446F2f', + quoter: '0xE9CC37904875B459Fa5D0FE37680d36F1ED55e38', + positionManager: '0xa5E0829CaCEd8fFDD4De3c43696c57F7D7A678ff', + }, + ], } diff --git a/packages/protocol-core/src/chains/manta.ts b/packages/protocol-core/src/chains/manta.ts index 0b9eec0..707a6ab 100644 --- a/packages/protocol-core/src/chains/manta.ts +++ b/packages/protocol-core/src/chains/manta.ts @@ -17,4 +17,14 @@ export const MANTA: ChainConfig = { { address: '0xf417F5A458eC102B90352F697D6e2Ac3A3d2851f', symbol: 'USDT', decimals: 6 }, { address: '0x1c466b9371f8aBA0D7c458bE10a62192Fcb8Aa71', symbol: 'DAI', decimals: 18 }, ], + multicall: '0x1FD671daC06DF1431E79d772037E93bdB2dfeb48', + deployments: [ + { + version: 'univ3', + factory: '0x56c2162254b0E4417288786eE402c2B41d4e181e', + swapRouter: '0xfdE3eaC61C5Ad5Ed617eB1451cc7C3a0AC197564', + quoter: '0x3005827fB92A0cb7D0f65738D6D645d98A4Ad96b', + positionManager: '0xa5E0829CaCEd8fFDD4De3c43696c57F7D7A678ff', + }, + ], } diff --git a/packages/protocol-core/src/chains/polygon.ts b/packages/protocol-core/src/chains/polygon.ts index 41b6edf..26c5bca 100644 --- a/packages/protocol-core/src/chains/polygon.ts +++ b/packages/protocol-core/src/chains/polygon.ts @@ -19,4 +19,20 @@ export const POLYGON: ChainConfig = { { address: '0xc2132D05D31c914a87C6611C10748AEb04B58e8F', symbol: 'USDT', decimals: 6 }, { address: '0x8f3Cf7ad23Cd3CaDbD9735AFf958023239c6A063', symbol: 'DAI', decimals: 18 }, ], + multicall: '0x6ccb9426CeceE2903FbD97fd833fD1D31c100292', + deployments: [ + { + version: 'v3', + factory: '0x411b0fAcC3489691f28ad58c47006AF5E3Ab3A28', + swapRouter: '0xf5b509bB0909a69B1c207E495f687a596C168E12', + quoter: '0xa15F0D7377B2A0C0c10db057f641beD21028FC89', + positionManager: '0x8eF88E4c7CfbbaC1C163f7eddd4B578792201de6', + poolDeployer: '0x2D98E2FA9da15aa6dC9581AB097Ced7af697CB92', + }, + { + version: 'v2', + factory: '0x5757371414417b8C6CAad45bAeF941aBc7d3Ab32', + swapRouter: '0xa5E0829CaCEd8fFDD4De3c43696c57F7D7A678ff', + }, + ], } diff --git a/packages/protocol-core/src/chains/types.ts b/packages/protocol-core/src/chains/types.ts index a9ab6b7..1873bb8 100644 --- a/packages/protocol-core/src/chains/types.ts +++ b/packages/protocol-core/src/chains/types.ts @@ -40,4 +40,47 @@ export interface ChainConfig { readonly wrappedNative: TokenInfo readonly protocols: ReadonlyArray readonly stablecoins: ReadonlyArray + /** Aggregator contract used to batch read calls. Version-agnostic. */ + readonly multicall?: string + readonly deployments?: ReadonlyArray } + +interface DeploymentBase { + readonly factory: string + readonly swapRouter: string +} + +interface ConcentratedDeploymentBase extends DeploymentBase { + readonly quoter: string + readonly positionManager: string +} + +/** + * V2 pairs are fungible ERC20 tokens priced by closed-form constant-product + * math, so this family deploys no quoter and no position manager. + */ +export interface V2Deployment extends DeploymentBase { + readonly version: typeof PROTOCOL_VERSIONS.V2 +} + +export interface V3Deployment extends ConcentratedDeploymentBase { + readonly version: typeof PROTOCOL_VERSIONS.V3 + /** Algebra derives pool addresses from this contract rather than from `factory`. */ + readonly poolDeployer: string +} + +export interface V4Deployment extends ConcentratedDeploymentBase { + readonly version: typeof PROTOCOL_VERSIONS.V4 + /** Algebra derives pool addresses from this contract rather than from `factory`. */ + readonly poolDeployer: string +} + +/** Uniswap-V3 fork family: pools are derived from `factory`, so no pool deployer exists. */ +export interface UniV3Deployment extends ConcentratedDeploymentBase { + readonly version: typeof PROTOCOL_VERSIONS.UNIV3 +} + +export type ProtocolDeployment = V2Deployment | V3Deployment | V4Deployment | UniV3Deployment + +/** Narrows `ProtocolDeployment` to the single family matching a protocol version. */ +export type DeploymentFor = Extract diff --git a/packages/protocol-core/src/index.ts b/packages/protocol-core/src/index.ts index d97b374..34f7a2c 100644 --- a/packages/protocol-core/src/index.ts +++ b/packages/protocol-core/src/index.ts @@ -1,10 +1,21 @@ // Types export type { ProtocolVersion, SchemaVariant, TokenInfo, ChainProtocolEntry, ChainConfig } from './chains/types' +export type { + ProtocolDeployment, + V2Deployment, + V3Deployment, + V4Deployment, + UniV3Deployment, + DeploymentFor, +} from './chains/types' export { PROTOCOL_VERSIONS, SCHEMA_VARIANTS } from './chains/types' // Chain Registry export { CHAIN_REGISTRY, CHAIN_ID, getChain, getChainOrThrow, getSupportedChainIds } from './chains/registry' +// Deployment Lookup +export { getDeployment } from './chains/deployments' + // Individual Chain Configs export { POLYGON } from './chains/polygon' export { BASE } from './chains/base' diff --git a/packages/protocol-core/vitest.config.ts b/packages/protocol-core/vitest.config.ts index e2a4f06..0e7917d 100644 --- a/packages/protocol-core/vitest.config.ts +++ b/packages/protocol-core/vitest.config.ts @@ -5,6 +5,11 @@ export default defineConfig({ globals: true, environment: 'node', include: ['src/**/*.test.ts'], + typecheck: { + enabled: true, + include: ['src/**/*.test-d.ts'], + tsconfig: './tsconfig.json', + }, coverage: { provider: 'v8', include: ['src/**/*.ts'],