@tokamak-private-dapps/private-state-cli 0.1.8 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.9 - 2026-05-03
4
+
5
+ - Used the bundled Groth16 package version as the default `private-state-cli --install` Groth16 runtime version.
6
+ - Treated stale local Groth16 CRS metadata without `compatibleBackendVersion` as a cache miss so the matching public CRS can be reinstalled.
7
+
3
8
  ## 0.1.8 - 2026-04-30
4
9
 
5
10
  - Reused common proof backend version helpers for Tokamak and Groth16 compatibility checks.
package/README.md CHANGED
@@ -2,6 +2,10 @@
2
2
 
3
3
  Command-line client for the Tokamak private-state DApp.
4
4
 
5
+ The full private-state DApp documentation is published with the repository:
6
+
7
+ - https://github.com/tokamak-network/Tokamak-zk-EVM-contracts/tree/main/packages/apps/private-state/docs
8
+
5
9
  ## Install
6
10
 
7
11
  ```bash
@@ -15,11 +19,12 @@ artifacts:
15
19
  private-state-cli --install
16
20
  ```
17
21
 
18
- By default, `--install` resolves the latest `@tokamak-zk-evm/cli` and `@tokamak-private-dapps/groth16` versions from
19
- the npm registry. To pin exact proof backend versions for a channel, pass explicit versions:
22
+ By default, `--install` resolves the latest `@tokamak-zk-evm/cli` from the npm registry and uses the bundled
23
+ `@tokamak-private-dapps/groth16` dependency version selected by the installed private-state CLI package. To pin exact
24
+ proof backend versions for a channel, pass explicit versions:
20
25
 
21
26
  ```bash
22
- private-state-cli --install --tokamak-zk-evm-cli-version 2.0.8 --groth16-cli-version 0.1.1
27
+ private-state-cli --install --tokamak-zk-evm-cli-version 2.1.0 --groth16-cli-version 0.2.0
23
28
  ```
24
29
 
25
30
  The Groth16 installer downloads the public Google Drive CRS archive whose major.minor compatibility version matches the
@@ -63,6 +68,19 @@ A common private-state flow is:
63
68
 
64
69
  Use `private-state-cli --help` for the full command list and required options.
65
70
 
71
+ Channel policy warning:
72
+
73
+ - `create-channel` commits to an immutable channel policy: verifier bindings, DApp execution metadata, function layout,
74
+ managed storage vector, and refund policy are fixed for that channel.
75
+ - `join-channel` means the user accepts the channel's current policy. Later policy-level fixes require a new channel or
76
+ migration; the existing channel is intentionally not mutated in place without renewed user consent.
77
+ - Before sending a channel-creation transaction or a first channel-registration transaction, the CLI prints the policy
78
+ snapshot that will be accepted: DApp metadata digest, digest schema, Groth16 verifier address, Groth16 compatible
79
+ backend version, Tokamak verifier address, and Tokamak compatible backend version.
80
+ - Users and operators must review this snapshot before signing. If any digest, schema, verifier address, or compatible
81
+ backend version is unexpected or has not been reviewed, do not create or join the channel. A later correction creates
82
+ a new channel; it does not rewrite the policy of an already-created channel.
83
+
66
84
  `private-state-cli --doctor` reports the CLI package version, dependency versions recorded by the last
67
85
  `private-state-cli --install`, selected proof backend runtime versions, current dependency versions through `tokamak-l2js`, and Tokamak zk-EVM runtime
68
86
  install mode, Docker mode, CUDA runtime metadata, live `nvidia-smi` and Docker GPU probe results, and Groth16
@@ -117,6 +135,9 @@ Operating rules:
117
135
  telling the user to move funds.
118
136
  - Explain that wallet names are local CLI identifiers, while private transfers use notes owned by L2 addresses
119
137
  registered in the channel.
138
+ - Before guiding a user through `create-channel` or `join-channel`, explain that channel policy is immutable after
139
+ creation and that joining a channel means accepting its current verifier, DApp metadata, function layout, managed
140
+ storage vector, and refund policy.
120
141
  - Do not present one fixed command sequence as universally correct. Some flows start from an existing channel or wallet,
121
142
  while others require creating or joining a channel first.
122
143
  - When the user asks for a transfer, first determine whether the sender has minted notes available. If not, guide them
@@ -400,6 +400,11 @@
400
400
  case, you lose ownership of all notes because you can no longer use them, and that ownership cannot be
401
401
  recovered.
402
402
  </li>
403
+ <li>
404
+ Channel policy is immutable after creation. Joining a channel means accepting its verifier bindings,
405
+ DApp metadata, function layout, managed storage vector, and refund policy; later policy-level fixes
406
+ require a new channel or migration.
407
+ </li>
403
408
  </ul>
404
409
  </div>
405
410
  </section>
@@ -521,12 +526,12 @@
521
526
  const commands = [
522
527
  { id: "install-zk-evm", description: "Install the local Tokamak zk-EVM toolchain. Optionally forward --docker for Linux-host Docker installs.", fields: ["docker"] },
523
528
  { id: "uninstall-zk-evm", description: "Remove the Tokamak zk-EVM CLI runtime workspace.", fields: [] },
524
- { id: "create-channel", description: "Create the bridge channel and initialize its workspace.", fields: ["channelName", "network", "privateKey", "alchemyApiKey"] },
529
+ { id: "create-channel", description: "Create a channel with an immutable operating policy and initialize its workspace.", fields: ["channelName", "network", "privateKey", "alchemyApiKey"] },
525
530
  { id: "recover-workspace", description: "Rebuild the saved channel workspace from bridge state.", fields: ["channelName", "network", "alchemyApiKey"] },
526
531
  { id: "deposit-bridge", description: "Deposit canonical tokens into the shared bridge vault.", fields: ["amount", "network", "privateKey", "alchemyApiKey"] },
527
532
  { id: "withdraw-bridge", description: "Withdraw shared bridge-vault funds back to the L1 wallet.", fields: ["amount", "network", "privateKey", "alchemyApiKey"] },
528
533
  { id: "get-my-bridge-fund", description: "Read the current bridge-vault balance.", fields: ["network", "privateKey", "alchemyApiKey"] },
529
- { id: "join-channel", description: "Bind the caller to a channel-specific L2 identity.", fields: ["channelName", "password", "network", "privateKey", "alchemyApiKey"] },
534
+ { id: "join-channel", description: "Accept the channel policy and bind the caller to a channel-specific L2 identity.", fields: ["channelName", "password", "network", "privateKey", "alchemyApiKey"] },
530
535
  { id: "recover-wallet", description: "Rebuild the recoverable portion of a wallet.", fields: ["channelName", "password", "network", "privateKey", "alchemyApiKey"] },
531
536
  { id: "get-my-wallet-meta", description: "Check whether a saved wallet matches on-chain registration.", fields: ["wallet", "password", "network"] },
532
537
  { id: "get-my-l1-address", description: "Derive the L1 address for a private key.", fields: ["privateKey"] },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokamak-private-dapps/private-state-cli",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "description": "Command-line client for the Tokamak private-state DApp.",
5
5
  "license": "MIT OR Apache-2.0",
6
6
  "author": "Tokamak Network",
@@ -43,10 +43,10 @@
43
43
  "@ethereumjs/util": "^10.1.1",
44
44
  "@noble/curves": "1.9.7",
45
45
  "@tokamak-private-dapps/common-library": "^0.1.1",
46
- "@tokamak-private-dapps/groth16": "^0.1.4",
47
- "@tokamak-zk-evm/cli": "^2.0.13",
46
+ "@tokamak-private-dapps/groth16": "^0.2.0",
47
+ "@tokamak-zk-evm/cli": "^2.1.0",
48
48
  "ethers": "^6.14.1",
49
- "tokamak-l2js": "^0.1.3"
49
+ "tokamak-l2js": "^0.1.4"
50
50
  },
51
51
  "engines": {
52
52
  "node": ">=18"
@@ -166,6 +166,68 @@ const DEFAULT_LOG_REQUESTS_PER_SECOND = 5;
166
166
  const LOG_REQUEST_INTERVAL_MS = Math.ceil(1000 / DEFAULT_LOG_REQUESTS_PER_SECOND);
167
167
  let lastLogRequestStartedAtMs = 0;
168
168
 
169
+ function printImmutableChannelPolicyWarning({
170
+ action,
171
+ channelName,
172
+ channelId,
173
+ channelManager = null,
174
+ policySnapshot = null,
175
+ }) {
176
+ const details = [
177
+ `WARNING: ${action} commits to an immutable channel policy.`,
178
+ `Channel: ${channelName} (${channelId.toString()})`,
179
+ ];
180
+ if (channelManager) {
181
+ details.push(`ChannelManager: ${channelManager}`);
182
+ }
183
+ details.push(
184
+ "The channel verifier bindings, DApp execution metadata, function layout, managed storage vector, and refund policy are fixed for this channel.",
185
+ "Those policy fields are intentionally not upgraded in place without channel-user consent.",
186
+ "If a policy bug is discovered later, the expected mitigation is creating or joining a new channel, not mutating this channel.",
187
+ "Review the DApp digest, digest schema, verifier addresses, and compatible backend versions before signing.",
188
+ );
189
+ if (policySnapshot) {
190
+ details.push(
191
+ "Channel policy snapshot:",
192
+ ` DApp id: ${policySnapshot.dappId}`,
193
+ ` DApp metadata digest schema: ${policySnapshot.dappMetadataDigestSchema}`,
194
+ ` DApp metadata digest: ${policySnapshot.dappMetadataDigest}`,
195
+ ` DApp function root: ${policySnapshot.functionRoot}`,
196
+ ` Groth16 verifier: ${policySnapshot.grothVerifier}`,
197
+ ` Groth16 compatible backend version: ${policySnapshot.grothVerifierCompatibleBackendVersion}`,
198
+ ` Tokamak verifier: ${policySnapshot.tokamakVerifier}`,
199
+ ` Tokamak compatible backend version: ${policySnapshot.tokamakVerifierCompatibleBackendVersion}`,
200
+ "Do not sign if any snapshot value is unexpected or has not been reviewed.",
201
+ );
202
+ }
203
+ console.error(details.join("\n"));
204
+ }
205
+
206
+ function normalizeDAppPolicySnapshot({
207
+ dappId,
208
+ metadataDigest,
209
+ metadataDigestSchema,
210
+ functionRoot,
211
+ verifierSnapshot,
212
+ }) {
213
+ return {
214
+ dappId: Number(dappId),
215
+ dappMetadataDigestSchema: normalizeBytes32Hex(metadataDigestSchema),
216
+ dappMetadataDigest: normalizeBytes32Hex(metadataDigest),
217
+ functionRoot: normalizeBytes32Hex(functionRoot),
218
+ grothVerifier: getAddress(verifierSnapshot.grothVerifier),
219
+ grothVerifierCompatibleBackendVersion: requireVersionString(
220
+ verifierSnapshot.grothVerifierCompatibleBackendVersion,
221
+ "registered DApp Groth16 verifier compatibleBackendVersion",
222
+ ),
223
+ tokamakVerifier: getAddress(verifierSnapshot.tokamakVerifier),
224
+ tokamakVerifierCompatibleBackendVersion: requireVersionString(
225
+ verifierSnapshot.tokamakVerifierCompatibleBackendVersion,
226
+ "registered DApp Tokamak verifier compatibleBackendVersion",
227
+ ),
228
+ };
229
+ }
230
+
169
231
  async function prepareDeploymentArtifacts(chainId) {
170
232
  const normalizedChainId = Number(chainId);
171
233
  const existingPaths = flatDeploymentArtifactPathsByChainId.get(normalizedChainId);
@@ -373,16 +435,25 @@ async function handleChannelCreate({ args, network, provider }) {
373
435
  );
374
436
  const canonicalAsset = getAddress(await bridgeCore.canonicalAsset());
375
437
  const canonicalAssetDecimals = await fetchTokenDecimals(provider, canonicalAsset);
376
- const joinFeeInput = requireArg(args.joinFee, "--join-fee");
377
- const joinFee = parseTokenAmount(joinFeeInput, canonicalAssetDecimals);
438
+ const joinTollInput = requireArg(args.joinToll, "--join-toll");
439
+ const joinToll = parseTokenAmount(joinTollInput, canonicalAssetDecimals);
378
440
  const channelId = deriveChannelIdFromName(channelName);
379
- const dappId = await resolveDAppIdByLabel({
441
+ const dapp = await resolveDAppIdByLabel({
380
442
  provider,
381
443
  bridgeResources,
382
444
  dappLabel: PRIVATE_STATE_DAPP_LABEL,
383
445
  });
446
+ const dappId = dapp.dappId;
447
+ const policySnapshot = dapp.policySnapshot;
384
448
 
385
- const receipt = await waitForReceipt(await bridgeCore.createChannel(channelId, dappId, leader, joinFee));
449
+ printImmutableChannelPolicyWarning({
450
+ action: "create-channel",
451
+ channelName,
452
+ channelId,
453
+ policySnapshot,
454
+ });
455
+ const receipt =
456
+ await waitForReceipt(await bridgeCore.createChannel(channelId, dappId, joinToll, dapp.metadataDigest));
386
457
  const channelInfo = await bridgeCore.getChannel(channelId);
387
458
 
388
459
  const workspaceResult = await initializeChannelWorkspace({
@@ -399,9 +470,12 @@ async function handleChannelCreate({ args, network, provider }) {
399
470
  channelName,
400
471
  channelId: channelId.toString(),
401
472
  dappId,
473
+ dappMetadataDigest: dapp.metadataDigest,
474
+ dappMetadataDigestSchema: dapp.metadataDigestSchema,
475
+ policySnapshot,
402
476
  leader,
403
- joinFeeBaseUnits: joinFee.toString(),
404
- joinFeeTokens: ethers.formatUnits(joinFee, canonicalAssetDecimals),
477
+ joinTollBaseUnits: joinToll.toString(),
478
+ joinTollTokens: ethers.formatUnits(joinToll, canonicalAssetDecimals),
405
479
  canonicalAsset,
406
480
  canonicalAssetDecimals,
407
481
  asset: channelInfo.asset,
@@ -426,9 +500,21 @@ async function resolveDAppIdByLabel({ provider, bridgeResources, dappLabel }) {
426
500
  const manifestLabel = typeof manifest.dappLabel === "string" ? manifest.dappLabel : null;
427
501
  const manifestDappId = manifest.dappId;
428
502
  const manifestManager = typeof manifest.dAppManager === "string" ? getAddress(manifest.dAppManager) : null;
503
+ const manifestMetadataDigest = normalizeBytes32Hex(manifest.registration?.metadataDigest);
504
+ const manifestMetadataDigestSchema = normalizeBytes32Hex(manifest.registration?.metadataDigestSchema);
505
+ const manifestFunctionRoot = normalizeBytes32Hex(manifest.registration?.functionRoot);
429
506
 
430
507
  expect(manifestLabel === dappLabel, `DApp registration manifest label mismatch in ${manifestPath}.`);
431
508
  expect(Number.isInteger(manifestDappId), `DApp registration manifest is missing an integer dappId: ${manifestPath}.`);
509
+ expect(manifestMetadataDigest !== null, `DApp registration manifest is missing registration.metadataDigest: ${manifestPath}.`);
510
+ expect(
511
+ manifestMetadataDigestSchema !== null,
512
+ `DApp registration manifest is missing registration.metadataDigestSchema: ${manifestPath}.`,
513
+ );
514
+ expect(
515
+ manifestFunctionRoot !== null,
516
+ `DApp registration manifest is missing registration.functionRoot: ${manifestPath}.`,
517
+ );
432
518
  expect(
433
519
  manifestManager !== null
434
520
  && ethers.toBigInt(manifestManager) === ethers.toBigInt(getAddress(bridgeResources.bridgeDeployment.dAppManager)),
@@ -441,7 +527,36 @@ async function resolveDAppIdByLabel({ provider, bridgeResources, dappLabel }) {
441
527
  ethers.toBigInt(normalizeBytes32Hex(info.labelHash)) === ethers.toBigInt(expectedLabelHash),
442
528
  `DApp id ${manifestDappId} from ${manifestPath} does not match label ${dappLabel} on-chain.`,
443
529
  );
444
- return Number(manifestDappId);
530
+ const onchainMetadataDigest = normalizeBytes32Hex(info.metadataDigest);
531
+ const onchainMetadataDigestSchema = normalizeBytes32Hex(info.metadataDigestSchema);
532
+ const onchainFunctionRoot = normalizeBytes32Hex(info.functionRoot);
533
+ const verifierSnapshot = await dAppManager.getDAppVerifierSnapshot(manifestDappId);
534
+ const policySnapshot = normalizeDAppPolicySnapshot({
535
+ dappId: manifestDappId,
536
+ metadataDigest: onchainMetadataDigest,
537
+ metadataDigestSchema: onchainMetadataDigestSchema,
538
+ functionRoot: onchainFunctionRoot,
539
+ verifierSnapshot,
540
+ });
541
+ expect(
542
+ ethers.toBigInt(onchainMetadataDigest) === ethers.toBigInt(manifestMetadataDigest),
543
+ `DApp id ${manifestDappId} metadata digest ${onchainMetadataDigest} does not match ${manifestMetadataDigest} from ${manifestPath}.`,
544
+ );
545
+ expect(
546
+ ethers.toBigInt(onchainMetadataDigestSchema) === ethers.toBigInt(manifestMetadataDigestSchema),
547
+ `DApp id ${manifestDappId} metadata digest schema ${onchainMetadataDigestSchema} does not match ${manifestMetadataDigestSchema} from ${manifestPath}.`,
548
+ );
549
+ expect(
550
+ ethers.toBigInt(onchainFunctionRoot) === ethers.toBigInt(manifestFunctionRoot),
551
+ `DApp id ${manifestDappId} function root ${onchainFunctionRoot} does not match ${manifestFunctionRoot} from ${manifestPath}.`,
552
+ );
553
+ return {
554
+ dappId: Number(manifestDappId),
555
+ metadataDigest: onchainMetadataDigest,
556
+ metadataDigestSchema: onchainMetadataDigestSchema,
557
+ functionRoot: onchainFunctionRoot,
558
+ policySnapshot,
559
+ };
445
560
  }
446
561
 
447
562
  async function handleWorkspaceInit({ args, network, provider }) {
@@ -514,6 +629,10 @@ async function initializeChannelWorkspace({
514
629
  const currentRootVectorHash = normalizeBytes32Hex(await channelManager.currentRootVectorHash());
515
630
  const genesisBlockNumber = Number(await channelManager.genesisBlockNumber());
516
631
  const managedStorageAddresses = normalizedAddressVector(await channelManager.getManagedStorageAddresses());
632
+ const policySnapshot = await readChannelPolicySnapshot({
633
+ channelManager,
634
+ dappId: Number(channelInfo.dappId),
635
+ });
517
636
  const deploymentManifestPath = dappDeploymentManifestPath(network.chainId);
518
637
  const storageLayoutManifestPath = dappStorageLayoutManifestPath(network.chainId);
519
638
  const deploymentManifest = readJson(deploymentManifestPath);
@@ -580,6 +699,10 @@ async function initializeChannelWorkspace({
580
699
  controller: controllerAddress,
581
700
  l2AccountingVault: l2AccountingVaultAddress,
582
701
  aPubBlockHash: normalizeBytes32Hex(channelInfo.aPubBlockHash),
702
+ dappMetadataDigestSchema: policySnapshot.dappMetadataDigestSchema,
703
+ dappMetadataDigest: policySnapshot.dappMetadataDigest,
704
+ functionRoot: policySnapshot.functionRoot,
705
+ policySnapshot,
583
706
  managedStorageAddresses,
584
707
  liquidBalancesSlot: liquidBalancesSlot.toString(),
585
708
  };
@@ -1194,20 +1317,27 @@ async function handleJoinChannel({ args, network, provider, rpcUrl }) {
1194
1317
  let resolvedLeafIndex = leafIndex;
1195
1318
  let approveReceipt = null;
1196
1319
  let receipt = null;
1197
- let joinFee = 0n;
1320
+ let joinToll = 0n;
1198
1321
  let status = null;
1199
1322
 
1200
1323
  if (!existingRegistration.exists) {
1201
- joinFee = ethers.toBigInt(await context.channelManager.joinFee());
1324
+ joinToll = ethers.toBigInt(await context.channelManager.joinToll());
1202
1325
  const asset = new Contract(
1203
1326
  context.workspace.canonicalAsset,
1204
1327
  context.bridgeAbiManifest.contracts.erc20.abi,
1205
1328
  signer,
1206
1329
  );
1207
1330
  let nextNonce = await provider.getTransactionCount(signer.address, "pending");
1208
- if (joinFee !== 0n) {
1331
+ printImmutableChannelPolicyWarning({
1332
+ action: "join-channel",
1333
+ channelName: context.workspace.channelName,
1334
+ channelId: ethers.toBigInt(context.workspace.channelId),
1335
+ channelManager: context.workspace.channelManager,
1336
+ policySnapshot: context.workspace.policySnapshot,
1337
+ });
1338
+ if (joinToll !== 0n) {
1209
1339
  approveReceipt = await waitForReceipt(
1210
- await asset.approve(context.workspace.bridgeTokenVault, joinFee, { nonce: nextNonce++ }),
1340
+ await asset.approve(context.workspace.bridgeTokenVault, joinToll, { nonce: nextNonce++ }),
1211
1341
  );
1212
1342
  }
1213
1343
  receipt = await waitForReceipt(
@@ -1241,7 +1371,7 @@ async function handleJoinChannel({ args, network, provider, rpcUrl }) {
1241
1371
  "The existing note-receive public key parity does not match the derived note-receive public key.",
1242
1372
  );
1243
1373
  resolvedLeafIndex = existingRegistration.leafIndex;
1244
- joinFee = ethers.toBigInt(existingRegistration.joinFeePaid);
1374
+ joinToll = ethers.toBigInt(existingRegistration.joinTollPaid);
1245
1375
  status = "already-registered";
1246
1376
  }
1247
1377
 
@@ -1267,9 +1397,10 @@ async function handleJoinChannel({ args, network, provider, rpcUrl }) {
1267
1397
  l2Address: l2Identity.l2Address,
1268
1398
  l2StorageKey: storageKey,
1269
1399
  leafIndex: resolvedLeafIndex.toString(),
1270
- joinFeeBaseUnits: joinFee.toString(),
1271
- joinFeeTokens: ethers.formatUnits(joinFee, Number(context.workspace.canonicalAssetDecimals)),
1400
+ joinTollBaseUnits: joinToll.toString(),
1401
+ joinTollTokens: ethers.formatUnits(joinToll, Number(context.workspace.canonicalAssetDecimals)),
1272
1402
  noteReceivePubKey: noteReceiveKeyMaterial.noteReceivePubKey,
1403
+ policySnapshot: context.workspace.policySnapshot,
1273
1404
  approveGasUsed: approveReceipt ? receiptGasUsed(approveReceipt) : null,
1274
1405
  gasUsed: receipt ? receiptGasUsed(receipt) : null,
1275
1406
  approveTxUrl: approveReceipt ? explorerTxUrl(network, approveReceipt.hash) : null,
@@ -1296,7 +1427,7 @@ async function handleExitChannel({ args, provider }) {
1296
1427
  "Run withdraw-channel first, or rerun exit-channel with --force to bypass this CLI check.",
1297
1428
  ].join(" "),
1298
1429
  );
1299
- const [refundAmount, refundBps] = await context.channelManager.getExitFeeRefundQuote(signer.address);
1430
+ const [refundAmount, refundBps] = await context.channelManager.getExitTollRefundQuote(signer.address);
1300
1431
  const receipt = await waitForReceipt(
1301
1432
  await context.bridgeTokenVault.connect(signer).exitChannel(ethers.toBigInt(context.workspace.channelId)),
1302
1433
  );
@@ -1402,8 +1533,9 @@ async function handleGrothVaultMove({ args, provider, direction }) {
1402
1533
  nextValue,
1403
1534
  });
1404
1535
 
1536
+ const methodName = direction === "deposit" ? "depositToChannelVault" : "withdrawFromChannelVault";
1405
1537
  const receipt = await waitForReceipt(
1406
- await bridgeTokenVault[direction](ethers.toBigInt(context.workspace.channelId), transition.proof, transition.update),
1538
+ await bridgeTokenVault[methodName](ethers.toBigInt(context.workspace.channelId), transition.proof, transition.update),
1407
1539
  );
1408
1540
  const onchainRootVectorHash = normalizeBytes32Hex(await context.channelManager.currentRootVectorHash());
1409
1541
  expect(
@@ -1462,6 +1594,63 @@ async function handleWithdrawBridge({ args, network, provider }) {
1462
1594
  });
1463
1595
  }
1464
1596
 
1597
+ function resolveFunctionMetadataProofForExecution({
1598
+ chainId,
1599
+ controllerAddress,
1600
+ functionSelector,
1601
+ preprocessInputHash,
1602
+ expectedFunctionRoot,
1603
+ }) {
1604
+ const manifestPath = requireFlatDeploymentArtifactPathsForChainId(chainId).dappRegistrationPath;
1605
+ const manifest = readJson(manifestPath);
1606
+ const proofRoot = normalizeBytes32Hex(manifest.functionMetadataProofs?.root);
1607
+ const expectedRoot = normalizeBytes32Hex(expectedFunctionRoot);
1608
+ expect(
1609
+ ethers.toBigInt(proofRoot) === ethers.toBigInt(expectedRoot),
1610
+ `DApp function proof root ${proofRoot} does not match channel function root ${expectedRoot}.`,
1611
+ );
1612
+
1613
+ const functions = manifest.functionMetadataProofs?.functions;
1614
+ expect(Array.isArray(functions), `DApp registration manifest is missing functionMetadataProofs.functions: ${manifestPath}.`);
1615
+ const normalizedController = getAddress(controllerAddress);
1616
+ const normalizedSelector = normalizeBytesHex(functionSelector, 4);
1617
+ const normalizedPreprocessInputHash = normalizeBytes32Hex(preprocessInputHash);
1618
+ const entry = functions.find((candidate) => {
1619
+ const metadata = candidate?.metadata;
1620
+ return metadata
1621
+ && getAddress(metadata.entryContract) === normalizedController
1622
+ && normalizeBytesHex(metadata.functionSig, 4) === normalizedSelector
1623
+ && normalizeBytes32Hex(metadata.preprocessInputHash) === normalizedPreprocessInputHash;
1624
+ });
1625
+ expect(
1626
+ entry !== undefined,
1627
+ [
1628
+ `No DApp function metadata proof found for ${normalizedController} ${normalizedSelector}.`,
1629
+ `Expected preprocess input hash: ${normalizedPreprocessInputHash}.`,
1630
+ `Manifest: ${manifestPath}.`,
1631
+ ].join(" "),
1632
+ );
1633
+
1634
+ return {
1635
+ metadata: {
1636
+ entryContract: getAddress(entry.metadata.entryContract),
1637
+ functionSig: normalizeBytesHex(entry.metadata.functionSig, 4),
1638
+ preprocessInputHash: normalizeBytes32Hex(entry.metadata.preprocessInputHash),
1639
+ instanceLayout: {
1640
+ entryContractOffsetWords: Number(entry.metadata.instanceLayout.entryContractOffsetWords),
1641
+ functionSigOffsetWords: Number(entry.metadata.instanceLayout.functionSigOffsetWords),
1642
+ currentRootVectorOffsetWords: Number(entry.metadata.instanceLayout.currentRootVectorOffsetWords),
1643
+ updatedRootVectorOffsetWords: Number(entry.metadata.instanceLayout.updatedRootVectorOffsetWords),
1644
+ eventLogs: entry.metadata.instanceLayout.eventLogs.map((eventLog) => ({
1645
+ startOffsetWords: Number(eventLog.startOffsetWords),
1646
+ topicCount: Number(eventLog.topicCount),
1647
+ })),
1648
+ },
1649
+ },
1650
+ siblings: entry.merkleProof.map((sibling) => normalizeBytes32Hex(sibling)),
1651
+ };
1652
+ }
1653
+
1465
1654
  async function handleMintNotes({ args, provider }) {
1466
1655
  const { wallet } = loadUnlockedWalletWithMetadata(args);
1467
1656
  const canonicalAssetDecimals = Number(wallet.wallet.canonicalAssetDecimals);
@@ -3118,6 +3307,16 @@ async function executeWalletTemplateSend({
3118
3307
  writeJson(path.join(operationDir, "resource", "synthesizer", "output", "state_snapshot.normalized.json"), nextSnapshot);
3119
3308
 
3120
3309
  const payload = loadTokamakPayloadFromStep(operationDir);
3310
+ const functionProof = resolveFunctionMetadataProofForExecution({
3311
+ chainId: context.workspace.chainId,
3312
+ controllerAddress: context.workspace.controller,
3313
+ functionSelector: calldata.slice(0, 10),
3314
+ preprocessInputHash: hashTokamakPointEncoding(
3315
+ payload.functionPreprocessPart1,
3316
+ payload.functionPreprocessPart2,
3317
+ ),
3318
+ expectedFunctionRoot: context.workspace.functionRoot ?? context.workspace.policySnapshot?.functionRoot,
3319
+ });
3121
3320
  const noteLifecycle = extractControllerStorageDelta({
3122
3321
  previousSnapshot: context.currentSnapshot,
3123
3322
  nextSnapshot,
@@ -3132,7 +3331,9 @@ async function executeWalletTemplateSend({
3132
3331
  );
3133
3332
 
3134
3333
  const receipt =
3135
- await waitForReceipt(await context.channelManager.connect(signer).executeChannelTransaction(payload));
3334
+ await waitForReceipt(
3335
+ await context.channelManager.connect(signer).executeChannelTransaction(payload, functionProof),
3336
+ );
3136
3337
 
3137
3338
  const onchainRootVectorHash = normalizeBytes32Hex(await context.channelManager.currentRootVectorHash());
3138
3339
  expect(
@@ -3517,6 +3718,53 @@ async function readChannelVerifierCompatibleBackendVersions(context) {
3517
3718
  }
3518
3719
  }
3519
3720
 
3721
+ async function readChannelPolicySnapshot({ channelManager, dappId }) {
3722
+ const channelManagerAddress = getAddress(await channelManager.getAddress());
3723
+ try {
3724
+ const [
3725
+ dappMetadataDigestSchema,
3726
+ dappMetadataDigest,
3727
+ functionRoot,
3728
+ grothVerifier,
3729
+ grothVerifierCompatibleBackendVersion,
3730
+ tokamakVerifier,
3731
+ tokamakVerifierCompatibleBackendVersion,
3732
+ ] = await Promise.all([
3733
+ channelManager.dappMetadataDigestSchema(),
3734
+ channelManager.dappMetadataDigest(),
3735
+ channelManager.functionRoot(),
3736
+ channelManager.grothVerifier(),
3737
+ channelManager.grothVerifierCompatibleBackendVersion(),
3738
+ channelManager.tokamakVerifier(),
3739
+ channelManager.tokamakVerifierCompatibleBackendVersion(),
3740
+ ]);
3741
+ return {
3742
+ dappId: Number(dappId),
3743
+ dappMetadataDigestSchema: normalizeBytes32Hex(dappMetadataDigestSchema),
3744
+ dappMetadataDigest: normalizeBytes32Hex(dappMetadataDigest),
3745
+ functionRoot: normalizeBytes32Hex(functionRoot),
3746
+ grothVerifier: getAddress(grothVerifier),
3747
+ grothVerifierCompatibleBackendVersion: requireVersionString(
3748
+ grothVerifierCompatibleBackendVersion,
3749
+ "channel Groth16 verifier compatibleBackendVersion",
3750
+ ),
3751
+ tokamakVerifier: getAddress(tokamakVerifier),
3752
+ tokamakVerifierCompatibleBackendVersion: requireVersionString(
3753
+ tokamakVerifierCompatibleBackendVersion,
3754
+ "channel Tokamak verifier compatibleBackendVersion",
3755
+ ),
3756
+ };
3757
+ } catch (error) {
3758
+ throw new Error(
3759
+ [
3760
+ `Unable to read immutable policy snapshot from channel manager ${channelManagerAddress}.`,
3761
+ "The target channel must expose DApp digest, verifier address, and compatibleBackendVersion getters.",
3762
+ ].join(" "),
3763
+ { cause: error },
3764
+ );
3765
+ }
3766
+ }
3767
+
3520
3768
  function readLocalProofBackendPackageVersions() {
3521
3769
  const groth16Runtime = inspectGroth16Runtime();
3522
3770
  const tokamakPackageReport = readTokamakCliPackageReport();
@@ -3927,6 +4175,10 @@ function hashTokamakPublicInputs(values) {
3927
4175
  return keccak256(abiCoder.encode(["uint256[]"], [values]));
3928
4176
  }
3929
4177
 
4178
+ function hashTokamakPointEncoding(part1, part2) {
4179
+ return keccak256(abiCoder.encode(["uint128[]", "uint256[]"], [part1, part2]));
4180
+ }
4181
+
3930
4182
  function encodeTokamakBlockInfo(blockInfo) {
3931
4183
  const values = new Array(TOKAMAK_APUB_BLOCK_LENGTH).fill(0n);
3932
4184
  appendSplitWord(values, 0, ethers.toBigInt(blockInfo.coinBase));
@@ -4813,15 +5065,15 @@ function assertGetMyNotesArgs(args) {
4813
5065
 
4814
5066
  function assertCreateChannelArgs(args) {
4815
5067
  requireArg(args.channelName, "--channel-name");
4816
- requireArg(args.joinFee, "--join-fee");
5068
+ requireArg(args.joinToll, "--join-toll");
4817
5069
  requireNetworkName(args);
4818
5070
  requireAlchemyApiKeyForPublicNetwork(args, "create-channel");
4819
5071
  requireArg(args.privateKey, "--private-key");
4820
5072
  assertAllowedCommandKeys(
4821
5073
  args,
4822
5074
  "create-channel",
4823
- new Set(["command", "positional", "channelName", "joinFee", "network", "alchemyApiKey", "privateKey"]),
4824
- "--channel-name, --join-fee, --network, --private-key, and --alchemy-api-key on public networks",
5075
+ new Set(["command", "positional", "channelName", "joinToll", "network", "alchemyApiKey", "privateKey"]),
5076
+ "--channel-name, --join-toll, --network, --private-key, and --alchemy-api-key on public networks",
4825
5077
  );
4826
5078
  }
4827
5079
 
@@ -4983,8 +5235,9 @@ Commands:
4983
5235
  --doctor
4984
5236
  Check private-state CLI package versions, runtime install state, Docker mode, CUDA mode, and deployment artifacts
4985
5237
 
4986
- create-channel --channel-name <NAME> --join-fee <TOKENS> --network <NAME> --private-key <HEX> --alchemy-api-key <KEY>
5238
+ create-channel --channel-name <NAME> --join-toll <TOKENS> --network <NAME> --private-key <HEX> --alchemy-api-key <KEY>
4987
5239
  Create a bridge channel and initialize its workspace
5240
+ Prints the immutable policy snapshot before sending the transaction
4988
5241
 
4989
5242
  recover-workspace --channel-name <NAME> --network <NAME> --alchemy-api-key <KEY>
4990
5243
  Rebuild the local channel workspace from bridge state
@@ -5002,7 +5255,8 @@ Commands:
5002
5255
  Rebuild a recoverable local wallet from on-chain channel state
5003
5256
 
5004
5257
  join-channel --channel-name <NAME> --password <PASSWORD> --network <NAME> --private-key <HEX> --alchemy-api-key <KEY>
5005
- Pay the channel join fee and bind a wallet to a channel-specific L2 identity
5258
+ Pay the channel join toll and bind a wallet to a channel-specific L2 identity
5259
+ Prints the immutable policy snapshot before first registration
5006
5260
 
5007
5261
  get-my-wallet-meta --wallet <NAME> --password <PASSWORD> --network <NAME>
5008
5262
  Check whether a wallet matches the on-chain channel registration
@@ -5468,11 +5722,7 @@ function buildSelectedRuntimeVersionCheck({ installManifest, tokamakCli, groth16
5468
5722
 
5469
5723
  async function resolvePrivateStateInstallRuntimeVersions(args) {
5470
5724
  const [groth16, tokamak] = await Promise.all([
5471
- resolveRequestedNpmPackageVersion({
5472
- packageName: GROTH16_PACKAGE_NAME,
5473
- requestedVersion: args.groth16CliVersion,
5474
- optionName: "--groth16-cli-version",
5475
- }),
5725
+ resolveRequestedGroth16PackageVersion(args.groth16CliVersion),
5476
5726
  resolveRequestedNpmPackageVersion({
5477
5727
  packageName: TOKAMAK_ZKEVM_CLI_PACKAGE_NAME,
5478
5728
  requestedVersion: args.tokamakZkEvmCliVersion,
@@ -5482,6 +5732,19 @@ async function resolvePrivateStateInstallRuntimeVersions(args) {
5482
5732
  return { groth16, tokamak };
5483
5733
  }
5484
5734
 
5735
+ async function resolveRequestedGroth16PackageVersion(requestedVersion) {
5736
+ if (requestedVersion !== undefined && requestedVersion !== null) {
5737
+ return resolveRequestedNpmPackageVersion({
5738
+ packageName: GROTH16_PACKAGE_NAME,
5739
+ requestedVersion,
5740
+ optionName: "--groth16-cli-version",
5741
+ });
5742
+ }
5743
+
5744
+ const bundledPackageJson = readJson(path.join(resolveGroth16PackageRoot(), "package.json"));
5745
+ return requireSemverVersion(bundledPackageJson.version, `${GROTH16_PACKAGE_NAME} bundled package version`);
5746
+ }
5747
+
5485
5748
  async function resolveRequestedNpmPackageVersion({ packageName, requestedVersion, optionName }) {
5486
5749
  const metadata = await fetchNpmPackageMetadata(packageName);
5487
5750
  if (requestedVersion === undefined || requestedVersion === null) {
@@ -5532,10 +5795,7 @@ async function installTokamakCliRuntimeForPrivateState({ version, docker }) {
5532
5795
  }
5533
5796
 
5534
5797
  async function installGroth16RuntimeForPrivateState({ version, docker }) {
5535
- const packageInstall = installManagedNpmPackage({
5536
- packageName: GROTH16_PACKAGE_NAME,
5537
- version,
5538
- });
5798
+ const packageInstall = resolveGroth16RuntimePackageInstall(version);
5539
5799
  const packageRoot = packageInstall.packageRoot;
5540
5800
  const entryPath = resolveGroth16CliEntryPath(packageRoot);
5541
5801
  const args = [entryPath, "--install", "--no-setup"];
@@ -5561,9 +5821,32 @@ async function installGroth16RuntimeForPrivateState({ version, docker }) {
5561
5821
  };
5562
5822
  }
5563
5823
 
5824
+ function resolveGroth16RuntimePackageInstall(version) {
5825
+ const normalizedVersion = requireSemverVersion(version, `${GROTH16_PACKAGE_NAME} version`);
5826
+ const bundledPackageRoot = resolveGroth16PackageRoot();
5827
+ const bundledPackageJson = readJson(path.join(bundledPackageRoot, "package.json"));
5828
+ if (bundledPackageJson.name === GROTH16_PACKAGE_NAME && bundledPackageJson.version === normalizedVersion) {
5829
+ return {
5830
+ packageName: GROTH16_PACKAGE_NAME,
5831
+ version: normalizedVersion,
5832
+ installPrefix: null,
5833
+ packageRoot: bundledPackageRoot,
5834
+ };
5835
+ }
5836
+
5837
+ return installManagedNpmPackage({
5838
+ packageName: GROTH16_PACKAGE_NAME,
5839
+ version: normalizedVersion,
5840
+ });
5841
+ }
5842
+
5564
5843
  async function installGroth16CrsForPrivateStateVersion(version) {
5565
5844
  const workspaceRoot = defaultGroth16WorkspaceRoot();
5566
5845
  const crsDir = path.join(workspaceRoot, "crs");
5846
+ const existingInstall = readExistingGroth16CrsInstall({ version, crsDir });
5847
+ if (existingInstall) {
5848
+ return existingInstall;
5849
+ }
5567
5850
  const crsInstall = await downloadPublicGroth16MpcArtifactsByVersion({
5568
5851
  version,
5569
5852
  outputDir: crsDir,
@@ -5585,6 +5868,56 @@ async function installGroth16CrsForPrivateStateVersion(version) {
5585
5868
  return crsInstall;
5586
5869
  }
5587
5870
 
5871
+ function readExistingGroth16CrsInstall({ version, crsDir }) {
5872
+ const normalizedVersion = requireCanonicalGroth16CompatibleBackendVersion(version, "Groth16 MPC CRS version");
5873
+ const selectedFiles = [
5874
+ "circuit_final.zkey",
5875
+ "verification_key.json",
5876
+ "metadata.json",
5877
+ "zkey_provenance.json",
5878
+ ];
5879
+ const targetPaths = selectedFiles.map((fileName) => path.join(crsDir, fileName));
5880
+ if (!targetPaths.every((targetPath) => fs.existsSync(targetPath))) {
5881
+ return null;
5882
+ }
5883
+ const metadata = readJson(path.join(crsDir, "metadata.json"));
5884
+ let metadataVersion;
5885
+ try {
5886
+ metadataVersion = requireCanonicalGroth16CompatibleBackendVersion(
5887
+ metadata.compatibleBackendVersion,
5888
+ "installed Groth16 MPC CRS version",
5889
+ );
5890
+ } catch {
5891
+ return null;
5892
+ }
5893
+ if (metadataVersion !== normalizedVersion) {
5894
+ return null;
5895
+ }
5896
+ const provenance = readJson(path.join(crsDir, "zkey_provenance.json"));
5897
+ return {
5898
+ source: "local-cache",
5899
+ archiveName: provenance.published_archive_name ?? null,
5900
+ archiveFileId: parseDriveFileIdFromDownloadUrl(provenance.zkey_download_url),
5901
+ folderUrl: provenance.published_folder_url ?? null,
5902
+ version: normalizedVersion,
5903
+ installedFiles: selectedFiles.map((archivePath, index) => ({
5904
+ archivePath,
5905
+ targetPath: targetPaths[index],
5906
+ })),
5907
+ };
5908
+ }
5909
+
5910
+ function parseDriveFileIdFromDownloadUrl(value) {
5911
+ if (typeof value !== "string" || value.length === 0) {
5912
+ return null;
5913
+ }
5914
+ try {
5915
+ return new URL(value).searchParams.get("id");
5916
+ } catch {
5917
+ return null;
5918
+ }
5919
+ }
5920
+
5588
5921
  function installManagedNpmPackage({ packageName, version, cacheBaseRoot = resolveArtifactCacheBaseRoot() }) {
5589
5922
  const normalizedPackageName = requireNonEmptyString(packageName, "packageName");
5590
5923
  const normalizedVersion = requireSemverVersion(version, `${normalizedPackageName} version`);