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. |
| 7 | import { encodeFunctionData, getAddress, isAddress, parseAbi, type Address, type Hex } from 'viem' |
| 8 | |
| 9 | /** In the order the war room's picker shows them */ |
| 10 | export const CARD_IDS = ['evil-mint', 'evil-owner', 'evil-fee', 'evil-basefee', 'evil-drain', 'remove-receiver', 'v2'] as const |
| 11 | export type CardId = (typeof CARD_IDS)[number] |
| 12 | |
| 13 | export 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 */ |
| 16 | export type ImplKey = 'v2' | 'evilMint' | 'evilOwner' | 'evilFee' | 'evilBaseFee' | 'evilDrain' |
| 17 | |
| 18 | /** card -> what it proposes (propose.sh: KEY=...) */ |
| 19 | export 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) */ |
| 30 | export 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 */ |
| 43 | export 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 | |
| 51 | const ZERO = '0x0000000000000000000000000000000000000000' |
| 52 | const 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 */ |
| 55 | export 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) */ |
| 60 | export 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 */ |
| 67 | export 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) */ |
| 72 | export 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 | } |