Source mirror · commit 369512f · 2026-10-26 · MIT · canonical repo: gitlab.com/jiapunk/upgrade-airlock
git clone https://upgrade-airlock.pages.dev/source/upgrade-airlock.git
upgrade-airlock / web / operator / cards.ts
web/operator/cards.ts81 lines · 4559 bytes
1// The seven fixed actions the operator may launch on Ethereum Sepolia, one per attack card of the war room.
2// They mirror scripts/sepolia/propose.sh exactly: an upgrade card calls AirlockAdmin.proposeUpgrade(<impl>, 0x) with
3// the deployment's address for that card; remove-receiver calls proposeSetGuardian(<receiver>, false). Nothing else
4// can be sent: the client names a card id, never an address or calldata.
5// Plain data and viem encoding only (no node: imports), so the Vite dev server, the Cloudflare Pages Functions and
6// the Node tests share it.
7import { encodeFunctionData, getAddress, isAddress, parseAbi, type Address, type Hex } from 'viem'
8
9/** In the order the war room's picker shows them */
10export const CARD_IDS = ['evil-mint', 'evil-owner', 'evil-fee', 'evil-basefee', 'evil-drain', 'remove-receiver', 'v2'] as const
11export type CardId = (typeof CARD_IDS)[number]
12
13export const isCardId = (v: unknown): v is CardId => typeof v === 'string' && (CARD_IDS as readonly string[]).includes(v)
14
15/** Implementation keys of the deployment (script/DeployEth.s.sol output) that a card upgrades to */
16export type ImplKey = 'v2' | 'evilMint' | 'evilOwner' | 'evilFee' | 'evilBaseFee' | 'evilDrain'
17
18/** card -> what it proposes (propose.sh: KEY=...) */
19export const CARD_ACTION: Readonly<Record<CardId, { kind: 'upgrade'; impl: ImplKey } | { kind: 'remove-receiver' }>> = {
20 'evil-mint': { kind: 'upgrade', impl: 'evilMint' },
21 'evil-owner': { kind: 'upgrade', impl: 'evilOwner' },
22 'evil-fee': { kind: 'upgrade', impl: 'evilFee' },
23 'evil-basefee': { kind: 'upgrade', impl: 'evilBaseFee' },
24 'evil-drain': { kind: 'upgrade', impl: 'evilDrain' },
25 'remove-receiver': { kind: 'remove-receiver' },
26 v2: { kind: 'upgrade', impl: 'v2' },
27}
28
29/** The public deployment data the operator needs (operator/deployment.generated.ts holds the Sepolia one) */
30export interface OperatorDeployment {
31 /** true while no deployment exists (the placeholder): the operator is off */
32 pending: boolean
33 chainId: number
34 airlockAdmin: string
35 /** the only account that may propose; the injected key must belong to it */
36 owner: string
37 /** the AirlockReceiver (the Chainlink CRE guardian) */
38 receiver: string
39 impls: Partial<Record<ImplKey, string>>
40}
41
42/** AirlockAdmin (src/AirlockAdmin.sol): the two proposals the cards send and the views the cooldown check reads */
43export const OPERATOR_ABI = parseAbi([
44 'function proposeUpgrade(address newImpl, bytes data) returns (uint256 id)',
45 'function proposeSetGuardian(address guardian, bool enabled) returns (uint256 id)',
46 'function nextId() view returns (uint256)',
47 'struct Proposal { uint8 kind; uint8 status; address proposer; address target; uint256 value; bytes32 codehash; bytes32 dataHash; uint64 proposedBlock; uint64 readyBlock; uint256 vetoCount; }',
48 'function getProposal(uint256 id) view returns (Proposal)',
49])
50
51const ZERO = '0x0000000000000000000000000000000000000000'
52const usable = (a: string | undefined): a is string => !!a && isAddress(a, { strict: false }) && a.toLowerCase() !== ZERO
53
54/** true when the deployment has the AirlockAdmin, the owner and the receiver */
55export function deploymentUsable(d: OperatorDeployment): boolean {
56 return !d.pending && d.chainId === 11155111 && usable(d.airlockAdmin) && usable(d.owner) && usable(d.receiver)
57}
58
59/** The address a card acts on in this deployment, or null when the deployment has none (it predates that card) */
60export function cardTarget(card: CardId, d: OperatorDeployment): Address | null {
61 const a = CARD_ACTION[card]
62 const addr = a.kind === 'remove-receiver' ? d.receiver : d.impls[a.impl]
63 return usable(addr) ? getAddress(addr) : null
64}
65
66/** The cards this deployment can launch, in picker order */
67export function availableCards(d: OperatorDeployment): CardId[] {
68 return deploymentUsable(d) ? CARD_IDS.filter((c) => cardTarget(c, d) !== null) : []
69}
70
71/** The exact transaction a card sends: to AirlockAdmin, with this calldata (null when the card is not available) */
72export function buildAction(card: CardId, d: OperatorDeployment): { to: Address; data: Hex } | null {
73 const target = deploymentUsable(d) ? cardTarget(card, d) : null
74 if (!target) return null
75 const a = CARD_ACTION[card]
76 const data =
77 a.kind === 'remove-receiver'
78 ? encodeFunctionData({ abi: OPERATOR_ABI, functionName: 'proposeSetGuardian', args: [target, false] })
79 : encodeFunctionData({ abi: OPERATOR_ABI, functionName: 'proposeUpgrade', args: [target, '0x'] })
80 return { to: getAddress(d.airlockAdmin), data }
81}