@cofhe/sdk 0.8.0 → 1.0.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/acps/acp.ts +25 -6
- package/acps/index.ts +20 -15
- package/acps/registry.ts +166 -8
- package/acps/sealing.ts +9 -3
- package/acps/share-metadata/appKey.ts +8 -0
- package/acps/share-metadata/decode.ts +120 -0
- package/acps/share-metadata/describe.ts +198 -0
- package/acps/share-metadata/encode.ts +147 -0
- package/acps/share-metadata/erc7984.ts +26 -0
- package/acps/share-metadata/index.ts +9 -0
- package/acps/share-metadata/layout.ts +69 -0
- package/acps/share-metadata/presets.ts +116 -0
- package/acps/share-metadata/schema.ts +47 -0
- package/acps/share-metadata/test/codec.test.ts +140 -0
- package/acps/share-metadata/test/describe.test.ts +112 -0
- package/acps/share-metadata/test/fixtures.ts +157 -0
- package/acps/share-metadata/test/live.test.ts +56 -0
- package/acps/share-metadata/test/presets.test.ts +119 -0
- package/acps/share-metadata/test/verify.test.ts +144 -0
- package/acps/share-metadata/verify.ts +155 -0
- package/acps/store.ts +48 -18
- package/acps/test/acp.test.ts +102 -7
- package/acps/test/localstorage.test.ts +28 -1
- package/acps/test/registry.test.ts +144 -0
- package/acps/test/sealing.test.ts +23 -0
- package/acps/test/store.test.ts +71 -0
- package/acps/test/utils.test.ts +63 -0
- package/acps/test/validation.test.ts +0 -4
- package/acps/types.ts +43 -6
- package/acps/utils.ts +15 -2
- package/acps/validation.ts +3 -1
- package/core/acps.ts +123 -74
- package/core/client.ts +33 -7
- package/core/clientTypes.ts +25 -7
- package/core/config.ts +8 -0
- package/core/decrypt/apiError.ts +4 -1
- package/core/decrypt/verifyDecryptResult.ts +4 -1
- package/core/encrypt/cofheMocksZkVerifySign.ts +19 -2
- package/core/test/acpDefaults.test.ts +108 -19
- package/core/test/acpStoreAccountCase.test.ts +80 -0
- package/core/test/acps.test.ts +38 -3
- package/core/test/apiError.test.ts +8 -0
- package/core/test/appKeyConfig.test.ts +94 -0
- package/core/test/config.test.ts +8 -0
- package/core/test/decryptBuilders.test.ts +110 -2
- package/core/test/incomingShares.test.ts +84 -0
- package/core/test/mocksEncryptRange.test.ts +90 -0
- package/core/test/utils.test.ts +128 -0
- package/core/test/verifyDecryptResult.test.ts +51 -0
- package/core/utils.ts +16 -9
- package/dist/acps.cjs +674 -33
- package/dist/acps.d.cts +367 -35
- package/dist/acps.d.ts +367 -35
- package/dist/acps.js +2 -2
- package/dist/chunk-6ZW76ZKB.js +228 -0
- package/dist/{chunk-UJYOBHYA.js → chunk-BQWHPIYU.js} +660 -198
- package/dist/{chunk-GGI7Y6AW.js → chunk-OL3DZN7A.js} +110 -269
- package/dist/chunk-XQ32JG3T.js +174 -0
- package/dist/{clientTypes-D3aIXmUp.d.cts → clientTypes-C0A9ji5O.d.cts} +61 -21
- package/dist/{clientTypes-5YPPR16m.d.ts → clientTypes-CcU5WvdV.d.ts} +61 -21
- package/dist/core.cjs +3239 -2731
- package/dist/core.d.cts +6 -3
- package/dist/core.d.ts +6 -3
- package/dist/core.js +4 -3
- package/dist/node.cjs +3046 -2538
- package/dist/node.d.cts +2 -2
- package/dist/node.d.ts +2 -2
- package/dist/node.js +18 -17
- package/dist/{onchain-utils-CUAK8glx.d.cts → onchain-utils-D9Z33IP7.d.cts} +136 -12
- package/dist/{onchain-utils-CUAK8glx.d.ts → onchain-utils-D9Z33IP7.d.ts} +136 -12
- package/dist/web.cjs +3074 -2573
- package/dist/web.d.cts +2 -2
- package/dist/web.d.ts +2 -2
- package/dist/web.js +4 -10
- package/dist/zkProve.worker.cjs +14 -7
- package/dist/zkProve.worker.js +2 -8
- package/node/index.ts +1 -10
- package/node/storage.ts +18 -7
- package/node/test/storage-permissions.test.ts +40 -0
- package/package.json +4 -4
- package/web/index.ts +1 -10
- package/web/test/threadPoolBenchHelper.ts +1 -7
- package/web/zkProve.worker.ts +1 -10
- 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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
101
|
-
state.acps[chainId][
|
|
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
|
-
|
|
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][
|
|
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
|
-
|
|
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])
|
|
177
|
+
if (state.activeACPHash[chainId])
|
|
178
|
+
state.activeACPHash[chainId][resolveAccountKey(state.activeACPHash[chainId], account)] = undefined;
|
|
149
179
|
})
|
|
150
180
|
);
|
|
151
181
|
};
|
package/acps/test/acp.test.ts
CHANGED
|
@@ -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(
|
|
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
|
|
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 {
|
|
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
|
});
|