@coordination-network/network-member-governance 0.0.1 → 0.1.0

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/README.md CHANGED
@@ -10,9 +10,9 @@ ours. What this package adds is the network member layer: turning an on-chain jo
10
10
  exact call that admits it.
11
11
 
12
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
13
+ [`NetworkMemberRegistry`](https://github.com/zk-kit/coordination-network/blob/main/packages/contracts/src/NetworkMemberRegistry.sol) and
14
+ [`NetworkMemberGroup`](https://github.com/zk-kit/coordination-network/blob/main/packages/contracts/src/NetworkMemberGroup.sol) are owned by the governance Safe, and
15
+ [`NetworkMemberGovernance`](https://github.com/zk-kit/coordination-network/blob/main/packages/contracts/src/NetworkMemberGovernance.sol) turns each governance
16
16
  operation into a single call only that Safe can make, so a member joins only when a majority of the
17
17
  already-approved members has approved the same transaction. This package is what a member server runs to
18
18
  take part: it never sends anything and holds no state, it turns a join request into the exact
@@ -35,8 +35,9 @@ One and two members are bootstrap states, not operating ones. Majority rule tole
35
35
  network permanently. Two is the worse of the two: if one of the pair goes dark, admitting a third needs
36
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
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)`
38
+ [runbook](https://github.com/zk-kit/coordination-network/blob/main/packages/contracts/docs/configuration/network-member-onboarding.md) has the full trade-offs. The rule is computed on chain by `NetworkMemberGovernance.governanceThreshold`, and no
39
+ operation takes a threshold argument, so no module call can move it. Direct Safe calls can; the
40
+ [runbook](https://github.com/zk-kit/coordination-network/blob/main/packages/contracts/docs/configuration/network-member-onboarding.md#the-governance-rule) lists the operating rules. `governanceThreshold(memberCount)`
40
41
  here is the same expression for off-chain use: checking that a Safe is configured correctly, and
41
42
  reporting what a pending operation will change the threshold to.
42
43
 
@@ -58,14 +59,23 @@ So a member never receives a transaction from anyone. Each approver derives the
58
59
  same event, which is why the approvals add up: identical inputs give an identical `safeTxHash`.
59
60
  Admission makes the new member an approver of the next candidate.
60
61
 
62
+ A candidate that holds its account key as a public key gets the request's key fields from
63
+ `memberAccountKey(publicKey)`, which accepts the 65-byte `0x04` form or the raw 64 bytes of `X || Y` and
64
+ returns `{ memberNetworkId, accountKeyX, accountKeyY }`.
65
+
61
66
  ### 1. Build the transaction from the request as the chain reported it
62
67
 
63
68
  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.
69
+ meaningful: the digest you approve covers the account key, the Semaphore commitment and the governance
70
+ signer you just vetted. The `networkId` is `keccak256(X || Y)` of the account key, and the registry
71
+ derives it from the key's coordinates, so approving a key approves exactly one id.
66
72
 
67
73
  ```ts
68
- import { buildAdmissionTransaction, decodeJoinRequestLog } from "@coordination-network/network-member-governance"
74
+ import {
75
+ NETWORK_MEMBER_JOIN_REQUESTED_EVENT,
76
+ buildAdmissionTransaction,
77
+ decodeJoinRequestLog
78
+ } from "@coordination-network/network-member-governance"
69
79
 
70
80
  const contracts = { governance, registry }
71
81
  const context = { safeAddress, chainId } // add safeVersion for a Safe older than the SDK default
@@ -73,9 +83,7 @@ const context = { safeAddress, chainId } // add safeVersion for a Safe older tha
73
83
  // The request comes from the candidate's own event, read from a node you trust and decoded here.
74
84
  const logs = await client.getLogs({
75
85
  address: registry,
76
- event: parseAbiItem(
77
- "event NetworkMemberJoinRequested(bytes32 indexed memberNetworkId, uint256 semaphoreCommitment, address governanceSigner, string metadataURI)"
78
- ),
86
+ event: NETWORK_MEMBER_JOIN_REQUESTED_EVENT,
79
87
  args: { memberNetworkId },
80
88
  fromBlock: REGISTRY_DEPLOY_BLOCK
81
89
  })
@@ -83,8 +91,8 @@ const logs = await client.getLogs({
83
91
  // A candidate may publish several requests before it is admitted, so there can be more than one log
84
92
  // here. `pickTheVettedRequest` is your own code: it must return the request your organisation actually
85
93
  // 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.
94
+ // of keys. Store your decision against a hash of the account key, the commitment, the signer and the
95
+ // URI, and treat a later request as a new decision.
88
96
  const admission = decodeJoinRequestLog(pickTheVettedRequest(logs), registry)
89
97
 
90
98
  // The Safe's nonce, read from the chain.
@@ -245,6 +253,6 @@ Only that member's governance signer can call it, so it reaches its own membersh
245
253
 
246
254
  ## References
247
255
 
248
- - [Network member onboarding and governance runbook](../contracts/docs/network-member-onboarding.md)
256
+ - [Network member onboarding and governance runbook](https://github.com/zk-kit/coordination-network/blob/main/packages/contracts/docs/configuration/network-member-onboarding.md)
249
257
  - [What is Safe](https://docs.safe.global/home/what-is-safe)
250
258
  - [Protocol Kit: signing transactions](https://docs.safe.global/sdk/protocol-kit/guides/signatures/transactions)
package/dist/index.cjs CHANGED
@@ -335,7 +335,8 @@ const NETWORK_MEMBER_GOVERNANCE_ABI = [
335
335
  name: "admitMember",
336
336
  stateMutability: "nonpayable",
337
337
  inputs: [
338
- { name: "memberNetworkId", type: "bytes32" },
338
+ { name: "accountKeyX", type: "uint256" },
339
+ { name: "accountKeyY", type: "uint256" },
339
340
  { name: "semaphoreCommitment", type: "uint256" },
340
341
  { name: "governanceSigner", type: "address" },
341
342
  { name: "expectedMemberCount", type: "uint256" }
@@ -378,6 +379,19 @@ const NETWORK_MEMBER_GOVERNANCE_ABI = [
378
379
  outputs: [{ name: "", type: "uint256" }]
379
380
  }
380
381
  ];
382
+ /** The event a candidate's join request emits, for watching with `getLogs`. */
383
+ const NETWORK_MEMBER_JOIN_REQUESTED_EVENT = {
384
+ type: "event",
385
+ name: "NetworkMemberJoinRequested",
386
+ inputs: [
387
+ { name: "memberNetworkId", type: "bytes32", indexed: true },
388
+ { name: "accountKeyX", type: "uint256", indexed: false },
389
+ { name: "accountKeyY", type: "uint256", indexed: false },
390
+ { name: "semaphoreCommitment", type: "uint256", indexed: false },
391
+ { name: "governanceSigner", type: "address", indexed: false },
392
+ { name: "metadataURI", type: "string", indexed: false }
393
+ ]
394
+ };
381
395
  /** The `NetworkMemberRegistry` surface a member server needs directly. */
382
396
  const NETWORK_MEMBER_REGISTRY_ABI = [
383
397
  {
@@ -385,23 +399,15 @@ const NETWORK_MEMBER_REGISTRY_ABI = [
385
399
  name: "requestToJoin",
386
400
  stateMutability: "nonpayable",
387
401
  inputs: [
388
- { name: "memberNetworkId", type: "bytes32" },
402
+ { name: "accountKeyX", type: "uint256" },
403
+ { name: "accountKeyY", type: "uint256" },
389
404
  { name: "semaphoreCommitment", type: "uint256" },
390
405
  { name: "governanceSigner", type: "address" },
391
406
  { name: "metadataURI", type: "string" }
392
407
  ],
393
408
  outputs: []
394
409
  },
395
- {
396
- type: "event",
397
- name: "NetworkMemberJoinRequested",
398
- inputs: [
399
- { name: "memberNetworkId", type: "bytes32", indexed: true },
400
- { name: "semaphoreCommitment", type: "uint256", indexed: false },
401
- { name: "governanceSigner", type: "address", indexed: false },
402
- { name: "metadataURI", type: "string", indexed: false }
403
- ]
404
- }
410
+ NETWORK_MEMBER_JOIN_REQUESTED_EVENT
405
411
  ];
406
412
  /**
407
413
  * Turns a `NetworkMemberJoinRequested` log into the request it announced.
@@ -428,13 +434,38 @@ function decodeJoinRequestLog(log, registry) {
428
434
  data: log.data,
429
435
  topics: log.topics
430
436
  });
437
+ const keyHash = viem.keccak256(viem.concat([viem.pad(viem.toHex(args.accountKeyX)), viem.pad(viem.toHex(args.accountKeyY))]));
438
+ if (args.memberNetworkId !== keyHash) {
439
+ throw new Error(`Log's memberNetworkId ${args.memberNetworkId} is not the hash of the account key it carries`);
440
+ }
431
441
  return {
432
442
  memberNetworkId: args.memberNetworkId,
443
+ accountKeyX: args.accountKeyX,
444
+ accountKeyY: args.accountKeyY,
433
445
  semaphoreCommitment: args.semaphoreCommitment,
434
446
  governanceSigner: viem.getAddress(args.governanceSigner),
435
447
  metadataURI: args.metadataURI
436
448
  };
437
449
  }
450
+ /**
451
+ * The networkId and key coordinates of a member's account key, the secp256k1 key whose address is its
452
+ * auth address.
453
+ *
454
+ * @param uncompressedPublicKey The public key: 65 bytes with the `0x04` prefix, or the raw 64 bytes of X || Y.
455
+ * @returns The coordinates, and `keccak256(X || Y)`, which is the id the registry derives from them.
456
+ */
457
+ function memberAccountKey(uncompressedPublicKey) {
458
+ // `size` rounds an odd digit count up, so the length is checked on the digits themselves.
459
+ if (!/^0x(04)?[0-9a-fA-F]{128}$/.test(uncompressedPublicKey)) {
460
+ throw new Error(`Expected a 64-byte X || Y or a 65-byte 0x04-prefixed public key of hex digits, got ${uncompressedPublicKey.length} characters`);
461
+ }
462
+ const coordinates = viem.slice(uncompressedPublicKey, viem.size(uncompressedPublicKey) - 64);
463
+ return {
464
+ memberNetworkId: viem.keccak256(coordinates),
465
+ accountKeyX: viem.hexToBigInt(viem.slice(coordinates, 0, 32)),
466
+ accountKeyY: viem.hexToBigInt(viem.slice(coordinates, 32))
467
+ };
468
+ }
438
469
  /**
439
470
  * The single call that admits a network member: its record, its group leaf, and its seat at the Safe.
440
471
  *
@@ -448,7 +479,8 @@ function encodeAdmitMember(admission, memberCount) {
448
479
  abi: NETWORK_MEMBER_GOVERNANCE_ABI,
449
480
  functionName: "admitMember",
450
481
  args: [
451
- admission.memberNetworkId,
482
+ admission.accountKeyX,
483
+ admission.accountKeyY,
452
484
  admission.semaphoreCommitment,
453
485
  viem.getAddress(admission.governanceSigner),
454
486
  BigInt(memberCount)
@@ -535,7 +567,7 @@ function encodeExitNetwork(memberNetworkId, merkleProofSiblings) {
535
567
  }
536
568
  /**
537
569
  * Calldata for a candidate's own join request, the one permissionless call in this flow. It must be
538
- * sent from the auth address the networkId encodes, and `governanceSigner` must be a different key.
570
+ * sent from the address of the account key, and `governanceSigner` must be a different key.
539
571
  *
540
572
  * @param request The request to announce.
541
573
  * @returns Calldata for the registry.
@@ -545,7 +577,8 @@ function encodeRequestToJoin(request) {
545
577
  abi: NETWORK_MEMBER_REGISTRY_ABI,
546
578
  functionName: "requestToJoin",
547
579
  args: [
548
- request.memberNetworkId,
580
+ request.accountKeyX,
581
+ request.accountKeyY,
549
582
  request.semaphoreCommitment,
550
583
  viem.getAddress(request.governanceSigner),
551
584
  request.metadataURI ?? ""
@@ -558,6 +591,7 @@ Object.defineProperty(exports, "OperationType", {
558
591
  get: function () { return typesKit.OperationType; }
559
592
  });
560
593
  exports.NETWORK_MEMBER_GOVERNANCE_ABI = NETWORK_MEMBER_GOVERNANCE_ABI;
594
+ exports.NETWORK_MEMBER_JOIN_REQUESTED_EVENT = NETWORK_MEMBER_JOIN_REQUESTED_EVENT;
561
595
  exports.NETWORK_MEMBER_REGISTRY_ABI = NETWORK_MEMBER_REGISTRY_ABI;
562
596
  exports.NO_REFUND = NO_REFUND;
563
597
  exports.ZERO_ADDRESS = ZERO_ADDRESS;
@@ -576,6 +610,7 @@ exports.encodeRequestToJoin = encodeRequestToJoin;
576
610
  exports.encodeSafeSignatures = encodeSafeSignatures;
577
611
  exports.governanceThreshold = governanceThreshold;
578
612
  exports.hashSafeTransaction = hashSafeTransaction;
613
+ exports.memberAccountKey = memberAccountKey;
579
614
  exports.onChainApproval = onChainApproval;
580
615
  exports.recoverSafeSigner = recoverSafeSigner;
581
616
  exports.removalCall = removalCall;
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { getAddress, encodeFunctionData, recoverAddress, isAddressEqual, decodeEventLog } from 'viem';
1
+ import { getAddress, encodeFunctionData, recoverAddress, isAddressEqual, decodeEventLog, keccak256, concat, pad, toHex, slice, size, hexToBigInt } from 'viem';
2
2
  import { encodeMultiSendData, buildSignatureBytes, calculateSafeTransactionHash, generatePreValidatedSignature, generateTypedData, EthSafeSignature, DEFAULT_SAFE_VERSION } from '@safe-global/protocol-kit';
3
3
  import { getMultiSendCallOnlyDeployment, getSafeSingletonDeployment } from '@safe-global/safe-deployments';
4
4
  import { OperationType } from '@safe-global/types-kit';
@@ -334,7 +334,8 @@ const NETWORK_MEMBER_GOVERNANCE_ABI = [
334
334
  name: "admitMember",
335
335
  stateMutability: "nonpayable",
336
336
  inputs: [
337
- { name: "memberNetworkId", type: "bytes32" },
337
+ { name: "accountKeyX", type: "uint256" },
338
+ { name: "accountKeyY", type: "uint256" },
338
339
  { name: "semaphoreCommitment", type: "uint256" },
339
340
  { name: "governanceSigner", type: "address" },
340
341
  { name: "expectedMemberCount", type: "uint256" }
@@ -377,6 +378,19 @@ const NETWORK_MEMBER_GOVERNANCE_ABI = [
377
378
  outputs: [{ name: "", type: "uint256" }]
378
379
  }
379
380
  ];
381
+ /** The event a candidate's join request emits, for watching with `getLogs`. */
382
+ const NETWORK_MEMBER_JOIN_REQUESTED_EVENT = {
383
+ type: "event",
384
+ name: "NetworkMemberJoinRequested",
385
+ inputs: [
386
+ { name: "memberNetworkId", type: "bytes32", indexed: true },
387
+ { name: "accountKeyX", type: "uint256", indexed: false },
388
+ { name: "accountKeyY", type: "uint256", indexed: false },
389
+ { name: "semaphoreCommitment", type: "uint256", indexed: false },
390
+ { name: "governanceSigner", type: "address", indexed: false },
391
+ { name: "metadataURI", type: "string", indexed: false }
392
+ ]
393
+ };
380
394
  /** The `NetworkMemberRegistry` surface a member server needs directly. */
381
395
  const NETWORK_MEMBER_REGISTRY_ABI = [
382
396
  {
@@ -384,23 +398,15 @@ const NETWORK_MEMBER_REGISTRY_ABI = [
384
398
  name: "requestToJoin",
385
399
  stateMutability: "nonpayable",
386
400
  inputs: [
387
- { name: "memberNetworkId", type: "bytes32" },
401
+ { name: "accountKeyX", type: "uint256" },
402
+ { name: "accountKeyY", type: "uint256" },
388
403
  { name: "semaphoreCommitment", type: "uint256" },
389
404
  { name: "governanceSigner", type: "address" },
390
405
  { name: "metadataURI", type: "string" }
391
406
  ],
392
407
  outputs: []
393
408
  },
394
- {
395
- type: "event",
396
- name: "NetworkMemberJoinRequested",
397
- inputs: [
398
- { name: "memberNetworkId", type: "bytes32", indexed: true },
399
- { name: "semaphoreCommitment", type: "uint256", indexed: false },
400
- { name: "governanceSigner", type: "address", indexed: false },
401
- { name: "metadataURI", type: "string", indexed: false }
402
- ]
403
- }
409
+ NETWORK_MEMBER_JOIN_REQUESTED_EVENT
404
410
  ];
405
411
  /**
406
412
  * Turns a `NetworkMemberJoinRequested` log into the request it announced.
@@ -427,13 +433,38 @@ function decodeJoinRequestLog(log, registry) {
427
433
  data: log.data,
428
434
  topics: log.topics
429
435
  });
436
+ const keyHash = keccak256(concat([pad(toHex(args.accountKeyX)), pad(toHex(args.accountKeyY))]));
437
+ if (args.memberNetworkId !== keyHash) {
438
+ throw new Error(`Log's memberNetworkId ${args.memberNetworkId} is not the hash of the account key it carries`);
439
+ }
430
440
  return {
431
441
  memberNetworkId: args.memberNetworkId,
442
+ accountKeyX: args.accountKeyX,
443
+ accountKeyY: args.accountKeyY,
432
444
  semaphoreCommitment: args.semaphoreCommitment,
433
445
  governanceSigner: getAddress(args.governanceSigner),
434
446
  metadataURI: args.metadataURI
435
447
  };
436
448
  }
449
+ /**
450
+ * The networkId and key coordinates of a member's account key, the secp256k1 key whose address is its
451
+ * auth address.
452
+ *
453
+ * @param uncompressedPublicKey The public key: 65 bytes with the `0x04` prefix, or the raw 64 bytes of X || Y.
454
+ * @returns The coordinates, and `keccak256(X || Y)`, which is the id the registry derives from them.
455
+ */
456
+ function memberAccountKey(uncompressedPublicKey) {
457
+ // `size` rounds an odd digit count up, so the length is checked on the digits themselves.
458
+ if (!/^0x(04)?[0-9a-fA-F]{128}$/.test(uncompressedPublicKey)) {
459
+ throw new Error(`Expected a 64-byte X || Y or a 65-byte 0x04-prefixed public key of hex digits, got ${uncompressedPublicKey.length} characters`);
460
+ }
461
+ const coordinates = slice(uncompressedPublicKey, size(uncompressedPublicKey) - 64);
462
+ return {
463
+ memberNetworkId: keccak256(coordinates),
464
+ accountKeyX: hexToBigInt(slice(coordinates, 0, 32)),
465
+ accountKeyY: hexToBigInt(slice(coordinates, 32))
466
+ };
467
+ }
437
468
  /**
438
469
  * The single call that admits a network member: its record, its group leaf, and its seat at the Safe.
439
470
  *
@@ -447,7 +478,8 @@ function encodeAdmitMember(admission, memberCount) {
447
478
  abi: NETWORK_MEMBER_GOVERNANCE_ABI,
448
479
  functionName: "admitMember",
449
480
  args: [
450
- admission.memberNetworkId,
481
+ admission.accountKeyX,
482
+ admission.accountKeyY,
451
483
  admission.semaphoreCommitment,
452
484
  getAddress(admission.governanceSigner),
453
485
  BigInt(memberCount)
@@ -534,7 +566,7 @@ function encodeExitNetwork(memberNetworkId, merkleProofSiblings) {
534
566
  }
535
567
  /**
536
568
  * Calldata for a candidate's own join request, the one permissionless call in this flow. It must be
537
- * sent from the auth address the networkId encodes, and `governanceSigner` must be a different key.
569
+ * sent from the address of the account key, and `governanceSigner` must be a different key.
538
570
  *
539
571
  * @param request The request to announce.
540
572
  * @returns Calldata for the registry.
@@ -544,7 +576,8 @@ function encodeRequestToJoin(request) {
544
576
  abi: NETWORK_MEMBER_REGISTRY_ABI,
545
577
  functionName: "requestToJoin",
546
578
  args: [
547
- request.memberNetworkId,
579
+ request.accountKeyX,
580
+ request.accountKeyY,
548
581
  request.semaphoreCommitment,
549
582
  getAddress(request.governanceSigner),
550
583
  request.metadataURI ?? ""
@@ -552,4 +585,4 @@ function encodeRequestToJoin(request) {
552
585
  });
553
586
  }
554
587
 
555
- export { NETWORK_MEMBER_GOVERNANCE_ABI, NETWORK_MEMBER_REGISTRY_ABI, NO_REFUND, ZERO_ADDRESS, admissionCall, buildAdmissionTransaction, buildRemovalTransaction, buildSafeTransactionData, checkQuorum, decodeJoinRequestLog, encodeAdmitMember, encodeApproveHash, encodeExecTransaction, encodeExitNetwork, encodeRemoveMember, encodeRequestToJoin, encodeSafeSignatures, governanceThreshold, hashSafeTransaction, onChainApproval, recoverSafeSigner, removalCall, resolveMultiSend, signSafeTransaction };
588
+ export { NETWORK_MEMBER_GOVERNANCE_ABI, NETWORK_MEMBER_JOIN_REQUESTED_EVENT, NETWORK_MEMBER_REGISTRY_ABI, NO_REFUND, ZERO_ADDRESS, admissionCall, buildAdmissionTransaction, buildRemovalTransaction, buildSafeTransactionData, checkQuorum, decodeJoinRequestLog, encodeAdmitMember, encodeApproveHash, encodeExecTransaction, encodeExitNetwork, encodeRemoveMember, encodeRequestToJoin, encodeSafeSignatures, governanceThreshold, hashSafeTransaction, memberAccountKey, onChainApproval, recoverSafeSigner, removalCall, resolveMultiSend, signSafeTransaction };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coordination-network/network-member-governance",
3
- "version": "0.0.1",
3
+ "version": "0.1.0",
4
4
  "description": "Governance Safe transactions for the network member registry: build the admission or removal transaction, sign it as an approved network member, and aggregate the signatures the Safe accepts on chain.",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/index.ts CHANGED
@@ -2,6 +2,7 @@ export { NO_REFUND, ZERO_ADDRESS } from "./constants"
2
2
  export { governanceThreshold } from "./governance-rule"
3
3
  export {
4
4
  NETWORK_MEMBER_GOVERNANCE_ABI,
5
+ NETWORK_MEMBER_JOIN_REQUESTED_EVENT,
5
6
  NETWORK_MEMBER_REGISTRY_ABI,
6
7
  admissionCall,
7
8
  buildAdmissionTransaction,
@@ -11,6 +12,7 @@ export {
11
12
  encodeExitNetwork,
12
13
  encodeRemoveMember,
13
14
  encodeRequestToJoin,
15
+ memberAccountKey,
14
16
  removalCall
15
17
  } from "./network-member-calls"
16
18
  export type {
@@ -1,5 +1,19 @@
1
1
  import type { MetaTransactionData, SafeTransactionData } from "@safe-global/types-kit"
2
- import { decodeEventLog, encodeFunctionData, getAddress, isAddressEqual, type Address, type Hex } from "viem"
2
+ import {
3
+ concat,
4
+ decodeEventLog,
5
+ encodeFunctionData,
6
+ getAddress,
7
+ hexToBigInt,
8
+ isAddressEqual,
9
+ keccak256,
10
+ pad,
11
+ size,
12
+ slice,
13
+ toHex,
14
+ type Address,
15
+ type Hex
16
+ } from "viem"
3
17
 
4
18
  import { buildSafeTransactionData } from "./safe-transaction"
5
19
  import type { SafeContext } from "./types"
@@ -22,7 +36,8 @@ export const NETWORK_MEMBER_GOVERNANCE_ABI = [
22
36
  name: "admitMember",
23
37
  stateMutability: "nonpayable",
24
38
  inputs: [
25
- { name: "memberNetworkId", type: "bytes32" },
39
+ { name: "accountKeyX", type: "uint256" },
40
+ { name: "accountKeyY", type: "uint256" },
26
41
  { name: "semaphoreCommitment", type: "uint256" },
27
42
  { name: "governanceSigner", type: "address" },
28
43
  { name: "expectedMemberCount", type: "uint256" }
@@ -66,6 +81,20 @@ export const NETWORK_MEMBER_GOVERNANCE_ABI = [
66
81
  }
67
82
  ] as const
68
83
 
84
+ /** The event a candidate's join request emits, for watching with `getLogs`. */
85
+ export const NETWORK_MEMBER_JOIN_REQUESTED_EVENT = {
86
+ type: "event",
87
+ name: "NetworkMemberJoinRequested",
88
+ inputs: [
89
+ { name: "memberNetworkId", type: "bytes32", indexed: true },
90
+ { name: "accountKeyX", type: "uint256", indexed: false },
91
+ { name: "accountKeyY", type: "uint256", indexed: false },
92
+ { name: "semaphoreCommitment", type: "uint256", indexed: false },
93
+ { name: "governanceSigner", type: "address", indexed: false },
94
+ { name: "metadataURI", type: "string", indexed: false }
95
+ ]
96
+ } as const
97
+
69
98
  /** The `NetworkMemberRegistry` surface a member server needs directly. */
70
99
  export const NETWORK_MEMBER_REGISTRY_ABI = [
71
100
  {
@@ -73,23 +102,15 @@ export const NETWORK_MEMBER_REGISTRY_ABI = [
73
102
  name: "requestToJoin",
74
103
  stateMutability: "nonpayable",
75
104
  inputs: [
76
- { name: "memberNetworkId", type: "bytes32" },
105
+ { name: "accountKeyX", type: "uint256" },
106
+ { name: "accountKeyY", type: "uint256" },
77
107
  { name: "semaphoreCommitment", type: "uint256" },
78
108
  { name: "governanceSigner", type: "address" },
79
109
  { name: "metadataURI", type: "string" }
80
110
  ],
81
111
  outputs: []
82
112
  },
83
- {
84
- type: "event",
85
- name: "NetworkMemberJoinRequested",
86
- inputs: [
87
- { name: "memberNetworkId", type: "bytes32", indexed: true },
88
- { name: "semaphoreCommitment", type: "uint256", indexed: false },
89
- { name: "governanceSigner", type: "address", indexed: false },
90
- { name: "metadataURI", type: "string", indexed: false }
91
- ]
92
- }
113
+ NETWORK_MEMBER_JOIN_REQUESTED_EVENT
93
114
  ] as const
94
115
 
95
116
  /** Where the governance operations live. */
@@ -108,8 +129,15 @@ export interface GovernanceContracts {
108
129
  * admission is a function of the candidate's own announcement.
109
130
  */
110
131
  export interface NetworkMemberJoinRequest {
111
- /** The candidate's networkId. */
132
+ /**
133
+ * The candidate's networkId, `keccak256(X || Y)` of its account key. Filled from the event topic
134
+ * when decoding, and ignored by the encoders, which pass the key.
135
+ */
112
136
  memberNetworkId: Hex
137
+ /** The X coordinate of the candidate's account key, a secp256k1 public key. */
138
+ accountKeyX: bigint
139
+ /** The Y coordinate of the candidate's account key. */
140
+ accountKeyY: bigint
113
141
  /** The Semaphore identity commitment it wants in the approved-member group. */
114
142
  semaphoreCommitment: bigint
115
143
  /** The address it wants added to the governance Safe, so it can vote on later candidates. */
@@ -167,14 +195,49 @@ export function decodeJoinRequestLog(log: JoinRequestLog, registry?: Address): N
167
195
  topics: log.topics as [signature: Hex, ...args: Hex[]]
168
196
  })
169
197
 
198
+ const keyHash = keccak256(concat([pad(toHex(args.accountKeyX)), pad(toHex(args.accountKeyY))]))
199
+ if (args.memberNetworkId !== keyHash) {
200
+ throw new Error(`Log's memberNetworkId ${args.memberNetworkId} is not the hash of the account key it carries`)
201
+ }
202
+
170
203
  return {
171
204
  memberNetworkId: args.memberNetworkId,
205
+ accountKeyX: args.accountKeyX,
206
+ accountKeyY: args.accountKeyY,
172
207
  semaphoreCommitment: args.semaphoreCommitment,
173
208
  governanceSigner: getAddress(args.governanceSigner),
174
209
  metadataURI: args.metadataURI
175
210
  }
176
211
  }
177
212
 
213
+ /**
214
+ * The networkId and key coordinates of a member's account key, the secp256k1 key whose address is its
215
+ * auth address.
216
+ *
217
+ * @param uncompressedPublicKey The public key: 65 bytes with the `0x04` prefix, or the raw 64 bytes of X || Y.
218
+ * @returns The coordinates, and `keccak256(X || Y)`, which is the id the registry derives from them.
219
+ */
220
+ export function memberAccountKey(uncompressedPublicKey: Hex): {
221
+ memberNetworkId: Hex
222
+ accountKeyX: bigint
223
+ accountKeyY: bigint
224
+ } {
225
+ // `size` rounds an odd digit count up, so the length is checked on the digits themselves.
226
+ if (!/^0x(04)?[0-9a-fA-F]{128}$/.test(uncompressedPublicKey)) {
227
+ throw new Error(
228
+ `Expected a 64-byte X || Y or a 65-byte 0x04-prefixed public key of hex digits, got ${uncompressedPublicKey.length} characters`
229
+ )
230
+ }
231
+
232
+ const coordinates = slice(uncompressedPublicKey, size(uncompressedPublicKey) - 64)
233
+
234
+ return {
235
+ memberNetworkId: keccak256(coordinates),
236
+ accountKeyX: hexToBigInt(slice(coordinates, 0, 32)),
237
+ accountKeyY: hexToBigInt(slice(coordinates, 32))
238
+ }
239
+ }
240
+
178
241
  /**
179
242
  * The single call that admits a network member: its record, its group leaf, and its seat at the Safe.
180
243
  *
@@ -188,7 +251,8 @@ export function encodeAdmitMember(admission: NetworkMemberJoinRequest, memberCou
188
251
  abi: NETWORK_MEMBER_GOVERNANCE_ABI,
189
252
  functionName: "admitMember",
190
253
  args: [
191
- admission.memberNetworkId,
254
+ admission.accountKeyX,
255
+ admission.accountKeyY,
192
256
  admission.semaphoreCommitment,
193
257
  getAddress(admission.governanceSigner),
194
258
  BigInt(memberCount)
@@ -302,7 +366,7 @@ export function encodeExitNetwork(memberNetworkId: Hex, merkleProofSiblings: big
302
366
 
303
367
  /**
304
368
  * Calldata for a candidate's own join request, the one permissionless call in this flow. It must be
305
- * sent from the auth address the networkId encodes, and `governanceSigner` must be a different key.
369
+ * sent from the address of the account key, and `governanceSigner` must be a different key.
306
370
  *
307
371
  * @param request The request to announce.
308
372
  * @returns Calldata for the registry.
@@ -312,7 +376,8 @@ export function encodeRequestToJoin(request: NetworkMemberJoinRequest): Hex {
312
376
  abi: NETWORK_MEMBER_REGISTRY_ABI,
313
377
  functionName: "requestToJoin",
314
378
  args: [
315
- request.memberNetworkId,
379
+ request.accountKeyX,
380
+ request.accountKeyY,
316
381
  request.semaphoreCommitment,
317
382
  getAddress(request.governanceSigner),
318
383
  request.metadataURI ?? ""
package/types/index.d.ts CHANGED
@@ -86,8 +86,11 @@ declare const NETWORK_MEMBER_GOVERNANCE_ABI: readonly [{
86
86
  readonly name: "admitMember";
87
87
  readonly stateMutability: "nonpayable";
88
88
  readonly inputs: readonly [{
89
- readonly name: "memberNetworkId";
90
- readonly type: "bytes32";
89
+ readonly name: "accountKeyX";
90
+ readonly type: "uint256";
91
+ }, {
92
+ readonly name: "accountKeyY";
93
+ readonly type: "uint256";
91
94
  }, {
92
95
  readonly name: "semaphoreCommitment";
93
96
  readonly type: "uint256";
@@ -148,14 +151,47 @@ declare const NETWORK_MEMBER_GOVERNANCE_ABI: readonly [{
148
151
  readonly type: "uint256";
149
152
  }];
150
153
  }];
154
+ /** The event a candidate's join request emits, for watching with `getLogs`. */
155
+ declare const NETWORK_MEMBER_JOIN_REQUESTED_EVENT: {
156
+ readonly type: "event";
157
+ readonly name: "NetworkMemberJoinRequested";
158
+ readonly inputs: readonly [{
159
+ readonly name: "memberNetworkId";
160
+ readonly type: "bytes32";
161
+ readonly indexed: true;
162
+ }, {
163
+ readonly name: "accountKeyX";
164
+ readonly type: "uint256";
165
+ readonly indexed: false;
166
+ }, {
167
+ readonly name: "accountKeyY";
168
+ readonly type: "uint256";
169
+ readonly indexed: false;
170
+ }, {
171
+ readonly name: "semaphoreCommitment";
172
+ readonly type: "uint256";
173
+ readonly indexed: false;
174
+ }, {
175
+ readonly name: "governanceSigner";
176
+ readonly type: "address";
177
+ readonly indexed: false;
178
+ }, {
179
+ readonly name: "metadataURI";
180
+ readonly type: "string";
181
+ readonly indexed: false;
182
+ }];
183
+ };
151
184
  /** The `NetworkMemberRegistry` surface a member server needs directly. */
152
185
  declare const NETWORK_MEMBER_REGISTRY_ABI: readonly [{
153
186
  readonly type: "function";
154
187
  readonly name: "requestToJoin";
155
188
  readonly stateMutability: "nonpayable";
156
189
  readonly inputs: readonly [{
157
- readonly name: "memberNetworkId";
158
- readonly type: "bytes32";
190
+ readonly name: "accountKeyX";
191
+ readonly type: "uint256";
192
+ }, {
193
+ readonly name: "accountKeyY";
194
+ readonly type: "uint256";
159
195
  }, {
160
196
  readonly name: "semaphoreCommitment";
161
197
  readonly type: "uint256";
@@ -174,6 +210,14 @@ declare const NETWORK_MEMBER_REGISTRY_ABI: readonly [{
174
210
  readonly name: "memberNetworkId";
175
211
  readonly type: "bytes32";
176
212
  readonly indexed: true;
213
+ }, {
214
+ readonly name: "accountKeyX";
215
+ readonly type: "uint256";
216
+ readonly indexed: false;
217
+ }, {
218
+ readonly name: "accountKeyY";
219
+ readonly type: "uint256";
220
+ readonly indexed: false;
177
221
  }, {
178
222
  readonly name: "semaphoreCommitment";
179
223
  readonly type: "uint256";
@@ -203,8 +247,15 @@ interface GovernanceContracts {
203
247
  * admission is a function of the candidate's own announcement.
204
248
  */
205
249
  interface NetworkMemberJoinRequest {
206
- /** The candidate's networkId. */
250
+ /**
251
+ * The candidate's networkId, `keccak256(X || Y)` of its account key. Filled from the event topic
252
+ * when decoding, and ignored by the encoders, which pass the key.
253
+ */
207
254
  memberNetworkId: Hex;
255
+ /** The X coordinate of the candidate's account key, a secp256k1 public key. */
256
+ accountKeyX: bigint;
257
+ /** The Y coordinate of the candidate's account key. */
258
+ accountKeyY: bigint;
208
259
  /** The Semaphore identity commitment it wants in the approved-member group. */
209
260
  semaphoreCommitment: bigint;
210
261
  /** The address it wants added to the governance Safe, so it can vote on later candidates. */
@@ -248,6 +299,18 @@ interface JoinRequestLog {
248
299
  * @returns The request the candidate published.
249
300
  */
250
301
  declare function decodeJoinRequestLog(log: JoinRequestLog, registry?: Address): NetworkMemberJoinRequest;
302
+ /**
303
+ * The networkId and key coordinates of a member's account key, the secp256k1 key whose address is its
304
+ * auth address.
305
+ *
306
+ * @param uncompressedPublicKey The public key: 65 bytes with the `0x04` prefix, or the raw 64 bytes of X || Y.
307
+ * @returns The coordinates, and `keccak256(X || Y)`, which is the id the registry derives from them.
308
+ */
309
+ declare function memberAccountKey(uncompressedPublicKey: Hex): {
310
+ memberNetworkId: Hex;
311
+ accountKeyX: bigint;
312
+ accountKeyY: bigint;
313
+ };
251
314
  /**
252
315
  * The single call that admits a network member: its record, its group leaf, and its seat at the Safe.
253
316
  *
@@ -317,7 +380,7 @@ declare function buildRemovalTransaction(contracts: GovernanceContracts, removal
317
380
  declare function encodeExitNetwork(memberNetworkId: Hex, merkleProofSiblings: bigint[]): Hex;
318
381
  /**
319
382
  * Calldata for a candidate's own join request, the one permissionless call in this flow. It must be
320
- * sent from the auth address the networkId encodes, and `governanceSigner` must be a different key.
383
+ * sent from the address of the account key, and `governanceSigner` must be a different key.
321
384
  *
322
385
  * @param request The request to announce.
323
386
  * @returns Calldata for the registry.
@@ -468,5 +531,5 @@ declare function checkQuorum(owners: Address[], threshold: number, signers: Addr
468
531
  */
469
532
  declare function encodeExecTransaction(context: SafeContext, transaction: SafeTransactionData, signatures: SafeSignature[]): Hex;
470
533
 
471
- export { NETWORK_MEMBER_GOVERNANCE_ABI, NETWORK_MEMBER_REGISTRY_ABI, NO_REFUND, ZERO_ADDRESS, admissionCall, buildAdmissionTransaction, buildRemovalTransaction, buildSafeTransactionData, checkQuorum, decodeJoinRequestLog, encodeAdmitMember, encodeApproveHash, encodeExecTransaction, encodeExitNetwork, encodeRemoveMember, encodeRequestToJoin, encodeSafeSignatures, governanceThreshold, hashSafeTransaction, onChainApproval, recoverSafeSigner, removalCall, resolveMultiSend, signSafeTransaction };
534
+ export { NETWORK_MEMBER_GOVERNANCE_ABI, NETWORK_MEMBER_JOIN_REQUESTED_EVENT, NETWORK_MEMBER_REGISTRY_ABI, NO_REFUND, ZERO_ADDRESS, admissionCall, buildAdmissionTransaction, buildRemovalTransaction, buildSafeTransactionData, checkQuorum, decodeJoinRequestLog, encodeAdmitMember, encodeApproveHash, encodeExecTransaction, encodeExitNetwork, encodeRemoveMember, encodeRequestToJoin, encodeSafeSignatures, governanceThreshold, hashSafeTransaction, memberAccountKey, onChainApproval, recoverSafeSigner, removalCall, resolveMultiSend, signSafeTransaction };
472
535
  export type { GovernanceContracts, JoinRequestLog, NetworkMemberJoinRequest, NetworkMemberRemoval, QuorumCheck, SafeContext, TypedDataSigner };