From c15b9803cd8202b8982c32f4267e9f9c2b19ef0c Mon Sep 17 00:00:00 2001 From: Henry Palacios <4270166+henrypalacios@users.noreply.github.com> Date: Wed, 26 Aug 2026 18:49:37 -0300 Subject: [PATCH 1/3] feat(protocol-core): add version-discriminated deployment schema and lookup Chain configs describe chain identity but carry no contract addresses, so every consumer maintains its own address maps and its own assumptions about which contracts each protocol family deploys. Add an optional `deployments` list to `ChainConfig`, modelled as a union discriminated by protocol version, together with `getDeployment(chainId, version)` whose result narrows to the requested family. Required fields follow the contracts a family actually deploys: V2 pairs are fungible ERC20 tokens priced by closed-form constant-product math and therefore have no quoter and no position manager, while only the Algebra families derive pool addresses from a pool deployer instead of the factory. Modelling this as a union makes those asymmetries compile errors rather than conventions. Add an optional chain-level `multicall` address, which is version-agnostic. Enable vitest type-checking over `*.test-d.ts` files so the type-level assertions covering these rules are enforced, and keep that glob disjoint from the runtime suite so no spec is collected twice. Document the new surface in the package README, and extend the chain onboarding flow so a new chain arrives with its deployment addresses rather than without them. Every added field is optional, so existing chain configs and every published export keep their current shape. --- docs/flows/chain-onboarding.md | 10 ++- packages/protocol-core/README.md | 21 ++++++ .../__tests__/chains/deployments.test-d.ts | 12 +++ .../src/__tests__/chains/deployments.test.ts | 31 ++++++++ .../src/__tests__/chains/types.test-d.ts | 74 +++++++++++++++++++ .../protocol-core/src/__tests__/index.test.ts | 32 ++++---- .../protocol-core/src/chains/deployments.ts | 16 ++++ packages/protocol-core/src/chains/types.ts | 43 +++++++++++ packages/protocol-core/src/index.ts | 11 +++ packages/protocol-core/vitest.config.ts | 5 ++ 10 files changed, 238 insertions(+), 17 deletions(-) create mode 100644 packages/protocol-core/src/__tests__/chains/deployments.test-d.ts create mode 100644 packages/protocol-core/src/__tests__/chains/deployments.test.ts create mode 100644 packages/protocol-core/src/__tests__/chains/types.test-d.ts create mode 100644 packages/protocol-core/src/chains/deployments.ts 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/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..e7b6782 --- /dev/null +++ b/packages/protocol-core/src/__tests__/chains/deployments.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from 'vitest' +import { getDeployment } from '../../chains/deployments' +import { PROTOCOL_VERSIONS } from '../../chains/types' + +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() + }, + ) + }) +}) 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/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/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'], From e3bd6b1d18a793f4d34c09d5fab430f7702db632 Mon Sep 17 00:00:00 2001 From: Henry Palacios <4270166+henrypalacios@users.noreply.github.com> Date: Wed, 26 Aug 2026 18:50:06 -0300 Subject: [PATCH 2/3] feat(protocol-core): validate deployment coherence at publish time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deployment data has to agree with the chain that declares it: every entry targets a protocol version that chain actually lists, each version appears at most once, and every address slot — the chain-level multicall included — holds a real contract address. Those are registry-level rules, so they belong in the same pass that already guards address format before publish. Add `checkDeploymentCoherence` and run it for every chain inside the existing validation loop, reporting through the same error list, the same exit code and the same path grammar. The publish gate now guarantees that every deployment entry reaching consumers is coherent with the protocols its chain declares. Keeping the rules in a pure function leaves the script responsible only for whole-registry wiring, so each rule is covered directly by unit tests. --- .../scripts/validate-addresses.ts | 5 +- .../chains/deploymentCoherence.test.ts | 193 ++++++++++++++++++ .../src/chains/deploymentCoherence.ts | 81 ++++++++ 3 files changed, 278 insertions(+), 1 deletion(-) create mode 100644 packages/protocol-core/src/__tests__/chains/deploymentCoherence.test.ts create mode 100644 packages/protocol-core/src/chains/deploymentCoherence.ts 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/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/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 +} From ca17c3551059caf3d90aa6fc5b3cf887c2e58161 Mon Sep 17 00:00:00 2001 From: Henry Palacios <4270166+henrypalacios@users.noreply.github.com> Date: Wed, 26 Aug 2026 18:50:08 -0300 Subject: [PATCH 3/3] feat(protocol-core): populate deployment addresses for Polygon, Manta and IMX Give the deployment schema real data on three chains. Polygon carries the Algebra v3 and v2 entries; Manta Pacific and Immutable zkEVM each carry a Uniswap-V3 fork entry. Together they cover both concentrated-liquidity families, so a lookup returning the family it was asked for is exercised across chains: the Algebra and Uniswap-V3 fork quoters expose different ABIs, and resolving them by version is what stops a caller reaching for the wrong one. The Uniswap-V3 fork entries carry no pool deployer, since that family derives pool addresses from the factory. Manta Pacific and Immutable zkEVM share a factory and a position manager address; both are confirmed against the deployed contracts. All three chains also gain their multicall address. Every value is stored in EIP-55 checksum form. --- .../src/__tests__/chains/chain-data.test.ts | 94 +++++++++++++++++++ .../src/__tests__/chains/deployments.test.ts | 58 ++++++++++++ .../src/__tests__/chains/registry.test.ts | 36 ++++++- packages/protocol-core/src/chains/imx.ts | 10 ++ packages/protocol-core/src/chains/manta.ts | 10 ++ packages/protocol-core/src/chains/polygon.ts | 16 ++++ 6 files changed, 223 insertions(+), 1 deletion(-) 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/deployments.test.ts b/packages/protocol-core/src/__tests__/chains/deployments.test.ts index e7b6782..62e4020 100644 --- a/packages/protocol-core/src/__tests__/chains/deployments.test.ts +++ b/packages/protocol-core/src/__tests__/chains/deployments.test.ts @@ -2,6 +2,9 @@ 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 @@ -27,5 +30,60 @@ describe('The deployment lookup', () => { 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/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', + }, + ], }