@cofhe/sdk 0.8.0 → 1.0.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 (87) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/acps/acp.ts +25 -6
  3. package/acps/index.ts +20 -15
  4. package/acps/onchain-utils.ts +15 -10
  5. package/acps/registry.ts +166 -8
  6. package/acps/sealing.ts +31 -5
  7. package/acps/share-metadata/appKey.ts +8 -0
  8. package/acps/share-metadata/decode.ts +120 -0
  9. package/acps/share-metadata/describe.ts +198 -0
  10. package/acps/share-metadata/encode.ts +147 -0
  11. package/acps/share-metadata/erc7984.ts +26 -0
  12. package/acps/share-metadata/index.ts +9 -0
  13. package/acps/share-metadata/layout.ts +69 -0
  14. package/acps/share-metadata/presets.ts +116 -0
  15. package/acps/share-metadata/schema.ts +47 -0
  16. package/acps/share-metadata/test/codec.test.ts +140 -0
  17. package/acps/share-metadata/test/describe.test.ts +112 -0
  18. package/acps/share-metadata/test/fixtures.ts +157 -0
  19. package/acps/share-metadata/test/live.test.ts +56 -0
  20. package/acps/share-metadata/test/presets.test.ts +119 -0
  21. package/acps/share-metadata/test/verify.test.ts +144 -0
  22. package/acps/share-metadata/verify.ts +155 -0
  23. package/acps/store.ts +48 -18
  24. package/acps/test/aclServedAddresses.test.ts +65 -0
  25. package/acps/test/acp.test.ts +102 -7
  26. package/acps/test/localstorage.test.ts +28 -1
  27. package/acps/test/registry.test.ts +144 -0
  28. package/acps/test/sealing.test.ts +74 -0
  29. package/acps/test/store.test.ts +71 -0
  30. package/acps/test/utils.test.ts +63 -0
  31. package/acps/test/validation.test.ts +0 -4
  32. package/acps/types.ts +43 -6
  33. package/acps/utils.ts +15 -2
  34. package/acps/validation.ts +3 -1
  35. package/core/acps.ts +123 -74
  36. package/core/client.ts +33 -7
  37. package/core/clientTypes.ts +25 -7
  38. package/core/config.ts +8 -0
  39. package/core/decrypt/apiError.ts +4 -1
  40. package/core/decrypt/verifyDecryptResult.ts +4 -1
  41. package/core/encrypt/cofheMocksZkVerifySign.ts +19 -2
  42. package/core/test/acpDefaults.test.ts +108 -19
  43. package/core/test/acpStoreAccountCase.test.ts +80 -0
  44. package/core/test/acps.test.ts +38 -3
  45. package/core/test/apiError.test.ts +8 -0
  46. package/core/test/appKeyConfig.test.ts +94 -0
  47. package/core/test/config.test.ts +8 -0
  48. package/core/test/decryptBuilders.test.ts +110 -2
  49. package/core/test/incomingShares.test.ts +84 -0
  50. package/core/test/mocksEncryptRange.test.ts +90 -0
  51. package/core/test/utils.test.ts +128 -0
  52. package/core/test/verifyDecryptResult.test.ts +51 -0
  53. package/core/utils.ts +16 -9
  54. package/dist/acps.cjs +796 -132
  55. package/dist/acps.d.cts +367 -35
  56. package/dist/acps.d.ts +367 -35
  57. package/dist/acps.js +2 -2
  58. package/dist/{chunk-UJYOBHYA.js → chunk-3L2IVPMI.js} +688 -203
  59. package/dist/chunk-J23FYAI2.js +174 -0
  60. package/dist/{chunk-GGI7Y6AW.js → chunk-O2ITEUEU.js} +110 -269
  61. package/dist/chunk-WJGTPW7K.js +228 -0
  62. package/dist/{clientTypes-D3aIXmUp.d.cts → clientTypes-IO6b5Um8.d.cts} +61 -21
  63. package/dist/{clientTypes-5YPPR16m.d.ts → clientTypes-b1pOxOUE.d.ts} +61 -21
  64. package/dist/core.cjs +3115 -2584
  65. package/dist/core.d.cts +6 -3
  66. package/dist/core.d.ts +6 -3
  67. package/dist/core.js +4 -3
  68. package/dist/node.cjs +3001 -2470
  69. package/dist/node.d.cts +2 -2
  70. package/dist/node.d.ts +2 -2
  71. package/dist/node.js +18 -17
  72. package/dist/{onchain-utils-CUAK8glx.d.cts → onchain-utils-CgOslubH.d.cts} +139 -15
  73. package/dist/{onchain-utils-CUAK8glx.d.ts → onchain-utils-CgOslubH.d.ts} +139 -15
  74. package/dist/web.cjs +3011 -2487
  75. package/dist/web.d.cts +2 -2
  76. package/dist/web.d.ts +2 -2
  77. package/dist/web.js +4 -10
  78. package/dist/zkProve.worker.cjs +14 -7
  79. package/dist/zkProve.worker.js +2 -8
  80. package/node/index.ts +1 -10
  81. package/node/storage.ts +18 -7
  82. package/node/test/storage-permissions.test.ts +40 -0
  83. package/package.json +4 -4
  84. package/web/index.ts +1 -10
  85. package/web/test/threadPoolBenchHelper.ts +1 -7
  86. package/web/zkProve.worker.ts +1 -10
  87. package/dist/chunk-NMKMCAI3.js +0 -14
package/acps/types.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { type EthEncryptedData } from './sealing.js';
2
2
  import { type Hex } from 'viem';
3
+ import type { ShareLabel } from './share-metadata/schema.js';
3
4
 
4
5
  /**
5
6
  * EIP712 related types
@@ -46,6 +47,12 @@ export interface ACPPrivate {
46
47
  * Never leaves the client.
47
48
  */
48
49
  sealingPrivateKey: Hex;
50
+ /**
51
+ * (sharing / recipient) The share metadata: the labels of its handles, a v3 blob
52
+ * (`decodeShareMetadata` / `describeShareMetadata`). Not signed; travels with the share, in the
53
+ * exported JSON or in the registry's `Shared` event. Absent when the share has none.
54
+ */
55
+ metadata?: Hex;
49
56
  /**
50
57
  * EIP712 domain used to sign this acp.
51
58
  * Should not be set manually, included in metadata as part of serialization flows.
@@ -191,17 +198,30 @@ export type CreateSharingACPOptions = ACPScopeOptions & {
191
198
  expiration?: number;
192
199
  revokerData?: number;
193
200
  revokerContract?: string;
201
+ /**
202
+ * The labels of the handles, one per handle in order. Kept on the acp as `metadata`, then
203
+ * written into `export()` and posted by `shareOnChain`.
204
+ */
205
+ labels?: readonly ShareLabel[];
206
+ /**
207
+ * The key of the app the share is for (`shareAppKey(app)`), kept in `metadata` with the labels.
208
+ * A client configured with `acp: { app }` fills it in; a recipient client with the same `app`
209
+ * lists the share, one with another app does not.
210
+ */
211
+ appKey?: Hex;
194
212
  };
195
213
 
214
+ /** A share as exported (`SharedACP`) or read from the registry. A `name` in it is ignored. */
196
215
  export type ImportSharedACPOptions = ACPScopeOptions & {
197
216
  type?: 'sharing';
198
217
  issuer: string;
199
218
  recipient: string;
200
219
  issuerSignature: string;
201
- name?: string;
202
220
  expiration: number;
203
221
  revokerData?: number;
204
222
  revokerContract?: string;
223
+ /** The share metadata (labels blob), kept on the recipient acp; `0x` or absent for none. */
224
+ metadata?: string;
205
225
  };
206
226
 
207
227
  /** An ACP is plain JSON-serializable data — the serialized form is the ACP itself. */
@@ -210,14 +230,16 @@ export type SerializedACP = ACP;
210
230
  /**
211
231
  * The share payload produced by `ACPUtils.export()` — the full public component
212
232
  * with `sealingKey`/`recipientSignature` left for the recipient to fill, plus
213
- * the display name and acp type. Fixed shape: every field is always present
233
+ * the acp type. The issuer's `name` is not exported: it is their own note, and the
234
+ * recipient names the share themselves. Fixed shape: every field is always present
214
235
  * (zero-values instead of omissions), so importers can parse a single schema.
215
- * Mirrors the on-chain sharing payload struct field-for-field.
236
+ * Mirrors the on-chain sharing payload struct field-for-field. The one optional
237
+ * field is `metadata`, the labels of the handles, present only when the share has them.
216
238
  */
217
239
  export type SharedACP = Expand<
218
240
  Omit<ACPPublic, 'sealingKey' | 'recipientSignature'> & {
219
- name: string;
220
241
  type: 'sharing';
242
+ metadata?: Hex;
221
243
  }
222
244
  >;
223
245
 
@@ -239,9 +261,24 @@ export type ACPAccessStatus =
239
261
 
240
262
  /**
241
263
  * A share read back from the on-chain ACPShareRegistry: the posted payload
242
- * (SharedACP minus the client-side name/type) plus its registry id.
264
+ * (SharedACP minus its type), its registry id, and the
265
+ * metadata blob posted with it (`0x` when none) — for a SNAPSHOT share, the
266
+ * labels of its handles (`decodeShareMetadata` / `describeShareMetadata`).
267
+ */
268
+ export type IncomingShare = Expand<
269
+ Omit<SharedACP, 'type' | 'metadata'> & {
270
+ shareId: Hex;
271
+ metadata: Hex;
272
+ /** The key of the app the share was posted for (`shareAppKey(app)`), when it names one. */
273
+ appKey?: Hex;
274
+ }
275
+ >;
276
+
277
+ /**
278
+ * What the share label helpers read: the handles, their issuer and the metadata blob. An
279
+ * `IncomingShare` (on-chain route) and a sharing or recipient `ACP` (exported JSON route) both fit.
243
280
  */
244
- export type IncomingShare = Expand<Omit<SharedACP, 'name' | 'type'> & { shareId: Hex }>;
281
+ export type LabelledShare = { issuer: Hex; handles: readonly Hex[]; metadata?: Hex };
245
282
 
246
283
  /**
247
284
  * A type representing the acp fields that are used to generate the hash
package/acps/utils.ts CHANGED
@@ -2,9 +2,22 @@
2
2
 
3
3
  declare const BigInt: (value: string | number | bigint) => bigint;
4
4
 
5
+ const HEX_BODY_REGEX = /^[0-9a-fA-F]*$/;
6
+
5
7
  export const fromHexString = (hexString: string): Uint8Array => {
6
- const cleanString = hexString.length % 2 === 1 ? `0${hexString}` : hexString;
7
- const arr = cleanString.replace(/^0x/, '').match(/.{1,2}/g);
8
+ // Strip `0x` before padding: padding first turns `0x123` into `00x123`, which no
9
+ // longer matches the prefix and decodes to garbage bytes.
10
+ const body = hexString.replace(/^0x/, '');
11
+
12
+ // `parseInt` maps a non-hex pair to NaN, which `Uint8Array` stores as 0. Without this check a
13
+ // malformed sealing key silently becomes zero bytes instead of failing.
14
+ if (!HEX_BODY_REGEX.test(body)) {
15
+ // The value is omitted on purpose: this helper also receives sealing private keys.
16
+ throw new Error('Invalid hex string: contains non-hexadecimal characters');
17
+ }
18
+
19
+ const padded = body.length % 2 === 1 ? `0${body}` : body;
20
+ const arr = padded.match(/.{1,2}/g);
8
21
  if (!arr) return new Uint8Array();
9
22
  return new Uint8Array(arr.map((byte) => parseInt(byte, 16)));
10
23
  };
@@ -85,6 +85,7 @@ const zACPWithDefaults = z.object({
85
85
  handles: handlesSchema,
86
86
  issuerSignature: bytesSchema.optional().default('0x'),
87
87
  recipientSignature: bytesSchema.optional().default('0x'),
88
+ metadata: bytesSchema.optional(),
88
89
  });
89
90
 
90
91
  const zACPWithSealingKeys = zACPWithDefaults.extend({
@@ -228,7 +229,6 @@ export const ImportACPOptionsValidator = z
228
229
  type: z.literal('recipient').optional().default('recipient'),
229
230
  issuer: addressNotZeroSchema,
230
231
  recipient: addressNotZeroSchema,
231
- name: z.string().optional().default('Unnamed ACP'),
232
232
  expiration: z.int(),
233
233
  revokerData: z.int().optional().default(0),
234
234
  revokerContract: addressSchema.optional().default(zeroAddress),
@@ -237,6 +237,8 @@ export const ImportACPOptionsValidator = z
237
237
  handles: handlesSchema,
238
238
  issuerSignature: bytesNotEmptySchema,
239
239
  recipientSignature: bytesSchema.optional().default('0x'),
240
+ /** The share metadata (labels blob); `0x` means none and is dropped */
241
+ metadata: bytesSchema.optional().transform((val) => (val === '0x' ? undefined : val)),
240
242
  })
241
243
  .refine(...ExternalValidatorRefinement)
242
244
  .transform(withDerivedScope)
package/core/acps.ts CHANGED
@@ -16,7 +16,12 @@ import {
16
16
  ACP_SHARE_REGISTRY_ABI,
17
17
  toChainShare,
18
18
  computeShareId,
19
- shareIdOfChainShare,
19
+ readSharesFor,
20
+ readShare,
21
+ decodeShareAppKey,
22
+ encodeShareMetadata,
23
+ type PostedShare,
24
+ type ShareLabel,
20
25
  getAclServedAddresses,
21
26
  clearAclServedAddresses,
22
27
  } from '@/acps';
@@ -98,13 +103,28 @@ export type ImportActivationOptions = {
98
103
  activate?: boolean;
99
104
  };
100
105
 
106
+ /** Options for importing a shared acp: activation, and the recipient's own name for it. */
107
+ export type ImportSharedOptions = ImportActivationOptions & {
108
+ /**
109
+ * The name of the imported acp. Defaults to "Shared by 0x1234…abcd" (its issuer); the issuer's
110
+ * own name for the share is not exported and is ignored if present.
111
+ */
112
+ name?: string;
113
+ };
114
+
101
115
  const importShared = async (
102
116
  options: ImportSharedACPOptions | string,
103
117
  publicClient: PublicClient,
104
118
  walletClient: WalletClient,
105
- { activate = true }: ImportActivationOptions = {}
119
+ { activate = true, name }: ImportSharedOptions = {}
106
120
  ): Promise<RecipientACP> => {
107
- return createACPWithSign(options, publicClient, walletClient, ACPUtils.importSharedAndSign, activate);
121
+ return createACPWithSign(
122
+ options,
123
+ publicClient,
124
+ walletClient,
125
+ (o, pc, wc) => ACPUtils.importSharedAndSign(o, pc, wc, { name }),
126
+ activate
127
+ );
108
128
  };
109
129
 
110
130
  // ACP UTILS
@@ -222,15 +242,14 @@ const getOrCreateSharingACP = async (
222
242
  // CONFIG DEFAULTS
223
243
 
224
244
  /**
225
- * Applies the config's ACP acp defaults to creation options (pure).
226
- * Explicit user options always win:
227
- * - revoker: injected only when the options carry NO revoker pair —
228
- * revokerContract = config default, revokerData = creation timestamp
229
- * ("every acp revocable by default")
245
+ * Applies the config's ACP defaults to creation options. Explicit user options always win:
246
+ * - revoker: injected only when the options carry NO revoker pair. The contract is the
247
+ * config's `defaultRevoker` for the chain, else the one the ACL serves; the id
248
+ * (`revokerData`) is the latest block's timestamp ("every acp revocable by default")
230
249
  * - contracts: injected only when the options carry NO scope fields at all —
231
250
  * injecting scope makes the created acp non-global by default
232
251
  */
233
- const applyACPDefaults = <
252
+ const applyACPDefaultsFromChain = async <
234
253
  T extends {
235
254
  revokerData?: number;
236
255
  revokerContract?: string;
@@ -241,20 +260,19 @@ const applyACPDefaults = <
241
260
  >(
242
261
  options: T,
243
262
  acpConfig: { defaultRevoker?: Record<number, Hex>; defaultContractScopes?: Record<number, Hex[]> } | undefined,
263
+ publicClient: PublicClient,
244
264
  chainId: number
245
- ): T => {
265
+ ): Promise<T> => {
246
266
  const result = { ...options };
247
267
 
248
- const defaultRevoker = acpConfig?.defaultRevoker?.[chainId];
249
- const hasValidatorOptions = options.revokerData != null || options.revokerContract != null;
250
- if (defaultRevoker != null && !hasValidatorOptions) {
251
- result.revokerContract = defaultRevoker;
252
- // Creation timestamp minus a clock-skew allowance: the revoker rejects
253
- // future-dated ids (vs block.timestamp of the LAST block), so a local clock
254
- // ahead of the chain — or a chain with sparse blocks — would otherwise make
255
- // a fresh acp temporarily unusable. 60s of backdating costs nothing
256
- // (revokeAllExisting at time T still kills this acp for any T >= id).
257
- result.revokerData = Math.round(Date.now() / 1000) - 60;
268
+ const hasRevokerOptions = options.revokerData != null || options.revokerContract != null;
269
+ if (!hasRevokerOptions) {
270
+ const defaultRevoker =
271
+ acpConfig?.defaultRevoker?.[chainId] ?? (await getAclServedAddresses(publicClient, chainId)).defaultRevoker;
272
+ if (defaultRevoker != null) {
273
+ result.revokerContract = defaultRevoker;
274
+ result.revokerData = await getBlockRevokerData(publicClient);
275
+ }
258
276
  }
259
277
 
260
278
  const defaultContracts = acpConfig?.defaultContractScopes?.[chainId];
@@ -267,34 +285,20 @@ const applyACPDefaults = <
267
285
  };
268
286
 
269
287
  /**
270
- * `applyACPDefaults` with the ACL consulted for the default revoker when
271
- * `acp.defaultRevoker` config does not name one for this chain — explicit
272
- * config wins over the ACL-served address.
288
+ * The chain-aware revoker id source: the latest block timestamp, not the local
289
+ * clock. The revoker rejects future-dated ids against `block.timestamp` of the
290
+ * LAST block, so a local clock ahead of the chain (or a chain with sparse
291
+ * blocks) would otherwise make a freshly created acp temporarily unusable.
273
292
  */
274
- const applyACPDefaultsFromChain = async <
275
- T extends {
276
- revokerData?: number;
277
- revokerContract?: string;
278
- scope?: number;
279
- contracts?: string[];
280
- handles?: (bigint | number | string)[];
281
- },
282
- >(
283
- options: T,
284
- acpConfig: { defaultRevoker?: Record<number, Hex>; defaultContractScopes?: Record<number, Hex[]> } | undefined,
285
- publicClient: PublicClient,
286
- chainId: number
287
- ): Promise<T> => {
288
- const hasExplicitRevoker =
289
- acpConfig?.defaultRevoker?.[chainId] != null || options.revokerData != null || options.revokerContract != null;
290
- if (hasExplicitRevoker) return applyACPDefaults(options, acpConfig, chainId);
291
-
292
- const served = await getAclServedAddresses(publicClient, chainId);
293
- const effectiveConfig =
294
- served.defaultRevoker != null
295
- ? { ...acpConfig, defaultRevoker: { ...acpConfig?.defaultRevoker, [chainId]: served.defaultRevoker } }
296
- : acpConfig;
297
- return applyACPDefaults(options, effectiveConfig, chainId);
293
+ const getBlockRevokerData = async (publicClient: PublicClient): Promise<number> => {
294
+ const block = await publicClient.getBlock();
295
+ const revokerData = Number(block.timestamp);
296
+ if (!Number.isSafeInteger(revokerData)) {
297
+ throw new Error(
298
+ `ACP revokerData cannot represent the chain block timestamp ${block.timestamp.toString()} as a safe integer`
299
+ );
300
+ }
301
+ return revokerData;
298
302
  };
299
303
 
300
304
  // REVOKE (on-chain, via the acp's revoker contract)
@@ -390,14 +394,33 @@ const isACPRevoked = async (acp: ACP, publicClient: PublicClient): Promise<boole
390
394
  });
391
395
  };
392
396
 
397
+ /**
398
+ * What `shareOnChain` posts with a share: the labels of its handles (one per handle, in order;
399
+ * encoded with `encodeShareMetadata`), or an already encoded metadata blob. Written once, with the
400
+ * share. Omitted, the labels the acp was created with are posted (`createSharing({ labels })`),
401
+ * or none. `appKey` goes with given labels; a client configured with `acp: { app }` fills it in.
402
+ */
403
+ export type ShareOnChainOptions = { labels?: readonly ShareLabel[]; metadata?: Hex; appKey?: Hex };
404
+
405
+ const metadataOf = (acp: ACP, { labels, metadata, appKey }: ShareOnChainOptions): Hex => {
406
+ if (labels != null && metadata != null) throw new Error('Pass either labels or metadata, not both');
407
+ if (labels == null) return metadata ?? acp.metadata ?? '0x';
408
+ if (labels.length !== acp.handles.length) {
409
+ throw new Error(`Cannot label a share of ${acp.handles.length} handles with ${labels.length} labels`);
410
+ }
411
+ return encodeShareMetadata(labels, { issuer: acp.issuer, appKey });
412
+ };
413
+
393
414
  /**
394
415
  * Post a signed sharing ACP to the on-chain share registry for its recipient
395
- * to discover and import — the on-chain alternative to `export()`.
416
+ * to discover and import — the on-chain alternative to `export()` — optionally
417
+ * with the labels of its handles.
396
418
  */
397
419
  const shareOnChain = async (
398
420
  acp: ACP,
399
421
  walletClient: WalletClient,
400
- registry: Hex
422
+ registry: Hex,
423
+ options: ShareOnChainOptions = {}
401
424
  ): Promise<{ txHash: Hex; shareId: Hex }> => {
402
425
  if (acp.type !== 'sharing') {
403
426
  throw new Error(`Cannot share a '${acp.type}' ACP on-chain — only 'sharing' ACPs are shareable.`);
@@ -409,12 +432,13 @@ const shareOnChain = async (
409
432
  if (walletClient.account.address.toLowerCase() !== acp.issuer.toLowerCase()) {
410
433
  throw new Error('Only the ACP issuer can share it on-chain');
411
434
  }
435
+ const metadata = metadataOf(acp, options);
412
436
 
413
437
  const txHash = await walletClient.writeContract({
414
438
  address: registry,
415
439
  abi: ACP_SHARE_REGISTRY_ABI,
416
440
  functionName: 'share',
417
- args: [toChainShare(acp)],
441
+ args: [toChainShare(acp), metadata],
418
442
  account: walletClient.account,
419
443
  chain: walletClient.chain ?? null,
420
444
  });
@@ -422,46 +446,71 @@ const shareOnChain = async (
422
446
  return { txHash, shareId: computeShareId(acp) };
423
447
  };
424
448
 
425
- /** All importable shares addressed to `recipient` (unexpired, not revoked). */
449
+ const toIncomingShare = ({ shareId, share: s, metadata }: PostedShare): IncomingShare => ({
450
+ shareId,
451
+ issuer: s.issuer,
452
+ expiration: Number(s.expiration),
453
+ recipient: s.recipient,
454
+ revokerData: Number(s.revokerData),
455
+ revokerContract: s.revokerContract,
456
+ scope: Number(s.scope),
457
+ contracts: [...s.contracts],
458
+ handles: [...s.handles],
459
+ issuerSignature: s.issuerSignature,
460
+ metadata,
461
+ ...(appKeyOf(metadata, s.handles) && { appKey: appKeyOf(metadata, s.handles) }),
462
+ });
463
+
464
+ /** The app key of a posted share; undefined when it has none, or its metadata does not decode. */
465
+ const appKeyOf = (metadata: Hex, handles: readonly Hex[]): Hex | undefined => {
466
+ try {
467
+ return decodeShareAppKey(metadata, handles);
468
+ } catch {
469
+ return undefined;
470
+ }
471
+ };
472
+
473
+ /**
474
+ * All importable shares addressed to `recipient` (unexpired, not revoked): the headers from
475
+ * `sharesFor`, then each share's `Shared` event from the block its header names (one
476
+ * single-block `getLogs` per block). A share whose event the node cannot serve yet is left out
477
+ * and turns up on a later read; it never fails the others (`readSharesFor` lists it as
478
+ * unreadable). Given an `appKey`, only the shares posted with that key.
479
+ */
426
480
  const getIncomingShares = async (
427
481
  publicClient: PublicClient,
428
482
  registry: Hex,
429
- recipient: Hex
483
+ recipient: Hex,
484
+ { appKey }: { appKey?: Hex } = {}
430
485
  ): Promise<IncomingShare[]> => {
431
- const raw = await publicClient.readContract({
432
- address: registry,
433
- abi: ACP_SHARE_REGISTRY_ABI,
434
- functionName: 'sharesFor',
435
- args: [recipient],
436
- });
486
+ const shares = (await readSharesFor(publicClient, registry, recipient)).posted.map(toIncomingShare);
487
+ return appKey == null ? shares : shares.filter((share) => share.appKey === appKey);
488
+ };
437
489
 
438
- return raw.map((s) => ({
439
- shareId: shareIdOfChainShare(s),
440
- issuer: s.issuer,
441
- expiration: Number(s.expiration),
442
- recipient: s.recipient,
443
- revokerData: Number(s.revokerData),
444
- revokerContract: s.revokerContract,
445
- scope: Number(s.scope),
446
- contracts: [...s.contracts],
447
- handles: [...s.handles],
448
- issuerSignature: s.issuerSignature,
449
- }));
490
+ /** One share by id, as posted; null when unknown or removed. Expired or revoked shares are returned as is. */
491
+ const getShareFromChain = async (
492
+ publicClient: PublicClient,
493
+ registry: Hex,
494
+ shareId: Hex
495
+ ): Promise<IncomingShare | null> => {
496
+ const posted = await readShare(publicClient, registry, shareId);
497
+ return posted && toIncomingShare(posted);
450
498
  };
451
499
 
452
500
  /**
453
501
  * Import a share read from the registry: fills the recipient's sealing key,
454
502
  * signs, stores and (unless `activate: false`) activates — the on-chain
455
- * counterpart of importing an exported JSON blob. The share stays on-chain
503
+ * counterpart of importing an exported JSON blob. Its metadata is kept on the
504
+ * recipient acp, as with an import of exported JSON. The share stays on-chain
456
505
  * until dismissed.
457
506
  */
458
507
  const importFromChain = async (
459
508
  share: IncomingShare,
460
509
  publicClient: PublicClient,
461
510
  walletClient: WalletClient,
462
- importOptions: ImportActivationOptions = {}
511
+ importOptions: ImportSharedOptions = {}
463
512
  ): Promise<RecipientACP> => {
464
- const { shareId: _shareId, ...options } = share;
513
+ const { shareId: _shareId, appKey: _appKey, ...options } = share;
465
514
  return importShared({ ...options, type: 'sharing' }, publicClient, walletClient, importOptions);
466
515
  };
467
516
 
@@ -519,11 +568,11 @@ export const acps = {
519
568
 
520
569
  shareOnChain,
521
570
  getIncomingShares,
571
+ getShareFromChain,
522
572
  importFromChain,
523
573
  removeShareOnChain,
524
574
  computeShareId,
525
575
 
526
- applyACPDefaults,
527
576
  applyACPDefaultsFromChain,
528
577
  getAclServedAddresses,
529
578
  clearAclServedAddresses,
package/core/client.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { CreateSelfACPOptions, CreateSharingACPOptions, ImportSharedACPOptions } from '@/acps';
2
+ import { decodeShareMetadata, shareAppKey, verifyShareLabels } from '@/acps';
2
3
 
3
4
  import { createStore } from 'zustand/vanilla';
4
5
  import { type Hex, type PublicClient, type WalletClient } from 'viem';
@@ -50,6 +51,10 @@ export function createCofheClientBase<TConfig extends CofheConfig>(
50
51
 
51
52
  // Share registry resolution: explicit `acp.sharingRegistry` config wins,
52
53
  // otherwise the address the chain's ACL serves.
54
+ /** `{ appKey }` of `config.acp.app`, for the shares this client creates, posts and lists; `{}` without an app. */
55
+ const withAppKey = (): { appKey?: `0x${string}` } =>
56
+ opts.config.acp?.app == null ? {} : { appKey: shareAppKey(opts.config.acp.app) };
57
+
53
58
  const _resolveSharingRegistry = async (publicClient: PublicClient, chainId: number): Promise<`0x${string}`> => {
54
59
  const configured = opts.config.acp?.sharingRegistry?.[chainId];
55
60
  if (configured != null) return configured;
@@ -254,7 +259,7 @@ export function createCofheClientBase<TConfig extends CofheConfig>(
254
259
  const { publicClient, walletClient } = clients ?? connectStore.getState();
255
260
  const chainId = await publicClient!.getChainId();
256
261
  return acps.createSharing(
257
- await acps.applyACPDefaultsFromChain(options, opts.config.acp, publicClient!, chainId),
262
+ await acps.applyACPDefaultsFromChain({ ...withAppKey(), ...options }, opts.config.acp, publicClient!, chainId),
258
263
  publicClient!,
259
264
  walletClient!
260
265
  );
@@ -262,9 +267,9 @@ export function createCofheClientBase<TConfig extends CofheConfig>(
262
267
 
263
268
  importShared: async (options: ImportSharedACPOptions | string, importOptions = {}) => {
264
269
  _requireConnected();
265
- const { activate, ...clients } = importOptions;
270
+ const { activate, name, ...clients } = importOptions;
266
271
  const { publicClient, walletClient } = clients.publicClient ? clients : connectStore.getState();
267
- return acps.importShared(options, publicClient!, walletClient!, { activate });
272
+ return acps.importShared(options, publicClient!, walletClient!, { activate, name });
268
273
  },
269
274
 
270
275
  // Get or create methods (require connection)
@@ -288,7 +293,7 @@ export function createCofheClientBase<TConfig extends CofheConfig>(
288
293
  return acps.getOrCreateSharingACP(
289
294
  publicClient!,
290
295
  walletClient!,
291
- await acps.applyACPDefaultsFromChain(options, opts.config.acp, publicClient!, _chainId),
296
+ await acps.applyACPDefaultsFromChain({ ...withAppKey(), ...options }, opts.config.acp, publicClient!, _chainId),
292
297
  _chainId,
293
298
  _account
294
299
  );
@@ -320,12 +325,12 @@ export function createCofheClientBase<TConfig extends CofheConfig>(
320
325
  },
321
326
 
322
327
  // On-chain sharing (require connection + config.acp.sharingRegistry)
323
- shareOnChain: async (acp) => {
328
+ shareOnChain: async (acp, options) => {
324
329
  _requireConnected();
325
330
  const { publicClient, walletClient } = connectStore.getState();
326
331
  const chainId = await publicClient!.getChainId();
327
332
  const sharingRegistry = await _resolveSharingRegistry(publicClient!, chainId);
328
- return acps.shareOnChain(acp, walletClient!, sharingRegistry);
333
+ return acps.shareOnChain(acp, walletClient!, sharingRegistry, { ...withAppKey(), ...options });
329
334
  },
330
335
 
331
336
  getIncomingShares: async () => {
@@ -334,7 +339,28 @@ export function createCofheClientBase<TConfig extends CofheConfig>(
334
339
  const chainId = await publicClient!.getChainId();
335
340
  const account = walletClient!.account!.address;
336
341
  const sharingRegistry = await _resolveSharingRegistry(publicClient!, chainId);
337
- return acps.getIncomingShares(publicClient!, sharingRegistry, account);
342
+ return acps.getIncomingShares(publicClient!, sharingRegistry, account, withAppKey());
343
+ },
344
+
345
+ getShareFromChain: async (shareId) => {
346
+ _requireConnected();
347
+ const { publicClient } = connectStore.getState();
348
+ const chainId = await publicClient!.getChainId();
349
+ const sharingRegistry = await _resolveSharingRegistry(publicClient!, chainId);
350
+ return acps.getShareFromChain(publicClient!, sharingRegistry, shareId);
351
+ },
352
+
353
+ verifyShareLabels: async (share, options) => {
354
+ _requireConnected();
355
+ const { publicClient } = connectStore.getState();
356
+ if (share.metadata == null || share.metadata === '0x') return null;
357
+ return verifyShareLabels({
358
+ labels: decodeShareMetadata(share.metadata, share.handles),
359
+ handles: share.handles,
360
+ issuer: share.issuer,
361
+ publicClient: publicClient!,
362
+ verify: options?.verify,
363
+ });
338
364
  },
339
365
 
340
366
  importFromChain: async (share, options) => {
@@ -6,7 +6,7 @@ import { type DecryptForTxBuilderUnset } from './decrypt/decryptForTxBuilder.js'
6
6
  import { type EncryptInputsBuilderUnset } from './encrypt/encryptInputsBuilder.js';
7
7
  import { type ZkBuilderAndCrsGenerator, type ZkProveWorkerFunction } from './encrypt/zkPackProveVerify.js';
8
8
  import { type FheKeyDeserializer } from './fetchKeys.js';
9
- import { acps, type ImportActivationOptions } from './acps.js';
9
+ import { acps, type ImportSharedOptions, type ShareOnChainOptions } from './acps.js';
10
10
  import type { EncryptableItem, FheTypes, TfheInitializer } from './types.js';
11
11
  import type { ACPUtils } from 'acps/acp.js';
12
12
  import type {
@@ -19,7 +19,9 @@ import type {
19
19
  RecipientACP,
20
20
  SelfACP,
21
21
  IncomingShare,
22
+ LabelledShare,
22
23
  } from 'acps/types.js';
24
+ import type { ShareLabelCheck, ShareLabelVerifyMode } from 'acps/share-metadata/verify.js';
23
25
 
24
26
  // CLIENT
25
27
 
@@ -77,9 +79,10 @@ export type CofheClientACPsClients = {
77
79
 
78
80
  /**
79
81
  * Options for `importShared`. `activate: false` stores the imported acp without making it the active
80
- * one (default `true`). Clients default to the connected ones; pass both to override.
82
+ * one (default `true`). `name` names it (default "Shared by 0x1234…abcd"). Clients default to the
83
+ * connected ones; pass both to override.
81
84
  */
82
- export type CofheClientImportSharedOptions = ImportActivationOptions &
85
+ export type CofheClientImportSharedOptions = ImportSharedOptions &
83
86
  (CofheClientACPsClients | { publicClient?: undefined; walletClient?: undefined });
84
87
 
85
88
  export type CofheClientACPs = {
@@ -121,12 +124,27 @@ export type CofheClientACPs = {
121
124
  */
122
125
  checkAccess: (acp: ACP, handle?: bigint | `0x${string}`) => Promise<ACPAccessStatus>;
123
126
 
124
- /** Post a signed sharing ACP to the on-chain share registry (ACL-served, or config `acp.sharingRegistry`). */
125
- shareOnChain: (acp: ACP) => Promise<{ txHash: `0x${string}`; shareId: `0x${string}` }>;
126
- /** Importable shares addressed to the connected account (unexpired, not revoked). */
127
+ /**
128
+ * Post a signed sharing ACP to the on-chain share registry (ACL-served, or config `acp.sharingRegistry`),
129
+ * optionally with the labels of its handles (`options.labels`, one per handle) or an encoded blob.
130
+ */
131
+ shareOnChain: (acp: ACP, options?: ShareOnChainOptions) => Promise<{ txHash: `0x${string}`; shareId: `0x${string}` }>;
132
+ /** Importable shares addressed to the connected account (unexpired, not revoked), with their metadata. */
127
133
  getIncomingShares: () => Promise<IncomingShare[]>;
134
+ /** One share by id, as posted; null when unknown or removed. */
135
+ getShareFromChain: (shareId: `0x${string}`) => Promise<IncomingShare | null>;
136
+ /**
137
+ * Check a share's labels against the chain, index for index with its handles, as far as
138
+ * `verify` says (default 'all'). Takes a share read from the registry or an acp imported from
139
+ * exported JSON (or the issuer's sharing acp). Null for a share without metadata. Throws on a
140
+ * malformed blob.
141
+ */
142
+ verifyShareLabels: (
143
+ share: LabelledShare,
144
+ options?: { verify?: ShareLabelVerifyMode }
145
+ ) => Promise<ShareLabelCheck[] | null>;
128
146
  /** Import a share read from the registry: sign as recipient, store and (unless `activate: false`) activate. */
129
- importFromChain: (share: IncomingShare, options?: ImportActivationOptions) => Promise<RecipientACP>;
147
+ importFromChain: (share: IncomingShare, options?: ImportSharedOptions) => Promise<RecipientACP>;
130
148
  /** Recipient-side: remove a share from the registry (after import, or to decline). */
131
149
  dismissShare: (shareId: `0x${string}`) => Promise<`0x${string}`>;
132
150
  /** Issuer-side: retract a pending share from the registry. */
package/core/config.ts CHANGED
@@ -73,6 +73,13 @@ export type CofheConfig = {
73
73
  * names a registry.
74
74
  */
75
75
  sharingRegistry?: Record<number, `0x${string}`>;
76
+ /**
77
+ * The name of this app, e.g. 'explorer.fhenix.io'. Set, every share the client creates
78
+ * carries the app's key (`shareAppKey(app)`, kept in the share metadata), and
79
+ * `getIncomingShares` lists only the shares posted for this app. Unset, shares carry no
80
+ * key and every importable share is listed.
81
+ */
82
+ app?: string;
76
83
  };
77
84
  /** Mocks configs */
78
85
  mocks: {
@@ -138,6 +145,7 @@ export const CofheConfigSchema = z.object({
138
145
  defaultRevoker: z.custom<Record<number, `0x${string}`>>().optional(),
139
146
  defaultContractScopes: z.custom<Record<number, `0x${string}`[]>>().optional(),
140
147
  sharingRegistry: z.custom<Record<number, `0x${string}`>>().optional(),
148
+ app: z.string().min(1).optional(),
141
149
  })
142
150
  .optional()
143
151
  .default({}),
@@ -48,7 +48,10 @@ const BACKEND_ERROR_CODE_TO_COFHE_ERROR_CODE: Record<BackendApiErrorCode, CofheE
48
48
  };
49
49
 
50
50
  function isBackendApiErrorCode(value: string): value is BackendApiErrorCode {
51
- return value in BACKEND_ERROR_CODE_TO_COFHE_ERROR_CODE;
51
+ // Own-property check, not `in`: `in` also matches Object.prototype keys, so codes like
52
+ // `toString` or `constructor` would be treated as recognised and the lookup would return a
53
+ // function instead of a CofheErrorCode.
54
+ return Object.prototype.hasOwnProperty.call(BACKEND_ERROR_CODE_TO_COFHE_ERROR_CODE, value);
52
55
  }
53
56
 
54
57
  export type ParsedApiError = {
@@ -30,6 +30,9 @@ const buildDecryptResultHash = (ctHash: bigint, cleartext: bigint, chainId: numb
30
30
  *
31
31
  * The recovered signer must equal the on-chain configured `decryptResultSigner`.
32
32
  *
33
+ * Fails closed: if the chain has no `decryptResultSigner` set (zero address), this returns `false`.
34
+ * The on-chain `verifyDecryptResult` instead accepts any signature in that case.
35
+ *
33
36
  * This mirrors the TaskManager decrypt-result hash format:
34
37
  * `keccak256(abi.encodePacked(result, encType, chainId, ctHash))`
35
38
  *
@@ -52,7 +55,7 @@ export async function verifyDecryptResult(
52
55
  args: [],
53
56
  });
54
57
 
55
- if (isAddressEqual(expectedSigner, zeroAddress)) return true;
58
+ if (isAddressEqual(expectedSigner, zeroAddress)) return false;
56
59
 
57
60
  const ctHash = BigInt(handle);
58
61
  const messageHash = buildDecryptResultHash(ctHash, cleartext, chainId);