@cofhe/sdk 0.8.0 → 1.0.0-alpha.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.
Files changed (84) hide show
  1. package/acps/acp.ts +25 -6
  2. package/acps/index.ts +20 -15
  3. package/acps/registry.ts +166 -8
  4. package/acps/sealing.ts +9 -3
  5. package/acps/share-metadata/appKey.ts +8 -0
  6. package/acps/share-metadata/decode.ts +120 -0
  7. package/acps/share-metadata/describe.ts +198 -0
  8. package/acps/share-metadata/encode.ts +147 -0
  9. package/acps/share-metadata/erc7984.ts +26 -0
  10. package/acps/share-metadata/index.ts +9 -0
  11. package/acps/share-metadata/layout.ts +69 -0
  12. package/acps/share-metadata/presets.ts +116 -0
  13. package/acps/share-metadata/schema.ts +47 -0
  14. package/acps/share-metadata/test/codec.test.ts +140 -0
  15. package/acps/share-metadata/test/describe.test.ts +112 -0
  16. package/acps/share-metadata/test/fixtures.ts +157 -0
  17. package/acps/share-metadata/test/live.test.ts +56 -0
  18. package/acps/share-metadata/test/presets.test.ts +119 -0
  19. package/acps/share-metadata/test/verify.test.ts +144 -0
  20. package/acps/share-metadata/verify.ts +155 -0
  21. package/acps/store.ts +48 -18
  22. package/acps/test/acp.test.ts +102 -7
  23. package/acps/test/localstorage.test.ts +28 -1
  24. package/acps/test/registry.test.ts +144 -0
  25. package/acps/test/sealing.test.ts +23 -0
  26. package/acps/test/store.test.ts +71 -0
  27. package/acps/test/utils.test.ts +63 -0
  28. package/acps/test/validation.test.ts +0 -4
  29. package/acps/types.ts +43 -6
  30. package/acps/utils.ts +15 -2
  31. package/acps/validation.ts +3 -1
  32. package/core/acps.ts +123 -74
  33. package/core/client.ts +33 -7
  34. package/core/clientTypes.ts +25 -7
  35. package/core/config.ts +8 -0
  36. package/core/decrypt/apiError.ts +4 -1
  37. package/core/decrypt/verifyDecryptResult.ts +4 -1
  38. package/core/encrypt/cofheMocksZkVerifySign.ts +19 -2
  39. package/core/test/acpDefaults.test.ts +108 -19
  40. package/core/test/acpStoreAccountCase.test.ts +80 -0
  41. package/core/test/acps.test.ts +38 -3
  42. package/core/test/apiError.test.ts +8 -0
  43. package/core/test/appKeyConfig.test.ts +94 -0
  44. package/core/test/config.test.ts +8 -0
  45. package/core/test/decryptBuilders.test.ts +110 -2
  46. package/core/test/incomingShares.test.ts +84 -0
  47. package/core/test/mocksEncryptRange.test.ts +90 -0
  48. package/core/test/utils.test.ts +128 -0
  49. package/core/test/verifyDecryptResult.test.ts +51 -0
  50. package/core/utils.ts +16 -9
  51. package/dist/acps.cjs +674 -33
  52. package/dist/acps.d.cts +367 -35
  53. package/dist/acps.d.ts +367 -35
  54. package/dist/acps.js +2 -2
  55. package/dist/chunk-6ZW76ZKB.js +228 -0
  56. package/dist/{chunk-UJYOBHYA.js → chunk-BQWHPIYU.js} +660 -198
  57. package/dist/{chunk-GGI7Y6AW.js → chunk-OL3DZN7A.js} +110 -269
  58. package/dist/chunk-XQ32JG3T.js +174 -0
  59. package/dist/{clientTypes-D3aIXmUp.d.cts → clientTypes-C0A9ji5O.d.cts} +61 -21
  60. package/dist/{clientTypes-5YPPR16m.d.ts → clientTypes-CcU5WvdV.d.ts} +61 -21
  61. package/dist/core.cjs +3239 -2731
  62. package/dist/core.d.cts +6 -3
  63. package/dist/core.d.ts +6 -3
  64. package/dist/core.js +4 -3
  65. package/dist/node.cjs +3046 -2538
  66. package/dist/node.d.cts +2 -2
  67. package/dist/node.d.ts +2 -2
  68. package/dist/node.js +18 -17
  69. package/dist/{onchain-utils-CUAK8glx.d.cts → onchain-utils-D9Z33IP7.d.cts} +136 -12
  70. package/dist/{onchain-utils-CUAK8glx.d.ts → onchain-utils-D9Z33IP7.d.ts} +136 -12
  71. package/dist/web.cjs +3074 -2573
  72. package/dist/web.d.cts +2 -2
  73. package/dist/web.d.ts +2 -2
  74. package/dist/web.js +4 -10
  75. package/dist/zkProve.worker.cjs +14 -7
  76. package/dist/zkProve.worker.js +2 -8
  77. package/node/index.ts +1 -10
  78. package/node/storage.ts +18 -7
  79. package/node/test/storage-permissions.test.ts +40 -0
  80. package/package.json +4 -4
  81. package/web/index.ts +1 -10
  82. package/web/test/threadPoolBenchHelper.ts +1 -7
  83. package/web/zkProve.worker.ts +1 -10
  84. package/dist/chunk-NMKMCAI3.js +0 -14
package/acps/acp.ts CHANGED
@@ -27,6 +27,11 @@ import {
27
27
  import { SignatureUtils } from './signature.js';
28
28
  import { GenerateSealingKey, unsealWithPrivateKey } from './sealing.js';
29
29
  import { checkACPValidityOnChain, getACPAccessStatusOnChain, getAclEIP712Domain } from './onchain-utils.js';
30
+ import { encodeShareMetadata } from './share-metadata/encode.js';
31
+ import { UNLABELLED } from './share-metadata/presets';
32
+
33
+ /** The name of an imported acp unless the recipient gives one: who it came from. */
34
+ const sharedByName = (issuer: Hex): string => `Shared by ${issuer.slice(0, 6)}…${issuer.slice(-4)}`;
30
35
 
31
36
  /**
32
37
  * Main ACP utilities - functional approach for React compatibility
@@ -57,6 +62,15 @@ export const ACPUtils = {
57
62
  */
58
63
  createSharing: (options: CreateSharingACPOptions): SharingACP => {
59
64
  const validation = validateSharingACPOptions(options);
65
+ const { labels, appKey } = options;
66
+ if (labels != null && labels.length !== validation.handles.length) {
67
+ throw new Error(`Cannot label a share of ${validation.handles.length} handles with ${labels.length} labels`);
68
+ }
69
+ // With an app key but no labels, the handles are listed as unlabelled so the key has a blob to sit in.
70
+ const metadata =
71
+ labels != null || appKey != null
72
+ ? encodeShareMetadata(labels ?? validation.handles.map(() => UNLABELLED), { issuer: validation.issuer, appKey })
73
+ : undefined;
60
74
 
61
75
  // Always generate a new sealing key - users cannot provide their own
62
76
  const sealingPair = GenerateSealingKey();
@@ -64,6 +78,7 @@ export const ACPUtils = {
64
78
  const acp = {
65
79
  hash: ACPUtils.getHash(validation),
66
80
  ...validation,
81
+ ...(metadata != null && { metadata }),
67
82
  sealingPrivateKey: sealingPair.privateKey,
68
83
  sealingKey: sealingPair.publicKey,
69
84
  _signedDomain: undefined,
@@ -73,9 +88,10 @@ export const ACPUtils = {
73
88
  },
74
89
 
75
90
  /**
76
- * Import a shared acp from various input formats
91
+ * Import a shared acp from various input formats. The acp is named `name`, or after its issuer
92
+ * ("Shared by 0x1234…abcd"); a `name` in the share itself is ignored.
77
93
  */
78
- importShared: (options: ImportSharedACPOptions | string): RecipientACP => {
94
+ importShared: (options: ImportSharedACPOptions | string, { name }: { name?: string } = {}): RecipientACP => {
79
95
  let parsedOptions: ImportSharedACPOptions;
80
96
 
81
97
  // Handle different input types
@@ -106,6 +122,7 @@ export const ACPUtils = {
106
122
  const acp = {
107
123
  hash: ACPUtils.getHash(validation),
108
124
  ...validation,
125
+ name: name?.trim() || sharedByName(validation.issuer),
109
126
  sealingPrivateKey: sealingPair.privateKey,
110
127
  sealingKey: sealingPair.publicKey,
111
128
  _signedDomain: undefined,
@@ -184,9 +201,10 @@ export const ACPUtils = {
184
201
  importSharedAndSign: async (
185
202
  options: ImportSharedACPOptions | string,
186
203
  publicClient: PublicClient,
187
- walletClient: WalletClient
204
+ walletClient: WalletClient,
205
+ importOptions: { name?: string } = {}
188
206
  ): Promise<RecipientACP> => {
189
- const acp = ACPUtils.importShared(options);
207
+ const acp = ACPUtils.importShared(options, importOptions);
190
208
  return ACPUtils.sign(acp, publicClient, walletClient);
191
209
  },
192
210
 
@@ -218,6 +236,7 @@ export const ACPUtils = {
218
236
  recipientSignature: acp.recipientSignature,
219
237
  _signedDomain: acp._signedDomain,
220
238
  sealingPrivateKey: acp.sealingPrivateKey,
239
+ ...(acp.metadata != null && { metadata: acp.metadata }),
221
240
  };
222
241
  },
223
242
 
@@ -297,7 +316,7 @@ export const ACPUtils = {
297
316
  /**
298
317
  * Export acp data for sharing (strips the private component).
299
318
  * Fixed `SharedACP` shape — every field always present, aligned with
300
- * `ACPPublic` and the on-chain sharing payload.
319
+ * `ACPPublic` and the on-chain sharing payload — plus `metadata` when the acp has labels.
301
320
  */
302
321
  export: (acp: ACP): string => {
303
322
  if (acp.type !== 'sharing') {
@@ -312,7 +331,6 @@ export const ACPUtils = {
312
331
  }
313
332
 
314
333
  const shared: SharedACP = {
315
- name: acp.name,
316
334
  type: acp.type,
317
335
  issuer: acp.issuer,
318
336
  expiration: acp.expiration,
@@ -323,6 +341,7 @@ export const ACPUtils = {
323
341
  contracts: acp.contracts,
324
342
  handles: acp.handles,
325
343
  issuerSignature: acp.issuerSignature,
344
+ ...(acp.metadata != null && acp.metadata !== '0x' && { metadata: acp.metadata }),
326
345
  };
327
346
 
328
347
  return JSON.stringify(shared, undefined, 2);
package/acps/index.ts CHANGED
@@ -9,18 +9,23 @@ export type {
9
9
  ACPPublic,
10
10
  SharedACP,
11
11
  IncomingShare,
12
+ LabelledShare,
12
13
  EIP712Domain,
13
14
  EIP712Types,
14
15
  EIP712Message,
15
16
  ValidationResult,
16
17
  ACPSignaturePrimaryType,
17
- } from './types.js';
18
+ } from './types';
18
19
 
19
20
  // Main utilities
20
- export { ACPUtils } from './acp.js';
21
+ export { ACPUtils } from './acp';
21
22
 
22
- // On-chain sharing: registry / revoker ABIs, the registry payload and share id of a sharing ACP
23
- export * from './registry.js';
23
+ // On-chain sharing: registry / revoker ABIs, the registry payload and share id of a sharing ACP,
24
+ // and the read of posted shares from their Shared events
25
+ export * from './registry';
26
+
27
+ // Share metadata: labels saying where each handle of a SNAPSHOT share came from
28
+ export * from './share-metadata/index';
24
29
 
25
30
  // Addresses the chain ACL serves (TaskManager -> acl() -> default revoker, share registry)
26
31
  export {
@@ -28,7 +33,7 @@ export {
28
33
  getAclServedAddresses,
29
34
  clearAclServedAddresses,
30
35
  type AclServedAddresses,
31
- } from './onchain-utils.js';
36
+ } from './onchain-utils';
32
37
 
33
38
  // Validation utilities
34
39
  export {
@@ -49,10 +54,10 @@ export {
49
54
  validateImportACP,
50
55
  // Common utilities
51
56
  ValidationUtils,
52
- } from './validation.js';
57
+ } from './validation';
53
58
 
54
59
  // Signature utilities
55
- export { SignatureUtils, getSignatureTypesAndMessage, SignatureTypes } from './signature.js';
60
+ export { SignatureUtils, getSignatureTypesAndMessage, SignatureTypes } from './signature';
56
61
 
57
62
  // Storage utilities
58
63
  export {
@@ -67,15 +72,15 @@ export {
67
72
  removeActiveACPHash,
68
73
  clearStaleStore,
69
74
  ACP_STORE_DEFAULTS,
70
- } from './store.js';
75
+ } from './store';
71
76
 
72
77
  // Sealing utilities
73
- export { GenerateSealingKey, seal, unsealWithPrivateKey, type SealingKeyPair } from './sealing.js';
74
- export type { EthEncryptedData } from './sealing.js';
78
+ export { GenerateSealingKey, seal, unsealWithPrivateKey, type SealingKeyPair } from './sealing';
79
+ export type { EthEncryptedData } from './sealing';
75
80
 
76
81
  // Re-export everything for convenience
77
- export * from './types.js';
78
- export * from './acp.js';
79
- export * from './validation.js';
80
- export * from './signature.js';
81
- export * from './store.js';
82
+ export * from './types';
83
+ export * from './acp';
84
+ export * from './validation';
85
+ export * from './signature';
86
+ export * from './store';
package/acps/registry.ts CHANGED
@@ -1,11 +1,25 @@
1
- import { encodeAbiParameters, keccak256, parseAbi, type AbiParameterToPrimitiveType, type Hex } from 'viem';
1
+ import {
2
+ encodeAbiParameters,
3
+ keccak256,
4
+ parseAbi,
5
+ type AbiParameterToPrimitiveType,
6
+ type Address,
7
+ type Hex,
8
+ type PublicClient,
9
+ } from 'viem';
2
10
  import { ACPUtils } from './acp.js';
3
11
  import type { ACP } from './types.js';
4
12
 
5
13
  /**
6
14
  * The on-chain sharing pieces: the share registry and revoker ABIs, the registry payload of a
7
- * sharing ACP and its share id. Exported so an app can send these writes through its own
8
- * transaction path and still agree with the registry on ids.
15
+ * sharing ACP and its share id, and the read of a share from its `Shared` event. Exported so an
16
+ * app can send these writes through its own transaction path and still agree with the registry
17
+ * on ids.
18
+ *
19
+ * The registry is pointer-based: storage keeps a share header (issuer, expiration, recipient,
20
+ * revoker, and the block of its `Shared` event); the full payload and the metadata blob travel in
21
+ * that event. A reader takes headers from `sharesFor` / `getShare` and fetches the events with one
22
+ * single-block `getLogs` per block.
9
23
  */
10
24
 
11
25
  // ACP default revoker (timestamp-based revocation) — interface shared by all revokers
@@ -19,11 +33,14 @@ export const ACP_REVOKER_ABI = parseAbi([
19
33
 
20
34
  export const ACP_SHARE_REGISTRY_ABI = parseAbi([
21
35
  'struct ACP { address issuer; uint64 expiration; address recipient; uint256 revokerData; address revokerContract; uint8 scope; address[] contracts; bytes32[] handles; bytes32 sealingKey; bytes issuerSignature; bytes recipientSignature; }',
22
- 'function share(ACP calldata acp) external returns (bytes32)',
36
+ 'struct ShareHeader { address issuer; uint64 expiration; address recipient; uint64 blockNumber; address revokerContract; uint256 revokerData; }',
37
+ 'function share(ACP calldata acp, bytes calldata metadata) external returns (bytes32)',
23
38
  'function removeShare(bytes32 shareId) external',
24
- 'function sharesFor(address recipient) external view returns (ACP[] memory)',
25
- 'function getShare(bytes32 shareId) external view returns (ACP memory)',
39
+ 'function sharesFor(address recipient) external view returns (bytes32[] shareIds, ShareHeader[] headers)',
40
+ 'function getShare(bytes32 shareId) external view returns (ShareHeader memory)',
26
41
  'function isShareValid(bytes32 shareId) external view returns (bool)',
42
+ 'event Shared(address indexed recipient, address indexed issuer, bytes32 indexed shareId, ACP acp, bytes metadata)',
43
+ 'event ShareRemoved(address indexed recipient, address indexed issuer, bytes32 indexed shareId)',
27
44
  ]);
28
45
 
29
46
  export const ACP_SHARE_TUPLE_ABI = [
@@ -68,6 +85,147 @@ export const computeShareId = (acp: ACP): Hex => {
68
85
  return keccak256(encodeAbiParameters(ACP_SHARE_TUPLE_ABI, [p]));
69
86
  };
70
87
 
71
- /** The share id of a registry payload as read back from `sharesFor` / `getShare`. */
72
- export const shareIdOfChainShare = (share: AbiParameterToPrimitiveType<(typeof ACP_SHARE_TUPLE_ABI)[0]>): Hex =>
88
+ /** The share id of a registry payload as read back from a `Shared` event. */
89
+ export const shareIdOfChainShare = (share: ChainShare): Hex =>
73
90
  keccak256(encodeAbiParameters(ACP_SHARE_TUPLE_ABI, [share]));
91
+
92
+ /** A registry payload, as decoded from a `Shared` event (or passed to `share`). */
93
+ export type ChainShare = AbiParameterToPrimitiveType<(typeof ACP_SHARE_TUPLE_ABI)[0]>;
94
+
95
+ /** What the registry stores of a share: the fields it checks, and the block of its `Shared` event. */
96
+ export type ShareHeader = {
97
+ issuer: Address;
98
+ expiration: bigint;
99
+ recipient: Address;
100
+ /** Block of the `Shared` event (the L2 block on Arbitrum). */
101
+ blockNumber: bigint;
102
+ revokerContract: Address;
103
+ revokerData: bigint;
104
+ };
105
+
106
+ /** A share as posted: its payload and the metadata blob that came with it (`0x` when none). */
107
+ export type PostedShare = { shareId: Hex; header: ShareHeader; share: ChainShare; metadata: Hex };
108
+
109
+ /** A share the registry lists but whose `Shared` event could not be read, and why. */
110
+ export type UnreadableShare = { shareId: Hex; header: ShareHeader; reason: string };
111
+
112
+ /** Shares read from their events, and those that could not be; each share is on its own. */
113
+ export type PostedShares = { posted: PostedShare[]; unreadable: UnreadableShare[] };
114
+
115
+ const messageOf = (error: unknown): string => (error instanceof Error ? error.message : String(error));
116
+
117
+ /**
118
+ * Fetches the `Shared` events of shares from the blocks their headers name: one single-block
119
+ * `getLogs` per distinct block, filtered by registry and share ids, so any node answers it. Returns
120
+ * the shares in the order given; where a block holds several events for one share id, the last.
121
+ * Never fails as a whole: a share whose block the node cannot serve yet, whose event is missing
122
+ * from it, or whose event payload does not hash to its id is returned as `unreadable`, with the
123
+ * reason, and the rest are posted as usual.
124
+ */
125
+ export const readPostedShares = async (
126
+ publicClient: PublicClient,
127
+ registry: Address,
128
+ headers: readonly { shareId: Hex; header: ShareHeader }[]
129
+ ): Promise<PostedShares> => {
130
+ const idsByBlock = new Map<bigint, Hex[]>();
131
+ for (const { shareId, header } of headers) {
132
+ idsByBlock.set(header.blockNumber, [...(idsByBlock.get(header.blockNumber) ?? []), shareId]);
133
+ }
134
+
135
+ type Found = { share: ChainShare; metadata: Hex } | { reason: string };
136
+ const found = new Map<string, Found>();
137
+ await Promise.all(
138
+ [...idsByBlock].map(async ([block, shareIds]) => {
139
+ let logs;
140
+ try {
141
+ logs = await publicClient.getContractEvents({
142
+ address: registry,
143
+ abi: ACP_SHARE_REGISTRY_ABI,
144
+ eventName: 'Shared',
145
+ args: { shareId: shareIds },
146
+ fromBlock: block,
147
+ toBlock: block,
148
+ strict: true,
149
+ });
150
+ } catch (error) {
151
+ for (const shareId of shareIds) {
152
+ found.set(shareId.toLowerCase(), {
153
+ reason: `the Shared events of block ${block} could not be read: ${messageOf(error)}`,
154
+ });
155
+ }
156
+ return;
157
+ }
158
+ // Logs come in log-index order. A share removed and posted again within one block has two
159
+ // Shared events there; the later one is the share, so each log overwrites the previous.
160
+ for (const { args } of logs) {
161
+ const hashes = shareIdOfChainShare(args.acp).toLowerCase() === args.shareId.toLowerCase();
162
+ found.set(
163
+ args.shareId.toLowerCase(),
164
+ hashes
165
+ ? { share: args.acp, metadata: args.metadata }
166
+ : { reason: 'the payload in its Shared event does not hash to its id' }
167
+ );
168
+ }
169
+ })
170
+ );
171
+
172
+ const result: PostedShares = { posted: [], unreadable: [] };
173
+ for (const { shareId, header } of headers) {
174
+ const entry = found.get(shareId.toLowerCase());
175
+ if (entry == null) {
176
+ result.unreadable.push({
177
+ shareId,
178
+ header,
179
+ reason: `no Shared event in block ${header.blockNumber}, where the registry points`,
180
+ });
181
+ } else if ('reason' in entry) {
182
+ result.unreadable.push({ shareId, header, reason: entry.reason });
183
+ } else {
184
+ result.posted.push({ shareId, header, ...entry });
185
+ }
186
+ }
187
+ return result;
188
+ };
189
+
190
+ /**
191
+ * The importable shares addressed to `recipient` (unexpired, not revoked), read from their events;
192
+ * those whose event could not be read are listed apart, so one of them never hides the rest.
193
+ */
194
+ export const readSharesFor = async (
195
+ publicClient: PublicClient,
196
+ registry: Address,
197
+ recipient: Address
198
+ ): Promise<PostedShares> => {
199
+ const [shareIds, headers] = await publicClient.readContract({
200
+ address: registry,
201
+ abi: ACP_SHARE_REGISTRY_ABI,
202
+ functionName: 'sharesFor',
203
+ args: [recipient],
204
+ });
205
+ return readPostedShares(
206
+ publicClient,
207
+ registry,
208
+ shareIds.map((shareId, i) => ({ shareId, header: headers[i] }))
209
+ );
210
+ };
211
+
212
+ /**
213
+ * One share by id, read from its event; null when the registry has no such share (unknown or
214
+ * removed). Throws, with the reason, when the registry lists it but its event could not be read.
215
+ */
216
+ export const readShare = async (
217
+ publicClient: PublicClient,
218
+ registry: Address,
219
+ shareId: Hex
220
+ ): Promise<PostedShare | null> => {
221
+ const header = await publicClient.readContract({
222
+ address: registry,
223
+ abi: ACP_SHARE_REGISTRY_ABI,
224
+ functionName: 'getShare',
225
+ args: [shareId],
226
+ });
227
+ if (header.issuer === '0x0000000000000000000000000000000000000000') return null;
228
+ const { posted, unreadable } = await readPostedShares(publicClient, registry, [{ shareId, header }]);
229
+ if (unreadable.length > 0) throw new Error(`Share ${shareId}: ${unreadable[0].reason}`);
230
+ return posted[0];
231
+ };
package/acps/sealing.ts CHANGED
@@ -46,7 +46,7 @@ export const GenerateSealingKey = (): SealingKeyPair => {
46
46
  export const seal = (value: bigint | number, publicKey: string): EthEncryptedData => {
47
47
  isString(publicKey);
48
48
  isBigIntOrNumber(value);
49
- assertKeyLength(publicKey, 'Public');
49
+ assertSealingKey(publicKey, 'Public');
50
50
 
51
51
  // generate ephemeral keypair
52
52
  const ephemeralKeyPair = nacl.box.keyPair();
@@ -67,7 +67,7 @@ export const seal = (value: bigint | number, publicKey: string): EthEncryptedDat
67
67
  * The ephemeral public key travels inside the payload, so the private key alone suffices.
68
68
  */
69
69
  export const unsealWithPrivateKey = (privateKey: string, parsedData: EthEncryptedData): bigint => {
70
- assertKeyLength(privateKey, 'Private');
70
+ assertSealingKey(privateKey, 'Private');
71
71
 
72
72
  const nonce = parsedData.nonce instanceof Uint8Array ? parsedData.nonce : new Uint8Array(parsedData.nonce);
73
73
  const ephemPublicKey =
@@ -82,9 +82,15 @@ export const unsealWithPrivateKey = (privateKey: string, parsedData: EthEncrypte
82
82
  return toBigInt(decryptedMessage);
83
83
  };
84
84
 
85
- const assertKeyLength = (key: string, kind: 'Private' | 'Public'): void => {
85
+ const assertSealingKey = (key: string, kind: 'Private' | 'Public'): void => {
86
86
  const bare = key.startsWith('0x') ? key.slice(2) : key;
87
87
  if (bare.length !== KEY_HEX_LENGTH) {
88
88
  throw new Error(`${kind} key must be of length ${KEY_HEX_LENGTH}`);
89
89
  }
90
+ // A correct-length key with non-hex characters would otherwise decode those characters to
91
+ // zero bytes. An entirely invalid public key becomes the all-zero key, a low-order point, so
92
+ // anything sealed to it could be opened without the matching private key.
93
+ if (!/^[0-9a-fA-F]+$/.test(bare)) {
94
+ throw new Error(`${kind} key must contain only hex characters`);
95
+ }
90
96
  };
@@ -0,0 +1,8 @@
1
+ import { keccak256, stringToHex, type Hex } from 'viem';
2
+
3
+ /**
4
+ * The key an app marks its shares with: keccak256 of its name, e.g. `shareAppKey('explorer.fhenix.io')`.
5
+ * A client configured with `acp: { app }` puts it on every share it creates and lists only the
6
+ * shares that carry it; the name itself is never on-chain.
7
+ */
8
+ export const shareAppKey = (app: string): Hex => keccak256(stringToHex(app));
@@ -0,0 +1,120 @@
1
+ import { getAddress, hexToBigInt, hexToNumber, size, sliceHex, type Hex } from 'viem';
2
+ import { APP_KEY_TAG, ARGS_CODE, ARG_TAG, ENTRY_KIND, LAYOUT_VERSION, BYTE_WIDTH } from './layout';
3
+ import type { ShareLabel, ShareLabelReadArg } from './schema.js';
4
+
5
+ /** Sequential reads over a blob. Each take throws when the blob ends before the field does. */
6
+ type Reader = {
7
+ take: (width: number) => Hex;
8
+ takeNumber: (width: number) => number;
9
+ done: () => boolean;
10
+ remaining: () => number;
11
+ };
12
+
13
+ const readerOf = (blob: Hex): Reader => {
14
+ let offset = 0;
15
+ const take = (width: number): Hex => {
16
+ if (offset + width > size(blob)) throw new Error('share metadata: the blob ends in the middle of a field');
17
+ offset += width;
18
+ return sliceHex(blob, offset - width, offset);
19
+ };
20
+ return {
21
+ take,
22
+ takeNumber: (width) => hexToNumber(take(width)),
23
+ done: () => offset === size(blob),
24
+ remaining: () => size(blob) - offset,
25
+ };
26
+ };
27
+
28
+ /** A counted list: its length in one byte, then that many items. */
29
+ const takeList = <T>(reader: Reader, takeItem: () => T): T[] =>
30
+ Array.from({ length: reader.takeNumber(BYTE_WIDTH.count) }, takeItem);
31
+
32
+ /** The item an entry points to. Throws when the position is outside the list. */
33
+ const takeFrom = <T>(reader: Reader, list: readonly T[], what: string): T => {
34
+ const position = reader.takeNumber(BYTE_WIDTH.index);
35
+ if (position >= list.length) {
36
+ throw new Error(`share metadata: ${what} ${position} is outside the ${what} list`);
37
+ }
38
+ return list[position];
39
+ };
40
+
41
+ const decodeArg = (reader: Reader): ShareLabelReadArg => {
42
+ const tag = reader.takeNumber(BYTE_WIDTH.argTag);
43
+ if (tag === ARG_TAG.issuer) return { type: 'issuer' };
44
+ if (tag === ARG_TAG.word) return { type: 'word', value: reader.take(BYTE_WIDTH.word) };
45
+ throw new Error(`share metadata: unknown argument tag ${tag}`);
46
+ };
47
+
48
+ const decodeArgs = (reader: Reader): { args: ShareLabelReadArg[]; returnWord: number } => {
49
+ const code = reader.takeNumber(BYTE_WIDTH.argsCode);
50
+ if (code === ARGS_CODE.none) return { args: [], returnWord: 0 };
51
+ if (code === ARGS_CODE.issuer) return { args: [{ type: 'issuer' }], returnWord: 0 };
52
+ if (code !== ARGS_CODE.explicit) throw new Error(`share metadata: unknown args code ${code}`);
53
+ const returnWord = reader.takeNumber(BYTE_WIDTH.returnWord);
54
+ return { args: takeList(reader, () => decodeArg(reader)), returnWord };
55
+ };
56
+
57
+ /** The optional app key that closes the blob: nothing, or the tag byte and 32 bytes. */
58
+ const takeAppKey = (reader: Reader): Hex | undefined => {
59
+ if (reader.done()) return undefined;
60
+ const isKey =
61
+ reader.remaining() === BYTE_WIDTH.count + BYTE_WIDTH.word && reader.takeNumber(BYTE_WIDTH.count) === APP_KEY_TAG;
62
+ if (!isKey) throw new Error('share metadata: the blob has more entries than the share has ctHashes');
63
+ return reader.take(BYTE_WIDTH.word);
64
+ };
65
+
66
+ /** The two things a blob holds: one label per ctHash, and the app key when the share names an app. */
67
+ export type DecodedShareMetadata = { labels: ShareLabel[]; appKey: Hex | undefined };
68
+
69
+ /**
70
+ * Decodes a version 3 blob: one label per ctHash, in the order of `acp.handles`, and the app key
71
+ * when there is one. The exact inverse of `encodeShareMetadata`. Throws on another version, an
72
+ * unknown kind, code or tag, a position outside its list, and unless the blob holds exactly one
73
+ * entry per ctHash.
74
+ */
75
+ export const decodeShareMetadataFull = (blob: Hex, handles: readonly Hex[]): DecodedShareMetadata => {
76
+ const reader = readerOf(blob);
77
+ const version = reader.takeNumber(BYTE_WIDTH.version);
78
+ if (version !== LAYOUT_VERSION) throw new Error(`share metadata: unsupported version ${version}`);
79
+
80
+ const contracts = takeList(reader, () => getAddress(reader.take(BYTE_WIDTH.address)));
81
+ const selectors = takeList(reader, () => reader.take(BYTE_WIDTH.selector));
82
+ const transactions = takeList(reader, () => ({
83
+ block: hexToBigInt(reader.take(BYTE_WIDTH.block)),
84
+ txIndex: reader.takeNumber(BYTE_WIDTH.txIndex),
85
+ }));
86
+
87
+ const labels = handles.map((): ShareLabel => {
88
+ const kind = reader.takeNumber(BYTE_WIDTH.kind);
89
+ switch (kind) {
90
+ case ENTRY_KIND.stored: {
91
+ const contract = takeFrom(reader, contracts, 'contract');
92
+ const selector = takeFrom(reader, selectors, 'selector');
93
+ const block = hexToBigInt(reader.take(BYTE_WIDTH.block));
94
+ return { kind: 'stored', contract, selector, ...decodeArgs(reader), block };
95
+ }
96
+ case ENTRY_KIND.event: {
97
+ const contract = takeFrom(reader, contracts, 'contract');
98
+ const { block, txIndex } = takeFrom(reader, transactions, 'transaction');
99
+ const logIndex = reader.takeNumber(BYTE_WIDTH.logIndex);
100
+ const selector = takeFrom(reader, selectors, 'selector');
101
+ return { kind: 'event', contract, block, txIndex, logIndex, selector };
102
+ }
103
+ case ENTRY_KIND.unlabelled:
104
+ return { kind: 'unlabelled' };
105
+ default:
106
+ throw new Error(`share metadata: unknown entry kind ${kind}`);
107
+ }
108
+ });
109
+ const appKey = takeAppKey(reader);
110
+ if (!reader.done()) throw new Error('share metadata: the blob has more entries than the share has ctHashes');
111
+ return { labels, appKey };
112
+ };
113
+
114
+ /** The labels of a blob, one per ctHash in the order of `acp.handles` (see `decodeShareMetadataFull`). */
115
+ export const decodeShareMetadata = (blob: Hex, handles: readonly Hex[]): ShareLabel[] =>
116
+ decodeShareMetadataFull(blob, handles).labels;
117
+
118
+ /** The app key a share was posted with, or undefined: for a share without metadata, or without a key. */
119
+ export const decodeShareAppKey = (metadata: Hex | undefined, handles: readonly Hex[]): Hex | undefined =>
120
+ metadata == null || metadata === '0x' ? undefined : decodeShareMetadataFull(metadata, handles).appKey;