@tokamak-private-dapps/private-state-cli 1.1.1 → 1.2.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.
@@ -9,6 +9,7 @@ import { spawnSync } from "node:child_process";
9
9
  import { createRequire } from "node:module";
10
10
  import AdmZip from "adm-zip";
11
11
  import {
12
+ createHash,
12
13
  createCipheriv,
13
14
  createDecipheriv,
14
15
  randomBytes,
@@ -135,6 +136,15 @@ const GROTH16_PACKAGE_NAME = "@tokamak-private-dapps/groth16";
135
136
  const TOKAMAK_ZKEVM_CLI_PACKAGE_NAME = "@tokamak-zk-evm/cli";
136
137
  const WALLET_EXPORT_FORMAT = "tokamak-private-state-wallet-export";
137
138
  const WALLET_EXPORT_FORMAT_VERSION = 1;
139
+ const CHANNEL_WORKSPACE_MIRROR_PROTOCOL_VERSION = 2;
140
+ const CHANNEL_WORKSPACE_MIRROR_MANIFEST_PATH_PREFIX =
141
+ ".well-known/tokamak-private-state/channel-workspace";
142
+ const CHANNEL_WORKSPACE_MIRROR_ARCHIVE_FILES = Object.freeze(new Set([
143
+ "workspace.json",
144
+ "state_snapshot.json",
145
+ "block_info.json",
146
+ "contract_codes.json",
147
+ ]));
138
148
  let jsonOutputRequested = false;
139
149
  let activeCliArgs = {};
140
150
 
@@ -197,6 +207,8 @@ const ZERO_TOPIC = normalizeBytes32Hex(ethers.ZeroHash);
197
207
  const DEFAULT_LOG_CHUNK_SIZE = 2000;
198
208
  const DEFAULT_LOG_REQUESTS_PER_SECOND = 5;
199
209
  const LOG_REQUEST_INTERVAL_MS = Math.ceil(1000 / DEFAULT_LOG_REQUESTS_PER_SECOND);
210
+ const AUTO_RECOVERY_TIME_BUDGET_SECONDS = 10;
211
+ const AUTO_RECOVERY_LOG_REQUEST_BUDGET = DEFAULT_LOG_REQUESTS_PER_SECOND * AUTO_RECOVERY_TIME_BUDGET_SECONDS;
200
212
  let lastLogRequestStartedAtMs = 0;
201
213
 
202
214
  function printImmutableChannelPolicyWarning({
@@ -473,6 +485,20 @@ async function main() {
473
485
  await handleGetChannel({ args, network, provider });
474
486
  return;
475
487
  }
488
+ case "channel-set-workspace-mirror": {
489
+ assertSetWorkspaceMirrorArgs(args);
490
+ const { network, provider } = loadExplicitCommandRuntime(args);
491
+ await prepareDeploymentArtifacts(network.chainId);
492
+ await handleSetChannelWorkspaceMirror({ args, network, provider });
493
+ return;
494
+ }
495
+ case "channel-publish-workspace-mirror": {
496
+ assertPublishWorkspaceMirrorArgs(args);
497
+ const { network, provider } = loadExplicitCommandRuntime(args);
498
+ await prepareDeploymentArtifacts(network.chainId);
499
+ await handlePublishChannelWorkspaceMirror({ args, network, provider });
500
+ return;
501
+ }
476
502
  case "account-deposit-bridge": {
477
503
  assertDepositBridgeArgs(args);
478
504
  const { network, provider } = loadExplicitCommandRuntime(args);
@@ -658,6 +684,7 @@ async function handleWorkspaceInit({ args, network, provider }) {
658
684
  const channelName = requireArg(args.channelName, "--channel-name");
659
685
  const workspaceName = channelName;
660
686
  const bridgeResources = loadBridgeResources({ chainId: network.chainId });
687
+ const recoverySource = resolveWorkspaceRecoverySource(args);
661
688
 
662
689
  const { workspaceDir, workspace, currentSnapshot } = await syncChannelWorkspace({
663
690
  workspaceName,
@@ -669,11 +696,13 @@ async function handleWorkspaceInit({ args, network, provider }) {
669
696
  allowExistingWorkspaceSync: true,
670
697
  useWorkspaceRecoveryIndex: true,
671
698
  fromGenesis: args.fromGenesis === true,
699
+ recoverySource,
672
700
  progressAction: "channel recover-workspace",
673
701
  });
674
702
 
675
703
  printJson({
676
704
  action: "channel recover-workspace",
705
+ source: workspace.recoverySource ?? recoverySource,
677
706
  workspace: workspaceName,
678
707
  workspaceDir,
679
708
  channelName,
@@ -686,6 +715,7 @@ async function handleWorkspaceInit({ args, network, provider }) {
686
715
  recoveryLastScannedBlock: workspace.recoveryLastScannedBlock,
687
716
  recoveryRootVectorHash: workspace.recoveryRootVectorHash,
688
717
  recoveryScanRange: workspace.recoveryScanRange,
718
+ workspaceMirror: workspace.workspaceMirror ?? null,
689
719
  });
690
720
  }
691
721
 
@@ -764,7 +794,1125 @@ async function handleGetChannel({ args, network, provider }) {
764
794
  policySnapshot,
765
795
  refundSchedule,
766
796
  bridgeCore: getAddress(bridgeResources.bridgeDeployment.bridgeCore),
797
+ workspaceMirror: await tryReadChannelWorkspaceMirror({ bridgeCore, channelId }),
798
+ });
799
+ }
800
+
801
+ async function handleSetChannelWorkspaceMirror({ args, network, provider }) {
802
+ const channelName = requireArg(args.channelName, "--channel-name");
803
+ const url = requireWorkspaceMirrorUrl(args.url);
804
+ const signer = requireL1Signer(args, provider);
805
+ const bridgeResources = loadBridgeResources({ chainId: network.chainId });
806
+ const bridgeCore = new Contract(
807
+ bridgeResources.bridgeDeployment.bridgeCore,
808
+ bridgeResources.bridgeAbiManifest.contracts.bridgeCore.abi,
809
+ signer,
810
+ );
811
+ const channelId = deriveChannelIdFromName(channelName);
812
+ const previousUrl = await readChannelWorkspaceMirror({ bridgeCore, channelId });
813
+ const receipt = await waitForReceipt(await bridgeCore.setChannelWorkspaceMirror(channelId, url));
814
+ const currentUrl = await readChannelWorkspaceMirror({ bridgeCore, channelId });
815
+
816
+ printJson({
817
+ action: "channel set-workspace-mirror",
818
+ channelName,
819
+ channelId: channelId.toString(),
820
+ leader: getAddress(signer.address),
821
+ previousUrl,
822
+ url: currentUrl,
823
+ bridgeCore: getAddress(bridgeResources.bridgeDeployment.bridgeCore),
824
+ gasUsed: receiptGasUsed(receipt),
825
+ txUrl: explorerTxUrl(network, receipt.hash),
826
+ receipt: sanitizeReceipt(receipt),
827
+ });
828
+ }
829
+
830
+ async function handlePublishChannelWorkspaceMirror({ args, network, provider }) {
831
+ const channelName = requireArg(args.channelName, "--channel-name");
832
+ const outputRoot = path.resolve(String(requireArg(args.output, "--output")));
833
+ const force = args.force === true;
834
+ const signer = requireL1Signer(args, provider);
835
+ const bridgeResources = loadBridgeResources({ chainId: network.chainId });
836
+ const { bridgeDeployment, bridgeAbiManifest } = bridgeResources;
837
+ const bridgeCore = new Contract(
838
+ bridgeDeployment.bridgeCore,
839
+ bridgeAbiManifest.contracts.bridgeCore.abi,
840
+ signer,
841
+ );
842
+ const channelId = deriveChannelIdFromName(channelName);
843
+ const channelInfo = await bridgeCore.getChannel(channelId);
844
+ expect(channelInfo.exists, `Unknown channel ${channelId.toString()} in bridge core ${bridgeDeployment.bridgeCore}.`);
845
+ expect(
846
+ ethers.toBigInt(getAddress(signer.address)) === ethers.toBigInt(getAddress(channelInfo.leader)),
847
+ "Only the channel leader can publish a signed workspace mirror checkpoint.",
848
+ );
849
+
850
+ const registeredUrl = String(await readChannelWorkspaceMirror({ bridgeCore, channelId })).trim();
851
+ expect(
852
+ registeredUrl.length > 0,
853
+ `No workspace mirror URL is registered for channel ${channelName}. Run channel set-workspace-mirror first.`,
854
+ );
855
+ const manifestUrl = channelWorkspaceMirrorManifestUrl({
856
+ registeredUrl,
857
+ chainId: network.chainId,
858
+ channelId,
859
+ });
860
+
861
+ const local = await loadPublishableLocalWorkspaceMirrorCheckpoint({
862
+ channelName,
863
+ network,
864
+ provider,
865
+ bridgeResources,
866
+ channelInfo,
867
+ });
868
+ const remote = await readRemoteWorkspaceMirrorCheckpoint({
869
+ manifestUrl,
870
+ chainId: network.chainId,
871
+ channelId,
872
+ channelName,
873
+ bridgeCoreAddress: bridgeDeployment.bridgeCore,
874
+ channelInfo,
875
+ blockInfo: local.blockInfo,
876
+ contractCodes: local.contractCodes,
877
+ force,
878
+ });
879
+ expect(
880
+ !remote.exists || Number(local.recoveryLastScannedBlock) > Number(remote.recoveryLastScannedBlock),
881
+ [
882
+ `Local workspace recovery index ${local.recoveryLastScannedBlock} is not ahead of the registered mirror`,
883
+ `checkpoint ${remote.exists ? remote.recoveryLastScannedBlock : "<missing>"}.`,
884
+ "Run channel recover-workspace first if the local workspace is stale.",
885
+ ].join(" "),
886
+ );
887
+
888
+ const publishTarget = workspaceMirrorPublishTarget({
889
+ outputRoot,
890
+ registeredUrl,
891
+ chainId: network.chainId,
892
+ channelId,
893
+ });
894
+ const mirrorDir = publishTarget.mirrorDir;
895
+ ensureDir(mirrorDir);
896
+
897
+ const checkpointBundle = buildWorkspaceMirrorCheckpointBundle({
898
+ workspace: local.workspace,
899
+ stateSnapshot: local.stateSnapshot,
900
+ blockInfo: local.blockInfo,
901
+ contractCodes: local.contractCodes,
902
+ });
903
+ const checkpointBundlePath = path.join(mirrorDir, "checkpoint.zip");
904
+ fs.writeFileSync(checkpointBundlePath, checkpointBundle.bytes);
905
+
906
+ const deltaBundles = [];
907
+ if (remote.exists) {
908
+ const delta = await buildWorkspaceMirrorDeltaBundle({
909
+ provider,
910
+ bridgeAbiManifest,
911
+ channelInfo,
912
+ chainId: network.chainId,
913
+ channelId,
914
+ fromBlock: Number(remote.recoveryLastScannedBlock),
915
+ toBlock: Number(local.recoveryLastScannedBlock) - 1,
916
+ baseRecoveryRootVectorHash: remote.recoveryRootVectorHash,
917
+ recoveryRootVectorHash: local.recoveryRootVectorHash,
918
+ });
919
+ const deltaRelativePath = `deltas/${delta.fromBlock}-${delta.toBlock}.json`;
920
+ const deltaBytes = Buffer.from(`${JSON.stringify(normalizeCliOutput(delta), null, 2)}\n`, "utf8");
921
+ const deltaPath = path.join(mirrorDir, deltaRelativePath);
922
+ ensureDir(path.dirname(deltaPath));
923
+ fs.writeFileSync(deltaPath, deltaBytes);
924
+ deltaBundles.push({
925
+ fromBlock: delta.fromBlock,
926
+ toBlock: delta.toBlock,
927
+ url: deltaRelativePath,
928
+ sha256: sha256Hex(deltaBytes),
929
+ sizeBytes: deltaBytes.length,
930
+ });
931
+ }
932
+
933
+ const unsignedManifest = {
934
+ protocolVersion: CHANNEL_WORKSPACE_MIRROR_PROTOCOL_VERSION,
935
+ chainId: Number(network.chainId),
936
+ channelId: channelId.toString(),
937
+ channelName,
938
+ bridgeCore: getAddress(bridgeDeployment.bridgeCore),
939
+ channelManager: getAddress(channelInfo.manager),
940
+ bridgeTokenVault: getAddress(channelInfo.bridgeTokenVault),
941
+ leader: getAddress(channelInfo.leader),
942
+ checkpoint: {
943
+ recoveryLastScannedBlock: Number(local.recoveryLastScannedBlock),
944
+ recoveryRootVectorHash: local.recoveryRootVectorHash,
945
+ workspaceHash: hashJsonValue(local.workspace),
946
+ stateSnapshotHash: hashJsonValue(local.stateSnapshot),
947
+ blockInfoHash: hashJsonValue(local.blockInfo),
948
+ contractCodesHash: hashJsonValue(local.contractCodes),
949
+ bundle: {
950
+ url: "checkpoint.zip",
951
+ sha256: checkpointBundle.sha256,
952
+ sizeBytes: checkpointBundle.bytes.length,
953
+ },
954
+ },
955
+ deltaBundles,
956
+ validationCertificate: {
957
+ schema: "tokamak-private-state-workspace-mirror",
958
+ signer: getAddress(signer.address),
959
+ signedAt: new Date().toISOString(),
960
+ canary: {
961
+ proofVerified: true,
962
+ description: "The channel leader attests that the checkpoint workspace passed the operator's canary proof generation and verification workflow.",
963
+ },
964
+ },
965
+ createdAt: new Date().toISOString(),
966
+ minCliVersion: privateStateCliPackageJson.version,
967
+ };
968
+ const signature = await signer.signMessage(ethers.getBytes(hashWorkspaceMirrorCertificatePayload(unsignedManifest)));
969
+ const manifest = {
970
+ ...unsignedManifest,
971
+ validationCertificate: {
972
+ ...unsignedManifest.validationCertificate,
973
+ signature,
974
+ },
975
+ };
976
+ writeJson(publishTarget.manifestPath, manifest);
977
+
978
+ printJson({
979
+ action: "channel publish-workspace-mirror",
980
+ channelName,
981
+ channelId: channelId.toString(),
982
+ force,
983
+ outputRoot,
984
+ mirrorDir,
985
+ manifestPath: publishTarget.manifestPath,
986
+ registeredUrl,
987
+ manifestUrl,
988
+ remoteCheckpoint: remote.exists
989
+ ? {
990
+ recoveryLastScannedBlock: remote.recoveryLastScannedBlock,
991
+ recoveryRootVectorHash: remote.recoveryRootVectorHash,
992
+ }
993
+ : null,
994
+ ignoredRemoteCheckpoint: remote.ignored
995
+ ? {
996
+ manifestUrl,
997
+ error: remote.error,
998
+ }
999
+ : null,
1000
+ checkpoint: {
1001
+ recoveryLastScannedBlock: local.recoveryLastScannedBlock,
1002
+ recoveryRootVectorHash: local.recoveryRootVectorHash,
1003
+ bundlePath: checkpointBundlePath,
1004
+ sha256: checkpointBundle.sha256,
1005
+ sizeBytes: checkpointBundle.bytes.length,
1006
+ },
1007
+ deltaBundles,
1008
+ });
1009
+ }
1010
+
1011
+ function resolveWorkspaceRecoverySource(args) {
1012
+ const source = args.source === undefined ? "rpc" : String(args.source).trim().toLowerCase();
1013
+ if (!["rpc", "mirror"].includes(source)) {
1014
+ throw new Error("--source must be one of: rpc, mirror.");
1015
+ }
1016
+ if (args.fromGenesis === true && (args.source === undefined || source !== "rpc")) {
1017
+ throw new Error("--from-genesis requires explicit --source rpc.");
1018
+ }
1019
+ return source;
1020
+ }
1021
+
1022
+ function requireWorkspaceMirrorUrl(value) {
1023
+ const url = String(requireArg(value, "--url")).trim();
1024
+ try {
1025
+ const parsed = new URL(url);
1026
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
1027
+ throw new Error("unsupported protocol");
1028
+ }
1029
+ } catch {
1030
+ throw new Error("--url must be an http or https URL.");
1031
+ }
1032
+ return url;
1033
+ }
1034
+
1035
+ async function readChannelWorkspaceMirror({ bridgeCore, channelId }) {
1036
+ return String(await bridgeCore.getChannelWorkspaceMirror(channelId));
1037
+ }
1038
+
1039
+ async function loadPublishableLocalWorkspaceMirrorCheckpoint({
1040
+ channelName,
1041
+ network,
1042
+ provider,
1043
+ bridgeResources,
1044
+ channelInfo,
1045
+ }) {
1046
+ const workspaceDir = channelWorkspacePath(networkNameFromChainId(network.chainId), channelName);
1047
+ const existingArtifacts = loadExistingWorkspaceArtifacts(workspaceDir);
1048
+ const workspace = existingArtifacts.workspace;
1049
+ expect(workspace, `Local channel workspace is missing at ${channelWorkspaceConfigPath(workspaceDir)}.`);
1050
+ const stateSnapshot = existingArtifacts.stateSnapshot;
1051
+ expect(stateSnapshot, `Local channel state snapshot is missing under ${channelWorkspaceCurrentPath(workspaceDir)}.`);
1052
+ expect(existingArtifacts.blockInfo, `Local channel block_info.json is missing under ${channelWorkspaceCurrentPath(workspaceDir)}.`);
1053
+ expect(existingArtifacts.contractCodes, `Local channel contract_codes.json is missing under ${channelWorkspaceCurrentPath(workspaceDir)}.`);
1054
+ const channelManager = new Contract(
1055
+ channelInfo.manager,
1056
+ bridgeResources.bridgeAbiManifest.contracts.channelManager.abi,
1057
+ provider,
1058
+ );
1059
+ const [
1060
+ currentRootVectorHash,
1061
+ genesisBlockNumber,
1062
+ latestBlock,
1063
+ managedStorageAddresses,
1064
+ ] = await Promise.all([
1065
+ channelManager.currentRootVectorHash(),
1066
+ channelManager.genesisBlockNumber(),
1067
+ provider.getBlockNumber(),
1068
+ channelManager.getManagedStorageAddresses(),
1069
+ ]);
1070
+ const normalizedManagedStorageAddresses = normalizedAddressVector(managedStorageAddresses);
1071
+ const recoveryIndex = getUsableWorkspaceRecoveryIndex({
1072
+ existingArtifacts,
1073
+ genesisBlockNumber: Number(genesisBlockNumber),
1074
+ latestBlock,
1075
+ managedStorageAddresses: normalizedManagedStorageAddresses,
1076
+ });
1077
+ expect(
1078
+ recoveryIndex,
1079
+ [
1080
+ "Local channel workspace does not contain a usable recovery index.",
1081
+ `Run channel recover-workspace --channel-name ${channelName} --network ${networkNameFromChainId(network.chainId)} first.`,
1082
+ ].join(" "),
1083
+ );
1084
+ expect(
1085
+ canReuseLocalWorkspaceSnapshot({
1086
+ existingArtifacts,
1087
+ currentRootVectorHash,
1088
+ managedStorageAddresses: normalizedManagedStorageAddresses,
1089
+ }),
1090
+ [
1091
+ "Local channel workspace is stale relative to the on-chain channel state.",
1092
+ `Run channel recover-workspace --channel-name ${channelName} --network ${networkNameFromChainId(network.chainId)} first.`,
1093
+ ].join(" "),
1094
+ );
1095
+ expect(Number(workspace.chainId) === Number(network.chainId), "Local workspace chainId does not match --network.");
1096
+ expect(ethers.toBigInt(workspace.channelId) === ethers.toBigInt(deriveChannelIdFromName(channelName)), "Local workspace channelId mismatch.");
1097
+ expect(String(workspace.channelName) === channelName, "Local workspace channelName mismatch.");
1098
+ expect(
1099
+ ethers.toBigInt(getAddress(workspace.channelManager)) === ethers.toBigInt(getAddress(channelInfo.manager)),
1100
+ "Local workspace channelManager mismatch.",
1101
+ );
1102
+ expect(
1103
+ ethers.toBigInt(getAddress(workspace.bridgeTokenVault)) === ethers.toBigInt(getAddress(channelInfo.bridgeTokenVault)),
1104
+ "Local workspace bridgeTokenVault mismatch.",
1105
+ );
1106
+
1107
+ return {
1108
+ workspace,
1109
+ stateSnapshot,
1110
+ blockInfo: existingArtifacts.blockInfo,
1111
+ contractCodes: existingArtifacts.contractCodes,
1112
+ recoveryLastScannedBlock: recoveryIndex.nextBlock,
1113
+ recoveryRootVectorHash: recoveryIndex.recoveryRootVectorHash,
1114
+ };
1115
+ }
1116
+
1117
+ async function readRemoteWorkspaceMirrorCheckpoint({
1118
+ manifestUrl,
1119
+ chainId,
1120
+ channelId,
1121
+ channelName,
1122
+ bridgeCoreAddress,
1123
+ channelInfo,
1124
+ blockInfo,
1125
+ contractCodes,
1126
+ force = false,
1127
+ }) {
1128
+ const ignoreRemote = (error) => {
1129
+ if (!force) {
1130
+ throw error;
1131
+ }
1132
+ return {
1133
+ exists: false,
1134
+ ignored: true,
1135
+ error: error.message,
1136
+ recoveryLastScannedBlock: null,
1137
+ recoveryRootVectorHash: null,
1138
+ };
1139
+ };
1140
+ let manifest;
1141
+ try {
1142
+ manifest = await fetchJsonFromUrl(manifestUrl, { maxBytes: 1024 * 1024 });
1143
+ } catch (error) {
1144
+ if (/\bHTTP (404|410)\b/u.test(error.message)) {
1145
+ return {
1146
+ exists: false,
1147
+ ignored: false,
1148
+ error: null,
1149
+ recoveryLastScannedBlock: null,
1150
+ recoveryRootVectorHash: null,
1151
+ };
1152
+ }
1153
+ return ignoreRemote(new Error(`Unable to read registered workspace mirror manifest before publish: ${error.message}`));
1154
+ }
1155
+ let checkpoint;
1156
+ try {
1157
+ checkpoint = validateWorkspaceMirrorManifest({
1158
+ manifest,
1159
+ chainId,
1160
+ channelId,
1161
+ channelName,
1162
+ bridgeCoreAddress,
1163
+ channelInfo,
1164
+ blockInfo,
1165
+ contractCodes,
1166
+ });
1167
+ } catch (error) {
1168
+ return ignoreRemote(new Error(`Registered workspace mirror manifest is invalid before publish: ${error.message}`));
1169
+ }
1170
+ return {
1171
+ exists: true,
1172
+ ignored: false,
1173
+ error: null,
1174
+ recoveryLastScannedBlock: checkpoint.recoveryLastScannedBlock,
1175
+ recoveryRootVectorHash: checkpoint.recoveryRootVectorHash,
1176
+ };
1177
+ }
1178
+
1179
+ function buildWorkspaceMirrorCheckpointBundle({
1180
+ workspace,
1181
+ stateSnapshot,
1182
+ blockInfo,
1183
+ contractCodes,
1184
+ }) {
1185
+ const archive = new AdmZip();
1186
+ const files = {
1187
+ "workspace.json": workspace,
1188
+ "state_snapshot.json": stateSnapshot,
1189
+ "block_info.json": blockInfo,
1190
+ "contract_codes.json": contractCodes,
1191
+ };
1192
+ for (const [fileName, value] of Object.entries(files)) {
1193
+ archive.addFile(fileName, Buffer.from(`${JSON.stringify(normalizeCliOutput(value), null, 2)}\n`, "utf8"));
1194
+ }
1195
+ const bytes = archive.toBuffer();
1196
+ return {
1197
+ bytes,
1198
+ sha256: sha256Hex(bytes),
1199
+ };
1200
+ }
1201
+
1202
+ async function buildWorkspaceMirrorDeltaBundle({
1203
+ provider,
1204
+ bridgeAbiManifest,
1205
+ channelInfo,
1206
+ chainId,
1207
+ channelId,
1208
+ fromBlock,
1209
+ toBlock,
1210
+ baseRecoveryRootVectorHash,
1211
+ recoveryRootVectorHash,
1212
+ }) {
1213
+ expect(Number.isInteger(fromBlock) && Number.isInteger(toBlock) && toBlock >= fromBlock, "Invalid workspace mirror delta block range.");
1214
+ const { channelManagerLogs, bridgeVaultLogs } = await fetchChannelRecoveryLogs({
1215
+ provider,
1216
+ bridgeAbiManifest,
1217
+ channelInfo,
1218
+ fromBlock,
1219
+ toBlock,
1220
+ });
1221
+ const logs = [...channelManagerLogs, ...bridgeVaultLogs]
1222
+ .sort(compareLogsByPosition)
1223
+ .map(serializeWorkspaceMirrorDeltaLog);
1224
+ return {
1225
+ protocolVersion: CHANNEL_WORKSPACE_MIRROR_PROTOCOL_VERSION,
1226
+ chainId: Number(chainId),
1227
+ channelId: channelId.toString(),
1228
+ fromBlock,
1229
+ toBlock,
1230
+ baseRecoveryRootVectorHash: normalizeBytes32Hex(baseRecoveryRootVectorHash),
1231
+ recoveryRootVectorHash: normalizeBytes32Hex(recoveryRootVectorHash),
1232
+ logs,
1233
+ };
1234
+ }
1235
+
1236
+ function serializeWorkspaceMirrorDeltaLog(log) {
1237
+ return {
1238
+ address: getAddress(log.address),
1239
+ topics: (log.topics ?? []).map((topic) => normalizeBytes32Hex(topic)),
1240
+ data: log.data ?? "0x",
1241
+ blockNumber: Number(log.blockNumber),
1242
+ transactionHash: normalizeBytes32Hex(log.transactionHash),
1243
+ transactionIndex: Number(log.transactionIndex),
1244
+ index: Number(log.index ?? log.logIndex),
1245
+ };
1246
+ }
1247
+
1248
+ function workspaceMirrorPublishTarget({ outputRoot, registeredUrl, chainId, channelId }) {
1249
+ const parsed = new URL(registeredUrl);
1250
+ const registeredSegments = safeUrlPathSegments(parsed.pathname);
1251
+ if (parsed.pathname.endsWith(".json")) {
1252
+ const manifestPath = path.join(outputRoot, ...registeredSegments);
1253
+ return {
1254
+ mirrorDir: path.dirname(manifestPath),
1255
+ manifestPath,
1256
+ };
1257
+ }
1258
+ const mirrorDir = path.join(
1259
+ outputRoot,
1260
+ ...registeredSegments,
1261
+ ...CHANNEL_WORKSPACE_MIRROR_MANIFEST_PATH_PREFIX.split("/"),
1262
+ String(chainId),
1263
+ channelId.toString(),
1264
+ );
1265
+ return {
1266
+ mirrorDir,
1267
+ manifestPath: path.join(mirrorDir, "manifest.json"),
1268
+ };
1269
+ }
1270
+
1271
+ function safeUrlPathSegments(pathname) {
1272
+ return pathname
1273
+ .split("/")
1274
+ .filter(Boolean)
1275
+ .map((segment) => decodeURIComponent(segment))
1276
+ .filter((segment) => segment !== ".")
1277
+ .map((segment) => {
1278
+ expect(segment !== ".." && !segment.includes("/") && !segment.includes("\\"), "Workspace mirror URL path contains an unsafe segment.");
1279
+ return segment;
1280
+ });
1281
+ }
1282
+
1283
+ async function tryReadChannelWorkspaceMirror({ bridgeCore, channelId }) {
1284
+ try {
1285
+ return await readChannelWorkspaceMirror({ bridgeCore, channelId });
1286
+ } catch {
1287
+ return null;
1288
+ }
1289
+ }
1290
+
1291
+ async function loadWorkspaceMirrorRecoveryIndex({
1292
+ recoverySource,
1293
+ bridgeCore,
1294
+ channelId,
1295
+ channelName,
1296
+ network,
1297
+ bridgeDeployment,
1298
+ channelInfo,
1299
+ genesisBlockNumber,
1300
+ managedStorageAddresses,
1301
+ blockInfo,
1302
+ contractCodes,
1303
+ latestBlock,
1304
+ localRecoveryIndex = null,
1305
+ bridgeAbiManifest,
1306
+ controllerAddress,
1307
+ l2AccountingVaultAddress,
1308
+ liquidBalancesSlot,
1309
+ }) {
1310
+ const baseStatus = {
1311
+ source: recoverySource,
1312
+ used: false,
1313
+ registeredUrl: null,
1314
+ manifestUrl: null,
1315
+ bundleUrl: null,
1316
+ skippedReason: null,
1317
+ recoveryLastScannedBlock: null,
1318
+ recoveryRootVectorHash: null,
1319
+ error: null,
1320
+ };
1321
+ if (recoverySource === "rpc") {
1322
+ return { recoveryIndex: null, workspaceMirror: baseStatus };
1323
+ }
1324
+
1325
+ let registeredUrl;
1326
+ try {
1327
+ registeredUrl = String(await readChannelWorkspaceMirror({ bridgeCore, channelId })).trim();
1328
+ } catch (error) {
1329
+ throw new Error(`Unable to read channel workspace mirror registry: ${error.message}`);
1330
+ }
1331
+ if (!registeredUrl) {
1332
+ throw new Error(`No workspace mirror URL is registered for channel ${channelName}.`);
1333
+ }
1334
+
1335
+ try {
1336
+ const mirror = await fetchChannelWorkspaceMirror({
1337
+ registeredUrl,
1338
+ chainId: Number(network.chainId),
1339
+ channelId,
1340
+ channelName,
1341
+ bridgeCoreAddress: bridgeDeployment.bridgeCore,
1342
+ channelInfo,
1343
+ genesisBlockNumber,
1344
+ managedStorageAddresses,
1345
+ blockInfo,
1346
+ contractCodes,
1347
+ latestBlock,
1348
+ localRecoveryIndex,
1349
+ bridgeAbiManifest,
1350
+ controllerAddress,
1351
+ l2AccountingVaultAddress,
1352
+ liquidBalancesSlot,
1353
+ });
1354
+ return {
1355
+ recoveryIndex: mirror.recoveryIndex,
1356
+ workspaceMirror: {
1357
+ source: recoverySource,
1358
+ used: mirror.recoveryIndex !== null,
1359
+ registeredUrl,
1360
+ manifestUrl: mirror.manifestUrl,
1361
+ bundleUrl: mirror.bundleUrl,
1362
+ skippedReason: mirror.skippedReason ?? null,
1363
+ recoveryLastScannedBlock: mirror.recoveryIndex?.nextBlock ?? null,
1364
+ recoveryRootVectorHash: mirror.recoveryIndex?.recoveryRootVectorHash ?? null,
1365
+ error: null,
1366
+ },
1367
+ };
1368
+ } catch (error) {
1369
+ throw new Error(`Workspace mirror recovery failed: ${error.message}`);
1370
+ }
1371
+ }
1372
+
1373
+ async function fetchChannelWorkspaceMirror({
1374
+ registeredUrl,
1375
+ chainId,
1376
+ channelId,
1377
+ channelName,
1378
+ bridgeCoreAddress,
1379
+ channelInfo,
1380
+ genesisBlockNumber,
1381
+ managedStorageAddresses,
1382
+ blockInfo,
1383
+ contractCodes,
1384
+ latestBlock,
1385
+ localRecoveryIndex = null,
1386
+ bridgeAbiManifest,
1387
+ controllerAddress,
1388
+ l2AccountingVaultAddress,
1389
+ liquidBalancesSlot,
1390
+ }) {
1391
+ const manifestUrl = channelWorkspaceMirrorManifestUrl({ registeredUrl, chainId, channelId });
1392
+ const manifest = await fetchJsonFromUrl(manifestUrl, { maxBytes: 1024 * 1024 });
1393
+ const manifestPrecheck = validateWorkspaceMirrorManifest({
1394
+ manifest,
1395
+ chainId,
1396
+ channelId,
1397
+ channelName,
1398
+ bridgeCoreAddress,
1399
+ channelInfo,
1400
+ blockInfo,
1401
+ contractCodes,
1402
+ });
1403
+
1404
+ if (
1405
+ localRecoveryIndex
1406
+ && Number(manifestPrecheck.recoveryLastScannedBlock) <= Number(localRecoveryIndex.nextBlock)
1407
+ ) {
1408
+ return {
1409
+ manifestUrl,
1410
+ bundleUrl: null,
1411
+ recoveryIndex: null,
1412
+ skippedReason: "mirror-checkpoint-not-ahead-of-local",
1413
+ };
1414
+ }
1415
+
1416
+ const mirrorAheadOfLocal = localRecoveryIndex
1417
+ && Number(manifestPrecheck.recoveryLastScannedBlock) > Number(localRecoveryIndex.nextBlock);
1418
+ const bundleResult = mirrorAheadOfLocal
1419
+ ? await fetchAndApplyWorkspaceMirrorDelta({
1420
+ manifest,
1421
+ manifestUrl,
1422
+ localRecoveryIndex,
1423
+ chainId,
1424
+ channelId,
1425
+ channelInfo,
1426
+ bridgeAbiManifest,
1427
+ managedStorageAddresses,
1428
+ contractCodes,
1429
+ controllerAddress,
1430
+ l2AccountingVaultAddress,
1431
+ liquidBalancesSlot,
1432
+ })
1433
+ : await fetchWorkspaceMirrorCheckpoint({
1434
+ manifest,
1435
+ manifestUrl,
1436
+ chainId,
1437
+ channelId,
1438
+ channelName,
1439
+ bridgeCoreAddress,
1440
+ channelInfo,
1441
+ genesisBlockNumber,
1442
+ managedStorageAddresses,
1443
+ blockInfo,
1444
+ contractCodes,
1445
+ latestBlock,
1446
+ });
1447
+
1448
+ return {
1449
+ manifestUrl,
1450
+ bundleUrl: bundleResult.bundleUrl,
1451
+ recoveryIndex: bundleResult.recoveryIndex,
1452
+ };
1453
+ }
1454
+
1455
+ function channelWorkspaceMirrorManifestUrl({ registeredUrl, chainId, channelId }) {
1456
+ const parsed = new URL(registeredUrl);
1457
+ if (parsed.pathname.endsWith(".json")) {
1458
+ return parsed.toString();
1459
+ }
1460
+ parsed.search = "";
1461
+ parsed.hash = "";
1462
+ const basePath = parsed.pathname.replace(/\/+$/u, "");
1463
+ parsed.pathname = [
1464
+ basePath,
1465
+ CHANNEL_WORKSPACE_MIRROR_MANIFEST_PATH_PREFIX,
1466
+ String(chainId),
1467
+ channelId.toString(),
1468
+ "manifest.json",
1469
+ ].filter(Boolean).join("/");
1470
+ return parsed.toString();
1471
+ }
1472
+
1473
+ function resolveWorkspaceMirrorBundleUrl(manifestUrl, bundlePath, label) {
1474
+ expect(typeof bundlePath === "string" && bundlePath.length > 0, `Workspace mirror manifest is missing ${label}.url.`);
1475
+ return new URL(bundlePath, manifestUrl).toString();
1476
+ }
1477
+
1478
+ async function fetchJsonFromUrl(url, { maxBytes = null } = {}) {
1479
+ const bytes = await fetchBytesFromUrl(url, { maxBytes });
1480
+ try {
1481
+ return JSON.parse(bytes.toString("utf8"));
1482
+ } catch (error) {
1483
+ throw new Error(`Invalid JSON from ${url}: ${error.message}`);
1484
+ }
1485
+ }
1486
+
1487
+ async function fetchBytesFromUrl(url, {
1488
+ maxBytes = null,
1489
+ expectedBytes = null,
1490
+ onProgress = null,
1491
+ } = {}) {
1492
+ const response = await fetch(url);
1493
+ if (!response.ok) {
1494
+ throw new Error(`GET ${url} failed with HTTP ${response.status}.`);
1495
+ }
1496
+ const contentLength = Number(response.headers.get("content-length"));
1497
+ const hasExpectedBytes = expectedBytes !== null && expectedBytes !== undefined && Number.isFinite(Number(expectedBytes));
1498
+ const hasContentLength = response.headers.get("content-length") !== null && Number.isFinite(contentLength);
1499
+ const totalBytes = hasExpectedBytes
1500
+ ? Number(expectedBytes)
1501
+ : hasContentLength
1502
+ ? contentLength
1503
+ : null;
1504
+ const chunks = [];
1505
+ let downloadedBytes = 0;
1506
+ onProgress?.({
1507
+ status: "start",
1508
+ downloadedBytes,
1509
+ totalBytes,
1510
+ });
1511
+
1512
+ const reportProgress = () => onProgress?.({
1513
+ status: "progress",
1514
+ downloadedBytes,
1515
+ totalBytes,
1516
+ });
1517
+
1518
+ if (!response.body?.getReader) {
1519
+ if (maxBytes !== null && hasContentLength && contentLength > Number(maxBytes)) {
1520
+ throw new Error(`GET ${url} Content-Length ${contentLength} exceeds the ${maxBytes} byte limit.`);
1521
+ }
1522
+ const bytes = Buffer.from(await response.arrayBuffer());
1523
+ if (maxBytes !== null && bytes.length > Number(maxBytes)) {
1524
+ throw new Error(`GET ${url} returned ${bytes.length} bytes, above the ${maxBytes} byte limit.`);
1525
+ }
1526
+ onProgress?.({
1527
+ status: "done",
1528
+ downloadedBytes: bytes.length,
1529
+ totalBytes: totalBytes ?? bytes.length,
1530
+ });
1531
+ return bytes;
1532
+ }
1533
+
1534
+ const reader = response.body.getReader();
1535
+ let lastProgressAtMs = 0;
1536
+ try {
1537
+ while (true) {
1538
+ const { done, value } = await reader.read();
1539
+ if (done) {
1540
+ break;
1541
+ }
1542
+ const chunk = Buffer.from(value);
1543
+ chunks.push(chunk);
1544
+ downloadedBytes += chunk.length;
1545
+ if (maxBytes !== null && downloadedBytes > Number(maxBytes)) {
1546
+ throw new Error(`GET ${url} returned more than ${maxBytes} bytes.`);
1547
+ }
1548
+ const now = Date.now();
1549
+ if (now - lastProgressAtMs >= 250) {
1550
+ reportProgress();
1551
+ lastProgressAtMs = now;
1552
+ }
1553
+ }
1554
+ } catch (error) {
1555
+ onProgress?.({
1556
+ status: "error",
1557
+ downloadedBytes,
1558
+ totalBytes,
1559
+ });
1560
+ throw error;
1561
+ }
1562
+
1563
+ const bytes = Buffer.concat(chunks, downloadedBytes);
1564
+ onProgress?.({
1565
+ status: "done",
1566
+ downloadedBytes,
1567
+ totalBytes: totalBytes ?? downloadedBytes,
1568
+ });
1569
+ return bytes;
1570
+ }
1571
+
1572
+ function sha256Hex(bytes) {
1573
+ return createHash("sha256").update(bytes).digest("hex");
1574
+ }
1575
+
1576
+ function parseWorkspaceMirrorArchive(bytes) {
1577
+ const zip = new AdmZip(bytes);
1578
+ const parsed = {};
1579
+ for (const entry of zip.getEntries()) {
1580
+ const entryName = entry.entryName.replace(/\\/gu, "/");
1581
+ if (entry.isDirectory) {
1582
+ continue;
1583
+ }
1584
+ expect(
1585
+ !entryName.startsWith("/") && !entryName.includes("/") && !entryName.includes(".."),
1586
+ `Workspace mirror checkpoint bundle contains unsupported path: ${entry.entryName}.`,
1587
+ );
1588
+ expect(
1589
+ CHANNEL_WORKSPACE_MIRROR_ARCHIVE_FILES.has(entryName),
1590
+ `Workspace mirror checkpoint bundle contains unsupported file: ${entry.entryName}.`,
1591
+ );
1592
+ expect(parsed[entryName] === undefined, `Workspace mirror checkpoint bundle contains duplicate file: ${entry.entryName}.`);
1593
+ parsed[entryName] = JSON.parse(entry.getData().toString("utf8"));
1594
+ }
1595
+ for (const fileName of CHANNEL_WORKSPACE_MIRROR_ARCHIVE_FILES) {
1596
+ expect(parsed[fileName] !== undefined, `Workspace mirror checkpoint bundle is missing ${fileName}.`);
1597
+ }
1598
+ return {
1599
+ workspace: parsed["workspace.json"],
1600
+ stateSnapshot: parsed["state_snapshot.json"],
1601
+ blockInfo: parsed["block_info.json"],
1602
+ contractCodes: parsed["contract_codes.json"],
1603
+ };
1604
+ }
1605
+
1606
+ function validateWorkspaceMirrorManifest({
1607
+ manifest,
1608
+ chainId,
1609
+ channelId,
1610
+ channelName,
1611
+ bridgeCoreAddress,
1612
+ channelInfo,
1613
+ blockInfo,
1614
+ contractCodes,
1615
+ }) {
1616
+ expect(Number(manifest.protocolVersion) === CHANNEL_WORKSPACE_MIRROR_PROTOCOL_VERSION, "Unsupported workspace mirror protocolVersion.");
1617
+ expect(Number(manifest.chainId) === Number(chainId), "Workspace mirror manifest chainId mismatch.");
1618
+ expect(
1619
+ ethers.toBigInt(manifest.channelId) === ethers.toBigInt(channelId),
1620
+ "Workspace mirror manifest channelId mismatch.",
1621
+ );
1622
+ if (manifest.channelName !== undefined) {
1623
+ expect(String(manifest.channelName) === channelName, "Workspace mirror manifest channelName mismatch.");
1624
+ }
1625
+ expect(
1626
+ ethers.toBigInt(getAddress(manifest.bridgeCore)) === ethers.toBigInt(getAddress(bridgeCoreAddress)),
1627
+ "Workspace mirror manifest bridgeCore mismatch.",
1628
+ );
1629
+ expect(
1630
+ ethers.toBigInt(getAddress(manifest.channelManager)) === ethers.toBigInt(getAddress(channelInfo.manager)),
1631
+ "Workspace mirror manifest channelManager mismatch.",
1632
+ );
1633
+ expect(
1634
+ ethers.toBigInt(getAddress(manifest.bridgeTokenVault)) === ethers.toBigInt(getAddress(channelInfo.bridgeTokenVault)),
1635
+ "Workspace mirror manifest bridgeTokenVault mismatch.",
1636
+ );
1637
+ expect(
1638
+ ethers.toBigInt(getAddress(manifest.leader)) === ethers.toBigInt(getAddress(channelInfo.leader)),
1639
+ "Workspace mirror manifest leader mismatch.",
1640
+ );
1641
+ const checkpoint = manifest.checkpoint;
1642
+ expect(checkpoint && typeof checkpoint === "object", "Workspace mirror manifest checkpoint is required.");
1643
+ const recoveryLastScannedBlock = Number(checkpoint.recoveryLastScannedBlock);
1644
+ expect(Number.isInteger(recoveryLastScannedBlock), "Workspace mirror checkpoint recoveryLastScannedBlock must be an integer.");
1645
+ const recoveryRootVectorHash = normalizeBytes32Hex(checkpoint.recoveryRootVectorHash);
1646
+ expect(recoveryRootVectorHash !== null, "Workspace mirror checkpoint recoveryRootVectorHash is required.");
1647
+ expect(typeof checkpoint.stateSnapshotHash === "string", "Workspace mirror checkpoint stateSnapshotHash is required.");
1648
+ expect(typeof checkpoint.workspaceHash === "string", "Workspace mirror checkpoint workspaceHash is required.");
1649
+ expect(hashJsonValue(blockInfo) === normalizeBytes32Hex(checkpoint.blockInfoHash), "Workspace mirror checkpoint blockInfoHash mismatch.");
1650
+ expect(hashJsonValue(contractCodes) === normalizeBytes32Hex(checkpoint.contractCodesHash), "Workspace mirror checkpoint contractCodesHash mismatch.");
1651
+ validateWorkspaceMirrorCertificate({ manifest });
1652
+ return {
1653
+ recoveryLastScannedBlock,
1654
+ recoveryRootVectorHash,
1655
+ };
1656
+ }
1657
+
1658
+ function validateWorkspaceMirrorCertificate({ manifest }) {
1659
+ const certificate = manifest.validationCertificate;
1660
+ expect(certificate && typeof certificate === "object", "Workspace mirror validationCertificate is required.");
1661
+ expect(certificate.schema === "tokamak-private-state-workspace-mirror", "Workspace mirror validationCertificate schema mismatch.");
1662
+ expect(certificate.canary?.proofVerified === true, "Workspace mirror validationCertificate must confirm canary proof verification.");
1663
+ expect(typeof certificate.signature === "string", "Workspace mirror validationCertificate signature is required.");
1664
+ const signer = getAddress(certificate.signer ?? manifest.leader);
1665
+ expect(
1666
+ ethers.toBigInt(signer) === ethers.toBigInt(getAddress(manifest.leader)),
1667
+ "Workspace mirror validationCertificate signer must be the channel leader.",
1668
+ );
1669
+ const payloadHash = hashWorkspaceMirrorCertificatePayload(manifest);
1670
+ const recoveredSigner = getAddress(ethers.verifyMessage(ethers.getBytes(payloadHash), certificate.signature));
1671
+ expect(
1672
+ ethers.toBigInt(recoveredSigner) === ethers.toBigInt(getAddress(manifest.leader)),
1673
+ "Workspace mirror validationCertificate signature was not produced by the channel leader.",
1674
+ );
1675
+ }
1676
+
1677
+ function hashWorkspaceMirrorCertificatePayload(manifest) {
1678
+ const certificate = { ...(manifest.validationCertificate ?? {}) };
1679
+ delete certificate.signature;
1680
+ return hashJsonValue({
1681
+ protocolVersion: manifest.protocolVersion,
1682
+ chainId: manifest.chainId,
1683
+ channelId: manifest.channelId,
1684
+ channelName: manifest.channelName,
1685
+ bridgeCore: manifest.bridgeCore,
1686
+ channelManager: manifest.channelManager,
1687
+ bridgeTokenVault: manifest.bridgeTokenVault,
1688
+ leader: manifest.leader,
1689
+ checkpoint: manifest.checkpoint,
1690
+ deltaBundles: manifest.deltaBundles ?? [],
1691
+ validationCertificate: certificate,
1692
+ });
1693
+ }
1694
+
1695
+ async function fetchWorkspaceMirrorCheckpoint({
1696
+ manifest,
1697
+ manifestUrl,
1698
+ chainId,
1699
+ channelId,
1700
+ channelName,
1701
+ bridgeCoreAddress,
1702
+ channelInfo,
1703
+ genesisBlockNumber,
1704
+ managedStorageAddresses,
1705
+ blockInfo,
1706
+ contractCodes,
1707
+ latestBlock,
1708
+ }) {
1709
+ const bundleDescriptor = manifest.checkpoint?.bundle;
1710
+ expect(bundleDescriptor?.url, "Workspace mirror checkpoint.bundle.url is required when no usable local recovery index exists.");
1711
+ expect(bundleDescriptor?.sha256, "Workspace mirror checkpoint.bundle.sha256 is required.");
1712
+ const bundleUrl = resolveWorkspaceMirrorBundleUrl(manifestUrl, bundleDescriptor.url, "checkpoint.bundle");
1713
+ const archiveBytes = await fetchWorkspaceMirrorBundleBytes({
1714
+ bundleUrl,
1715
+ bundleDescriptor,
1716
+ label: "workspace mirror checkpoint",
1717
+ });
1718
+ const archive = parseWorkspaceMirrorArchive(archiveBytes);
1719
+ const recoveryIndex = validateWorkspaceMirrorCheckpointArchive({
1720
+ manifest,
1721
+ archive,
1722
+ chainId,
1723
+ channelId,
1724
+ channelName,
1725
+ bridgeCoreAddress,
1726
+ channelInfo,
1727
+ genesisBlockNumber,
1728
+ managedStorageAddresses,
1729
+ blockInfo,
1730
+ contractCodes,
1731
+ latestBlock,
1732
+ });
1733
+ return { bundleUrl, recoveryIndex };
1734
+ }
1735
+
1736
+ function validateWorkspaceMirrorCheckpointArchive({
1737
+ manifest,
1738
+ archive,
1739
+ chainId,
1740
+ channelId,
1741
+ channelName,
1742
+ bridgeCoreAddress,
1743
+ channelInfo,
1744
+ genesisBlockNumber,
1745
+ managedStorageAddresses,
1746
+ blockInfo,
1747
+ contractCodes,
1748
+ latestBlock,
1749
+ }) {
1750
+ const workspace = archive.workspace;
1751
+ expect(Number(workspace.chainId) === Number(chainId), "Workspace mirror workspace chainId mismatch.");
1752
+ expect(ethers.toBigInt(workspace.channelId) === ethers.toBigInt(channelId), "Workspace mirror workspace channelId mismatch.");
1753
+ expect(String(workspace.channelName) === channelName, "Workspace mirror workspace channelName mismatch.");
1754
+ expect(
1755
+ ethers.toBigInt(getAddress(workspace.bridgeCore)) === ethers.toBigInt(getAddress(bridgeCoreAddress)),
1756
+ "Workspace mirror workspace bridgeCore mismatch.",
1757
+ );
1758
+ expect(
1759
+ ethers.toBigInt(getAddress(workspace.channelManager)) === ethers.toBigInt(getAddress(channelInfo.manager)),
1760
+ "Workspace mirror workspace channelManager mismatch.",
1761
+ );
1762
+ expect(
1763
+ ethers.toBigInt(getAddress(workspace.bridgeTokenVault)) === ethers.toBigInt(getAddress(channelInfo.bridgeTokenVault)),
1764
+ "Workspace mirror workspace bridgeTokenVault mismatch.",
1765
+ );
1766
+ expect(Number(workspace.genesisBlockNumber) === Number(genesisBlockNumber), "Workspace mirror genesisBlockNumber mismatch.");
1767
+ const mirroredManagedStorageAddresses = normalizedAddressVector(workspace.managedStorageAddresses ?? []);
1768
+ expect(
1769
+ mirroredManagedStorageAddresses.length === managedStorageAddresses.length
1770
+ && mirroredManagedStorageAddresses.every(
1771
+ (address, index) => ethers.toBigInt(getAddress(address)) === ethers.toBigInt(getAddress(managedStorageAddresses[index])),
1772
+ ),
1773
+ "Workspace mirror managedStorageAddresses mismatch.",
1774
+ );
1775
+ expect(hashJsonValue(workspace) === normalizeBytes32Hex(manifest.checkpoint.workspaceHash), "Workspace mirror workspace hash mismatch.");
1776
+ expect(
1777
+ hashJsonValue(archive.stateSnapshot) === normalizeBytes32Hex(manifest.checkpoint.stateSnapshotHash),
1778
+ "Workspace mirror state_snapshot hash mismatch.",
1779
+ );
1780
+ expect(hashJsonValue(archive.blockInfo) === hashJsonValue(blockInfo), "Workspace mirror block_info.json does not match the channel genesis block.");
1781
+ expect(
1782
+ hashJsonValue(archive.contractCodes) === hashJsonValue(contractCodes),
1783
+ "Workspace mirror contract_codes.json does not match current managed storage contract code.",
1784
+ );
1785
+ const workspaceRootVectorHash = normalizeBytes32Hex(workspace.recoveryRootVectorHash);
1786
+ expect(
1787
+ ethers.toBigInt(normalizeBytes32Hex(manifest.checkpoint.recoveryRootVectorHash)) === ethers.toBigInt(workspaceRootVectorHash),
1788
+ "Workspace mirror recoveryRootVectorHash mismatch between manifest and workspace.",
1789
+ );
1790
+ const snapshotRootVectorHash = normalizeBytes32Hex(hashRootVector(archive.stateSnapshot.stateRoots));
1791
+ expect(
1792
+ ethers.toBigInt(snapshotRootVectorHash) === ethers.toBigInt(workspaceRootVectorHash),
1793
+ "Workspace mirror state_snapshot root vector hash mismatch.",
1794
+ );
1795
+ const mirrorRecoveryLastScannedBlock = Number(manifest.checkpoint.recoveryLastScannedBlock);
1796
+ expect(
1797
+ Number.isInteger(mirrorRecoveryLastScannedBlock)
1798
+ && mirrorRecoveryLastScannedBlock === Number(workspace.recoveryLastScannedBlock),
1799
+ "Workspace mirror recoveryLastScannedBlock mismatch between manifest and workspace.",
1800
+ );
1801
+ const recoveryIndex = getUsableWorkspaceRecoveryIndex({
1802
+ existingArtifacts: {
1803
+ workspace: {
1804
+ recoveryLastScannedBlock: mirrorRecoveryLastScannedBlock,
1805
+ recoveryRootVectorHash: workspaceRootVectorHash,
1806
+ },
1807
+ stateSnapshot: archive.stateSnapshot,
1808
+ },
1809
+ genesisBlockNumber,
1810
+ latestBlock,
1811
+ managedStorageAddresses,
1812
+ });
1813
+ expect(recoveryIndex, "Workspace mirror recovery index is missing or unusable.");
1814
+ return {
1815
+ ...recoveryIndex,
1816
+ source: "mirror",
1817
+ };
1818
+ }
1819
+
1820
+ async function fetchAndApplyWorkspaceMirrorDelta({
1821
+ manifest,
1822
+ manifestUrl,
1823
+ localRecoveryIndex,
1824
+ chainId,
1825
+ channelId,
1826
+ channelInfo,
1827
+ bridgeAbiManifest,
1828
+ managedStorageAddresses,
1829
+ contractCodes,
1830
+ controllerAddress,
1831
+ l2AccountingVaultAddress,
1832
+ liquidBalancesSlot,
1833
+ }) {
1834
+ const fromBlock = Number(localRecoveryIndex.nextBlock);
1835
+ const toBlock = Number(manifest.checkpoint.recoveryLastScannedBlock) - 1;
1836
+ const bundleDescriptor = selectWorkspaceMirrorDeltaBundle({ manifest, fromBlock, toBlock });
1837
+ expect(
1838
+ bundleDescriptor,
1839
+ `Workspace mirror does not provide a delta bundle for local recovery index ${fromBlock} to checkpoint block ${toBlock}.`,
1840
+ );
1841
+ const bundleUrl = resolveWorkspaceMirrorBundleUrl(manifestUrl, bundleDescriptor.url, "deltaBundles[]");
1842
+ const bundleBytes = await fetchWorkspaceMirrorBundleBytes({
1843
+ bundleUrl,
1844
+ bundleDescriptor,
1845
+ label: "workspace mirror delta",
1846
+ });
1847
+ const delta = parseJsonBytes(bundleBytes, bundleUrl);
1848
+ const recoveryIndex = await applyWorkspaceMirrorDeltaBundle({
1849
+ delta,
1850
+ localRecoveryIndex,
1851
+ manifest,
1852
+ chainId,
1853
+ channelId,
1854
+ channelInfo,
1855
+ bridgeAbiManifest,
1856
+ managedStorageAddresses,
1857
+ contractCodes,
1858
+ controllerAddress,
1859
+ l2AccountingVaultAddress,
1860
+ liquidBalancesSlot,
1861
+ });
1862
+ return { bundleUrl, recoveryIndex };
1863
+ }
1864
+
1865
+ function selectWorkspaceMirrorDeltaBundle({ manifest, fromBlock, toBlock }) {
1866
+ const bundles = Array.isArray(manifest.deltaBundles) ? manifest.deltaBundles : [];
1867
+ return bundles.find((bundle) => Number(bundle.fromBlock) === fromBlock && Number(bundle.toBlock) === toBlock) ?? null;
1868
+ }
1869
+
1870
+ async function fetchWorkspaceMirrorBundleBytes({ bundleUrl, bundleDescriptor, label }) {
1871
+ expect(bundleDescriptor?.sizeBytes !== undefined, `Workspace mirror ${label} sizeBytes is required.`);
1872
+ const expectedBytes = Number(bundleDescriptor.sizeBytes);
1873
+ expect(
1874
+ Number.isSafeInteger(expectedBytes) && expectedBytes >= 0,
1875
+ `Workspace mirror ${label} sizeBytes must be a non-negative safe integer.`,
1876
+ );
1877
+ const bytes = await fetchBytesFromUrl(bundleUrl, {
1878
+ maxBytes: expectedBytes,
1879
+ expectedBytes,
1880
+ onProgress: createByteDownloadProgress({
1881
+ action: "channel recover-workspace",
1882
+ label,
1883
+ url: bundleUrl,
1884
+ }),
767
1885
  });
1886
+ expect(
1887
+ expectedBytes === bytes.length,
1888
+ `Workspace mirror bundle size mismatch. Expected ${expectedBytes}, got ${bytes.length}.`,
1889
+ );
1890
+ const bundleSha256 = sha256Hex(bytes);
1891
+ expect(
1892
+ String(bundleDescriptor.sha256).toLowerCase() === bundleSha256,
1893
+ `Workspace mirror bundle sha256 mismatch. Expected ${bundleDescriptor.sha256}, got ${bundleSha256}.`,
1894
+ );
1895
+ return bytes;
1896
+ }
1897
+
1898
+ function parseJsonBytes(bytes, url) {
1899
+ try {
1900
+ return JSON.parse(bytes.toString("utf8"));
1901
+ } catch (error) {
1902
+ throw new Error(`Invalid JSON from ${url}: ${error.message}`);
1903
+ }
1904
+ }
1905
+
1906
+ function selectWorkspaceRecoveryIndex(localRecoveryIndex, mirrorRecoveryIndex) {
1907
+ if (!localRecoveryIndex) {
1908
+ return mirrorRecoveryIndex ?? null;
1909
+ }
1910
+ if (!mirrorRecoveryIndex) {
1911
+ return localRecoveryIndex;
1912
+ }
1913
+ return Number(mirrorRecoveryIndex.nextBlock) > Number(localRecoveryIndex.nextBlock)
1914
+ ? mirrorRecoveryIndex
1915
+ : localRecoveryIndex;
768
1916
  }
769
1917
 
770
1918
  async function syncChannelWorkspace({
@@ -777,6 +1925,7 @@ async function syncChannelWorkspace({
777
1925
  allowExistingWorkspaceSync = false,
778
1926
  useWorkspaceRecoveryIndex = false,
779
1927
  fromGenesis = false,
1928
+ recoverySource = "rpc",
780
1929
  minimumToBlock = null,
781
1930
  progressAction = null,
782
1931
  }) {
@@ -854,17 +2003,57 @@ async function syncChannelWorkspace({
854
2003
  managedStorageAddresses,
855
2004
  })
856
2005
  : null;
2006
+ const mirrorRecovery = useWorkspaceRecoveryIndex && !fromGenesis
2007
+ ? await loadWorkspaceMirrorRecoveryIndex({
2008
+ recoverySource,
2009
+ bridgeCore,
2010
+ channelId,
2011
+ channelName,
2012
+ network,
2013
+ bridgeDeployment,
2014
+ channelInfo,
2015
+ genesisBlockNumber,
2016
+ managedStorageAddresses,
2017
+ blockInfo,
2018
+ contractCodes,
2019
+ latestBlock,
2020
+ localRecoveryIndex: recoveryIndex,
2021
+ bridgeAbiManifest,
2022
+ controllerAddress,
2023
+ l2AccountingVaultAddress,
2024
+ liquidBalancesSlot,
2025
+ })
2026
+ : {
2027
+ recoveryIndex: null,
2028
+ workspaceMirror: {
2029
+ source: recoverySource,
2030
+ used: false,
2031
+ registeredUrl: null,
2032
+ manifestUrl: null,
2033
+ error: null,
2034
+ },
2035
+ };
2036
+ const selectedRecoveryIndex = selectWorkspaceRecoveryIndex(recoveryIndex, mirrorRecovery.recoveryIndex);
857
2037
  const localSnapshotReusable = !fromGenesis && (!useWorkspaceRecoveryIndex || recoveryIndex)
858
2038
  && canReuseLocalWorkspaceSnapshot({
859
2039
  existingArtifacts,
860
2040
  currentRootVectorHash,
861
2041
  managedStorageAddresses,
862
2042
  });
863
- if (useWorkspaceRecoveryIndex && !fromGenesis && !localSnapshotReusable && !recoveryIndex) {
2043
+ const mirrorSnapshotReusable = !fromGenesis && mirrorRecovery.recoveryIndex
2044
+ && ethers.toBigInt(normalizeBytes32Hex(mirrorRecovery.recoveryIndex.recoveryRootVectorHash))
2045
+ === ethers.toBigInt(normalizeBytes32Hex(currentRootVectorHash));
2046
+ if (
2047
+ useWorkspaceRecoveryIndex
2048
+ && !fromGenesis
2049
+ && !localSnapshotReusable
2050
+ && !mirrorSnapshotReusable
2051
+ && !selectedRecoveryIndex
2052
+ ) {
864
2053
  throw new Error([
865
2054
  `Workspace recovery index is missing or unusable for channel ${channelName} on ${networkNameFromChainId(network.chainId)}.`,
866
2055
  "The CLI will not fall back to replaying channel logs from genesis unless explicitly requested.",
867
- "Run channel recover-workspace --from-genesis or wallet recover-workspace --from-genesis to rebuild from channel genesis.",
2056
+ "Run channel recover-workspace --source rpc --from-genesis or wallet recover-workspace --from-genesis to rebuild from channel genesis.",
868
2057
  ].join(" "));
869
2058
  }
870
2059
  const reconstruction = localSnapshotReusable
@@ -876,6 +2065,15 @@ async function syncChannelWorkspace({
876
2065
  mode: "reused-current-snapshot",
877
2066
  },
878
2067
  }
2068
+ : mirrorSnapshotReusable
2069
+ ? {
2070
+ currentSnapshot: mirrorRecovery.recoveryIndex.stateSnapshot,
2071
+ scanRange: {
2072
+ fromBlock: latestBlock + 1,
2073
+ toBlock: latestBlock,
2074
+ mode: "reused-mirror-snapshot",
2075
+ },
2076
+ }
879
2077
  : await reconstructChannelSnapshot({
880
2078
  provider,
881
2079
  bridgeAbiManifest,
@@ -889,8 +2087,8 @@ async function syncChannelWorkspace({
889
2087
  controllerAddress,
890
2088
  l2AccountingVaultAddress,
891
2089
  liquidBalancesSlot,
892
- baseSnapshot: recoveryIndex?.stateSnapshot ?? null,
893
- fromBlock: recoveryIndex?.nextBlock ?? genesisBlockNumber,
2090
+ baseSnapshot: selectedRecoveryIndex?.stateSnapshot ?? null,
2091
+ fromBlock: selectedRecoveryIndex?.nextBlock ?? genesisBlockNumber,
894
2092
  toBlock: latestBlock,
895
2093
  progressAction,
896
2094
  });
@@ -922,6 +2120,8 @@ async function syncChannelWorkspace({
922
2120
  policySnapshot,
923
2121
  managedStorageAddresses,
924
2122
  liquidBalancesSlot: liquidBalancesSlot.toString(),
2123
+ recoverySource,
2124
+ workspaceMirror: mirrorRecovery.workspaceMirror,
925
2125
  recoveryLastScannedBlock,
926
2126
  recoveryRootVectorHash,
927
2127
  recoveryScanRange: reconstruction.scanRange,
@@ -2509,7 +3709,7 @@ function applyGuideNextAction(guide) {
2509
3709
  }
2510
3710
  if (guide.selectors.channelName && guide.state.channel?.onchain?.exists && !guide.state.channel?.local?.workspaceExists) {
2511
3711
  setGuideNextAction(guide, {
2512
- command: `channel recover-workspace --channel-name ${guide.selectors.channelName} --network ${guide.selectors.network} --from-genesis`,
3712
+ command: `channel recover-workspace --channel-name ${guide.selectors.channelName} --network ${guide.selectors.network} --source rpc --from-genesis`,
2513
3713
  why: "The channel exists on-chain, but the local channel workspace has not been recovered yet, so there is no local recovery index to resume from.",
2514
3714
  });
2515
3715
  return;
@@ -2649,7 +3849,7 @@ function redactRpcUrl(rpcUrl) {
2649
3849
 
2650
3850
  async function handleWalletGetMeta({ args, provider }) {
2651
3851
  const { wallet, walletMetadata } = loadUnlockedWalletWithMetadata(args);
2652
- const contextResult = await loadPreferredWalletChannelContext({
3852
+ const contextResult = await loadFreshWalletChannelContext({
2653
3853
  walletContext: wallet,
2654
3854
  provider,
2655
3855
  progressAction: "wallet get-meta",
@@ -2683,8 +3883,8 @@ async function handleWalletGetMeta({ args, provider }) {
2683
3883
  });
2684
3884
  }
2685
3885
 
2686
- async function loadWalletChannelFundState({ walletContext, provider, progressAction = null }) {
2687
- const contextResult = await loadPreferredWalletChannelContext({
3886
+ async function loadWalletChannelFundState({ walletContext, provider, progressAction = "wallet get-channel-fund" }) {
3887
+ const contextResult = await loadFreshWalletChannelContext({
2688
3888
  walletContext,
2689
3889
  provider,
2690
3890
  progressAction,
@@ -2768,7 +3968,6 @@ async function handleWalletGetChannelFund({ args, provider }) {
2768
3968
  } = await loadWalletChannelFundState({
2769
3969
  walletContext: wallet,
2770
3970
  provider,
2771
- progressAction: "wallet get-channel-fund",
2772
3971
  });
2773
3972
 
2774
3973
  printJson({
@@ -2914,6 +4113,7 @@ async function handleExitChannel({ args, provider }) {
2914
4113
  const { signer, context, channelFund, contextResult } = await loadWalletChannelFundState({
2915
4114
  walletContext,
2916
4115
  provider,
4116
+ progressAction: "channel exit",
2917
4117
  });
2918
4118
  const network = contextResult.network;
2919
4119
  expect(
@@ -2959,7 +4159,7 @@ async function handleGrothVaultMove({ args, provider, direction }) {
2959
4159
  : "wallet withdraw-channel";
2960
4160
  emitProgress(operationName, "loading");
2961
4161
  const { wallet: walletContext } = loadUnlockedWalletWithMetadata(args);
2962
- const contextResult = await loadPreferredWalletChannelContext({
4162
+ const contextResult = await loadFreshWalletChannelContext({
2963
4163
  walletContext,
2964
4164
  provider,
2965
4165
  progressAction: operationName,
@@ -3189,7 +4389,7 @@ async function handleMintNotes({ args, provider }) {
3189
4389
  "Invalid --amounts. The array must contain at least one amount greater than zero.",
3190
4390
  );
3191
4391
  const totalMintAmount = baseUnitAmounts.reduce((sum, { amountBaseUnits }) => sum + amountBaseUnits, 0n);
3192
- const { channelFund } = await loadWalletChannelFundState({
4392
+ const { channelFund, contextResult: preparedContextResult } = await loadWalletChannelFundState({
3193
4393
  walletContext: wallet,
3194
4394
  provider,
3195
4395
  progressAction: "wallet mint-notes",
@@ -3205,12 +4405,13 @@ async function handleMintNotes({ args, provider }) {
3205
4405
  wallet,
3206
4406
  baseUnitAmounts: baseUnitAmounts.map(({ amountBaseUnits }) => amountBaseUnits),
3207
4407
  });
3208
- const { execution, contextResult, recoveredWorkspace } = await executeWalletDirectTemplateCommand({
4408
+ const { execution, contextResult, walletWarnings } = await executeWalletDirectTemplateCommand({
3209
4409
  args,
3210
4410
  wallet,
3211
4411
  provider,
3212
4412
  operationName: "wallet mint-notes",
3213
4413
  templatePayload,
4414
+ preparedContextResult,
3214
4415
  });
3215
4416
 
3216
4417
  printJson({
@@ -3236,7 +4437,8 @@ async function handleMintNotes({ args, provider }) {
3236
4437
  gasUsed: receiptGasUsed(execution.receipt),
3237
4438
  txUrl: explorerTxUrl(contextResult.network, execution.receipt.hash),
3238
4439
  usedWorkspaceCache: contextResult.usingWorkspaceCache,
3239
- recoveredWorkspace,
4440
+ recoveredWorkspace: contextResult.recoveredWorkspace,
4441
+ warnings: walletWarnings,
3240
4442
  updatedRoots: execution.context.currentSnapshot.stateRoots,
3241
4443
  });
3242
4444
  }
@@ -3244,17 +4446,30 @@ async function handleMintNotes({ args, provider }) {
3244
4446
  async function handleRedeemNotes({ args, provider }) {
3245
4447
  const { wallet } = loadUnlockedWalletWithMetadata(args);
3246
4448
  const noteIds = parseNoteIdVector(requireArg(args.noteIds, "--note-ids"));
4449
+ const preparedContextResult = await loadFreshWalletChannelContext({
4450
+ walletContext: wallet,
4451
+ provider,
4452
+ progressAction: "wallet redeem-notes",
4453
+ });
4454
+ await ensureWalletNoteReceiveStateCurrent({
4455
+ walletContext: wallet,
4456
+ context: preparedContextResult.context,
4457
+ provider,
4458
+ progressAction: "wallet redeem-notes",
4459
+ preConsumedLogRequests: preparedContextResult.autoRecoveryLogRequests,
4460
+ });
3247
4461
  const inputNotes = loadWalletUnusedInputNotes(wallet, noteIds);
3248
4462
  const templatePayload = buildRedeemNotesTemplatePayload({
3249
4463
  wallet,
3250
4464
  inputNotes,
3251
4465
  });
3252
- const { execution, contextResult, recoveredWorkspace } = await executeWalletDirectTemplateCommand({
4466
+ const { execution, contextResult, walletWarnings } = await executeWalletDirectTemplateCommand({
3253
4467
  args,
3254
4468
  wallet,
3255
4469
  provider,
3256
4470
  operationName: "wallet redeem-notes",
3257
4471
  templatePayload,
4472
+ preparedContextResult,
3258
4473
  });
3259
4474
 
3260
4475
  printJson({
@@ -3280,7 +4495,8 @@ async function handleRedeemNotes({ args, provider }) {
3280
4495
  gasUsed: receiptGasUsed(execution.receipt),
3281
4496
  txUrl: explorerTxUrl(contextResult.network, execution.receipt.hash),
3282
4497
  usedWorkspaceCache: contextResult.usingWorkspaceCache,
3283
- recoveredWorkspace,
4498
+ recoveredWorkspace: contextResult.recoveredWorkspace,
4499
+ warnings: walletWarnings,
3284
4500
  updatedRoots: execution.context.currentSnapshot.stateRoots,
3285
4501
  });
3286
4502
  }
@@ -3292,18 +4508,18 @@ async function handleWalletGetNotes({ args, provider }) {
3292
4508
  `Wallet ${wallet.walletName} is missing the stored controller address.`,
3293
4509
  );
3294
4510
  const canonicalAssetDecimals = Number(wallet.wallet.canonicalAssetDecimals);
3295
- const { context } = await loadPreferredWalletChannelContext({
4511
+ const contextResult = await loadFreshWalletChannelContext({
3296
4512
  walletContext: wallet,
3297
4513
  provider,
3298
4514
  progressAction: "wallet get-notes",
3299
4515
  });
3300
- const signer = restoreWalletSigner(wallet, provider);
3301
- const { recoveredDeliveryState } = await recoverWalletReceivedNotes({
4516
+ const context = contextResult.context;
4517
+ const noteReceiveFreshness = await ensureWalletNoteReceiveStateCurrent({
3302
4518
  walletContext: wallet,
3303
4519
  context,
3304
4520
  provider,
3305
- signer,
3306
4521
  progressAction: "wallet get-notes",
4522
+ preConsumedLogRequests: contextResult.autoRecoveryLogRequests,
3307
4523
  });
3308
4524
 
3309
4525
  const unusedTrackedNotes = wallet.wallet.notes.unusedOrder
@@ -3340,21 +4556,32 @@ async function handleWalletGetNotes({ args, provider }) {
3340
4556
  spentTotalBaseUnits: spentTotal.toString(),
3341
4557
  spentTotalTokens: ethers.formatUnits(spentTotal, canonicalAssetDecimals),
3342
4558
  bridgeStatusMismatches: [...unusedNotes, ...spentNotes].filter((note) => !note.walletStatusMatchesBridge).length,
3343
- recoveredFromLogs: recoveredDeliveryState.importedNotes,
3344
- scannedDeliveryLogs: recoveredDeliveryState.scannedLogs,
3345
- noteReceiveScanRange: recoveredDeliveryState.scanRange,
4559
+ noteReceiveLastScannedBlock: noteReceiveFreshness.nextBlock,
4560
+ latestBlock: noteReceiveFreshness.latestBlock,
4561
+ recoveredWalletWorkspace: noteReceiveFreshness.recoveredWalletWorkspace,
4562
+ recoveredFromLogs: noteReceiveFreshness.recoveredDeliveryState?.importedNotes ?? 0,
4563
+ scannedDeliveryLogs: noteReceiveFreshness.recoveredDeliveryState?.scannedLogs ?? 0,
4564
+ noteReceiveScanRange: noteReceiveFreshness.recoveredDeliveryState?.scanRange ?? null,
3346
4565
  });
3347
4566
  }
3348
4567
 
3349
4568
  async function handleTransferNotes({ args, provider }) {
3350
4569
  const { wallet } = loadUnlockedWalletWithMetadata(args);
3351
4570
  const { signer } = restoreWalletParticipant(wallet, provider);
3352
- const preparedContextResult = await loadPreferredWalletChannelContext({
4571
+ const preparedContextResult = await loadFreshWalletChannelContext({
3353
4572
  walletContext: wallet,
3354
4573
  provider,
3355
4574
  progressAction: "wallet transfer-notes",
3356
4575
  });
3357
4576
  const context = preparedContextResult.context;
4577
+ await ensureWalletNoteReceiveStateCurrent({
4578
+ walletContext: wallet,
4579
+ context,
4580
+ provider,
4581
+ signer,
4582
+ progressAction: "wallet transfer-notes",
4583
+ preConsumedLogRequests: preparedContextResult.autoRecoveryLogRequests,
4584
+ });
3358
4585
  const canonicalAssetDecimals = Number(wallet.wallet.canonicalAssetDecimals);
3359
4586
  const noteIds = parseNoteIdVector(requireArg(args.noteIds, "--note-ids"));
3360
4587
  const recipients = parseRecipientVector(requireArg(args.recipients, "--recipients"));
@@ -3364,12 +4591,12 @@ async function handleTransferNotes({ args, provider }) {
3364
4591
  "--amounts length must match --recipients length.",
3365
4592
  );
3366
4593
 
3367
- const inputNotes = loadWalletUnusedInputNotes(wallet, noteIds);
3368
4594
  const outputAmounts = amountInputs.map((value, index) => {
3369
4595
  const parsed = parseTokenAmount(value, canonicalAssetDecimals);
3370
4596
  expect(parsed > 0n, `Invalid --amounts[${index}]. Each amount must be greater than zero.`);
3371
4597
  return parsed;
3372
4598
  });
4599
+ const inputNotes = loadWalletUnusedInputNotes(wallet, noteIds);
3373
4600
  const totalInput = inputNotes.reduce((sum, note) => sum + ethers.toBigInt(note.value), 0n);
3374
4601
  const totalOutput = outputAmounts.reduce((sum, value) => sum + value, 0n);
3375
4602
  expect(
@@ -3384,12 +4611,13 @@ async function handleTransferNotes({ args, provider }) {
3384
4611
  recipients,
3385
4612
  outputAmounts,
3386
4613
  });
3387
- const { execution, contextResult, recoveredWorkspace } = await executeWalletDirectTemplateCommand({
4614
+ const { execution, contextResult, walletWarnings } = await executeWalletDirectTemplateCommand({
3388
4615
  args,
3389
4616
  wallet,
3390
4617
  provider,
3391
4618
  operationName: "wallet transfer-notes",
3392
4619
  templatePayload,
4620
+ preparedContextResult,
3393
4621
  });
3394
4622
  const outputNotes = buildLifecycleTrackedOutputs({
3395
4623
  outputNotes: templatePayload.lifecycleOutputs,
@@ -3420,7 +4648,8 @@ async function handleTransferNotes({ args, provider }) {
3420
4648
  gasUsed: receiptGasUsed(execution.receipt),
3421
4649
  txUrl: explorerTxUrl(contextResult.network, execution.receipt.hash),
3422
4650
  usedWorkspaceCache: contextResult.usingWorkspaceCache,
3423
- recoveredWorkspace,
4651
+ recoveredWorkspace: contextResult.recoveredWorkspace,
4652
+ warnings: walletWarnings,
3424
4653
  updatedRoots: execution.context.currentSnapshot.stateRoots,
3425
4654
  });
3426
4655
  }
@@ -3887,6 +5116,132 @@ function requireUsableWalletNoteReceiveRecoveryIndex({ walletContext, context, l
3887
5116
  return nextBlock;
3888
5117
  }
3889
5118
 
5119
+ async function assertWalletNoteReceiveStateFresh({ walletContext, context, provider }) {
5120
+ const latestBlock = await provider.getBlockNumber();
5121
+ const nextBlock = requireUsableWalletNoteReceiveRecoveryIndex({
5122
+ walletContext,
5123
+ context,
5124
+ latestBlock,
5125
+ });
5126
+ if (nextBlock !== latestBlock + 1) {
5127
+ throw new Error([
5128
+ `Wallet note workspace is stale for wallet ${walletContext.walletName}.`,
5129
+ `noteReceiveLastScannedBlock is ${nextBlock}, but latest block requires ${latestBlock + 1}.`,
5130
+ "Run wallet recover-workspace before using commands that read or spend wallet notes.",
5131
+ ].join(" "));
5132
+ }
5133
+ return {
5134
+ latestBlock,
5135
+ nextBlock,
5136
+ };
5137
+ }
5138
+
5139
+ async function ensureWalletNoteReceiveStateCurrent({
5140
+ walletContext,
5141
+ context,
5142
+ provider,
5143
+ signer = null,
5144
+ progressAction = null,
5145
+ preConsumedLogRequests = 0,
5146
+ }) {
5147
+ const latestBlock = await provider.getBlockNumber();
5148
+ let nextBlock;
5149
+ try {
5150
+ nextBlock = requireUsableWalletNoteReceiveRecoveryIndex({
5151
+ walletContext,
5152
+ context,
5153
+ latestBlock,
5154
+ });
5155
+ } catch (indexError) {
5156
+ throw new Error([
5157
+ `Wallet note recovery index is missing or unusable for wallet ${walletContext.walletName}.`,
5158
+ "Automatic wallet recovery uses only the saved note recovery index and will not replay from genesis.",
5159
+ `Run wallet recover-workspace --channel-name ${context.workspace.channelName} --network ${context.workspace.network} --account <ACCOUNT> --from-genesis if a genesis rebuild is required.`,
5160
+ `Details: ${indexError.message}`,
5161
+ ].join(" "));
5162
+ }
5163
+
5164
+ if (nextBlock === latestBlock + 1) {
5165
+ return {
5166
+ latestBlock,
5167
+ nextBlock,
5168
+ recoveredWalletWorkspace: false,
5169
+ recoveredDeliveryState: null,
5170
+ autoRecoveryLogRequests: 0,
5171
+ };
5172
+ }
5173
+ const remainingLogRequestBudget = AUTO_RECOVERY_LOG_REQUEST_BUDGET - Math.max(0, Number(preConsumedLogRequests));
5174
+ const autoRecoveryLogRequests = assertAutoRecoveryLogScanBudget({
5175
+ label: `wallet note workspace ${walletContext.walletName}`,
5176
+ fromBlock: nextBlock,
5177
+ toBlock: latestBlock,
5178
+ logScanCount: 1,
5179
+ recoveryCommand: `wallet recover-workspace --channel-name ${context.workspace.channelName} --network ${context.workspace.network} --account <ACCOUNT>`,
5180
+ logRequestBudget: remainingLogRequestBudget,
5181
+ });
5182
+
5183
+ const resolvedSigner = signer ?? restoreWalletParticipant(walletContext, provider).signer;
5184
+ let recoveredDeliveryState;
5185
+ try {
5186
+ ({ recoveredDeliveryState } = await recoverWalletReceivedNotes({
5187
+ walletContext,
5188
+ context,
5189
+ provider,
5190
+ signer: resolvedSigner,
5191
+ progressAction,
5192
+ fromGenesis: false,
5193
+ }));
5194
+ } catch (recoveryError) {
5195
+ throw new Error([
5196
+ `Wallet workspace is not current for wallet ${walletContext.walletName}.`,
5197
+ "Automatic wallet recovery uses only the saved note recovery index and will not replay from genesis.",
5198
+ `Run wallet recover-workspace --channel-name ${context.workspace.channelName} --network ${context.workspace.network} --account <ACCOUNT> --from-genesis if a genesis rebuild is required.`,
5199
+ `Details: ${recoveryError.message}`,
5200
+ ].join(" "));
5201
+ }
5202
+ try {
5203
+ const freshness = await assertWalletNoteReceiveStateFresh({ walletContext, context, provider });
5204
+ return {
5205
+ ...freshness,
5206
+ recoveredWalletWorkspace: true,
5207
+ recoveredDeliveryState,
5208
+ autoRecoveryLogRequests,
5209
+ };
5210
+ } catch (postRecoveryError) {
5211
+ throw new Error([
5212
+ `Wallet workspace is still stale after recovery-index sync for wallet ${walletContext.walletName}.`,
5213
+ "Automatic wallet recovery will not replay from genesis.",
5214
+ `Run wallet recover-workspace --channel-name ${context.workspace.channelName} --network ${context.workspace.network} --account <ACCOUNT> --from-genesis if a genesis rebuild is required.`,
5215
+ `Details: ${postRecoveryError.message}`,
5216
+ ].join(" "));
5217
+ }
5218
+ }
5219
+
5220
+ async function buildPostWalletCommandWarnings({ walletContext, context, provider, receipt }) {
5221
+ const latestBlock = await provider.getBlockNumber();
5222
+ const noteReceiveNextBlock = Number(walletContext.wallet.noteReceiveLastScannedBlock);
5223
+ const warnings = [];
5224
+ if (
5225
+ Number.isInteger(noteReceiveNextBlock)
5226
+ && receipt?.blockNumber !== undefined
5227
+ && noteReceiveNextBlock <= Number(receipt.blockNumber)
5228
+ ) {
5229
+ warnings.push([
5230
+ `Wallet note workspace may be stale after block ${receipt.blockNumber}.`,
5231
+ `Run wallet recover-workspace --channel-name ${context.workspace.channelName}`,
5232
+ `--network ${context.workspace.network}`,
5233
+ `--account <ACCOUNT> before commands that read or spend newly received notes.`,
5234
+ ].join(" "));
5235
+ } else if (Number.isInteger(noteReceiveNextBlock) && noteReceiveNextBlock !== latestBlock + 1) {
5236
+ warnings.push([
5237
+ "Wallet note workspace is not aligned with the latest block.",
5238
+ `noteReceiveLastScannedBlock is ${noteReceiveNextBlock}, latest block requires ${latestBlock + 1}.`,
5239
+ "Run wallet recover-workspace before commands that read or spend wallet notes.",
5240
+ ].join(" "));
5241
+ }
5242
+ return warnings;
5243
+ }
5244
+
3890
5245
  function extractEncryptedNoteValueFromBridgeLog(log) {
3891
5246
  if (!Array.isArray(log?.topics) || log.topics.length !== 1) {
3892
5247
  return null;
@@ -4350,52 +5705,195 @@ function walletChannelWorkspaceIsReady(walletContext) {
4350
5705
  && fs.existsSync(path.join(channelWorkspaceCurrentPath(workspaceDir), "contract_codes.json"));
4351
5706
  }
4352
5707
 
4353
- async function loadPreferredWalletChannelContext({
5708
+ async function loadFreshWalletChannelContext({
4354
5709
  walletContext,
4355
5710
  provider,
4356
- forceRecover = false,
4357
5711
  progressAction = null,
4358
5712
  }) {
4359
- let recoveredWorkspace = false;
4360
- if (forceRecover || !walletChannelWorkspaceIsReady(walletContext)) {
4361
- await recoverWalletChannelWorkspace({ walletContext, provider, progressAction });
4362
- recoveredWorkspace = true;
4363
- }
4364
- let context = await loadWorkspaceContext(walletContext.wallet.channelName, walletContext.wallet.network, provider);
5713
+ const contextResult = await loadFreshChannelWorkspaceContextResult({
5714
+ channelName: walletContext.wallet.channelName,
5715
+ networkName: walletContext.wallet.network,
5716
+ provider,
5717
+ progressAction,
5718
+ });
5719
+ return {
5720
+ context: contextResult.context,
5721
+ network: resolveCliNetwork(contextResult.context.workspace.network),
5722
+ usingWorkspaceCache: !contextResult.recoveredWorkspace,
5723
+ recoveredWorkspace: contextResult.recoveredWorkspace,
5724
+ autoRecoveryLogRequests: contextResult.autoRecoveryLogRequests,
5725
+ };
5726
+ }
5727
+
5728
+ async function loadFreshChannelWorkspaceContext({
5729
+ channelName,
5730
+ networkName,
5731
+ provider,
5732
+ progressAction = null,
5733
+ }) {
5734
+ const { context } = await loadFreshChannelWorkspaceContextResult({
5735
+ channelName,
5736
+ networkName,
5737
+ provider,
5738
+ progressAction,
5739
+ });
5740
+ return context;
5741
+ }
5742
+
5743
+ async function loadFreshChannelWorkspaceContextResult({
5744
+ channelName,
5745
+ networkName,
5746
+ provider,
5747
+ progressAction = null,
5748
+ }) {
5749
+ let context;
4365
5750
  try {
5751
+ context = await loadWorkspaceContext(channelName, networkName, provider);
4366
5752
  await assertWorkspaceAlignedWithChain(context);
5753
+ return {
5754
+ context,
5755
+ recoveredWorkspace: false,
5756
+ autoRecoveryLogRequests: 0,
5757
+ };
4367
5758
  } catch (error) {
4368
- if (!isRecoverableWalletWorkspaceFailure(error)) {
4369
- throw error;
4370
- }
4371
- await recoverWalletChannelWorkspace({ walletContext, provider, progressAction });
4372
- recoveredWorkspace = true;
4373
- context = await loadWorkspaceContext(walletContext.wallet.channelName, walletContext.wallet.network, provider);
5759
+ const recovery = await recoverChannelWorkspaceFromIndexOnly({
5760
+ channelName,
5761
+ networkName,
5762
+ provider,
5763
+ progressAction,
5764
+ cause: error,
5765
+ });
5766
+ return {
5767
+ context: recovery.context,
5768
+ recoveredWorkspace: true,
5769
+ autoRecoveryLogRequests: recovery.autoRecoveryLogRequests,
5770
+ };
5771
+ }
5772
+ }
5773
+
5774
+ async function recoverChannelWorkspaceFromIndexOnly({
5775
+ channelName,
5776
+ networkName,
5777
+ provider,
5778
+ progressAction = null,
5779
+ cause = null,
5780
+ }) {
5781
+ const network = resolveCliNetwork(networkName);
5782
+ const bridgeResources = loadBridgeResources({ chainId: network.chainId });
5783
+ const readiness = await requireChannelWorkspaceRecoveryIndexForAutoRefresh({
5784
+ channelName,
5785
+ networkName,
5786
+ provider,
5787
+ bridgeResources,
5788
+ cause,
5789
+ });
5790
+ if (readiness.alreadyCurrent) {
5791
+ return {
5792
+ context: await loadWorkspaceContext(channelName, networkName, provider),
5793
+ autoRecoveryLogRequests: 0,
5794
+ };
5795
+ }
5796
+ try {
5797
+ await syncChannelWorkspace({
5798
+ workspaceName: channelName,
5799
+ channelName,
5800
+ network,
5801
+ provider,
5802
+ bridgeResources,
5803
+ persist: true,
5804
+ allowExistingWorkspaceSync: true,
5805
+ useWorkspaceRecoveryIndex: true,
5806
+ fromGenesis: false,
5807
+ progressAction,
5808
+ });
5809
+ } catch (recoveryError) {
5810
+ throw new Error([
5811
+ `Channel workspace is not current for ${channelName} on ${networkName}.`,
5812
+ "Automatic channel workspace recovery uses only the saved recovery index and will not replay from genesis.",
5813
+ `Run channel recover-workspace --channel-name ${channelName} --network ${networkName} --source rpc --from-genesis if a genesis rebuild is required.`,
5814
+ `Details: ${recoveryError.message}`,
5815
+ cause ? `Initial freshness failure: ${cause.message}` : null,
5816
+ ].filter(Boolean).join(" "));
5817
+ }
5818
+
5819
+ const context = await loadWorkspaceContext(channelName, networkName, provider);
5820
+ try {
4374
5821
  await assertWorkspaceAlignedWithChain(context);
5822
+ } catch (postRecoveryError) {
5823
+ throw new Error([
5824
+ `Channel workspace is still stale after recovery-index sync for ${channelName} on ${networkName}.`,
5825
+ "Automatic channel workspace recovery will not replay from genesis.",
5826
+ `Run channel recover-workspace --channel-name ${channelName} --network ${networkName} --source rpc --from-genesis if a genesis rebuild is required.`,
5827
+ `Details: ${postRecoveryError.message}`,
5828
+ cause ? `Initial freshness failure: ${cause.message}` : null,
5829
+ ].filter(Boolean).join(" "));
4375
5830
  }
4376
5831
  return {
4377
5832
  context,
4378
- network: resolveCliNetwork(context.workspace.network),
4379
- usingWorkspaceCache: !recoveredWorkspace,
4380
- recoveredWorkspace,
5833
+ autoRecoveryLogRequests: readiness.autoRecoveryLogRequests,
4381
5834
  };
4382
5835
  }
4383
5836
 
4384
- async function recoverWalletChannelWorkspace({ walletContext, provider, progressAction = null }) {
4385
- const networkName = walletContext.wallet.network ?? networkNameFromChainId(Number(walletContext.wallet.chainId));
4386
- const network = resolveCliNetwork(networkName);
4387
- const bridgeResources = loadBridgeResources({ chainId: network.chainId });
4388
- await syncChannelWorkspace({
4389
- workspaceName: walletContext.wallet.channelName,
4390
- channelName: walletContext.wallet.channelName,
4391
- network,
5837
+ async function requireChannelWorkspaceRecoveryIndexForAutoRefresh({
5838
+ channelName,
5839
+ networkName,
5840
+ provider,
5841
+ bridgeResources,
5842
+ cause = null,
5843
+ }) {
5844
+ const workspaceDir = channelWorkspacePath(networkName, channelName);
5845
+ const existingArtifacts = loadExistingWorkspaceArtifacts(workspaceDir);
5846
+ const fail = (message) => {
5847
+ throw new Error([
5848
+ message,
5849
+ "Automatic channel workspace recovery uses only the saved recovery index and will not replay from genesis.",
5850
+ `Run channel recover-workspace --channel-name ${channelName} --network ${networkName} --source rpc --from-genesis if a genesis rebuild is required.`,
5851
+ cause ? `Initial freshness failure: ${cause.message}` : null,
5852
+ ].filter(Boolean).join(" "));
5853
+ };
5854
+ if (!existingArtifacts.workspace || !existingArtifacts.stateSnapshot) {
5855
+ fail(`Channel workspace recovery index is missing for ${channelName} on ${networkName}.`);
5856
+ }
5857
+
5858
+ const { bridgeDeployment, bridgeAbiManifest } = bridgeResources;
5859
+ const bridgeCore = new Contract(bridgeDeployment.bridgeCore, bridgeAbiManifest.contracts.bridgeCore.abi, provider);
5860
+ const channelId = deriveChannelIdFromName(channelName);
5861
+ const channelInfo = await bridgeCore.getChannel(channelId);
5862
+ if (!channelInfo.exists) {
5863
+ fail(`Unknown channel ${channelId.toString()} in bridge core ${bridgeDeployment.bridgeCore}.`);
5864
+ }
5865
+ const channelManager = new Contract(
5866
+ channelInfo.manager,
5867
+ bridgeAbiManifest.contracts.channelManager.abi,
4392
5868
  provider,
4393
- bridgeResources,
4394
- persist: true,
4395
- allowExistingWorkspaceSync: true,
4396
- useWorkspaceRecoveryIndex: true,
4397
- progressAction,
5869
+ );
5870
+ const genesisBlockNumber = Number(await channelManager.genesisBlockNumber());
5871
+ const latestBlock = await provider.getBlockNumber();
5872
+ const managedStorageAddresses = normalizedAddressVector(await channelManager.getManagedStorageAddresses());
5873
+ const currentRootVectorHash = normalizeBytes32Hex(await channelManager.currentRootVectorHash());
5874
+ if (canReuseLocalWorkspaceSnapshot({ existingArtifacts, currentRootVectorHash, managedStorageAddresses })) {
5875
+ return { alreadyCurrent: true };
5876
+ }
5877
+ const recoveryIndex = getUsableWorkspaceRecoveryIndex({
5878
+ existingArtifacts,
5879
+ genesisBlockNumber,
5880
+ latestBlock,
5881
+ managedStorageAddresses,
5882
+ });
5883
+ if (!recoveryIndex) {
5884
+ fail(`Channel workspace recovery index is unusable for ${channelName} on ${networkName}.`);
5885
+ }
5886
+ if (Number(recoveryIndex.nextBlock) > Number(latestBlock)) {
5887
+ fail(`Channel workspace recovery index has already scanned through block ${recoveryIndex.nextBlock - 1}, but the local snapshot is not current.`);
5888
+ }
5889
+ const autoRecoveryLogRequests = assertAutoRecoveryLogScanBudget({
5890
+ label: `channel workspace ${channelName} on ${networkName}`,
5891
+ fromBlock: recoveryIndex.nextBlock,
5892
+ toBlock: latestBlock,
5893
+ logScanCount: 2,
5894
+ recoveryCommand: `channel recover-workspace --channel-name ${channelName} --network ${networkName}`,
4398
5895
  });
5896
+ return { alreadyCurrent: false, autoRecoveryLogRequests };
4399
5897
  }
4400
5898
 
4401
5899
  async function refreshPersistedWorkspaceAfterLocalTransaction({
@@ -4441,12 +5939,6 @@ async function refreshPersistedWorkspaceAfterLocalTransaction({
4441
5939
  return context;
4442
5940
  }
4443
5941
 
4444
- function isRecoverableWalletWorkspaceFailure(error) {
4445
- const message = String(error?.message ?? error);
4446
- return (message.includes("--verify") && message.includes("failed with exit code"))
4447
- || message.includes("The workspace snapshot is stale relative to the bridge channel state.");
4448
- }
4449
-
4450
5942
  function assertWalletMatchesChannelContext(walletContext, l2Identity, context) {
4451
5943
  expect(
4452
5944
  ethers.toBigInt(walletContext.wallet.channelId) === ethers.toBigInt(context.workspace.channelId),
@@ -4464,6 +5956,7 @@ async function executeWalletDirectTemplateCommand({
4464
5956
  provider,
4465
5957
  operationName,
4466
5958
  templatePayload,
5959
+ preparedContextResult = null,
4467
5960
  }) {
4468
5961
  emitProgress(operationName, "loading");
4469
5962
  const { signer, l2Identity } = restoreWalletParticipant(wallet, provider);
@@ -4471,51 +5964,16 @@ async function executeWalletDirectTemplateCommand({
4471
5964
  txSubmitter,
4472
5965
  source: txSubmitterSource,
4473
5966
  account: txSubmitterAccount,
4474
- } = resolveTxSubmitterSigner({
4475
- args,
4476
- ownerSigner: signer,
4477
- provider,
4478
- });
4479
- let contextResult = await loadPreferredWalletChannelContext({
4480
- walletContext: wallet,
4481
- provider,
4482
- progressAction: operationName,
4483
- });
4484
- let recoveredWorkspace = contextResult.recoveredWorkspace;
4485
-
4486
- try {
4487
- const execution = await executeWalletTemplateSend({
4488
- wallet,
4489
- signer,
4490
- txSubmitter,
4491
- txSubmitterSource,
4492
- txSubmitterAccount,
4493
- l2Identity,
4494
- context: contextResult.context,
4495
- provider,
4496
- operationName,
4497
- functionName: templatePayload.method,
4498
- templatePayload,
4499
- });
4500
- emitProgress(operationName, "done");
4501
- return {
4502
- execution,
4503
- contextResult,
4504
- recoveredWorkspace,
4505
- };
4506
- } catch (error) {
4507
- if (!isRecoverableWalletWorkspaceFailure(error)) {
4508
- throw error;
4509
- }
4510
- }
4511
-
4512
- contextResult = await loadPreferredWalletChannelContext({
5967
+ } = resolveTxSubmitterSigner({
5968
+ args,
5969
+ ownerSigner: signer,
5970
+ provider,
5971
+ });
5972
+ const contextResult = preparedContextResult ?? await loadFreshWalletChannelContext({
4513
5973
  walletContext: wallet,
4514
5974
  provider,
4515
- forceRecover: true,
4516
5975
  progressAction: operationName,
4517
5976
  });
4518
- recoveredWorkspace = contextResult.recoveredWorkspace;
4519
5977
  const execution = await executeWalletTemplateSend({
4520
5978
  wallet,
4521
5979
  signer,
@@ -4529,11 +5987,18 @@ async function executeWalletDirectTemplateCommand({
4529
5987
  functionName: templatePayload.method,
4530
5988
  templatePayload,
4531
5989
  });
5990
+ const walletWarnings = await buildPostWalletCommandWarnings({
5991
+ walletContext: wallet,
5992
+ context: execution.context,
5993
+ provider,
5994
+ receipt: execution.receipt,
5995
+ });
4532
5996
  emitProgress(operationName, "done");
4533
5997
  return {
4534
5998
  execution,
4535
5999
  contextResult,
4536
- recoveredWorkspace,
6000
+ recoveredWorkspace: contextResult.recoveredWorkspace,
6001
+ walletWarnings,
4537
6002
  };
4538
6003
  }
4539
6004
 
@@ -4704,34 +6169,29 @@ async function loadJoinChannelContext({ args, network, provider }) {
4704
6169
  const channelName = requireArg(args.channelName, "--channel-name");
4705
6170
 
4706
6171
  const bridgeResources = loadBridgeResources({ chainId });
4707
- const initialized = await syncChannelWorkspace({
4708
- workspaceName: channelName,
6172
+ const context = await loadFreshChannelWorkspaceContext({
4709
6173
  channelName,
4710
- network: { chainId, name: resolvedNetworkName },
6174
+ networkName: resolvedNetworkName,
4711
6175
  provider,
4712
- bridgeResources,
4713
- persist: true,
4714
- allowExistingWorkspaceSync: true,
4715
- useWorkspaceRecoveryIndex: true,
4716
6176
  progressAction: "channel join",
4717
6177
  });
4718
6178
 
4719
6179
  return {
4720
6180
  workspaceName: channelName,
4721
- workspaceDir: initialized.workspaceDir,
6181
+ workspaceDir: context.workspaceDir,
4722
6182
  persistChannelWorkspace: true,
4723
- workspace: initialized.workspace,
6183
+ workspace: context.workspace,
4724
6184
  bridgeAbiManifest: bridgeResources.bridgeAbiManifest,
4725
- currentSnapshot: initialized.currentSnapshot,
4726
- blockInfo: initialized.blockInfo,
4727
- contractCodes: initialized.contractCodes,
6185
+ currentSnapshot: context.currentSnapshot,
6186
+ blockInfo: context.blockInfo,
6187
+ contractCodes: context.contractCodes,
4728
6188
  channelManager: new Contract(
4729
- initialized.workspace.channelManager,
6189
+ context.workspace.channelManager,
4730
6190
  bridgeResources.bridgeAbiManifest.contracts.channelManager.abi,
4731
6191
  provider,
4732
6192
  ),
4733
6193
  bridgeTokenVault: new Contract(
4734
- initialized.workspace.bridgeTokenVault,
6194
+ context.workspace.bridgeTokenVault,
4735
6195
  bridgeResources.bridgeAbiManifest.contracts.bridgeTokenVault.abi,
4736
6196
  provider,
4737
6197
  ),
@@ -4933,6 +6393,7 @@ async function assertWorkspaceAlignedWithChain(context) {
4933
6393
  [
4934
6394
  "The workspace snapshot is stale relative to the bridge channel state.",
4935
6395
  `Workspace: ${context.workspaceDir}`,
6396
+ `Run channel recover-workspace --channel-name ${context.workspace.channelName} --network ${context.workspace.network}.`,
4936
6397
  ].join(" "),
4937
6398
  ),
4938
6399
  );
@@ -5474,6 +6935,56 @@ async function fetchChannelBlockInfo(provider, blockNumber) {
5474
6935
  };
5475
6936
  }
5476
6937
 
6938
+ async function fetchChannelRecoveryLogs({
6939
+ provider,
6940
+ bridgeAbiManifest,
6941
+ channelInfo,
6942
+ channelManager = null,
6943
+ fromBlock,
6944
+ toBlock,
6945
+ progressAction = null,
6946
+ }) {
6947
+ const resolvedChannelManager = channelManager ?? new Contract(
6948
+ channelInfo.manager,
6949
+ bridgeAbiManifest.contracts.channelManager.abi,
6950
+ provider,
6951
+ );
6952
+ const bridgeTokenVault = new Contract(
6953
+ channelInfo.bridgeTokenVault,
6954
+ bridgeAbiManifest.contracts.bridgeTokenVault.abi,
6955
+ provider,
6956
+ );
6957
+ const currentRootVectorObservedTopic =
6958
+ normalizeBytes32Hex(resolvedChannelManager.interface.getEvent("CurrentRootVectorObserved").topicHash);
6959
+ const channelManagerLogs = await fetchLogsChunked(provider, {
6960
+ address: channelInfo.manager,
6961
+ topics: [[
6962
+ currentRootVectorObservedTopic,
6963
+ CONTROLLER_STORAGE_KEY_OBSERVED_TOPIC,
6964
+ VAULT_STORAGE_WRITE_OBSERVED_TOPIC,
6965
+ ]],
6966
+ fromBlock,
6967
+ toBlock,
6968
+ onProgress: progressAction
6969
+ ? createRpcLogScanProgress({ action: progressAction, label: "channel-manager events" })
6970
+ : null,
6971
+ });
6972
+ const bridgeVaultLogs = await queryContractEventsChunked({
6973
+ contract: bridgeTokenVault,
6974
+ eventName: "StorageWriteObserved",
6975
+ fromBlock,
6976
+ toBlock,
6977
+ onProgress: progressAction
6978
+ ? createRpcLogScanProgress({ action: progressAction, label: "bridge-vault events" })
6979
+ : null,
6980
+ });
6981
+ return {
6982
+ currentRootVectorObservedTopic,
6983
+ channelManagerLogs,
6984
+ bridgeVaultLogs,
6985
+ };
6986
+ }
6987
+
5477
6988
  async function reconstructChannelSnapshot({
5478
6989
  provider,
5479
6990
  bridgeAbiManifest,
@@ -5504,27 +7015,20 @@ async function reconstructChannelSnapshot({
5504
7015
  startingSnapshot = await genesisStateManager.captureStateSnapshot();
5505
7016
  }
5506
7017
 
5507
- const bridgeTokenVault = new Contract(
5508
- channelInfo.bridgeTokenVault,
5509
- bridgeAbiManifest.contracts.bridgeTokenVault.abi,
5510
- provider,
5511
- );
5512
7018
  const latestBlock = toBlock === null ? await provider.getBlockNumber() : Number(toBlock);
5513
7019
  const scanFromBlock = Math.max(Number(genesisBlockNumber), Number(fromBlock));
5514
- const currentRootVectorObservedTopic =
5515
- normalizeBytes32Hex(channelManager.interface.getEvent("CurrentRootVectorObserved").topicHash);
5516
- const channelManagerLogs = await fetchLogsChunked(provider, {
5517
- address: channelInfo.manager,
5518
- topics: [[
5519
- currentRootVectorObservedTopic,
5520
- CONTROLLER_STORAGE_KEY_OBSERVED_TOPIC,
5521
- VAULT_STORAGE_WRITE_OBSERVED_TOPIC,
5522
- ]],
7020
+ const {
7021
+ currentRootVectorObservedTopic,
7022
+ channelManagerLogs,
7023
+ bridgeVaultLogs,
7024
+ } = await fetchChannelRecoveryLogs({
7025
+ provider,
7026
+ bridgeAbiManifest,
7027
+ channelInfo,
7028
+ channelManager,
5523
7029
  fromBlock: scanFromBlock,
5524
7030
  toBlock: latestBlock,
5525
- onProgress: progressAction
5526
- ? createRpcLogScanProgress({ action: progressAction, label: "channel-manager events" })
5527
- : null,
7031
+ progressAction,
5528
7032
  });
5529
7033
  const channelManagerEvents = channelManagerLogs.map((log) => {
5530
7034
  const topic0 = log.topics[0] ? normalizeBytes32Hex(log.topics[0]) : null;
@@ -5538,18 +7042,9 @@ async function reconstructChannelSnapshot({
5538
7042
  }
5539
7043
  return log;
5540
7044
  });
5541
- const vaultStorageWriteEvents = await queryContractEventsChunked({
5542
- contract: bridgeTokenVault,
5543
- eventName: "StorageWriteObserved",
5544
- fromBlock: scanFromBlock,
5545
- toBlock: latestBlock,
5546
- onProgress: progressAction
5547
- ? createRpcLogScanProgress({ action: progressAction, label: "bridge-vault events" })
5548
- : null,
5549
- });
5550
7045
 
5551
7046
  const groupedEvents = new Map();
5552
- for (const event of [...channelManagerEvents, ...vaultStorageWriteEvents]) {
7047
+ for (const event of [...channelManagerEvents, ...bridgeVaultLogs]) {
5553
7048
  const key = event.transactionHash;
5554
7049
  const group = groupedEvents.get(key) ?? [];
5555
7050
  group.push(event);
@@ -5557,8 +7052,43 @@ async function reconstructChannelSnapshot({
5557
7052
  }
5558
7053
 
5559
7054
  const groupedValues = [...groupedEvents.values()].sort((left, right) => compareLogsByPosition(left[0], right[0]));
7055
+ const currentSnapshot = await applyChannelRecoveryEventGroups({
7056
+ startingSnapshot,
7057
+ groupedValues,
7058
+ contractCodes,
7059
+ channelInfo,
7060
+ controllerAddress,
7061
+ l2AccountingVaultAddress,
7062
+ liquidBalancesSlot,
7063
+ });
7064
+
7065
+ expect(
7066
+ ethers.toBigInt(normalizeBytes32Hex(hashRootVector(currentSnapshot.stateRoots)))
7067
+ === ethers.toBigInt(normalizeBytes32Hex(currentRootVectorHash)),
7068
+ "Reconstructed channel snapshot does not match the current on-chain root vector hash.",
7069
+ );
7070
+
7071
+ return {
7072
+ currentSnapshot,
7073
+ scanRange: {
7074
+ fromBlock: scanFromBlock,
7075
+ toBlock: latestBlock,
7076
+ mode: baseSnapshot ? "recovery-index" : "genesis",
7077
+ },
7078
+ };
7079
+ }
7080
+
7081
+ async function applyChannelRecoveryEventGroups({
7082
+ startingSnapshot,
7083
+ groupedValues,
7084
+ contractCodes,
7085
+ channelInfo,
7086
+ controllerAddress,
7087
+ l2AccountingVaultAddress,
7088
+ liquidBalancesSlot,
7089
+ }) {
5560
7090
  let currentSnapshot = startingSnapshot;
5561
- let stateManager = await buildStateManager(currentSnapshot, contractCodes);
7091
+ const stateManager = await buildStateManager(currentSnapshot, contractCodes);
5562
7092
 
5563
7093
  for (const group of groupedValues) {
5564
7094
  const orderedGroup = [...group].sort(compareLogsByPosition);
@@ -5632,20 +7162,161 @@ async function reconstructChannelSnapshot({
5632
7162
  `CurrentRootVectorObserved root vector mismatch at tx ${rootEvent.transactionHash}.`,
5633
7163
  );
5634
7164
  }
7165
+ return currentSnapshot;
7166
+ }
5635
7167
 
7168
+ async function applyWorkspaceMirrorDeltaBundle({
7169
+ delta,
7170
+ localRecoveryIndex,
7171
+ manifest,
7172
+ chainId,
7173
+ channelId,
7174
+ channelInfo,
7175
+ bridgeAbiManifest,
7176
+ managedStorageAddresses,
7177
+ contractCodes,
7178
+ controllerAddress,
7179
+ l2AccountingVaultAddress,
7180
+ liquidBalancesSlot,
7181
+ }) {
7182
+ expect(Number(delta.protocolVersion) === CHANNEL_WORKSPACE_MIRROR_PROTOCOL_VERSION, "Workspace mirror delta protocolVersion mismatch.");
7183
+ expect(Number(delta.chainId) === Number(chainId), "Workspace mirror delta chainId mismatch.");
7184
+ expect(ethers.toBigInt(delta.channelId) === ethers.toBigInt(channelId), "Workspace mirror delta channelId mismatch.");
7185
+ const fromBlock = Number(localRecoveryIndex.nextBlock);
7186
+ const toBlock = Number(manifest.checkpoint.recoveryLastScannedBlock) - 1;
7187
+ expect(Number(delta.fromBlock) === fromBlock, "Workspace mirror delta fromBlock mismatch.");
7188
+ expect(Number(delta.toBlock) === toBlock, "Workspace mirror delta toBlock mismatch.");
5636
7189
  expect(
5637
- ethers.toBigInt(normalizeBytes32Hex(hashRootVector(currentSnapshot.stateRoots)))
5638
- === ethers.toBigInt(normalizeBytes32Hex(currentRootVectorHash)),
5639
- "Reconstructed channel snapshot does not match the current on-chain root vector hash.",
7190
+ ethers.toBigInt(normalizeBytes32Hex(delta.baseRecoveryRootVectorHash))
7191
+ === ethers.toBigInt(normalizeBytes32Hex(localRecoveryIndex.recoveryRootVectorHash)),
7192
+ "Workspace mirror delta base root mismatch.",
7193
+ );
7194
+ expect(
7195
+ ethers.toBigInt(normalizeBytes32Hex(delta.recoveryRootVectorHash))
7196
+ === ethers.toBigInt(normalizeBytes32Hex(manifest.checkpoint.recoveryRootVectorHash)),
7197
+ "Workspace mirror delta recovery root mismatch.",
7198
+ );
7199
+ const groupedValues = normalizeWorkspaceMirrorDeltaEventGroups({
7200
+ logs: delta.logs,
7201
+ channelInfo,
7202
+ bridgeAbiManifest,
7203
+ fromBlock,
7204
+ toBlock,
7205
+ });
7206
+ const currentSnapshot = await applyChannelRecoveryEventGroups({
7207
+ startingSnapshot: localRecoveryIndex.stateSnapshot,
7208
+ groupedValues,
7209
+ contractCodes,
7210
+ channelInfo,
7211
+ controllerAddress,
7212
+ l2AccountingVaultAddress,
7213
+ liquidBalancesSlot,
7214
+ });
7215
+ const recoveryRootVectorHash = normalizeBytes32Hex(hashRootVector(currentSnapshot.stateRoots));
7216
+ expect(
7217
+ ethers.toBigInt(recoveryRootVectorHash) === ethers.toBigInt(normalizeBytes32Hex(manifest.checkpoint.recoveryRootVectorHash)),
7218
+ "Workspace mirror delta result root does not match the manifest checkpoint root.",
7219
+ );
7220
+ expect(
7221
+ Array.isArray(currentSnapshot.storageAddresses)
7222
+ && currentSnapshot.storageAddresses.length === managedStorageAddresses.length
7223
+ && currentSnapshot.storageAddresses.every(
7224
+ (address, index) => ethers.toBigInt(getAddress(address)) === ethers.toBigInt(getAddress(managedStorageAddresses[index])),
7225
+ ),
7226
+ "Workspace mirror delta result storage address vector mismatch.",
7227
+ );
7228
+ return {
7229
+ nextBlock: Number(manifest.checkpoint.recoveryLastScannedBlock),
7230
+ stateSnapshot: currentSnapshot,
7231
+ recoveryRootVectorHash,
7232
+ source: "mirror",
7233
+ };
7234
+ }
7235
+
7236
+ function normalizeWorkspaceMirrorDeltaEventGroups({
7237
+ logs,
7238
+ channelInfo,
7239
+ bridgeAbiManifest,
7240
+ fromBlock,
7241
+ toBlock,
7242
+ }) {
7243
+ expect(Array.isArray(logs), "Workspace mirror delta logs must be an array.");
7244
+ const channelManager = new Contract(
7245
+ channelInfo.manager,
7246
+ bridgeAbiManifest.contracts.channelManager.abi,
7247
+ );
7248
+ const bridgeTokenVault = new Contract(
7249
+ channelInfo.bridgeTokenVault,
7250
+ bridgeAbiManifest.contracts.bridgeTokenVault.abi,
5640
7251
  );
7252
+ const currentRootVectorObservedTopic =
7253
+ normalizeBytes32Hex(channelManager.interface.getEvent("CurrentRootVectorObserved").topicHash);
7254
+ const groupedEvents = new Map();
7255
+ for (const rawLog of logs) {
7256
+ const event = normalizeWorkspaceMirrorDeltaLog({
7257
+ rawLog,
7258
+ channelInfo,
7259
+ channelManager,
7260
+ bridgeTokenVault,
7261
+ currentRootVectorObservedTopic,
7262
+ fromBlock,
7263
+ toBlock,
7264
+ });
7265
+ const group = groupedEvents.get(event.transactionHash) ?? [];
7266
+ group.push(event);
7267
+ groupedEvents.set(event.transactionHash, group);
7268
+ }
7269
+ return [...groupedEvents.values()].sort((left, right) => compareLogsByPosition(left[0], right[0]));
7270
+ }
5641
7271
 
7272
+ function normalizeWorkspaceMirrorDeltaLog({
7273
+ rawLog,
7274
+ channelInfo,
7275
+ channelManager,
7276
+ bridgeTokenVault,
7277
+ currentRootVectorObservedTopic,
7278
+ fromBlock,
7279
+ toBlock,
7280
+ }) {
7281
+ const event = {
7282
+ ...rawLog,
7283
+ address: getAddress(rawLog.address),
7284
+ topics: (rawLog.topics ?? []).map((topic) => normalizeBytes32Hex(topic)),
7285
+ data: rawLog.data ?? "0x",
7286
+ blockNumber: Number(rawLog.blockNumber),
7287
+ transactionHash: normalizeBytes32Hex(rawLog.transactionHash),
7288
+ transactionIndex: Number(rawLog.transactionIndex),
7289
+ index: Number(rawLog.index ?? rawLog.logIndex),
7290
+ };
7291
+ expect(event.blockNumber >= fromBlock && event.blockNumber <= toBlock, "Workspace mirror delta log block is outside the declared range.");
7292
+ expect(Number.isInteger(event.transactionIndex) && Number.isInteger(event.index), "Workspace mirror delta log is missing transactionIndex or index.");
7293
+ const topic0 = event.topics[0] ? normalizeBytes32Hex(event.topics[0]) : null;
7294
+ if (ethers.toBigInt(event.address) === ethers.toBigInt(getAddress(channelInfo.manager))) {
7295
+ if (topic0 === currentRootVectorObservedTopic) {
7296
+ const parsed = channelManager.interface.parseLog(event);
7297
+ return {
7298
+ ...event,
7299
+ args: parsed.args,
7300
+ fragment: parsed.fragment,
7301
+ };
7302
+ }
7303
+ expect(
7304
+ topic0 === normalizeBytes32Hex(CONTROLLER_STORAGE_KEY_OBSERVED_TOPIC)
7305
+ || topic0 === normalizeBytes32Hex(VAULT_STORAGE_WRITE_OBSERVED_TOPIC),
7306
+ "Workspace mirror delta contains unsupported channel manager log topic.",
7307
+ );
7308
+ return event;
7309
+ }
7310
+ expect(
7311
+ ethers.toBigInt(event.address) === ethers.toBigInt(getAddress(channelInfo.bridgeTokenVault)),
7312
+ "Workspace mirror delta contains a log from an unsupported address.",
7313
+ );
7314
+ const parsed = bridgeTokenVault.interface.parseLog(event);
7315
+ expect(parsed.fragment?.name === "StorageWriteObserved", "Workspace mirror delta contains unsupported bridge vault log.");
5642
7316
  return {
5643
- currentSnapshot,
5644
- scanRange: {
5645
- fromBlock: scanFromBlock,
5646
- toBlock: latestBlock,
5647
- mode: baseSnapshot ? "recovery-index" : "genesis",
5648
- },
7317
+ ...event,
7318
+ args: parsed.args,
7319
+ fragment: parsed.fragment,
5649
7320
  };
5650
7321
  }
5651
7322
 
@@ -5745,6 +7416,56 @@ async function fetchLogsChunked(provider, {
5745
7416
  return aggregatedLogs;
5746
7417
  }
5747
7418
 
7419
+ function estimateLogScanRequestCount({
7420
+ fromBlock,
7421
+ toBlock,
7422
+ logScanCount = 1,
7423
+ chunkSize = DEFAULT_LOG_CHUNK_SIZE,
7424
+ }) {
7425
+ const normalizedFromBlock = Number(fromBlock);
7426
+ const normalizedToBlock = Number(toBlock);
7427
+ if (!Number.isInteger(normalizedFromBlock) || !Number.isInteger(normalizedToBlock)) {
7428
+ return Number.POSITIVE_INFINITY;
7429
+ }
7430
+ if (normalizedFromBlock > normalizedToBlock) {
7431
+ return 0;
7432
+ }
7433
+ const totalBlocks = normalizedToBlock - normalizedFromBlock + 1;
7434
+ return Math.ceil(totalBlocks / Math.max(1, Number(chunkSize))) * Math.max(1, Number(logScanCount));
7435
+ }
7436
+
7437
+ function assertAutoRecoveryLogScanBudget({
7438
+ label,
7439
+ fromBlock,
7440
+ toBlock,
7441
+ logScanCount,
7442
+ recoveryCommand,
7443
+ logRequestBudget = AUTO_RECOVERY_LOG_REQUEST_BUDGET,
7444
+ }) {
7445
+ const estimatedRequests = estimateLogScanRequestCount({
7446
+ fromBlock,
7447
+ toBlock,
7448
+ logScanCount,
7449
+ });
7450
+ const normalizedBudget = Math.max(0, Number(logRequestBudget));
7451
+ if (estimatedRequests <= normalizedBudget) {
7452
+ return estimatedRequests;
7453
+ }
7454
+ const normalizedFromBlock = Number(fromBlock);
7455
+ const normalizedToBlock = Number(toBlock);
7456
+ const totalBlocks = normalizedFromBlock <= normalizedToBlock
7457
+ ? normalizedToBlock - normalizedFromBlock + 1
7458
+ : 0;
7459
+ const estimatedSeconds = estimatedRequests / DEFAULT_LOG_REQUESTS_PER_SECOND;
7460
+ throw new Error([
7461
+ `Automatic recovery for ${label} would exceed the ${AUTO_RECOVERY_TIME_BUDGET_SECONDS}s pre-command budget.`,
7462
+ `Recovery delta is ${totalBlocks} blocks from ${normalizedFromBlock} to ${normalizedToBlock}.`,
7463
+ `Estimated log requests: ${estimatedRequests}; remaining budget: ${normalizedBudget} of ${AUTO_RECOVERY_LOG_REQUEST_BUDGET} at ${DEFAULT_LOG_REQUESTS_PER_SECOND}/s.`,
7464
+ `Estimated minimum scan time: ${estimatedSeconds.toFixed(1)}s.`,
7465
+ `Run ${recoveryCommand} first; add --from-genesis only if the saved recovery index is unusable.`,
7466
+ ].join(" "));
7467
+ }
7468
+
5748
7469
  async function throttleLogRequest() {
5749
7470
  const elapsedMs = Date.now() - lastLogRequestStartedAtMs;
5750
7471
  if (elapsedMs < LOG_REQUEST_INTERVAL_MS) {
@@ -6681,12 +8402,29 @@ function assertCreateChannelArgs(args) {
6681
8402
 
6682
8403
  function assertRecoverWorkspaceArgs(args) {
6683
8404
  assertAllowedCommandSchema(args, "channel-recover-workspace");
8405
+ resolveWorkspaceRecoverySource(args);
8406
+ if (args.fromGenesis !== undefined && args.fromGenesis !== true) {
8407
+ throw new Error("channel recover-workspace option --from-genesis does not accept a value.");
8408
+ }
6684
8409
  }
6685
8410
 
6686
8411
  function assertGetChannelArgs(args) {
6687
8412
  assertAllowedCommandSchema(args, "channel-get-meta");
6688
8413
  }
6689
8414
 
8415
+ function assertSetWorkspaceMirrorArgs(args) {
8416
+ assertAllowedCommandSchema(args, "channel-set-workspace-mirror");
8417
+ requireWorkspaceMirrorUrl(args.url);
8418
+ }
8419
+
8420
+ function assertPublishWorkspaceMirrorArgs(args) {
8421
+ assertAllowedCommandSchema(args, "channel-publish-workspace-mirror");
8422
+ requireArg(args.output, "--output");
8423
+ if (args.force !== undefined && args.force !== true) {
8424
+ throw new Error("channel publish-workspace-mirror option --force does not accept a value.");
8425
+ }
8426
+ }
8427
+
6690
8428
  function assertDepositBridgeArgs(args) {
6691
8429
  assertAllowedCommandSchema(args, "account-deposit-bridge");
6692
8430
  }
@@ -7457,6 +9195,94 @@ function emitProgress(action, phase) {
7457
9195
  }
7458
9196
  }
7459
9197
 
9198
+ function createByteDownloadProgress({ action, label, url }) {
9199
+ const startedAtMs = Date.now();
9200
+ const useInlineProgress = process.stderr.isTTY && !isJsonOutputRequested();
9201
+ let lastLineLength = 0;
9202
+ const writeInline = (line, done = false) => {
9203
+ if (!useInlineProgress) {
9204
+ if (done) {
9205
+ emitProgress(action, line);
9206
+ }
9207
+ return;
9208
+ }
9209
+ const paddedLine = line.padEnd(lastLineLength, " ");
9210
+ process.stderr.write(`\r[${action}] ${paddedLine}`);
9211
+ lastLineLength = line.length;
9212
+ if (done) {
9213
+ process.stderr.write("\n");
9214
+ lastLineLength = 0;
9215
+ }
9216
+ };
9217
+ return (event) => {
9218
+ const downloadedBytes = Number(event.downloadedBytes ?? 0);
9219
+ const totalBytes = Number.isFinite(Number(event.totalBytes)) ? Number(event.totalBytes) : null;
9220
+ const elapsedSeconds = Math.max(0.001, (Date.now() - startedAtMs) / 1000);
9221
+ const bytesPerSecond = downloadedBytes / elapsedSeconds;
9222
+ const remainingBytes = totalBytes !== null ? Math.max(0, totalBytes - downloadedBytes) : null;
9223
+ const etaSeconds = remainingBytes !== null && bytesPerSecond > 0
9224
+ ? remainingBytes / bytesPerSecond
9225
+ : null;
9226
+ const percent = totalBytes && totalBytes > 0
9227
+ ? `${Math.min(100, (downloadedBytes * 100) / totalBytes).toFixed(1)}%`
9228
+ : "unknown";
9229
+ const base = [
9230
+ `${label}: ${percent}`,
9231
+ `${formatByteCount(downloadedBytes)}/${totalBytes !== null ? formatByteCount(totalBytes) : "unknown"}`,
9232
+ `${formatByteRate(bytesPerSecond)}`,
9233
+ `ETA ${etaSeconds !== null ? formatDurationSeconds(etaSeconds) : "unknown"}`,
9234
+ ].join(" ");
9235
+ if (event.status === "start") {
9236
+ writeInline(`${base} from ${url}`);
9237
+ return;
9238
+ }
9239
+ if (event.status === "done") {
9240
+ writeInline(`${label}: 100% (${formatByteCount(downloadedBytes)}, done)`, true);
9241
+ return;
9242
+ }
9243
+ if (event.status === "error") {
9244
+ writeInline(`${label}: failed after ${formatByteCount(downloadedBytes)}`, true);
9245
+ return;
9246
+ }
9247
+ if (event.status === "progress") {
9248
+ writeInline(base);
9249
+ }
9250
+ };
9251
+ }
9252
+
9253
+ function formatByteCount(bytes) {
9254
+ const value = Number(bytes);
9255
+ if (!Number.isFinite(value)) {
9256
+ return "unknown";
9257
+ }
9258
+ const units = ["B", "KiB", "MiB", "GiB", "TiB"];
9259
+ let scaled = Math.max(0, value);
9260
+ let unitIndex = 0;
9261
+ while (scaled >= 1024 && unitIndex < units.length - 1) {
9262
+ scaled /= 1024;
9263
+ unitIndex += 1;
9264
+ }
9265
+ const decimals = unitIndex === 0 ? 0 : 1;
9266
+ return `${scaled.toFixed(decimals)} ${units[unitIndex]}`;
9267
+ }
9268
+
9269
+ function formatByteRate(bytesPerSecond) {
9270
+ return `${formatByteCount(bytesPerSecond)}/s`;
9271
+ }
9272
+
9273
+ function formatDurationSeconds(seconds) {
9274
+ const value = Math.max(0, Number(seconds));
9275
+ if (!Number.isFinite(value)) {
9276
+ return "unknown";
9277
+ }
9278
+ if (value < 60) {
9279
+ return `${Math.ceil(value)}s`;
9280
+ }
9281
+ const minutes = Math.floor(value / 60);
9282
+ const remainingSeconds = Math.ceil(value % 60);
9283
+ return `${minutes}m ${remainingSeconds}s`;
9284
+ }
9285
+
7460
9286
  function createRpcLogScanProgress({ action, label }) {
7461
9287
  let lastBucket = -1;
7462
9288
  return (event) => {
@@ -7587,7 +9413,7 @@ function buildRecoveryHints(error, args = {}) {
7587
9413
  }
7588
9414
 
7589
9415
  if (message.includes("Workspace recovery index is missing or unusable")) {
7590
- hints.push(`private-state-cli channel recover-workspace --channel-name ${channelName} --network ${networkName} --from-genesis`);
9416
+ hints.push(`private-state-cli channel recover-workspace --channel-name ${channelName} --network ${networkName} --source rpc --from-genesis`);
7591
9417
  hints.push(`private-state-cli wallet recover-workspace --channel-name ${channelName} --network ${networkName} --account ${accountName} --from-genesis`);
7592
9418
  }
7593
9419