@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 +51 -0
- package/acps/onchain-utils.ts +15 -10
- package/acps/sealing.ts +22 -2
- package/acps/test/aclServedAddresses.test.ts +65 -0
- package/acps/test/sealing.test.ts +51 -0
- package/core/acps.ts +64 -23
- package/core/client.ts +32 -1
- package/core/clientTypes.ts +22 -1
- package/core/test/acpsPrepare.test.ts +77 -0
- package/dist/acps.cjs +122 -99
- package/dist/acps.d.cts +4 -358
- package/dist/acps.d.ts +4 -358
- package/dist/acps.js +2 -2
- package/dist/{chunk-OL3DZN7A.js → chunk-2EO4JSR7.js} +65 -23
- package/dist/{chunk-BQWHPIYU.js → chunk-3L2IVPMI.js} +29 -6
- package/dist/{chunk-6ZW76ZKB.js → chunk-WJGTPW7K.js} +1 -1
- package/dist/{clientTypes-CcU5WvdV.d.ts → clientTypes-CnslFzxB.d.ts} +38 -2
- package/dist/{clientTypes-C0A9ji5O.d.cts → clientTypes-RXwLVWou.d.cts} +38 -2
- package/dist/core.cjs +250 -185
- package/dist/core.d.cts +3 -3
- package/dist/core.d.ts +3 -3
- package/dist/core.js +4 -4
- package/dist/node.cjs +184 -119
- package/dist/node.d.cts +2 -2
- package/dist/node.d.ts +2 -2
- package/dist/node.js +4 -4
- package/dist/{onchain-utils-D9Z33IP7.d.ts → onchain-utils-Ns56cBm0.d.cts} +359 -5
- package/dist/{onchain-utils-D9Z33IP7.d.cts → onchain-utils-Ns56cBm0.d.ts} +359 -5
- package/dist/web.cjs +184 -119
- package/dist/web.d.cts +2 -2
- package/dist/web.d.ts +2 -2
- package/dist/web.js +4 -4
- package/dist/zkProve.worker.js +2 -2
- package/package.json +1 -1
- package/dist/{chunk-XQ32JG3T.js → chunk-J23FYAI2.js} +13 -13
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
|
package/acps/onchain-utils.ts
CHANGED
|
@@ -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
|
|
349
|
-
* error, no CoFHE deployment) is NOT cached, so a transient outage
|
|
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
|
-
|
|
372
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
):
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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();
|
package/core/clientTypes.ts
CHANGED
|
@@ -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 {
|
|
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
|
+
});
|