@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.
Files changed (84) hide show
  1. package/acps/acp.ts +25 -6
  2. package/acps/index.ts +20 -15
  3. package/acps/registry.ts +166 -8
  4. package/acps/sealing.ts +9 -3
  5. package/acps/share-metadata/appKey.ts +8 -0
  6. package/acps/share-metadata/decode.ts +120 -0
  7. package/acps/share-metadata/describe.ts +198 -0
  8. package/acps/share-metadata/encode.ts +147 -0
  9. package/acps/share-metadata/erc7984.ts +26 -0
  10. package/acps/share-metadata/index.ts +9 -0
  11. package/acps/share-metadata/layout.ts +69 -0
  12. package/acps/share-metadata/presets.ts +116 -0
  13. package/acps/share-metadata/schema.ts +47 -0
  14. package/acps/share-metadata/test/codec.test.ts +140 -0
  15. package/acps/share-metadata/test/describe.test.ts +112 -0
  16. package/acps/share-metadata/test/fixtures.ts +157 -0
  17. package/acps/share-metadata/test/live.test.ts +56 -0
  18. package/acps/share-metadata/test/presets.test.ts +119 -0
  19. package/acps/share-metadata/test/verify.test.ts +144 -0
  20. package/acps/share-metadata/verify.ts +155 -0
  21. package/acps/store.ts +48 -18
  22. package/acps/test/acp.test.ts +102 -7
  23. package/acps/test/localstorage.test.ts +28 -1
  24. package/acps/test/registry.test.ts +144 -0
  25. package/acps/test/sealing.test.ts +23 -0
  26. package/acps/test/store.test.ts +71 -0
  27. package/acps/test/utils.test.ts +63 -0
  28. package/acps/test/validation.test.ts +0 -4
  29. package/acps/types.ts +43 -6
  30. package/acps/utils.ts +15 -2
  31. package/acps/validation.ts +3 -1
  32. package/core/acps.ts +123 -74
  33. package/core/client.ts +33 -7
  34. package/core/clientTypes.ts +25 -7
  35. package/core/config.ts +8 -0
  36. package/core/decrypt/apiError.ts +4 -1
  37. package/core/decrypt/verifyDecryptResult.ts +4 -1
  38. package/core/encrypt/cofheMocksZkVerifySign.ts +19 -2
  39. package/core/test/acpDefaults.test.ts +108 -19
  40. package/core/test/acpStoreAccountCase.test.ts +80 -0
  41. package/core/test/acps.test.ts +38 -3
  42. package/core/test/apiError.test.ts +8 -0
  43. package/core/test/appKeyConfig.test.ts +94 -0
  44. package/core/test/config.test.ts +8 -0
  45. package/core/test/decryptBuilders.test.ts +110 -2
  46. package/core/test/incomingShares.test.ts +84 -0
  47. package/core/test/mocksEncryptRange.test.ts +90 -0
  48. package/core/test/utils.test.ts +128 -0
  49. package/core/test/verifyDecryptResult.test.ts +51 -0
  50. package/core/utils.ts +16 -9
  51. package/dist/acps.cjs +674 -33
  52. package/dist/acps.d.cts +367 -35
  53. package/dist/acps.d.ts +367 -35
  54. package/dist/acps.js +2 -2
  55. package/dist/chunk-6ZW76ZKB.js +228 -0
  56. package/dist/{chunk-UJYOBHYA.js → chunk-BQWHPIYU.js} +660 -198
  57. package/dist/{chunk-GGI7Y6AW.js → chunk-OL3DZN7A.js} +110 -269
  58. package/dist/chunk-XQ32JG3T.js +174 -0
  59. package/dist/{clientTypes-D3aIXmUp.d.cts → clientTypes-C0A9ji5O.d.cts} +61 -21
  60. package/dist/{clientTypes-5YPPR16m.d.ts → clientTypes-CcU5WvdV.d.ts} +61 -21
  61. package/dist/core.cjs +3239 -2731
  62. package/dist/core.d.cts +6 -3
  63. package/dist/core.d.ts +6 -3
  64. package/dist/core.js +4 -3
  65. package/dist/node.cjs +3046 -2538
  66. package/dist/node.d.cts +2 -2
  67. package/dist/node.d.ts +2 -2
  68. package/dist/node.js +18 -17
  69. package/dist/{onchain-utils-CUAK8glx.d.cts → onchain-utils-D9Z33IP7.d.cts} +136 -12
  70. package/dist/{onchain-utils-CUAK8glx.d.ts → onchain-utils-D9Z33IP7.d.ts} +136 -12
  71. package/dist/web.cjs +3074 -2573
  72. package/dist/web.d.cts +2 -2
  73. package/dist/web.d.ts +2 -2
  74. package/dist/web.js +4 -10
  75. package/dist/zkProve.worker.cjs +14 -7
  76. package/dist/zkProve.worker.js +2 -8
  77. package/node/index.ts +1 -10
  78. package/node/storage.ts +18 -7
  79. package/node/test/storage-permissions.test.ts +40 -0
  80. package/package.json +4 -4
  81. package/web/index.ts +1 -10
  82. package/web/test/threadPoolBenchHelper.ts +1 -7
  83. package/web/zkProve.worker.ts +1 -10
  84. package/dist/chunk-NMKMCAI3.js +0 -14
@@ -0,0 +1,144 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import type { PublicClient } from 'viem';
3
+ import {
4
+ readPostedShares,
5
+ readShare,
6
+ readSharesFor,
7
+ shareIdOfChainShare,
8
+ type ChainShare,
9
+ type ShareHeader,
10
+ } from '../registry';
11
+
12
+ const REGISTRY = '0x00000000000000000000000000000000000000e0';
13
+ const ISSUER = '0x00000000000000000000000000000000000000b0';
14
+ const RECIPIENT = '0x00000000000000000000000000000000000000a1';
15
+ const ZERO = '0x0000000000000000000000000000000000000000';
16
+
17
+ const chainShare = (handle: number): ChainShare => ({
18
+ issuer: ISSUER,
19
+ expiration: 2_000_000_000n,
20
+ recipient: RECIPIENT,
21
+ revokerData: 0n,
22
+ revokerContract: ZERO,
23
+ scope: 2,
24
+ contracts: [],
25
+ handles: [`0x${handle.toString(16).padStart(64, '0')}`],
26
+ sealingKey: `0x${'0'.repeat(64)}`,
27
+ issuerSignature: '0x11',
28
+ recipientSignature: '0x',
29
+ });
30
+
31
+ const headerAt = (blockNumber: bigint): ShareHeader => ({
32
+ issuer: ISSUER,
33
+ expiration: 2_000_000_000n,
34
+ recipient: RECIPIENT,
35
+ blockNumber,
36
+ revokerContract: ZERO,
37
+ revokerData: 0n,
38
+ });
39
+
40
+ /** Three shares: two posted in block 10, one in block 20. */
41
+ const shares = [chainShare(1), chainShare(2), chainShare(3)];
42
+ const ids = shares.map(shareIdOfChainShare);
43
+ const headers = [headerAt(10n), headerAt(10n), headerAt(20n)];
44
+ const metadata: `0x${string}`[] = ['0x03aa', '0x', '0x03bb'];
45
+
46
+ const clientWith = (
47
+ events = shares.map((acp, i) => ({ block: headers[i].blockNumber, acp, id: ids[i], metadata: metadata[i] }))
48
+ ) => {
49
+ const getContractEvents = vi.fn(async ({ fromBlock, args }: { fromBlock: bigint; args: { shareId: string[] } }) =>
50
+ events
51
+ .filter((e) => e.block === fromBlock && args.shareId.includes(e.id))
52
+ .map((e) => ({ args: { shareId: e.id, acp: e.acp, metadata: e.metadata } }))
53
+ );
54
+ const readContract = vi.fn(async ({ functionName, args }: { functionName: string; args: [string] }) => {
55
+ if (functionName === 'sharesFor') return [ids, headers];
56
+ const i = ids.indexOf(args[0] as `0x${string}`);
57
+ return i === -1 ? { ...headerAt(0n), issuer: ZERO } : headers[i];
58
+ });
59
+ return { client: { getContractEvents, readContract } as unknown as PublicClient, getContractEvents };
60
+ };
61
+
62
+ describe('registry reads', () => {
63
+ it('reads the inbox with one getLogs per distinct block, in sharesFor order', async () => {
64
+ const { client, getContractEvents } = clientWith();
65
+ const { posted, unreadable } = await readSharesFor(client, REGISTRY, RECIPIENT);
66
+
67
+ expect(unreadable).toEqual([]);
68
+ expect(posted.map((p) => p.shareId)).toEqual(ids);
69
+ expect(posted.map((p) => p.share)).toEqual(shares);
70
+ expect(posted.map((p) => p.metadata)).toEqual([...metadata]);
71
+ expect(getContractEvents).toHaveBeenCalledTimes(2);
72
+ expect(getContractEvents).toHaveBeenCalledWith(
73
+ expect.objectContaining({
74
+ eventName: 'Shared',
75
+ fromBlock: 10n,
76
+ toBlock: 10n,
77
+ args: { shareId: [ids[0], ids[1]] },
78
+ })
79
+ );
80
+ });
81
+
82
+ it('reads one share, and null for an unknown one', async () => {
83
+ const { client } = clientWith();
84
+ expect((await readShare(client, REGISTRY, ids[2]))?.metadata).toBe('0x03bb');
85
+ expect(await readShare(client, REGISTRY, `0x${'f'.repeat(64)}`)).toBeNull();
86
+ });
87
+
88
+ it('takes the last Shared event when a share was removed and posted again in the same block', async () => {
89
+ const { client } = clientWith([
90
+ { block: 10n, acp: shares[0], id: ids[0], metadata: '0x03aa' },
91
+ { block: 10n, acp: shares[0], id: ids[0], metadata: '0x03cc' },
92
+ ]);
93
+ const {
94
+ posted: [posted],
95
+ } = await readPostedShares(client, REGISTRY, [{ shareId: ids[0], header: headers[0] }]);
96
+ expect(posted.metadata).toBe('0x03cc');
97
+ });
98
+
99
+ describe('one share never hides the rest', () => {
100
+ const all = () => shares.map((_, i) => ({ shareId: ids[i], header: headers[i] }));
101
+
102
+ it('a share whose block has no Shared event of it is unreadable, the others are posted', async () => {
103
+ // the node answered sharesFor from a newer block than the one it serves logs for
104
+ const { client } = clientWith(
105
+ shares.slice(0, 2).map((acp, i) => ({ block: headers[i].blockNumber, acp, id: ids[i], metadata: metadata[i] }))
106
+ );
107
+ const { posted, unreadable } = await readPostedShares(client, REGISTRY, all());
108
+ expect(posted.map((p) => p.shareId)).toEqual([ids[0], ids[1]]);
109
+ expect(unreadable).toEqual([
110
+ { shareId: ids[2], header: headers[2], reason: 'no Shared event in block 20, where the registry points' },
111
+ ]);
112
+ });
113
+
114
+ it('a share whose event payload does not hash to its id is unreadable, the others are posted', async () => {
115
+ const { client } = clientWith([
116
+ { block: 10n, acp: shares[1], id: ids[0], metadata: '0x' }, // the payload of share 2 under the id of share 1
117
+ { block: 10n, acp: shares[1], id: ids[1], metadata: '0x' },
118
+ { block: 20n, acp: shares[2], id: ids[2], metadata: '0x03bb' },
119
+ ]);
120
+ const { posted, unreadable } = await readPostedShares(client, REGISTRY, all());
121
+ expect(posted.map((p) => p.shareId)).toEqual([ids[1], ids[2]]);
122
+ expect(unreadable.map((u) => [u.shareId, u.reason])).toEqual([
123
+ [ids[0], 'the payload in its Shared event does not hash to its id'],
124
+ ]);
125
+ });
126
+
127
+ it('a block the node cannot serve makes its shares unreadable, with the error, and the others are posted', async () => {
128
+ const { client, getContractEvents } = clientWith();
129
+ getContractEvents.mockImplementationOnce(async () => {
130
+ throw new Error('block not found');
131
+ });
132
+ const { posted, unreadable } = await readPostedShares(client, REGISTRY, all());
133
+ expect(posted.length + unreadable.length).toBe(3);
134
+ expect(unreadable.length).toBeGreaterThan(0);
135
+ for (const u of unreadable)
136
+ expect(u.reason).toMatch(/Shared events of block \d+ could not be read: block not found/);
137
+ });
138
+
139
+ it('readShare throws, with the reason, for a listed share whose event cannot be read', async () => {
140
+ const { client } = clientWith([]);
141
+ await expect(readShare(client, REGISTRY, ids[0])).rejects.toThrow('no Shared event in block 10');
142
+ });
143
+ });
144
+ });
@@ -34,6 +34,14 @@ describe('seal / unsealWithPrivateKey', () => {
34
34
  expect(unsealWithPrivateKey(pair.privateKey.slice(2), sealed)).toBe(value);
35
35
  });
36
36
 
37
+ it('accepts uppercase hex keys', () => {
38
+ const pair = GenerateSealingKey();
39
+ const value = BigInt(7);
40
+
41
+ const sealed = seal(value, `0x${pair.publicKey.slice(2).toUpperCase()}`);
42
+ expect(unsealWithPrivateKey(`0x${pair.privateKey.slice(2).toUpperCase()}`, sealed)).toBe(value);
43
+ });
44
+
37
45
  it('should throw error for invalid public key in seal', () => {
38
46
  expect(() => {
39
47
  seal(BigInt(12345), 'invalid');
@@ -49,6 +57,21 @@ describe('seal / unsealWithPrivateKey', () => {
49
57
  }).toThrow('Private key must be of length 64');
50
58
  });
51
59
 
60
+ it('rejects a correct-length public key with non-hex characters instead of sealing to a zeroed key', () => {
61
+ expect(() => {
62
+ seal(BigInt(12345), 'z'.repeat(64));
63
+ }).toThrow('Public key must contain only hex characters');
64
+ });
65
+
66
+ it('rejects a correct-length private key with non-hex characters in unseal', () => {
67
+ const pair = GenerateSealingKey();
68
+ const sealed = seal(BigInt(1), pair.publicKey);
69
+
70
+ expect(() => {
71
+ unsealWithPrivateKey(`0x${'z'.repeat(64)}`, sealed);
72
+ }).toThrow('Private key must contain only hex characters');
73
+ });
74
+
52
75
  it('should throw error for invalid value in seal', () => {
53
76
  const publicKey = `0x${'b'.repeat(64)}`;
54
77
 
@@ -12,6 +12,8 @@ import {
12
12
  removeACP,
13
13
  getActiveACPHash,
14
14
  setActiveACPHash,
15
+ clearStaleStore,
16
+ ACP_STORE_DEFAULTS,
15
17
  ACPUtils,
16
18
  } from '../index.js';
17
19
 
@@ -85,4 +87,73 @@ describe('Storage Tests', () => {
85
87
  expect(activeHash).toBeUndefined();
86
88
  });
87
89
  });
90
+
91
+ describe('Stale Store Detection', () => {
92
+ // `typeof null` and `typeof []` are both 'object', so these shapes used to pass the
93
+ // structure guard and stay in the store; a `null` record then throws on `acps[chainId]`.
94
+ const seedRawState = (state: unknown) => {
95
+ acpStore.store.setState(state as never, true);
96
+ };
97
+
98
+ it('should clear the store when acps is null', () => {
99
+ seedRawState({ acps: null, activeACPHash: {} });
100
+
101
+ clearStaleStore();
102
+
103
+ expect(acpStore.store.getState()).toEqual(ACP_STORE_DEFAULTS);
104
+ expect(() => getACP(chainId, account, '0xdeadbeef')).not.toThrow();
105
+ expect(getACP(chainId, account, '0xdeadbeef')).toBeUndefined();
106
+ });
107
+
108
+ it('should clear the store when acps is an array', () => {
109
+ seedRawState({ acps: [], activeACPHash: {} });
110
+
111
+ clearStaleStore();
112
+
113
+ expect(acpStore.store.getState()).toEqual(ACP_STORE_DEFAULTS);
114
+ expect(() => getACP(chainId, account, '0xdeadbeef')).not.toThrow();
115
+ expect(getACP(chainId, account, '0xdeadbeef')).toBeUndefined();
116
+ });
117
+
118
+ it('should clear the store when activeACPHash is null', () => {
119
+ seedRawState({ acps: {}, activeACPHash: null });
120
+
121
+ clearStaleStore();
122
+
123
+ expect(acpStore.store.getState()).toEqual(ACP_STORE_DEFAULTS);
124
+ expect(() => getActiveACP(chainId, account)).not.toThrow();
125
+ expect(getActiveACP(chainId, account)).toBeUndefined();
126
+ });
127
+
128
+ it('should clear the store when activeACPHash is an array', () => {
129
+ seedRawState({ acps: {}, activeACPHash: [] });
130
+
131
+ clearStaleStore();
132
+
133
+ expect(acpStore.store.getState()).toEqual(ACP_STORE_DEFAULTS);
134
+ });
135
+
136
+ it('should let setACP recover a store whose acps is null', async () => {
137
+ const acp = await createMockACP();
138
+ seedRawState({ acps: null, activeACPHash: {} });
139
+
140
+ expect(() => setACP(chainId, account, acp)).not.toThrow();
141
+ expect(ACPUtils.serialize(getACP(chainId, account, acp.hash)!)).toEqual(ACPUtils.serialize(acp));
142
+ });
143
+
144
+ it('should leave a valid store untouched', async () => {
145
+ const acp = await createMockACP();
146
+ setACP(chainId, account, acp);
147
+ setActiveACPHash(chainId, account, acp.hash);
148
+
149
+ const before = acpStore.store.getState();
150
+
151
+ clearStaleStore();
152
+
153
+ // Same reference: a valid store must not be rewritten at all.
154
+ expect(acpStore.store.getState()).toBe(before);
155
+ expect(ACPUtils.serialize(getACP(chainId, account, acp.hash)!)).toEqual(ACPUtils.serialize(acp));
156
+ expect(getActiveACPHash(chainId, account)).toBe(acp.hash);
157
+ });
158
+ });
88
159
  });
@@ -0,0 +1,63 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { fromHexString, toBeArray, toHexString } from '../utils.js';
3
+
4
+ describe('fromHexString', () => {
5
+ it('strips the 0x prefix before padding an odd-length string', () => {
6
+ // Regression: padding first produced `00x123`, which decoded to [0x00, NaN->0, 0x23].
7
+ expect(Array.from(fromHexString('0x123'))).toEqual([0x01, 0x23]);
8
+ });
9
+
10
+ it('pads odd-length strings without a prefix', () => {
11
+ expect(Array.from(fromHexString('123'))).toEqual([0x01, 0x23]);
12
+ });
13
+
14
+ it('decodes even-length strings with and without the 0x prefix identically', () => {
15
+ expect(Array.from(fromHexString('0x1234'))).toEqual([0x12, 0x34]);
16
+ expect(Array.from(fromHexString('1234'))).toEqual([0x12, 0x34]);
17
+ });
18
+
19
+ it('decodes uppercase hex', () => {
20
+ expect(Array.from(fromHexString('0xDEADBEEF'))).toEqual([0xde, 0xad, 0xbe, 0xef]);
21
+ });
22
+
23
+ it('returns an empty array for empty input', () => {
24
+ expect(fromHexString('')).toEqual(new Uint8Array());
25
+ expect(fromHexString('0x')).toEqual(new Uint8Array());
26
+ });
27
+
28
+ it('round-trips a 32-byte sealing key through toHexString', () => {
29
+ const privateKey = 'a3'.repeat(32);
30
+
31
+ const bytes = fromHexString(privateKey);
32
+
33
+ expect(bytes.length).toBe(32);
34
+ expect(toHexString(bytes)).toBe(privateKey);
35
+ });
36
+
37
+ it('throws instead of silently producing zero bytes for non-hex characters', () => {
38
+ expect(() => fromHexString('zzzz')).toThrow('Invalid hex string');
39
+ });
40
+
41
+ it('throws for hex strings with a single invalid character', () => {
42
+ expect(() => fromHexString('0x12g4')).toThrow('Invalid hex string');
43
+ });
44
+
45
+ it('only strips a lowercase 0x prefix, matching the sealing key and ACP schema checks', () => {
46
+ expect(() => fromHexString('0X12')).toThrow('Invalid hex string');
47
+ });
48
+
49
+ it('does not leak the value in the error message', () => {
50
+ const secret = 'f'.repeat(63) + 'z';
51
+
52
+ expect(() => fromHexString(secret)).toThrow(
53
+ expect.objectContaining({ message: expect.not.stringContaining(secret) })
54
+ );
55
+ });
56
+ });
57
+
58
+ describe('toBeArray', () => {
59
+ it('still encodes values that need an odd-length hex representation', () => {
60
+ expect(Array.from(toBeArray(0x123))).toEqual([0x01, 0x23]);
61
+ expect(Array.from(toBeArray(255n))).toEqual([0xff]);
62
+ });
63
+ });
@@ -91,7 +91,6 @@ describe('Validation Tests', () => {
91
91
  expiration: Math.floor(Date.now() / 1000) + 3600, // 1 hour from now
92
92
  recipient: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8', // Alice's address
93
93
  issuerSignature: '0x1234567890abcdef',
94
- name: 'Import ACP',
95
94
  };
96
95
 
97
96
  expect(() => validateImportACPOptions(options)).not.toThrow();
@@ -102,7 +101,6 @@ describe('Validation Tests', () => {
102
101
  issuer: '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266', // Bob's address
103
102
  recipient: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8', // Alice's address
104
103
  issuerSignature: '0x1234567890abcdef',
105
- name: 'Import ACP',
106
104
  };
107
105
  expect(() => validateImportACPOptions(options)).toThrow();
108
106
  });
@@ -113,7 +111,6 @@ describe('Validation Tests', () => {
113
111
  expiration: Math.floor(Date.now() / 1000) + 3600, // 1 hour from now
114
112
  recipient: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8', // Alice's address
115
113
  issuerSignature: '0x',
116
- name: 'Import ACP',
117
114
  };
118
115
 
119
116
  expect(() => validateImportACPOptions(options)).toThrow();
@@ -125,7 +122,6 @@ describe('Validation Tests', () => {
125
122
  expiration: Math.floor(Date.now() / 1000) + 3600, // 1 hour from now
126
123
  recipient: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8', // Alice's address
127
124
  issuerSignature: '0x',
128
- name: 'Import ACP',
129
125
  };
130
126
 
131
127
  expect(() => validateImportACPOptions(options)).toThrow();
package/acps/types.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { type EthEncryptedData } from './sealing.js';
2
2
  import { type Hex } from 'viem';
3
+ import type { ShareLabel } from './share-metadata/schema.js';
3
4
 
4
5
  /**
5
6
  * EIP712 related types
@@ -46,6 +47,12 @@ export interface ACPPrivate {
46
47
  * Never leaves the client.
47
48
  */
48
49
  sealingPrivateKey: Hex;
50
+ /**
51
+ * (sharing / recipient) The share metadata: the labels of its handles, a v3 blob
52
+ * (`decodeShareMetadata` / `describeShareMetadata`). Not signed; travels with the share, in the
53
+ * exported JSON or in the registry's `Shared` event. Absent when the share has none.
54
+ */
55
+ metadata?: Hex;
49
56
  /**
50
57
  * EIP712 domain used to sign this acp.
51
58
  * Should not be set manually, included in metadata as part of serialization flows.
@@ -191,17 +198,30 @@ export type CreateSharingACPOptions = ACPScopeOptions & {
191
198
  expiration?: number;
192
199
  revokerData?: number;
193
200
  revokerContract?: string;
201
+ /**
202
+ * The labels of the handles, one per handle in order. Kept on the acp as `metadata`, then
203
+ * written into `export()` and posted by `shareOnChain`.
204
+ */
205
+ labels?: readonly ShareLabel[];
206
+ /**
207
+ * The key of the app the share is for (`shareAppKey(app)`), kept in `metadata` with the labels.
208
+ * A client configured with `acp: { app }` fills it in; a recipient client with the same `app`
209
+ * lists the share, one with another app does not.
210
+ */
211
+ appKey?: Hex;
194
212
  };
195
213
 
214
+ /** A share as exported (`SharedACP`) or read from the registry. A `name` in it is ignored. */
196
215
  export type ImportSharedACPOptions = ACPScopeOptions & {
197
216
  type?: 'sharing';
198
217
  issuer: string;
199
218
  recipient: string;
200
219
  issuerSignature: string;
201
- name?: string;
202
220
  expiration: number;
203
221
  revokerData?: number;
204
222
  revokerContract?: string;
223
+ /** The share metadata (labels blob), kept on the recipient acp; `0x` or absent for none. */
224
+ metadata?: string;
205
225
  };
206
226
 
207
227
  /** An ACP is plain JSON-serializable data — the serialized form is the ACP itself. */
@@ -210,14 +230,16 @@ export type SerializedACP = ACP;
210
230
  /**
211
231
  * The share payload produced by `ACPUtils.export()` — the full public component
212
232
  * with `sealingKey`/`recipientSignature` left for the recipient to fill, plus
213
- * the display name and acp type. Fixed shape: every field is always present
233
+ * the acp type. The issuer's `name` is not exported: it is their own note, and the
234
+ * recipient names the share themselves. Fixed shape: every field is always present
214
235
  * (zero-values instead of omissions), so importers can parse a single schema.
215
- * Mirrors the on-chain sharing payload struct field-for-field.
236
+ * Mirrors the on-chain sharing payload struct field-for-field. The one optional
237
+ * field is `metadata`, the labels of the handles, present only when the share has them.
216
238
  */
217
239
  export type SharedACP = Expand<
218
240
  Omit<ACPPublic, 'sealingKey' | 'recipientSignature'> & {
219
- name: string;
220
241
  type: 'sharing';
242
+ metadata?: Hex;
221
243
  }
222
244
  >;
223
245
 
@@ -239,9 +261,24 @@ export type ACPAccessStatus =
239
261
 
240
262
  /**
241
263
  * A share read back from the on-chain ACPShareRegistry: the posted payload
242
- * (SharedACP minus the client-side name/type) plus its registry id.
264
+ * (SharedACP minus its type), its registry id, and the
265
+ * metadata blob posted with it (`0x` when none) — for a SNAPSHOT share, the
266
+ * labels of its handles (`decodeShareMetadata` / `describeShareMetadata`).
267
+ */
268
+ export type IncomingShare = Expand<
269
+ Omit<SharedACP, 'type' | 'metadata'> & {
270
+ shareId: Hex;
271
+ metadata: Hex;
272
+ /** The key of the app the share was posted for (`shareAppKey(app)`), when it names one. */
273
+ appKey?: Hex;
274
+ }
275
+ >;
276
+
277
+ /**
278
+ * What the share label helpers read: the handles, their issuer and the metadata blob. An
279
+ * `IncomingShare` (on-chain route) and a sharing or recipient `ACP` (exported JSON route) both fit.
243
280
  */
244
- export type IncomingShare = Expand<Omit<SharedACP, 'name' | 'type'> & { shareId: Hex }>;
281
+ export type LabelledShare = { issuer: Hex; handles: readonly Hex[]; metadata?: Hex };
245
282
 
246
283
  /**
247
284
  * A type representing the acp fields that are used to generate the hash
package/acps/utils.ts CHANGED
@@ -2,9 +2,22 @@
2
2
 
3
3
  declare const BigInt: (value: string | number | bigint) => bigint;
4
4
 
5
+ const HEX_BODY_REGEX = /^[0-9a-fA-F]*$/;
6
+
5
7
  export const fromHexString = (hexString: string): Uint8Array => {
6
- const cleanString = hexString.length % 2 === 1 ? `0${hexString}` : hexString;
7
- const arr = cleanString.replace(/^0x/, '').match(/.{1,2}/g);
8
+ // Strip `0x` before padding: padding first turns `0x123` into `00x123`, which no
9
+ // longer matches the prefix and decodes to garbage bytes.
10
+ const body = hexString.replace(/^0x/, '');
11
+
12
+ // `parseInt` maps a non-hex pair to NaN, which `Uint8Array` stores as 0. Without this check a
13
+ // malformed sealing key silently becomes zero bytes instead of failing.
14
+ if (!HEX_BODY_REGEX.test(body)) {
15
+ // The value is omitted on purpose: this helper also receives sealing private keys.
16
+ throw new Error('Invalid hex string: contains non-hexadecimal characters');
17
+ }
18
+
19
+ const padded = body.length % 2 === 1 ? `0${body}` : body;
20
+ const arr = padded.match(/.{1,2}/g);
8
21
  if (!arr) return new Uint8Array();
9
22
  return new Uint8Array(arr.map((byte) => parseInt(byte, 16)));
10
23
  };
@@ -85,6 +85,7 @@ const zACPWithDefaults = z.object({
85
85
  handles: handlesSchema,
86
86
  issuerSignature: bytesSchema.optional().default('0x'),
87
87
  recipientSignature: bytesSchema.optional().default('0x'),
88
+ metadata: bytesSchema.optional(),
88
89
  });
89
90
 
90
91
  const zACPWithSealingKeys = zACPWithDefaults.extend({
@@ -228,7 +229,6 @@ export const ImportACPOptionsValidator = z
228
229
  type: z.literal('recipient').optional().default('recipient'),
229
230
  issuer: addressNotZeroSchema,
230
231
  recipient: addressNotZeroSchema,
231
- name: z.string().optional().default('Unnamed ACP'),
232
232
  expiration: z.int(),
233
233
  revokerData: z.int().optional().default(0),
234
234
  revokerContract: addressSchema.optional().default(zeroAddress),
@@ -237,6 +237,8 @@ export const ImportACPOptionsValidator = z
237
237
  handles: handlesSchema,
238
238
  issuerSignature: bytesNotEmptySchema,
239
239
  recipientSignature: bytesSchema.optional().default('0x'),
240
+ /** The share metadata (labels blob); `0x` means none and is dropped */
241
+ metadata: bytesSchema.optional().transform((val) => (val === '0x' ? undefined : val)),
240
242
  })
241
243
  .refine(...ExternalValidatorRefinement)
242
244
  .transform(withDerivedScope)