From 529c4ecfbd97bc6a3523468b1bc8c332d58583f4 Mon Sep 17 00:00:00 2001 From: Kartik Date: Fri, 13 Nov 2026 13:47:40 -0500 Subject: [PATCH 1/3] fix(deployments): check router provenance before pool admission --- deployments/modules/authority.test.ts | 22 +++++++++++ deployments/modules/earn/zone-router/steps.ts | 13 ++++++- deployments/modules/zone-router/steps.test.ts | 37 +++++++++++++++++++ deployments/modules/zone-router/steps.ts | 1 + docs/earn/modules/zone-router.md | 4 +- 5 files changed, 75 insertions(+), 2 deletions(-) diff --git a/deployments/modules/authority.test.ts b/deployments/modules/authority.test.ts index ec3c121..2ebe881 100644 --- a/deployments/modules/authority.test.ts +++ b/deployments/modules/authority.test.ts @@ -9,6 +9,7 @@ import { describe, expect, test, vi } from 'vitest' import { executeAvailableAuthority } from '../../deployments/modules/index.js' import { canAuthorizeRouter } from './earn/zone-router/steps.js' +import { requireRouterProvenance } from './earn/zone-router/provenance.js' import { earnEngineBindingAbi, propAMMPoolAbi, @@ -364,6 +365,27 @@ describe('deployment authority adapters', () => { await expect(canAuthorize(unreachable.dependencies)).rejects.toThrow(HttpRequestError) }) + test('checks the recovered router implementation before pool admission', async () => { + const router = address('d') + const error = new Error('noncanonical router') + vi.mocked(requireRouterProvenance).mockRejectedValueOnce(error) + const runtime = mockDependencies([authorityAccount, false, false, router]) + await expect( + execute( + profile, + 'authorize-router', + { DEPLOYER_PRIVATE_KEY: authorityKey }, + { ...outputs, earnRouter: router }, + runtime.dependencies, + ), + ).rejects.toBe(error) + expect(vi.mocked(requireRouterProvenance).mock.lastCall?.[1]).toMatchObject({ + address: router, + contract: 'src/router/SingleZoneEarnRouter.sol:SingleZoneEarnRouter', + }) + expect(runtime.sendTransactionSync).not.toHaveBeenCalled() + }) + test('refuses to admit a router whose pool recipient is redirected', async () => { const runtime = mockDependencies([authorityAccount, false, false, address('9')]) await expect( diff --git a/deployments/modules/earn/zone-router/steps.ts b/deployments/modules/earn/zone-router/steps.ts index e846f20..4f3c59b 100644 --- a/deployments/modules/earn/zone-router/steps.ts +++ b/deployments/modules/earn/zone-router/steps.ts @@ -154,18 +154,28 @@ export const authorizeRouter = defineAuthorityStep({ dependencies, required(configuredRouterPool(options.profile), 'router conversion pool').pool, requiredOutput(options.outputs, 'earnRouter'), + (client) => + requireRouterProvenance(client, { + address: requiredOutput(options.outputs, 'earnRouter'), + project: 'earn', + contract: 'src/router/SingleZoneEarnRouter.sol:SingleZoneEarnRouter', + constructorArgs: deployRouter.source!.constructorArgs(options.profile, options.outputs), + }), ), }) /** * Admits `router` as a taker and recipient on `pool` as the deployer, when it owns the pool directly - * or through its rebalance controller; `undefined` when it holds neither. + * or through its rebalance controller; `undefined` when it holds neither. `requireProvenance` must + * prove the recovered router canonical: like Portal admission, pool admission grants it authority + * the lifecycle cannot take back. */ export async function admitPoolRouter( options: AuthorityOptions, dependencies: AuthorityDependencies, pool: Address, router: Address, + requireProvenance: (client: AuthorityClient) => Promise, ): Promise<{ transactions: Hex[] } | undefined> { const key = envPrivateKey(options.environment, 'DEPLOYER_PRIVATE_KEY') if (!key) return undefined @@ -202,6 +212,7 @@ export async function admitPoolRouter( ]), ] if (poolCalls.length === 0) return { transactions: [] } + await requireProvenance(client) // A DirectPropAMMRebalanceController owns its pool and forwards owner calls through `execute`. const calls = poolCalls.map((data) => authority.controller diff --git a/deployments/modules/zone-router/steps.test.ts b/deployments/modules/zone-router/steps.test.ts index 54c708d..b78b35f 100644 --- a/deployments/modules/zone-router/steps.test.ts +++ b/deployments/modules/zone-router/steps.test.ts @@ -640,6 +640,43 @@ describe('Zone router steps', () => { args: [outputs.zoneRouter, true], }) }) + + test('refuses a restored noncanonical factory or router before pool admission', async () => { + const deployerKey = `0x${'4'.repeat(64)}` as const + const deployer = privateKeyToAccount(deployerKey).address + const error = new Error('noncanonical router') + vi.mocked(requireRouterProvenance).mockRejectedValueOnce(error) + // Pool owner, then taker, recipient, and resolved recipient. + const readContract = vi + .fn() + .mockResolvedValueOnce(deployer) + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(outputs.zoneRouter) + const sendTransactionSync = vi.fn() + await expect( + executeAvailableAuthority( + { + action: { id: 'authorize-zone-router' }, + environment: { DEPLOYER_PRIVATE_KEY: deployerKey }, + outputs, + profile: cardAndEarn, + rpcUrl: 'https://rpc.invalid', + }, + { + authorityClient: ((_options: unknown, account: unknown) => ({ + account, + chain: { id: 42431 }, + getTransactionCount: async () => 0, + readContract, + sendTransactionSync, + })) as never, + fundFromFaucet: vi.fn() as never, + }, + ), + ).rejects.toBe(error) + expect(sendTransactionSync).not.toHaveBeenCalled() + }) }) describe('Zone router preflight', () => { diff --git a/deployments/modules/zone-router/steps.ts b/deployments/modules/zone-router/steps.ts index 596bab1..62a224b 100644 --- a/deployments/modules/zone-router/steps.ts +++ b/deployments/modules/zone-router/steps.ts @@ -231,6 +231,7 @@ export const authorizeZoneRouter = defineAuthorityStep({ dependencies, required(routerPool(options.profile), 'Zone router conversion pool'), requiredOutput(options.outputs, 'zoneRouter'), + (client) => requireModeProvenance(client, options.profile, options.outputs), ), }) diff --git a/docs/earn/modules/zone-router.md b/docs/earn/modules/zone-router.md index 016d460..e851c48 100644 --- a/docs/earn/modules/zone-router.md +++ b/docs/earn/modules/zone-router.md @@ -287,7 +287,9 @@ Authorities and handoffs: - **Pool owner.** `authorize-router` executes under `DEPLOYER_PRIVATE_KEY` only when planning proves that key is the pool's `owner()`, or owns the `DirectPropAMMRebalanceController` that owns the pool (calls then go through `controller.execute`). It refuses a pool that redirects the router's - recipient, which admission cannot repair. Otherwise it emits a pool-owner handoff. + recipient, which admission cannot repair, and a router that fails the provenance check below, + as does the Zone router's `authorize-zone-router` for its factory and router. Otherwise it emits a + pool-owner handoff. - **Zone administrator.** When tokens or gateway registration are missing, the command still publishes the record and returns the administrator handoff. The administrator checks out the committed profile and record and reruns the same command with only `ZONE_ADMIN_PRIVATE_KEY`. That From 103f7ff0899a3efdc0c031667dd0122111989021 Mon Sep 17 00:00:00 2001 From: Kartik Date: Fri, 13 Nov 2026 14:15:19 -0500 Subject: [PATCH 2/3] docs(zone-router): note the provenance check before pool admission --- docs/zone-router/zone-router.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/zone-router/zone-router.md b/docs/zone-router/zone-router.md index 76d5970..1b05b10 100644 --- a/docs/zone-router/zone-router.md +++ b/docs/zone-router/zone-router.md @@ -77,7 +77,7 @@ contracts: one in one batch). The lifecycle grants pool, gateway and account admission with its own keys only when it deployed the factory itself; for a router from a factory the profile names, each is always handed off, for the pool owner or the Portal administrator to check that factory's verified source - first. Before any automatic Portal role grant, read-only constructor simulations of the reviewed local + first. Before any automatic pool admission or Portal role grant, read-only constructor simulations of the reviewed local factory and router builds must match the deployed runtime exactly, including their immutable values. A matching getter or address alone is insufficient; missing evidence stops the grant. From 1d6571b98ff45e881f91a686f4cb01eda6dc3e7a Mon Sep 17 00:00:00 2001 From: Kartik Date: Fri, 13 Nov 2026 14:19:18 -0500 Subject: [PATCH 3/3] fix(deployments): ask the pool owner to verify the router in pool-admission handoffs --- deployments/modules/authority.test.ts | 8 +++++++- deployments/modules/earn/zone-router/steps.ts | 2 +- deployments/modules/zone-router/steps.test.ts | 6 +++++- deployments/modules/zone-router/steps.ts | 2 +- 4 files changed, 14 insertions(+), 4 deletions(-) diff --git a/deployments/modules/authority.test.ts b/deployments/modules/authority.test.ts index 2ebe881..9175d98 100644 --- a/deployments/modules/authority.test.ts +++ b/deployments/modules/authority.test.ts @@ -8,7 +8,7 @@ import { privateKeyToAccount } from 'viem/accounts' import { describe, expect, test, vi } from 'vitest' import { executeAvailableAuthority } from '../../deployments/modules/index.js' -import { canAuthorizeRouter } from './earn/zone-router/steps.js' +import { authorizeRouter, canAuthorizeRouter } from './earn/zone-router/steps.js' import { requireRouterProvenance } from './earn/zone-router/provenance.js' import { earnEngineBindingAbi, @@ -386,6 +386,12 @@ describe('deployment authority adapters', () => { expect(runtime.sendTransactionSync).not.toHaveBeenCalled() }) + test('asks the pool owner to verify the router before admitting it', () => { + expect(authorizeRouter.handoff(profile, { ...outputs, earnRouter: address('d') })).toMatch( + /^First verify the router's canonical implementation and immutable bindings\. Then the owner of PropAMM pool/, + ) + }) + test('refuses to admit a router whose pool recipient is redirected', async () => { const runtime = mockDependencies([authorityAccount, false, false, address('9')]) await expect( diff --git a/deployments/modules/earn/zone-router/steps.ts b/deployments/modules/earn/zone-router/steps.ts index 4f3c59b..429520a 100644 --- a/deployments/modules/earn/zone-router/steps.ts +++ b/deployments/modules/earn/zone-router/steps.ts @@ -144,7 +144,7 @@ export const authorizeRouter = defineAuthorityStep({ return taker && recipient && getAddress(resolved) === router }, handoff: (profile, outputs) => - `The owner of PropAMM pool ${configuredZone(profile)?.pool} must call setTakerAllowed(${requiredOutput(outputs, 'earnRouter')}, true) and setRecipientAllowed(${requiredOutput(outputs, 'earnRouter')}, true), and the pool must resolve that router as its own recipient. When a DirectPropAMMRebalanceController owns the pool, its owner makes both calls through controller.execute(data). Re-run the same pnpm earn deploy command; it will verify the admission onchain and continue.`, + `First verify the router's canonical implementation and immutable bindings. Then the owner of PropAMM pool ${configuredZone(profile)?.pool} must call setTakerAllowed(${requiredOutput(outputs, 'earnRouter')}, true) and setRecipientAllowed(${requiredOutput(outputs, 'earnRouter')}, true), and the pool must resolve that router as its own recipient. When a DirectPropAMMRebalanceController owns the pool, its owner makes both calls through controller.execute(data). Re-run the same pnpm earn deploy command; it will verify the admission onchain and continue.`, signerAvailable: (_profile, environment, proven) => Boolean(environment.DEPLOYER_PRIVATE_KEY?.trim()) && proven, probeSigner: (options) => canAuthorizeRouter(options), diff --git a/deployments/modules/zone-router/steps.test.ts b/deployments/modules/zone-router/steps.test.ts index b78b35f..758ba52 100644 --- a/deployments/modules/zone-router/steps.test.ts +++ b/deployments/modules/zone-router/steps.test.ts @@ -19,7 +19,7 @@ import { accessMemberContracts, deploymentStep, executeAvailableAuthority } from import type { ChainClient, FoundryStep } from '../module.js' import { requireRouterProvenance } from '../earn/zone-router/provenance.js' import { zoneRouterModeModule } from './index.js' -import { zoneRouterConstructorArgs, zoneRouterMode } from './profile.js' +import { zoneRouterConstructorArgs, zoneRouterFactory, zoneRouterMode } from './profile.js' vi.mock(import('../earn/zone-router/provenance.js'), () => ({ requireRouterProvenance: vi.fn() })) @@ -439,6 +439,10 @@ describe('Zone router steps', () => { `ZoneRouterFactory ${address('fac')} is source verified as zone-router/src/ZoneRouterFactory.sol`, ) expect(step.handoff(cardAndEarn, outputs)).not.toContain('source verified') + for (const profile of [named, cardAndEarn]) + expect(step.handoff(profile, outputs)).toContain( + `Confirm ZoneRouterFactory ${zoneRouterFactory(profile, outputs)}.isRouter(${outputs.zoneRouter}) and the mode in its RouterDeployed event, and verify the router's canonical implementation`, + ) const authorityClient = vi.fn() const result = await executeAvailableAuthority( { diff --git a/deployments/modules/zone-router/steps.ts b/deployments/modules/zone-router/steps.ts index 62a224b..f717f4e 100644 --- a/deployments/modules/zone-router/steps.ts +++ b/deployments/modules/zone-router/steps.ts @@ -214,7 +214,7 @@ export const authorizeZoneRouter = defineAuthorityStep({ }, handoff: (profile, outputs) => { const router = requiredOutput(outputs, 'zoneRouter') - return `${namedFactoryCheck(profile, outputs)}The owner of PropAMM pool ${routerPool(profile)} must call setTakerAllowed(${router}, true) and setRecipientAllowed(${router}, true), and the pool must resolve that router as its own recipient. When a DirectPropAMMRebalanceController owns the pool, its owner makes both calls through controller.execute(data). Re-run the same pnpm earn deploy command; it will verify the admission onchain and continue.` + return `${namedFactoryCheck(profile, outputs)}Confirm ZoneRouterFactory ${zoneRouterFactory(profile, outputs)}.isRouter(${router}) and the mode in its RouterDeployed event, and verify the router's canonical implementation and immutable bindings. Then the owner of PropAMM pool ${routerPool(profile)} must call setTakerAllowed(${router}, true) and setRecipientAllowed(${router}, true), and the pool must resolve that router as its own recipient. When a DirectPropAMMRebalanceController owns the pool, its owner makes both calls through controller.execute(data). Re-run the same pnpm earn deploy command; it will verify the admission onchain and continue.` }, // Like gateway admission, a router from a factory the profile names is admitted only by a person. signerAvailable: (profile, environment, proven) =>