@cofhe/sdk 1.0.0-alpha.0 → 1.0.1

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 (36) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/acps/onchain-utils.ts +15 -10
  3. package/acps/sealing.ts +22 -2
  4. package/acps/test/aclServedAddresses.test.ts +65 -0
  5. package/acps/test/sealing.test.ts +51 -0
  6. package/core/encrypt/encryptInputsBuilder.ts +31 -13
  7. package/core/encrypt/zkPackProveVerify.ts +16 -4
  8. package/core/error.ts +1 -0
  9. package/core/test/encryptInputsBuilder.test.ts +56 -4
  10. package/core/test/zkPoKMetadata.test.ts +27 -0
  11. package/dist/acps.cjs +122 -99
  12. package/dist/acps.d.cts +2 -2
  13. package/dist/acps.d.ts +2 -2
  14. package/dist/acps.js +2 -2
  15. package/dist/{chunk-6ZW76ZKB.js → chunk-3SD2UCRE.js} +1 -1
  16. package/dist/{chunk-OL3DZN7A.js → chunk-GKEJHZ52.js} +35 -19
  17. package/dist/{chunk-XQ32JG3T.js → chunk-OC5TGQGG.js} +14 -13
  18. package/dist/{chunk-BQWHPIYU.js → chunk-WAA2U7CT.js} +29 -6
  19. package/dist/{clientTypes-C0A9ji5O.d.cts → clientTypes-C2b_qub5.d.cts} +6 -3
  20. package/dist/{clientTypes-CcU5WvdV.d.ts → clientTypes-DEKYh0cw.d.ts} +6 -3
  21. package/dist/core.cjs +220 -180
  22. package/dist/core.d.cts +4 -3
  23. package/dist/core.d.ts +4 -3
  24. package/dist/core.js +4 -4
  25. package/dist/node.cjs +153 -114
  26. package/dist/node.d.cts +2 -2
  27. package/dist/node.d.ts +2 -2
  28. package/dist/node.js +4 -4
  29. package/dist/{onchain-utils-D9Z33IP7.d.cts → onchain-utils-CgOslubH.d.cts} +3 -3
  30. package/dist/{onchain-utils-D9Z33IP7.d.ts → onchain-utils-CgOslubH.d.ts} +3 -3
  31. package/dist/web.cjs +153 -114
  32. package/dist/web.d.cts +2 -2
  33. package/dist/web.d.ts +2 -2
  34. package/dist/web.js +4 -4
  35. package/dist/zkProve.worker.js +2 -2
  36. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,64 @@
1
1
  # @cofhe/sdk Changelog
2
2
 
3
+ ## 1.0.1
4
+
5
+ ### Patch Changes
6
+
7
+ - fcc819c: **Breaking:** `encryptInputs(...).execute()` now binds the consuming contract into the ZK proof itself, and the CoFHE verifier requires it. Upgrade to 1.0.1: 1.0.0 is deprecated because of an issue in ZK verification, and once the verifier is updated a proof from 1.0.0 fails with `ZK_VERIFY_FAILED`. No code change is needed beyond the upgrade; `setConsumingContract(...)` was already required.
8
+
9
+ `execute()` now throws `CofheError` with code `CONSUMING_CONTRACT_INVALID` when `setConsumingContract(...)` was given something that is not an address, before TFHE is initialised or any proving starts. The check is byte-level, 20 bytes of hex in any case, and does not validate the EIP-55 checksum, so every address 1.0.0 accepted still passes. Previously the bad address reached the verifier and failed there, after the proof was generated. `CofheErrorCode` gains `ConsumingContractInvalid`; an exhaustive `switch` over the enum needs the new arm.
10
+
11
+ ## 1.0.0
12
+
13
+ ### Major Changes
14
+
15
+ - d711ce2: 1.0.0, the first stable release. Breaking changes since 0.8, detailed in their own entries below:
16
+
17
+ - The on-chain share registry has a new layout (`share(acp, metadata)`, header-only `sharesFor` / `getShare`), and shared ACPs no longer carry a `name`.
18
+ - The mocks need Solidity 0.8.25 or later and `@openzeppelin/contracts-upgradeable`.
19
+ - `CofheProvider` takes either `config` or `cofheClient`, not both.
20
+
21
+ ### Minor Changes
22
+
23
+ - 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)
24
+
25
+ **@cofhe/sdk**
26
+
27
+ - `createSharing({ labels })`, `shareOnChain(acp, { labels | metadata })`; labels travel in `export()` / `importShared` as `SharedACP.metadata`.
28
+ - New `client.acp.verifyShareLabels`, `client.acp.getShareFromChain`, and `describeShareMetadata`. `IncomingShare` gains `metadata` and `appKey`.
29
+ - 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)).
30
+ - `@cofhe/sdk/acps` exports the metadata codec, label builders (`viewFunctionLabel`, `eventLogLabel`, `UNLABELLED`), verifiers, registry readers, `shareAppKey` and `ERC7984_ABI`.
31
+
32
+ **@cofhe/react**
33
+
34
+ - New `useCofheShareLabels`. `useCofheShareOnChain` accepts `{ acp, labels | metadata }`.
35
+ - The inbox shows each share's labels and their verification. [Docs](https://cofhesdk.fhenix.io/react/acp#share-labels)
36
+
37
+ **Mocks and plugins**
38
+
39
+ - `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`.
40
+ - Mocks now need Solidity ≥0.8.25 and `@openzeppelin/contracts-upgradeable` (new Hardhat plugin peer dependency; Foundry users add its remapping).
41
+
42
+ ### Patch Changes
43
+
44
+ - 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.
45
+
46
+ - `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.
47
+ - `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`).
48
+ - `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.
49
+ - `FHE.rem(x, 0)` returns `x` instead of reverting.
50
+ - `FHE.random*` handles carry their type and security zone and can be allowed and computed on.
51
+ - `MockTaskManager.isPubliclyAllowed` returns whether a handle was made public with `FHE.allowGlobal` / `FHE.allowPublic`, instead of reverting with `NotImplemented`.
52
+ - 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.
53
+ - `publishDecryptResultBatch` emits `DecryptionResult` with the caller as `requestor`, and the batch decrypt result functions revert with `LengthMismatch` when the arrays differ in length.
54
+ - `setSecurityZones` rejects an inverted range (`min > max`) with `InvalidSecurityZone`.
55
+
56
+ - 5a192ee: Node storage now creates ~/.cofhesdk with mode 0700 and files with 0600, and tightens permissions on existing ones.
57
+ - 757e448: Use the latest chain block timestamp for injected ACP revoker data while preserving explicit revoker configuration precedence.
58
+ - 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`.
59
+ - 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.
60
+ - 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.
61
+
3
62
  ## 0.8.0
4
63
 
5
64
  ### 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', () => {
@@ -21,7 +21,7 @@ import { cofheMocksCheckEncryptableBits, cofheMocksZkVerifySign } from './cofheM
21
21
  import { hardhat } from 'viem/chains';
22
22
  import { fetchKeys, type FheKeyDeserializer } from '../fetchKeys.js';
23
23
  import { getZkVerifierUrlOrThrow } from '../config.js';
24
- import { type WalletClient } from 'viem';
24
+ import { type WalletClient, isAddress } from 'viem';
25
25
  import { sleep } from '../utils.js';
26
26
  import { BaseBuilder, type BaseBuilderParams } from '../baseBuilder.js';
27
27
  import { type KeysStorage } from '../keyStore.js';
@@ -256,19 +256,37 @@ export class EncryptInputsBuilder<T extends EncryptableItem[]> extends BaseBuild
256
256
  }
257
257
 
258
258
  /**
259
- * Asserts that this.consumingContract is populated
260
- * @throws {CofheError} If consumingContract is not set
259
+ * Asserts that this.consumingContract is populated and is an address.
260
+ * Runs before TFHE init, key fetching and proving, so a bad address costs
261
+ * nothing: the contract is bound into the proof, and a proof built for a
262
+ * non-address could only fail at the verifier, after the expensive step.
263
+ * @throws {CofheError} If consumingContract is not set or is not an address
261
264
  */
262
265
  private assertConsumingContract(): asserts this is this & { consumingContract: string } {
263
- if (this.consumingContract) return;
264
- throw new CofheError({
265
- code: CofheErrorCode.ConsumingContractUninitialized,
266
- message: 'Consuming contract is not set',
267
- hint: 'Use setConsumingContract(...) to set the contract that will consume the encrypted inputs.',
268
- context: {
269
- consumingContract: this.consumingContract,
270
- },
271
- });
266
+ if (!this.consumingContract) {
267
+ throw new CofheError({
268
+ code: CofheErrorCode.ConsumingContractUninitialized,
269
+ message: 'Consuming contract is not set',
270
+ hint: 'Use setConsumingContract(...) to set the contract that will consume the encrypted inputs.',
271
+ context: {
272
+ consumingContract: this.consumingContract,
273
+ },
274
+ });
275
+ }
276
+ // strict: false — 20 bytes of hex in any case, no EIP-55 checksum check. The
277
+ // verifier decodes the address case-insensitively and the chain compares bytes,
278
+ // so a mixed-case address with a wrong checksum works end to end and a patch
279
+ // release must keep accepting it.
280
+ if (!isAddress(this.consumingContract, { strict: false })) {
281
+ throw new CofheError({
282
+ code: CofheErrorCode.ConsumingContractInvalid,
283
+ message: 'Consuming contract is not an address',
284
+ hint: 'setConsumingContract(...) takes the 20-byte address of the contract that will consume the encrypted inputs.',
285
+ context: {
286
+ consumingContract: this.consumingContract,
287
+ },
288
+ });
289
+ }
272
290
  }
273
291
 
274
292
  /**
@@ -567,7 +585,7 @@ export class EncryptInputsBuilder<T extends EncryptableItem[]> extends BaseBuild
567
585
  this.fireStepStart(EncryptStep.Prove);
568
586
 
569
587
  // Construct metadata once (used by both worker and main thread paths)
570
- const metadata = constructZkPoKMetadata(this.account, this.securityZone, this.chainId);
588
+ const metadata = constructZkPoKMetadata(this.account, this.securityZone, this.chainId, this.consumingContract);
571
589
 
572
590
  let proof: Uint8Array | null = null;
573
591
  let usedWorker = false;
@@ -239,13 +239,24 @@ export const zkProve = async (
239
239
  };
240
240
 
241
241
  /**
242
- * Constructs the ZK Proof of Knowledge metadata for the proof
242
+ * Constructs the ZK Proof of Knowledge metadata for the proof:
243
+ * `securityZone (1) || account (20) || chainId (32, big-endian) || consumingContract (20)`.
244
+ *
245
+ * The verifier rebuilds the same bytes from the request fields before it checks the proof, so the
246
+ * proof is bound to the consuming contract itself, not only to the signature the verifier returns.
243
247
  * @internal - Used internally within the encrypt module
244
248
  */
245
- export const constructZkPoKMetadata = (accountAddr: string, securityZone: number, chainId: number): Uint8Array => {
246
- // Decode the account address from hex
249
+ export const constructZkPoKMetadata = (
250
+ accountAddr: string,
251
+ securityZone: number,
252
+ chainId: number,
253
+ consumingContract: string
254
+ ): Uint8Array => {
255
+ // Decode the account and contract addresses from hex
247
256
  const accountAddrNoPrefix = accountAddr.startsWith('0x') ? accountAddr.slice(2) : accountAddr;
248
257
  const accountBytes = hexToBytes(accountAddrNoPrefix);
258
+ const contractNoPrefix = consumingContract.startsWith('0x') ? consumingContract.slice(2) : consumingContract;
259
+ const contractBytes = hexToBytes(contractNoPrefix);
249
260
 
250
261
  // Encode chainId as 32 bytes (u256) in big-endian format
251
262
  const chainIdBytes = new Uint8Array(32);
@@ -259,10 +270,11 @@ export const constructZkPoKMetadata = (accountAddr: string, securityZone: number
259
270
  value = value >>> 8;
260
271
  }
261
272
 
262
- const metadata = new Uint8Array(1 + accountBytes.length + 32);
273
+ const metadata = new Uint8Array(1 + accountBytes.length + 32 + contractBytes.length);
263
274
  metadata[0] = securityZone;
264
275
  metadata.set(accountBytes, 1);
265
276
  metadata.set(chainIdBytes, 1 + accountBytes.length);
277
+ metadata.set(contractBytes, 1 + accountBytes.length + 32);
266
278
 
267
279
  return metadata;
268
280
  };
package/core/error.ts CHANGED
@@ -16,6 +16,7 @@ export enum CofheErrorCode {
16
16
  AccountUninitialized = 'ACCOUNT_UNINITIALIZED',
17
17
  ChainIdUninitialized = 'CHAIN_ID_UNINITIALIZED',
18
18
  ConsumingContractUninitialized = 'CONSUMING_CONTRACT_UNINITIALIZED',
19
+ ConsumingContractInvalid = 'CONSUMING_CONTRACT_INVALID',
19
20
  SealOutputFailed = 'SEAL_OUTPUT_FAILED',
20
21
  SealOutputReturnedNull = 'SEAL_OUTPUT_RETURNED_NULL',
21
22
  InvalidUtype = 'INVALID_UTYPE',
@@ -10,7 +10,7 @@ import {
10
10
  } from '../types.js';
11
11
  import { CofheError, CofheErrorCode } from '../error.js';
12
12
  import { fromHexString, toHexString } from '../utils.js';
13
- import { type PublicClient, createPublicClient, http, type WalletClient, createWalletClient } from 'viem';
13
+ import { type PublicClient, createPublicClient, http, type WalletClient, createWalletClient, isAddress } from 'viem';
14
14
  import { privateKeyToAccount } from 'viem/accounts';
15
15
  import { arbitrumSepolia } from 'viem/chains';
16
16
  import { type CofheConfig, createCofheConfigBase } from '../config.js';
@@ -53,9 +53,9 @@ const unpackMetadata = (metadata: string) => {
53
53
 
54
54
  export const deconstructZkPoKMetadata = (
55
55
  metadata: Uint8Array
56
- ): { accountAddr: string; securityZone: number; chainId: number } => {
57
- if (metadata.length < 53) {
58
- // 1 + 20 + 32 = 53 bytes minimum
56
+ ): { accountAddr: string; securityZone: number; chainId: number; contractAddr: string } => {
57
+ if (metadata.length < 73) {
58
+ // 1 + 20 + 32 + 20 = 73 bytes minimum
59
59
  throw new CofheError({
60
60
  code: CofheErrorCode.InternalError,
61
61
  message: 'Invalid metadata: insufficient length',
@@ -78,10 +78,14 @@ export const deconstructZkPoKMetadata = (
78
78
  chainId = (chainId << 8) | chainIdBytes[i];
79
79
  }
80
80
 
81
+ // Extract the consuming contract (last 20 bytes)
82
+ const contractAddr = '0x' + toHexString(metadata.slice(53, 73));
83
+
81
84
  return {
82
85
  accountAddr,
83
86
  securityZone,
84
87
  chainId,
88
+ contractAddr,
85
89
  };
86
90
  };
87
91
 
@@ -445,6 +449,38 @@ describe('EncryptInputsBuilder', () => {
445
449
  expect((error as CofheError).code).toBe(CofheErrorCode.ConsumingContractUninitialized);
446
450
  }
447
451
  });
452
+
453
+ it('accepts a mixed-case consuming contract whose checksum is wrong', async () => {
454
+ // The verifier decodes the address case-insensitively and the chain compares
455
+ // 20 bytes, so a 1.0.0 dApp with such an address must keep working.
456
+ const wrongChecksum = '0xBEEFbeefBEEFbeefBEEFbeefBEEFbeefBEEFbeef';
457
+ expect(isAddress(wrongChecksum)).toBe(false);
458
+ expect(isAddress(wrongChecksum, { strict: false })).toBe(true);
459
+
460
+ const result = await new EncryptInputsBuilder(createDefaultParams())
461
+ .setConsumingContract(wrongChecksum)
462
+ .execute();
463
+
464
+ expect(Array.isArray(result)).toBe(true);
465
+ });
466
+
467
+ it('rejects a consuming contract that is not an address before any encrypt step runs', async () => {
468
+ for (const notAnAddress of ['0x0', 'not-an-address', '0xbeefbeefbeefbeefbeefbeefbeefbeefbeefbee']) {
469
+ const steps = vi.fn();
470
+ const invalid = new EncryptInputsBuilder(createDefaultParams())
471
+ .setConsumingContract(notAnAddress)
472
+ .onStep(steps);
473
+ try {
474
+ await invalid.execute();
475
+ expect.unreachable(`execute() should have thrown for ${notAnAddress}`);
476
+ } catch (error) {
477
+ expect(error, notAnAddress).toBeInstanceOf(CofheError);
478
+ expect((error as CofheError).code, notAnAddress).toBe(CofheErrorCode.ConsumingContractInvalid);
479
+ }
480
+ // Rejected before initTfhe, so no step (and no proving) ever started.
481
+ expect(steps, notAnAddress).not.toHaveBeenCalled();
482
+ }
483
+ });
448
484
  });
449
485
 
450
486
  describe('zkVerifierUrl', () => {
@@ -595,6 +631,22 @@ describe('EncryptInputsBuilder', () => {
595
631
  expect(encryptedMetadata.chainId).toBe(defaultChainId);
596
632
  });
597
633
 
634
+ it('binds the consuming contract into the proof metadata', async () => {
635
+ mockFetch.mockClear();
636
+
637
+ await builder.execute();
638
+
639
+ const verifyCall = mockFetch.mock.calls.find(([url]) => url === `${MockZkVerifierUrl}/verifyBatch`);
640
+ expect(verifyCall).toBeDefined();
641
+ const body = JSON.parse(verifyCall![1].body as string);
642
+ const { metadata } = parseWithBigInt(new TextDecoder().decode(fromHexString(body.packed_list)));
643
+ const embedded = deconstructZkPoKMetadata(Uint8Array.from(Object.values(metadata) as number[]));
644
+ expect(embedded.accountAddr).toBe(defaultSender);
645
+ expect(embedded.securityZone).toBe(0);
646
+ expect(embedded.chainId).toBe(defaultChainId);
647
+ expect(embedded.contractAddr).toBe(defaultConsumingContract);
648
+ });
649
+
598
650
  it('should use overridden account when set', async () => {
599
651
  const overriddenSender = '0x5555555555555555555555555555555555555555';
600
652
  builder.setAccount(overriddenSender);
@@ -0,0 +1,27 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { constructZkPoKMetadata } from '../encrypt/zkPackProveVerify.js';
3
+ import { toHexString } from '../utils.js';
4
+
5
+ describe('constructZkPoKMetadata', () => {
6
+ const account = '0x1234567890123456789012345678901234567890';
7
+ const contract = '0xbeefbeefbeefbeefbeefbeefbeefbeefbeefbeef';
8
+
9
+ it('lays out security zone, account, chain id and the consuming contract', () => {
10
+ const metadata = constructZkPoKMetadata(account, 3, 0x010203, contract);
11
+
12
+ expect(metadata.length).toBe(1 + 20 + 32 + 20);
13
+ expect(metadata[0]).toBe(3);
14
+ expect('0x' + toHexString(metadata.slice(1, 21))).toBe(account);
15
+ const chainId = metadata.slice(21, 53);
16
+ expect(Array.from(chainId.slice(0, 29))).toEqual(new Array(29).fill(0));
17
+ expect(Array.from(chainId.slice(29))).toEqual([1, 2, 3]);
18
+ expect('0x' + toHexString(metadata.slice(53))).toBe(contract);
19
+ });
20
+
21
+ it('accepts the consuming contract with or without the 0x prefix', () => {
22
+ const withPrefix = constructZkPoKMetadata(account, 0, 1, contract);
23
+ const withoutPrefix = constructZkPoKMetadata(account, 0, 1, contract.slice(2));
24
+
25
+ expect(Array.from(withoutPrefix)).toEqual(Array.from(withPrefix));
26
+ });
27
+ });