@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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ethereum Foundation
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,250 @@
1
+ # @coordination-network/network-member-governance
2
+
3
+ The off-chain half of network member governance: build the Safe transaction that admits or removes a
4
+ network member, approve it on chain as one of the members, and pack the approvals into the call that
5
+ executes it.
6
+
7
+ Built on the Safe team's own [Protocol Kit](https://docs.safe.global/sdk/protocol-kit) primitives, so
8
+ the digest, the version handling, the MultiSend encoding and the signature packing are Safe's code, not
9
+ ours. What this package adds is the network member layer: turning an on-chain join request into the
10
+ exact call that admits it.
11
+
12
+ Admission is not a call any single party can make. The
13
+ [`NetworkMemberRegistry`](../contracts/src/NetworkMemberRegistry.sol) and
14
+ [`NetworkMemberGroup`](../contracts/src/NetworkMemberGroup.sol) are owned by the governance Safe, and
15
+ [`NetworkMemberGovernance`](../contracts/src/NetworkMemberGovernance.sol) turns each governance
16
+ operation into a single call only that Safe can make, so a member joins only when a majority of the
17
+ already-approved members has approved the same transaction. This package is what a member server runs to
18
+ take part: it never sends anything and holds no state, it turns a join request into the exact
19
+ transaction to approve, and turns a set of approvals into the one `execTransaction` call that lands it.
20
+
21
+ ## Install
22
+
23
+ ```bash
24
+ yarn add @coordination-network/network-member-governance
25
+ ```
26
+
27
+ ## The governance rule
28
+
29
+ A transaction needs a majority of the current members, one more than half: five members need three,
30
+ eight need five. A network may start from one founding member, where the majority is that member alone,
31
+ and may shrink back to one, but never to nobody: the last member cannot leave.
32
+
33
+ One and two members are bootstrap states, not operating ones. Majority rule tolerates
34
+ `floor((n - 1) / 2)` lost keys, so neither size survives a single loss, and a lost signer freezes the
35
+ network permanently. Two is the worse of the two: if one of the pair goes dark, admitting a third needs
36
+ its signature and so does removing it, so nothing can change again. The only way out is for the live member to depart, which hands the network to the key
37
+ that already stopped working. Three is the first size that survives a lost key. The
38
+ [runbook](../contracts/docs/network-member-onboarding.md) has the full trade-offs. The rule is enforced on chain by `NetworkMemberGovernance.governanceThreshold`, and no
39
+ operation takes a threshold argument, so a caller cannot move it. `governanceThreshold(memberCount)`
40
+ here is the same expression for off-chain use: checking that a Safe is configured correctly, and
41
+ reporting what a pending operation will change the threshold to.
42
+
43
+ ## The flow
44
+
45
+ A candidate announces itself with `requestToJoin`, which is permissionless. From there, every approved
46
+ member server does the same four things independently, with no coordinator and nothing circulated:
47
+
48
+ 1. **Watch** `NetworkMemberJoinRequested` on the registry.
49
+ 2. **Decode** the log into a request. `decodeJoinRequestLog` does the field mapping.
50
+ 3. **Decide.** Vetting is your own code and lives outside this package: fetch the `metadataURI`, check
51
+ the keys, apply whatever policy your organisation has. If the answer is no, stop here. Nothing you
52
+ do or fail to do is visible on chain, and there is no vote against.
53
+ 4. **Approve on chain** by building the transaction from the request and calling `approveHash` on the
54
+ Safe. The member whose approval completes the quorum submits `execTransaction` instead, and the
55
+ admission executes.
56
+
57
+ So a member never receives a transaction from anyone. Each approver derives the same bytes from the
58
+ same event, which is why the approvals add up: identical inputs give an identical `safeTxHash`.
59
+ Admission makes the new member an approver of the next candidate.
60
+
61
+ ### 1. Build the transaction from the request as the chain reported it
62
+
63
+ Building it from the event, rather than from what someone tells you, is what makes approving
64
+ meaningful: the digest you approve covers the networkId, the Semaphore commitment and the governance
65
+ signer you just vetted.
66
+
67
+ ```ts
68
+ import { buildAdmissionTransaction, decodeJoinRequestLog } from "@coordination-network/network-member-governance"
69
+
70
+ const contracts = { governance, registry }
71
+ const context = { safeAddress, chainId } // add safeVersion for a Safe older than the SDK default
72
+
73
+ // The request comes from the candidate's own event, read from a node you trust and decoded here.
74
+ const logs = await client.getLogs({
75
+ address: registry,
76
+ event: parseAbiItem(
77
+ "event NetworkMemberJoinRequested(bytes32 indexed memberNetworkId, uint256 semaphoreCommitment, address governanceSigner, string metadataURI)"
78
+ ),
79
+ args: { memberNetworkId },
80
+ fromBlock: REGISTRY_DEPLOY_BLOCK
81
+ })
82
+
83
+ // A candidate may publish several requests before it is admitted, so there can be more than one log
84
+ // here. `pickTheVettedRequest` is your own code: it must return the request your organisation actually
85
+ // vetted, not simply the newest, because approving an organisation is not the same as approving a set
86
+ // of keys. Store your decision against a hash of these three fields, and treat a later request as a new
87
+ // decision.
88
+ const admission = decodeJoinRequestLog(pickTheVettedRequest(logs), registry)
89
+
90
+ // The Safe's nonce, read from the chain.
91
+ const nonce = await client.readContract({ address: safeAddress, abi: safeAbi, functionName: "nonce" })
92
+ const owners = await client.readContract({ address: safeAddress, abi: safeAbi, functionName: "getOwners" })
93
+
94
+ const transaction = buildAdmissionTransaction(contracts, admission, context, Number(nonce), owners.length)
95
+ ```
96
+
97
+ `contracts` is `{ governance, registry }`: the orchestrator, and the registry for reading records and
98
+ for a candidate's own join request. The result is one Safe transaction making one plain call to
99
+ `admitMember`. The owner count is pinned into that call so the transaction goes stale if the member set
100
+ changes before it executes: a voluntary exit shrinks the set through a module call, which does not
101
+ advance the Safe's nonce, so without the pin an in-flight signature set could reach a quorum it was
102
+ short of when signed. The orchestrator then writes the record, adds the group leaf, and has the Safe add the
103
+ new member's governance signer as an owner with the threshold it computes. All three land or none do,
104
+ and the threshold is never in the calldata.
105
+
106
+ **Every approver builds this independently, and that is the point.** The transaction is a pure function
107
+ of the on-chain request, the Safe's live owner set and the nonce, so independent builds are
108
+ byte-identical and hash to the same `safeTxHash`: approvals accumulate against one hash rather than
109
+ competing. What does compete is two different candidates at once, since both bind the same nonce; the
110
+ loser is rebuilt against the new nonce and approved again.
111
+
112
+ ### 2. Approve it on chain
113
+
114
+ Every approver runs the same code, and it sends one or two transactions depending on where the quorum
115
+ stands when it runs.
116
+
117
+ - **`approveHash` always.** This is how you say yes: it records your approval in the Safe's own
118
+ storage, from your own key.
119
+ - **`execTransaction` only if your approval completed the quorum.** Not a different way of
120
+ approving, an extra step for whoever happens to be last. Every earlier approver stops after the
121
+ first call.
122
+
123
+ Nobody knows in advance which one they will be, so both branches belong in every member's code.
124
+
125
+ ```ts
126
+ import {
127
+ checkQuorum,
128
+ encodeApproveHash,
129
+ encodeExecTransaction,
130
+ hashSafeTransaction,
131
+ onChainApproval
132
+ } from "@coordination-network/network-member-governance"
133
+
134
+ const txHash = hashSafeTransaction(context, transaction)
135
+
136
+ // Record your own approval. One transaction, from your own key.
137
+ await wallet.sendTransaction({ to: safeAddress, data: encodeApproveHash(context, txHash) })
138
+
139
+ // Then ask whether the quorum is now complete, counting yourself: as executor you occupy a slot
140
+ // without a prior approveHash, because the Safe counts the executor. Read the threshold from the
141
+ // Safe rather than computing it, since getThreshold is what it will enforce.
142
+ const threshold = await client.readContract({ address: safeAddress, abi: safeAbi, functionName: "getThreshold" })
143
+ const slots = [...approvers, myAddress]
144
+ const { hasQuorum } = checkQuorum(owners, Number(threshold), slots)
145
+
146
+ // Short of the quorum, you are done: your approval is on chain and a later member will submit.
147
+ // Submitting anyway would revert GS020, and naming an owner that never approved reverts GS025.
148
+ if (hasQuorum) {
149
+ const calldata = encodeExecTransaction(context, transaction, slots.map(onChainApproval))
150
+ await wallet.sendTransaction({ to: safeAddress, data: calldata })
151
+ }
152
+ ```
153
+
154
+ `checkQuorum` counts distinct addresses that are current owners, so it answers whether the approvals you
155
+ read off the chain are enough. `onChainApproval` produces the pre-validated `SafeSignature` the Safe
156
+ accepts in place of a real one (`v = 1`, `r` = the owner, `s = 0`), which is what goes into the
157
+ `signatures` argument of `encodeExecTransaction`.
158
+
159
+ Reading who has approved needs chain access, so it is yours: either `approvedHashes(owner, txHash)` per
160
+ owner, or the Safe's `ApproveHash(bytes32 indexed hash, address indexed owner)` event. Filter to
161
+ addresses that are **still** owners, since an approval from a departed member fails `GS026` at
162
+ execution.
163
+
164
+ Nothing is ever circulated here, so there is no channel to secure, and no approver acts on a payload
165
+ another party assembled: the only bytes anyone approves are bytes they derived themselves from the
166
+ chain. Blind approval is not merely detectable but impossible, which is why there is no verification
167
+ step in this package: it would compare your own build against the request you built it from.
168
+
169
+ `encodeExecTransaction` encodes against the Safe ABI for the context's version, sorts the entries by
170
+ address as the Safe requires, and refuses a set that counts the same owner twice.
171
+
172
+ ### Removing a member
173
+
174
+ Same shape, with the member's membership proof, which must be computed against the group state the
175
+ transaction will execute in:
176
+
177
+ ```ts
178
+ import { buildRemovalTransaction } from "@coordination-network/network-member-governance"
179
+
180
+ const transaction = buildRemovalTransaction(
181
+ contracts,
182
+ { memberNetworkId, merkleProofSiblings },
183
+ context,
184
+ Number(nonce),
185
+ owners.length
186
+ )
187
+ ```
188
+
189
+ Just the networkId and the proof: the orchestrator reads the member's commitment and governance signer
190
+ from the registry, so a removal cannot be pointed at another member's leaf or seat. It lowers the
191
+ threshold with the member count and refuses to shrink the set below its floor. Approving it works
192
+ exactly as in section 2.
193
+
194
+ `admissionCall` and `removalCall` return the underlying meta-transaction, if you would rather assemble
195
+ the Safe transaction yourself. `buildSafeTransactionData` is what both builders use: one call goes
196
+ through as a plain call, and several are packed into a MultiSend batch, which is the only place this
197
+ package produces a delegate call. Governance never needs a batch, so both builders pass a single call.
198
+
199
+ ### Leaving voluntarily
200
+
201
+ `encodeExitNetwork(memberNetworkId, merkleProofSiblings)` is calldata the member's own governance signer
202
+ sends **directly** to the orchestrator. Not a Safe transaction: departure needs no consent from the
203
+ members being left, so there is no transaction for anyone else to approve. It clears the member's record,
204
+ leaf and seat in one call, and hands the threshold back rather than leaving a seat nobody can sign for.
205
+
206
+ Only that member's governance signer can call it, so it reaches its own membership and no other.
207
+
208
+ ## What this package does not do
209
+
210
+ - **It does not talk to a chain.** No RPC, no provider: only the Protocol Kit's pure helpers are used,
211
+ never `Safe.init`. You read the join request event, the Safe's nonce and its owners, and you submit the
212
+ calldata. `decodeJoinRequestLog` turns a log you fetched into a request, so the field mapping is not
213
+ hand-rolled, but fetching it is yours.
214
+ - **It cannot tell where your inputs came from.** It builds a transaction from the request you hand
215
+ it, and cannot know whether that request was read from a chain or invented. Fetching the event from a
216
+ node you control is what makes your approval mean anything, and it is on you.
217
+ - **It does not decide anything.** Vetting a candidate is the approvers' judgement, and no code can
218
+ check the one thing that matters most: whether the candidate already holds a member slot under
219
+ another name. This package only encodes the decision.
220
+ - **It does not hold keys and sends nothing.** It returns calldata; submitting it from the member
221
+ server's governance key is yours.
222
+ - **It supports one flow only.** Approvals are recorded on chain with `approveHash`, so there is
223
+ nothing to distribute and no collector. Off-chain signing through the Safe Transaction Service or the
224
+ Safe web UI is deliberately out of scope: it would put approvers in front of payloads other parties
225
+ assembled, which is the risk this design removes. `signSafeTransaction` and `recoverSafeSigner`
226
+ remain as Safe primitives, but no part of the flow uses them.
227
+
228
+ ## Notes on correctness
229
+
230
+ - The digest comes from the SDK's `calculateSafeTransactionHash`, which is version-aware: Safe changed
231
+ both the EIP-712 domain and the `SafeTx` struct before 1.3.0, so `safeVersion` in the context is what
232
+ keeps the digest right on an older Safe. The tests still pin it against a hand-encoded oracle built
233
+ from the literal Safe typehashes.
234
+ - The contracts package tests governance against the real Safe contracts
235
+ (`@safe-global/safe-smart-account`): a proxy-factory deployment, a majority owner set, the
236
+ orchestrator enabled as a module, and `execTransaction` with approvals packed exactly as this package
237
+ packs them. A set that is short of quorum, or reused across transactions, is rejected by the Safe
238
+ itself.
239
+ - The threshold is not something this package can get wrong, because it never sends one. It is computed
240
+ on chain from the Safe's live owner count.
241
+ - Every approval is single-use: it covers the Safe's nonce, so executing any Safe transaction
242
+ invalidates every approval recorded against that nonce. A voluntary exit is the exception, because it
243
+ changes the owner set through a module call without advancing the nonce, which is exactly why the
244
+ member count is pinned into every admission and removal.
245
+
246
+ ## References
247
+
248
+ - [Network member onboarding and governance runbook](../contracts/docs/network-member-onboarding.md)
249
+ - [What is Safe](https://docs.safe.global/home/what-is-safe)
250
+ - [Protocol Kit: signing transactions](https://docs.safe.global/sdk/protocol-kit/guides/signatures/transactions)