Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion deployments/modules/authority.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
15 changes: 13 additions & 2 deletions deployments/modules/earn/zone-router/steps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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<void>,
): Promise<{ transactions: Hex[] } | undefined> {
const key = envPrivateKey(options.environment, 'DEPLOYER_PRIVATE_KEY')
if (!key) return undefined
Expand Down Expand Up @@ -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
Expand Down
43 changes: 42 additions & 1 deletion deployments/modules/zone-router/steps.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() }))

Expand Down Expand Up @@ -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(
{
Expand Down Expand Up @@ -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', () => {
Expand Down
3 changes: 2 additions & 1 deletion deployments/modules/zone-router/steps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand All @@ -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),
),
})

Expand Down
4 changes: 3 additions & 1 deletion docs/earn/modules/zone-router.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
tempoxyz-bot marked this conversation as resolved.
Resolved
- **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
Expand Down
2 changes: 1 addition & 1 deletion docs/zone-router/zone-router.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Loading