@lfdecentralizedtrust/zeto-contracts 0.2.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/README.md +70 -162
  2. package/contracts/factory.sol +30 -34
  3. package/contracts/factory_upgradeable.sol +160 -0
  4. package/contracts/lib/common/util.sol +40 -0
  5. package/contracts/lib/interfaces/ILockableCapability.sol +318 -0
  6. package/contracts/lib/interfaces/{izeto.sol → IZeto.sol} +13 -1
  7. package/contracts/lib/interfaces/{izeto_initializable.sol → IZetoInitializable.sol} +14 -12
  8. package/contracts/lib/interfaces/{izeto_kyc.sol → IZetoKyc.sol} +3 -3
  9. package/contracts/lib/interfaces/IZetoLockableCapability.sol +237 -0
  10. package/contracts/lib/interfaces/IZetoStorage.sol +106 -0
  11. package/contracts/lib/interfaces/{izeto_lockable.sol → IZetoVerifier.sol} +8 -21
  12. package/contracts/lib/registry.sol +77 -27
  13. package/contracts/lib/storage/base.sol +210 -0
  14. package/contracts/lib/storage/nullifier.sol +166 -0
  15. package/contracts/lib/zeto_common.sol +277 -28
  16. package/contracts/lib/zeto_fungible.sol +451 -33
  17. package/contracts/lib/zeto_fungible_base.sol +69 -0
  18. package/contracts/lib/zeto_fungible_burn.sol +83 -53
  19. package/contracts/lib/zeto_fungible_burn_nullifier.sol +115 -72
  20. package/contracts/lib/zeto_fungible_nullifier.sol +167 -0
  21. package/contracts/lib/zeto_lockable.sol +613 -0
  22. package/contracts/lib/zeto_non_fungible.sol +236 -0
  23. package/contracts/lib/zeto_non_fungible_base.sol +61 -0
  24. package/contracts/lib/zeto_non_fungible_nullifier.sol +61 -0
  25. package/contracts/test/escrow1.sol +90 -34
  26. package/contracts/test/escrow2.sol +64 -29
  27. package/contracts/test/qurrency.sol +10 -2
  28. package/contracts/test/smt.sol +8 -3
  29. package/contracts/test/tendecimals.sol +14 -12
  30. package/contracts/verifiers/impl/anon.sol +189 -0
  31. package/contracts/verifiers/impl/anon_batch.sol +301 -0
  32. package/contracts/verifiers/impl/anon_enc.sol +266 -0
  33. package/contracts/verifiers/impl/anon_enc_batch.sol +602 -0
  34. package/contracts/verifiers/impl/anon_enc_nullifier.sol +287 -0
  35. package/contracts/verifiers/impl/anon_enc_nullifier_batch.sol +679 -0
  36. package/contracts/verifiers/impl/anon_enc_nullifier_kyc.sol +294 -0
  37. package/contracts/verifiers/impl/anon_enc_nullifier_kyc_batch.sol +686 -0
  38. package/contracts/verifiers/impl/anon_enc_nullifier_non_repudiation.sol +413 -0
  39. package/contracts/verifiers/impl/anon_enc_nullifier_non_repudiation_batch.sol +1141 -0
  40. package/contracts/verifiers/impl/anon_nullifier_kyc_transfer.sol +217 -0
  41. package/contracts/verifiers/impl/anon_nullifier_kyc_transferLocked.sol +196 -0
  42. package/contracts/verifiers/impl/anon_nullifier_kyc_transferLocked_batch.sol +308 -0
  43. package/contracts/verifiers/impl/anon_nullifier_kyc_transfer_batch.sol +385 -0
  44. package/contracts/verifiers/impl/anon_nullifier_qurrency_transfer.sol +497 -0
  45. package/contracts/verifiers/impl/anon_nullifier_qurrency_transfer_batch.sol +1001 -0
  46. package/contracts/verifiers/{verifier_anon_nullifier_transferLocked.sol → impl/anon_nullifier_transfer.sol} +12 -19
  47. package/contracts/verifiers/{verifier_anon_nullifier_transferLocked_batch.sol → impl/anon_nullifier_transfer_batch.sol} +44 -51
  48. package/contracts/verifiers/impl/burn.sol +182 -0
  49. package/contracts/verifiers/impl/burn_batch.sol +238 -0
  50. package/contracts/verifiers/impl/burn_nullifier.sol +203 -0
  51. package/contracts/verifiers/impl/burn_nullifier_batch.sol +315 -0
  52. package/contracts/verifiers/impl/deposit.sol +182 -0
  53. package/contracts/verifiers/impl/deposit_kyc.sol +189 -0
  54. package/contracts/verifiers/impl/nf_anon.sol +175 -0
  55. package/contracts/verifiers/{verifier_nf_anon_nullifier_transferLocked.sol → impl/nf_anon_nullifier_transfer.sol} +6 -13
  56. package/contracts/verifiers/impl/withdraw.sol +189 -0
  57. package/contracts/verifiers/impl/withdraw_batch.sol +245 -0
  58. package/contracts/verifiers/impl/withdraw_nullifier.sol +210 -0
  59. package/contracts/verifiers/impl/withdraw_nullifier_batch.sol +322 -0
  60. package/contracts/verifiers/verifier_anon.sol +31 -186
  61. package/contracts/verifiers/verifier_anon_batch.sol +31 -298
  62. package/contracts/verifiers/verifier_anon_enc.sol +31 -263
  63. package/contracts/verifiers/verifier_anon_enc_batch.sol +31 -599
  64. package/contracts/verifiers/verifier_anon_enc_nullifier.sol +31 -284
  65. package/contracts/verifiers/verifier_anon_enc_nullifier_batch.sol +33 -676
  66. package/contracts/verifiers/verifier_anon_enc_nullifier_kyc.sol +31 -291
  67. package/contracts/verifiers/verifier_anon_enc_nullifier_kyc_batch.sol +33 -683
  68. package/contracts/verifiers/verifier_anon_enc_nullifier_non_repudiation.sol +33 -410
  69. package/contracts/verifiers/verifier_anon_enc_nullifier_non_repudiation_batch.sol +33 -1138
  70. package/contracts/verifiers/verifier_anon_nullifier_kyc_transfer.sol +33 -214
  71. package/contracts/verifiers/verifier_anon_nullifier_kyc_transferLocked.sol +33 -221
  72. package/contracts/verifiers/verifier_anon_nullifier_kyc_transferLocked_batch.sol +33 -389
  73. package/contracts/verifiers/verifier_anon_nullifier_kyc_transfer_batch.sol +33 -382
  74. package/contracts/verifiers/verifier_anon_nullifier_qurrency_transfer.sol +33 -221
  75. package/contracts/verifiers/verifier_anon_nullifier_qurrency_transfer_batch.sol +33 -389
  76. package/contracts/verifiers/verifier_anon_nullifier_transfer.sol +33 -207
  77. package/contracts/verifiers/verifier_anon_nullifier_transfer_batch.sol +33 -375
  78. package/contracts/verifiers/verifier_burn.sol +31 -179
  79. package/contracts/verifiers/verifier_burn_batch.sol +31 -235
  80. package/contracts/verifiers/verifier_burn_nullifier.sol +31 -200
  81. package/contracts/verifiers/verifier_burn_nullifier_batch.sol +31 -312
  82. package/contracts/verifiers/verifier_deposit.sol +31 -179
  83. package/contracts/verifiers/verifier_deposit_kyc.sol +34 -0
  84. package/contracts/verifiers/verifier_nf_anon.sol +31 -172
  85. package/contracts/verifiers/verifier_nf_anon_nullifier_transfer.sol +33 -179
  86. package/contracts/verifiers/verifier_withdraw.sol +31 -186
  87. package/contracts/verifiers/verifier_withdraw_batch.sol +31 -242
  88. package/contracts/verifiers/verifier_withdraw_nullifier.sol +31 -207
  89. package/contracts/verifiers/verifier_withdraw_nullifier_batch.sol +33 -319
  90. package/contracts/zeto_anon.sol +77 -231
  91. package/contracts/zeto_anon_burnable.sol +56 -12
  92. package/contracts/zeto_anon_enc.sol +93 -190
  93. package/contracts/zeto_anon_enc_nullifier.sol +249 -195
  94. package/contracts/zeto_anon_enc_nullifier_kyc.sol +51 -232
  95. package/contracts/zeto_anon_enc_nullifier_non_repudiation.sol +231 -238
  96. package/contracts/zeto_anon_nullifier.sol +164 -298
  97. package/contracts/zeto_anon_nullifier_burnable.sol +68 -18
  98. package/contracts/zeto_anon_nullifier_kyc.sol +40 -345
  99. package/contracts/zeto_anon_nullifier_qurrency.sol +218 -360
  100. package/contracts/zeto_nf_anon.sol +55 -130
  101. package/contracts/zeto_nf_anon_nullifier.sol +90 -152
  102. package/hardhat.config.ts +9 -3
  103. package/ignition/modules/lib/deps.ts +9 -6
  104. package/ignition/modules/zeto_anon_burnable.ts +8 -7
  105. package/ignition/modules/zeto_anon_enc_nullifier.ts +33 -1
  106. package/ignition/modules/zeto_anon_enc_nullifier_kyc.ts +2 -2
  107. package/ignition/modules/zeto_anon_enc_nullifier_non_repudiation.ts +2 -1
  108. package/ignition/modules/zeto_anon_nullifier.ts +30 -5
  109. package/ignition/modules/zeto_anon_nullifier_burnable.ts +52 -17
  110. package/ignition/modules/zeto_anon_nullifier_kyc.ts +27 -12
  111. package/ignition/modules/zeto_anon_nullifier_qurrency.ts +2 -1
  112. package/ignition/modules/zeto_nf_anon_nullifier.ts +16 -12
  113. package/package.json +4 -2
  114. package/scripts/deploy_cloneable.ts +19 -10
  115. package/scripts/deploy_upgradeable.ts +9 -5
  116. package/scripts/tokens/Zeto_Anon.ts +3 -3
  117. package/scripts/tokens/Zeto_AnonBurnable.ts +1 -1
  118. package/scripts/tokens/Zeto_AnonEnc.ts +12 -3
  119. package/scripts/tokens/Zeto_AnonEncNullifier.ts +7 -3
  120. package/scripts/tokens/Zeto_AnonEncNullifierKyc.ts +1 -1
  121. package/scripts/tokens/Zeto_AnonEncNullifierNonRepudiation.ts +3 -1
  122. package/scripts/tokens/Zeto_AnonNullifier.ts +3 -1
  123. package/scripts/tokens/Zeto_AnonNullifierBurnable.ts +3 -1
  124. package/scripts/tokens/Zeto_AnonNullifierKyc.ts +1 -1
  125. package/scripts/tokens/Zeto_AnonNullifierQurrency.ts +3 -1
  126. package/scripts/tokens/Zeto_NfAnon.ts +2 -2
  127. package/scripts/tokens/Zeto_NfAnonNullifier.ts +3 -2
  128. package/test/factory.ts +46 -73
  129. package/test/factory_upgradeable.ts +311 -0
  130. package/test/lib/anon_nullifier_helpers.ts +89 -0
  131. package/test/lib/anon_zeto_helpers.ts +76 -0
  132. package/test/lib/deploy.ts +3 -2
  133. package/test/lib/utils.ts +74 -35
  134. package/test/test/escrow1.ts +185 -58
  135. package/test/test/escrow2.ts +200 -107
  136. package/test/test/qurrency.ts +13 -33
  137. package/test/usdc-shielding.ts +39 -26
  138. package/test/utils.ts +144 -21
  139. package/test/zeto_anon.ts +956 -465
  140. package/test/zeto_anon_enc.ts +881 -142
  141. package/test/zeto_anon_enc_nullifier.ts +847 -38
  142. package/test/zeto_anon_enc_nullifier_kyc.ts +120 -181
  143. package/test/zeto_anon_enc_nullifier_non_repudiation.ts +77 -46
  144. package/test/zeto_anon_nullifier.ts +1212 -953
  145. package/test/zeto_anon_nullifier_kyc.ts +674 -655
  146. package/test/zeto_anon_nullifier_qurrency.ts +452 -306
  147. package/test/zeto_nf_anon.ts +737 -137
  148. package/test/zeto_nf_anon_nullifier.ts +876 -246
  149. package/contracts/lib/zeto_base.sol +0 -293
  150. package/contracts/lib/zeto_fungible_withdraw.sol +0 -132
  151. package/contracts/lib/zeto_fungible_withdraw_nullifier.sol +0 -144
  152. package/contracts/lib/zeto_nullifier.sol +0 -334
  153. package/contracts/zkDvP.sol_ +0 -273
  154. package/test/zkDvP.ts_ +0 -455
  155. /package/contracts/lib/{common.sol → common/common.sol} +0 -0
@@ -15,7 +15,13 @@
15
15
  // limitations under the License.
16
16
 
17
17
  import { ethers, network } from "hardhat";
18
- import { ContractTransactionReceipt, Signer, BigNumberish } from "ethers";
18
+ import {
19
+ ContractTransactionReceipt,
20
+ Signer,
21
+ BigNumberish,
22
+ AbiCoder,
23
+ ZeroAddress,
24
+ } from "ethers";
19
25
  import { expect } from "chai";
20
26
  import {
21
27
  loadCircuit,
@@ -39,12 +45,15 @@ import {
39
45
  doMint,
40
46
  ZERO_UTXO,
41
47
  parseUTXOEvents,
48
+ logger,
42
49
  } from "./lib/utils";
43
50
  import {
44
51
  loadProvingKeys,
45
52
  prepareDepositProof,
46
- prepareLockProof,
47
53
  prepareWithdrawProof,
54
+ encodeToBytesForDeposit,
55
+ calculateSpendHash,
56
+ calculateCancelHash,
48
57
  } from "./utils";
49
58
  import { deployZeto } from "./lib/deploy";
50
59
 
@@ -66,6 +75,12 @@ describe("Zeto based fungible token with anonymity and encryption", function ()
66
75
  let batchCircuit: any, batchProvingKey: any;
67
76
 
68
77
  before(async function () {
78
+ // skip the tests if this file is being imported from another test
79
+ // module solely to access its exported `prepareProof` / `encodeToBytes`
80
+ // helpers (mirrors the SKIP_ANON_TESTS pattern in `zeto_anon.ts`).
81
+ if (process.env.SKIP_ANON_ENC_TESTS === "true") {
82
+ this.skip();
83
+ }
69
84
  if (network.name !== "hardhat") {
70
85
  // accommodate for longer block times on public networks
71
86
  this.timeout(120000);
@@ -84,6 +99,12 @@ describe("Zeto based fungible token with anonymity and encryption", function ()
84
99
  ({ provingKeyFile: batchProvingKey } = loadProvingKeys("anon_enc_batch"));
85
100
  });
86
101
 
102
+ beforeEach(async function () {
103
+ if (process.env.SKIP_ANON_ENC_TESTS === "true") {
104
+ this.skip();
105
+ }
106
+ });
107
+
87
108
  it("(batch) mint to Alice and batch transfer 10 UTXOs honestly to Bob & Charlie then withdraw should succeed", async function () {
88
109
  // first mint the tokens for batch testing
89
110
  const inputUtxos = [];
@@ -120,9 +141,10 @@ describe("Zeto based fungible token with anonymity and encryption", function ()
120
141
  );
121
142
 
122
143
  const events = parseUTXOEvents(zeto, result.txResult!);
123
- expect(events[0].inputs).to.deep.equal(inputUtxos.map((i) => i.hash));
124
- const incomingUTXOs: any = events[0].outputs;
125
- const ecdhPublicKey = events[0].ecdhPublicKey;
144
+ const event = events[0];
145
+ expect(event.inputs).to.deep.equal(inputUtxos.map((i) => i.hash));
146
+ const incomingUTXOs: any = event.outputs;
147
+ const ecdhPublicKey = event.ecdhPublicKey;
126
148
 
127
149
  // check the non-empty output hashes are correct
128
150
  for (let i = 0; i < outputUtxos.length; i++) {
@@ -132,9 +154,9 @@ describe("Zeto based fungible token with anonymity and encryption", function ()
132
154
  ecdhPublicKey,
133
155
  );
134
156
  const plainText = poseidonDecrypt(
135
- events[0].encryptedValues.slice(4 * i, 4 * i + 4),
157
+ event.encryptedValues.slice(4 * i, 4 * i + 4),
136
158
  sharedKey,
137
- events[0].encryptionNonce,
159
+ event.encryptionNonce,
138
160
  2,
139
161
  );
140
162
  expect(plainText).to.deep.equal(
@@ -149,11 +171,6 @@ describe("Zeto based fungible token with anonymity and encryption", function ()
149
171
  expect(incomingUTXOs[i]).to.equal(hash);
150
172
  }
151
173
 
152
- // check empty values, salt and hashes are empty
153
- for (let i = outputUtxos.length; i < 10; i++) {
154
- expect(incomingUTXOs[i]).to.equal(0);
155
- }
156
-
157
174
  // mint sufficient balance in Zeto contract address for Alice to withdraw
158
175
  const mintTx = await erc20.connect(deployer).mint(zeto, 3);
159
176
  await mintTx.wait();
@@ -173,7 +190,13 @@ describe("Zeto based fungible token with anonymity and encryption", function ()
173
190
  // Alice withdraws her UTXOs to ERC20 tokens
174
191
  const tx = await zeto
175
192
  .connect(Alice.signer)
176
- .withdraw(3, inputCommitments, outputCommitments[0], encodedProof, "0x");
193
+ .withdraw(
194
+ 3,
195
+ inputCommitments,
196
+ outputCommitments[0],
197
+ encodeToBytesForWithdraw(encodedProof),
198
+ "0x",
199
+ );
177
200
  await tx.wait();
178
201
 
179
202
  // Alice checks her ERC20 balance
@@ -199,7 +222,12 @@ describe("Zeto based fungible token with anonymity and encryption", function ()
199
222
  );
200
223
  const tx2 = await zeto
201
224
  .connect(Alice.signer)
202
- .deposit(100, outputCommitments, encodedProof, "0x");
225
+ .deposit(
226
+ 100,
227
+ outputCommitments,
228
+ encodeToBytesForDeposit(encodedProof),
229
+ "0x",
230
+ );
203
231
  await tx2.wait();
204
232
  }).timeout(60000);
205
233
 
@@ -233,18 +261,19 @@ describe("Zeto based fungible token with anonymity and encryption", function ()
233
261
  // Bob uses the information in the event to recover the incoming UTXO
234
262
  // first obtain the UTXO from the transaction event
235
263
  const events = parseUTXOEvents(zeto, result.txResult!);
236
- expect(events[0].inputs).to.deep.equal([utxo1.hash, utxo2.hash]);
237
- expect(events[0].outputs).to.deep.equal([_utxo1.hash, utxo4.hash]);
238
- const incomingUTXOs: any = events[0].outputs;
264
+ const event = events[0];
265
+ expect(event.inputs).to.deep.equal([utxo1.hash, utxo2.hash]);
266
+ expect(event.outputs).to.deep.equal([_utxo1.hash, utxo4.hash]);
267
+ const incomingUTXOs: any = event.outputs;
239
268
 
240
- const ecdhPublicKey = events[0].ecdhPublicKey;
269
+ const ecdhPublicKey = event.ecdhPublicKey;
241
270
  // Bob reconstructs the shared key using his private key and ephemeral public key
242
271
 
243
272
  const sharedKey = genEcdhSharedKey(Bob.babyJubPrivateKey, ecdhPublicKey);
244
273
  const plainText = poseidonDecrypt(
245
- events[0].encryptedValues.slice(0, 4),
274
+ event.encryptedValues.slice(0, 4),
246
275
  sharedKey,
247
- events[0].encryptionNonce,
276
+ event.encryptionNonce,
248
277
  2,
249
278
  );
250
279
  expect(plainText).to.deep.equal(result.expectedPlainText.slice(0, 2));
@@ -284,7 +313,13 @@ describe("Zeto based fungible token with anonymity and encryption", function ()
284
313
  // Alice withdraws her UTXOs to ERC20 tokens
285
314
  const tx = await zeto
286
315
  .connect(Alice.signer)
287
- .withdraw(80, inputCommitments, outputCommitments[0], encodedProof, "0x");
316
+ .withdraw(
317
+ 80,
318
+ inputCommitments,
319
+ outputCommitments[0],
320
+ encodeToBytesForWithdraw(encodedProof),
321
+ "0x",
322
+ );
288
323
  await tx.wait();
289
324
 
290
325
  // Alice checks her ERC20 balance
@@ -318,7 +353,7 @@ describe("Zeto based fungible token with anonymity and encryption", function ()
318
353
  10,
319
354
  inputCommitments,
320
355
  outputCommitments[0],
321
- encodedProof,
356
+ encodeToBytesForWithdraw(encodedProof),
322
357
  "0x",
323
358
  ),
324
359
  ).rejectedWith("UTXOAlreadySpent");
@@ -371,62 +406,705 @@ describe("Zeto based fungible token with anonymity and encryption", function ()
371
406
  });
372
407
  });
373
408
 
374
- // describe("lockStates() tests", function () {
375
- // it("lockStates() should succeed when using unlocked states", async function () {
376
- // const { commitments, encodedProof } = await prepareLockProof(Alice, [utxo4, ZERO_UTXO]);
377
-
378
- // const tx = await zeto.connect(Alice.signer).lockStates(
379
- // commitments.filter((ic) => ic !== 0n), // trim off empty utxo hashes to check padding logic for batching works
380
- // encodedProof,
381
- // Bob.ethAddress, // make Bob the delegate who can spend the state (if she has the right proof)
382
- // "0x",
383
- // );
384
- // const results = await tx.wait();
385
- // console.log(`Method transfer() complete. Gas used: ${results?.gasUsed}`);
386
- // });
387
-
388
- // it("lockStates() should fail when trying to lock as non-delegate", async function () {
389
- // if (network.name !== "hardhat") {
390
- // return;
391
- // }
392
-
393
- // // Bob is the owner of the UTXO, so he can generate the right proof
394
- // const { commitments, encodedProof } = await prepareLockProof(Alice, [utxo4, ZERO_UTXO]);
395
-
396
- // // but he's no longer the delegate (Alice is) to spend the state
397
- // await expect(zeto.connect(Alice.signer).lockStates(
398
- // commitments.filter((ic) => ic !== 0n), // trim off empty utxo hashes to check padding logic for batching works
399
- // encodedProof,
400
- // Alice.ethAddress,
401
- // "0x",
402
- // )).rejectedWith(`UTXOAlreadyLocked(${utxo4.hash.toString()})`);
403
- // });
404
-
405
- // it("the original owner can NOT spend the locked state", async function () {
406
- // const utxo8 = newUTXO(5, Charlie);
407
- // const ephemeralKeypair = genKeypair();
408
- // await expect(doTransfer(Alice, [utxo4, ZERO_UTXO], [utxo8, ZERO_UTXO], [Charlie, Alice])).to.be.rejectedWith("UTXOAlreadyLocked");
409
- // });
410
-
411
- // it("the original owner can NOT withdraw the locked state", async function () {
412
- // const utxo8 = newUTXO(0, Alice);
413
- // const { inputCommitments, outputCommitments, encodedProof } =
414
- // await prepareWithdrawProof(Alice, [utxo4, ZERO_UTXO], utxo8);
415
-
416
- // await expect(zeto
417
- // .connect(Alice.signer)
418
- // .withdraw(5, inputCommitments, outputCommitments[0], encodedProof, "0x")).to.be.rejectedWith("UTXOAlreadyLocked");
419
- // });
420
-
421
- // it("the designated delegate can use the proper proof to spend the locked state", async function () {
422
- // const utxo8 = newUTXO(5, Charlie);
423
- // const ephemeralKeypair = genKeypair();
424
- // const { inputCommitments, outputCommitments, encodedProof, encryptedValues, encryptionNonce } = await prepareProof(Alice, [utxo4, ZERO_UTXO], [utxo8, ZERO_UTXO], [Charlie, Alice], ephemeralKeypair.privKey);
425
- // // Bob (in reality this is usually a contract that orchestrates a trade flow) can spend the locked state
426
- // // using the proof generated by the trade counterparty (Alice in this case)
427
- // await expect(sendTx(Bob, inputCommitments, outputCommitments, encryptedValues, encryptionNonce, encodedProof, ephemeralKeypair.pubKey)).to.be.fulfilled;
428
- // });
429
- // });
409
+ describe("ILockableCapability tests", function () {
410
+ // ABI fragments for the ZetoLockableCapability *Args payloads. These
411
+ // mirror the layout used in `zeto_anon.ts` / `zeto_anon_nullifier.ts`
412
+ // so that the cross-token contract surface stays uniform — the only
413
+ // thing that changes for the encryption-aware token is what goes
414
+ // inside the opaque `proof` blob (we wrap the Groth16 proof with the
415
+ // ECDH key, encryption nonce and encrypted values via
416
+ // `encodeToBytes` below).
417
+ const CREATE_ARGS_ABI =
418
+ "tuple(bytes32 txId, uint256[] inputs, uint256[] outputs, uint256[] lockedOutputs, bytes proof)";
419
+ const UPDATE_ARGS_ABI = "tuple(bytes32 txId)";
420
+ const DELEGATE_ARGS_ABI = "tuple(bytes32 txId)";
421
+ const SPEND_ARGS_ABI =
422
+ "tuple(bytes32 txId, uint256[] lockedOutputs, uint256[] outputs, bytes proof, bytes data)";
423
+
424
+ function encodeCreateArgs(args: {
425
+ txId: string;
426
+ inputs: BigNumberish[];
427
+ outputs: BigNumberish[];
428
+ lockedOutputs: BigNumberish[];
429
+ proof: string;
430
+ }) {
431
+ return new AbiCoder().encode([CREATE_ARGS_ABI], [args]);
432
+ }
433
+
434
+ function encodeUpdateArgs(txId: string) {
435
+ return new AbiCoder().encode([UPDATE_ARGS_ABI], [{ txId }]);
436
+ }
437
+
438
+ function encodeDelegateArgs(txId: string) {
439
+ return new AbiCoder().encode([DELEGATE_ARGS_ABI], [{ txId }]);
440
+ }
441
+
442
+ function encodeSpendArgs(args: {
443
+ txId: string;
444
+ lockedOutputs: BigNumberish[];
445
+ outputs: BigNumberish[];
446
+ proof: string;
447
+ data: string;
448
+ }) {
449
+ return new AbiCoder().encode([SPEND_ARGS_ABI], [args]);
450
+ }
451
+
452
+ function randomBytes32(): string {
453
+ return ethers.hexlify(ethers.randomBytes(32));
454
+ }
455
+
456
+ // prepareEncProofBytes wraps `prepareProof` to return ABI-encoded
457
+ // proof bytes ready to be slotted into createArgs / spendArgs.
458
+ // `Zeto_AnonEnc.constructPublicInputs` expects the proof tuple
459
+ // (uint256, uint256[2], uint256[], Commonlib.Proof) same layout
460
+ // as the regular `transfer` flow so the locked-input path reuses
461
+ // the same proof builder.
462
+ async function prepareEncProofBytes(
463
+ signer: User,
464
+ inputs: UTXO[],
465
+ outputs: UTXO[],
466
+ owners: User[],
467
+ ): Promise<string> {
468
+ const ephemeralKeypair = genKeypair();
469
+ const result = await prepareProof(
470
+ signer,
471
+ inputs,
472
+ outputs,
473
+ owners,
474
+ ephemeralKeypair.privKey,
475
+ );
476
+ return encodeToBytes(
477
+ result.encryptionNonce,
478
+ ephemeralKeypair.pubKey,
479
+ result.encryptedValues,
480
+ result.encodedProof,
481
+ );
482
+ }
483
+
484
+ describe("createLock -> updateLock -> delegateLock -> spendLock flow", function () {
485
+ let bobSourceUtxo: UTXO;
486
+ let lockedUtxo: UTXO;
487
+ let lockId: string;
488
+ let outUtxo1: UTXO;
489
+ let outUtxo2: UTXO;
490
+ let unlockHash: string;
491
+
492
+ before(async function () {
493
+ bobSourceUtxo = newUTXO(100, Bob);
494
+ await doMint(zeto, deployer, [bobSourceUtxo]);
495
+ });
496
+
497
+ it("createLock() with deterministic lockId computed from txId", async function () {
498
+ // The locked content is just a fresh UTXO of equal value held under Bob.
499
+ // The proof on the create path proves the standard transfer
500
+ // relationship: bobSourceUtxo -> lockedUtxo. The contract treats
501
+ // the locked output identically to a regular output for the purposes
502
+ // of circuit verification (they are merged into a single output set
503
+ // before constructPublicInputs), so the same anon_enc circuit is
504
+ // reused.
505
+ lockedUtxo = newUTXO(bobSourceUtxo.value!, Bob);
506
+ const proofBytes = await prepareEncProofBytes(
507
+ Bob,
508
+ [bobSourceUtxo, ZERO_UTXO],
509
+ [lockedUtxo, ZERO_UTXO],
510
+ [Bob, Bob],
511
+ );
512
+
513
+ const txId = randomBytes32();
514
+ const createArgs = encodeCreateArgs({
515
+ txId,
516
+ inputs: [bobSourceUtxo.hash],
517
+ outputs: [],
518
+ lockedOutputs: [lockedUtxo.hash],
519
+ proof: proofBytes,
520
+ });
521
+
522
+ const predicted = await zeto
523
+ .connect(Bob.signer)
524
+ .computeLockId(createArgs);
525
+ lockId = predicted;
526
+
527
+ const tx = await zeto
528
+ .connect(Bob.signer)
529
+ .createLock(createArgs, ethers.ZeroHash, ethers.ZeroHash, "0x");
530
+ const result: ContractTransactionReceipt | null = await tx.wait();
531
+ logger.debug(`createLock() complete. Gas used: ${result?.gasUsed}`);
532
+
533
+ const created = result!.logs
534
+ .map((l) => {
535
+ try {
536
+ return zeto.interface.parseLog(l as any);
537
+ } catch (_e) {
538
+ return null;
539
+ }
540
+ })
541
+ .find((p) => p && p.name === "LockCreated");
542
+ expect(created, "LockCreated event not found").to.not.be.null;
543
+ expect(created!.args.lockId).to.equal(predicted);
544
+ expect(created!.args.owner).to.equal(Bob.ethAddress);
545
+ expect(created!.args.spender).to.equal(Bob.ethAddress);
546
+ });
547
+
548
+ it("isLockActive() and getLock() reflect the newly created lock", async function () {
549
+ expect(await zeto.isLockActive(lockId)).to.equal(true);
550
+ const info = await zeto.getLock(lockId);
551
+ expect(info.owner).to.equal(Bob.ethAddress);
552
+ expect(info.spender).to.equal(Bob.ethAddress);
553
+ expect(info.spendCommitment).to.equal(ethers.ZeroHash);
554
+ expect(info.cancelCommitment).to.equal(ethers.ZeroHash);
555
+ });
556
+
557
+ it("locked() returns true for locked UTXOs and false for unlocked or spent UTXOs", async function () {
558
+ // Just-created lock: spender == owner == Bob.
559
+ expect(await zeto.locked(lockedUtxo.hash)).to.deep.equal([
560
+ true,
561
+ Bob.ethAddress,
562
+ ]);
563
+ expect((await zeto.locked(bobSourceUtxo.hash))[0]).to.be.false;
564
+ });
565
+
566
+ it("updateLock() commits the spend hash while owner == spender", async function () {
567
+ outUtxo1 = newUTXO(10, Alice);
568
+ outUtxo2 = newUTXO(90, Bob);
569
+
570
+ unlockHash = calculateSpendHash(
571
+ [lockedUtxo],
572
+ [],
573
+ [outUtxo1, outUtxo2],
574
+ "0x",
575
+ );
576
+
577
+ const tx = await zeto
578
+ .connect(Bob.signer)
579
+ .updateLock(
580
+ lockId,
581
+ encodeUpdateArgs(randomBytes32()),
582
+ unlockHash,
583
+ ethers.ZeroHash,
584
+ "0x",
585
+ );
586
+ const result = await tx.wait();
587
+ logger.debug(`updateLock() complete. Gas used: ${result?.gasUsed}`);
588
+
589
+ const info = await zeto.getLock(lockId);
590
+ expect(info.spendCommitment).to.equal(unlockHash);
591
+ });
592
+
593
+ it("delegateLock() transfers spending authority to Alice", async function () {
594
+ const tx = await zeto
595
+ .connect(Bob.signer)
596
+ .delegateLock(
597
+ lockId,
598
+ encodeDelegateArgs(randomBytes32()),
599
+ Alice.ethAddress,
600
+ "0x",
601
+ );
602
+ const result = await tx.wait();
603
+ logger.debug(`delegateLock() complete. Gas used: ${result?.gasUsed}`);
604
+
605
+ const info = await zeto.getLock(lockId);
606
+ expect(info.spender).to.equal(Alice.ethAddress);
607
+ expect(await zeto.locked(lockedUtxo.hash)).to.deep.equal([
608
+ true,
609
+ Alice.ethAddress,
610
+ ]);
611
+ });
612
+
613
+ it("the new spender can spendLock() with the matching payload", async function () {
614
+ // Bob (still the original owner of the locked UTXO) generates the
615
+ // settlement proof; Alice (the new spender) submits it.
616
+ const settleProofBytes = await prepareEncProofBytes(
617
+ Bob,
618
+ [lockedUtxo, ZERO_UTXO],
619
+ [outUtxo1, outUtxo2],
620
+ [Alice, Bob],
621
+ );
622
+
623
+ const spendArgs = encodeSpendArgs({
624
+ txId: randomBytes32(),
625
+ lockedOutputs: [],
626
+ outputs: [outUtxo1.hash, outUtxo2.hash],
627
+ proof: settleProofBytes,
628
+ data: "0x",
629
+ });
630
+
631
+ const tx = await zeto
632
+ .connect(Alice.signer)
633
+ .spendLock(lockId, spendArgs, "0x");
634
+ const result = await tx.wait();
635
+
636
+ const parsed = result!.logs
637
+ .map((l) => {
638
+ try {
639
+ return zeto.interface.parseLog(l as any);
640
+ } catch (_e) {
641
+ return null;
642
+ }
643
+ })
644
+ .filter((p) => p !== null) as ReadonlyArray<{
645
+ name: string;
646
+ args: any;
647
+ }>;
648
+ const lockSpent = parsed.find((p) => p.name === "LockSpent");
649
+ const zetoLockSpent = parsed.find((p) => p.name === "ZetoLockSpent");
650
+ expect(lockSpent, "LockSpent event not emitted").to.not.be.undefined;
651
+ expect(zetoLockSpent, "ZetoLockSpent event not emitted").to.not.be
652
+ .undefined;
653
+ expect(lockSpent!.args.lockId).to.equal(lockId);
654
+ expect(lockSpent!.args.spender).to.equal(Alice.ethAddress);
655
+
656
+ // Lock is no longer active.
657
+ expect(await zeto.isLockActive(lockId)).to.equal(false);
658
+
659
+ // Outputs are now ordinary unlocked UTXOs.
660
+ expect(await zeto.spent(outUtxo1.hash)).to.equal(1n); // UNSPENT
661
+ expect(await zeto.spent(outUtxo2.hash)).to.equal(1n); // UNSPENT
662
+
663
+ // Per-UTXO delegate projection is cleared post-consume.
664
+ expect(await zeto.locked(lockedUtxo.hash)).to.deep.equal([
665
+ false,
666
+ ZeroAddress,
667
+ ]);
668
+ });
669
+ });
670
+
671
+ describe("createLock -> cancelLock flow", function () {
672
+ let bobSourceUtxo: UTXO;
673
+ let lockedUtxo: UTXO;
674
+ let lockId: string;
675
+ let cancelHash: string;
676
+ let outUtxo1: UTXO;
677
+ let outUtxo2: UTXO;
678
+
679
+ before(async function () {
680
+ bobSourceUtxo = newUTXO(100, Bob);
681
+ await doMint(zeto, deployer, [bobSourceUtxo]);
682
+ });
683
+
684
+ it("Bob createLock() with a non-zero cancelCommitment", async function () {
685
+ lockedUtxo = newUTXO(bobSourceUtxo.value!, Bob);
686
+ const proofBytes = await prepareEncProofBytes(
687
+ Bob,
688
+ [bobSourceUtxo, ZERO_UTXO],
689
+ [lockedUtxo, ZERO_UTXO],
690
+ [Bob, Bob],
691
+ );
692
+
693
+ outUtxo1 = newUTXO(10, Alice);
694
+ outUtxo2 = newUTXO(90, Bob);
695
+ cancelHash = calculateCancelHash(
696
+ [lockedUtxo],
697
+ [],
698
+ [outUtxo1, outUtxo2],
699
+ "0x",
700
+ );
701
+
702
+ const createArgs = encodeCreateArgs({
703
+ txId: randomBytes32(),
704
+ inputs: [bobSourceUtxo.hash],
705
+ outputs: [],
706
+ lockedOutputs: [lockedUtxo.hash],
707
+ proof: proofBytes,
708
+ });
709
+ lockId = await zeto.connect(Bob.signer).computeLockId(createArgs);
710
+ const tx = await zeto
711
+ .connect(Bob.signer)
712
+ .createLock(createArgs, ethers.ZeroHash, cancelHash, "0x");
713
+ const result = await tx.wait();
714
+ logger.debug(`createLock() complete. Gas used: ${result?.gasUsed}`);
715
+ });
716
+
717
+ it("the owner can cancelLock() to reverse the lock without delegation", async function () {
718
+ const cancelProofBytes = await prepareEncProofBytes(
719
+ Bob,
720
+ [lockedUtxo, ZERO_UTXO],
721
+ [outUtxo1, outUtxo2],
722
+ [Alice, Bob],
723
+ );
724
+
725
+ const cancelArgs = encodeSpendArgs({
726
+ txId: randomBytes32(),
727
+ lockedOutputs: [],
728
+ outputs: [outUtxo1.hash, outUtxo2.hash],
729
+ proof: cancelProofBytes,
730
+ data: "0x",
731
+ });
732
+
733
+ const tx = await zeto
734
+ .connect(Bob.signer)
735
+ .cancelLock(lockId, cancelArgs, "0x");
736
+ const result = await tx.wait();
737
+
738
+ const parsed = result!.logs
739
+ .map((l) => {
740
+ try {
741
+ return zeto.interface.parseLog(l as any);
742
+ } catch (_e) {
743
+ return null;
744
+ }
745
+ })
746
+ .filter((p) => p !== null) as ReadonlyArray<{
747
+ name: string;
748
+ args: any;
749
+ }>;
750
+ const cancelled = parsed.find((p) => p.name === "LockCancelled");
751
+ const zetoCancelled = parsed.find(
752
+ (p) => p.name === "ZetoLockCancelled",
753
+ );
754
+ expect(cancelled, "LockCancelled event not emitted").to.not.be
755
+ .undefined;
756
+ expect(zetoCancelled, "ZetoLockCancelled event not emitted").to.not.be
757
+ .undefined;
758
+
759
+ expect(await zeto.isLockActive(lockId)).to.equal(false);
760
+ });
761
+ });
762
+
763
+ describe("spendLock with a payload that does not match the spend commitment fails", function () {
764
+ let bobSourceUtxo: UTXO;
765
+ let lockedUtxo: UTXO;
766
+ let lockId: string;
767
+ let expectedHash: string;
768
+
769
+ before(async function () {
770
+ bobSourceUtxo = newUTXO(100, Bob);
771
+ await doMint(zeto, deployer, [bobSourceUtxo]);
772
+ });
773
+
774
+ it("Bob createLock() then updateLock() committing a specific spend hash", async function () {
775
+ lockedUtxo = newUTXO(bobSourceUtxo.value!, Bob);
776
+ const proofBytes = await prepareEncProofBytes(
777
+ Bob,
778
+ [bobSourceUtxo, ZERO_UTXO],
779
+ [lockedUtxo, ZERO_UTXO],
780
+ [Bob, Bob],
781
+ );
782
+ const createArgs = encodeCreateArgs({
783
+ txId: randomBytes32(),
784
+ inputs: [bobSourceUtxo.hash],
785
+ outputs: [],
786
+ lockedOutputs: [lockedUtxo.hash],
787
+ proof: proofBytes,
788
+ });
789
+ lockId = await zeto.connect(Bob.signer).computeLockId(createArgs);
790
+ await (
791
+ await zeto
792
+ .connect(Bob.signer)
793
+ .createLock(createArgs, ethers.ZeroHash, ethers.ZeroHash, "0x")
794
+ ).wait();
795
+
796
+ const expectedOut1 = newUTXO(10, Alice);
797
+ const expectedOut2 = newUTXO(90, Bob);
798
+ expectedHash = calculateSpendHash(
799
+ [lockedUtxo],
800
+ [],
801
+ [expectedOut1, expectedOut2],
802
+ "0x",
803
+ );
804
+ await (
805
+ await zeto
806
+ .connect(Bob.signer)
807
+ .updateLock(
808
+ lockId,
809
+ encodeUpdateArgs(randomBytes32()),
810
+ expectedHash,
811
+ ethers.ZeroHash,
812
+ "0x",
813
+ )
814
+ ).wait();
815
+ });
816
+
817
+ it("spendLock() with a different payload reverts with InvalidUnlockHash", async function () {
818
+ if (network.name !== "hardhat") {
819
+ this.skip();
820
+ }
821
+ const wrongOut1 = newUTXO(20, Alice);
822
+ const wrongOut2 = newUTXO(80, Bob);
823
+
824
+ const settleProofBytes = await prepareEncProofBytes(
825
+ Bob,
826
+ [lockedUtxo, ZERO_UTXO],
827
+ [wrongOut1, wrongOut2],
828
+ [Alice, Bob],
829
+ );
830
+
831
+ const spendArgs = encodeSpendArgs({
832
+ txId: randomBytes32(),
833
+ lockedOutputs: [],
834
+ outputs: [wrongOut1.hash, wrongOut2.hash],
835
+ proof: settleProofBytes,
836
+ data: "0x",
837
+ });
838
+
839
+ const calculatedHash = calculateSpendHash(
840
+ [lockedUtxo],
841
+ [],
842
+ [wrongOut1, wrongOut2],
843
+ "0x",
844
+ );
845
+
846
+ await expect(
847
+ zeto.connect(Bob.signer).spendLock(lockId, spendArgs, "0x"),
848
+ )
849
+ .to.be.revertedWithCustomError(zeto, "InvalidUnlockHash")
850
+ .withArgs(expectedHash, calculatedHash);
851
+ });
852
+ });
853
+
854
+ describe("negative cases for the lock lifecycle", function () {
855
+ // These tests rely on hardhat-style revert decoding.
856
+ if (network.name !== "hardhat") {
857
+ return;
858
+ }
859
+
860
+ // freshLock mints a UTXO for `owner`, locks it, and returns the
861
+ // resulting lock metadata so each negative case can branch off
862
+ // without polluting other test scopes.
863
+ async function freshLock(
864
+ owner: User,
865
+ spendCommitment: string = ethers.ZeroHash,
866
+ cancelCommitment: string = ethers.ZeroHash,
867
+ ): Promise<{
868
+ lockId: string;
869
+ sourceUtxo: UTXO;
870
+ lockedUtxo: UTXO;
871
+ createArgs: string;
872
+ }> {
873
+ const sourceUtxo = newUTXO(100, owner);
874
+ await doMint(zeto, deployer, [sourceUtxo]);
875
+
876
+ const lockedUtxo = newUTXO(sourceUtxo.value!, owner);
877
+ const proofBytes = await prepareEncProofBytes(
878
+ owner,
879
+ [sourceUtxo, ZERO_UTXO],
880
+ [lockedUtxo, ZERO_UTXO],
881
+ [owner, owner],
882
+ );
883
+
884
+ const createArgs = encodeCreateArgs({
885
+ txId: randomBytes32(),
886
+ inputs: [sourceUtxo.hash],
887
+ outputs: [],
888
+ lockedOutputs: [lockedUtxo.hash],
889
+ proof: proofBytes,
890
+ });
891
+ const lockId = await zeto
892
+ .connect(owner.signer)
893
+ .computeLockId(createArgs);
894
+ await (
895
+ await zeto
896
+ .connect(owner.signer)
897
+ .createLock(createArgs, spendCommitment, cancelCommitment, "0x")
898
+ ).wait();
899
+ return { lockId, sourceUtxo, lockedUtxo, createArgs };
900
+ }
901
+
902
+ // Re-encoding helper: a syntactically valid spend payload that does
903
+ // NOT need to verify a real ZK proof. Tests for authorization /
904
+ // immutability assertions short-circuit before the proof is touched.
905
+ function dummySpendArgs(): string {
906
+ return encodeSpendArgs({
907
+ txId: randomBytes32(),
908
+ lockedOutputs: [],
909
+ outputs: [],
910
+ proof: "0x",
911
+ data: "0x",
912
+ });
913
+ }
914
+
915
+ it("createLock() with a duplicate txId from the same caller reverts with DuplicateLock", async function () {
916
+ const { lockId, createArgs } = await freshLock(Bob);
917
+
918
+ // Same createArgs (same txId, same caller) => same lockId =>
919
+ // DuplicateLock fires before any input or proof validation.
920
+ await expect(
921
+ zeto
922
+ .connect(Bob.signer)
923
+ .createLock(createArgs, ethers.ZeroHash, ethers.ZeroHash, "0x"),
924
+ )
925
+ .to.be.revertedWithCustomError(zeto, "DuplicateLock")
926
+ .withArgs(lockId);
927
+ });
928
+
929
+ it("updateLock() by a non-owner reverts with LockUnauthorized", async function () {
930
+ const { lockId } = await freshLock(Bob);
931
+
932
+ await expect(
933
+ zeto
934
+ .connect(Alice.signer)
935
+ .updateLock(
936
+ lockId,
937
+ encodeUpdateArgs(randomBytes32()),
938
+ ethers.ZeroHash,
939
+ ethers.ZeroHash,
940
+ "0x",
941
+ ),
942
+ )
943
+ .to.be.revertedWithCustomError(zeto, "LockUnauthorized")
944
+ .withArgs(lockId, Bob.ethAddress, Alice.ethAddress);
945
+ });
946
+
947
+ it("updateLock() prefers LockUnauthorized over LockImmutable when both apply (M-5)", async function () {
948
+ // Lock is delegated (so spender != owner -> immutable) AND the
949
+ // caller is neither owner nor spender. The contract MUST report
950
+ // LockUnauthorized, not leak the immutability state to the
951
+ // unauthorized caller.
952
+ const { lockId } = await freshLock(Bob);
953
+ await (
954
+ await zeto
955
+ .connect(Bob.signer)
956
+ .delegateLock(
957
+ lockId,
958
+ encodeDelegateArgs(randomBytes32()),
959
+ Alice.ethAddress,
960
+ "0x",
961
+ )
962
+ ).wait();
963
+
964
+ await expect(
965
+ zeto
966
+ .connect(Charlie.signer)
967
+ .updateLock(
968
+ lockId,
969
+ encodeUpdateArgs(randomBytes32()),
970
+ ethers.ZeroHash,
971
+ ethers.ZeroHash,
972
+ "0x",
973
+ ),
974
+ )
975
+ .to.be.revertedWithCustomError(zeto, "LockUnauthorized")
976
+ // spender at this point is Alice (the new delegate).
977
+ .withArgs(lockId, Alice.ethAddress, Charlie.ethAddress);
978
+ });
979
+
980
+ it("updateLock() after delegateLock() reverts with LockImmutable", async function () {
981
+ const { lockId } = await freshLock(Bob);
982
+
983
+ // Bob delegates spending authority to Alice; spender (Alice) now
984
+ // differs from owner (Bob).
985
+ await (
986
+ await zeto
987
+ .connect(Bob.signer)
988
+ .delegateLock(
989
+ lockId,
990
+ encodeDelegateArgs(randomBytes32()),
991
+ Alice.ethAddress,
992
+ "0x",
993
+ )
994
+ ).wait();
995
+
996
+ // Even Bob (the owner) can no longer mutate the commitments —
997
+ // the lock is now externally controlled and must be considered
998
+ // immutable.
999
+ await expect(
1000
+ zeto
1001
+ .connect(Bob.signer)
1002
+ .updateLock(
1003
+ lockId,
1004
+ encodeUpdateArgs(randomBytes32()),
1005
+ ethers.ZeroHash,
1006
+ ethers.ZeroHash,
1007
+ "0x",
1008
+ ),
1009
+ )
1010
+ .to.be.revertedWithCustomError(zeto, "LockImmutable")
1011
+ .withArgs(lockId);
1012
+ });
1013
+
1014
+ it("delegateLock() by a non-spender reverts with LockUnauthorized", async function () {
1015
+ const { lockId } = await freshLock(Bob);
1016
+
1017
+ await expect(
1018
+ zeto
1019
+ .connect(Alice.signer)
1020
+ .delegateLock(
1021
+ lockId,
1022
+ encodeDelegateArgs(randomBytes32()),
1023
+ Charlie.ethAddress,
1024
+ "0x",
1025
+ ),
1026
+ )
1027
+ .to.be.revertedWithCustomError(zeto, "LockUnauthorized")
1028
+ .withArgs(lockId, Bob.ethAddress, Alice.ethAddress);
1029
+ });
1030
+
1031
+ it("spendLock() by a non-spender reverts with LockUnauthorized before touching the proof", async function () {
1032
+ const { lockId } = await freshLock(Bob);
1033
+
1034
+ // Garbage proof — the onlySpender modifier MUST short-circuit
1035
+ // before any proof verification is attempted.
1036
+ await expect(
1037
+ zeto.connect(Alice.signer).spendLock(lockId, dummySpendArgs(), "0x"),
1038
+ )
1039
+ .to.be.revertedWithCustomError(zeto, "LockUnauthorized")
1040
+ .withArgs(lockId, Bob.ethAddress, Alice.ethAddress);
1041
+ });
1042
+
1043
+ it("cancelLock() by a non-spender reverts with LockUnauthorized", async function () {
1044
+ const { lockId } = await freshLock(Bob);
1045
+
1046
+ await expect(
1047
+ zeto.connect(Alice.signer).cancelLock(lockId, dummySpendArgs(), "0x"),
1048
+ )
1049
+ .to.be.revertedWithCustomError(zeto, "LockUnauthorized")
1050
+ .withArgs(lockId, Bob.ethAddress, Alice.ethAddress);
1051
+ });
1052
+
1053
+ it("after a successful spendLock(), the lock is no longer active and getLock() reverts", async function () {
1054
+ const { lockId, lockedUtxo } = await freshLock(Bob);
1055
+
1056
+ const out1 = newUTXO(10, Alice);
1057
+ const out2 = newUTXO(90, Bob);
1058
+ const settleProofBytes = await prepareEncProofBytes(
1059
+ Bob,
1060
+ [lockedUtxo, ZERO_UTXO],
1061
+ [out1, out2],
1062
+ [Alice, Bob],
1063
+ );
1064
+ const spendArgs = encodeSpendArgs({
1065
+ txId: randomBytes32(),
1066
+ lockedOutputs: [],
1067
+ outputs: [out1.hash, out2.hash],
1068
+ proof: settleProofBytes,
1069
+ data: "0x",
1070
+ });
1071
+ await (
1072
+ await zeto.connect(Bob.signer).spendLock(lockId, spendArgs, "0x")
1073
+ ).wait();
1074
+
1075
+ expect(await zeto.isLockActive(lockId)).to.equal(false);
1076
+ await expect(zeto.getLock(lockId))
1077
+ .to.be.revertedWithCustomError(zeto, "LockNotActive")
1078
+ .withArgs(lockId);
1079
+ // Re-spending a consumed lock MUST also fail — lockActive is
1080
+ // the first modifier and emits LockNotActive before
1081
+ // onlySpender has a chance to fire.
1082
+ await expect(
1083
+ zeto.connect(Bob.signer).spendLock(lockId, dummySpendArgs(), "0x"),
1084
+ )
1085
+ .to.be.revertedWithCustomError(zeto, "LockNotActive")
1086
+ .withArgs(lockId);
1087
+ });
1088
+
1089
+ it("an unlocked input flow rejects a locked UTXO with AlreadyLocked", async function () {
1090
+ // Mint a UTXO, lock it, then try to spend it via the regular
1091
+ // transfer path. The base-storage validateInputs() guards this
1092
+ // and reverts with AlreadyLocked on the locked input.
1093
+ const { lockedUtxo } = await freshLock(Bob);
1094
+
1095
+ await expect(
1096
+ doTransfer(
1097
+ Bob,
1098
+ [lockedUtxo, ZERO_UTXO],
1099
+ [newUTXO(100, Alice), ZERO_UTXO],
1100
+ [Alice, Alice],
1101
+ ),
1102
+ )
1103
+ .to.be.revertedWithCustomError(zeto, "AlreadyLocked")
1104
+ .withArgs(lockedUtxo.hash);
1105
+ });
1106
+ });
1107
+ });
430
1108
 
431
1109
  async function doTransfer(
432
1110
  signer: User,
@@ -480,71 +1158,21 @@ describe("Zeto based fungible token with anonymity and encryption", function ()
480
1158
  owners: User[],
481
1159
  ephemeralPrivateKey: BigInt,
482
1160
  ) {
483
- const inputCommitments: BigNumberish[] = inputs.map(
484
- (input) => input.hash,
485
- ) as BigNumberish[];
486
- const inputValues = inputs.map((input) => BigInt(input.value || 0n));
487
- const inputSalts = inputs.map((input) => input.salt || 0n);
488
- const outputCommitments: BigNumberish[] = outputs.map(
489
- (output) => output.hash,
490
- ) as BigNumberish[];
491
- const outputValues = outputs.map((output) => BigInt(output.value || 0n));
492
- const outputOwnerPublicKeys: BigNumberish[][] = owners.map(
493
- (owner) => owner.babyJubPublicKey,
494
- ) as BigNumberish[][];
495
- const encryptionNonce: BigNumberish = newEncryptionNonce() as BigNumberish;
496
- const encryptInputs = stringifyBigInts({
497
- encryptionNonce,
498
- ecdhPrivateKey: formatPrivKeyForBabyJub(ephemeralPrivateKey),
499
- });
500
-
501
1161
  let circuitToUse = circuit;
502
1162
  let provingKeyToUse = provingKey;
503
- let isBatch = false;
504
- if (inputCommitments.length > 2 || outputCommitments.length > 2) {
505
- isBatch = true;
1163
+ if (inputs.length > 2 || outputs.length > 2) {
506
1164
  circuitToUse = batchCircuit;
507
1165
  provingKeyToUse = batchProvingKey;
508
1166
  }
509
- const startWitnessCalculation = Date.now();
510
- const witness = await circuitToUse.calculateWTNSBin(
511
- {
512
- inputCommitments,
513
- inputValues,
514
- inputSalts,
515
- inputOwnerPrivateKey: formatPrivKeyForBabyJub(signer.babyJubPrivateKey),
516
- outputCommitments,
517
- outputValues,
518
- outputSalts: outputs.map((output) => output.salt || 0n),
519
- outputOwnerPublicKeys,
520
- ...encryptInputs,
521
- },
522
- true,
523
- );
524
- const timeWitnessCalculation = Date.now() - startWitnessCalculation;
525
-
526
- const startProofGeneration = Date.now();
527
- const { proof, publicSignals } = (await groth16.prove(
1167
+ return prepareProofModule(
1168
+ circuitToUse,
528
1169
  provingKeyToUse,
529
- witness,
530
- )) as { proof: BigNumberish[]; publicSignals: BigNumberish[] };
531
- const timeProofGeneration = Date.now() - startProofGeneration;
532
- console.log(
533
- `Witness calculation time: ${timeWitnessCalculation}ms, Proof generation time: ${timeProofGeneration}ms`,
1170
+ signer,
1171
+ inputs,
1172
+ outputs,
1173
+ owners,
1174
+ ephemeralPrivateKey,
534
1175
  );
535
-
536
- // console.log(publicSignals);
537
- const encodedProof = encodeProof(proof);
538
- const encryptedValues = isBatch
539
- ? publicSignals.slice(2, 42)
540
- : publicSignals.slice(2, 10);
541
- return {
542
- inputCommitments,
543
- outputCommitments,
544
- encryptedValues,
545
- encryptionNonce,
546
- encodedProof,
547
- };
548
1176
  }
549
1177
 
550
1178
  async function sendTx(
@@ -559,24 +1187,135 @@ describe("Zeto based fungible token with anonymity and encryption", function ()
559
1187
  const tx = await zeto.connect(signer.signer).transfer(
560
1188
  inputCommitments.filter((ic) => ic !== 0n), // trim off empty utxo hashes to check padding logic for batching works
561
1189
  outputCommitments.filter((oc) => oc !== 0n), // trim off empty utxo hashes to check padding logic for batching works
562
- encryptionNonce,
563
- ecdhPublicKey,
564
- encryptedValues,
565
- encodedProof,
1190
+ encodeToBytes(
1191
+ encryptionNonce,
1192
+ ecdhPublicKey,
1193
+ encryptedValues,
1194
+ encodedProof,
1195
+ ),
566
1196
  "0x",
567
1197
  );
568
1198
  const results: ContractTransactionReceipt | null = await tx.wait();
569
1199
 
570
1200
  for (const input of inputCommitments) {
571
1201
  if (input === 0n) continue;
572
- expect(await zeto.spent(input)).to.equal(true);
1202
+ expect(await zeto.spent(input)).to.equal(2n);
573
1203
  }
574
1204
  for (const output of outputCommitments) {
575
1205
  if (output === 0n) continue;
576
- expect(await zeto.spent(output)).to.equal(false);
1206
+ expect(await zeto.spent(output)).to.equal(1n);
577
1207
  }
578
1208
  console.log(`Method transfer() complete. Gas used: ${results?.gasUsed}`);
579
1209
 
580
1210
  return results;
581
1211
  }
582
1212
  });
1213
+
1214
+ function encodeToBytes(
1215
+ encryptionNonce: any,
1216
+ ecdhPublicKey: any,
1217
+ encryptedValues: any,
1218
+ proof: any,
1219
+ ) {
1220
+ const bytes = new AbiCoder().encode(
1221
+ [
1222
+ "uint256 encryptionNonce",
1223
+ "uint256[2] ecdhPublicKey",
1224
+ "uint256[] encryptedValues",
1225
+ "tuple(uint256[2] pA, uint256[2][2] pB, uint256[2] pC)",
1226
+ ],
1227
+ [encryptionNonce, ecdhPublicKey, encryptedValues, proof],
1228
+ );
1229
+ // console.log("Encryption nonce:", encryptionNonce);
1230
+ // console.log("ECDH public key:", ecdhPublicKey);
1231
+ // console.log("Encrypted values:", encryptedValues);
1232
+ // console.log("Proof:", proof);
1233
+ // console.log("Encoded proof:", bytes);
1234
+ return bytes;
1235
+ }
1236
+
1237
+ function encodeToBytesForWithdraw(proof: any) {
1238
+ return new AbiCoder().encode(
1239
+ ["tuple(uint256[2] pA, uint256[2][2] pB, uint256[2] pC)"],
1240
+ [proof],
1241
+ );
1242
+ }
1243
+
1244
+ // Module-scope twin of the in-describe `prepareProof`. Lifted so that other
1245
+ // test suites (notably `zeto_anon_enc_nullifier.ts`) can reuse it for
1246
+ // locked-input proofs that need to verify against the `anon_enc` circuit
1247
+ // rather than a nullifier-aware encryption circuit. Mirrors the
1248
+ // {prepareProof,encodeToBytes} export pattern at the bottom of
1249
+ // `zeto_anon.ts`.
1250
+ async function prepareProofModule(
1251
+ circuit: any,
1252
+ provingKey: any,
1253
+ signer: User,
1254
+ inputs: UTXO[],
1255
+ outputs: UTXO[],
1256
+ owners: User[],
1257
+ ephemeralPrivateKey: BigInt,
1258
+ ) {
1259
+ const inputCommitments: BigNumberish[] = inputs.map(
1260
+ (input) => input.hash,
1261
+ ) as BigNumberish[];
1262
+ const inputValues = inputs.map((input) => BigInt(input.value || 0n));
1263
+ const inputSalts = inputs.map((input) => input.salt || 0n);
1264
+ const outputCommitments: BigNumberish[] = outputs.map(
1265
+ (output) => output.hash,
1266
+ ) as BigNumberish[];
1267
+ const outputValues = outputs.map((output) => BigInt(output.value || 0n));
1268
+ const outputOwnerPublicKeys: BigNumberish[][] = owners.map(
1269
+ (owner) => owner.babyJubPublicKey,
1270
+ ) as BigNumberish[][];
1271
+ const encryptionNonce: BigNumberish = newEncryptionNonce() as BigNumberish;
1272
+ const encryptInputs = stringifyBigInts({
1273
+ encryptionNonce,
1274
+ ecdhPrivateKey: formatPrivKeyForBabyJub(ephemeralPrivateKey),
1275
+ });
1276
+
1277
+ const startWitnessCalculation = Date.now();
1278
+ const witness = await circuit.calculateWTNSBin(
1279
+ {
1280
+ inputCommitments,
1281
+ inputValues,
1282
+ inputSalts,
1283
+ inputOwnerPrivateKey: formatPrivKeyForBabyJub(signer.babyJubPrivateKey),
1284
+ outputCommitments,
1285
+ outputValues,
1286
+ outputSalts: outputs.map((output) => output.salt || 0n),
1287
+ outputOwnerPublicKeys,
1288
+ ...encryptInputs,
1289
+ },
1290
+ true,
1291
+ );
1292
+ const timeWitnessCalculation = Date.now() - startWitnessCalculation;
1293
+
1294
+ const startProofGeneration = Date.now();
1295
+ const { proof, publicSignals } = (await groth16.prove(
1296
+ provingKey,
1297
+ witness,
1298
+ )) as { proof: BigNumberish[]; publicSignals: BigNumberish[] };
1299
+ const timeProofGeneration = Date.now() - startProofGeneration;
1300
+ console.log(
1301
+ `Witness calculation time: ${timeWitnessCalculation}ms, Proof generation time: ${timeProofGeneration}ms`,
1302
+ );
1303
+
1304
+ // The encryption circuit emits 4 encrypted-values per output commitment
1305
+ // (poseidon-cipher chunked into 4 field elements). publicSignals[0..2]
1306
+ // are the ECDH public key, so the encrypted blob starts at index 2.
1307
+ const encryptedValues = publicSignals.slice(2, 2 + 4 * outputs.length);
1308
+ const encodedProof = encodeProof(proof);
1309
+ return {
1310
+ inputCommitments,
1311
+ outputCommitments,
1312
+ encryptedValues,
1313
+ encryptionNonce,
1314
+ encodedProof,
1315
+ };
1316
+ }
1317
+
1318
+ module.exports = {
1319
+ prepareProof: prepareProofModule,
1320
+ encodeToBytes,
1321
+ };