diff --git a/deployments/modules/authority.test.ts b/deployments/modules/authority.test.ts index ec3c121..9175d98 100644 --- a/deployments/modules/authority.test.ts +++ b/deployments/modules/authority.test.ts @@ -8,7 +8,8 @@ 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, propAMMPoolAbi, @@ -364,6 +365,33 @@ 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('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 e846f20..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), @@ -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..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( { @@ -640,6 +644,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..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) => @@ -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 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.