@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
@@ -0,0 +1,144 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { concatHex, padHex, type Hex, type Log, type PublicClient } from 'viem';
3
+ import { verifyShareLabel, verifyShareLabels } from '../verify';
4
+ import type { ShareLabel } from '../schema';
5
+ import { BALANCE_OF, CONFIDENTIAL_TRANSFER, CONFIDENTIAL_TRANSFER_TOPIC0, FUSD, FUSDY, ISSUER } from './fixtures';
6
+
7
+ const AMOUNT = `0x${'11'.repeat(32)}` as Hex;
8
+ const BALANCE = `0x${'22'.repeat(32)}` as Hex;
9
+ const OTHER = `0x${'33'.repeat(32)}` as Hex;
10
+ const ALICE = padHex('0xa11ce', { size: 32 });
11
+
12
+ const transferLog = (overrides: Partial<Log> = {}): Log =>
13
+ ({
14
+ address: FUSD,
15
+ blockNumber: 100n,
16
+ transactionIndex: 3,
17
+ logIndex: 9,
18
+ topics: [CONFIDENTIAL_TRANSFER_TOPIC0, ALICE, ALICE, AMOUNT],
19
+ data: '0x',
20
+ ...overrides,
21
+ }) as Log;
22
+
23
+ const eventLabel = (overrides: Partial<ShareLabel & { kind: 'event' }> = {}): ShareLabel => ({
24
+ kind: 'event',
25
+ contract: FUSD,
26
+ block: 100n,
27
+ txIndex: 3,
28
+ logIndex: 9,
29
+ selector: CONFIDENTIAL_TRANSFER,
30
+ ...overrides,
31
+ });
32
+
33
+ const balanceLabel: ShareLabel = {
34
+ kind: 'stored',
35
+ contract: FUSDY,
36
+ selector: BALANCE_OF,
37
+ args: [{ type: 'issuer' }],
38
+ returnWord: 0,
39
+ block: 90n,
40
+ };
41
+
42
+ const clientWith = ({ logs = [transferLog()], callResult = BALANCE }: { logs?: Log[]; callResult?: Hex } = {}) => {
43
+ const getLogs = vi.fn(async () => logs);
44
+ const call = vi.fn(async () => ({ data: callResult }));
45
+ return { client: { getLogs, call } as unknown as PublicClient, getLogs, call };
46
+ };
47
+
48
+ describe('verifyShareLabel', () => {
49
+ it('verifies an event value at its log index', async () => {
50
+ const { client, getLogs } = clientWith();
51
+ expect(await verifyShareLabel(eventLabel(), AMOUNT, client, ISSUER)).toBe('verified');
52
+ expect(getLogs).toHaveBeenCalledWith({ address: FUSD, fromBlock: 100n, toBlock: 100n });
53
+ });
54
+
55
+ it('finds the ctHash among the data words too', async () => {
56
+ const { client } = clientWith({
57
+ logs: [transferLog({ topics: [CONFIDENTIAL_TRANSFER_TOPIC0], data: concatHex([OTHER, AMOUNT]) })],
58
+ });
59
+ expect(await verifyShareLabel(eventLabel(), AMOUNT, client, ISSUER)).toBe('verified');
60
+ });
61
+
62
+ it.each([
63
+ ['another log index', eventLabel({ logIndex: 8 })],
64
+ ['another transaction', eventLabel({ txIndex: 4 })],
65
+ ['another event selector', eventLabel({ selector: '0xddf252ad' })],
66
+ ])('rejects an event value naming %s', async (_, label) => {
67
+ const { client } = clientWith();
68
+ expect(await verifyShareLabel(label, AMOUNT, client, ISSUER)).toBe('mismatch');
69
+ });
70
+
71
+ it('rejects an event value whose log does not carry the ctHash', async () => {
72
+ const { client } = clientWith();
73
+ expect(await verifyShareLabel(eventLabel(), OTHER, client, ISSUER)).toBe('mismatch');
74
+ });
75
+
76
+ it('verifies a stored value by repeating the call with the issuer at the labelled block', async () => {
77
+ const { client, call } = clientWith();
78
+ expect(await verifyShareLabel(balanceLabel, BALANCE, client, ISSUER)).toBe('verified');
79
+ expect(call).toHaveBeenCalledWith({
80
+ to: FUSDY,
81
+ data: concatHex([BALANCE_OF, padHex(ISSUER, { size: 32 })]),
82
+ blockNumber: 90n,
83
+ });
84
+ });
85
+
86
+ it('rejects a stored value the call does not return', async () => {
87
+ const { client } = clientWith({ callResult: OTHER });
88
+ expect(await verifyShareLabel(balanceLabel, BALANCE, client, ISSUER)).toBe('mismatch');
89
+ });
90
+
91
+ it('reports a failed request, and an unlabelled value, as unverifiable', async () => {
92
+ const client = { call: vi.fn().mockRejectedValue(new Error('missing trie node')) } as unknown as PublicClient;
93
+ expect(await verifyShareLabel(balanceLabel, BALANCE, client, ISSUER)).toBe('unverifiable');
94
+ expect(await verifyShareLabel({ kind: 'unlabelled' }, BALANCE, client, ISSUER)).toBe('unverifiable');
95
+ });
96
+ });
97
+
98
+ describe('verifyShareLabels', () => {
99
+ const labels: ShareLabel[] = [balanceLabel, eventLabel(), eventLabel({ logIndex: 10 }), { kind: 'unlabelled' }];
100
+ const handles = [BALANCE, AMOUNT, OTHER, OTHER];
101
+ const logs = [
102
+ transferLog(),
103
+ transferLog({ logIndex: 10, topics: [CONFIDENTIAL_TRANSFER_TOPIC0, ALICE, ALICE, OTHER] }),
104
+ ];
105
+
106
+ it("checks everything with 'all', one getLogs per contract and block", async () => {
107
+ const { client, getLogs, call } = clientWith({ logs });
108
+ expect(await verifyShareLabels({ labels, handles, issuer: ISSUER, publicClient: client })).toEqual([
109
+ 'verified',
110
+ 'verified',
111
+ 'verified',
112
+ 'unverifiable',
113
+ ]);
114
+ expect(getLogs).toHaveBeenCalledTimes(1);
115
+ expect(call).toHaveBeenCalledTimes(1);
116
+ });
117
+
118
+ it("skips stored values with 'events'", async () => {
119
+ const { client, call } = clientWith({ logs });
120
+ expect(
121
+ await verifyShareLabels({ labels, handles, issuer: ISSUER, publicClient: client, verify: 'events' })
122
+ ).toEqual(['skipped', 'verified', 'verified', 'unverifiable']);
123
+ expect(call).not.toHaveBeenCalled();
124
+ });
125
+
126
+ it("makes no request with 'none'", async () => {
127
+ const { client, call, getLogs } = clientWith({ logs });
128
+ expect(await verifyShareLabels({ labels, handles, issuer: ISSUER, publicClient: client, verify: 'none' })).toEqual([
129
+ 'skipped',
130
+ 'skipped',
131
+ 'skipped',
132
+ 'skipped',
133
+ ]);
134
+ expect(call).not.toHaveBeenCalled();
135
+ expect(getLogs).not.toHaveBeenCalled();
136
+ });
137
+
138
+ it('refuses labels that do not match the handles one for one', async () => {
139
+ const { client } = clientWith();
140
+ await expect(
141
+ verifyShareLabels({ labels, handles: [BALANCE], issuer: ISSUER, publicClient: client })
142
+ ).rejects.toThrow('4 labels for 1 ctHashes');
143
+ });
144
+ });
@@ -0,0 +1,155 @@
1
+ import { concatHex, padHex, size, sliceHex, type Address, type Hex, type Log, type PublicClient } from 'viem';
2
+ import { BYTE_WIDTH } from './layout';
3
+ import type { EventValueLabel, ShareLabel, StoredValueLabel } from './schema.js';
4
+
5
+ /**
6
+ * - `verified`: the chain confirms the label.
7
+ * - `mismatch`: the chain contradicts it.
8
+ * - `unverifiable`: the chain could not be asked — an unlabelled ctHash, a node without the
9
+ * state of that block (a stored value needs an archive node once its block is old), or a
10
+ * failed request.
11
+ */
12
+ export type LabelVerification = 'verified' | 'mismatch' | 'unverifiable';
13
+
14
+ /**
15
+ * How much of a share's labels a reader checks against the chain. Labels are the issuer's claim:
16
+ * - `all` (default): every label — one `getLogs` per contract and block, one call per stored value;
17
+ * - `events`: event values only, which any node can answer however old the block;
18
+ * - `none`: trust the labels as written, no requests (the issuer previewing their own share).
19
+ */
20
+ export type ShareLabelVerifyMode = 'all' | 'events' | 'none';
21
+
22
+ /** A label's verification, or `skipped` when the verify mode leaves it unchecked. */
23
+ export type ShareLabelCheck = LabelVerification | 'skipped';
24
+
25
+ const sameWord = (a: Hex, b: Hex): boolean => a.toLowerCase() === b.toLowerCase();
26
+
27
+ /** The 32-byte words of a byte string, in order. A trailing partial word is left out. */
28
+ const wordsOf = (data: Hex): Hex[] => {
29
+ const count = Math.floor(size(data) / BYTE_WIDTH.word);
30
+ return Array.from({ length: count }, (_, index) =>
31
+ sliceHex(data, index * BYTE_WIDTH.word, (index + 1) * BYTE_WIDTH.word)
32
+ );
33
+ };
34
+
35
+ /** The view call a stored-value label names, with the issuer argument filled in. */
36
+ const storedCallData = (label: StoredValueLabel, issuer: Address): Hex =>
37
+ concatHex([
38
+ label.selector,
39
+ ...label.args.map((arg) => (arg.type === 'issuer' ? padHex(issuer, { size: BYTE_WIDTH.word }) : arg.value)),
40
+ ]);
41
+
42
+ /** Whether the ctHash is the labelled return word of the call's result. */
43
+ const checkStored = (label: StoredValueLabel, handle: Hex, data: Hex | undefined): LabelVerification => {
44
+ const found = data ? wordsOf(data)[label.returnWord] : undefined;
45
+ return found && sameWord(found, handle) ? 'verified' : 'mismatch';
46
+ };
47
+
48
+ /**
49
+ * Whether the labelled log is there and carries the ctHash: among the logs of the contract in the
50
+ * labelled block, the one at the labelled log index must belong to the labelled transaction, its
51
+ * topic0 must start with the labelled selector, and the ctHash must be one of its topics or data
52
+ * words.
53
+ */
54
+ const checkEvent = (label: EventValueLabel, handle: Hex, logs: readonly Log[]): LabelVerification => {
55
+ const log = logs.find((l) => l.logIndex === label.logIndex);
56
+ if (log == null) return 'mismatch';
57
+ if (log.transactionIndex !== label.txIndex) return 'mismatch';
58
+ const topic0 = log.topics[0];
59
+ if (topic0 == null || !topic0.toLowerCase().startsWith(label.selector.toLowerCase())) return 'mismatch';
60
+ const carried = [...log.topics.slice(1), ...wordsOf(log.data)].some((word) => word != null && sameWord(word, handle));
61
+ return carried ? 'verified' : 'mismatch';
62
+ };
63
+
64
+ const callAt = async (publicClient: PublicClient, to: Address, data: Hex, blockNumber: bigint) =>
65
+ (await publicClient.call({ to, data, blockNumber })).data;
66
+
67
+ const logsAt = (publicClient: PublicClient, address: Address, block: bigint) =>
68
+ publicClient.getLogs({ address, fromBlock: block, toBlock: block });
69
+
70
+ /**
71
+ * Checks one label against the chain: does the ctHash really come from where the label says?
72
+ * `issuer` is the share issuer (`acp.issuer`), which an issuer argument of a stored value stands
73
+ * for. One request.
74
+ */
75
+ export const verifyShareLabel = async (
76
+ label: ShareLabel,
77
+ handle: Hex,
78
+ publicClient: PublicClient,
79
+ issuer: Address
80
+ ): Promise<LabelVerification> => {
81
+ try {
82
+ switch (label.kind) {
83
+ case 'stored':
84
+ return checkStored(
85
+ label,
86
+ handle,
87
+ await callAt(publicClient, label.contract, storedCallData(label, issuer), label.block)
88
+ );
89
+ case 'event':
90
+ return checkEvent(label, handle, await logsAt(publicClient, label.contract, label.block));
91
+ case 'unlabelled':
92
+ return 'unverifiable';
93
+ }
94
+ } catch {
95
+ return 'unverifiable';
96
+ }
97
+ };
98
+
99
+ /**
100
+ * Checks the labels of a share, index for index with `handles`, as far as `verify` says. Requests
101
+ * are shared: one `getLogs` per contract and block, however many values it carries, and one call
102
+ * per distinct stored read.
103
+ */
104
+ export const verifyShareLabels = async ({
105
+ labels,
106
+ handles,
107
+ issuer,
108
+ publicClient,
109
+ verify = 'all',
110
+ }: {
111
+ labels: readonly ShareLabel[];
112
+ handles: readonly Hex[];
113
+ issuer: Address;
114
+ publicClient: PublicClient;
115
+ verify?: ShareLabelVerifyMode;
116
+ }): Promise<ShareLabelCheck[]> => {
117
+ if (labels.length !== handles.length) {
118
+ throw new Error(`share metadata: ${labels.length} labels for ${handles.length} ctHashes`);
119
+ }
120
+
121
+ const requests = new Map<string, Promise<unknown>>();
122
+ const once = <T>(key: string, request: () => Promise<T>): Promise<T> => {
123
+ if (!requests.has(key)) requests.set(key, request());
124
+ return requests.get(key) as Promise<T>;
125
+ };
126
+
127
+ return Promise.all(
128
+ labels.map(async (label, i): Promise<ShareLabelCheck> => {
129
+ const handle = handles[i];
130
+ try {
131
+ switch (label.kind) {
132
+ case 'stored': {
133
+ if (verify !== 'all') return 'skipped';
134
+ const data = storedCallData(label, issuer);
135
+ const key = `call:${label.contract.toLowerCase()}:${label.block}:${data}`;
136
+ return checkStored(
137
+ label,
138
+ handle,
139
+ await once(key, () => callAt(publicClient, label.contract, data, label.block))
140
+ );
141
+ }
142
+ case 'event': {
143
+ if (verify === 'none') return 'skipped';
144
+ const key = `logs:${label.contract.toLowerCase()}:${label.block}`;
145
+ return checkEvent(label, handle, await once(key, () => logsAt(publicClient, label.contract, label.block)));
146
+ }
147
+ case 'unlabelled':
148
+ return verify === 'none' ? 'skipped' : 'unverifiable';
149
+ }
150
+ } catch {
151
+ return 'unverifiable';
152
+ }
153
+ })
154
+ );
155
+ };
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
  };
@@ -0,0 +1,65 @@
1
+ import { ContractFunctionRevertedError, HttpRequestError, parseAbi } from 'viem';
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
3
+
4
+ import { clearTaskManagerAddressCache } from '../../core/taskManager.js';
5
+ import { clearAclServedAddresses, getAclServedAddresses } from '../onchain-utils.js';
6
+
7
+ const CHAIN_ID = 421614;
8
+ const TASK_MANAGER = '0x0000000000000000000000000000000000005000';
9
+ const ACL = '0x00000000000000000000000000000000000000dd';
10
+ const REVOKER = '0x00000000000000000000000000000000000000cc';
11
+ const REGISTRY = '0x00000000000000000000000000000000000000ee';
12
+
13
+ const GETTERS_ABI = parseAbi([
14
+ 'function defaultRevokerContract() view returns (address)',
15
+ 'function shareRegistry() view returns (address)',
16
+ ]);
17
+
18
+ function clientWithGetters(getter: (functionName: string) => Promise<unknown>) {
19
+ return {
20
+ readContract: vi.fn(async ({ functionName }: { functionName: string }) => {
21
+ if (functionName === 'getTm') return TASK_MANAGER;
22
+ if (functionName === 'acl') return ACL;
23
+ return getter(functionName);
24
+ }),
25
+ } as any;
26
+ }
27
+
28
+ describe('getAclServedAddresses', () => {
29
+ beforeEach(() => {
30
+ clearAclServedAddresses();
31
+ clearTaskManagerAddressCache();
32
+ });
33
+
34
+ it('does not cache a transient failure of the ACL getters', async () => {
35
+ let down = true;
36
+ const publicClient = clientWithGetters(async (functionName) => {
37
+ if (down) throw new HttpRequestError({ url: 'http://rpc', status: 429 });
38
+ return functionName === 'defaultRevokerContract' ? REVOKER : REGISTRY;
39
+ });
40
+
41
+ expect(await getAclServedAddresses(publicClient, CHAIN_ID)).toEqual({
42
+ defaultRevoker: undefined,
43
+ shareRegistry: undefined,
44
+ });
45
+
46
+ down = false;
47
+ expect(await getAclServedAddresses(publicClient, CHAIN_ID)).toEqual({
48
+ defaultRevoker: REVOKER,
49
+ shareRegistry: REGISTRY,
50
+ });
51
+ });
52
+
53
+ it('caches a pre-upgrade ACL whose getters revert', async () => {
54
+ const publicClient = clientWithGetters(async (functionName) => {
55
+ throw new ContractFunctionRevertedError({ abi: GETTERS_ABI, functionName, data: '0x' });
56
+ });
57
+
58
+ const expected = { defaultRevoker: undefined, shareRegistry: undefined };
59
+ expect(await getAclServedAddresses(publicClient, CHAIN_ID)).toEqual(expected);
60
+ const calls = publicClient.readContract.mock.calls.length;
61
+
62
+ expect(await getAclServedAddresses(publicClient, CHAIN_ID)).toEqual(expected);
63
+ expect(publicClient.readContract.mock.calls.length).toBe(calls);
64
+ });
65
+ });