Repository navigation
🔨 deploy: sign safe proposals as proposer #826
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,47 +1,81 @@ | ||
| import { env } from "process"; | ||
| import { ethers, network, getNamedAccounts } from "hardhat"; | ||
| import SafeApiKit from "@safe-global/api-kit"; | ||
| import Safe from "@safe-global/protocol-kit"; | ||
| import type { BaseContract } from "ethers"; | ||
| import { TypedDataEncoder, ZeroAddress, type BaseContract } from "ethers"; | ||
| import format from "./format"; | ||
|
|
||
| const services: Record<string, string> = { 1: "eth", 10: "oeth", 8453: "base", 84532: "basesep" }; | ||
| const types = { | ||
| SafeTx: [ | ||
| { name: "to", type: "address" }, | ||
| { name: "value", type: "uint256" }, | ||
| { name: "data", type: "bytes" }, | ||
| { name: "operation", type: "uint8" }, | ||
| { name: "safeTxGas", type: "uint256" }, | ||
| { name: "baseGas", type: "uint256" }, | ||
| { name: "gasPrice", type: "uint256" }, | ||
| { name: "gasToken", type: "address" }, | ||
| { name: "refundReceiver", type: "address" }, | ||
| { name: "nonce", type: "uint256" }, | ||
| ], | ||
| }; | ||
|
|
||
| export default async ( | ||
| account: string, | ||
| contract: BaseContract, | ||
| functionName: string, | ||
| args: readonly unknown[] = [], | ||
| safe = "multisig", | ||
| ) => { | ||
| const { [account]: senderAddress, [safe]: safeAddress } = await getNamedAccounts(); | ||
| const safeApiKit = new SafeApiKit({ | ||
| chainId: BigInt(network.config.chainId ?? (await ethers.provider.getNetwork()).chainId), | ||
| }); | ||
| const { [account]: sender, [safe]: safeAddress } = await getNamedAccounts(); | ||
| const chainId = network.config.chainId ?? (await ethers.provider.getNetwork()).chainId; | ||
| const service = services[String(chainId)]; | ||
| if (!service) throw new Error(`no safe transaction service for chain ${chainId}`); | ||
| const api = async <T>(path: string, body?: object) => { | ||
| const response = await fetch(`https://api.safe.global/tx-service/${service}/api/v1/safes/${safeAddress}/${path}`, { | ||
| method: body ? "POST" : "GET", | ||
| headers: { | ||
| "Content-Type": "application/json", | ||
| ...(env.SAFE_API_KEY ? { Authorization: `Bearer ${env.SAFE_API_KEY}` } : {}), | ||
| }, | ||
| body: body && JSON.stringify(body), | ||
| }); | ||
| const text = await response.text(); | ||
| if (!response.ok) throw new Error(text || response.statusText); | ||
| return (text && JSON.parse(text)) as T; | ||
| }; | ||
| const to = await contract.getAddress(); | ||
| const data = contract.interface.encodeFunctionData(functionName, args); | ||
| try { | ||
| if ( | ||
| !(await safeApiKit.getPendingTransactions(safeAddress)).results.find((tx) => tx.to === to && tx.data === data) | ||
| ) { | ||
| // eslint-disable-next-line no-console | ||
| console.log(`${safe}: proposing`, `${await format(to)}.${functionName}`, await format(args)); | ||
| const safeSdk = await Safe.init({ safeAddress, signer: senderAddress, provider: network.provider }); | ||
| if (!(await safeSdk.isOwner(senderAddress))) return printManual(safe, to, data); | ||
| const safeTransaction = await safeSdk.createTransaction({ | ||
| transactions: [{ to, data, value: "0" }], | ||
| options: { nonce: await safeApiKit.getNextNonce(safeAddress) }, | ||
| }); | ||
| const safeTxHash = await safeSdk.getTransactionHash(safeTransaction); | ||
| const senderSignature = await safeSdk.signHash(safeTxHash); | ||
| await safeApiKit.proposeTransaction({ | ||
| safeTxHash, | ||
| safeAddress, | ||
| senderAddress, | ||
| senderSignature: senderSignature.data, | ||
| safeTransactionData: safeTransaction.data, | ||
| origin: "deploy", | ||
| }); | ||
| } | ||
| const { nonce } = await api<{ nonce: string }>(""); | ||
| const { results } = await api<{ results: { to: string; data: string | null; nonce: number }[] }>( | ||
| `multisig-transactions/?executed=false&nonce__gte=${nonce}`, | ||
| ); | ||
| if (results.some((tx) => tx.to === to && tx.data === data)) return; | ||
|
|
||
| // eslint-disable-next-line no-console | ||
| console.log(`${safe}: proposing`, `${await format(to)}.${functionName}`, await format(args)); | ||
| const domain = { chainId, verifyingContract: safeAddress }; | ||
| const safeTx = { | ||
| to, | ||
| value: 0, | ||
| data, | ||
| operation: 0, | ||
| safeTxGas: 0, | ||
| baseGas: 0, | ||
| gasPrice: 0, | ||
| gasToken: ZeroAddress, | ||
| refundReceiver: ZeroAddress, | ||
| nonce: Math.max(Number(nonce), ...results.map((tx) => Number(tx.nonce) + 1)), | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: sed -n '40,85p' deploy/.utils/multisigPropose.ts
rg -n 'multisigPropose\(|already queued|pending|results.some' deploy/.utils deploy testRepository: exactly/protocol Length of output: 2342 Fetch the full pending queue before both checks.
Fetch every page at the shared response boundary, then use the aggregated results for both the duplicate check and nonce calculation.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is the same point as the Codex thread above. Both checks read only the first page (100 pending transactions), and that's also how the Generated by Claude Code There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @cruzdanilo Thanks for clarifying. Given the existing first-page behavior, I overstated this as a major issue introduced by this PR. Pagination remains an edge-case limitation, but I accept leaving it unchanged unless the maintainer requests it. ✏️ Learnings added
You are interacting with an AI system. |
||
| }; | ||
| await api("multisig-transactions/", { | ||
| ...safeTx, | ||
| contractTransactionHash: TypedDataEncoder.hash(domain, types, safeTx), | ||
| sender, | ||
| signature: await (await ethers.getSigner(sender)).signTypedData(domain, types, safeTx), | ||
| origin: "deploy", | ||
| }); | ||
| } catch (error) { | ||
| if (senderAddress !== safeAddress) { | ||
| if (sender !== safeAddress) { | ||
| // eslint-disable-next-line no-console | ||
| console.log(`${safe}: error`, error instanceof Error ? error.message : error); | ||
| return printManual(safe, to, data); | ||
|
|
||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When this Safe has more pending transactions than the transaction service returns on its default page,
resultscan omit the transaction with the highest nonce. TheMath.maxcalculation then reuses an existing nonce and submits a competing proposal, rather than appending after all pending proposals as the previousgetNextNoncecall did. Request the highest nonce explicitly (for example, descending nonce ordering with a one-item limit) or follow pagination before calculating the new nonce.Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This isn't a regression. The previous
api-kit@2.4.6getNextNoncealso read only the first page ofgetPendingTransactions(nolimit, so the service default of 100), and its duplicate check did the same. Hitting this would take more than 100 unexecuted proposals queued on the multisig at once. For scale, the optimism multisig has 117 transactions in its entire history, and its pending queue is empty right now. Handling it properly would mean paginating both the duplicate check and the nonce scan, so I'm leaving this as is unless the maintainer wants it.Generated by Claude Code