@cofhe/sdk 0.7.1 → 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 (123) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/acps/acp.ts +35 -7
  3. package/acps/index.ts +28 -12
  4. package/acps/onchain-utils.ts +188 -49
  5. package/acps/registry.ts +231 -0
  6. package/acps/sealing.ts +9 -3
  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/acp.test.ts +102 -7
  25. package/acps/test/localstorage.test.ts +28 -1
  26. package/acps/test/onchainAccessStatus.test.ts +116 -0
  27. package/acps/test/registry.test.ts +144 -0
  28. package/acps/test/sealing.test.ts +23 -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 +59 -6
  33. package/acps/utils.ts +15 -2
  34. package/acps/validation.ts +3 -1
  35. package/chains/chains/arbitrum.ts +14 -0
  36. package/chains/chains/ethereum.ts +14 -0
  37. package/chains/index.ts +5 -1
  38. package/chains/test/chains.test.ts +24 -3
  39. package/core/acps.ts +164 -207
  40. package/core/client.ts +44 -13
  41. package/core/clientTypes.ts +50 -7
  42. package/core/config.ts +33 -1
  43. package/core/consts.ts +14 -2
  44. package/core/decrypt/apiError.ts +4 -1
  45. package/core/decrypt/verifyDecryptResult.ts +8 -4
  46. package/core/encrypt/cofheMocksZkVerifySign.ts +19 -2
  47. package/core/encrypt/encryptInputsBuilder.ts +6 -0
  48. package/core/encrypt/zkPackProveVerify.ts +7 -1
  49. package/core/error.ts +1 -0
  50. package/core/fetchKeys.ts +2 -2
  51. package/core/index.ts +9 -1
  52. package/core/keyStore.ts +23 -10
  53. package/core/taskManager.ts +48 -0
  54. package/core/test/acpDefaults.test.ts +108 -19
  55. package/core/test/acpStoreAccountCase.test.ts +80 -0
  56. package/core/test/acps.test.ts +128 -3
  57. package/core/test/apiError.test.ts +8 -0
  58. package/core/test/appKeyConfig.test.ts +94 -0
  59. package/core/test/client.test.ts +41 -0
  60. package/core/test/config.test.ts +24 -0
  61. package/core/test/decrypt.test.ts +11 -4
  62. package/core/test/decryptBuilders.test.ts +110 -2
  63. package/core/test/encryptInputsBuilder.test.ts +1 -1
  64. package/core/test/fetchKeys.test.ts +23 -2
  65. package/core/test/incomingShares.test.ts +84 -0
  66. package/core/test/keyStore.test.ts +18 -6
  67. package/core/test/mocksEncryptRange.test.ts +90 -0
  68. package/core/test/taskManager.test.ts +57 -0
  69. package/core/test/utils.test.ts +128 -0
  70. package/core/test/verifyDecryptResult.test.ts +51 -0
  71. package/core/types.ts +8 -0
  72. package/core/utils.ts +16 -9
  73. package/dist/acps.cjs +984 -64
  74. package/dist/acps.d.cts +589 -10
  75. package/dist/acps.d.ts +589 -10
  76. package/dist/acps.js +2 -2
  77. package/dist/chains.cjs +26 -0
  78. package/dist/chains.d.cts +45 -1
  79. package/dist/chains.d.ts +45 -1
  80. package/dist/chains.js +1 -1
  81. package/dist/chunk-6ZW76ZKB.js +228 -0
  82. package/dist/chunk-BQWHPIYU.js +1828 -0
  83. package/dist/chunk-KSOV5B4B.js +50 -0
  84. package/dist/{chunk-43USWPEH.js → chunk-OL3DZN7A.js} +159 -523
  85. package/dist/{chunk-N6IDQRRU.js → chunk-UKQAJK4N.js} +25 -1
  86. package/dist/chunk-XQ32JG3T.js +174 -0
  87. package/dist/{clientTypes-CYZjFznO.d.cts → clientTypes-C0A9ji5O.d.cts} +132 -29
  88. package/dist/{clientTypes-BN3nbzYM.d.ts → clientTypes-CcU5WvdV.d.ts} +132 -29
  89. package/dist/core.cjs +3269 -2626
  90. package/dist/core.d.cts +32 -8
  91. package/dist/core.d.ts +32 -8
  92. package/dist/core.js +5 -4
  93. package/dist/node.cjs +3074 -2436
  94. package/dist/node.d.cts +2 -2
  95. package/dist/node.d.ts +2 -2
  96. package/dist/node.js +20 -19
  97. package/dist/{acp-Wi6isVQI.d.cts → onchain-utils-D9Z33IP7.d.cts} +166 -12
  98. package/dist/{acp-Wi6isVQI.d.ts → onchain-utils-D9Z33IP7.d.ts} +166 -12
  99. package/dist/web.cjs +3204 -2493
  100. package/dist/web.d.cts +49 -3
  101. package/dist/web.d.ts +49 -3
  102. package/dist/web.js +52 -27
  103. package/dist/zkProve.worker.cjs +79 -18
  104. package/dist/zkProve.worker.js +24 -24
  105. package/node/index.ts +1 -10
  106. package/node/storage.ts +20 -9
  107. package/node/test/inherited.test.ts +5 -1
  108. package/node/test/storage-memory-fallback.test.ts +47 -0
  109. package/node/test/storage-permissions.test.ts +40 -0
  110. package/package.json +3 -1
  111. package/web/index.ts +101 -36
  112. package/web/test/countingZkProveWorker.ts +31 -0
  113. package/web/test/inherited.web.test.ts +5 -1
  114. package/web/test/mainThreadPool.web.test.ts +174 -0
  115. package/web/test/threadPoolBenchHelper.ts +134 -0
  116. package/web/test/threadPoolOff.web.test.ts +17 -0
  117. package/web/test/threadPoolOn.web.test.ts +80 -0
  118. package/web/test/workerInitRace.web.test.ts +79 -0
  119. package/web/tfheThreadPool.ts +140 -0
  120. package/web/workerManager.ts +4 -2
  121. package/web/zkProve.worker.ts +42 -26
  122. package/dist/chunk-ESMZCFJY.js +0 -12
  123. package/dist/chunk-Q7CBWGQX.js +0 -1029
package/acps/store.ts CHANGED
@@ -28,6 +28,24 @@ export const ACP_STORE_DEFAULTS: ACPsStore = {
28
28
  */
29
29
  const ACP_STORE_VERSION = 3;
30
30
 
31
+ // `typeof null` and `typeof []` are both 'object', so a bare typeof check lets a broken
32
+ // persisted store through. A `null` record then throws on the first `acps[chainId]` lookup.
33
+ // Defined above the store: localStorage rehydrates synchronously inside `createStore`.
34
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
35
+ value != null && typeof value === 'object' && !Array.isArray(value);
36
+
37
+ // Accounts are addresses, so their case carries no meaning: the same account may arrive
38
+ // checksummed (from the wallet client) or lowercased (from an indexer, a URL, user input).
39
+ // Resolve to the key the account is already stored under, so a lookup in another case still
40
+ // finds it and a write does not split one account's acps across two buckets. A new account
41
+ // keeps the spelling it was first written with (the connected wallet's address in practice),
42
+ // which is the key the React hooks read from the raw snapshot.
43
+ const resolveAccountKey = (records: Record<string, unknown> | undefined, account: string): string => {
44
+ if (records == null || account in records) return account;
45
+ const lower = account.toLowerCase();
46
+ return Object.keys(records).find((key) => key.toLowerCase() === lower) ?? account;
47
+ };
48
+
31
49
  export const _acpStore = createStore<ACPsStore>()(
32
50
  persist(() => ACP_STORE_DEFAULTS, {
33
51
  name: 'cofhesdk-acps',
@@ -36,6 +54,17 @@ export const _acpStore = createStore<ACPsStore>()(
36
54
  if (version < ACP_STORE_VERSION) return ACP_STORE_DEFAULTS;
37
55
  return persistedState as ACPsStore;
38
56
  },
57
+ // Runs on every rehydrate, including a corrupt state saved at the current version that
58
+ // `migrate` passes through unchanged. Consumers that read the raw snapshot (e.g. the React
59
+ // hooks) never go through `clearStaleStore`, so the shape has to be fixed here.
60
+ merge: (persistedState, currentState) => {
61
+ const persisted = (isRecord(persistedState) ? persistedState : {}) as Partial<ACPsStore>;
62
+ return {
63
+ ...currentState,
64
+ acps: isRecord(persisted.acps) ? persisted.acps : currentState.acps,
65
+ activeACPHash: isRecord(persisted.activeACPHash) ? persisted.activeACPHash : currentState.activeACPHash,
66
+ };
67
+ },
39
68
  })
40
69
  );
41
70
 
@@ -44,13 +73,7 @@ export const clearStaleStore = () => {
44
73
  const state = _acpStore.getState() as any;
45
74
 
46
75
  // Check if the store has the expected structure
47
- const hasExpectedStructure =
48
- state &&
49
- typeof state === 'object' &&
50
- 'acps' in state &&
51
- 'activeACPHash' in state &&
52
- typeof state.acps === 'object' &&
53
- typeof state.activeACPHash === 'object';
76
+ const hasExpectedStructure = isRecord(state) && isRecord(state.acps) && isRecord(state.activeACPHash);
54
77
 
55
78
  if (hasExpectedStructure) return;
56
79
  // Invalid structure detected - clear the store
@@ -65,7 +88,8 @@ export const getACP = (
65
88
  clearStaleStore();
66
89
  if (chainId == null || account == null || hash == null) return;
67
90
 
68
- const savedACP = _acpStore.getState().acps[chainId]?.[account]?.[hash];
91
+ const accountACPs = _acpStore.getState().acps[chainId];
92
+ const savedACP = accountACPs?.[resolveAccountKey(accountACPs, account)]?.[hash];
69
93
  if (savedACP == null) return;
70
94
 
71
95
  return ACPUtils.deserialize(savedACP);
@@ -75,7 +99,8 @@ export const getActiveACP = (chainId: number | undefined, account: string | unde
75
99
  clearStaleStore();
76
100
  if (chainId == null || account == null) return;
77
101
 
78
- const activeACPHash = _acpStore.getState().activeACPHash[chainId]?.[account];
102
+ const activeHashes = _acpStore.getState().activeACPHash[chainId];
103
+ const activeACPHash = activeHashes?.[resolveAccountKey(activeHashes, account)];
79
104
  return getACP(chainId, account, activeACPHash);
80
105
  };
81
106
 
@@ -83,7 +108,8 @@ export const getACPs = (chainId: number | undefined, account: string | undefined
83
108
  clearStaleStore();
84
109
  if (chainId == null || account == null) return {};
85
110
 
86
- return Object.entries(_acpStore.getState().acps[chainId]?.[account] ?? {}).reduce(
111
+ const accountACPs = _acpStore.getState().acps[chainId];
112
+ return Object.entries(accountACPs?.[resolveAccountKey(accountACPs, account)] ?? {}).reduce(
87
113
  (acc, [hash, acp]) => {
88
114
  if (acp == undefined) return acc;
89
115
  return { ...acc, [hash]: ACPUtils.deserialize(acp) };
@@ -97,8 +123,9 @@ export const setACP = (chainId: number, account: string, acp: ACP) => {
97
123
  _acpStore.setState(
98
124
  produce<ACPsStore>((state) => {
99
125
  if (state.acps[chainId] == null) state.acps[chainId] = {};
100
- if (state.acps[chainId][account] == null) state.acps[chainId][account] = {};
101
- state.acps[chainId][account][acp.hash] = ACPUtils.serialize(acp);
126
+ const key = resolveAccountKey(state.acps[chainId], account);
127
+ if (state.acps[chainId][key] == null) state.acps[chainId][key] = {};
128
+ state.acps[chainId][key][acp.hash] = ACPUtils.serialize(acp);
102
129
  })
103
130
  );
104
131
  };
@@ -110,14 +137,15 @@ export const removeACP = (chainId: number, account: string, hash: string) => {
110
137
  if (state.acps[chainId] == null) state.acps[chainId] = {};
111
138
  if (state.activeACPHash[chainId] == null) state.activeACPHash[chainId] = {};
112
139
 
113
- const accountACPs = state.acps[chainId][account];
140
+ const accountACPs = state.acps[chainId][resolveAccountKey(state.acps[chainId], account)];
114
141
  if (accountACPs == null) return;
115
142
 
116
143
  if (accountACPs[hash] == null) return;
117
144
 
118
- if (state.activeACPHash[chainId][account] === hash) {
145
+ const activeKey = resolveAccountKey(state.activeACPHash[chainId], account);
146
+ if (state.activeACPHash[chainId][activeKey] === hash) {
119
147
  // if the active acp is the one to be removed - unset it
120
- state.activeACPHash[chainId][account] = undefined;
148
+ state.activeACPHash[chainId][activeKey] = undefined;
121
149
  }
122
150
  // Remove the acp
123
151
  accountACPs[hash] = undefined;
@@ -128,7 +156,8 @@ export const removeACP = (chainId: number, account: string, hash: string) => {
128
156
  export const getActiveACPHash = (chainId: number | undefined, account: string | undefined): string | undefined => {
129
157
  clearStaleStore();
130
158
  if (chainId == null || account == null) return undefined;
131
- return _acpStore.getState().activeACPHash[chainId]?.[account];
159
+ const activeHashes = _acpStore.getState().activeACPHash[chainId];
160
+ return activeHashes?.[resolveAccountKey(activeHashes, account)];
132
161
  };
133
162
 
134
163
  export const setActiveACPHash = (chainId: number, account: string, hash: string) => {
@@ -136,7 +165,7 @@ export const setActiveACPHash = (chainId: number, account: string, hash: string)
136
165
  _acpStore.setState(
137
166
  produce<ACPsStore>((state) => {
138
167
  if (state.activeACPHash[chainId] == null) state.activeACPHash[chainId] = {};
139
- state.activeACPHash[chainId][account] = hash;
168
+ state.activeACPHash[chainId][resolveAccountKey(state.activeACPHash[chainId], account)] = hash;
140
169
  })
141
170
  );
142
171
  };
@@ -145,7 +174,8 @@ export const removeActiveACPHash = (chainId: number, account: string) => {
145
174
  clearStaleStore();
146
175
  _acpStore.setState(
147
176
  produce<ACPsStore>((state) => {
148
- if (state.activeACPHash[chainId]) state.activeACPHash[chainId][account] = undefined;
177
+ if (state.activeACPHash[chainId])
178
+ state.activeACPHash[chainId][resolveAccountKey(state.activeACPHash[chainId], account)] = undefined;
149
179
  })
150
180
  );
151
181
  };
@@ -1,9 +1,12 @@
1
1
  import { describe, it, expect, vi } from 'vitest';
2
+ import { UNLABELLED, decodeShareAppKey, shareAppKey } from '../share-metadata';
2
3
  import {
3
4
  ACPUtils,
5
+ encodeShareMetadata,
4
6
  type CreateSelfACPOptions,
5
7
  type CreateSharingACPOptions,
6
8
  type ImportSharedACPOptions,
9
+ type ShareLabel,
7
10
  } from '../index.js';
8
11
 
9
12
  // ACP domain resolution requires an upgraded on-chain ACL (domain ("ACL","2") served by the ACL itself).
@@ -86,6 +89,21 @@ describe('ACPUtils Tests', () => {
86
89
  });
87
90
 
88
91
  describe('createSharing', () => {
92
+ it('keeps the app key in the metadata, with the labels or with the handles listed as unlabelled', () => {
93
+ const handle = `0x${'aa'.repeat(32)}` as const;
94
+ const appKey = shareAppKey('explorer.fhenix.io');
95
+ const base = { type: 'sharing' as const, issuer: bobAddress, recipient: aliceAddress, handles: [handle] };
96
+
97
+ const keyOnly = ACPUtils.createSharing({ ...base, appKey });
98
+ expect(keyOnly.metadata).toBe(encodeShareMetadata([UNLABELLED], { appKey }));
99
+ expect(decodeShareAppKey(keyOnly.metadata, [handle])).toBe(appKey);
100
+
101
+ const labelled = ACPUtils.createSharing({ ...base, appKey, labels: [UNLABELLED] });
102
+ expect(labelled.metadata).toBe(keyOnly.metadata);
103
+
104
+ expect(ACPUtils.createSharing(base).metadata).toBeUndefined();
105
+ });
106
+
89
107
  it('should create a sharing acp with valid options', async () => {
90
108
  const options: CreateSharingACPOptions = {
91
109
  type: 'sharing',
@@ -129,14 +147,13 @@ describe('ACPUtils Tests', () => {
129
147
  expiration: Math.floor(Date.now() / 1000) + 3600, // 1 hour from now
130
148
  recipient: aliceAddress,
131
149
  issuerSignature: '0x1234567890abcdef',
132
- name: 'Test Import ACP',
133
150
  };
134
151
 
135
152
  const acp = ACPUtils.importShared(options);
136
153
 
137
154
  expect(acp.hash).toBe(ACPUtils.getHash(acp));
138
155
  expect(acp.type).toBe('recipient');
139
- expect(acp.name).toBe('Test Import ACP');
156
+ expect(acp.name).toBe(`Shared by ${bobAddress.slice(0, 6)}…${bobAddress.slice(-4)}`);
140
157
  expect(acp.issuer).toBe(bobAddress);
141
158
  expect(acp.recipient).toBe(aliceAddress);
142
159
  expect(acp.issuerSignature).toBe('0x1234567890abcdef');
@@ -147,6 +164,19 @@ describe('ACPUtils Tests', () => {
147
164
  expect(acp.recipientSignature).toBe('0x');
148
165
  });
149
166
 
167
+ it("ignores the issuer's name in the share; the recipient may give their own", async () => {
168
+ const share = JSON.stringify({
169
+ issuer: bobAddress,
170
+ expiration: Math.floor(Date.now() / 1000) + 3600,
171
+ recipient: aliceAddress,
172
+ issuerSignature: '0x1234567890abcdef',
173
+ name: "Bob's note",
174
+ });
175
+
176
+ expect(ACPUtils.importShared(share).name).toBe(`Shared by ${bobAddress.slice(0, 6)}…${bobAddress.slice(-4)}`);
177
+ expect(ACPUtils.importShared(share, { name: ' Payroll from Bob ' }).name).toBe('Payroll from Bob');
178
+ });
179
+
150
180
  it('should import a shared acp with valid options as string', async () => {
151
181
  const options: ImportSharedACPOptions = {
152
182
  issuer: bobAddress,
@@ -188,7 +218,6 @@ describe('ACPUtils Tests', () => {
188
218
  expiration: Math.floor(Date.now() / 1000) + 3600, // 1 hour from now
189
219
  recipient: aliceAddress,
190
220
  issuerSignature: '0x', // Invalid empty signature
191
- name: 'Test Import ACP',
192
221
  };
193
222
 
194
223
  expect(() => ACPUtils.importShared(options)).toThrow();
@@ -246,7 +275,6 @@ describe('ACPUtils Tests', () => {
246
275
  recipient: aliceAddress,
247
276
  expiration: Math.floor(Date.now() / 1000) + 3600, // 1 hour from now
248
277
  issuerSignature: '0x1234567890abcdef',
249
- name: 'Test Import ACP',
250
278
  };
251
279
 
252
280
  const acp = await ACPUtils.importSharedAndSign(options, publicClient, aliceWalletClient);
@@ -315,7 +343,6 @@ describe('ACPUtils Tests', () => {
315
343
  recipient: aliceAddress,
316
344
  expiration: Math.floor(Date.now() / 1000) + 3600, // 1 hour from now
317
345
  issuerSignature: '0x1111111111111111111111111111111111111111111111111111111111111111',
318
- name: 'Test ACP',
319
346
  });
320
347
 
321
348
  const signedACP = await ACPUtils.sign(acp, publicClient, aliceWalletClient);
@@ -431,7 +458,7 @@ describe('ACPUtils Tests', () => {
431
458
  const exported = ACPUtils.export(acp);
432
459
  const parsed = JSON.parse(exported);
433
460
 
434
- expect(parsed.name).toBe('Test Sharing ACP');
461
+ expect(parsed).not.toHaveProperty('name'); // the issuer's own note stays with them
435
462
  expect(parsed.type).toBe('sharing');
436
463
  expect(parsed.issuer).toBe(bobAddress);
437
464
  expect(parsed.recipient).toBe(aliceAddress);
@@ -456,7 +483,6 @@ describe('ACPUtils Tests', () => {
456
483
 
457
484
  expect(Object.keys(parsed).sort()).toEqual(
458
485
  [
459
- 'name',
460
486
  'type',
461
487
  'issuer',
462
488
  'expiration',
@@ -475,6 +501,7 @@ describe('ACPUtils Tests', () => {
475
501
  expect(parsed.handles).toEqual([]);
476
502
  expect(parsed.revokerData).toBe(0);
477
503
  // private component never leaves the client
504
+ expect(parsed).not.toHaveProperty('name');
478
505
  expect(parsed).not.toHaveProperty('sealingPrivateKey');
479
506
  expect(parsed).not.toHaveProperty('sealingKey');
480
507
  expect(parsed).not.toHaveProperty('hash');
@@ -482,6 +509,74 @@ describe('ACPUtils Tests', () => {
482
509
  });
483
510
  });
484
511
 
512
+ describe('share labels in exported JSON', () => {
513
+ const HANDLE = `0x${'ab'.repeat(32)}` as const;
514
+ const label: ShareLabel = {
515
+ kind: 'stored',
516
+ contract: '0x2222222222222222222222222222222222222222',
517
+ selector: '0x344ff101',
518
+ args: [{ type: 'issuer' }],
519
+ returnWord: 0,
520
+ block: 123n,
521
+ };
522
+ const signed = (acp: ReturnType<typeof ACPUtils.createSharing>) => ({
523
+ ...acp,
524
+ issuerSignature: `0x${'11'.repeat(65)}` as const,
525
+ });
526
+
527
+ it('createSharing keeps the labels on the acp as metadata', () => {
528
+ const acp = ACPUtils.createSharing({
529
+ issuer: bobAddress,
530
+ recipient: aliceAddress,
531
+ handles: [HANDLE],
532
+ labels: [label],
533
+ });
534
+ expect(acp.metadata).toBe(encodeShareMetadata([label]));
535
+ expect(ACPUtils.deserialize(ACPUtils.serialize(acp)).metadata).toBe(acp.metadata);
536
+ });
537
+
538
+ it('createSharing rejects a label count that does not match the handles', () => {
539
+ expect(() =>
540
+ ACPUtils.createSharing({
541
+ issuer: bobAddress,
542
+ recipient: aliceAddress,
543
+ handles: [HANDLE],
544
+ labels: [label, label],
545
+ })
546
+ ).toThrow('Cannot label a share of 1 handles with 2 labels');
547
+ });
548
+
549
+ it('export writes the metadata and importShared keeps it on the recipient acp', () => {
550
+ const sharing = signed(
551
+ ACPUtils.createSharing({ issuer: bobAddress, recipient: aliceAddress, handles: [HANDLE], labels: [label] })
552
+ );
553
+ const json = ACPUtils.export(sharing);
554
+ expect(JSON.parse(json).metadata).toBe(sharing.metadata);
555
+
556
+ const recipient = ACPUtils.importShared(json);
557
+ expect(recipient.metadata).toBe(sharing.metadata);
558
+ expect(recipient.hash).toBe(ACPUtils.importShared({ ...JSON.parse(json), metadata: undefined }).hash);
559
+ });
560
+
561
+ it('imports JSON without metadata, or with an empty one, as a share without labels', () => {
562
+ const sharing = signed(
563
+ ACPUtils.createSharing({ issuer: bobAddress, recipient: aliceAddress, handles: [HANDLE] })
564
+ );
565
+ const json = ACPUtils.export(sharing);
566
+ expect(JSON.parse(json)).not.toHaveProperty('metadata');
567
+
568
+ expect(ACPUtils.importShared(json).metadata).toBeUndefined();
569
+ expect(ACPUtils.importShared({ ...JSON.parse(json), metadata: '0x' }).metadata).toBeUndefined();
570
+ });
571
+
572
+ it('importShared rejects metadata that is not hex', () => {
573
+ const json = ACPUtils.export(
574
+ signed(ACPUtils.createSharing({ issuer: bobAddress, recipient: aliceAddress, handles: [HANDLE] }))
575
+ );
576
+ expect(() => ACPUtils.importShared({ ...JSON.parse(json), metadata: 'labels' })).toThrow(/metadata/);
577
+ });
578
+ });
579
+
485
580
  describe('updateName', () => {
486
581
  it('should update acp name immutably', async () => {
487
582
  const acp = ACPUtils.createSelf({
@@ -3,7 +3,16 @@
3
3
  */
4
4
 
5
5
  import { describe, it, expect, beforeEach, afterEach } from 'vitest';
6
- import { getACP, setACP, removeACP, getActiveACPHash, setActiveACPHash, ACPUtils, acpStore } from '../index.js';
6
+ import {
7
+ getACP,
8
+ setACP,
9
+ removeACP,
10
+ getActiveACPHash,
11
+ setActiveACPHash,
12
+ ACPUtils,
13
+ acpStore,
14
+ ACP_STORE_DEFAULTS,
15
+ } from '../index.js';
7
16
  import { createMockACP } from '../test-utils.js';
8
17
 
9
18
  // Type declarations for happy-dom environment
@@ -102,4 +111,22 @@ describe('ACPs localStorage Tests', () => {
102
111
  expect(parsedData.state.acps[chainId][account][acp.hash]).toBeUndefined();
103
112
  expect(parsedData.state.activeACPHash[chainId][account]).toBeUndefined();
104
113
  });
114
+
115
+ it('should recover from a corrupt store persisted at the current version', async () => {
116
+ // `migrate` only runs for older versions, so a corrupt state saved at the current
117
+ // version reaches the store unless it is cleaned on rehydrate.
118
+ localStorage.setItem(
119
+ 'cofhesdk-acps',
120
+ JSON.stringify({ state: { acps: null, activeACPHash: {} }, version: acpStore.store.persist.getOptions().version })
121
+ );
122
+ await acpStore.store.persist.rehydrate();
123
+
124
+ // Fixed on load, so consumers that read the raw snapshot (the React hooks) are covered too.
125
+ expect(acpStore.store.getState()).toEqual(ACP_STORE_DEFAULTS);
126
+ const snapshot = acpStore.store.getState();
127
+ expect(() => snapshot.acps[chainId]?.[account]).not.toThrow();
128
+
129
+ expect(() => getACP(chainId, account, '0xdeadbeef')).not.toThrow();
130
+ expect(getACP(chainId, account, '0xdeadbeef')).toBeUndefined();
131
+ });
105
132
  });
@@ -0,0 +1,116 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import type { PublicClient } from 'viem';
3
+ import { checkACPValidityOnChain, getACPAccessStatusOnChain } from '../onchain-utils';
4
+ import type { ACPPublic } from '../types';
5
+
6
+ const TASK_MANAGER = '0x0000000000000000000000000000000000005000';
7
+ const ACL = '0x00000000000000000000000000000000000000ac';
8
+ const ISSUER = '0x00000000000000000000000000000000000000b0';
9
+
10
+ const acp: ACPPublic = {
11
+ issuer: ISSUER,
12
+ expiration: 2_000_000_000,
13
+ recipient: '0x00000000000000000000000000000000000000a1',
14
+ revokerData: 1,
15
+ revokerContract: '0x00000000000000000000000000000000000000cc',
16
+ scope: 2,
17
+ contracts: [],
18
+ handles: [],
19
+ sealingKey: `0x${'0'.repeat(64)}`,
20
+ issuerSignature: '0x01',
21
+ recipientSignature: '0x02',
22
+ } as unknown as ACPPublic;
23
+
24
+ /**
25
+ * A revert as a viem copy OTHER than the SDK one reports it: the same shape, but no class
26
+ * identity the SDK could match with `instanceof`.
27
+ */
28
+ function foreignRevert(errorName: string) {
29
+ const reverted = { name: 'ContractFunctionRevertedError', data: { errorName } };
30
+ return Object.assign(new Error(`The contract function reverted: ${errorName}`), {
31
+ walk: (fn: (e: unknown) => boolean) => (fn(reverted) ? reverted : null),
32
+ });
33
+ }
34
+
35
+ function client({ simulate, reads = {} }: { simulate?: () => unknown; reads?: Record<string, () => unknown> }) {
36
+ return {
37
+ chain: { id: 31337 },
38
+ readContract: vi.fn(async ({ functionName }: { functionName: string }) => {
39
+ if (functionName === 'getTm') return TASK_MANAGER;
40
+ if (functionName === 'acl') return ACL;
41
+ const read = reads[functionName];
42
+ if (!read) throw new Error(`unexpected read ${functionName}`);
43
+ return read();
44
+ }),
45
+ simulateContract: vi.fn(async () => simulate?.()),
46
+ } as unknown as PublicClient;
47
+ }
48
+
49
+ describe('getACPAccessStatusOnChain', () => {
50
+ it('returns valid when the ACL accepts the ACP', async () => {
51
+ expect(await getACPAccessStatusOnChain(acp, client({}))).toBe('valid');
52
+ });
53
+
54
+ it.each([
55
+ ['PermissionInvalid_Expired', 'expired'],
56
+ ['PermissionInvalid_Disabled', 'revoked'],
57
+ ['PermissionInvalid_IssuerSignature', 'invalid-issuer-signature'],
58
+ ['PermissionInvalid_RecipientSignature', 'invalid-recipient-signature'],
59
+ ])('maps %s to %s, even from another viem copy', async (errorName, status) => {
60
+ const publicClient = client({
61
+ simulate: () => {
62
+ throw foreignRevert(errorName);
63
+ },
64
+ });
65
+ expect(await getACPAccessStatusOnChain(acp, publicClient)).toBe(status);
66
+ });
67
+
68
+ it('tells out-of-scope from issuer-not-allowed with the issuer own allowance', async () => {
69
+ const outOfScope = client({ reads: { isAllowedWithPermission: () => false, isAllowed: () => true } });
70
+ const notAllowed = client({ reads: { isAllowedWithPermission: () => false, isAllowed: () => false } });
71
+ const allowed = client({ reads: { isAllowedWithPermission: () => true } });
72
+ expect(await getACPAccessStatusOnChain(acp, outOfScope, 1n)).toBe('out-of-scope');
73
+ expect(await getACPAccessStatusOnChain(acp, notAllowed, '0x01')).toBe('issuer-not-allowed');
74
+ expect(await getACPAccessStatusOnChain(acp, allowed, 1n)).toBe('allowed');
75
+ });
76
+
77
+ it('maps a revert on the handle check too', async () => {
78
+ const publicClient = client({
79
+ reads: {
80
+ isAllowedWithPermission: () => {
81
+ throw foreignRevert('PermissionInvalid_Disabled');
82
+ },
83
+ },
84
+ });
85
+ expect(await getACPAccessStatusOnChain(acp, publicClient, 1n)).toBe('revoked');
86
+ });
87
+
88
+ it('reads a revert reported only as text with the error selector', async () => {
89
+ const publicClient = client({
90
+ simulate: () => {
91
+ throw new Error('Execution reverted with reason: RPC eth_call failed: custom error 0xcbd3a966.');
92
+ },
93
+ });
94
+ expect(await getACPAccessStatusOnChain(acp, publicClient)).toBe('revoked');
95
+ });
96
+
97
+ it('still throws what is not an ACL revert', async () => {
98
+ const publicClient = client({
99
+ simulate: () => {
100
+ throw new Error('fetch failed');
101
+ },
102
+ });
103
+ await expect(getACPAccessStatusOnChain(acp, publicClient)).rejects.toThrow('fetch failed');
104
+ });
105
+ });
106
+
107
+ describe('checkACPValidityOnChain', () => {
108
+ it('names the revert from another viem copy instead of rethrowing it raw', async () => {
109
+ const publicClient = client({
110
+ simulate: () => {
111
+ throw foreignRevert('PermissionInvalid_Disabled');
112
+ },
113
+ });
114
+ await expect(checkACPValidityOnChain(acp, publicClient)).rejects.toThrow(/^PermissionInvalid_Disabled$/);
115
+ });
116
+ });
@@ -0,0 +1,144 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import type { PublicClient } from 'viem';
3
+ import {
4
+ readPostedShares,
5
+ readShare,
6
+ readSharesFor,
7
+ shareIdOfChainShare,
8
+ type ChainShare,
9
+ type ShareHeader,
10
+ } from '../registry';
11
+
12
+ const REGISTRY = '0x00000000000000000000000000000000000000e0';
13
+ const ISSUER = '0x00000000000000000000000000000000000000b0';
14
+ const RECIPIENT = '0x00000000000000000000000000000000000000a1';
15
+ const ZERO = '0x0000000000000000000000000000000000000000';
16
+
17
+ const chainShare = (handle: number): ChainShare => ({
18
+ issuer: ISSUER,
19
+ expiration: 2_000_000_000n,
20
+ recipient: RECIPIENT,
21
+ revokerData: 0n,
22
+ revokerContract: ZERO,
23
+ scope: 2,
24
+ contracts: [],
25
+ handles: [`0x${handle.toString(16).padStart(64, '0')}`],
26
+ sealingKey: `0x${'0'.repeat(64)}`,
27
+ issuerSignature: '0x11',
28
+ recipientSignature: '0x',
29
+ });
30
+
31
+ const headerAt = (blockNumber: bigint): ShareHeader => ({
32
+ issuer: ISSUER,
33
+ expiration: 2_000_000_000n,
34
+ recipient: RECIPIENT,
35
+ blockNumber,
36
+ revokerContract: ZERO,
37
+ revokerData: 0n,
38
+ });
39
+
40
+ /** Three shares: two posted in block 10, one in block 20. */
41
+ const shares = [chainShare(1), chainShare(2), chainShare(3)];
42
+ const ids = shares.map(shareIdOfChainShare);
43
+ const headers = [headerAt(10n), headerAt(10n), headerAt(20n)];
44
+ const metadata: `0x${string}`[] = ['0x03aa', '0x', '0x03bb'];
45
+
46
+ const clientWith = (
47
+ events = shares.map((acp, i) => ({ block: headers[i].blockNumber, acp, id: ids[i], metadata: metadata[i] }))
48
+ ) => {
49
+ const getContractEvents = vi.fn(async ({ fromBlock, args }: { fromBlock: bigint; args: { shareId: string[] } }) =>
50
+ events
51
+ .filter((e) => e.block === fromBlock && args.shareId.includes(e.id))
52
+ .map((e) => ({ args: { shareId: e.id, acp: e.acp, metadata: e.metadata } }))
53
+ );
54
+ const readContract = vi.fn(async ({ functionName, args }: { functionName: string; args: [string] }) => {
55
+ if (functionName === 'sharesFor') return [ids, headers];
56
+ const i = ids.indexOf(args[0] as `0x${string}`);
57
+ return i === -1 ? { ...headerAt(0n), issuer: ZERO } : headers[i];
58
+ });
59
+ return { client: { getContractEvents, readContract } as unknown as PublicClient, getContractEvents };
60
+ };
61
+
62
+ describe('registry reads', () => {
63
+ it('reads the inbox with one getLogs per distinct block, in sharesFor order', async () => {
64
+ const { client, getContractEvents } = clientWith();
65
+ const { posted, unreadable } = await readSharesFor(client, REGISTRY, RECIPIENT);
66
+
67
+ expect(unreadable).toEqual([]);
68
+ expect(posted.map((p) => p.shareId)).toEqual(ids);
69
+ expect(posted.map((p) => p.share)).toEqual(shares);
70
+ expect(posted.map((p) => p.metadata)).toEqual([...metadata]);
71
+ expect(getContractEvents).toHaveBeenCalledTimes(2);
72
+ expect(getContractEvents).toHaveBeenCalledWith(
73
+ expect.objectContaining({
74
+ eventName: 'Shared',
75
+ fromBlock: 10n,
76
+ toBlock: 10n,
77
+ args: { shareId: [ids[0], ids[1]] },
78
+ })
79
+ );
80
+ });
81
+
82
+ it('reads one share, and null for an unknown one', async () => {
83
+ const { client } = clientWith();
84
+ expect((await readShare(client, REGISTRY, ids[2]))?.metadata).toBe('0x03bb');
85
+ expect(await readShare(client, REGISTRY, `0x${'f'.repeat(64)}`)).toBeNull();
86
+ });
87
+
88
+ it('takes the last Shared event when a share was removed and posted again in the same block', async () => {
89
+ const { client } = clientWith([
90
+ { block: 10n, acp: shares[0], id: ids[0], metadata: '0x03aa' },
91
+ { block: 10n, acp: shares[0], id: ids[0], metadata: '0x03cc' },
92
+ ]);
93
+ const {
94
+ posted: [posted],
95
+ } = await readPostedShares(client, REGISTRY, [{ shareId: ids[0], header: headers[0] }]);
96
+ expect(posted.metadata).toBe('0x03cc');
97
+ });
98
+
99
+ describe('one share never hides the rest', () => {
100
+ const all = () => shares.map((_, i) => ({ shareId: ids[i], header: headers[i] }));
101
+
102
+ it('a share whose block has no Shared event of it is unreadable, the others are posted', async () => {
103
+ // the node answered sharesFor from a newer block than the one it serves logs for
104
+ const { client } = clientWith(
105
+ shares.slice(0, 2).map((acp, i) => ({ block: headers[i].blockNumber, acp, id: ids[i], metadata: metadata[i] }))
106
+ );
107
+ const { posted, unreadable } = await readPostedShares(client, REGISTRY, all());
108
+ expect(posted.map((p) => p.shareId)).toEqual([ids[0], ids[1]]);
109
+ expect(unreadable).toEqual([
110
+ { shareId: ids[2], header: headers[2], reason: 'no Shared event in block 20, where the registry points' },
111
+ ]);
112
+ });
113
+
114
+ it('a share whose event payload does not hash to its id is unreadable, the others are posted', async () => {
115
+ const { client } = clientWith([
116
+ { block: 10n, acp: shares[1], id: ids[0], metadata: '0x' }, // the payload of share 2 under the id of share 1
117
+ { block: 10n, acp: shares[1], id: ids[1], metadata: '0x' },
118
+ { block: 20n, acp: shares[2], id: ids[2], metadata: '0x03bb' },
119
+ ]);
120
+ const { posted, unreadable } = await readPostedShares(client, REGISTRY, all());
121
+ expect(posted.map((p) => p.shareId)).toEqual([ids[1], ids[2]]);
122
+ expect(unreadable.map((u) => [u.shareId, u.reason])).toEqual([
123
+ [ids[0], 'the payload in its Shared event does not hash to its id'],
124
+ ]);
125
+ });
126
+
127
+ it('a block the node cannot serve makes its shares unreadable, with the error, and the others are posted', async () => {
128
+ const { client, getContractEvents } = clientWith();
129
+ getContractEvents.mockImplementationOnce(async () => {
130
+ throw new Error('block not found');
131
+ });
132
+ const { posted, unreadable } = await readPostedShares(client, REGISTRY, all());
133
+ expect(posted.length + unreadable.length).toBe(3);
134
+ expect(unreadable.length).toBeGreaterThan(0);
135
+ for (const u of unreadable)
136
+ expect(u.reason).toMatch(/Shared events of block \d+ could not be read: block not found/);
137
+ });
138
+
139
+ it('readShare throws, with the reason, for a listed share whose event cannot be read', async () => {
140
+ const { client } = clientWith([]);
141
+ await expect(readShare(client, REGISTRY, ids[0])).rejects.toThrow('no Shared event in block 10');
142
+ });
143
+ });
144
+ });
@@ -34,6 +34,14 @@ describe('seal / unsealWithPrivateKey', () => {
34
34
  expect(unsealWithPrivateKey(pair.privateKey.slice(2), sealed)).toBe(value);
35
35
  });
36
36
 
37
+ it('accepts uppercase hex keys', () => {
38
+ const pair = GenerateSealingKey();
39
+ const value = BigInt(7);
40
+
41
+ const sealed = seal(value, `0x${pair.publicKey.slice(2).toUpperCase()}`);
42
+ expect(unsealWithPrivateKey(`0x${pair.privateKey.slice(2).toUpperCase()}`, sealed)).toBe(value);
43
+ });
44
+
37
45
  it('should throw error for invalid public key in seal', () => {
38
46
  expect(() => {
39
47
  seal(BigInt(12345), 'invalid');
@@ -49,6 +57,21 @@ describe('seal / unsealWithPrivateKey', () => {
49
57
  }).toThrow('Private key must be of length 64');
50
58
  });
51
59
 
60
+ it('rejects a correct-length public key with non-hex characters instead of sealing to a zeroed key', () => {
61
+ expect(() => {
62
+ seal(BigInt(12345), 'z'.repeat(64));
63
+ }).toThrow('Public key must contain only hex characters');
64
+ });
65
+
66
+ it('rejects a correct-length private key with non-hex characters in unseal', () => {
67
+ const pair = GenerateSealingKey();
68
+ const sealed = seal(BigInt(1), pair.publicKey);
69
+
70
+ expect(() => {
71
+ unsealWithPrivateKey(`0x${'z'.repeat(64)}`, sealed);
72
+ }).toThrow('Private key must contain only hex characters');
73
+ });
74
+
52
75
  it('should throw error for invalid value in seal', () => {
53
76
  const publicKey = `0x${'b'.repeat(64)}`;
54
77