@cofhe/sdk 1.0.0-alpha.0 → 1.1.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/CHANGELOG.md CHANGED
@@ -1,5 +1,56 @@
1
1
  # @cofhe/sdk Changelog
2
2
 
3
+ ## 1.0.0
4
+
5
+ ### Major Changes
6
+
7
+ - d711ce2: 1.0.0, the first stable release. Breaking changes since 0.8, detailed in their own entries below:
8
+
9
+ - The on-chain share registry has a new layout (`share(acp, metadata)`, header-only `sharesFor` / `getShare`), and shared ACPs no longer carry a `name`.
10
+ - The mocks need Solidity 0.8.25 or later and `@openzeppelin/contracts-upgradeable`.
11
+ - `CofheProvider` takes either `config` or `cofheClient`, not both.
12
+
13
+ ### Minor Changes
14
+
15
+ - 6dcfb7d: ACPs sharing SNAPSHOTs (ctHashes rather than live data) can use the new on-chain ACP Sharing Registry `metadata` field to cite the origin of the snapshotted data (contract address + view-function / event), and the recipient can verify it on-chain. [Docs](https://cofhesdk.fhenix.io/sdk/acp#labelling-a-snapshot-share)
16
+
17
+ **@cofhe/sdk**
18
+
19
+ - `createSharing({ labels })`, `shareOnChain(acp, { labels | metadata })`; labels travel in `export()` / `importShared` as `SharedACP.metadata`.
20
+ - New `client.acp.verifyShareLabels`, `client.acp.getShareFromChain`, and `describeShareMetadata`. `IncomingShare` gains `metadata` and `appKey`.
21
+ - New `acp.app` config option: shares are tagged with the app's key and `getIncomingShares` lists only that app's shares ([docs](https://cofhesdk.fhenix.io/sdk/acp#mark-shares-for-your-app)).
22
+ - `@cofhe/sdk/acps` exports the metadata codec, label builders (`viewFunctionLabel`, `eventLogLabel`, `UNLABELLED`), verifiers, registry readers, `shareAppKey` and `ERC7984_ABI`.
23
+
24
+ **@cofhe/react**
25
+
26
+ - New `useCofheShareLabels`. `useCofheShareOnChain` accepts `{ acp, labels | metadata }`.
27
+ - The inbox shows each share's labels and their verification. [Docs](https://cofhesdk.fhenix.io/react/acp#share-labels)
28
+
29
+ **Mocks and plugins**
30
+
31
+ - `ACPShareRegistry`, `ACPTimestampRevoker` and `Permissioned` are copied verbatim from a pinned cofhe-contracts commit (`pnpm update:cofhe-contracts <commit>`). The share registry is deployed behind `MockERC1967Proxy`.
32
+ - Mocks now need Solidity ≥0.8.25 and `@openzeppelin/contracts-upgradeable` (new Hardhat plugin peer dependency; Foundry users add its remapping).
33
+
34
+ ### Patch Changes
35
+
36
+ - d711ce2: The mocks now behave like the real TaskManager and TFHE-rs in more places. Tests that relied on the old mock behaviour may start failing, as they would on a real network.
37
+
38
+ - `FHE.asE*` (trivial encrypt) reverts with `InvalidInputForFunction` when the value does not fit the type (e.g. `FHE.asEuint8(256)`), and with `InvalidSecurityZone` for an out of range zone, instead of storing the value masked.
39
+ - `encryptInputs` against the mocks rejects out-of-range values with the same error as the production path, instead of passing them on to be masked (e.g. `Encryptable.uint8(256n)` became `0`).
40
+ - `FHE.not` on encrypted integers is a bitwise NOT (`not(euint8(2))` is 253, not 0), and `FHE.rol` / `FHE.ror` rotate within the type's width, with the amount taken modulo the width, instead of shifting.
41
+ - `FHE.rem(x, 0)` returns `x` instead of reverting.
42
+ - `FHE.random*` handles carry their type and security zone and can be allowed and computed on.
43
+ - `MockTaskManager.isPubliclyAllowed` returns whether a handle was made public with `FHE.allowGlobal` / `FHE.allowPublic`, instead of reverting with `NotImplemented`.
44
+ - Handles in a negative security zone go through the ACL like any other handle. Previously any contract could use them and `FHE.allow*` did nothing.
45
+ - `publishDecryptResultBatch` emits `DecryptionResult` with the caller as `requestor`, and the batch decrypt result functions revert with `LengthMismatch` when the arrays differ in length.
46
+ - `setSecurityZones` rejects an inverted range (`min > max`) with `InvalidSecurityZone`.
47
+
48
+ - 5a192ee: Node storage now creates ~/.cofhesdk with mode 0700 and files with 0600, and tightens permissions on existing ones.
49
+ - 757e448: Use the latest chain block timestamp for injected ACP revoker data while preserving explicit revoker configuration precedence.
50
+ - 73d5297: `seal` and `unsealWithPrivateKey` now throw when the sealing key contains non-hex characters. Previously a 64-character key with invalid characters passed the length check and those characters decoded to zero bytes, so `seal` silently encrypted to the wrong key (an entirely invalid key became the all-zero key, which offers no confidentiality), and `unsealWithPrivateKey` failed later with `Failed to decrypt message`.
51
+ - 8fad647: `unsealWithPrivateKey` throws a `CofheError` with code `SealOutputFailed` when a decrypt response can't be unsealed, instead of a plain `Error`. `useCofheDecrypt` no longer retries these failures.
52
+ - a4fcea7: `verifyDecryptResult` now returns `false` when the chain's TaskManager has no `decryptResultSigner` set (zero address). It used to return `true` for any signature in that case. The on-chain `verifyDecryptResult` still skips the check when no signer is set.
53
+
3
54
  ## 0.8.0
4
55
 
5
56
  ### Minor Changes
@@ -345,9 +345,9 @@ export const clearAclServedAddresses = () => aclServedAddressesCache.clear();
345
345
  * TaskManager -> acl() -> getters). Zero addresses and pre-upgrade ACLs (getters absent -> revert)
346
346
  * resolve to `undefined` — callers fall back to `acp.*` config.
347
347
  *
348
- * Resolutions are cached per chainId. A failure to reach the TaskManager (network
349
- * error, no CoFHE deployment) is NOT cached, so a transient outage does not pin
350
- * an empty result for the whole session.
348
+ * Resolutions are cached per chainId. A failure to reach the TaskManager or the ACL
349
+ * getters (network error, no CoFHE deployment) is NOT cached, so a transient outage
350
+ * does not pin an empty result for the whole session.
351
351
  */
352
352
  export const getAclServedAddresses = async (
353
353
  publicClient: PublicClient,
@@ -367,19 +367,24 @@ export const getAclServedAddresses = async (
367
367
  return {};
368
368
  }
369
369
 
370
+ // A revert means the getter is missing (pre-upgrade ACL) and is safe to cache. Any other
371
+ // error (rate limit, timeout) is transient: return what we have but don't cache it.
372
+ let transient = false;
373
+ const readServedAddress = (functionName: 'defaultRevokerContract' | 'shareRegistry') =>
374
+ publicClient.readContract({ address: aclAddress, abi: ACL_SERVED_ADDRESSES_ABI, functionName }).catch((err) => {
375
+ if (revertErrorName(err, ACL_SERVED_ADDRESSES_ABI) === undefined) transient = true;
376
+ return undefined;
377
+ });
378
+
370
379
  const [defaultRevoker, shareRegistry] = await Promise.all([
371
- publicClient
372
- .readContract({ address: aclAddress, abi: ACL_SERVED_ADDRESSES_ABI, functionName: 'defaultRevokerContract' })
373
- .catch(() => undefined),
374
- publicClient
375
- .readContract({ address: aclAddress, abi: ACL_SERVED_ADDRESSES_ABI, functionName: 'shareRegistry' })
376
- .catch(() => undefined),
380
+ readServedAddress('defaultRevokerContract'),
381
+ readServedAddress('shareRegistry'),
377
382
  ]);
378
383
 
379
384
  const resolved: AclServedAddresses = {
380
385
  defaultRevoker: defaultRevoker != null && defaultRevoker !== zeroAddress ? defaultRevoker : undefined,
381
386
  shareRegistry: shareRegistry != null && shareRegistry !== zeroAddress ? shareRegistry : undefined,
382
387
  };
383
- aclServedAddressesCache.set(chainId, resolved);
388
+ if (!transient) aclServedAddressesCache.set(chainId, resolved);
384
389
  return resolved;
385
390
  };
package/acps/sealing.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import nacl from 'tweetnacl';
2
2
  import { type Hex } from 'viem';
3
3
  import { fromHexString, toBeArray, toBigInt, toHexString, isBigIntOrNumber, isString } from './utils.js';
4
+ import { CofheError, CofheErrorCode } from '../core/error.js';
4
5
 
5
6
  /** 32-byte X25519 keys as bare hex (no 0x prefix) */
6
7
  const KEY_HEX_LENGTH = 64;
@@ -74,9 +75,28 @@ export const unsealWithPrivateKey = (privateKey: string, parsedData: EthEncrypte
74
75
  parsedData.public_key instanceof Uint8Array ? parsedData.public_key : new Uint8Array(parsedData.public_key);
75
76
  const dataToDecrypt = parsedData.data instanceof Uint8Array ? parsedData.data : new Uint8Array(parsedData.data);
76
77
 
77
- const decryptedMessage = nacl.box.open(dataToDecrypt, nonce, ephemPublicKey, fromHexString(privateKey));
78
+ // nacl returns null for a wrong key or tampered payload and throws on malformed inputs. Either way it
79
+ // is a CofheError, so callers that stop retrying on those (e.g. `useCofheDecrypt`) do not re-request.
80
+ let decryptedMessage: Uint8Array | null;
81
+ let cause: Error | undefined;
82
+ try {
83
+ decryptedMessage = nacl.box.open(dataToDecrypt, nonce, ephemPublicKey, fromHexString(privateKey));
84
+ } catch (e) {
85
+ decryptedMessage = null;
86
+ cause = e instanceof Error ? e : undefined;
87
+ }
78
88
  if (!decryptedMessage) {
79
- throw new Error('Failed to decrypt message');
89
+ // Lengths only: the context must not carry the private key or any sealing material.
90
+ throw new CofheError({
91
+ code: CofheErrorCode.SealOutputFailed,
92
+ message: 'Failed to unseal decrypt response',
93
+ cause,
94
+ context: {
95
+ dataLength: dataToDecrypt.length,
96
+ nonceLength: nonce.length,
97
+ publicKeyLength: ephemPublicKey.length,
98
+ },
99
+ });
80
100
  }
81
101
 
82
102
  return toBigInt(decryptedMessage);
@@ -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
+ });
@@ -1,5 +1,6 @@
1
1
  import { describe, it, expect } from 'vitest';
2
2
  import { GenerateSealingKey, seal, unsealWithPrivateKey } from '../index.js';
3
+ import { CofheError, CofheErrorCode } from '../../core/error.js';
3
4
 
4
5
  describe('seal / unsealWithPrivateKey', () => {
5
6
  it('should seal data into EthEncryptedData shape', () => {
@@ -80,6 +81,56 @@ describe('seal / unsealWithPrivateKey', () => {
80
81
  seal('not a number', publicKey);
81
82
  }).toThrow('Value not a number is not a number or bigint: string');
82
83
  });
84
+
85
+ describe('unseal failures are CofheErrors', () => {
86
+ const expectUnsealFailed = (fn: () => unknown, secrets: string[] = []) => {
87
+ let thrown: unknown;
88
+ try {
89
+ fn();
90
+ } catch (e) {
91
+ thrown = e;
92
+ }
93
+ expect(thrown).toBeInstanceOf(CofheError);
94
+ expect((thrown as CofheError).code).toBe(CofheErrorCode.SealOutputFailed);
95
+ const serialized = (thrown as CofheError).serialize();
96
+ for (const secret of secrets) expect(serialized).not.toContain(secret.replace(/^0x/, ''));
97
+ };
98
+
99
+ it('throws SealOutputFailed when unsealing with the wrong private key', () => {
100
+ const pair = GenerateSealingKey();
101
+ const other = GenerateSealingKey();
102
+ const sealed = seal(BigInt(42), pair.publicKey);
103
+
104
+ expectUnsealFailed(() => unsealWithPrivateKey(other.privateKey, sealed), [other.privateKey, pair.privateKey]);
105
+ });
106
+
107
+ it('throws SealOutputFailed when the sealed payload is corrupted', () => {
108
+ const pair = GenerateSealingKey();
109
+ const sealed = seal(BigInt(42), pair.publicKey);
110
+ const corrupted = { ...sealed, data: Uint8Array.from(sealed.data) };
111
+ corrupted.data[0] ^= 0xff;
112
+
113
+ expectUnsealFailed(() => unsealWithPrivateKey(pair.privateKey, corrupted), [pair.privateKey]);
114
+ });
115
+
116
+ it('throws SealOutputFailed, keeping the cause, when nacl rejects malformed inputs', () => {
117
+ const pair = GenerateSealingKey();
118
+ const sealed = seal(BigInt(42), pair.publicKey);
119
+ const shortNonce = { ...sealed, nonce: sealed.nonce.slice(0, 4) };
120
+
121
+ expectUnsealFailed(() => unsealWithPrivateKey(pair.privateKey, shortNonce), [pair.privateKey]);
122
+ try {
123
+ unsealWithPrivateKey(pair.privateKey, shortNonce);
124
+ } catch (e) {
125
+ expect((e as CofheError).cause).toBeInstanceOf(Error);
126
+ }
127
+ });
128
+
129
+ it('still reports a malformed sealing key as a plain key error, not a seal failure', () => {
130
+ const sealed = seal(BigInt(1), GenerateSealingKey().publicKey);
131
+ expect(() => unsealWithPrivateKey('deadbeef', sealed)).toThrow('Private key must be of length 64');
132
+ });
133
+ });
83
134
  });
84
135
 
85
136
  describe('GenerateSealingKey', () => {
package/core/acps.ts CHANGED
@@ -26,7 +26,7 @@ import {
26
26
  clearAclServedAddresses,
27
27
  } from '@/acps';
28
28
 
29
- import { type Hex, type PublicClient, type WalletClient, zeroAddress } from 'viem';
29
+ import { type ContractFunctionParameters, type Hex, type PublicClient, type WalletClient, zeroAddress } from 'viem';
30
30
 
31
31
  // HELPERS
32
32
 
@@ -311,23 +311,39 @@ const getBlockRevokerData = async (publicClient: PublicClient): Promise<number>
311
311
  *
312
312
  * @returns the revocation transaction hash
313
313
  */
314
- const revokeACP = async (acp: ACP, walletClient: WalletClient): Promise<Hex> => {
314
+ /**
315
+ * A registry or revoker write built but not sent: what a simulation or `writeContract` takes,
316
+ * minus the sender. For a caller that sends through its own path (simulate first, record the
317
+ * transaction) instead of the plain `writeContract` the senders below do.
318
+ */
319
+ export type ShareRegistryWrite<functionName extends 'share' | 'removeShare'> = ContractFunctionParameters<
320
+ typeof ACP_SHARE_REGISTRY_ABI,
321
+ 'nonpayable',
322
+ functionName
323
+ >;
324
+ export type RevokerWrite = ContractFunctionParameters<typeof ACP_REVOKER_ABI, 'nonpayable', 'revokeSingle'>;
325
+
326
+ /** The revoker write that revokes one acp, built but not sent. Throws for an acp without a revoker. */
327
+ const prepareRevokeACP = (acp: ACP): RevokerWrite => {
315
328
  if (acp.revokerContract === zeroAddress || acp.revokerData === 0) {
316
329
  throw new Error('ACP is not revocable: it has no revoker (revokerContract/revokerData unset)');
317
330
  }
331
+ return {
332
+ address: acp.revokerContract,
333
+ abi: ACP_REVOKER_ABI,
334
+ functionName: 'revokeSingle',
335
+ args: [BigInt(acp.revokerData)],
336
+ };
337
+ };
338
+
339
+ const revokeACP = async (acp: ACP, walletClient: WalletClient): Promise<Hex> => {
340
+ const write = prepareRevokeACP(acp);
318
341
  if (walletClient.account == null) throw new Error('Missing walletClient account');
319
342
  if (walletClient.account.address.toLowerCase() !== acp.issuer.toLowerCase()) {
320
343
  throw new Error('Only the acp issuer can revoke it');
321
344
  }
322
345
 
323
- return walletClient.writeContract({
324
- address: acp.revokerContract,
325
- abi: ACP_REVOKER_ABI,
326
- functionName: 'revokeSingle',
327
- args: [BigInt(acp.revokerData)],
328
- account: walletClient.account,
329
- chain: walletClient.chain,
330
- });
346
+ return walletClient.writeContract({ ...write, account: walletClient.account, chain: walletClient.chain });
331
347
  };
332
348
 
333
349
  /**
@@ -416,34 +432,51 @@ const metadataOf = (acp: ACP, { labels, metadata, appKey }: ShareOnChainOptions)
416
432
  * to discover and import — the on-chain alternative to `export()` — optionally
417
433
  * with the labels of its handles.
418
434
  */
419
- const shareOnChain = async (
435
+ /**
436
+ * The registry `share` write for a signed sharing acp, built but not sent, with the share id it
437
+ * gets. Validates the acp, not the sender: the registry takes the write from the issuer only.
438
+ */
439
+ const prepareShareOnChain = (
420
440
  acp: ACP,
421
- walletClient: WalletClient,
422
441
  registry: Hex,
423
442
  options: ShareOnChainOptions = {}
424
- ): Promise<{ txHash: Hex; shareId: Hex }> => {
443
+ ): { write: ShareRegistryWrite<'share'>; shareId: Hex } => {
425
444
  if (acp.type !== 'sharing') {
426
445
  throw new Error(`Cannot share a '${acp.type}' ACP on-chain — only 'sharing' ACPs are shareable.`);
427
446
  }
428
447
  if (acp.issuerSignature === '0x') {
429
448
  throw new Error('Cannot share an unsigned sharing ACP — sign it first.');
430
449
  }
450
+ return {
451
+ write: {
452
+ address: registry,
453
+ abi: ACP_SHARE_REGISTRY_ABI,
454
+ functionName: 'share',
455
+ args: [toChainShare(acp), metadataOf(acp, options)],
456
+ },
457
+ shareId: computeShareId(acp),
458
+ };
459
+ };
460
+
461
+ const shareOnChain = async (
462
+ acp: ACP,
463
+ walletClient: WalletClient,
464
+ registry: Hex,
465
+ options: ShareOnChainOptions = {}
466
+ ): Promise<{ txHash: Hex; shareId: Hex }> => {
467
+ const { write, shareId } = prepareShareOnChain(acp, registry, options);
431
468
  if (walletClient.account == null) throw new Error('Missing walletClient account');
432
469
  if (walletClient.account.address.toLowerCase() !== acp.issuer.toLowerCase()) {
433
470
  throw new Error('Only the ACP issuer can share it on-chain');
434
471
  }
435
- const metadata = metadataOf(acp, options);
436
472
 
437
473
  const txHash = await walletClient.writeContract({
438
- address: registry,
439
- abi: ACP_SHARE_REGISTRY_ABI,
440
- functionName: 'share',
441
- args: [toChainShare(acp), metadata],
474
+ ...write,
442
475
  account: walletClient.account,
443
476
  chain: walletClient.chain ?? null,
444
477
  });
445
478
 
446
- return { txHash, shareId: computeShareId(acp) };
479
+ return { txHash, shareId };
447
480
  };
448
481
 
449
482
  const toIncomingShare = ({ shareId, share: s, metadata }: PostedShare): IncomingShare => ({
@@ -514,14 +547,19 @@ const importFromChain = async (
514
547
  return importShared({ ...options, type: 'sharing' }, publicClient, walletClient, importOptions);
515
548
  };
516
549
 
550
+ /** The registry `removeShare` write for one share id, built but not sent. */
551
+ const prepareRemoveShare = (shareId: Hex, registry: Hex): ShareRegistryWrite<'removeShare'> => ({
552
+ address: registry,
553
+ abi: ACP_SHARE_REGISTRY_ABI,
554
+ functionName: 'removeShare',
555
+ args: [shareId],
556
+ });
557
+
517
558
  /** Remove a share from the registry (issuer retracts / recipient dismisses). */
518
559
  const removeShareOnChain = async (shareId: Hex, walletClient: WalletClient, registry: Hex): Promise<Hex> => {
519
560
  if (walletClient.account == null) throw new Error('Missing walletClient account');
520
561
  return walletClient.writeContract({
521
- address: registry,
522
- abi: ACP_SHARE_REGISTRY_ABI,
523
- functionName: 'removeShare',
524
- args: [shareId],
562
+ ...prepareRemoveShare(shareId, registry),
525
563
  account: walletClient.account,
526
564
  chain: walletClient.chain ?? null,
527
565
  });
@@ -562,15 +600,18 @@ export const acps = {
562
600
  removeActiveACP,
563
601
 
564
602
  revokeACP,
603
+ prepareRevokeACP,
565
604
  revokeAllACPs,
566
605
  isACPRevoked,
567
606
  checkAccess,
568
607
 
569
608
  shareOnChain,
609
+ prepareShareOnChain,
570
610
  getIncomingShares,
571
611
  getShareFromChain,
572
612
  importFromChain,
573
613
  removeShareOnChain,
614
+ prepareRemoveShare,
574
615
  computeShareId,
575
616
 
576
617
  applyACPDefaultsFromChain,
package/core/client.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { CreateSelfACPOptions, CreateSharingACPOptions, ImportSharedACPOptions } from '@/acps';
1
+ import type { ACP, CreateSelfACPOptions, CreateSharingACPOptions, ImportSharedACPOptions } from '@/acps';
2
2
  import { decodeShareMetadata, shareAppKey, verifyShareLabels } from '@/acps';
3
3
 
4
4
  import { createStore } from 'zustand/vanilla';
@@ -90,6 +90,14 @@ export function createCofheClientBase<TConfig extends CofheConfig>(
90
90
  }
91
91
  };
92
92
 
93
+ /** The sharing writes the registry and the revoker take from the issuer only; say so before building one. */
94
+ const _requireIssuer = (acp: ACP, what: string) => {
95
+ const sender = connectStore.getState().walletClient?.account?.address;
96
+ if (sender == null || sender.toLowerCase() !== acp.issuer.toLowerCase()) {
97
+ throw new Error(`Only the acp issuer can ${what}`);
98
+ }
99
+ };
100
+
93
101
  // LIFECYCLE
94
102
 
95
103
  async function connect(publicClient: PublicClient, walletClient: WalletClient) {
@@ -306,6 +314,12 @@ export function createCofheClientBase<TConfig extends CofheConfig>(
306
314
  return acps.revokeACP(acp, walletClient!);
307
315
  },
308
316
 
317
+ prepareRevokeACP: (acp) => {
318
+ _requireConnected();
319
+ _requireIssuer(acp, 'revoke it');
320
+ return acps.prepareRevokeACP(acp);
321
+ },
322
+
309
323
  revokeAllACPs: async (revokerContract?: `0x${string}`) => {
310
324
  _requireConnected();
311
325
  const { publicClient, walletClient } = connectStore.getState();
@@ -333,6 +347,23 @@ export function createCofheClientBase<TConfig extends CofheConfig>(
333
347
  return acps.shareOnChain(acp, walletClient!, sharingRegistry, { ...withAppKey(), ...options });
334
348
  },
335
349
 
350
+ prepareShareOnChain: async (acp, options) => {
351
+ _requireConnected();
352
+ _requireIssuer(acp, 'share it on-chain');
353
+ const { publicClient } = connectStore.getState();
354
+ const chainId = await publicClient!.getChainId();
355
+ const sharingRegistry = await _resolveSharingRegistry(publicClient!, chainId);
356
+ return acps.prepareShareOnChain(acp, sharingRegistry, { ...withAppKey(), ...options });
357
+ },
358
+
359
+ prepareRemoveShare: async (shareId) => {
360
+ _requireConnected();
361
+ const { publicClient } = connectStore.getState();
362
+ const chainId = await publicClient!.getChainId();
363
+ const sharingRegistry = await _resolveSharingRegistry(publicClient!, chainId);
364
+ return acps.prepareRemoveShare(shareId, sharingRegistry);
365
+ },
366
+
336
367
  getIncomingShares: async () => {
337
368
  _requireConnected();
338
369
  const { publicClient, walletClient } = connectStore.getState();
@@ -6,7 +6,13 @@ import { type DecryptForTxBuilderUnset } from './decrypt/decryptForTxBuilder.js'
6
6
  import { type EncryptInputsBuilderUnset } from './encrypt/encryptInputsBuilder.js';
7
7
  import { type ZkBuilderAndCrsGenerator, type ZkProveWorkerFunction } from './encrypt/zkPackProveVerify.js';
8
8
  import { type FheKeyDeserializer } from './fetchKeys.js';
9
- import { acps, type ImportSharedOptions, type ShareOnChainOptions } from './acps.js';
9
+ import {
10
+ acps,
11
+ type ImportSharedOptions,
12
+ type RevokerWrite,
13
+ type ShareOnChainOptions,
14
+ type ShareRegistryWrite,
15
+ } from './acps.js';
10
16
  import type { EncryptableItem, FheTypes, TfheInitializer } from './types.js';
11
17
  import type { ACPUtils } from 'acps/acp.js';
12
18
  import type {
@@ -114,6 +120,11 @@ export type CofheClientACPs = {
114
120
 
115
121
  // Revocation (on-chain, require connection)
116
122
  revokeACP: (acp: ACP) => Promise<`0x${string}`>;
123
+ /**
124
+ * The revoker write `revokeACP` sends, built but not sent — for an app that sends through its own
125
+ * path (a simulation first, a transaction store). Issuer only; throws for an acp without a revoker.
126
+ */
127
+ prepareRevokeACP: (acp: ACP) => RevokerWrite;
117
128
  revokeAllACPs: (revokerContract?: `0x${string}`) => Promise<`0x${string}`>;
118
129
  isACPRevoked: (acp: ACP) => Promise<boolean>;
119
130
  /**
@@ -129,6 +140,16 @@ export type CofheClientACPs = {
129
140
  * optionally with the labels of its handles (`options.labels`, one per handle) or an encoded blob.
130
141
  */
131
142
  shareOnChain: (acp: ACP, options?: ShareOnChainOptions) => Promise<{ txHash: `0x${string}`; shareId: `0x${string}` }>;
143
+ /**
144
+ * The registry write `shareOnChain` sends and the share id it gets, built but not sent — for an
145
+ * app that sends through its own path (a simulation first, a transaction store). Issuer only.
146
+ */
147
+ prepareShareOnChain: (
148
+ acp: ACP,
149
+ options?: ShareOnChainOptions
150
+ ) => Promise<{ write: ShareRegistryWrite<'share'>; shareId: `0x${string}` }>;
151
+ /** The registry write `dismissShare` / `cancelShare` sends, built but not sent. */
152
+ prepareRemoveShare: (shareId: `0x${string}`) => Promise<ShareRegistryWrite<'removeShare'>>;
132
153
  /** Importable shares addressed to the connected account (unexpired, not revoked), with their metadata. */
133
154
  getIncomingShares: () => Promise<IncomingShare[]>;
134
155
  /** One share by id, as posted; null when unknown or removed. */
@@ -0,0 +1,77 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { Hex } from 'viem';
3
+ import { ACP_REVOKER_ABI, ACP_SHARE_REGISTRY_ABI, computeShareId, encodeShareMetadata, toChainShare } from '@/acps';
4
+ import { UNLABELLED } from '../../acps/share-metadata';
5
+ import { createMockACP } from '../../acps/test-utils';
6
+ import { acps } from '../acps';
7
+
8
+ /**
9
+ * The sharing writes built but not sent: what a caller that sends through its own path
10
+ * (simulation, transaction store) gets, and what the plain senders send. No chain involved.
11
+ */
12
+
13
+ const REGISTRY = '0x00000000000000000000000000000000000000A1';
14
+ const REVOKER = '0x00000000000000000000000000000000000000B2';
15
+ const ALICE = '0x70997970C51812dc3A010C7d01b50e0d17dc79C8';
16
+ const HANDLE: Hex = `0x${'1'.repeat(64)}`;
17
+
18
+ const signedSharing = (overrides: Parameters<typeof createMockACP>[0] = {}) =>
19
+ createMockACP({ type: 'sharing', recipient: ALICE, issuerSignature: '0x1234', ...overrides });
20
+
21
+ describe('prepareShareOnChain', () => {
22
+ it('a signed sharing acp: the registry share call with the acp and its metadata, and its share id', async () => {
23
+ const acp = await signedSharing();
24
+ const { write, shareId } = acps.prepareShareOnChain(acp, REGISTRY);
25
+ expect(write).toStrictEqual({
26
+ address: REGISTRY,
27
+ abi: ACP_SHARE_REGISTRY_ABI,
28
+ functionName: 'share',
29
+ args: [toChainShare(acp), '0x'],
30
+ });
31
+ expect(shareId).toBe(computeShareId(acp));
32
+ });
33
+
34
+ it('posts the labels it is given, else the metadata on the acp', async () => {
35
+ const acp = await signedSharing({ handles: [HANDLE] });
36
+ const labelled = acps.prepareShareOnChain(acp, REGISTRY, { labels: [UNLABELLED] });
37
+ expect(labelled.write.args[1]).toBe(encodeShareMetadata([UNLABELLED], { issuer: acp.issuer }));
38
+
39
+ const kept = acps.prepareShareOnChain({ ...acp, metadata: '0xdead' }, REGISTRY);
40
+ expect(kept.write.args[1]).toBe('0xdead');
41
+ });
42
+
43
+ it('refuses a self acp and an unsigned sharing acp', async () => {
44
+ const self = await createMockACP();
45
+ expect(() => acps.prepareShareOnChain(self, REGISTRY)).toThrow(/only 'sharing' ACPs/);
46
+ const unsigned = await createMockACP({ type: 'sharing', recipient: ALICE });
47
+ expect(() => acps.prepareShareOnChain(unsigned, REGISTRY)).toThrow(/sign it first/);
48
+ });
49
+ });
50
+
51
+ describe('prepareRemoveShare', () => {
52
+ it('the registry removeShare call for the share id', () => {
53
+ expect(acps.prepareRemoveShare(HANDLE, REGISTRY)).toStrictEqual({
54
+ address: REGISTRY,
55
+ abi: ACP_SHARE_REGISTRY_ABI,
56
+ functionName: 'removeShare',
57
+ args: [HANDLE],
58
+ });
59
+ });
60
+ });
61
+
62
+ describe('prepareRevokeACP', () => {
63
+ it('the revoker revokeSingle call for the acp revoker data', async () => {
64
+ const acp = await signedSharing({ revokerContract: REVOKER, revokerData: 42 });
65
+ expect(acps.prepareRevokeACP(acp)).toStrictEqual({
66
+ address: REVOKER,
67
+ abi: ACP_REVOKER_ABI,
68
+ functionName: 'revokeSingle',
69
+ args: [42n],
70
+ });
71
+ });
72
+
73
+ it('refuses an acp without a revoker', async () => {
74
+ const acp = await signedSharing();
75
+ expect(() => acps.prepareRevokeACP(acp)).toThrow(/not revocable/);
76
+ });
77
+ });