@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
@@ -1,5 +1,14 @@
1
1
  import { type EncryptableItem, FheTypes } from '../types.js';
2
- import { type VerifyBatchResult } from './zkPackProveVerify.js';
2
+ import {
3
+ type VerifyBatchResult,
4
+ MAX_UINT8,
5
+ MAX_UINT16,
6
+ MAX_UINT32,
7
+ MAX_UINT64,
8
+ MAX_UINT128,
9
+ MAX_UINT160,
10
+ } from './zkPackProveVerify.js';
11
+ import { toBigIntOrThrow, validateBigIntInRange } from '../utils.js';
3
12
  import { createWalletClient, http, encodePacked, keccak256, type PublicClient, type WalletClient } from 'viem';
4
13
  import { MockZkVerifierAbi } from './MockZkVerifierAbi.js';
5
14
  import { hardhat } from 'viem/chains';
@@ -19,7 +28,9 @@ function createMockZkVerifierSigner() {
19
28
  });
20
29
  }
21
30
  /**
22
- * The mocks don't use a tfhe builder, so we check the encryptable bits here to preserve parity
31
+ * The mocks don't use a tfhe builder, so we check the encryptable bits and value ranges here to
32
+ * preserve parity with `zkPack`. Without the range check, MockCoFHE masks an out-of-range value
33
+ * with the utype mask (e.g. uint8(256) -> 0) instead of rejecting it like the production path.
23
34
  */
24
35
  export async function cofheMocksCheckEncryptableBits(items: EncryptableItem[]): Promise<void> {
25
36
  let totalBits = 0;
@@ -30,22 +41,27 @@ export async function cofheMocksCheckEncryptableBits(items: EncryptableItem[]):
30
41
  break;
31
42
  }
32
43
  case FheTypes.Uint8: {
44
+ validateBigIntInRange(toBigIntOrThrow(item.data), MAX_UINT8);
33
45
  totalBits += 8;
34
46
  break;
35
47
  }
36
48
  case FheTypes.Uint16: {
49
+ validateBigIntInRange(toBigIntOrThrow(item.data), MAX_UINT16);
37
50
  totalBits += 16;
38
51
  break;
39
52
  }
40
53
  case FheTypes.Uint32: {
54
+ validateBigIntInRange(toBigIntOrThrow(item.data), MAX_UINT32);
41
55
  totalBits += 32;
42
56
  break;
43
57
  }
44
58
  case FheTypes.Uint64: {
59
+ validateBigIntInRange(toBigIntOrThrow(item.data), MAX_UINT64);
45
60
  totalBits += 64;
46
61
  break;
47
62
  }
48
63
  case FheTypes.Uint128: {
64
+ validateBigIntInRange(toBigIntOrThrow(item.data), MAX_UINT128);
49
65
  totalBits += 128;
50
66
  break;
51
67
  }
@@ -55,6 +71,7 @@ export async function cofheMocksCheckEncryptableBits(items: EncryptableItem[]):
55
71
  // break;
56
72
  // }
57
73
  case FheTypes.Uint160: {
74
+ validateBigIntInRange(toBigIntOrThrow(item.data), MAX_UINT160);
58
75
  totalBits += 160;
59
76
  break;
60
77
  }
@@ -1,5 +1,6 @@
1
- import { describe, it, expect } from 'vitest';
1
+ import { beforeEach, describe, it, expect, vi } from 'vitest';
2
2
  import { acps } from '../acps.js';
3
+ import { clearTaskManagerAddressCache } from '../taskManager.js';
3
4
 
4
5
  type ScopeOpts = {
5
6
  revokerData?: number;
@@ -12,7 +13,10 @@ const opts = (o: ScopeOpts): ScopeOpts => o;
12
13
 
13
14
  const CHAIN = 31337;
14
15
  const REVOKER = '0x00000000000000000000000000000000000000aa' as `0x${string}`;
16
+ const ACL_REVOKER = '0x00000000000000000000000000000000000000cc' as `0x${string}`;
15
17
  const CONTRACT_A = '0x00000000000000000000000000000000000000bb' as `0x${string}`;
18
+ const ACL = '0x00000000000000000000000000000000000000dd' as `0x${string}`;
19
+ const TM = '0x0000000000000000000000000000000000005000' as `0x${string}`;
16
20
 
17
21
  const acpConfig = {
18
22
  defaultRevoker: { [CHAIN]: REVOKER },
@@ -20,33 +24,118 @@ const acpConfig = {
20
24
  };
21
25
 
22
26
  describe('applyACPDefaults', () => {
23
- it('injects default revoker (contract + creation timestamp) when no revoker options given', () => {
24
- const before = Math.round(Date.now() / 1000);
25
- const result = acps.applyACPDefaults(opts({}), acpConfig, CHAIN);
27
+ beforeEach(() => {
28
+ acps.clearAclServedAddresses();
29
+ clearTaskManagerAddressCache();
30
+ });
31
+
32
+ it('uses the exact chain block timestamp for an ACL-served revoker', async () => {
33
+ const latestBlockTimestamp = 1_700_000_000;
34
+ const publicClient = {
35
+ readContract: vi.fn(({ functionName }: { functionName: string }) => {
36
+ if (functionName === 'getTm') return Promise.resolve(TM);
37
+ if (functionName === 'acl') return Promise.resolve(ACL);
38
+ if (functionName === 'defaultRevokerContract') return Promise.resolve(ACL_REVOKER);
39
+ return Promise.reject(new Error(`unexpected ACL read: ${functionName}`));
40
+ }),
41
+ getBlock: vi.fn().mockResolvedValue({ timestamp: BigInt(latestBlockTimestamp) }),
42
+ } as any;
43
+
44
+ const result = await acps.applyACPDefaultsFromChain(opts({}), undefined, publicClient, CHAIN);
45
+
46
+ expect(publicClient.getBlock).toHaveBeenCalledWith();
47
+ expect(result.revokerContract).toBe(ACL_REVOKER);
48
+ expect(result.revokerData).toBe(latestBlockTimestamp);
49
+ });
50
+
51
+ it('prefers configured revoker over ACL but still uses the latest block timestamp', async () => {
52
+ const latestBlockTimestamp = 1_700_000_001;
53
+ const publicClient = {
54
+ readContract: vi.fn(({ functionName }: { functionName: string }) => {
55
+ if (functionName === 'getTm') return Promise.resolve(TM);
56
+ if (functionName === 'acl') return Promise.resolve(ACL);
57
+ if (functionName === 'defaultRevokerContract') return Promise.resolve(ACL_REVOKER);
58
+ return Promise.reject(new Error(`unexpected ACL read: ${functionName}`));
59
+ }),
60
+ getBlock: vi.fn().mockResolvedValue({ timestamp: BigInt(latestBlockTimestamp) }),
61
+ } as any;
62
+
63
+ const result = await acps.applyACPDefaultsFromChain(opts({}), acpConfig, publicClient, CHAIN);
64
+
65
+ expect(publicClient.readContract).not.toHaveBeenCalled();
66
+ expect(publicClient.getBlock).toHaveBeenCalledWith();
26
67
  expect(result.revokerContract).toBe(REVOKER);
27
- expect(result.revokerData).toBeGreaterThanOrEqual(before - 60); // 60s clock-skew backdating
28
- expect(result.revokerData).toBeLessThanOrEqual(Math.round(Date.now() / 1000) - 59);
68
+ expect(result.revokerData).toBe(latestBlockTimestamp);
69
+ });
70
+
71
+ it('avoids getBlock for explicit revoker options', async () => {
72
+ const publicClient = {
73
+ readContract: vi.fn(),
74
+ getBlock: vi.fn(),
75
+ } as any;
76
+
77
+ const result = await acps.applyACPDefaultsFromChain(
78
+ opts({ revokerData: 42, revokerContract: CONTRACT_A }),
79
+ acpConfig,
80
+ publicClient,
81
+ CHAIN
82
+ );
83
+
84
+ expect(publicClient.readContract).not.toHaveBeenCalled();
85
+ expect(publicClient.getBlock).not.toHaveBeenCalled();
86
+ expect(result).toMatchObject({ revokerData: 42, revokerContract: CONTRACT_A });
29
87
  });
30
88
 
31
- it('does not override an explicit revoker pair', () => {
32
- const result = acps.applyACPDefaults({ revokerData: 42, revokerContract: CONTRACT_A }, acpConfig, CHAIN);
33
- expect(result.revokerData).toBe(42);
34
- expect(result.revokerContract).toBe(CONTRACT_A);
89
+ it('avoids getBlock when neither config nor ACL provide a revoker', async () => {
90
+ const publicClient = {
91
+ readContract: vi.fn().mockRejectedValue(new Error('no ACL deployment')),
92
+ getBlock: vi.fn(),
93
+ } as any;
94
+
95
+ const result = await acps.applyACPDefaultsFromChain(opts({}), undefined, publicClient, CHAIN);
96
+
97
+ expect(publicClient.getBlock).not.toHaveBeenCalled();
98
+ expect(result).toEqual({});
35
99
  });
36
100
 
37
- it('injects default contract scopes when no scope options given', () => {
38
- const result = acps.applyACPDefaults(opts({}), acpConfig, CHAIN);
101
+ it('rejects a block timestamp outside JavaScript safe integer range', async () => {
102
+ const publicClient = {
103
+ readContract: vi.fn(({ functionName }: { functionName: string }) => {
104
+ if (functionName === 'getTm') return Promise.resolve(TM);
105
+ if (functionName === 'acl') return Promise.resolve(ACL);
106
+ if (functionName === 'defaultRevokerContract') return Promise.resolve(ACL_REVOKER);
107
+ return Promise.reject(new Error(`unexpected ACL read: ${functionName}`));
108
+ }),
109
+ getBlock: vi.fn().mockResolvedValue({ timestamp: BigInt(Number.MAX_SAFE_INTEGER) + 1n }),
110
+ } as any;
111
+
112
+ await expect(acps.applyACPDefaultsFromChain(opts({}), undefined, publicClient, CHAIN)).rejects.toThrow(
113
+ /ACP revokerData cannot represent the chain block timestamp .* as a safe integer/
114
+ );
115
+ });
116
+
117
+ it('injects default contract scopes when no scope options given', async () => {
118
+ const result = await acps.applyACPDefaultsFromChain(opts({}), acpConfig, noAclClient(), CHAIN);
39
119
  expect(result.contracts).toEqual([CONTRACT_A]);
40
120
  });
41
121
 
42
- it('does not inject scopes when the caller provides any scope option (incl. explicit scope)', () => {
43
- expect(acps.applyACPDefaults(opts({ scope: 0 }), acpConfig, CHAIN).contracts).toBeUndefined();
44
- expect(acps.applyACPDefaults(opts({ handles: [1n] }), acpConfig, CHAIN).contracts).toBeUndefined();
45
- expect(acps.applyACPDefaults(opts({ contracts: [] }), acpConfig, CHAIN).contracts).toEqual([]);
122
+ it('does not inject scopes when the caller provides any scope option (incl. explicit scope)', async () => {
123
+ const apply = (o: ScopeOpts) => acps.applyACPDefaultsFromChain(o, acpConfig, noAclClient(), CHAIN);
124
+ expect((await apply(opts({ scope: 0 }))).contracts).toBeUndefined();
125
+ expect((await apply(opts({ handles: [1n] }))).contracts).toBeUndefined();
126
+ expect((await apply(opts({ contracts: [] }))).contracts).toEqual([]);
46
127
  });
47
128
 
48
- it('no-op for chains without defaults and for missing config', () => {
49
- expect(acps.applyACPDefaults(opts({}), acpConfig, 999)).toEqual({});
50
- expect(acps.applyACPDefaults(opts({}), undefined, CHAIN)).toEqual({});
129
+ it('no-op for chains without defaults and for missing config', async () => {
130
+ expect(await acps.applyACPDefaultsFromChain(opts({}), acpConfig, noAclClient(), 999)).toEqual({});
131
+ expect(await acps.applyACPDefaultsFromChain(opts({}), undefined, noAclClient(), CHAIN)).toEqual({});
51
132
  });
52
133
  });
134
+
135
+ /** A chain with no ACL deployment: the ACL lookup serves no revoker. */
136
+ function noAclClient() {
137
+ return {
138
+ readContract: vi.fn().mockRejectedValue(new Error('no ACL deployment')),
139
+ getBlock: vi.fn().mockResolvedValue({ timestamp: 1_700_000_000n }),
140
+ } as any;
141
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * @vitest-environment happy-dom
3
+ */
4
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
5
+
6
+ // Stub the on-chain EIP-712 domain read so the test needs no RPC (same approach as acps.test.ts).
7
+ vi.mock('../../acps/onchain-utils.js', async (importOriginal) => ({
8
+ ...(await importOriginal<typeof import('../../acps/onchain-utils.js')>()),
9
+ getAclEIP712Domain: async () => ({
10
+ name: 'ACL',
11
+ version: '2',
12
+ chainId: 421614,
13
+ verifyingContract: '0x1111111111111111111111111111111111111111' as `0x${string}`,
14
+ }),
15
+ }));
16
+
17
+ import { custom, createWalletClient, type PublicClient, type WalletClient } from 'viem';
18
+ import { arbitrumSepolia } from 'viem/chains';
19
+ import { privateKeyToAccount } from 'viem/accounts';
20
+ import { acpStore } from '@/acps';
21
+ import { acps } from '../acps.js';
22
+
23
+ const chainId = 421614;
24
+ // Offline clients: the wallet signs typed data locally, and any RPC call fails the test.
25
+ const noRpc = custom({
26
+ request: async ({ method }) => {
27
+ throw new Error(`unexpected RPC call: ${method}`);
28
+ },
29
+ });
30
+ const publicClient = { getChainId: async () => chainId } as unknown as PublicClient;
31
+ const walletClient: WalletClient = createWalletClient({
32
+ chain: arbitrumSepolia,
33
+ transport: noRpc,
34
+ account: privateKeyToAccount('0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80'),
35
+ });
36
+ const checksummed = walletClient.account!.address; // 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
37
+ const lowercase = checksummed.toLowerCase();
38
+
39
+ describe('ACP store account keys are case-insensitive', () => {
40
+ beforeEach(() => {
41
+ acpStore.store.setState({ acps: {}, activeACPHash: {} });
42
+ });
43
+
44
+ it('finds the active ACP when the account is passed in lowercase', async () => {
45
+ const created = await acps.createSelf({ issuer: checksummed }, publicClient, walletClient);
46
+
47
+ expect(acps.getActiveACP(chainId, checksummed)?.hash).toBe(created.hash);
48
+ expect(acps.getActiveACP(chainId, lowercase)?.hash).toBe(created.hash);
49
+ expect(acps.getACP(chainId, lowercase, created.hash)?.hash).toBe(created.hash);
50
+ expect(Object.keys(acps.getACPs(chainId, lowercase))).toEqual([created.hash]);
51
+ });
52
+
53
+ it('getOrCreateSelfACP with a lowercase account reuses the stored ACP instead of signing a new one', async () => {
54
+ const signSpy = vi.spyOn(walletClient, 'signTypedData');
55
+
56
+ const first = await acps.getOrCreateSelfACP(publicClient, walletClient, chainId, lowercase);
57
+ const second = await acps.getOrCreateSelfACP(publicClient, walletClient, chainId, lowercase);
58
+
59
+ expect(second.hash).toBe(first.hash);
60
+ expect(signSpy).toHaveBeenCalledTimes(1);
61
+ expect(Object.keys(acps.getACPs(chainId, checksummed))).toHaveLength(1);
62
+ });
63
+
64
+ it('keeps one bucket per account whatever case it is written and read in', async () => {
65
+ const created = await acps.createSelf({ issuer: checksummed }, publicClient, walletClient);
66
+ acpStore.store.setState({ acps: {}, activeACPHash: {} });
67
+
68
+ // Stored under the lowercase spelling (e.g. a wallet client built from a lowercase address)...
69
+ acpStore.setACP(chainId, lowercase, created);
70
+ acpStore.setActiveACPHash(chainId, lowercase, created.hash);
71
+ // ...still found with the checksummed one, and a write in that case reuses the same bucket.
72
+ expect(acpStore.getActiveACP(chainId, checksummed)?.hash).toBe(created.hash);
73
+ acpStore.setACP(chainId, checksummed, created);
74
+ expect(Object.keys(acpStore.store.getState().acps[chainId])).toEqual([lowercase]);
75
+
76
+ acpStore.removeACP(chainId, checksummed, created.hash);
77
+ expect(acpStore.getActiveACPHash(chainId, lowercase)).toBeUndefined();
78
+ expect(acpStore.getACP(chainId, lowercase, created.hash)).toBeUndefined();
79
+ });
80
+ });
@@ -143,7 +143,8 @@ describe('Core ACPs Tests', () => {
143
143
  // Import the acp as Alice (recipient)
144
144
  const acp = await acps.importShared(acpJson, publicClient, aliceWalletClient);
145
145
 
146
- expect(acp.name).toBe('Original Sharing ACP');
146
+ // the issuer's name is not taken over: the acp is named after its issuer
147
+ expect(acp.name).toBe(`Shared by ${bobAddress.slice(0, 6)}…${bobAddress.slice(-4)}`);
147
148
  expect(acp.type).toBe('recipient');
148
149
  expect(acp.issuer).toBe(bobAddress);
149
150
  expect(acp.recipient).toBe(aliceAddress);
@@ -190,7 +191,7 @@ describe('Core ACPs Tests', () => {
190
191
 
191
192
  const toIncomingShare = (sharing: SharingACP): IncomingShare => {
192
193
  const { sealingKey: _sealingKey, recipientSignature: _recipientSignature, ...pub } = ACPUtils.getPublic(sharing);
193
- return { ...pub, shareId: ZERO_SHARE_ID };
194
+ return { ...pub, shareId: ZERO_SHARE_ID, metadata: '0x' };
194
195
  };
195
196
 
196
197
  it('importShared activates the imported acp by default', async () => {
@@ -231,6 +232,40 @@ describe('Core ACPs Tests', () => {
231
232
  expect(acps.getActiveACPHash(chainId, aliceAddress)).toBe(imported.hash);
232
233
  });
233
234
 
235
+ it('importFromChain keeps the share metadata on the stored recipient acp', async () => {
236
+ const { sharing } = await setUp();
237
+ const imported = await acps.importFromChain(
238
+ { ...toIncomingShare(sharing), metadata: '0x03aa' },
239
+ publicClient,
240
+ aliceWalletClient
241
+ );
242
+
243
+ expect(imported.metadata).toBe('0x03aa');
244
+ expect(acps.getACPs(chainId, aliceAddress)?.[imported.hash]?.metadata).toBe('0x03aa');
245
+ });
246
+
247
+ it('importShared and importFromChain take the recipient name as an option', async () => {
248
+ const { sharing } = await setUp();
249
+ const fromJson = await acps.importShared(ACPUtils.export(sharing), publicClient, aliceWalletClient, {
250
+ name: 'From Bob (JSON)',
251
+ });
252
+ const fromChain = await acps.importFromChain(toIncomingShare(sharing), publicClient, aliceWalletClient, {
253
+ name: 'From Bob (chain)',
254
+ });
255
+
256
+ expect(fromJson.name).toBe('From Bob (JSON)');
257
+ expect(fromChain.name).toBe('From Bob (chain)');
258
+ // the same share, so the second import replaced the first in the store
259
+ expect(acps.getACPs(chainId, aliceAddress)?.[fromChain.hash]?.name).toBe('From Bob (chain)');
260
+ });
261
+
262
+ it('importFromChain of a share without metadata leaves none on the acp', async () => {
263
+ const { sharing } = await setUp();
264
+ const imported = await acps.importFromChain(toIncomingShare(sharing), publicClient, aliceWalletClient);
265
+
266
+ expect(imported.metadata).toBeUndefined();
267
+ });
268
+
234
269
  it('importFromChain with activate: false stores the acp and keeps the active one', async () => {
235
270
  const { self, sharing } = await setUp();
236
271
  const imported = await acps.importFromChain(toIncomingShare(sharing), publicClient, aliceWalletClient, {
@@ -618,7 +653,7 @@ describe('Core ACPs Tests', () => {
618
653
  const exported = acps.export(acp);
619
654
  const parsed = JSON.parse(exported);
620
655
 
621
- expect(parsed.name).toBe('Test Sharing ACP');
656
+ expect(parsed).not.toHaveProperty('name');
622
657
  expect(parsed.type).toBe('sharing');
623
658
  expect(parsed.issuer).toBe(bobAddress);
624
659
  expect(parsed.recipient).toBe(aliceAddress);
@@ -47,6 +47,14 @@ describe('mapApiErrorCodeToCofheErrorCode', () => {
47
47
  CofheErrorCode.SealOutputFailed
48
48
  );
49
49
  });
50
+
51
+ it('falls back for a code that only exists on Object.prototype', () => {
52
+ for (const inherited of ['toString', 'constructor', 'valueOf', 'hasOwnProperty']) {
53
+ expect(mapApiErrorCodeToCofheErrorCode(inherited, CofheErrorCode.DecryptFailed)).toBe(
54
+ CofheErrorCode.DecryptFailed
55
+ );
56
+ }
57
+ });
50
58
  });
51
59
 
52
60
  describe('parseApiErrorResponseBody', () => {
@@ -0,0 +1,94 @@
1
+ import { describe, it, expect, vi, afterEach } from 'vitest';
2
+ import { type PublicClient, type WalletClient } from 'viem';
3
+ import { createCofheClientBase } from '../client';
4
+ import { type CofheClient } from '../clientTypes';
5
+ import { createCofheConfigBase, type CofheInputConfig } from '../config';
6
+ import { acps } from '../acps';
7
+ import { shareAppKey } from '../../acps/share-metadata';
8
+
9
+ vi.mock('../keyStore', () => ({
10
+ createKeysStore: vi.fn(() => ({
11
+ rehydrateKeysStore: vi.fn().mockResolvedValue(undefined),
12
+ getFheKey: vi.fn(),
13
+ getCrs: vi.fn(),
14
+ setFheKey: vi.fn(),
15
+ setCrs: vi.fn(),
16
+ clearKeysStorage: vi.fn(),
17
+ store: {} as any,
18
+ })),
19
+ }));
20
+
21
+ const CHAIN_ID = 11155111;
22
+ const ACCOUNT = '0x1234567890123456789012345678901234567890';
23
+ const REGISTRY = '0x00000000000000000000000000000000000000e0';
24
+ const APP = 'explorer.fhenix.io';
25
+ const APP_KEY = shareAppKey(APP);
26
+
27
+ const publicClient = { getChainId: vi.fn().mockResolvedValue(CHAIN_ID) } as unknown as PublicClient;
28
+ const walletClient = {
29
+ getAddresses: vi.fn().mockResolvedValue([ACCOUNT]),
30
+ account: { address: ACCOUNT },
31
+ } as unknown as WalletClient;
32
+
33
+ /** A connected client; `acp.sharingRegistry` is set so the on-chain calls need no ACL lookup. */
34
+ const connectedClient = async (acp: CofheInputConfig['acp']): Promise<CofheClient> => {
35
+ const client = createCofheClientBase({
36
+ config: createCofheConfigBase({ supportedChains: [], acp: { sharingRegistry: { [CHAIN_ID]: REGISTRY }, ...acp } }),
37
+ zkBuilderAndCrsGenerator: {} as any,
38
+ tfhePublicKeyDeserializer: {} as any,
39
+ compactPkeCrsDeserializer: {} as any,
40
+ initTfhe: () => Promise.resolve(false),
41
+ });
42
+ await client.connect(publicClient, walletClient);
43
+ return client;
44
+ };
45
+
46
+ const sharingOptions = { issuer: ACCOUNT, recipient: '0x00000000000000000000000000000000000000a1' };
47
+
48
+ describe('the app key from config.acp.app', () => {
49
+ const spies = {
50
+ defaults: vi.spyOn(acps, 'applyACPDefaultsFromChain').mockImplementation(async (options) => options),
51
+ createSharing: vi.spyOn(acps, 'createSharing').mockResolvedValue({} as any),
52
+ getOrCreate: vi.spyOn(acps, 'getOrCreateSharingACP').mockResolvedValue({} as any),
53
+ shareOnChain: vi.spyOn(acps, 'shareOnChain').mockResolvedValue({ txHash: '0x', shareId: '0x' }),
54
+ getIncoming: vi.spyOn(acps, 'getIncomingShares').mockResolvedValue([]),
55
+ };
56
+ afterEach(() => Object.values(spies).forEach((spy) => spy.mockClear()));
57
+
58
+ it('goes on every share the client creates', async () => {
59
+ const client = await connectedClient({ app: APP });
60
+ await client.acp.createSharing(sharingOptions);
61
+ expect(spies.createSharing.mock.calls[0][0]).toMatchObject({ ...sharingOptions, appKey: APP_KEY });
62
+
63
+ await client.acp.getOrCreateSharingACP(sharingOptions);
64
+ expect(spies.getOrCreate.mock.calls[0][2]).toMatchObject({ ...sharingOptions, appKey: APP_KEY });
65
+ });
66
+
67
+ it('goes with labels posted by shareOnChain', async () => {
68
+ const client = await connectedClient({ app: APP });
69
+ await client.acp.shareOnChain({} as any, { labels: [] });
70
+ expect(spies.shareOnChain.mock.calls[0][3]).toEqual({ labels: [], appKey: APP_KEY });
71
+ });
72
+
73
+ it('filters the inbox', async () => {
74
+ const client = await connectedClient({ app: APP });
75
+ await client.acp.getIncomingShares();
76
+ expect(spies.getIncoming).toHaveBeenCalledWith(publicClient, REGISTRY, ACCOUNT, { appKey: APP_KEY });
77
+ });
78
+
79
+ it('is the keccak256 of the app name, and an explicit appKey wins over it', async () => {
80
+ const client = await connectedClient({ app: APP });
81
+ const other = shareAppKey('other.app');
82
+ await client.acp.createSharing({ ...sharingOptions, appKey: other });
83
+ expect(spies.createSharing.mock.calls[0][0]).toMatchObject({ appKey: other });
84
+ expect(APP_KEY).toMatch(/^0x[0-9a-f]{64}$/);
85
+ });
86
+
87
+ it('is absent without an app: shares carry no key and every share is listed', async () => {
88
+ const client = await connectedClient({});
89
+ await client.acp.createSharing(sharingOptions);
90
+ expect(spies.createSharing.mock.calls[0][0]).not.toHaveProperty('appKey');
91
+ await client.acp.getIncomingShares();
92
+ expect(spies.getIncoming).toHaveBeenCalledWith(publicClient, REGISTRY, ACCOUNT, {});
93
+ });
94
+ });
@@ -62,6 +62,14 @@ describe('createCofheConfigBase', () => {
62
62
  expectValidConfigItem('environment', 'react', 'react');
63
63
  });
64
64
 
65
+ it('acp.app', () => {
66
+ expectInvalidConfigItem('acp.app', '');
67
+ expectInvalidConfigItem('acp.app', 123);
68
+
69
+ expectValidConfigItem('acp.app', 'explorer.fhenix.io', 'explorer.fhenix.io');
70
+ expectValidConfigItem('acp.app', undefined, undefined);
71
+ });
72
+
65
73
  it('supportedChains', () => {
66
74
  expectInvalidConfigItem('supportedChains', {});
67
75
  expectInvalidConfigItem('supportedChains', 'not-an-array');
@@ -1,13 +1,26 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { type PublicClient, type WalletClient, createPublicClient, createWalletClient, http } from 'viem';
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import { type PublicClient, type WalletClient, createPublicClient, createWalletClient, http, zeroAddress } from 'viem';
3
3
  import { privateKeyToAccount } from 'viem/accounts';
4
4
  import { arbitrumSepolia } from 'viem/chains';
5
+ import { ACPUtils, acpStore, type ACP } from '@/acps';
5
6
  import { DecryptForTxBuilder } from '../decrypt/decryptForTxBuilder.js';
6
7
  import { DecryptForViewBuilder } from '../decrypt/decryptForViewBuilder.js';
8
+ import { tnDecryptV2 } from '../decrypt/tnDecryptV2';
9
+ import { tnSealOutputV2 } from '../decrypt/tnSealOutputV2';
7
10
  import { createCofheConfigBase, type CofheConfig } from '../config.js';
8
11
  import { CofheErrorCode } from '../error.js';
9
12
  import { FheTypes } from '../types.js';
10
13
 
14
+ // Stub the threshold network calls so tests can assert whether a request was dispatched
15
+ vi.mock('../decrypt/tnDecryptV2', async (importOriginal) => ({
16
+ ...(await importOriginal<typeof import('../decrypt/tnDecryptV2')>()),
17
+ tnDecryptV2: vi.fn(),
18
+ }));
19
+ vi.mock('../decrypt/tnSealOutputV2', async (importOriginal) => ({
20
+ ...(await importOriginal<typeof import('../decrypt/tnSealOutputV2')>()),
21
+ tnSealOutputV2: vi.fn(),
22
+ }));
23
+
11
24
  const TEST_PRIVATE_KEY = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80';
12
25
  const account = privateKeyToAccount(TEST_PRIVATE_KEY);
13
26
  const TEST_CHAIN_ID = 421614;
@@ -416,3 +429,98 @@ describe('DecryptForViewBuilder', () => {
416
429
  });
417
430
  });
418
431
  });
432
+
433
+ // ---------------------------------------------------------------------------
434
+ // Expired acp (regression, issue #272)
435
+ //
436
+ // The getOrCreate* half of #272 is covered in acps.test.ts. These cover the decrypt path:
437
+ // execute() must reject an expired acp (ACPUtils.validate) before any request is dispatched
438
+ // to the threshold network, however the acp was selected.
439
+ // ---------------------------------------------------------------------------
440
+
441
+ describe('execute – expired acp (regression, issue #272)', () => {
442
+ type Selection = 'active acp' | 'acp hash' | 'acp object';
443
+ const selections: Selection[] = ['active acp', 'acp hash', 'acp object'];
444
+
445
+ const now = () => Math.floor(Date.now() / 1000);
446
+
447
+ // Built by hand rather than with ACPUtils.sign, which reads the ACL's EIP-712 domain over RPC.
448
+ // validate() only checks that a signature is present, and the threshold network is stubbed.
449
+ function createSignedSelfACP(expiration: number): ACP {
450
+ return {
451
+ ...ACPUtils.createSelf({ name: 'Test Self ACP', issuer: account.address, expiration }),
452
+ issuerSignature: `0x${'11'.repeat(65)}`,
453
+ _signedDomain: { name: 'ACL', version: '1', chainId: TEST_CHAIN_ID, verifyingContract: zeroAddress },
454
+ };
455
+ }
456
+
457
+ // Stores the acp and makes it the active acp for the test chain + account
458
+ function seedACP(acp: ACP) {
459
+ acpStore.setACP(TEST_CHAIN_ID, account.address, acp);
460
+ acpStore.setActiveACPHash(TEST_CHAIN_ID, account.address, acp.hash);
461
+ }
462
+
463
+ function selectTxACP(selection: Selection, acp: ACP) {
464
+ const builder = createTxBuilder();
465
+ if (selection === 'acp hash') return builder.withACP(acp.hash);
466
+ if (selection === 'acp object') return builder.withACP(acp);
467
+ return builder.withACP();
468
+ }
469
+
470
+ function selectViewACP(selection: Selection, acp: ACP) {
471
+ const builder = createViewBuilder(FheTypes.Uint32);
472
+ if (selection === 'acp hash') return builder.withACP(acp.hash);
473
+ if (selection === 'acp object') return builder.withACP(acp);
474
+ return builder.withACP();
475
+ }
476
+
477
+ beforeEach(() => {
478
+ acpStore.store.setState({ acps: {}, activeACPHash: {} });
479
+ vi.mocked(tnDecryptV2).mockReset();
480
+ vi.mocked(tnSealOutputV2).mockReset();
481
+ });
482
+
483
+ afterEach(() => {
484
+ acpStore.store.setState({ acps: {}, activeACPHash: {} });
485
+ });
486
+
487
+ describe('decryptForTx', () => {
488
+ it.each(selections)('rejects an expired acp selected via %s without dispatching', async (selection) => {
489
+ const acp = createSignedSelfACP(now() - 3600);
490
+ seedACP(acp);
491
+
492
+ await expect(selectTxACP(selection, acp).execute()).rejects.toThrow('ACP is expired');
493
+ expect(tnDecryptV2).not.toHaveBeenCalled();
494
+ });
495
+
496
+ it('dispatches a valid acp (control for the expired cases)', async () => {
497
+ vi.mocked(tnDecryptV2).mockResolvedValue({ decryptedValue: 7n, signature: '0x1234' });
498
+ const acp = createSignedSelfACP(now() + 3600);
499
+ seedACP(acp);
500
+
501
+ const result = await selectTxACP('active acp', acp).execute();
502
+
503
+ expect(result.decryptedValue).toBe(7n);
504
+ expect(tnDecryptV2).toHaveBeenCalledOnce();
505
+ });
506
+ });
507
+
508
+ describe('decryptForView', () => {
509
+ it.each(selections)('rejects an expired acp selected via %s without dispatching', async (selection) => {
510
+ const acp = createSignedSelfACP(now() - 3600);
511
+ seedACP(acp);
512
+
513
+ await expect(selectViewACP(selection, acp).execute()).rejects.toThrow('ACP is expired');
514
+ expect(tnSealOutputV2).not.toHaveBeenCalled();
515
+ });
516
+
517
+ it('dispatches a valid acp (control for the expired cases)', async () => {
518
+ vi.mocked(tnSealOutputV2).mockRejectedValue(new Error('stubbed threshold network'));
519
+ const acp = createSignedSelfACP(now() + 3600);
520
+ seedACP(acp);
521
+
522
+ await expect(selectViewACP('active acp', acp).execute()).rejects.toThrow('stubbed threshold network');
523
+ expect(tnSealOutputV2).toHaveBeenCalledOnce();
524
+ });
525
+ });
526
+ });