Skip to content

Commit 8b6af23

Browse files
authored
feat(protocol-contracts): task to get proposal arguments to change safe owners (#1756)
* feat(protocol-contracts): task to get proposal arguments to change safe owners * chore(protocol-contracts): retrigger CI * chore(protocol-contracts): linked issue for todo * chore(protocol-contracts): update task argument description
1 parent b05f1f0 commit 8b6af23

File tree

3 files changed

+468
-0
lines changed

3 files changed

+468
-0
lines changed

protocol-contracts/governance/hardhat.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { HardhatUserConfig, HttpNetworkAccountsUserConfig } from 'hardhat/types'
1414
import { EndpointId } from '@layerzerolabs/lz-definitions'
1515

1616
import './tasks/getLZOptions'
17+
import './tasks/getSafeOwnerChangeArgs'
1718
import './tasks/sendRemoteProposal'
1819
import './tasks/setAdminSafeModule'
1920

Lines changed: 329 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,329 @@
1+
import { Options } from '@layerzerolabs/lz-v2-utilities'
2+
import { task, types } from 'hardhat/config'
3+
import { HardhatRuntimeEnvironment } from 'hardhat/types'
4+
5+
// The SENTINEL_OWNERS is the head of the linked list in Safe's owner management
6+
const SENTINEL_OWNERS = '0x0000000000000000000000000000000000000001'
7+
8+
/**
9+
* Conservative gas estimates for Safe owner management operations.
10+
* These are intentionally high to ensure transactions don't run out of gas.
11+
* The actual gas usage will likely be lower.
12+
*
13+
* Formula: baseOverhead + (numOperations * gasPerOperation)
14+
* TODO: this could be optimized later with proper gas profiling,
15+
* see https://github.com/zama-ai/fhevm-internal/issues/660
16+
*/
17+
const GAS_ESTIMATES = {
18+
gasPerOperation: 100000, // Conservative estimate per Safe operation (swap/add/remove/changeThreshold)
19+
baseOverhead: 100000, // Base overhead for LZ receive, message decoding, AdminModule, Safe proxy
20+
}
21+
22+
interface SafeOwnerChangeOutput {
23+
targets: string[]
24+
values: string[]
25+
functionSignatures: string[]
26+
datas: string[]
27+
operations: number[]
28+
optionsBytes: string
29+
}
30+
31+
/**
32+
* Finds the previous owner in the linked list for a given owner.
33+
* In Safe, owners are stored as a linked list: SENTINEL -> owner1 -> owner2 -> ... -> ownerN -> SENTINEL
34+
* @param owners Array of current owners (in linked list order, first owner is pointed to by SENTINEL)
35+
* @param owner The owner to find the previous owner for
36+
* @returns The previous owner address (or SENTINEL if owner is the first)
37+
*/
38+
function findPrevOwner(owners: string[], owner: string): string {
39+
const ownerLower = owner.toLowerCase()
40+
const index = owners.findIndex((o) => o.toLowerCase() === ownerLower)
41+
if (index === -1) {
42+
throw new Error(`Owner ${owner} not found in owners list`)
43+
}
44+
if (index === 0) {
45+
return SENTINEL_OWNERS
46+
}
47+
return owners[index - 1]
48+
}
49+
50+
/**
51+
* Computes the sequence of Safe owner management calls needed to transform
52+
* the current owner set to the desired owner set with the new threshold.
53+
*
54+
* Strategy:
55+
* 1. First, swap owners that need to be replaced (keeping count stable)
56+
* 2. Then, either add new owners OR remove extra owners (never both, because new set of owners is either larger OR smaller than current set)
57+
* 3. Set the final threshold on the last add/remove to avoid extra changeThreshold call
58+
* 4. If only swaps (or no owner changes), add changeThreshold if needed
59+
*/
60+
async function computeOwnerChanges(
61+
hre: HardhatRuntimeEnvironment,
62+
currentOwners: string[],
63+
currentThreshold: number,
64+
newOwners: string[],
65+
newThreshold: number
66+
): Promise<SafeOwnerChangeOutput> {
67+
const targets: string[] = []
68+
const values: string[] = []
69+
const functionSignatures: string[] = []
70+
const datas: string[] = []
71+
const operations: number[] = []
72+
73+
const currentOwnersSet = new Set(currentOwners.map((o) => o.toLowerCase()))
74+
const newOwnersSet = new Set(newOwners.map((o) => o.toLowerCase()))
75+
76+
// Find owners to remove (in current but not in new)
77+
const ownersToRemove = currentOwners.filter((o) => !newOwnersSet.has(o.toLowerCase()))
78+
// Find owners to add (in new but not in current)
79+
const ownersToAdd = newOwners.filter((o) => !currentOwnersSet.has(o.toLowerCase()))
80+
// Find owners to keep (in both)
81+
const ownersToKeep = currentOwners.filter((o) => newOwnersSet.has(o.toLowerCase()))
82+
83+
console.log('\n📊 Owner change analysis:')
84+
console.log(` New owners (${newOwners.length}):`, newOwners)
85+
console.log(` Owners to keep (${ownersToKeep.length}):`, ownersToKeep)
86+
console.log(` Owners to remove (${ownersToRemove.length}):`, ownersToRemove)
87+
console.log(` Owners to add (${ownersToAdd.length}):`, ownersToAdd)
88+
console.log(` New threshold: ${newThreshold}`)
89+
90+
// Validate threshold
91+
if (newThreshold < 1) {
92+
throw new Error('Threshold must be at least 1')
93+
}
94+
if (newThreshold > newOwners.length) {
95+
throw new Error(`Threshold (${newThreshold}) cannot be greater than number of owners (${newOwners.length})`)
96+
}
97+
98+
// Track the current state as we build operations
99+
let workingOwners = [...currentOwners]
100+
101+
// Step 1: Perform swaps first (to replace owners while keeping count stable)
102+
// Match owners to remove with owners to add for swapping
103+
const swapCount = Math.min(ownersToRemove.length, ownersToAdd.length)
104+
105+
for (let i = 0; i < swapCount; i++) {
106+
const oldOwner = ownersToRemove[i]
107+
const newOwner = ownersToAdd[i]
108+
const prevOwner = findPrevOwner(workingOwners, oldOwner)
109+
110+
console.log(`\n🔄 Swap: ${oldOwner} -> ${newOwner} (prev: ${prevOwner})`)
111+
112+
targets.push('SAFE_ADDRESS') // Placeholder, will be replaced
113+
values.push('0')
114+
functionSignatures.push('swapOwner(address,address,address)')
115+
datas.push(
116+
hre.ethers.utils.defaultAbiCoder.encode(['address', 'address', 'address'], [prevOwner, oldOwner, newOwner])
117+
)
118+
operations.push(0) // Operation.Call
119+
120+
// Update working owners
121+
const oldIndex = workingOwners.findIndex((o) => o.toLowerCase() === oldOwner.toLowerCase())
122+
workingOwners[oldIndex] = newOwner
123+
}
124+
125+
// Note: We never have both remainingToAdd > 0 AND remainingToRemove > 0
126+
// because swapCount = min(ownersToRemove.length, ownersToAdd.length)
127+
const remainingToAdd = ownersToAdd.slice(swapCount)
128+
const remainingToRemove = ownersToRemove.slice(swapCount)
129+
130+
// Step 2-uno: Add remaining new owners (only if newOwners.length > currentOwners.length)
131+
if (remainingToAdd.length > 0) {
132+
for (let i = 0; i < remainingToAdd.length; i++) {
133+
const newOwner = remainingToAdd[i]
134+
const isLastAdd = i === remainingToAdd.length - 1
135+
// Use final threshold on last add, otherwise use 1 (safe intermediate value)
136+
const addThreshold = isLastAdd ? newThreshold : 1
137+
138+
console.log(
139+
`\n➕ Add owner: ${newOwner} (threshold: ${addThreshold}${isLastAdd ? ' - final' : ' - intermediate'})`
140+
)
141+
142+
targets.push('SAFE_ADDRESS')
143+
values.push('0')
144+
functionSignatures.push('addOwnerWithThreshold(address,uint256)')
145+
datas.push(hre.ethers.utils.defaultAbiCoder.encode(['address', 'uint256'], [newOwner, addThreshold]))
146+
operations.push(0) // Operation.Call
147+
148+
// Update working owners (new owners are added at the beginning of the list)
149+
workingOwners = [newOwner, ...workingOwners]
150+
}
151+
}
152+
153+
// Step 2-bis (exclusive with step 2-uno): Remove remaining old owners (only if newOwners.length < currentOwners.length)
154+
if (remainingToRemove.length > 0) {
155+
for (let i = 0; i < remainingToRemove.length; i++) {
156+
const oldOwner = remainingToRemove[i]
157+
const prevOwner = findPrevOwner(workingOwners, oldOwner)
158+
const isLastRemove = i === remainingToRemove.length - 1
159+
// Use final threshold on last remove, otherwise use 1 (safe intermediate value)
160+
const removeThreshold = isLastRemove ? newThreshold : 1
161+
162+
console.log(
163+
`\n➖ Remove owner: ${oldOwner} (prev: ${prevOwner}, threshold: ${removeThreshold}${isLastRemove ? ' - final' : ' - intermediate'})`
164+
)
165+
166+
targets.push('SAFE_ADDRESS')
167+
values.push('0')
168+
functionSignatures.push('removeOwner(address,address,uint256)')
169+
datas.push(
170+
hre.ethers.utils.defaultAbiCoder.encode(
171+
['address', 'address', 'uint256'],
172+
[prevOwner, oldOwner, removeThreshold]
173+
)
174+
)
175+
operations.push(0) // Operation.Call
176+
177+
// Update working owners
178+
workingOwners = workingOwners.filter((o) => o.toLowerCase() !== oldOwner.toLowerCase())
179+
}
180+
}
181+
182+
// Step 4: Set final threshold only if:
183+
// - No adds and no removes happened (only swaps or no owner changes)
184+
// - AND threshold needs to change
185+
if (remainingToAdd.length === 0 && remainingToRemove.length === 0 && currentThreshold !== newThreshold) {
186+
console.log(`\n🔢 Change threshold: ${currentThreshold} -> ${newThreshold}`)
187+
188+
targets.push('SAFE_ADDRESS')
189+
values.push('0')
190+
functionSignatures.push('changeThreshold(uint256)')
191+
datas.push(hre.ethers.utils.defaultAbiCoder.encode(['uint256'], [newThreshold]))
192+
operations.push(0) // Operation.Call
193+
}
194+
195+
// Calculate estimated gas using conservative formula
196+
const numOperations = targets.length
197+
const estimatedGas = GAS_ESTIMATES.baseOverhead + numOperations * GAS_ESTIMATES.gasPerOperation
198+
199+
console.log(`\n✅ Total operations: ${numOperations}`)
200+
console.log(`⛽ Estimated gas (conservative): ${estimatedGas}`)
201+
202+
// Generate LZ options bytes using estimated gas
203+
const optionsBytes = Options.newOptions().addExecutorLzReceiveOption(estimatedGas, 0).toHex().toString()
204+
205+
return { targets, values, functionSignatures, datas, operations, optionsBytes }
206+
}
207+
208+
// Usage: npx hardhat task:getSafeOwnerChangeArgs --safe 0x... --new-owners "0x...,0x...,0x..." --threshold 2 --network gateway-mainnet
209+
task('task:getSafeOwnerChangeArgs', 'Computes sendRemoteProposal arguments to change Safe owners and threshold')
210+
.addParam('safe', 'Address of the deployed Safe contract', undefined, types.string)
211+
.addParam(
212+
'newOwners',
213+
'Comma-separated list of new owner addresses which is replacing the current list of owners',
214+
undefined,
215+
types.string
216+
)
217+
.addParam('threshold', 'New threshold value', undefined, types.int)
218+
.setAction(async function (
219+
taskArgs: { safe: string; newOwners: string; threshold: number },
220+
hre: HardhatRuntimeEnvironment
221+
) {
222+
const safeAddress = hre.ethers.utils.getAddress(taskArgs.safe)
223+
const newThreshold = taskArgs.threshold
224+
225+
// Parse and validate new owners
226+
const rawOwners = taskArgs.newOwners.split(',').map((addr) => addr.trim())
227+
const newOwners: string[] = []
228+
229+
for (const addr of rawOwners) {
230+
// Check if address is valid
231+
if (!hre.ethers.utils.isAddress(addr)) {
232+
throw new Error(`❌ Invalid address format: "${addr}"`)
233+
}
234+
newOwners.push(hre.ethers.utils.getAddress(addr)) // Normalize to checksummed format
235+
}
236+
237+
// Check for duplicates
238+
const seenAddresses = new Set<string>()
239+
for (const addr of newOwners) {
240+
const lowerAddr = addr.toLowerCase()
241+
if (seenAddresses.has(lowerAddr)) {
242+
throw new Error(`❌ Duplicate owner address found: "${addr}"`)
243+
}
244+
seenAddresses.add(lowerAddr)
245+
}
246+
247+
// Check that we have at least one owner
248+
if (newOwners.length === 0) {
249+
throw new Error('❌ At least one owner address must be provided')
250+
}
251+
252+
console.log('\n🔐 Safe Owner Change Arguments Generator')
253+
console.log('=========================================')
254+
console.log(`Safe address: ${safeAddress}`)
255+
console.log(`Network: ${hre.network.name}`)
256+
257+
// Get the Safe contract to read current owners
258+
const safeAbi = [
259+
'function getOwners() view returns (address[])',
260+
'function getThreshold() view returns (uint256)',
261+
]
262+
const safeContract = new hre.ethers.Contract(safeAddress, safeAbi, hre.ethers.provider)
263+
264+
let currentOwners: string[]
265+
let currentThreshold: number
266+
try {
267+
currentOwners = await safeContract.getOwners()
268+
currentThreshold = (await safeContract.getThreshold()).toNumber()
269+
console.log(`\n📋 Current state:`)
270+
console.log(` Owners (${currentOwners.length}):`, currentOwners)
271+
console.log(` Threshold: ${currentThreshold}`)
272+
} catch (error) {
273+
console.error(
274+
'❌ Failed to read Safe contract. Make sure the address is correct and the network is configured.'
275+
)
276+
throw error
277+
}
278+
279+
// Check if any changes are needed
280+
const newOwnersSet = new Set(newOwners.map((o) => o.toLowerCase()))
281+
const ownersMatch =
282+
currentOwners.length === newOwners.length && currentOwners.every((o) => newOwnersSet.has(o.toLowerCase()))
283+
284+
if (ownersMatch && currentThreshold === newThreshold) {
285+
console.log('\n✅ No changes needed. Current state matches desired state.')
286+
return
287+
}
288+
289+
// Compute the owner changes
290+
const result = await computeOwnerChanges(hre, currentOwners, currentThreshold, newOwners, newThreshold)
291+
292+
// Replace placeholders with actual Safe address
293+
const finalTargets = result.targets.map(() => safeAddress)
294+
295+
// Helper to format array without quotes
296+
const formatArray = (arr: (string | number)[]) => '[\n' + arr.map((v) => ` ${v}`).join('\n') + '\n]'
297+
298+
// Output the arguments
299+
console.log('\n' + '='.repeat(80))
300+
console.log('📤 sendRemoteProposal ARGUMENTS (to be used via the Aragon DAO)')
301+
console.log('='.repeat(80))
302+
303+
console.log('\n// targets (address[]):')
304+
console.log(formatArray(finalTargets))
305+
306+
console.log('\n// values (uint256[]):')
307+
console.log(formatArray(result.values))
308+
309+
console.log('\n// functionSignatures (string[]):')
310+
console.log(formatArray(result.functionSignatures))
311+
312+
console.log('\n// datas (bytes[]) - ABI encoded parameters:')
313+
console.log(formatArray(result.datas))
314+
315+
console.log('\n// operations (Operation[]) - 0 = Call, 1 = DelegateCall:')
316+
console.log(formatArray(result.operations))
317+
318+
console.log('\n// options (bytes) - LayerZero execution options:')
319+
console.log(result.optionsBytes)
320+
321+
return {
322+
targets: finalTargets,
323+
values: result.values,
324+
functionSignatures: result.functionSignatures,
325+
datas: result.datas,
326+
operations: result.operations,
327+
options: result.optionsBytes,
328+
}
329+
})

0 commit comments

Comments
 (0)