@lfdecentralizedtrust/zeto-contracts 0.2.2 → 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 (152) hide show
  1. package/README.md +70 -162
  2. package/contracts/factory.sol +30 -34
  3. package/contracts/factory_upgradeable.sol +11 -7
  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 +74 -26
  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 +6 -1
  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 +217 -361
  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 +8 -0
  104. package/ignition/modules/zeto_anon_burnable.ts +8 -7
  105. package/ignition/modules/zeto_anon_enc_nullifier.ts +31 -0
  106. package/ignition/modules/zeto_anon_enc_nullifier_kyc.ts +2 -2
  107. package/ignition/modules/zeto_anon_nullifier.ts +28 -4
  108. package/ignition/modules/zeto_anon_nullifier_burnable.ts +50 -16
  109. package/ignition/modules/zeto_anon_nullifier_kyc.ts +27 -12
  110. package/ignition/modules/zeto_nf_anon_nullifier.ts +14 -10
  111. package/package.json +4 -2
  112. package/scripts/deploy_cloneable.ts +19 -10
  113. package/scripts/deploy_upgradeable.ts +9 -5
  114. package/scripts/tokens/Zeto_Anon.ts +3 -3
  115. package/scripts/tokens/Zeto_AnonBurnable.ts +1 -1
  116. package/scripts/tokens/Zeto_AnonEnc.ts +12 -3
  117. package/scripts/tokens/Zeto_AnonEncNullifier.ts +5 -3
  118. package/scripts/tokens/Zeto_AnonEncNullifierKyc.ts +1 -1
  119. package/scripts/tokens/Zeto_AnonEncNullifierNonRepudiation.ts +1 -1
  120. package/scripts/tokens/Zeto_AnonNullifier.ts +1 -1
  121. package/scripts/tokens/Zeto_AnonNullifierBurnable.ts +1 -1
  122. package/scripts/tokens/Zeto_AnonNullifierKyc.ts +1 -1
  123. package/scripts/tokens/Zeto_AnonNullifierQurrency.ts +1 -1
  124. package/scripts/tokens/Zeto_NfAnon.ts +2 -2
  125. package/scripts/tokens/Zeto_NfAnonNullifier.ts +1 -1
  126. package/test/factory.ts +46 -73
  127. package/test/lib/anon_nullifier_helpers.ts +89 -0
  128. package/test/lib/anon_zeto_helpers.ts +76 -0
  129. package/test/lib/deploy.ts +3 -2
  130. package/test/lib/utils.ts +74 -35
  131. package/test/test/escrow1.ts +185 -58
  132. package/test/test/escrow2.ts +200 -107
  133. package/test/test/qurrency.ts +13 -33
  134. package/test/usdc-shielding.ts +39 -26
  135. package/test/utils.ts +144 -21
  136. package/test/zeto_anon.ts +956 -465
  137. package/test/zeto_anon_enc.ts +881 -142
  138. package/test/zeto_anon_enc_nullifier.ts +847 -38
  139. package/test/zeto_anon_enc_nullifier_kyc.ts +120 -181
  140. package/test/zeto_anon_enc_nullifier_non_repudiation.ts +77 -46
  141. package/test/zeto_anon_nullifier.ts +1212 -953
  142. package/test/zeto_anon_nullifier_kyc.ts +674 -655
  143. package/test/zeto_anon_nullifier_qurrency.ts +452 -306
  144. package/test/zeto_nf_anon.ts +737 -137
  145. package/test/zeto_nf_anon_nullifier.ts +876 -246
  146. package/contracts/lib/zeto_base.sol +0 -293
  147. package/contracts/lib/zeto_fungible_withdraw.sol +0 -132
  148. package/contracts/lib/zeto_fungible_withdraw_nullifier.sol +0 -144
  149. package/contracts/lib/zeto_nullifier.sol +0 -340
  150. package/contracts/zkDvP.sol_ +0 -273
  151. package/test/zkDvP.ts_ +0 -455
  152. /package/contracts/lib/{common.sol → common/common.sol} +0 -0
package/test/zeto_anon.ts CHANGED
@@ -14,12 +14,17 @@
14
14
  // See the License for the specific language governing permissions and
15
15
  // limitations under the License.
16
16
 
17
- import { ethers, network } from "hardhat";
18
- import { Signer, BigNumberish, AddressLike, ZeroAddress } from "ethers";
17
+ import { ethers, network, upgrades } from "hardhat";
18
+ import {
19
+ Signer,
20
+ BigNumberish,
21
+ AddressLike,
22
+ ZeroAddress,
23
+ AbiCoder,
24
+ ContractTransactionReceipt,
25
+ } from "ethers";
19
26
  import { expect } from "chai";
20
- import { loadCircuit, encodeProof, Poseidon } from "zeto-js";
21
- import { groth16 } from "snarkjs";
22
- import { formatPrivKeyForBabyJub, stringifyBigInts } from "maci-crypto";
27
+ import { loadCircuit, Poseidon } from "zeto-js";
23
28
  import {
24
29
  User,
25
30
  UTXO,
@@ -28,15 +33,21 @@ import {
28
33
  doMint,
29
34
  parseUTXOEvents,
30
35
  ZERO_UTXO,
36
+ logger,
31
37
  } from "./lib/utils";
32
38
  import {
33
39
  loadProvingKeys,
34
40
  prepareDepositProof,
35
41
  prepareWithdrawProof,
36
42
  prepareBurnProof,
43
+ inflateUtxos,
44
+ inflateOwners,
45
+ calculateSpendHash,
46
+ calculateCancelHash,
37
47
  } from "./utils";
38
48
  import { Zeto_Anon, Zeto_AnonBurnable } from "../typechain-types";
39
49
  import { deployZeto } from "./lib/deploy";
50
+ import { prepareProof, encodeToBytes } from "./lib/anon_zeto_helpers";
40
51
 
41
52
  const ZERO_PUBKEY = [0n, 0n];
42
53
  const poseidonHash = Poseidon.poseidon4;
@@ -59,6 +70,9 @@ describe("Zeto based fungible token with anonymity without encryption or nullifi
59
70
  let batchCircuit: any, batchProvingKey: any;
60
71
 
61
72
  before(async function () {
73
+ if (process.env.SKIP_ANON_TESTS === "true") {
74
+ this.skip();
75
+ }
62
76
  if (network.name !== "hardhat") {
63
77
  // accommodate for longer block times on public networks
64
78
  this.timeout(120000);
@@ -79,92 +93,220 @@ describe("Zeto based fungible token with anonymity without encryption or nullifi
79
93
  ({ provingKeyFile: batchProvingKey } = loadProvingKeys("anon_batch"));
80
94
  });
81
95
 
96
+ beforeEach(async function () {
97
+ if (process.env.SKIP_ANON_TESTS === "true") {
98
+ this.skip();
99
+ }
100
+ });
101
+
82
102
  it("has 4 decimals", async function () {
83
103
  const decimals = await zeto.decimals();
84
104
  expect(decimals).to.equal(4, "Decimals should be 4");
85
105
  });
86
106
 
87
- it("(batch) mint to Alice and batch transfer 10 UTXOs honestly to Bob & Charlie then withdraw should succeed", async function () {
88
- // first mint the tokens for batch testing
89
- const inputUtxos = [];
90
- for (let i = 0; i < 10; i++) {
91
- // mint 10 utxos
92
- inputUtxos.push(newUTXO(1, Alice));
93
- }
94
- await doMint(zeto, deployer, inputUtxos);
95
-
96
- const aliceUTXOsToBeWithdrawn = [
97
- newUTXO(1, Alice),
98
- newUTXO(1, Alice),
99
- newUTXO(1, Alice),
100
- ];
101
- // Alice proposes the output UTXOs, 1 utxo to bob, 1 utxo to charlie and 3 utxos to alice
102
- const _bOut1 = newUTXO(6, Bob);
103
- const _bOut2 = newUTXO(1, Charlie);
104
-
105
- const outputUtxos = [_bOut1, _bOut2, ...aliceUTXOsToBeWithdrawn];
106
- const outputOwners = [Bob, Charlie, Alice, Alice, Alice];
107
- const inflatedOutputUtxos = [...outputUtxos];
108
- const inflatedOutputOwners = [...outputOwners];
109
- for (let i = 0; i < 10 - outputUtxos.length; i++) {
110
- inflatedOutputUtxos.push(ZERO_UTXO);
111
- inflatedOutputOwners.push(Bob);
112
- }
107
+ it("non-owner should not be able to mint", async function () {
108
+ const utxo1 = newUTXO(10, Alice);
109
+ const utxo2 = newUTXO(20, Alice);
110
+ await expect(doMint(zeto, Alice.signer, [utxo1, utxo2])).to.be.rejectedWith(
111
+ "OwnableUnauthorizedAccount",
112
+ );
113
+ });
113
114
 
114
- // Alice transfers UTXOs to Bob and Charlie
115
- const result = await doTransfer(
116
- Alice,
117
- inputUtxos,
118
- inflatedOutputUtxos,
119
- inflatedOutputOwners,
115
+ // H-2: implementation contracts must be initialization-locked so an
116
+ // attacker cannot call initialize() directly on the impl, become its
117
+ // owner, and then upgradeTo(any) via _authorizeUpgrade (the OZ
118
+ // "implementation takeover" pattern, CVE-2022-35961 family). We test
119
+ // against the *actual deployed* implementation (read from the EIP-1967
120
+ // impl slot on the proxy) so this asserts the production deployment
121
+ // path produces a locked impl, not just that a redeployed contract
122
+ // would be locked.
123
+ it("initialize() reverts on the bare Zeto_Anon implementation contract", async function () {
124
+ const implAddress = await upgrades.erc1967.getImplementationAddress(
125
+ await zeto.getAddress(),
120
126
  );
127
+ const impl = await ethers.getContractAt("Zeto_Anon", implAddress);
128
+ await expect(
129
+ impl.initialize("Z", "Z", await Alice.signer.getAddress(), {
130
+ verifier: ZeroAddress,
131
+ depositVerifier: ZeroAddress,
132
+ withdrawVerifier: ZeroAddress,
133
+ lockVerifier: ZeroAddress,
134
+ burnVerifier: ZeroAddress,
135
+ batchVerifier: ZeroAddress,
136
+ batchWithdrawVerifier: ZeroAddress,
137
+ batchLockVerifier: ZeroAddress,
138
+ batchBurnVerifier: ZeroAddress,
139
+ }),
140
+ ).to.be.revertedWithCustomError(impl, "InvalidInitialization");
141
+ });
121
142
 
122
- const events = parseUTXOEvents(zeto, result);
123
- const incomingUTXOs: any = events[0].outputs;
124
- // check the non-empty output hashes are correct
125
- for (let i = 0; i < outputUtxos.length; i++) {
126
- // Bob uses the information received from Alice to reconstruct the UTXO sent to him
127
- const receivedValue = outputUtxos[i].value;
128
- const receivedSalt = outputUtxos[i].salt;
129
- const hash = poseidonHash([
130
- BigInt(receivedValue),
131
- receivedSalt,
132
- outputOwners[i].babyJubPublicKey[0],
133
- outputOwners[i].babyJubPublicKey[1],
134
- ]);
135
- expect(incomingUTXOs[i]).to.equal(hash);
136
- }
143
+ it("initialize() reverts on the bare Zeto_AnonBurnable implementation contract", async function () {
144
+ const implAddress = await upgrades.erc1967.getImplementationAddress(
145
+ await zetoBurnable.getAddress(),
146
+ );
147
+ const impl = await ethers.getContractAt("Zeto_AnonBurnable", implAddress);
148
+ await expect(
149
+ impl.initialize("Z", "Z", await Alice.signer.getAddress(), {
150
+ verifier: ZeroAddress,
151
+ depositVerifier: ZeroAddress,
152
+ withdrawVerifier: ZeroAddress,
153
+ lockVerifier: ZeroAddress,
154
+ burnVerifier: ZeroAddress,
155
+ batchVerifier: ZeroAddress,
156
+ batchWithdrawVerifier: ZeroAddress,
157
+ batchLockVerifier: ZeroAddress,
158
+ batchBurnVerifier: ZeroAddress,
159
+ }),
160
+ ).to.be.revertedWithCustomError(impl, "InvalidInitialization");
161
+ });
137
162
 
138
- // check empty hashes are empty
139
- for (let i = outputUtxos.length; i < 10; i++) {
140
- expect(incomingUTXOs[i]).to.equal(0);
141
- }
163
+ describe("administrative invariants", function () {
164
+ it("setERC20() rejects the zero address", async function () {
165
+ // Use the burnable proxy as a stand-in: it has not had its ERC20
166
+ // bound during deployZeto (only the non-burnable Zeto_Anon does),
167
+ // so we are exercising the not-yet-set branch.
168
+ await expect(
169
+ zetoBurnable.connect(deployer).setERC20(ZeroAddress),
170
+ ).to.be.revertedWithCustomError(zetoBurnable, "ZeroERC20Address");
171
+ });
142
172
 
143
- // mint sufficient balance in Zeto contract address for Alice to withdraw
144
- const mintTx = await erc20.connect(deployer).mint(zeto, 3);
145
- await mintTx.wait();
146
- const startingBalance = await erc20.balanceOf(Alice.ethAddress);
173
+ it("setERC20() is one-shot: a second call reverts with ERC20AlreadySet", async function () {
174
+ // `zeto` already had setERC20(erc20) wired up in deployZeto, so the
175
+ // backing token is bound. Any subsequent call -- even by the owner,
176
+ // even to the same address -- must revert.
177
+ const current = await erc20.getAddress();
178
+ await expect(zeto.connect(deployer).setERC20(current))
179
+ .to.be.revertedWithCustomError(zeto, "ERC20AlreadySet")
180
+ .withArgs(current);
181
+ });
147
182
 
148
- // Alice generates the nullifiers for the UTXOs to be spent
149
- const inflatedWithdrawInputs = [...aliceUTXOsToBeWithdrawn];
183
+ it("ownership transfer is two-step (Ownable2Step)", async function () {
184
+ // Stage Alice as the pending owner. The current owner (deployer)
185
+ // remains in control until Alice accepts.
186
+ const aliceAddr = await Alice.signer.getAddress();
187
+ const deployerAddr = await deployer.getAddress();
188
+ await (await zeto.connect(deployer).transferOwnership(aliceAddr)).wait();
189
+ expect(await zeto.owner()).to.equal(deployerAddr);
190
+ expect(await zeto.pendingOwner()).to.equal(aliceAddr);
150
191
 
151
- // Alice generates inclusion proofs for the UTXOs to be spent
192
+ // Anyone other than the pending owner accepting must revert.
193
+ await expect(
194
+ zeto.connect(Bob.signer).acceptOwnership(),
195
+ ).to.be.revertedWithCustomError(zeto, "OwnableUnauthorizedAccount");
196
+
197
+ // Alice accepts and the swap completes atomically.
198
+ await (await zeto.connect(Alice.signer).acceptOwnership()).wait();
199
+ expect(await zeto.owner()).to.equal(aliceAddr);
200
+
201
+ // Restore the original owner so subsequent tests are unaffected.
202
+ await (
203
+ await zeto.connect(Alice.signer).transferOwnership(deployerAddr)
204
+ ).wait();
205
+ await (await zeto.connect(deployer).acceptOwnership()).wait();
206
+ expect(await zeto.owner()).to.equal(deployerAddr);
207
+ });
152
208
 
153
- for (let i = aliceUTXOsToBeWithdrawn.length; i < 10; i++) {
154
- inflatedWithdrawInputs.push(ZERO_UTXO);
155
- }
156
- const { inputCommitments, outputCommitments, encodedProof } =
157
- await prepareWithdrawProof(Alice, inflatedWithdrawInputs, ZERO_UTXO);
209
+ it("computeSpendHash() and computeCancelHash() live in disjoint hash spaces", async function () {
210
+ // Defense-in-depth from M-7. Same payload must produce different
211
+ // commitments under the spend-vs-cancel domain so a spender cannot
212
+ // transpose a payload between spendLock and cancelLock.
213
+ const utxo = newUTXO(1, Alice);
214
+ const out = newUTXO(1, Bob);
215
+ const lockedInputs = [utxo.hash];
216
+ const lockedOutputs: bigint[] = [];
217
+ const outputs = [out.hash];
218
+ const data = "0x";
219
+ const spend = await zeto.computeSpendHash(
220
+ lockedInputs,
221
+ lockedOutputs,
222
+ outputs,
223
+ data,
224
+ );
225
+ const cancel = await zeto.computeCancelHash(
226
+ lockedInputs,
227
+ lockedOutputs,
228
+ outputs,
229
+ data,
230
+ );
231
+ expect(spend).to.not.equal(cancel);
232
+ // Mirror of off-chain helpers used by other tests.
233
+ expect(spend).to.equal(calculateSpendHash([utxo], [], [out], data));
234
+ expect(cancel).to.equal(calculateCancelHash([utxo], [], [out], data));
235
+ });
236
+ });
158
237
 
159
- // Alice withdraws her UTXOs to ERC20 tokens
160
- const tx = await zeto
161
- .connect(Alice.signer)
162
- .withdraw(3, inputCommitments, outputCommitments[0], encodedProof, "0x");
163
- await tx.wait();
238
+ describe("batch transfers", () => {
239
+ let inputUtxos: UTXO[];
240
+ let outputUtxos: UTXO[];
241
+ let outputOwners: User[];
242
+ let aliceUTXOsToBeWithdrawn: UTXO[];
243
+ let txResult: ContractTransactionReceipt | null;
244
+
245
+ it("mint to Alice 10 UTXOs", async () => {
246
+ inputUtxos = [];
247
+ for (let i = 0; i < 10; i++) {
248
+ inputUtxos.push(newUTXO(1, Alice));
249
+ }
250
+ await doMint(zeto, deployer, inputUtxos);
251
+ });
164
252
 
165
- // Alice checks her ERC20 balance
166
- const endingBalance = await erc20.balanceOf(Alice.ethAddress);
167
- expect(endingBalance - startingBalance).to.be.equal(3);
253
+ it("transfer 10 UTXOs honestly to Bob & Charlie should succeed", async function () {
254
+ aliceUTXOsToBeWithdrawn = [
255
+ newUTXO(1, Alice),
256
+ newUTXO(1, Alice),
257
+ newUTXO(1, Alice),
258
+ ];
259
+ const _bOut1 = newUTXO(6, Bob);
260
+ const _bOut2 = newUTXO(1, Charlie);
261
+
262
+ outputUtxos = [_bOut1, _bOut2, ...aliceUTXOsToBeWithdrawn];
263
+ outputOwners = [Bob, Charlie, Alice, Alice, Alice];
264
+
265
+ txResult = await doTransfer(Alice, inputUtxos, outputUtxos, outputOwners);
266
+ });
267
+
268
+ it("check the non-empty output hashes are correct", async function () {
269
+ const events = parseUTXOEvents(zeto, txResult);
270
+ const incomingUTXOs: any = events[0].outputs;
271
+ for (let i = 0; i < outputUtxos.length; i++) {
272
+ const receivedValue = outputUtxos[i].value;
273
+ const receivedSalt = outputUtxos[i].salt;
274
+ const hash = poseidonHash([
275
+ BigInt(receivedValue),
276
+ receivedSalt,
277
+ outputOwners[i].babyJubPublicKey[0],
278
+ outputOwners[i].babyJubPublicKey[1],
279
+ ]);
280
+ expect(incomingUTXOs[i]).to.equal(hash);
281
+ }
282
+ });
283
+
284
+ it("withdraw 3 UTXOs to ERC20 tokens", async function () {
285
+ const mintTx = await erc20.connect(deployer).mint(zeto, 3);
286
+ await mintTx.wait();
287
+ const startingBalance = await erc20.balanceOf(Alice.ethAddress);
288
+
289
+ const inflatedWithdrawInputs = [...aliceUTXOsToBeWithdrawn];
290
+ for (let i = aliceUTXOsToBeWithdrawn.length; i < 10; i++) {
291
+ inflatedWithdrawInputs.push(ZERO_UTXO);
292
+ }
293
+ const { inputCommitments, outputCommitments, encodedProof } =
294
+ await prepareWithdrawProof(Alice, inflatedWithdrawInputs, ZERO_UTXO);
295
+
296
+ const tx = await zeto
297
+ .connect(Alice.signer)
298
+ .withdraw(
299
+ 3,
300
+ inputCommitments,
301
+ outputCommitments[0],
302
+ encodeToBytes(encodedProof),
303
+ "0x",
304
+ );
305
+ await tx.wait();
306
+
307
+ const endingBalance = await erc20.balanceOf(Alice.ethAddress);
308
+ expect(endingBalance - startingBalance).to.be.equal(3);
309
+ });
168
310
  });
169
311
 
170
312
  it("mint ERC20 tokens to Alice to deposit to Zeto should succeed", async function () {
@@ -185,26 +327,24 @@ describe("Zeto based fungible token with anonymity without encryption or nullifi
185
327
  );
186
328
  const tx2 = await zeto
187
329
  .connect(Alice.signer)
188
- .deposit(100, outputCommitments, encodedProof, "0x");
189
- await tx2.wait();
330
+ .deposit(100, outputCommitments, encodeToBytes(encodedProof), "0x");
331
+ const result = await tx2.wait();
332
+ logger.debug(`Method deposit() complete. Gas used: ${result?.gasUsed}`);
190
333
  });
191
334
 
192
335
  it("mint to Alice and transfer UTXOs honestly to Bob should succeed", async function () {
336
+ this.timeout(60000);
193
337
  const startingBalance = await erc20.balanceOf(Alice.ethAddress);
194
- // first the authority mints UTXOs to Alice
195
338
  utxo1 = newUTXO(10, Alice);
196
339
  utxo2 = newUTXO(20, Alice);
197
340
  await doMint(zeto, deployer, [utxo1, utxo2]);
198
341
 
199
- // check the private mint activity is not exposed in the ERC20 contract
200
342
  const afterMintBalance = await erc20.balanceOf(Alice.ethAddress);
201
343
  expect(afterMintBalance).to.equal(startingBalance);
202
344
 
203
- // Alice proposes the output UTXOs
204
345
  const _txo3 = newUTXO(25, Bob);
205
346
  utxo4 = newUTXO(5, Alice, _txo3.salt);
206
347
 
207
- // Alice transfers UTXOs to Bob
208
348
  const result = await doTransfer(
209
349
  Alice,
210
350
  [utxo1, utxo2],
@@ -212,16 +352,11 @@ describe("Zeto based fungible token with anonymity without encryption or nullifi
212
352
  [Bob, Alice],
213
353
  );
214
354
 
215
- // check the private transfer activity is not exposed in the ERC20 contract
216
355
  const afterTransferBalance = await erc20.balanceOf(Alice.ethAddress);
217
356
  expect(afterTransferBalance).to.equal(startingBalance);
218
357
 
219
- // Bob reconstructs the UTXO from off-chain secure message channels with Alice
220
- // first obtain the UTXOs from the transaction event
221
358
  const events = parseUTXOEvents(zeto, result);
222
359
  const incomingUTXOs: any = events[0].outputs;
223
-
224
- // Bob uses the information received from Alice to reconstruct the UTXO sent to him
225
360
  const receivedValue = 25;
226
361
  const receivedSalt = _txo3.salt;
227
362
  const hash = poseidonHash([
@@ -232,28 +367,22 @@ describe("Zeto based fungible token with anonymity without encryption or nullifi
232
367
  ]);
233
368
  expect(incomingUTXOs[0]).to.equal(hash);
234
369
 
235
- // now Bob can reconstruct the UTXO using the information received from Alice
236
370
  utxo3 = newUTXO(receivedValue, Bob, receivedSalt);
237
- }).timeout(60000);
371
+ });
238
372
 
239
373
  it("Bob transfers UTXOs, previously received from Alice, honestly to Charlie should succeed", async function () {
240
374
  const startingBalance = await erc20.balanceOf(Alice.ethAddress);
241
- // give Bob another UTXO to be able to spend
242
375
  const _utxo1 = newUTXO(20, Bob);
243
376
  await doMint(zeto, deployer, [_utxo1]);
244
377
 
245
- // check the private mint activity is not exposed in the ERC20 contract
246
378
  const afterMintBalance = await erc20.balanceOf(Alice.ethAddress);
247
379
  expect(afterMintBalance).to.equal(startingBalance);
248
380
 
249
- // propose the output UTXOs
250
381
  const _utxo2 = newUTXO(30, Charlie);
251
382
  utxo7 = newUTXO(15, Bob);
252
383
 
253
- // Bob should be able to spend the UTXO that was reconstructed from the previous transaction
254
384
  await doTransfer(Bob, [utxo3, _utxo1], [_utxo2, utxo7], [Charlie, Bob]);
255
385
 
256
- // check the private transfer activity is not exposed in the ERC20 contract
257
386
  const afterTransferBalance = await erc20.balanceOf(Alice.ethAddress);
258
387
  expect(afterTransferBalance).to.equal(startingBalance);
259
388
  });
@@ -261,19 +390,23 @@ describe("Zeto based fungible token with anonymity without encryption or nullifi
261
390
  it("Alice withdraws her UTXOs to ERC20 tokens should succeed", async function () {
262
391
  const startingBalance = await erc20.balanceOf(Alice.ethAddress);
263
392
 
264
- // Alice proposes the output ERC20 tokens
265
393
  const outputCommitment = newUTXO(20, Alice);
266
394
 
267
395
  const { inputCommitments, outputCommitments, encodedProof } =
268
396
  await prepareWithdrawProof(Alice, [utxo100, ZERO_UTXO], outputCommitment);
269
397
 
270
- // Alice withdraws her UTXOs to ERC20 tokens
271
398
  const tx = await zeto
272
399
  .connect(Alice.signer)
273
- .withdraw(80, inputCommitments, outputCommitments[0], encodedProof, "0x");
274
- await tx.wait();
400
+ .withdraw(
401
+ 80,
402
+ inputCommitments,
403
+ outputCommitments[0],
404
+ encodeToBytes(encodedProof),
405
+ "0x",
406
+ );
407
+ const result = await tx.wait();
408
+ logger.debug(`Method withdraw() complete. Gas used: ${result?.gasUsed}`);
275
409
 
276
- // Alice checks her ERC20 balance
277
410
  const endingBalance = await erc20.balanceOf(Alice.ethAddress);
278
411
  expect(endingBalance - startingBalance).to.be.equal(80);
279
412
  });
@@ -281,31 +414,24 @@ describe("Zeto based fungible token with anonymity without encryption or nullifi
281
414
  it("Test support for large values, such as when using 18 decimals", async function () {
282
415
  const EighteenDecimals = 10 ** 18;
283
416
 
284
- // first the authority mints UTXOs to Alice
285
- const utxo1 = newUTXO(10 * EighteenDecimals, Alice);
286
- const utxo2 = newUTXO(20 * EighteenDecimals, Alice);
287
- await doMint(zeto, deployer, [utxo1, utxo2]);
417
+ const utxoA = newUTXO(10 * EighteenDecimals, Alice);
418
+ const utxoB = newUTXO(20 * EighteenDecimals, Alice);
419
+ await doMint(zeto, deployer, [utxoA, utxoB]);
288
420
 
289
- // Alice proposes the output UTXOs
290
- const utxo3 = newUTXO(25 * EighteenDecimals, Bob);
291
- const utxo4 = newUTXO(5 * EighteenDecimals, Alice);
421
+ const utxoC = newUTXO(25 * EighteenDecimals, Bob);
422
+ const utxoD = newUTXO(5 * EighteenDecimals, Alice);
292
423
 
293
- // Alice transfers UTXOs to Bob
294
424
  const result = await doTransfer(
295
425
  Alice,
296
- [utxo1, utxo2],
297
- [utxo3, utxo4],
426
+ [utxoA, utxoB],
427
+ [utxoC, utxoD],
298
428
  [Bob, Alice],
299
429
  );
300
430
 
301
- // Bob reconstructs the UTXO from off-chain secure message channels with Alice
302
- // first obtain the UTXOs from the transaction event
303
431
  const events = parseUTXOEvents(zeto, result);
304
432
  const incomingUTXOs: any = events[0].outputs;
305
-
306
- // Bob uses the information received from Alice to reconstruct the UTXO sent to him
307
433
  const receivedValue = 25 * EighteenDecimals;
308
- const receivedSalt = utxo3.salt;
434
+ const receivedSalt = utxoC.salt;
309
435
  const hash = poseidonHash([
310
436
  BigInt(receivedValue),
311
437
  receivedSalt,
@@ -315,34 +441,6 @@ describe("Zeto based fungible token with anonymity without encryption or nullifi
315
441
  expect(incomingUTXOs[0]).to.equal(hash);
316
442
  });
317
443
 
318
- it("(burnable) mint to Alice and transfer honestly to Bob then burn should succeed", async function () {
319
- // first mint the tokens for testing
320
- const inputUtxos = [];
321
- for (let i = 0; i < 3; i++) {
322
- inputUtxos.push(newUTXO(10, Alice));
323
- }
324
- await doMint(zetoBurnable, deployer, inputUtxos);
325
-
326
- const remainder = newUTXO(5, Alice);
327
-
328
- // Alice can burn the UTXOs she owns
329
- const { inputCommitments, outputCommitment, encodedProof } =
330
- await prepareBurnProof(Alice, inputUtxos.slice(0, 2), remainder);
331
- const tx = await zetoBurnable
332
- .connect(Alice.signer)
333
- .burn(inputCommitments, outputCommitment, encodedProof, "0x");
334
- await tx.wait();
335
-
336
- // check that the burned UTXOs are spent
337
- let spent = await zetoBurnable.spent(inputCommitments[0]);
338
- expect(spent).to.be.true;
339
- spent = await zetoBurnable.spent(inputCommitments[1]);
340
- expect(spent).to.be.true;
341
- // check that the remaining UTXO is not spent
342
- spent = await zetoBurnable.spent(inputUtxos[2].hash as BigNumberish);
343
- expect(spent).to.be.false;
344
- });
345
-
346
444
  describe("failure cases", function () {
347
445
  // the following failure cases rely on the hardhat network
348
446
  // to return the details of the errors. This is not possible
@@ -352,7 +450,6 @@ describe("Zeto based fungible token with anonymity without encryption or nullifi
352
450
  }
353
451
 
354
452
  it("Alice attempting to withdraw spent UTXOs should fail", async function () {
355
- // Alice proposes the output ERC20 tokens
356
453
  const outputCommitment = newUTXO(90, Alice);
357
454
 
358
455
  const { inputCommitments, outputCommitments, encodedProof } =
@@ -369,7 +466,7 @@ describe("Zeto based fungible token with anonymity without encryption or nullifi
369
466
  10,
370
467
  inputCommitments,
371
468
  outputCommitments[0],
372
- encodedProof,
469
+ encodeToBytes(encodedProof),
373
470
  "0x",
374
471
  ),
375
472
  ).rejectedWith("UTXOAlreadySpent");
@@ -401,7 +498,6 @@ describe("Zeto based fungible token with anonymity without encryption or nullifi
401
498
  });
402
499
 
403
500
  it("transfer spent UTXOs should fail (double spend protection)", async function () {
404
- // create outputs
405
501
  const utxo5 = newUTXO(25, Bob);
406
502
  const utxo6 = newUTXO(5, Alice, utxo5.salt);
407
503
  await expect(
@@ -418,287 +514,687 @@ describe("Zeto based fungible token with anonymity without encryption or nullifi
418
514
  });
419
515
  });
420
516
 
421
- describe("lock() tests", function () {
422
- let lockedUtxo1: UTXO;
423
- let lockedUtxo2: UTXO;
424
-
425
- it("lock() should fail when duplicate UTXOs are provided", async function () {
426
- const resusedUtxo = newUTXO(7, Bob);
427
- const spareUtxo = newUTXO(1, Bob);
428
- const inflatedInputUtxos = [utxo7, ZERO_UTXO, ZERO_UTXO];
429
- const inflatedOutputUtxos = [resusedUtxo, spareUtxo, resusedUtxo];
430
- const inflatedOutputOwners = [Bob, Bob, Bob];
431
- for (let i = 0; i < 7; i++) {
432
- inflatedInputUtxos.push(ZERO_UTXO);
433
- inflatedOutputUtxos.push(ZERO_UTXO);
434
- inflatedOutputOwners.push(Bob);
435
- }
436
-
437
- const result = await prepareProof(
438
- batchCircuit,
439
- batchProvingKey,
440
- Bob,
441
- inflatedInputUtxos,
442
- inflatedOutputUtxos,
443
- inflatedOutputOwners,
444
- );
445
- await expect(
446
- zeto.connect(Bob.signer).lock(
447
- result.inputCommitments,
448
- [result.outputCommitments[0]], // unlocked output
449
- result.outputCommitments, // locked output
450
- result.encodedProof,
451
- Alice.ethAddress, // make Alice the delegate who can spend the state (if she has the right proof)
452
- "0x",
453
- ),
454
- ).rejectedWith(`UTXODuplicate(${resusedUtxo.hash.toString()})`);
455
- });
456
-
457
- it("lock() should succeed when using unlocked states", async function () {
458
- lockedUtxo1 = newUTXO(10, Bob);
459
- lockedUtxo2 = newUTXO(5, Bob);
460
- const result = await prepareProof(
461
- circuit,
462
- provingKey,
463
- Bob,
464
- [utxo7, ZERO_UTXO],
465
- [lockedUtxo1, lockedUtxo2],
466
- [Bob, Bob],
467
- );
468
- const tx = await zeto.connect(Bob.signer).lock(
469
- result.inputCommitments,
470
- [], // unlocked output
471
- result.outputCommitments, // locked output
472
- result.encodedProof,
473
- Alice.ethAddress, // make Alice the delegate who can spend the state (if she has the right proof)
474
- "0x",
475
- );
476
- const results = await tx.wait();
477
- console.log(`Method transfer() complete. Gas used: ${results?.gasUsed}`);
478
- });
517
+ describe("ILockableCapability tests", function () {
518
+ // ABI fragments for the ZetoLockableCapability *Args payloads. Mirrors
519
+ // the layout used in zeto_anon_nullifier.ts so that the cross-token
520
+ // contract surface stays uniform.
521
+ const CREATE_ARGS_ABI =
522
+ "tuple(bytes32 txId, uint256[] inputs, uint256[] outputs, uint256[] lockedOutputs, bytes proof)";
523
+ const UPDATE_ARGS_ABI = "tuple(bytes32 txId)";
524
+ const DELEGATE_ARGS_ABI = "tuple(bytes32 txId)";
525
+ const SPEND_ARGS_ABI =
526
+ "tuple(bytes32 txId, uint256[] lockedOutputs, uint256[] outputs, bytes proof, bytes data)";
527
+
528
+ function encodeCreateArgs(args: {
529
+ txId: string;
530
+ inputs: BigNumberish[];
531
+ outputs: BigNumberish[];
532
+ lockedOutputs: BigNumberish[];
533
+ proof: string;
534
+ }) {
535
+ return new AbiCoder().encode([CREATE_ARGS_ABI], [args]);
536
+ }
479
537
 
480
- it("locked() should return true for locked UTXOs, and false for unlocked or spent UTXOs", async function () {
481
- expect(await zeto.locked(lockedUtxo1.hash)).to.deep.equal([
482
- true,
483
- Alice.ethAddress,
484
- ]);
485
- expect(await zeto.locked(lockedUtxo2.hash)).to.deep.equal([
486
- true,
487
- Alice.ethAddress,
488
- ]);
489
- expect(await zeto.locked(utxo7.hash)).to.deep.equal([
490
- false,
491
- "0x0000000000000000000000000000000000000000",
492
- ]);
493
- expect(await zeto.locked(utxo1.hash)).to.deep.equal([
494
- false,
495
- "0x0000000000000000000000000000000000000000",
496
- ]);
497
- });
538
+ function encodeUpdateArgs(txId: string) {
539
+ return new AbiCoder().encode([UPDATE_ARGS_ABI], [{ txId }]);
540
+ }
498
541
 
499
- it("lock() should fail when trying to lock again", async function () {
500
- if (network.name !== "hardhat") {
501
- return;
502
- }
542
+ function encodeDelegateArgs(txId: string) {
543
+ return new AbiCoder().encode([DELEGATE_ARGS_ABI], [{ txId }]);
544
+ }
503
545
 
504
- // Bob is the owner of the UTXO, so he can generate the right proof
505
- const utxo1 = newUTXO(10, Bob);
506
- const result = await prepareProof(
507
- circuit,
508
- provingKey,
509
- Bob,
510
- [lockedUtxo1, ZERO_UTXO],
511
- [utxo1, ZERO_UTXO],
512
- [Bob, Bob],
513
- );
546
+ function encodeSpendArgs(args: {
547
+ txId: string;
548
+ lockedOutputs: BigNumberish[];
549
+ outputs: BigNumberish[];
550
+ proof: string;
551
+ data: string;
552
+ }) {
553
+ return new AbiCoder().encode([SPEND_ARGS_ABI], [args]);
554
+ }
514
555
 
515
- // but he's no longer the delegate (Alice is) to spend the state
516
- await expect(
517
- zeto
518
- .connect(Bob.signer)
519
- .lock(
520
- result.inputCommitments,
521
- [],
522
- result.outputCommitments,
523
- result.encodedProof,
524
- Bob.ethAddress,
525
- "0x",
526
- ),
527
- ).rejectedWith(`UTXOAlreadyLocked(${lockedUtxo1.hash.toString()})`);
528
- });
556
+ function randomBytes32(): string {
557
+ return ethers.hexlify(ethers.randomBytes(32));
558
+ }
529
559
 
530
- it("the original owner can NOT spend the locked state", async function () {
531
- const utxo1 = newUTXO(10, Alice);
532
- await expect(
533
- doTransfer(
560
+ describe("createLock -> updateLock -> delegateLock -> spendLock flow", function () {
561
+ let bobSourceUtxo: UTXO;
562
+ let lockedUtxo: UTXO;
563
+ let lockId: string;
564
+ let outUtxo1: UTXO;
565
+ let outUtxo2: UTXO;
566
+ let unlockHash: string;
567
+
568
+ before(async function () {
569
+ bobSourceUtxo = newUTXO(100, Bob);
570
+ await doMint(zeto, deployer, [bobSourceUtxo]);
571
+ });
572
+
573
+ it("createLock() with deterministic lockId computed from txId", async function () {
574
+ // The locked content is just a fresh UTXO of equal value held under Bob.
575
+ // The proof on the create path proves the standard transfer
576
+ // relationship: bobSourceUtxo -> lockedUtxo. The contract treats
577
+ // the locked output identically to a regular output for the purposes
578
+ // of circuit verification (they are merged into a single output set
579
+ // before constructPublicInputs), so the same anon circuit is reused.
580
+ lockedUtxo = newUTXO(bobSourceUtxo.value!, Bob);
581
+ const encodedZkProof = await prepareProof(
582
+ circuit,
583
+ provingKey,
534
584
  Bob,
535
- [lockedUtxo1, ZERO_UTXO],
536
- [utxo1, ZERO_UTXO],
537
- [Alice, Alice],
538
- ),
539
- ).to.be.rejectedWith("UTXOAlreadyLocked");
540
- });
585
+ [bobSourceUtxo, ZERO_UTXO],
586
+ [lockedUtxo, ZERO_UTXO],
587
+ [Bob, Bob],
588
+ );
541
589
 
542
- it("the original owner can NOT withdraw the locked state", async function () {
543
- const outputCommitment = newUTXO(5, Bob);
590
+ const txId = randomBytes32();
591
+ const createArgs = encodeCreateArgs({
592
+ txId,
593
+ inputs: [bobSourceUtxo.hash],
594
+ outputs: [],
595
+ lockedOutputs: [lockedUtxo.hash],
596
+ proof: encodeToBytes(encodedZkProof),
597
+ });
544
598
 
545
- const { inputCommitments, outputCommitments, encodedProof } =
546
- await prepareWithdrawProof(
547
- Bob,
548
- [lockedUtxo1, ZERO_UTXO],
549
- outputCommitment,
599
+ const predicted = await zeto
600
+ .connect(Bob.signer)
601
+ .computeLockId(createArgs);
602
+ lockId = predicted;
603
+
604
+ const tx = await zeto
605
+ .connect(Bob.signer)
606
+ .createLock(createArgs, ethers.ZeroHash, ethers.ZeroHash, "0x");
607
+ const result: ContractTransactionReceipt | null = await tx.wait();
608
+ logger.debug(`createLock() complete. Gas used: ${result?.gasUsed}`);
609
+
610
+ const created = result!.logs
611
+ .map((l) => {
612
+ try {
613
+ return zeto.interface.parseLog(l as any);
614
+ } catch (_e) {
615
+ return null;
616
+ }
617
+ })
618
+ .find((p) => p && p.name === "LockCreated");
619
+ expect(created, "LockCreated event not found").to.not.be.null;
620
+ expect(created!.args.lockId).to.equal(predicted);
621
+ expect(created!.args.owner).to.equal(Bob.ethAddress);
622
+ expect(created!.args.spender).to.equal(Bob.ethAddress);
623
+ });
624
+
625
+ it("isLockActive() and getLock() reflect the newly created lock", async function () {
626
+ expect(await zeto.isLockActive(lockId)).to.equal(true);
627
+ const info = await zeto.getLock(lockId);
628
+ expect(info.owner).to.equal(Bob.ethAddress);
629
+ expect(info.spender).to.equal(Bob.ethAddress);
630
+ expect(info.spendCommitment).to.equal(ethers.ZeroHash);
631
+ expect(info.cancelCommitment).to.equal(ethers.ZeroHash);
632
+ });
633
+
634
+ it("locked() returns true for locked UTXOs and false for unlocked or spent UTXOs", async function () {
635
+ // Just-created lock: spender == owner == Bob.
636
+ expect(await zeto.locked(lockedUtxo.hash)).to.deep.equal([
637
+ true,
638
+ Bob.ethAddress,
639
+ ]);
640
+ expect((await zeto.locked(bobSourceUtxo.hash))[0]).to.be.false;
641
+ });
642
+
643
+ it("updateLock() commits the spend hash while owner == spender", async function () {
644
+ outUtxo1 = newUTXO(10, Alice);
645
+ outUtxo2 = newUTXO(90, Bob);
646
+
647
+ unlockHash = calculateSpendHash(
648
+ [lockedUtxo],
649
+ [],
650
+ [outUtxo1, outUtxo2],
651
+ "0x",
550
652
  );
551
653
 
552
- // Alice withdraws her UTXOs to ERC20 tokens
553
- await expect(
554
- zeto
654
+ const tx = await zeto
555
655
  .connect(Bob.signer)
556
- .withdraw(
557
- 10,
558
- inputCommitments,
559
- outputCommitments[0],
560
- encodedProof,
656
+ .updateLock(
657
+ lockId,
658
+ encodeUpdateArgs(randomBytes32()),
659
+ unlockHash,
660
+ ethers.ZeroHash,
561
661
  "0x",
562
- ),
563
- ).to.be.rejectedWith("UTXOAlreadyLocked");
564
- });
662
+ );
663
+ const result = await tx.wait();
664
+ logger.debug(`updateLock() complete. Gas used: ${result?.gasUsed}`);
565
665
 
566
- it("an invalid lock delegate can NOT give the lock to another delegate", async function () {
567
- await expect(
568
- zeto
666
+ const info = await zeto.getLock(lockId);
667
+ expect(info.spendCommitment).to.equal(unlockHash);
668
+ });
669
+
670
+ it("delegateLock() transfers spending authority to Alice", async function () {
671
+ const tx = await zeto
569
672
  .connect(Bob.signer)
570
673
  .delegateLock(
571
- [lockedUtxo1.hash as BigNumberish],
572
- Charlie.ethAddress,
674
+ lockId,
675
+ encodeDelegateArgs(randomBytes32()),
676
+ Alice.ethAddress,
573
677
  "0x",
574
- ),
575
- ).to.be.rejectedWith("NotLockDelegate");
576
- });
678
+ );
679
+ const result = await tx.wait();
680
+ logger.debug(`delegateLock() complete. Gas used: ${result?.gasUsed}`);
681
+
682
+ const info = await zeto.getLock(lockId);
683
+ expect(info.spender).to.equal(Alice.ethAddress);
684
+ // Per-UTXO delegate projection in ZetoFungible must reflect the
685
+ // new spender. This is the layer that BaseStorage no longer tracks
686
+ // after the "pull delegates out of BaseStorage" refactor.
687
+ expect(await zeto.locked(lockedUtxo.hash)).to.deep.equal([
688
+ true,
689
+ Alice.ethAddress,
690
+ ]);
691
+ });
577
692
 
578
- it("the valid lock delegate can give the lock to another delegate", async function () {
579
- await expect(
580
- zeto
693
+ it("the new spender can spendLock() with the matching payload", async function () {
694
+ const encodedZkProofForSettle = await prepareProof(
695
+ circuit,
696
+ provingKey,
697
+ Bob,
698
+ [lockedUtxo, ZERO_UTXO],
699
+ [outUtxo1, outUtxo2],
700
+ [Alice, Bob],
701
+ );
702
+
703
+ const spendArgs = encodeSpendArgs({
704
+ txId: randomBytes32(),
705
+ lockedOutputs: [],
706
+ outputs: [outUtxo1.hash, outUtxo2.hash],
707
+ proof: encodeToBytes(encodedZkProofForSettle),
708
+ data: "0x",
709
+ });
710
+
711
+ const tx = await zeto
581
712
  .connect(Alice.signer)
582
- .delegateLock(
583
- [lockedUtxo1.hash as BigNumberish],
584
- Charlie.ethAddress,
585
- "0x",
586
- ),
587
- ).to.be.fulfilled;
713
+ .spendLock(lockId, spendArgs, "0x");
714
+ const result = await tx.wait();
715
+
716
+ const parsed = result!.logs
717
+ .map((l) => {
718
+ try {
719
+ return zeto.interface.parseLog(l as any);
720
+ } catch (_e) {
721
+ return null;
722
+ }
723
+ })
724
+ .filter((p) => p !== null) as ReadonlyArray<{
725
+ name: string;
726
+ args: any;
727
+ }>;
728
+ const lockSpent = parsed.find((p) => p.name === "LockSpent");
729
+ const zetoLockSpent = parsed.find((p) => p.name === "ZetoLockSpent");
730
+ expect(lockSpent, "LockSpent event not emitted").to.not.be.undefined;
731
+ expect(zetoLockSpent, "ZetoLockSpent event not emitted").to.not.be
732
+ .undefined;
733
+ expect(lockSpent!.args.lockId).to.equal(lockId);
734
+ expect(lockSpent!.args.spender).to.equal(Alice.ethAddress);
735
+
736
+ // Lock is no longer active.
737
+ expect(await zeto.isLockActive(lockId)).to.equal(false);
738
+
739
+ // Outputs are now ordinary unlocked UTXOs.
740
+ expect(await zeto.spent(outUtxo1.hash)).to.equal(1n); // UNSPENT
741
+ expect(await zeto.spent(outUtxo2.hash)).to.equal(1n); // UNSPENT
742
+
743
+ // Per-UTXO delegate projection is cleared post-consume.
744
+ expect(await zeto.locked(lockedUtxo.hash)).to.deep.equal([
745
+ false,
746
+ ZeroAddress,
747
+ ]);
748
+ });
588
749
  });
589
750
 
590
- it("attempting to use an existing UTXO as output should fail", async function () {
591
- const utxo1 = newUTXO(5, Alice);
592
- const { inputCommitments, outputCommitments, encodedProof } =
593
- await prepareProof(
751
+ describe("createLock -> cancelLock flow", function () {
752
+ let bobSourceUtxo: UTXO;
753
+ let lockedUtxo: UTXO;
754
+ let lockId: string;
755
+ let cancelHash: string;
756
+ let outUtxo1: UTXO;
757
+ let outUtxo2: UTXO;
758
+
759
+ before(async function () {
760
+ bobSourceUtxo = newUTXO(100, Bob);
761
+ await doMint(zeto, deployer, [bobSourceUtxo]);
762
+ });
763
+
764
+ it("Bob createLock() with a non-zero cancelCommitment", async function () {
765
+ lockedUtxo = newUTXO(bobSourceUtxo.value!, Bob);
766
+ const encodedZkProof = await prepareProof(
594
767
  circuit,
595
768
  provingKey,
596
769
  Bob,
597
- [lockedUtxo1, ZERO_UTXO],
598
- [utxo1, lockedUtxo2],
599
- [Alice, Bob],
770
+ [bobSourceUtxo, ZERO_UTXO],
771
+ [lockedUtxo, ZERO_UTXO],
772
+ [Bob, Bob],
600
773
  );
601
- // Charlie is the new delegate (in reality this is usually a contract that orchestrates a trade flow) to spend the locked state
602
- // using the proof generated by the trade counterparty (Bob in this case)
603
- await expect(
604
- sendTx(
605
- Charlie,
606
- inputCommitments,
607
- outputCommitments,
608
- encodedProof,
609
- true,
610
- ),
611
- ).to.be.rejectedWith(`UTXOAlreadyOwned(${lockedUtxo2.hash.toString()})`);
612
- });
613
774
 
614
- it("attempting to use a spent UTXO as output should fail", async function () {
615
- // spend utxo4
616
- const utxo1 = newUTXO(2, Bob);
617
- const utxo2 = newUTXO(3, Alice);
618
- await expect(
619
- doTransfer(Alice, [utxo4, ZERO_UTXO], [utxo1, utxo2], [Bob, Alice]),
620
- ).to.be.fulfilled;
775
+ outUtxo1 = newUTXO(10, Alice);
776
+ outUtxo2 = newUTXO(90, Bob);
777
+ cancelHash = calculateCancelHash(
778
+ [lockedUtxo],
779
+ [],
780
+ [outUtxo1, outUtxo2],
781
+ "0x",
782
+ );
621
783
 
622
- // attempt to use utxo4 as output
623
- const utxo3 = newUTXO(5, Alice);
624
- const { inputCommitments, outputCommitments, encodedProof } =
625
- await prepareProof(
784
+ const createArgs = encodeCreateArgs({
785
+ txId: randomBytes32(),
786
+ inputs: [bobSourceUtxo.hash],
787
+ outputs: [],
788
+ lockedOutputs: [lockedUtxo.hash],
789
+ proof: encodeToBytes(encodedZkProof),
790
+ });
791
+ lockId = await zeto.connect(Bob.signer).computeLockId(createArgs);
792
+ const tx = await zeto
793
+ .connect(Bob.signer)
794
+ .createLock(createArgs, ethers.ZeroHash, cancelHash, "0x");
795
+ const result = await tx.wait();
796
+ logger.debug(`createLock() complete. Gas used: ${result?.gasUsed}`);
797
+ });
798
+
799
+ it("the owner can cancelLock() to reverse the lock without delegation", async function () {
800
+ const encodedZkProofForCancel = await prepareProof(
626
801
  circuit,
627
802
  provingKey,
628
803
  Bob,
629
- [lockedUtxo1, ZERO_UTXO],
630
- [utxo3, utxo4],
631
- [Alice, Alice],
804
+ [lockedUtxo, ZERO_UTXO],
805
+ [outUtxo1, outUtxo2],
806
+ [Alice, Bob],
632
807
  );
633
- // Charlie is the new delegate (in reality this is usually a contract that orchestrates a trade flow) to spend the locked state
634
- // using the proof generated by the trade counterparty (Bob in this case)
635
- await expect(
636
- sendTx(
637
- Charlie,
638
- inputCommitments,
639
- outputCommitments,
640
- encodedProof,
641
- true,
642
- ),
643
- ).to.be.rejectedWith(`UTXOAlreadySpent(${utxo4.hash.toString()})`);
808
+
809
+ const cancelArgs = encodeSpendArgs({
810
+ txId: randomBytes32(),
811
+ lockedOutputs: [],
812
+ outputs: [outUtxo1.hash, outUtxo2.hash],
813
+ proof: encodeToBytes(encodedZkProofForCancel),
814
+ data: "0x",
815
+ });
816
+
817
+ const tx = await zeto
818
+ .connect(Bob.signer)
819
+ .cancelLock(lockId, cancelArgs, "0x");
820
+ const result = await tx.wait();
821
+
822
+ const parsed = result!.logs
823
+ .map((l) => {
824
+ try {
825
+ return zeto.interface.parseLog(l as any);
826
+ } catch (_e) {
827
+ return null;
828
+ }
829
+ })
830
+ .filter((p) => p !== null) as ReadonlyArray<{
831
+ name: string;
832
+ args: any;
833
+ }>;
834
+ const cancelled = parsed.find((p) => p.name === "LockCancelled");
835
+ const zetoCancelled = parsed.find(
836
+ (p) => p.name === "ZetoLockCancelled",
837
+ );
838
+ expect(cancelled, "LockCancelled event not emitted").to.not.be
839
+ .undefined;
840
+ expect(zetoCancelled, "ZetoLockCancelled event not emitted").to.not.be
841
+ .undefined;
842
+
843
+ expect(await zeto.isLockActive(lockId)).to.equal(false);
844
+ });
644
845
  });
645
846
 
646
- it("the designated delegate can use the proper proof to spend the locked state", async function () {
647
- const utxo1 = newUTXO(10, Alice);
648
- const { inputCommitments, outputCommitments, encodedProof } =
649
- await prepareProof(
847
+ describe("spendLock with a payload that does not match the spend commitment fails", function () {
848
+ let bobSourceUtxo: UTXO;
849
+ let lockedUtxo: UTXO;
850
+ let lockId: string;
851
+ let expectedHash: string;
852
+
853
+ before(async function () {
854
+ bobSourceUtxo = newUTXO(100, Bob);
855
+ await doMint(zeto, deployer, [bobSourceUtxo]);
856
+ });
857
+
858
+ it("Bob createLock() then updateLock() committing a specific spend hash", async function () {
859
+ lockedUtxo = newUTXO(bobSourceUtxo.value!, Bob);
860
+ const encodedZkProof = await prepareProof(
650
861
  circuit,
651
862
  provingKey,
652
863
  Bob,
653
- [lockedUtxo1, ZERO_UTXO],
654
- [utxo1, ZERO_UTXO],
655
- [Alice, Alice],
864
+ [bobSourceUtxo, ZERO_UTXO],
865
+ [lockedUtxo, ZERO_UTXO],
866
+ [Bob, Bob],
656
867
  );
657
- // Alice as the delegate (in reality this is usually a contract that orchestrates a trade flow) can spend the locked state
658
- // using the proof generated by the trade counterparty (Bob in this case)
659
- await expect(
660
- sendTx(
661
- Charlie,
662
- inputCommitments,
663
- outputCommitments,
664
- encodedProof,
665
- true,
666
- ),
667
- ).to.be.fulfilled;
868
+ const createArgs = encodeCreateArgs({
869
+ txId: randomBytes32(),
870
+ inputs: [bobSourceUtxo.hash],
871
+ outputs: [],
872
+ lockedOutputs: [lockedUtxo.hash],
873
+ proof: encodeToBytes(encodedZkProof),
874
+ });
875
+ lockId = await zeto.connect(Bob.signer).computeLockId(createArgs);
876
+ await (
877
+ await zeto
878
+ .connect(Bob.signer)
879
+ .createLock(createArgs, ethers.ZeroHash, ethers.ZeroHash, "0x")
880
+ ).wait();
881
+
882
+ const expectedOut1 = newUTXO(10, Alice);
883
+ const expectedOut2 = newUTXO(90, Bob);
884
+ expectedHash = calculateSpendHash(
885
+ [lockedUtxo],
886
+ [],
887
+ [expectedOut1, expectedOut2],
888
+ "0x",
889
+ );
890
+ await (
891
+ await zeto
892
+ .connect(Bob.signer)
893
+ .updateLock(
894
+ lockId,
895
+ encodeUpdateArgs(randomBytes32()),
896
+ expectedHash,
897
+ ethers.ZeroHash,
898
+ "0x",
899
+ )
900
+ ).wait();
901
+ });
902
+
903
+ it("spendLock() with a different payload reverts with InvalidUnlockHash", async function () {
904
+ if (network.name !== "hardhat") {
905
+ this.skip();
906
+ }
907
+ const wrongOut1 = newUTXO(20, Alice);
908
+ const wrongOut2 = newUTXO(80, Bob);
909
+
910
+ const encodedZkProofForSettle = await prepareProof(
911
+ circuit,
912
+ provingKey,
913
+ Bob,
914
+ [lockedUtxo, ZERO_UTXO],
915
+ [wrongOut1, wrongOut2],
916
+ [Alice, Bob],
917
+ );
918
+
919
+ const spendArgs = encodeSpendArgs({
920
+ txId: randomBytes32(),
921
+ lockedOutputs: [],
922
+ outputs: [wrongOut1.hash, wrongOut2.hash],
923
+ proof: encodeToBytes(encodedZkProofForSettle),
924
+ data: "0x",
925
+ });
926
+
927
+ const calculatedHash = calculateSpendHash(
928
+ [lockedUtxo],
929
+ [],
930
+ [wrongOut1, wrongOut2],
931
+ "0x",
932
+ );
933
+
934
+ await expect(
935
+ zeto.connect(Bob.signer).spendLock(lockId, spendArgs, "0x"),
936
+ )
937
+ .to.be.revertedWithCustomError(zeto, "InvalidUnlockHash")
938
+ .withArgs(expectedHash, calculatedHash);
939
+ });
668
940
  });
669
941
 
670
- it("unlocks a previously locked UTXO, and spend as usual", async function () {
671
- const utxo1 = newUTXO(5, Charlie);
672
- await expect(
673
- doTransfer(
674
- Bob,
675
- [lockedUtxo2, ZERO_UTXO],
676
- [utxo1, ZERO_UTXO],
677
- [Charlie, Charlie],
678
- ),
679
- ).to.be.rejectedWith("UTXOAlreadyLocked");
942
+ describe("negative cases for the lock lifecycle", function () {
943
+ // These tests rely on hardhat-style revert decoding.
944
+ if (network.name !== "hardhat") {
945
+ return;
946
+ }
680
947
 
681
- // Alice as the current delegate can unlock the UTXO
682
- const { inputCommitments, outputCommitments, encodedProof } =
683
- await prepareProof(
948
+ // freshLock mints a UTXO for `owner`, locks it, and returns the
949
+ // resulting lock metadata so each negative case can branch off
950
+ // without polluting other test scopes.
951
+ async function freshLock(
952
+ owner: User,
953
+ spendCommitment: string = ethers.ZeroHash,
954
+ cancelCommitment: string = ethers.ZeroHash,
955
+ ): Promise<{
956
+ lockId: string;
957
+ sourceUtxo: UTXO;
958
+ lockedUtxo: UTXO;
959
+ createArgs: string;
960
+ }> {
961
+ const sourceUtxo = newUTXO(100, owner);
962
+ await doMint(zeto, deployer, [sourceUtxo]);
963
+
964
+ const lockedUtxo = newUTXO(sourceUtxo.value!, owner);
965
+ const encodedZkProof = await prepareProof(
684
966
  circuit,
685
967
  provingKey,
686
- Bob,
687
- [lockedUtxo2, ZERO_UTXO],
688
- [utxo1, ZERO_UTXO],
689
- [Charlie, Charlie],
968
+ owner,
969
+ [sourceUtxo, ZERO_UTXO],
970
+ [lockedUtxo, ZERO_UTXO],
971
+ [owner, owner],
690
972
  );
691
- await expect(
692
- zeto
693
- .connect(Alice.signer)
694
- .unlock(inputCommitments, outputCommitments, encodedProof, "0x"),
695
- ).to.be.fulfilled;
696
973
 
697
- // now Bob as the owner can spend the UTXO as usual
698
- const utxo2 = newUTXO(5, Bob);
699
- await expect(
700
- doTransfer(Charlie, [utxo1, ZERO_UTXO], [utxo2, ZERO_UTXO], [Bob, Bob]),
701
- ).to.be.fulfilled;
974
+ const createArgs = encodeCreateArgs({
975
+ txId: randomBytes32(),
976
+ inputs: [sourceUtxo.hash],
977
+ outputs: [],
978
+ lockedOutputs: [lockedUtxo.hash],
979
+ proof: encodeToBytes(encodedZkProof),
980
+ });
981
+ const lockId = await zeto
982
+ .connect(owner.signer)
983
+ .computeLockId(createArgs);
984
+ await (
985
+ await zeto
986
+ .connect(owner.signer)
987
+ .createLock(createArgs, spendCommitment, cancelCommitment, "0x")
988
+ ).wait();
989
+ return { lockId, sourceUtxo, lockedUtxo, createArgs };
990
+ }
991
+
992
+ // Re-encoding helper: a syntactically valid spend payload that does
993
+ // NOT need to verify a real ZK proof. Tests for authorization /
994
+ // immutability assertions short-circuit before the proof is touched.
995
+ function dummySpendArgs(): string {
996
+ return encodeSpendArgs({
997
+ txId: randomBytes32(),
998
+ lockedOutputs: [],
999
+ outputs: [],
1000
+ proof: "0x",
1001
+ data: "0x",
1002
+ });
1003
+ }
1004
+
1005
+ it("createLock() with a duplicate txId from the same caller reverts with DuplicateLock", async function () {
1006
+ const { lockId, createArgs } = await freshLock(Bob);
1007
+
1008
+ // Same createArgs (same txId, same caller) => same lockId =>
1009
+ // DuplicateLock fires before any input or proof validation.
1010
+ await expect(
1011
+ zeto
1012
+ .connect(Bob.signer)
1013
+ .createLock(createArgs, ethers.ZeroHash, ethers.ZeroHash, "0x"),
1014
+ )
1015
+ .to.be.revertedWithCustomError(zeto, "DuplicateLock")
1016
+ .withArgs(lockId);
1017
+ });
1018
+
1019
+ it("updateLock() by a non-owner reverts with LockUnauthorized", async function () {
1020
+ const { lockId } = await freshLock(Bob);
1021
+
1022
+ await expect(
1023
+ zeto
1024
+ .connect(Alice.signer)
1025
+ .updateLock(
1026
+ lockId,
1027
+ encodeUpdateArgs(randomBytes32()),
1028
+ ethers.ZeroHash,
1029
+ ethers.ZeroHash,
1030
+ "0x",
1031
+ ),
1032
+ )
1033
+ .to.be.revertedWithCustomError(zeto, "LockUnauthorized")
1034
+ .withArgs(lockId, Bob.ethAddress, Alice.ethAddress);
1035
+ });
1036
+
1037
+ it("updateLock() prefers LockUnauthorized over LockImmutable when both apply (M-5)", async function () {
1038
+ // Lock is delegated (so spender != owner -> immutable) AND the
1039
+ // caller is neither owner nor spender. The contract MUST report
1040
+ // LockUnauthorized, not leak the immutability state to the
1041
+ // unauthorized caller.
1042
+ const { lockId } = await freshLock(Bob);
1043
+ await (
1044
+ await zeto
1045
+ .connect(Bob.signer)
1046
+ .delegateLock(
1047
+ lockId,
1048
+ encodeDelegateArgs(randomBytes32()),
1049
+ Alice.ethAddress,
1050
+ "0x",
1051
+ )
1052
+ ).wait();
1053
+
1054
+ await expect(
1055
+ zeto
1056
+ .connect(Charlie.signer)
1057
+ .updateLock(
1058
+ lockId,
1059
+ encodeUpdateArgs(randomBytes32()),
1060
+ ethers.ZeroHash,
1061
+ ethers.ZeroHash,
1062
+ "0x",
1063
+ ),
1064
+ )
1065
+ .to.be.revertedWithCustomError(zeto, "LockUnauthorized")
1066
+ // spender at this point is Alice (the new delegate).
1067
+ .withArgs(lockId, Bob.ethAddress, Charlie.ethAddress);
1068
+ });
1069
+
1070
+ it("updateLock() after delegateLock() reverts with LockImmutable", async function () {
1071
+ const { lockId } = await freshLock(Bob);
1072
+
1073
+ // Bob delegates spending authority to Alice; spender (Alice) now
1074
+ // differs from owner (Bob).
1075
+ await (
1076
+ await zeto
1077
+ .connect(Bob.signer)
1078
+ .delegateLock(
1079
+ lockId,
1080
+ encodeDelegateArgs(randomBytes32()),
1081
+ Alice.ethAddress,
1082
+ "0x",
1083
+ )
1084
+ ).wait();
1085
+
1086
+ // Even Bob (the owner) can no longer mutate the commitments —
1087
+ // the lock is now externally controlled and must be considered
1088
+ // immutable.
1089
+ await expect(
1090
+ zeto
1091
+ .connect(Bob.signer)
1092
+ .updateLock(
1093
+ lockId,
1094
+ encodeUpdateArgs(randomBytes32()),
1095
+ ethers.ZeroHash,
1096
+ ethers.ZeroHash,
1097
+ "0x",
1098
+ ),
1099
+ )
1100
+ .to.be.revertedWithCustomError(zeto, "LockImmutable")
1101
+ .withArgs(lockId);
1102
+ });
1103
+
1104
+ it("delegateLock() by a non-spender reverts with LockUnauthorized", async function () {
1105
+ const { lockId } = await freshLock(Bob);
1106
+
1107
+ await expect(
1108
+ zeto
1109
+ .connect(Alice.signer)
1110
+ .delegateLock(
1111
+ lockId,
1112
+ encodeDelegateArgs(randomBytes32()),
1113
+ Charlie.ethAddress,
1114
+ "0x",
1115
+ ),
1116
+ )
1117
+ .to.be.revertedWithCustomError(zeto, "LockUnauthorized")
1118
+ .withArgs(lockId, Bob.ethAddress, Alice.ethAddress);
1119
+ });
1120
+
1121
+ it("spendLock() by a non-spender reverts with LockUnauthorized before touching the proof", async function () {
1122
+ const { lockId } = await freshLock(Bob);
1123
+
1124
+ // Garbage proof — the onlySpender modifier MUST short-circuit
1125
+ // before any proof verification is attempted.
1126
+ await expect(
1127
+ zeto.connect(Alice.signer).spendLock(lockId, dummySpendArgs(), "0x"),
1128
+ )
1129
+ .to.be.revertedWithCustomError(zeto, "LockUnauthorized")
1130
+ .withArgs(lockId, Bob.ethAddress, Alice.ethAddress);
1131
+ });
1132
+
1133
+ it("cancelLock() by a non-spender reverts with LockUnauthorized", async function () {
1134
+ const { lockId } = await freshLock(Bob);
1135
+
1136
+ await expect(
1137
+ zeto.connect(Alice.signer).cancelLock(lockId, dummySpendArgs(), "0x"),
1138
+ )
1139
+ .to.be.revertedWithCustomError(zeto, "LockUnauthorized")
1140
+ .withArgs(lockId, Bob.ethAddress, Alice.ethAddress);
1141
+ });
1142
+
1143
+ it("after a successful spendLock(), the lock is no longer active and getLock() reverts", async function () {
1144
+ const { lockId, lockedUtxo } = await freshLock(Bob);
1145
+
1146
+ const out1 = newUTXO(10, Alice);
1147
+ const out2 = newUTXO(90, Bob);
1148
+ const zkProof = await prepareProof(
1149
+ circuit,
1150
+ provingKey,
1151
+ Bob,
1152
+ [lockedUtxo, ZERO_UTXO],
1153
+ [out1, out2],
1154
+ [Alice, Bob],
1155
+ );
1156
+ const spendArgs = encodeSpendArgs({
1157
+ txId: randomBytes32(),
1158
+ lockedOutputs: [],
1159
+ outputs: [out1.hash, out2.hash],
1160
+ proof: encodeToBytes(zkProof),
1161
+ data: "0x",
1162
+ });
1163
+ await (
1164
+ await zeto.connect(Bob.signer).spendLock(lockId, spendArgs, "0x")
1165
+ ).wait();
1166
+
1167
+ expect(await zeto.isLockActive(lockId)).to.equal(false);
1168
+ await expect(zeto.getLock(lockId))
1169
+ .to.be.revertedWithCustomError(zeto, "LockNotActive")
1170
+ .withArgs(lockId);
1171
+ // Re-spending a consumed lock MUST also fail — lockActive is
1172
+ // the first modifier and emits LockNotActive before
1173
+ // onlySpender has a chance to fire.
1174
+ await expect(
1175
+ zeto.connect(Bob.signer).spendLock(lockId, dummySpendArgs(), "0x"),
1176
+ )
1177
+ .to.be.revertedWithCustomError(zeto, "LockNotActive")
1178
+ .withArgs(lockId);
1179
+ });
1180
+
1181
+ it("an unlocked input flow rejects a locked UTXO with AlreadyLocked", async function () {
1182
+ // Mint a UTXO, lock it, then try to spend it via the regular
1183
+ // transfer path. The base-storage validateInputs() guards this
1184
+ // and reverts with AlreadyLocked on the locked input.
1185
+ const { lockedUtxo } = await freshLock(Bob);
1186
+
1187
+ await expect(
1188
+ doTransfer(
1189
+ Bob,
1190
+ [lockedUtxo, ZERO_UTXO],
1191
+ [newUTXO(100, Alice), ZERO_UTXO],
1192
+ [Alice, Alice],
1193
+ ),
1194
+ )
1195
+ .to.be.revertedWithCustomError(zeto, "AlreadyLocked")
1196
+ .withArgs(lockedUtxo.hash);
1197
+ });
702
1198
  });
703
1199
  });
704
1200
 
@@ -718,20 +1214,23 @@ describe("Zeto based fungible token with anonymity without encryption or nullifi
718
1214
  circuitToUse = batchCircuit;
719
1215
  provingKeyToUse = batchProvingKey;
720
1216
  }
721
- const result = await prepareProof(
1217
+ const inflatedInputUtxos = inflateUtxos(inputs);
1218
+ const inflatedOutputUtxos = inflateUtxos(outputs);
1219
+ const inflatedOwners = inflateOwners(owners);
1220
+
1221
+ encodedProof = await prepareProof(
722
1222
  circuitToUse,
723
1223
  provingKeyToUse,
724
1224
  signer,
725
- inputs,
726
- outputs,
727
- owners,
1225
+ inflatedInputUtxos,
1226
+ inflatedOutputUtxos,
1227
+ inflatedOwners,
728
1228
  );
729
- inputCommitments = result.inputCommitments;
730
- outputCommitments = result.outputCommitments;
1229
+ inputCommitments = inputs.map((input) => input.hash);
1230
+ outputCommitments = outputs.map((output) => output.hash);
731
1231
  outputOwnerAddresses = owners.map(
732
1232
  (owner) => owner.ethAddress || ZeroAddress,
733
1233
  ) as [AddressLike, AddressLike];
734
- encodedProof = result.encodedProof;
735
1234
 
736
1235
  return await sendTx(
737
1236
  signer,
@@ -746,100 +1245,92 @@ describe("Zeto based fungible token with anonymity without encryption or nullifi
746
1245
  inputCommitments: BigNumberish[],
747
1246
  outputCommitments: BigNumberish[],
748
1247
  encodedProof: any,
749
- isLocked = false,
750
1248
  ) {
751
- let tx;
752
- if (isLocked) {
753
- tx = await zeto.connect(signer.signer).transferLocked(
754
- inputCommitments.filter((ic) => ic !== 0n), // trim off empty utxo hashes to check padding logic for batching works
755
- outputCommitments.filter((oc) => oc !== 0n), // trim off empty utxo hashes to check padding logic for batching works
756
- encodedProof,
757
- "0x",
758
- );
759
- } else {
760
- tx = await zeto
761
- .connect(signer.signer)
762
- .transfer(inputCommitments, outputCommitments, encodedProof, "0x");
763
- }
1249
+ const proof = encodeToBytes(encodedProof);
1250
+ const tx = await zeto
1251
+ .connect(signer.signer)
1252
+ .transfer(inputCommitments, outputCommitments, proof, "0x");
764
1253
  const results = await tx.wait();
765
- console.log(`Method transfer() complete. Gas used: ${results?.gasUsed}`);
1254
+ logger.debug(`Method transfer() complete. Gas used: ${results?.gasUsed}`);
766
1255
 
767
1256
  for (const input of inputCommitments) {
768
1257
  if (input === 0n) {
769
1258
  continue;
770
1259
  }
771
- const owner = await zeto.spent(input);
772
- expect(owner).to.equal(true);
1260
+ const status = await zeto.spent(input);
1261
+ expect(status).to.equal(2n); // UTXOStatus.SPENT
773
1262
  }
774
1263
  for (const output of outputCommitments) {
775
1264
  if (output === 0n) {
776
1265
  continue;
777
1266
  }
778
- expect(await zeto.spent(output)).to.equal(false);
1267
+ const status = await zeto.spent(output);
1268
+ expect(status).to.equal(1n); // UTXOStatus.UNSPENT
779
1269
  }
780
1270
 
781
1271
  return results;
782
1272
  }
783
- });
784
1273
 
785
- async function prepareProof(
786
- circuit: any,
787
- provingKey: any,
788
- signer: User,
789
- inputs: UTXO[],
790
- outputs: UTXO[],
791
- owners: User[],
792
- ) {
793
- const inputCommitments: BigNumberish[] = inputs.map(
794
- (input) => input.hash,
795
- ) as BigNumberish[];
796
- const inputValues = inputs.map((input) => BigInt(input.value || 0n));
797
- const inputSalts = inputs.map((input) => input.salt || 0n);
798
- const outputCommitments: BigNumberish[] = outputs.map(
799
- (output) => output.hash,
800
- ) as BigNumberish[];
801
- const outputValues = outputs.map((output) => BigInt(output.value || 0n));
802
- const outputSalts = outputs.map((o) => o.salt || 0n);
803
- const outputOwnerPublicKeys: BigNumberish[][] = owners.map(
804
- (owner) => owner.babyJubPublicKey || ZERO_PUBKEY,
805
- ) as BigNumberish[][];
806
- const otherInputs = stringifyBigInts({
807
- inputOwnerPrivateKey: formatPrivKeyForBabyJub(signer.babyJubPrivateKey),
808
- });
1274
+ describe("Zeto_AnonBurnable", function () {
1275
+ it("(burnable) mint to Alice and burn a subset should succeed", async function () {
1276
+ const inputUtxos = [];
1277
+ for (let i = 0; i < 3; i++) {
1278
+ inputUtxos.push(newUTXO(10, Alice));
1279
+ }
1280
+ await doMint(zetoBurnable, deployer, inputUtxos);
1281
+
1282
+ const remainder = newUTXO(5, Alice);
1283
+
1284
+ const { inputCommitments, outputCommitment, encodedProof } =
1285
+ await prepareBurnProof(Alice, inputUtxos.slice(0, 2), remainder);
1286
+ const tx = await zetoBurnable
1287
+ .connect(Alice.signer)
1288
+ .burn(inputCommitments, outputCommitment, encodedProof, "0x");
1289
+ const result = await tx.wait();
1290
+ logger.debug(`Method burn() complete. Gas used: ${result?.gasUsed}`);
1291
+
1292
+ let spent = await zetoBurnable.spent(inputCommitments[0]);
1293
+ expect(spent).to.equal(2n); // UTXOStatus.SPENT
1294
+ spent = await zetoBurnable.spent(inputCommitments[1]);
1295
+ expect(spent).to.equal(2n); // UTXOStatus.SPENT
1296
+ spent = await zetoBurnable.spent(inputUtxos[2].hash as BigNumberish);
1297
+ expect(spent).to.equal(1n); // UTXOStatus.UNSPENT
1298
+ spent = await zetoBurnable.spent(outputCommitment);
1299
+ expect(spent).to.equal(1n); // UTXOStatus.UNSPENT (the burn remainder)
1300
+ });
809
1301
 
810
- const startWitnessCalculation = Date.now();
811
- const witness = await circuit.calculateWTNSBin(
812
- {
813
- inputCommitments,
814
- inputValues,
815
- inputSalts,
816
- outputCommitments,
817
- outputValues,
818
- outputSalts,
819
- outputOwnerPublicKeys,
820
- ...otherInputs,
821
- },
822
- true,
823
- );
824
- const timeWitnessCalculation = Date.now() - startWitnessCalculation;
825
-
826
- const startProofGeneration = Date.now();
827
- const { proof, publicSignals } = (await groth16.prove(
828
- provingKey,
829
- witness,
830
- )) as { proof: BigNumberish[]; publicSignals: BigNumberish[] };
831
- const timeProofGeneration = Date.now() - startProofGeneration;
832
- console.log(
833
- `Witness calculation time: ${timeWitnessCalculation}ms, Proof generation time: ${timeProofGeneration}ms`,
834
- );
835
- const encodedProof = encodeProof(proof);
836
- return {
837
- inputCommitments,
838
- outputCommitments,
839
- encodedProof,
840
- };
841
- }
1302
+ it("burn rejects already-spent inputs", async function () {
1303
+ const inputUtxos = [newUTXO(10, Alice), newUTXO(10, Alice)];
1304
+ await doMint(zetoBurnable, deployer, inputUtxos);
1305
+
1306
+ const remainder1 = newUTXO(5, Alice);
1307
+ const proof1 = await prepareBurnProof(Alice, inputUtxos, remainder1);
1308
+ await zetoBurnable
1309
+ .connect(Alice.signer)
1310
+ .burn(
1311
+ proof1.inputCommitments,
1312
+ proof1.outputCommitment,
1313
+ proof1.encodedProof,
1314
+ "0x",
1315
+ );
1316
+
1317
+ const remainder2 = newUTXO(5, Alice);
1318
+ const proof2 = await prepareBurnProof(Alice, inputUtxos, remainder2);
1319
+ await expect(
1320
+ zetoBurnable
1321
+ .connect(Alice.signer)
1322
+ .burn(
1323
+ proof2.inputCommitments,
1324
+ proof2.outputCommitment,
1325
+ proof2.encodedProof,
1326
+ "0x",
1327
+ ),
1328
+ ).to.be.rejectedWith("UTXOAlreadySpent");
1329
+ });
1330
+ });
1331
+ });
842
1332
 
843
1333
  module.exports = {
844
1334
  prepareProof,
1335
+ encodeToBytes,
845
1336
  };