@coordination-network/network-member-governance 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +250 -0
- package/dist/index.cjs +583 -0
- package/dist/index.js +555 -0
- package/package.json +54 -0
- package/src/constants.ts +19 -0
- package/src/governance-rule.ts +20 -0
- package/src/index.ts +43 -0
- package/src/network-member-calls.ts +321 -0
- package/src/safe-transaction.ts +361 -0
- package/src/types.ts +37 -0
- package/types/index.d.ts +472 -0
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_SAFE_VERSION,
|
|
3
|
+
EthSafeSignature,
|
|
4
|
+
buildSignatureBytes,
|
|
5
|
+
calculateSafeTransactionHash,
|
|
6
|
+
encodeMultiSendData,
|
|
7
|
+
generatePreValidatedSignature,
|
|
8
|
+
generateTypedData
|
|
9
|
+
} from "@safe-global/protocol-kit"
|
|
10
|
+
import { getMultiSendCallOnlyDeployment, getSafeSingletonDeployment } from "@safe-global/safe-deployments"
|
|
11
|
+
import {
|
|
12
|
+
OperationType,
|
|
13
|
+
type MetaTransactionData,
|
|
14
|
+
type SafeSignature,
|
|
15
|
+
type SafeTransactionData
|
|
16
|
+
} from "@safe-global/types-kit"
|
|
17
|
+
import {
|
|
18
|
+
encodeFunctionData,
|
|
19
|
+
getAddress,
|
|
20
|
+
isAddressEqual,
|
|
21
|
+
recoverAddress,
|
|
22
|
+
type Abi,
|
|
23
|
+
type Address,
|
|
24
|
+
type Hex,
|
|
25
|
+
type TypedDataDefinition
|
|
26
|
+
} from "viem"
|
|
27
|
+
|
|
28
|
+
import { NO_REFUND } from "./constants"
|
|
29
|
+
import type { QuorumCheck, SafeContext } from "./types"
|
|
30
|
+
|
|
31
|
+
function safeVersionOf(context: SafeContext) {
|
|
32
|
+
return context.safeVersion ?? DEFAULT_SAFE_VERSION
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function chainIdOf(context: SafeContext) {
|
|
36
|
+
return BigInt(context.chainId)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The MultiSendCallOnly ABI for this context's Safe version.
|
|
41
|
+
*
|
|
42
|
+
* Deliberately not chain-scoped. Only the address varies by chain, so resolving the ABI separately is
|
|
43
|
+
* what lets {SafeContext.multiSend} work on a chain with no canonical deployment, which is the only
|
|
44
|
+
* situation that override exists for.
|
|
45
|
+
*
|
|
46
|
+
* @param context The Safe version the batch is for.
|
|
47
|
+
* @returns The MultiSendCallOnly ABI.
|
|
48
|
+
*/
|
|
49
|
+
function multiSendAbi(context: SafeContext): Abi {
|
|
50
|
+
const deployment = getMultiSendCallOnlyDeployment({ version: safeVersionOf(context) })
|
|
51
|
+
|
|
52
|
+
if (!deployment) throw new Error(`No MultiSendCallOnly deployment for Safe ${safeVersionOf(context)}`)
|
|
53
|
+
|
|
54
|
+
return deployment.abi as Abi
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The canonical MultiSendCallOnly address for this context's chain.
|
|
59
|
+
*
|
|
60
|
+
* @param context The Safe and chain the batch is for.
|
|
61
|
+
* @returns The address, or throws when the chain has no canonical deployment.
|
|
62
|
+
*/
|
|
63
|
+
function multiSendAddress(context: SafeContext): Address {
|
|
64
|
+
const chainId = chainIdOf(context).toString()
|
|
65
|
+
const deployment = getMultiSendCallOnlyDeployment({ version: safeVersionOf(context), network: chainId })
|
|
66
|
+
|
|
67
|
+
if (!deployment?.networkAddresses[chainId]) {
|
|
68
|
+
throw new Error(
|
|
69
|
+
`No canonical MultiSendCallOnly deployment for chain ${chainId} at Safe ${safeVersionOf(context)}. Pass \`multiSend\` explicitly.`
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return getAddress(deployment.networkAddresses[chainId])
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The MultiSendCallOnly the Safe delegate-calls to batch several calls.
|
|
78
|
+
*
|
|
79
|
+
* Resolved from `@safe-global/safe-deployments` for this chain and Safe version,
|
|
80
|
+
* which is what keeps a wrong address out of a delegate call. Override it in the
|
|
81
|
+
* context only for a chain where the canonical deployment is missing.
|
|
82
|
+
*
|
|
83
|
+
* @param context The Safe and chain the batch is for.
|
|
84
|
+
* @returns The MultiSendCallOnly address.
|
|
85
|
+
*/
|
|
86
|
+
export function resolveMultiSend(context: SafeContext): Address {
|
|
87
|
+
if (context.multiSend) return getAddress(context.multiSend)
|
|
88
|
+
|
|
89
|
+
return multiSendAddress(context)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The Safe singleton ABI for this context's version, from the Safe deployments. */
|
|
93
|
+
function safeAbi(context: SafeContext): Abi {
|
|
94
|
+
const deployment = getSafeSingletonDeployment({ version: safeVersionOf(context) })
|
|
95
|
+
|
|
96
|
+
if (!deployment) throw new Error(`No Safe singleton deployment for version ${safeVersionOf(context)}`)
|
|
97
|
+
|
|
98
|
+
return deployment.abi as Abi
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Builds the Safe transaction data for one or more calls, with the gas and refund
|
|
103
|
+
* fields zeroed so whoever submits it pays.
|
|
104
|
+
*
|
|
105
|
+
* A single call is executed directly. Several calls are batched through
|
|
106
|
+
* MultiSendCallOnly as one delegate call, so the Safe applies them together or
|
|
107
|
+
* not at all.
|
|
108
|
+
*
|
|
109
|
+
* @param transactions The calls, in the order the Safe should make them.
|
|
110
|
+
* @param context The Safe and chain the transaction is for.
|
|
111
|
+
* @param nonce The Safe's current `nonce()`. Read it from the chain: a
|
|
112
|
+
* transaction built against a stale nonce is rejected, and executing any Safe
|
|
113
|
+
* transaction invalidates every approval recorded against that nonce.
|
|
114
|
+
* @returns The transaction data to hash and sign.
|
|
115
|
+
*/
|
|
116
|
+
export function buildSafeTransactionData(
|
|
117
|
+
transactions: MetaTransactionData[],
|
|
118
|
+
context: SafeContext,
|
|
119
|
+
nonce: number
|
|
120
|
+
): SafeTransactionData {
|
|
121
|
+
if (transactions.length === 0) throw new Error("A Safe transaction needs at least one call")
|
|
122
|
+
|
|
123
|
+
if (transactions.length === 1) {
|
|
124
|
+
const [transaction] = transactions
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
to: getAddress(transaction.to as Address),
|
|
128
|
+
value: transaction.value,
|
|
129
|
+
data: transaction.data,
|
|
130
|
+
operation: transaction.operation ?? OperationType.Call,
|
|
131
|
+
...NO_REFUND,
|
|
132
|
+
nonce
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// `encodeMultiSendData` packs the calls; MultiSendCallOnly still has to be
|
|
137
|
+
// called through its own `multiSend` entry point.
|
|
138
|
+
const packedCalls = encodeMultiSendData(
|
|
139
|
+
transactions.map((transaction) => ({
|
|
140
|
+
...transaction,
|
|
141
|
+
operation: transaction.operation ?? OperationType.Call
|
|
142
|
+
}))
|
|
143
|
+
) as Hex
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
to: resolveMultiSend(context),
|
|
147
|
+
value: "0",
|
|
148
|
+
data: encodeFunctionData({
|
|
149
|
+
abi: multiSendAbi(context),
|
|
150
|
+
functionName: "multiSend",
|
|
151
|
+
args: [packedCalls]
|
|
152
|
+
}),
|
|
153
|
+
operation: OperationType.DelegateCall,
|
|
154
|
+
...NO_REFUND,
|
|
155
|
+
nonce
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The digest the Safe's owners sign, as the Safe's own `getTransactionHash`
|
|
161
|
+
* computes it. Bound to the Safe and the chain by the EIP-712 domain, so a
|
|
162
|
+
* signature cannot be replayed against another Safe or another network.
|
|
163
|
+
*
|
|
164
|
+
* @param context The Safe and chain the transaction is for.
|
|
165
|
+
* @param transaction The transaction data to hash.
|
|
166
|
+
* @returns The EIP-712 digest.
|
|
167
|
+
*/
|
|
168
|
+
export function hashSafeTransaction(context: SafeContext, transaction: SafeTransactionData): Hex {
|
|
169
|
+
return calculateSafeTransactionHash(
|
|
170
|
+
getAddress(context.safeAddress),
|
|
171
|
+
transaction,
|
|
172
|
+
safeVersionOf(context),
|
|
173
|
+
chainIdOf(context)
|
|
174
|
+
) as Hex
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** What a member server needs from its governance key: EIP-712 signing. */
|
|
178
|
+
export interface TypedDataSigner {
|
|
179
|
+
/** The owner address this key signs for. */
|
|
180
|
+
address: Address
|
|
181
|
+
/** Signs EIP-712 typed data, returning a 65-byte signature. */
|
|
182
|
+
signTypedData(typedData: TypedDataDefinition): Promise<Hex>
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Signs a Safe transaction as one approver.
|
|
187
|
+
*
|
|
188
|
+
* Governance approves on chain with {encodeApproveHash} and never circulates a
|
|
189
|
+
* signature, so nothing in this package's flow calls this. It stays as a Safe
|
|
190
|
+
* primitive, and the tests use it to check the packing in
|
|
191
|
+
* {encodeSafeSignatures}.
|
|
192
|
+
*
|
|
193
|
+
* The signer must be an owner of the Safe. A viem account works directly, and so
|
|
194
|
+
* does any wrapper over a KMS or HSM that signs EIP-712 and knows its address.
|
|
195
|
+
*
|
|
196
|
+
* @param signer The approver's signing key.
|
|
197
|
+
* @param context The Safe and chain the transaction is for.
|
|
198
|
+
* @param transaction The transaction to approve.
|
|
199
|
+
* @returns The approver's signature.
|
|
200
|
+
*/
|
|
201
|
+
export async function signSafeTransaction(
|
|
202
|
+
signer: TypedDataSigner,
|
|
203
|
+
context: SafeContext,
|
|
204
|
+
transaction: SafeTransactionData
|
|
205
|
+
): Promise<SafeSignature> {
|
|
206
|
+
const typedData = generateTypedData({
|
|
207
|
+
safeAddress: getAddress(context.safeAddress),
|
|
208
|
+
safeVersion: safeVersionOf(context),
|
|
209
|
+
chainId: chainIdOf(context),
|
|
210
|
+
data: transaction
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
const signature = await signer.signTypedData(typedData as unknown as TypedDataDefinition)
|
|
214
|
+
|
|
215
|
+
return new EthSafeSignature(getAddress(signer.address), signature)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Recovers the owner behind a signature over a Safe transaction, trusting the
|
|
220
|
+
* signature rather than a claimed sender. Like {signSafeTransaction}, it serves
|
|
221
|
+
* off-chain signatures, which this package's flow does not produce.
|
|
222
|
+
*
|
|
223
|
+
* @param context The Safe and chain the transaction is for.
|
|
224
|
+
* @param transaction The signed transaction.
|
|
225
|
+
* @param signature The 65-byte signature.
|
|
226
|
+
* @returns The recovered signer address.
|
|
227
|
+
*/
|
|
228
|
+
export async function recoverSafeSigner(
|
|
229
|
+
context: SafeContext,
|
|
230
|
+
transaction: SafeTransactionData,
|
|
231
|
+
signature: Hex
|
|
232
|
+
): Promise<Address> {
|
|
233
|
+
return recoverAddress({ hash: hashSafeTransaction(context, transaction), signature })
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Calldata for `approveHash`, which records an approval in the Safe's own storage instead of handing a
|
|
238
|
+
* signature to somebody.
|
|
239
|
+
*
|
|
240
|
+
* This is the fully on-chain approval path, and its appeal is that nothing is ever circulated: each
|
|
241
|
+
* approver derives the transaction from the chain, approves its hash from its own key, and the member
|
|
242
|
+
* that completes the quorum reads the approvals and executes. There is no channel to secure, and no
|
|
243
|
+
* approver ever acts on a payload another party assembled, which is what makes blind signing
|
|
244
|
+
* impossible rather than merely detectable.
|
|
245
|
+
*
|
|
246
|
+
* Only an owner may call it, and the hash must be the one from {hashSafeTransaction}: an approval is
|
|
247
|
+
* bound to one exact transaction, so approving one candidate's admission says nothing about another's.
|
|
248
|
+
*
|
|
249
|
+
* @param context The Safe and chain the transaction is for.
|
|
250
|
+
* @param safeTxHash The digest from {hashSafeTransaction}.
|
|
251
|
+
* @returns Calldata to send to the Safe address, from the approver's own key.
|
|
252
|
+
*/
|
|
253
|
+
export function encodeApproveHash(context: SafeContext, safeTxHash: Hex): Hex {
|
|
254
|
+
return encodeFunctionData({
|
|
255
|
+
abi: safeAbi(context),
|
|
256
|
+
functionName: "approveHash",
|
|
257
|
+
args: [safeTxHash]
|
|
258
|
+
})
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* The signature entry for an owner that approved on chain with {encodeApproveHash}.
|
|
263
|
+
*
|
|
264
|
+
* It carries no signature material: the Safe reads `v == 1` and looks the approval up in its own
|
|
265
|
+
* storage, so the entry only names the approver. Feed these to {encodeSafeSignatures} or
|
|
266
|
+
* {encodeExecTransaction} exactly like signatures made off chain, and the two kinds may be mixed in one
|
|
267
|
+
* transaction.
|
|
268
|
+
*
|
|
269
|
+
* The member that submits gets its own entry for free: the Safe treats the executor as having approved,
|
|
270
|
+
* so it can include itself without a prior `approveHash`, turning a 3-of-5 into two approval
|
|
271
|
+
* transactions plus the execution.
|
|
272
|
+
*
|
|
273
|
+
* @param owner The approver, which must still be an owner when the transaction executes.
|
|
274
|
+
* @returns A signature entry naming that approver.
|
|
275
|
+
*/
|
|
276
|
+
export function onChainApproval(owner: Address): SafeSignature {
|
|
277
|
+
return generatePreValidatedSignature(getAddress(owner))
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Packs signatures into the `signatures` argument of `execTransaction`.
|
|
282
|
+
*
|
|
283
|
+
* The Safe reads the packed signatures at fixed 65-byte offsets and requires each recovered signer to be
|
|
284
|
+
* strictly greater than the previous one, so the SDK sorts them by signer here.
|
|
285
|
+
* A set that counts the same owner twice would sort fine and then be rejected on
|
|
286
|
+
* chain, so it is refused up front.
|
|
287
|
+
*
|
|
288
|
+
* @param signatures The approvals, in any order. {onChainApproval} produces them for the on-chain
|
|
289
|
+
* flow.
|
|
290
|
+
* @returns The entries concatenated into the single `bytes` value `execTransaction` takes, 65 bytes
|
|
291
|
+
* per owner, ordered by signer.
|
|
292
|
+
*/
|
|
293
|
+
export function encodeSafeSignatures(signatures: SafeSignature[]): Hex {
|
|
294
|
+
if (signatures.length === 0) throw new Error("A Safe transaction needs at least one signature")
|
|
295
|
+
|
|
296
|
+
const seen = new Set<string>()
|
|
297
|
+
signatures.forEach(({ signer }) => {
|
|
298
|
+
const owner = getAddress(signer as Address)
|
|
299
|
+
if (seen.has(owner)) throw new Error(`Duplicate signature from ${owner}: the Safe counts each owner once`)
|
|
300
|
+
seen.add(owner)
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
return buildSignatureBytes([...signatures]) as Hex
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Checks a set of approvers against the Safe's owners and threshold, so a member
|
|
308
|
+
* knows whether it can submit yet and, if not, how many approvals are missing.
|
|
309
|
+
* Addresses that are not current owners are ignored rather than counted.
|
|
310
|
+
*
|
|
311
|
+
* @param owners The Safe's current owners, from `getOwners()`.
|
|
312
|
+
* @param threshold The Safe's current `getThreshold()`.
|
|
313
|
+
* @param signers The addresses that have approved, read from the chain.
|
|
314
|
+
* @returns Whether the set is enough, and what is still missing.
|
|
315
|
+
*/
|
|
316
|
+
export function checkQuorum(owners: Address[], threshold: number, signers: Address[]): QuorumCheck {
|
|
317
|
+
const isOwner = (candidate: Address) => owners.some((owner) => isAddressEqual(owner, candidate))
|
|
318
|
+
const distinct = [...new Set(signers.filter(isOwner).map((signer) => getAddress(signer)))].sort((a, b) =>
|
|
319
|
+
BigInt(a) < BigInt(b) ? -1 : 1
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
return {
|
|
323
|
+
hasQuorum: distinct.length >= threshold,
|
|
324
|
+
signers: distinct,
|
|
325
|
+
missing: Math.max(threshold - distinct.length, 0)
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Encodes the `execTransaction` call that lands the transaction on chain, against
|
|
331
|
+
* the Safe ABI for this context's version. The result is plain calldata to the
|
|
332
|
+
* Safe, so anyone can submit it: the approvals carry the authority, not the
|
|
333
|
+
* sender.
|
|
334
|
+
*
|
|
335
|
+
* @param context The Safe and chain the transaction is for.
|
|
336
|
+
* @param transaction The approved transaction.
|
|
337
|
+
* @param signatures The approvals, in any order.
|
|
338
|
+
* @returns Calldata to send to the Safe address.
|
|
339
|
+
*/
|
|
340
|
+
export function encodeExecTransaction(
|
|
341
|
+
context: SafeContext,
|
|
342
|
+
transaction: SafeTransactionData,
|
|
343
|
+
signatures: SafeSignature[]
|
|
344
|
+
): Hex {
|
|
345
|
+
return encodeFunctionData({
|
|
346
|
+
abi: safeAbi(context),
|
|
347
|
+
functionName: "execTransaction",
|
|
348
|
+
args: [
|
|
349
|
+
transaction.to,
|
|
350
|
+
BigInt(transaction.value),
|
|
351
|
+
transaction.data,
|
|
352
|
+
transaction.operation,
|
|
353
|
+
BigInt(transaction.safeTxGas),
|
|
354
|
+
BigInt(transaction.baseGas),
|
|
355
|
+
BigInt(transaction.gasPrice),
|
|
356
|
+
transaction.gasToken,
|
|
357
|
+
transaction.refundReceiver,
|
|
358
|
+
encodeSafeSignatures(signatures)
|
|
359
|
+
]
|
|
360
|
+
})
|
|
361
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Address } from "viem"
|
|
2
|
+
|
|
3
|
+
export { OperationType } from "@safe-global/types-kit"
|
|
4
|
+
export type { MetaTransactionData, SafeSignature, SafeTransactionData, SafeVersion } from "@safe-global/types-kit"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Which Safe, on which chain, a transaction is bound to.
|
|
8
|
+
*
|
|
9
|
+
* `safeVersion` selects the encoding: Safe changed both the EIP-712 domain and
|
|
10
|
+
* the `SafeTx` struct across versions, so a digest computed for the wrong version
|
|
11
|
+
* is silently wrong. It defaults to the SDK's default version; read the Safe's
|
|
12
|
+
* own `VERSION()` and pass it when governing an older deployment.
|
|
13
|
+
*/
|
|
14
|
+
export interface SafeContext {
|
|
15
|
+
/** The governance Safe's address. */
|
|
16
|
+
safeAddress: Address
|
|
17
|
+
/** The chain the Safe is deployed on. */
|
|
18
|
+
chainId: number | bigint
|
|
19
|
+
/** The Safe's contract version. Defaults to the SDK's default. */
|
|
20
|
+
safeVersion?: import("@safe-global/types-kit").SafeVersion
|
|
21
|
+
/**
|
|
22
|
+
* MultiSendCallOnly to batch through. Defaults to the canonical deployment
|
|
23
|
+
* for this chain and Safe version, from `@safe-global/safe-deployments`. Pass
|
|
24
|
+
* one only for a chain where that deployment is missing.
|
|
25
|
+
*/
|
|
26
|
+
multiSend?: Address
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The result of checking a signature set against a Safe's owners. */
|
|
30
|
+
export interface QuorumCheck {
|
|
31
|
+
/** Whether the set is enough for the Safe to execute. */
|
|
32
|
+
hasQuorum: boolean
|
|
33
|
+
/** The distinct owners that signed, sorted ascending. */
|
|
34
|
+
signers: Address[]
|
|
35
|
+
/** How many more owner signatures are needed. Zero once quorum is met. */
|
|
36
|
+
missing: number
|
|
37
|
+
}
|