@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
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)
|