@tokamak-private-dapps/private-state-cli 1.1.0 → 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.
- package/CHANGELOG.md +54 -0
- package/README.md +24 -8
- package/lib/private-state-cli-command-registry.mjs +80 -16
- package/package.json +1 -1
- package/private-state-bridge-cli.mjs +2079 -235
|
@@ -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,106 +794,1266 @@ 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 }),
|
|
767
798
|
});
|
|
768
799
|
}
|
|
769
800
|
|
|
770
|
-
async function
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
const
|
|
784
|
-
const channelDir = channelDataPath(workspaceDir);
|
|
785
|
-
const hasPersistedChannelData = fs.existsSync(channelWorkspaceConfigPath(workspaceDir))
|
|
786
|
-
|| fs.existsSync(channelWorkspaceCurrentPath(workspaceDir))
|
|
787
|
-
|| fs.existsSync(channelWorkspaceOperationsPath(workspaceDir));
|
|
788
|
-
|
|
789
|
-
if (persist && hasPersistedChannelData && !allowExistingWorkspaceSync) {
|
|
790
|
-
throw new Error(`Workspace already exists: ${workspaceDir}.`);
|
|
791
|
-
}
|
|
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 });
|
|
792
815
|
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
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
|
+
}
|
|
796
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 });
|
|
797
836
|
const { bridgeDeployment, bridgeAbiManifest } = bridgeResources;
|
|
798
|
-
const bridgeCore = new Contract(
|
|
837
|
+
const bridgeCore = new Contract(
|
|
838
|
+
bridgeDeployment.bridgeCore,
|
|
839
|
+
bridgeAbiManifest.contracts.bridgeCore.abi,
|
|
840
|
+
signer,
|
|
841
|
+
);
|
|
799
842
|
const channelId = deriveChannelIdFromName(channelName);
|
|
800
843
|
const channelInfo = await bridgeCore.getChannel(channelId);
|
|
801
|
-
|
|
802
|
-
|
|
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.");
|
|
803
1031
|
}
|
|
1032
|
+
return url;
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
async function readChannelWorkspaceMirror({ bridgeCore, channelId }) {
|
|
1036
|
+
return String(await bridgeCore.getChannelWorkspaceMirror(channelId));
|
|
1037
|
+
}
|
|
804
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)}.`);
|
|
805
1054
|
const channelManager = new Contract(
|
|
806
1055
|
channelInfo.manager,
|
|
807
|
-
bridgeAbiManifest.contracts.channelManager.abi,
|
|
1056
|
+
bridgeResources.bridgeAbiManifest.contracts.channelManager.abi,
|
|
808
1057
|
provider,
|
|
809
1058
|
);
|
|
810
|
-
const
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
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,
|
|
822
1076
|
});
|
|
823
|
-
const deploymentManifestPath = dappDeploymentManifestPath(network.chainId);
|
|
824
|
-
const storageLayoutManifestPath = dappStorageLayoutManifestPath(network.chainId);
|
|
825
|
-
const deploymentManifest = readJson(deploymentManifestPath);
|
|
826
|
-
const storageLayoutManifest = readJson(storageLayoutManifestPath);
|
|
827
|
-
const controllerAddress = getAddress(deploymentManifest.contracts.controller);
|
|
828
|
-
const l2AccountingVaultAddress = getAddress(deploymentManifest.contracts.l2AccountingVault);
|
|
829
|
-
const liquidBalancesSlot = ethers.toBigInt(findStorageSlot(storageLayoutManifest, "L2AccountingVault", "liquidBalances"));
|
|
830
|
-
|
|
831
1077
|
expect(
|
|
832
|
-
|
|
833
|
-
|
|
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(" "),
|
|
834
1083
|
);
|
|
835
1084
|
expect(
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
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(" "),
|
|
840
1094
|
);
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
const derivedAPubBlockHash = normalizeBytes32Hex(hashTokamakPublicInputs(encodeTokamakBlockInfo(blockInfo)));
|
|
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.");
|
|
845
1098
|
expect(
|
|
846
|
-
ethers.toBigInt(
|
|
847
|
-
|
|
1099
|
+
ethers.toBigInt(getAddress(workspace.channelManager)) === ethers.toBigInt(getAddress(channelInfo.manager)),
|
|
1100
|
+
"Local workspace channelManager mismatch.",
|
|
848
1101
|
);
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
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
|
+
}),
|
|
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;
|
|
1916
|
+
}
|
|
1917
|
+
|
|
1918
|
+
async function syncChannelWorkspace({
|
|
1919
|
+
workspaceName,
|
|
1920
|
+
channelName,
|
|
1921
|
+
network,
|
|
1922
|
+
provider,
|
|
1923
|
+
bridgeResources,
|
|
1924
|
+
persist,
|
|
1925
|
+
allowExistingWorkspaceSync = false,
|
|
1926
|
+
useWorkspaceRecoveryIndex = false,
|
|
1927
|
+
fromGenesis = false,
|
|
1928
|
+
recoverySource = "rpc",
|
|
1929
|
+
minimumToBlock = null,
|
|
1930
|
+
progressAction = null,
|
|
1931
|
+
}) {
|
|
1932
|
+
const workspaceDir = channelWorkspacePath(networkNameFromChainId(network.chainId), workspaceName);
|
|
1933
|
+
const channelDir = channelDataPath(workspaceDir);
|
|
1934
|
+
const hasPersistedChannelData = fs.existsSync(channelWorkspaceConfigPath(workspaceDir))
|
|
1935
|
+
|| fs.existsSync(channelWorkspaceCurrentPath(workspaceDir))
|
|
1936
|
+
|| fs.existsSync(channelWorkspaceOperationsPath(workspaceDir));
|
|
1937
|
+
|
|
1938
|
+
if (persist && hasPersistedChannelData && !allowExistingWorkspaceSync) {
|
|
1939
|
+
throw new Error(`Workspace already exists: ${workspaceDir}.`);
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1942
|
+
const existingArtifacts = persist && hasPersistedChannelData
|
|
1943
|
+
? loadExistingWorkspaceArtifacts(workspaceDir)
|
|
1944
|
+
: null;
|
|
1945
|
+
|
|
1946
|
+
const { bridgeDeployment, bridgeAbiManifest } = bridgeResources;
|
|
1947
|
+
const bridgeCore = new Contract(bridgeDeployment.bridgeCore, bridgeAbiManifest.contracts.bridgeCore.abi, provider);
|
|
1948
|
+
const channelId = deriveChannelIdFromName(channelName);
|
|
1949
|
+
const channelInfo = await bridgeCore.getChannel(channelId);
|
|
1950
|
+
if (!channelInfo.exists) {
|
|
1951
|
+
throw new Error(`Unknown channel ${channelId.toString()} in bridge core ${bridgeDeployment.bridgeCore}.`);
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
const channelManager = new Contract(
|
|
1955
|
+
channelInfo.manager,
|
|
1956
|
+
bridgeAbiManifest.contracts.channelManager.abi,
|
|
1957
|
+
provider,
|
|
1958
|
+
);
|
|
1959
|
+
const canonicalAsset = getAddress(channelInfo.asset);
|
|
1960
|
+
const canonicalAssetDecimals = await fetchTokenDecimals(provider, canonicalAsset);
|
|
1961
|
+
const currentRootVectorHash = normalizeBytes32Hex(await channelManager.currentRootVectorHash());
|
|
1962
|
+
const genesisBlockNumber = Number(await channelManager.genesisBlockNumber());
|
|
1963
|
+
const observedLatestBlock = await provider.getBlockNumber();
|
|
1964
|
+
const latestBlock = minimumToBlock === null
|
|
1965
|
+
? observedLatestBlock
|
|
1966
|
+
: Math.max(observedLatestBlock, Number(minimumToBlock));
|
|
1967
|
+
const managedStorageAddresses = normalizedAddressVector(await channelManager.getManagedStorageAddresses());
|
|
1968
|
+
const policySnapshot = await readChannelPolicySnapshot({
|
|
1969
|
+
channelManager,
|
|
1970
|
+
dappId: Number(channelInfo.dappId),
|
|
1971
|
+
});
|
|
1972
|
+
const deploymentManifestPath = dappDeploymentManifestPath(network.chainId);
|
|
1973
|
+
const storageLayoutManifestPath = dappStorageLayoutManifestPath(network.chainId);
|
|
1974
|
+
const deploymentManifest = readJson(deploymentManifestPath);
|
|
1975
|
+
const storageLayoutManifest = readJson(storageLayoutManifestPath);
|
|
1976
|
+
const controllerAddress = getAddress(deploymentManifest.contracts.controller);
|
|
1977
|
+
const l2AccountingVaultAddress = getAddress(deploymentManifest.contracts.l2AccountingVault);
|
|
1978
|
+
const liquidBalancesSlot = ethers.toBigInt(findStorageSlot(storageLayoutManifest, "L2AccountingVault", "liquidBalances"));
|
|
1979
|
+
|
|
1980
|
+
expect(
|
|
1981
|
+
managedStorageAddresses.some((address) => ethers.toBigInt(getAddress(address)) === ethers.toBigInt(controllerAddress)),
|
|
1982
|
+
`Managed storage vector does not include controller ${controllerAddress}.`,
|
|
1983
|
+
);
|
|
1984
|
+
expect(
|
|
1985
|
+
managedStorageAddresses.some(
|
|
1986
|
+
(address) => ethers.toBigInt(getAddress(address)) === ethers.toBigInt(l2AccountingVaultAddress),
|
|
1987
|
+
),
|
|
1988
|
+
`Managed storage vector does not include L2 accounting vault ${l2AccountingVaultAddress}.`,
|
|
1989
|
+
);
|
|
1990
|
+
|
|
1991
|
+
const contractCodes = await fetchContractCodes(provider, managedStorageAddresses);
|
|
1992
|
+
const blockInfo = await fetchChannelBlockInfo(provider, genesisBlockNumber);
|
|
1993
|
+
const derivedAPubBlockHash = normalizeBytes32Hex(hashTokamakPublicInputs(encodeTokamakBlockInfo(blockInfo)));
|
|
1994
|
+
expect(
|
|
1995
|
+
ethers.toBigInt(derivedAPubBlockHash) === ethers.toBigInt(normalizeBytes32Hex(channelInfo.aPubBlockHash)),
|
|
1996
|
+
`Derived channel block-info hash ${derivedAPubBlockHash} does not match onchain ${channelInfo.aPubBlockHash}.`,
|
|
1997
|
+
);
|
|
1998
|
+
const recoveryIndex = useWorkspaceRecoveryIndex && !fromGenesis
|
|
1999
|
+
? getUsableWorkspaceRecoveryIndex({
|
|
2000
|
+
existingArtifacts,
|
|
2001
|
+
genesisBlockNumber,
|
|
2002
|
+
latestBlock,
|
|
2003
|
+
managedStorageAddresses,
|
|
2004
|
+
})
|
|
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,
|
|
859
2016
|
managedStorageAddresses,
|
|
2017
|
+
blockInfo,
|
|
2018
|
+
contractCodes,
|
|
2019
|
+
latestBlock,
|
|
2020
|
+
localRecoveryIndex: recoveryIndex,
|
|
2021
|
+
bridgeAbiManifest,
|
|
2022
|
+
controllerAddress,
|
|
2023
|
+
l2AccountingVaultAddress,
|
|
2024
|
+
liquidBalancesSlot,
|
|
860
2025
|
})
|
|
861
|
-
:
|
|
862
|
-
|
|
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);
|
|
2037
|
+
const localSnapshotReusable = !fromGenesis && (!useWorkspaceRecoveryIndex || recoveryIndex)
|
|
2038
|
+
&& canReuseLocalWorkspaceSnapshot({
|
|
2039
|
+
existingArtifacts,
|
|
2040
|
+
currentRootVectorHash,
|
|
2041
|
+
managedStorageAddresses,
|
|
2042
|
+
});
|
|
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
|
+
) {
|
|
863
2053
|
throw new Error([
|
|
864
2054
|
`Workspace recovery index is missing or unusable for channel ${channelName} on ${networkNameFromChainId(network.chainId)}.`,
|
|
865
2055
|
"The CLI will not fall back to replaying channel logs from genesis unless explicitly requested.",
|
|
866
|
-
|
|
2056
|
+
"Run channel recover-workspace --source rpc --from-genesis or wallet recover-workspace --from-genesis to rebuild from channel genesis.",
|
|
867
2057
|
].join(" "));
|
|
868
2058
|
}
|
|
869
2059
|
const reconstruction = localSnapshotReusable
|
|
@@ -875,6 +2065,15 @@ async function syncChannelWorkspace({
|
|
|
875
2065
|
mode: "reused-current-snapshot",
|
|
876
2066
|
},
|
|
877
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
|
+
}
|
|
878
2077
|
: await reconstructChannelSnapshot({
|
|
879
2078
|
provider,
|
|
880
2079
|
bridgeAbiManifest,
|
|
@@ -888,8 +2087,8 @@ async function syncChannelWorkspace({
|
|
|
888
2087
|
controllerAddress,
|
|
889
2088
|
l2AccountingVaultAddress,
|
|
890
2089
|
liquidBalancesSlot,
|
|
891
|
-
baseSnapshot:
|
|
892
|
-
fromBlock:
|
|
2090
|
+
baseSnapshot: selectedRecoveryIndex?.stateSnapshot ?? null,
|
|
2091
|
+
fromBlock: selectedRecoveryIndex?.nextBlock ?? genesisBlockNumber,
|
|
893
2092
|
toBlock: latestBlock,
|
|
894
2093
|
progressAction,
|
|
895
2094
|
});
|
|
@@ -921,6 +2120,8 @@ async function syncChannelWorkspace({
|
|
|
921
2120
|
policySnapshot,
|
|
922
2121
|
managedStorageAddresses,
|
|
923
2122
|
liquidBalancesSlot: liquidBalancesSlot.toString(),
|
|
2123
|
+
recoverySource,
|
|
2124
|
+
workspaceMirror: mirrorRecovery.workspaceMirror,
|
|
924
2125
|
recoveryLastScannedBlock,
|
|
925
2126
|
recoveryRootVectorHash,
|
|
926
2127
|
recoveryScanRange: reconstruction.scanRange,
|
|
@@ -1155,6 +2356,7 @@ async function handleRecoverWallet({ args, network, provider, rpcUrl }) {
|
|
|
1155
2356
|
signer,
|
|
1156
2357
|
noteReceiveKeyMaterial,
|
|
1157
2358
|
progressAction: "wallet recover-workspace",
|
|
2359
|
+
fromGenesis: args.fromGenesis === true,
|
|
1158
2360
|
});
|
|
1159
2361
|
printJson({
|
|
1160
2362
|
action: "wallet recover-workspace",
|
|
@@ -1202,6 +2404,7 @@ async function handleRecoverWallet({ args, network, provider, rpcUrl }) {
|
|
|
1202
2404
|
signer,
|
|
1203
2405
|
noteReceiveKeyMaterial,
|
|
1204
2406
|
progressAction: "wallet recover-workspace",
|
|
2407
|
+
fromGenesis: args.fromGenesis === true,
|
|
1205
2408
|
});
|
|
1206
2409
|
|
|
1207
2410
|
printJson({
|
|
@@ -2506,7 +3709,7 @@ function applyGuideNextAction(guide) {
|
|
|
2506
3709
|
}
|
|
2507
3710
|
if (guide.selectors.channelName && guide.state.channel?.onchain?.exists && !guide.state.channel?.local?.workspaceExists) {
|
|
2508
3711
|
setGuideNextAction(guide, {
|
|
2509
|
-
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`,
|
|
2510
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.",
|
|
2511
3714
|
});
|
|
2512
3715
|
return;
|
|
@@ -2646,7 +3849,7 @@ function redactRpcUrl(rpcUrl) {
|
|
|
2646
3849
|
|
|
2647
3850
|
async function handleWalletGetMeta({ args, provider }) {
|
|
2648
3851
|
const { wallet, walletMetadata } = loadUnlockedWalletWithMetadata(args);
|
|
2649
|
-
const contextResult = await
|
|
3852
|
+
const contextResult = await loadFreshWalletChannelContext({
|
|
2650
3853
|
walletContext: wallet,
|
|
2651
3854
|
provider,
|
|
2652
3855
|
progressAction: "wallet get-meta",
|
|
@@ -2680,8 +3883,8 @@ async function handleWalletGetMeta({ args, provider }) {
|
|
|
2680
3883
|
});
|
|
2681
3884
|
}
|
|
2682
3885
|
|
|
2683
|
-
async function loadWalletChannelFundState({ walletContext, provider, progressAction =
|
|
2684
|
-
const contextResult = await
|
|
3886
|
+
async function loadWalletChannelFundState({ walletContext, provider, progressAction = "wallet get-channel-fund" }) {
|
|
3887
|
+
const contextResult = await loadFreshWalletChannelContext({
|
|
2685
3888
|
walletContext,
|
|
2686
3889
|
provider,
|
|
2687
3890
|
progressAction,
|
|
@@ -2765,7 +3968,6 @@ async function handleWalletGetChannelFund({ args, provider }) {
|
|
|
2765
3968
|
} = await loadWalletChannelFundState({
|
|
2766
3969
|
walletContext: wallet,
|
|
2767
3970
|
provider,
|
|
2768
|
-
progressAction: "wallet get-channel-fund",
|
|
2769
3971
|
});
|
|
2770
3972
|
|
|
2771
3973
|
printJson({
|
|
@@ -2861,6 +4063,13 @@ async function handleJoinChannel({ args, network, provider, rpcUrl }) {
|
|
|
2861
4063
|
);
|
|
2862
4064
|
status = "joined";
|
|
2863
4065
|
|
|
4066
|
+
await refreshPersistedWorkspaceAfterLocalTransaction({
|
|
4067
|
+
context,
|
|
4068
|
+
provider,
|
|
4069
|
+
receipt,
|
|
4070
|
+
progressAction: "channel join",
|
|
4071
|
+
});
|
|
4072
|
+
|
|
2864
4073
|
const walletContext = ensureWallet({
|
|
2865
4074
|
channelContext: context,
|
|
2866
4075
|
signerAddress: signer.address,
|
|
@@ -2904,6 +4113,7 @@ async function handleExitChannel({ args, provider }) {
|
|
|
2904
4113
|
const { signer, context, channelFund, contextResult } = await loadWalletChannelFundState({
|
|
2905
4114
|
walletContext,
|
|
2906
4115
|
provider,
|
|
4116
|
+
progressAction: "channel exit",
|
|
2907
4117
|
});
|
|
2908
4118
|
const network = contextResult.network;
|
|
2909
4119
|
expect(
|
|
@@ -2949,7 +4159,7 @@ async function handleGrothVaultMove({ args, provider, direction }) {
|
|
|
2949
4159
|
: "wallet withdraw-channel";
|
|
2950
4160
|
emitProgress(operationName, "loading");
|
|
2951
4161
|
const { wallet: walletContext } = loadUnlockedWalletWithMetadata(args);
|
|
2952
|
-
const contextResult = await
|
|
4162
|
+
const contextResult = await loadFreshWalletChannelContext({
|
|
2953
4163
|
walletContext,
|
|
2954
4164
|
provider,
|
|
2955
4165
|
progressAction: operationName,
|
|
@@ -3179,7 +4389,7 @@ async function handleMintNotes({ args, provider }) {
|
|
|
3179
4389
|
"Invalid --amounts. The array must contain at least one amount greater than zero.",
|
|
3180
4390
|
);
|
|
3181
4391
|
const totalMintAmount = baseUnitAmounts.reduce((sum, { amountBaseUnits }) => sum + amountBaseUnits, 0n);
|
|
3182
|
-
const { channelFund } = await loadWalletChannelFundState({
|
|
4392
|
+
const { channelFund, contextResult: preparedContextResult } = await loadWalletChannelFundState({
|
|
3183
4393
|
walletContext: wallet,
|
|
3184
4394
|
provider,
|
|
3185
4395
|
progressAction: "wallet mint-notes",
|
|
@@ -3195,12 +4405,13 @@ async function handleMintNotes({ args, provider }) {
|
|
|
3195
4405
|
wallet,
|
|
3196
4406
|
baseUnitAmounts: baseUnitAmounts.map(({ amountBaseUnits }) => amountBaseUnits),
|
|
3197
4407
|
});
|
|
3198
|
-
const { execution, contextResult,
|
|
4408
|
+
const { execution, contextResult, walletWarnings } = await executeWalletDirectTemplateCommand({
|
|
3199
4409
|
args,
|
|
3200
4410
|
wallet,
|
|
3201
4411
|
provider,
|
|
3202
4412
|
operationName: "wallet mint-notes",
|
|
3203
4413
|
templatePayload,
|
|
4414
|
+
preparedContextResult,
|
|
3204
4415
|
});
|
|
3205
4416
|
|
|
3206
4417
|
printJson({
|
|
@@ -3226,7 +4437,8 @@ async function handleMintNotes({ args, provider }) {
|
|
|
3226
4437
|
gasUsed: receiptGasUsed(execution.receipt),
|
|
3227
4438
|
txUrl: explorerTxUrl(contextResult.network, execution.receipt.hash),
|
|
3228
4439
|
usedWorkspaceCache: contextResult.usingWorkspaceCache,
|
|
3229
|
-
recoveredWorkspace,
|
|
4440
|
+
recoveredWorkspace: contextResult.recoveredWorkspace,
|
|
4441
|
+
warnings: walletWarnings,
|
|
3230
4442
|
updatedRoots: execution.context.currentSnapshot.stateRoots,
|
|
3231
4443
|
});
|
|
3232
4444
|
}
|
|
@@ -3234,17 +4446,30 @@ async function handleMintNotes({ args, provider }) {
|
|
|
3234
4446
|
async function handleRedeemNotes({ args, provider }) {
|
|
3235
4447
|
const { wallet } = loadUnlockedWalletWithMetadata(args);
|
|
3236
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
|
+
});
|
|
3237
4461
|
const inputNotes = loadWalletUnusedInputNotes(wallet, noteIds);
|
|
3238
4462
|
const templatePayload = buildRedeemNotesTemplatePayload({
|
|
3239
4463
|
wallet,
|
|
3240
4464
|
inputNotes,
|
|
3241
4465
|
});
|
|
3242
|
-
const { execution, contextResult,
|
|
4466
|
+
const { execution, contextResult, walletWarnings } = await executeWalletDirectTemplateCommand({
|
|
3243
4467
|
args,
|
|
3244
4468
|
wallet,
|
|
3245
4469
|
provider,
|
|
3246
4470
|
operationName: "wallet redeem-notes",
|
|
3247
4471
|
templatePayload,
|
|
4472
|
+
preparedContextResult,
|
|
3248
4473
|
});
|
|
3249
4474
|
|
|
3250
4475
|
printJson({
|
|
@@ -3270,7 +4495,8 @@ async function handleRedeemNotes({ args, provider }) {
|
|
|
3270
4495
|
gasUsed: receiptGasUsed(execution.receipt),
|
|
3271
4496
|
txUrl: explorerTxUrl(contextResult.network, execution.receipt.hash),
|
|
3272
4497
|
usedWorkspaceCache: contextResult.usingWorkspaceCache,
|
|
3273
|
-
recoveredWorkspace,
|
|
4498
|
+
recoveredWorkspace: contextResult.recoveredWorkspace,
|
|
4499
|
+
warnings: walletWarnings,
|
|
3274
4500
|
updatedRoots: execution.context.currentSnapshot.stateRoots,
|
|
3275
4501
|
});
|
|
3276
4502
|
}
|
|
@@ -3282,18 +4508,18 @@ async function handleWalletGetNotes({ args, provider }) {
|
|
|
3282
4508
|
`Wallet ${wallet.walletName} is missing the stored controller address.`,
|
|
3283
4509
|
);
|
|
3284
4510
|
const canonicalAssetDecimals = Number(wallet.wallet.canonicalAssetDecimals);
|
|
3285
|
-
const
|
|
4511
|
+
const contextResult = await loadFreshWalletChannelContext({
|
|
3286
4512
|
walletContext: wallet,
|
|
3287
4513
|
provider,
|
|
3288
4514
|
progressAction: "wallet get-notes",
|
|
3289
4515
|
});
|
|
3290
|
-
const
|
|
3291
|
-
const
|
|
4516
|
+
const context = contextResult.context;
|
|
4517
|
+
const noteReceiveFreshness = await ensureWalletNoteReceiveStateCurrent({
|
|
3292
4518
|
walletContext: wallet,
|
|
3293
4519
|
context,
|
|
3294
4520
|
provider,
|
|
3295
|
-
signer,
|
|
3296
4521
|
progressAction: "wallet get-notes",
|
|
4522
|
+
preConsumedLogRequests: contextResult.autoRecoveryLogRequests,
|
|
3297
4523
|
});
|
|
3298
4524
|
|
|
3299
4525
|
const unusedTrackedNotes = wallet.wallet.notes.unusedOrder
|
|
@@ -3330,21 +4556,32 @@ async function handleWalletGetNotes({ args, provider }) {
|
|
|
3330
4556
|
spentTotalBaseUnits: spentTotal.toString(),
|
|
3331
4557
|
spentTotalTokens: ethers.formatUnits(spentTotal, canonicalAssetDecimals),
|
|
3332
4558
|
bridgeStatusMismatches: [...unusedNotes, ...spentNotes].filter((note) => !note.walletStatusMatchesBridge).length,
|
|
3333
|
-
|
|
3334
|
-
|
|
3335
|
-
|
|
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,
|
|
3336
4565
|
});
|
|
3337
4566
|
}
|
|
3338
4567
|
|
|
3339
4568
|
async function handleTransferNotes({ args, provider }) {
|
|
3340
4569
|
const { wallet } = loadUnlockedWalletWithMetadata(args);
|
|
3341
4570
|
const { signer } = restoreWalletParticipant(wallet, provider);
|
|
3342
|
-
const preparedContextResult = await
|
|
4571
|
+
const preparedContextResult = await loadFreshWalletChannelContext({
|
|
3343
4572
|
walletContext: wallet,
|
|
3344
4573
|
provider,
|
|
3345
4574
|
progressAction: "wallet transfer-notes",
|
|
3346
4575
|
});
|
|
3347
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
|
+
});
|
|
3348
4585
|
const canonicalAssetDecimals = Number(wallet.wallet.canonicalAssetDecimals);
|
|
3349
4586
|
const noteIds = parseNoteIdVector(requireArg(args.noteIds, "--note-ids"));
|
|
3350
4587
|
const recipients = parseRecipientVector(requireArg(args.recipients, "--recipients"));
|
|
@@ -3354,12 +4591,12 @@ async function handleTransferNotes({ args, provider }) {
|
|
|
3354
4591
|
"--amounts length must match --recipients length.",
|
|
3355
4592
|
);
|
|
3356
4593
|
|
|
3357
|
-
const inputNotes = loadWalletUnusedInputNotes(wallet, noteIds);
|
|
3358
4594
|
const outputAmounts = amountInputs.map((value, index) => {
|
|
3359
4595
|
const parsed = parseTokenAmount(value, canonicalAssetDecimals);
|
|
3360
4596
|
expect(parsed > 0n, `Invalid --amounts[${index}]. Each amount must be greater than zero.`);
|
|
3361
4597
|
return parsed;
|
|
3362
4598
|
});
|
|
4599
|
+
const inputNotes = loadWalletUnusedInputNotes(wallet, noteIds);
|
|
3363
4600
|
const totalInput = inputNotes.reduce((sum, note) => sum + ethers.toBigInt(note.value), 0n);
|
|
3364
4601
|
const totalOutput = outputAmounts.reduce((sum, value) => sum + value, 0n);
|
|
3365
4602
|
expect(
|
|
@@ -3374,12 +4611,13 @@ async function handleTransferNotes({ args, provider }) {
|
|
|
3374
4611
|
recipients,
|
|
3375
4612
|
outputAmounts,
|
|
3376
4613
|
});
|
|
3377
|
-
const { execution, contextResult,
|
|
4614
|
+
const { execution, contextResult, walletWarnings } = await executeWalletDirectTemplateCommand({
|
|
3378
4615
|
args,
|
|
3379
4616
|
wallet,
|
|
3380
4617
|
provider,
|
|
3381
4618
|
operationName: "wallet transfer-notes",
|
|
3382
4619
|
templatePayload,
|
|
4620
|
+
preparedContextResult,
|
|
3383
4621
|
});
|
|
3384
4622
|
const outputNotes = buildLifecycleTrackedOutputs({
|
|
3385
4623
|
outputNotes: templatePayload.lifecycleOutputs,
|
|
@@ -3410,7 +4648,8 @@ async function handleTransferNotes({ args, provider }) {
|
|
|
3410
4648
|
gasUsed: receiptGasUsed(execution.receipt),
|
|
3411
4649
|
txUrl: explorerTxUrl(contextResult.network, execution.receipt.hash),
|
|
3412
4650
|
usedWorkspaceCache: contextResult.usingWorkspaceCache,
|
|
3413
|
-
recoveredWorkspace,
|
|
4651
|
+
recoveredWorkspace: contextResult.recoveredWorkspace,
|
|
4652
|
+
warnings: walletWarnings,
|
|
3414
4653
|
updatedRoots: execution.context.currentSnapshot.stateRoots,
|
|
3415
4654
|
});
|
|
3416
4655
|
}
|
|
@@ -3718,6 +4957,7 @@ async function recoverWalletReceivedNotes({
|
|
|
3718
4957
|
signer,
|
|
3719
4958
|
noteReceiveKeyMaterial = null,
|
|
3720
4959
|
progressAction = null,
|
|
4960
|
+
fromGenesis = false,
|
|
3721
4961
|
}) {
|
|
3722
4962
|
const resolvedNoteReceiveKeyMaterial = noteReceiveKeyMaterial ?? await deriveNoteReceiveKeyMaterial({
|
|
3723
4963
|
signer,
|
|
@@ -3732,6 +4972,7 @@ async function recoverWalletReceivedNotes({
|
|
|
3732
4972
|
provider,
|
|
3733
4973
|
noteReceivePrivateKey: resolvedNoteReceiveKeyMaterial.privateKey,
|
|
3734
4974
|
progressAction,
|
|
4975
|
+
fromGenesis,
|
|
3735
4976
|
});
|
|
3736
4977
|
return {
|
|
3737
4978
|
noteReceiveKeyMaterial: resolvedNoteReceiveKeyMaterial,
|
|
@@ -3745,13 +4986,16 @@ async function recoverDeliveredNotesFromEventLogs({
|
|
|
3745
4986
|
provider,
|
|
3746
4987
|
noteReceivePrivateKey,
|
|
3747
4988
|
progressAction = null,
|
|
4989
|
+
fromGenesis = false,
|
|
3748
4990
|
}) {
|
|
3749
4991
|
const latestBlock = await provider.getBlockNumber();
|
|
3750
|
-
const scanStartBlock =
|
|
3751
|
-
|
|
3752
|
-
|
|
3753
|
-
|
|
3754
|
-
|
|
4992
|
+
const scanStartBlock = fromGenesis
|
|
4993
|
+
? Number(context.workspace.genesisBlockNumber)
|
|
4994
|
+
: requireUsableWalletNoteReceiveRecoveryIndex({
|
|
4995
|
+
walletContext,
|
|
4996
|
+
context,
|
|
4997
|
+
latestBlock,
|
|
4998
|
+
});
|
|
3755
4999
|
const scanRange = {
|
|
3756
5000
|
fromBlock: scanStartBlock,
|
|
3757
5001
|
toBlock: latestBlock,
|
|
@@ -3872,6 +5116,132 @@ function requireUsableWalletNoteReceiveRecoveryIndex({ walletContext, context, l
|
|
|
3872
5116
|
return nextBlock;
|
|
3873
5117
|
}
|
|
3874
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
|
+
|
|
3875
5245
|
function extractEncryptedNoteValueFromBridgeLog(log) {
|
|
3876
5246
|
if (!Array.isArray(log?.topics) || log.topics.length !== 1) {
|
|
3877
5247
|
return null;
|
|
@@ -4335,52 +5705,195 @@ function walletChannelWorkspaceIsReady(walletContext) {
|
|
|
4335
5705
|
&& fs.existsSync(path.join(channelWorkspaceCurrentPath(workspaceDir), "contract_codes.json"));
|
|
4336
5706
|
}
|
|
4337
5707
|
|
|
4338
|
-
async function
|
|
5708
|
+
async function loadFreshWalletChannelContext({
|
|
4339
5709
|
walletContext,
|
|
4340
5710
|
provider,
|
|
4341
|
-
forceRecover = false,
|
|
4342
5711
|
progressAction = null,
|
|
4343
5712
|
}) {
|
|
4344
|
-
|
|
4345
|
-
|
|
4346
|
-
|
|
4347
|
-
|
|
4348
|
-
|
|
4349
|
-
|
|
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;
|
|
4350
5750
|
try {
|
|
5751
|
+
context = await loadWorkspaceContext(channelName, networkName, provider);
|
|
4351
5752
|
await assertWorkspaceAlignedWithChain(context);
|
|
5753
|
+
return {
|
|
5754
|
+
context,
|
|
5755
|
+
recoveredWorkspace: false,
|
|
5756
|
+
autoRecoveryLogRequests: 0,
|
|
5757
|
+
};
|
|
4352
5758
|
} catch (error) {
|
|
4353
|
-
|
|
4354
|
-
|
|
4355
|
-
|
|
4356
|
-
|
|
4357
|
-
|
|
4358
|
-
|
|
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 {
|
|
4359
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(" "));
|
|
4360
5830
|
}
|
|
4361
5831
|
return {
|
|
4362
5832
|
context,
|
|
4363
|
-
|
|
4364
|
-
usingWorkspaceCache: !recoveredWorkspace,
|
|
4365
|
-
recoveredWorkspace,
|
|
5833
|
+
autoRecoveryLogRequests: readiness.autoRecoveryLogRequests,
|
|
4366
5834
|
};
|
|
4367
5835
|
}
|
|
4368
5836
|
|
|
4369
|
-
async function
|
|
4370
|
-
|
|
4371
|
-
|
|
4372
|
-
|
|
4373
|
-
|
|
4374
|
-
|
|
4375
|
-
|
|
4376
|
-
|
|
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,
|
|
4377
5868
|
provider,
|
|
4378
|
-
|
|
4379
|
-
|
|
4380
|
-
|
|
4381
|
-
|
|
4382
|
-
|
|
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,
|
|
4383
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}`,
|
|
5895
|
+
});
|
|
5896
|
+
return { alreadyCurrent: false, autoRecoveryLogRequests };
|
|
4384
5897
|
}
|
|
4385
5898
|
|
|
4386
5899
|
async function refreshPersistedWorkspaceAfterLocalTransaction({
|
|
@@ -4426,12 +5939,6 @@ async function refreshPersistedWorkspaceAfterLocalTransaction({
|
|
|
4426
5939
|
return context;
|
|
4427
5940
|
}
|
|
4428
5941
|
|
|
4429
|
-
function isRecoverableWalletWorkspaceFailure(error) {
|
|
4430
|
-
const message = String(error?.message ?? error);
|
|
4431
|
-
return (message.includes("--verify") && message.includes("failed with exit code"))
|
|
4432
|
-
|| message.includes("The workspace snapshot is stale relative to the bridge channel state.");
|
|
4433
|
-
}
|
|
4434
|
-
|
|
4435
5942
|
function assertWalletMatchesChannelContext(walletContext, l2Identity, context) {
|
|
4436
5943
|
expect(
|
|
4437
5944
|
ethers.toBigInt(walletContext.wallet.channelId) === ethers.toBigInt(context.workspace.channelId),
|
|
@@ -4449,58 +5956,24 @@ async function executeWalletDirectTemplateCommand({
|
|
|
4449
5956
|
provider,
|
|
4450
5957
|
operationName,
|
|
4451
5958
|
templatePayload,
|
|
5959
|
+
preparedContextResult = null,
|
|
4452
5960
|
}) {
|
|
4453
5961
|
emitProgress(operationName, "loading");
|
|
4454
5962
|
const { signer, l2Identity } = restoreWalletParticipant(wallet, provider);
|
|
4455
5963
|
const {
|
|
4456
|
-
txSubmitter,
|
|
4457
|
-
source: txSubmitterSource,
|
|
4458
|
-
account: txSubmitterAccount,
|
|
4459
|
-
} = resolveTxSubmitterSigner({
|
|
4460
|
-
args,
|
|
4461
|
-
ownerSigner: signer,
|
|
4462
|
-
provider,
|
|
4463
|
-
});
|
|
4464
|
-
|
|
4465
|
-
walletContext: wallet,
|
|
4466
|
-
provider,
|
|
4467
|
-
progressAction: operationName,
|
|
4468
|
-
});
|
|
4469
|
-
let recoveredWorkspace = contextResult.recoveredWorkspace;
|
|
4470
|
-
|
|
4471
|
-
try {
|
|
4472
|
-
const execution = await executeWalletTemplateSend({
|
|
4473
|
-
wallet,
|
|
4474
|
-
signer,
|
|
4475
|
-
txSubmitter,
|
|
4476
|
-
txSubmitterSource,
|
|
4477
|
-
txSubmitterAccount,
|
|
4478
|
-
l2Identity,
|
|
4479
|
-
context: contextResult.context,
|
|
4480
|
-
provider,
|
|
4481
|
-
operationName,
|
|
4482
|
-
functionName: templatePayload.method,
|
|
4483
|
-
templatePayload,
|
|
4484
|
-
});
|
|
4485
|
-
emitProgress(operationName, "done");
|
|
4486
|
-
return {
|
|
4487
|
-
execution,
|
|
4488
|
-
contextResult,
|
|
4489
|
-
recoveredWorkspace,
|
|
4490
|
-
};
|
|
4491
|
-
} catch (error) {
|
|
4492
|
-
if (!isRecoverableWalletWorkspaceFailure(error)) {
|
|
4493
|
-
throw error;
|
|
4494
|
-
}
|
|
4495
|
-
}
|
|
4496
|
-
|
|
4497
|
-
contextResult = await loadPreferredWalletChannelContext({
|
|
5964
|
+
txSubmitter,
|
|
5965
|
+
source: txSubmitterSource,
|
|
5966
|
+
account: txSubmitterAccount,
|
|
5967
|
+
} = resolveTxSubmitterSigner({
|
|
5968
|
+
args,
|
|
5969
|
+
ownerSigner: signer,
|
|
5970
|
+
provider,
|
|
5971
|
+
});
|
|
5972
|
+
const contextResult = preparedContextResult ?? await loadFreshWalletChannelContext({
|
|
4498
5973
|
walletContext: wallet,
|
|
4499
5974
|
provider,
|
|
4500
|
-
forceRecover: true,
|
|
4501
5975
|
progressAction: operationName,
|
|
4502
5976
|
});
|
|
4503
|
-
recoveredWorkspace = contextResult.recoveredWorkspace;
|
|
4504
5977
|
const execution = await executeWalletTemplateSend({
|
|
4505
5978
|
wallet,
|
|
4506
5979
|
signer,
|
|
@@ -4514,11 +5987,18 @@ async function executeWalletDirectTemplateCommand({
|
|
|
4514
5987
|
functionName: templatePayload.method,
|
|
4515
5988
|
templatePayload,
|
|
4516
5989
|
});
|
|
5990
|
+
const walletWarnings = await buildPostWalletCommandWarnings({
|
|
5991
|
+
walletContext: wallet,
|
|
5992
|
+
context: execution.context,
|
|
5993
|
+
provider,
|
|
5994
|
+
receipt: execution.receipt,
|
|
5995
|
+
});
|
|
4517
5996
|
emitProgress(operationName, "done");
|
|
4518
5997
|
return {
|
|
4519
5998
|
execution,
|
|
4520
5999
|
contextResult,
|
|
4521
|
-
recoveredWorkspace,
|
|
6000
|
+
recoveredWorkspace: contextResult.recoveredWorkspace,
|
|
6001
|
+
walletWarnings,
|
|
4522
6002
|
};
|
|
4523
6003
|
}
|
|
4524
6004
|
|
|
@@ -4689,31 +6169,29 @@ async function loadJoinChannelContext({ args, network, provider }) {
|
|
|
4689
6169
|
const channelName = requireArg(args.channelName, "--channel-name");
|
|
4690
6170
|
|
|
4691
6171
|
const bridgeResources = loadBridgeResources({ chainId });
|
|
4692
|
-
const
|
|
4693
|
-
workspaceName: channelName,
|
|
6172
|
+
const context = await loadFreshChannelWorkspaceContext({
|
|
4694
6173
|
channelName,
|
|
4695
|
-
|
|
6174
|
+
networkName: resolvedNetworkName,
|
|
4696
6175
|
provider,
|
|
4697
|
-
|
|
4698
|
-
persist: false,
|
|
6176
|
+
progressAction: "channel join",
|
|
4699
6177
|
});
|
|
4700
6178
|
|
|
4701
6179
|
return {
|
|
4702
6180
|
workspaceName: channelName,
|
|
4703
|
-
workspaceDir:
|
|
4704
|
-
persistChannelWorkspace:
|
|
4705
|
-
workspace:
|
|
6181
|
+
workspaceDir: context.workspaceDir,
|
|
6182
|
+
persistChannelWorkspace: true,
|
|
6183
|
+
workspace: context.workspace,
|
|
4706
6184
|
bridgeAbiManifest: bridgeResources.bridgeAbiManifest,
|
|
4707
|
-
currentSnapshot:
|
|
4708
|
-
blockInfo:
|
|
4709
|
-
contractCodes:
|
|
6185
|
+
currentSnapshot: context.currentSnapshot,
|
|
6186
|
+
blockInfo: context.blockInfo,
|
|
6187
|
+
contractCodes: context.contractCodes,
|
|
4710
6188
|
channelManager: new Contract(
|
|
4711
|
-
|
|
6189
|
+
context.workspace.channelManager,
|
|
4712
6190
|
bridgeResources.bridgeAbiManifest.contracts.channelManager.abi,
|
|
4713
6191
|
provider,
|
|
4714
6192
|
),
|
|
4715
6193
|
bridgeTokenVault: new Contract(
|
|
4716
|
-
|
|
6194
|
+
context.workspace.bridgeTokenVault,
|
|
4717
6195
|
bridgeResources.bridgeAbiManifest.contracts.bridgeTokenVault.abi,
|
|
4718
6196
|
provider,
|
|
4719
6197
|
),
|
|
@@ -4915,6 +6393,7 @@ async function assertWorkspaceAlignedWithChain(context) {
|
|
|
4915
6393
|
[
|
|
4916
6394
|
"The workspace snapshot is stale relative to the bridge channel state.",
|
|
4917
6395
|
`Workspace: ${context.workspaceDir}`,
|
|
6396
|
+
`Run channel recover-workspace --channel-name ${context.workspace.channelName} --network ${context.workspace.network}.`,
|
|
4918
6397
|
].join(" "),
|
|
4919
6398
|
),
|
|
4920
6399
|
);
|
|
@@ -5456,6 +6935,56 @@ async function fetchChannelBlockInfo(provider, blockNumber) {
|
|
|
5456
6935
|
};
|
|
5457
6936
|
}
|
|
5458
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
|
+
|
|
5459
6988
|
async function reconstructChannelSnapshot({
|
|
5460
6989
|
provider,
|
|
5461
6990
|
bridgeAbiManifest,
|
|
@@ -5486,27 +7015,20 @@ async function reconstructChannelSnapshot({
|
|
|
5486
7015
|
startingSnapshot = await genesisStateManager.captureStateSnapshot();
|
|
5487
7016
|
}
|
|
5488
7017
|
|
|
5489
|
-
const bridgeTokenVault = new Contract(
|
|
5490
|
-
channelInfo.bridgeTokenVault,
|
|
5491
|
-
bridgeAbiManifest.contracts.bridgeTokenVault.abi,
|
|
5492
|
-
provider,
|
|
5493
|
-
);
|
|
5494
7018
|
const latestBlock = toBlock === null ? await provider.getBlockNumber() : Number(toBlock);
|
|
5495
7019
|
const scanFromBlock = Math.max(Number(genesisBlockNumber), Number(fromBlock));
|
|
5496
|
-
const
|
|
5497
|
-
|
|
5498
|
-
|
|
5499
|
-
|
|
5500
|
-
|
|
5501
|
-
|
|
5502
|
-
|
|
5503
|
-
|
|
5504
|
-
|
|
7020
|
+
const {
|
|
7021
|
+
currentRootVectorObservedTopic,
|
|
7022
|
+
channelManagerLogs,
|
|
7023
|
+
bridgeVaultLogs,
|
|
7024
|
+
} = await fetchChannelRecoveryLogs({
|
|
7025
|
+
provider,
|
|
7026
|
+
bridgeAbiManifest,
|
|
7027
|
+
channelInfo,
|
|
7028
|
+
channelManager,
|
|
5505
7029
|
fromBlock: scanFromBlock,
|
|
5506
7030
|
toBlock: latestBlock,
|
|
5507
|
-
|
|
5508
|
-
? createRpcLogScanProgress({ action: progressAction, label: "channel-manager events" })
|
|
5509
|
-
: null,
|
|
7031
|
+
progressAction,
|
|
5510
7032
|
});
|
|
5511
7033
|
const channelManagerEvents = channelManagerLogs.map((log) => {
|
|
5512
7034
|
const topic0 = log.topics[0] ? normalizeBytes32Hex(log.topics[0]) : null;
|
|
@@ -5520,18 +7042,9 @@ async function reconstructChannelSnapshot({
|
|
|
5520
7042
|
}
|
|
5521
7043
|
return log;
|
|
5522
7044
|
});
|
|
5523
|
-
const vaultStorageWriteEvents = await queryContractEventsChunked({
|
|
5524
|
-
contract: bridgeTokenVault,
|
|
5525
|
-
eventName: "StorageWriteObserved",
|
|
5526
|
-
fromBlock: scanFromBlock,
|
|
5527
|
-
toBlock: latestBlock,
|
|
5528
|
-
onProgress: progressAction
|
|
5529
|
-
? createRpcLogScanProgress({ action: progressAction, label: "bridge-vault events" })
|
|
5530
|
-
: null,
|
|
5531
|
-
});
|
|
5532
7045
|
|
|
5533
7046
|
const groupedEvents = new Map();
|
|
5534
|
-
for (const event of [...channelManagerEvents, ...
|
|
7047
|
+
for (const event of [...channelManagerEvents, ...bridgeVaultLogs]) {
|
|
5535
7048
|
const key = event.transactionHash;
|
|
5536
7049
|
const group = groupedEvents.get(key) ?? [];
|
|
5537
7050
|
group.push(event);
|
|
@@ -5539,8 +7052,43 @@ async function reconstructChannelSnapshot({
|
|
|
5539
7052
|
}
|
|
5540
7053
|
|
|
5541
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
|
+
}) {
|
|
5542
7090
|
let currentSnapshot = startingSnapshot;
|
|
5543
|
-
|
|
7091
|
+
const stateManager = await buildStateManager(currentSnapshot, contractCodes);
|
|
5544
7092
|
|
|
5545
7093
|
for (const group of groupedValues) {
|
|
5546
7094
|
const orderedGroup = [...group].sort(compareLogsByPosition);
|
|
@@ -5614,20 +7162,161 @@ async function reconstructChannelSnapshot({
|
|
|
5614
7162
|
`CurrentRootVectorObserved root vector mismatch at tx ${rootEvent.transactionHash}.`,
|
|
5615
7163
|
);
|
|
5616
7164
|
}
|
|
7165
|
+
return currentSnapshot;
|
|
7166
|
+
}
|
|
5617
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.");
|
|
5618
7189
|
expect(
|
|
5619
|
-
ethers.toBigInt(normalizeBytes32Hex(
|
|
5620
|
-
=== ethers.toBigInt(normalizeBytes32Hex(
|
|
5621
|
-
"
|
|
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,
|
|
5622
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
|
+
}
|
|
5623
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.");
|
|
5624
7316
|
return {
|
|
5625
|
-
|
|
5626
|
-
|
|
5627
|
-
|
|
5628
|
-
toBlock: latestBlock,
|
|
5629
|
-
mode: baseSnapshot ? "recovery-index" : "genesis",
|
|
5630
|
-
},
|
|
7317
|
+
...event,
|
|
7318
|
+
args: parsed.args,
|
|
7319
|
+
fragment: parsed.fragment,
|
|
5631
7320
|
};
|
|
5632
7321
|
}
|
|
5633
7322
|
|
|
@@ -5727,6 +7416,56 @@ async function fetchLogsChunked(provider, {
|
|
|
5727
7416
|
return aggregatedLogs;
|
|
5728
7417
|
}
|
|
5729
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
|
+
|
|
5730
7469
|
async function throttleLogRequest() {
|
|
5731
7470
|
const elapsedMs = Date.now() - lastLogRequestStartedAtMs;
|
|
5732
7471
|
if (elapsedMs < LOG_REQUEST_INTERVAL_MS) {
|
|
@@ -6663,12 +8402,29 @@ function assertCreateChannelArgs(args) {
|
|
|
6663
8402
|
|
|
6664
8403
|
function assertRecoverWorkspaceArgs(args) {
|
|
6665
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
|
+
}
|
|
6666
8409
|
}
|
|
6667
8410
|
|
|
6668
8411
|
function assertGetChannelArgs(args) {
|
|
6669
8412
|
assertAllowedCommandSchema(args, "channel-get-meta");
|
|
6670
8413
|
}
|
|
6671
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
|
+
|
|
6672
8428
|
function assertDepositBridgeArgs(args) {
|
|
6673
8429
|
assertAllowedCommandSchema(args, "account-deposit-bridge");
|
|
6674
8430
|
}
|
|
@@ -7439,6 +9195,94 @@ function emitProgress(action, phase) {
|
|
|
7439
9195
|
}
|
|
7440
9196
|
}
|
|
7441
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
|
+
|
|
7442
9286
|
function createRpcLogScanProgress({ action, label }) {
|
|
7443
9287
|
let lastBucket = -1;
|
|
7444
9288
|
return (event) => {
|
|
@@ -7569,7 +9413,7 @@ function buildRecoveryHints(error, args = {}) {
|
|
|
7569
9413
|
}
|
|
7570
9414
|
|
|
7571
9415
|
if (message.includes("Workspace recovery index is missing or unusable")) {
|
|
7572
|
-
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`);
|
|
7573
9417
|
hints.push(`private-state-cli wallet recover-workspace --channel-name ${channelName} --network ${networkName} --account ${accountName} --from-genesis`);
|
|
7574
9418
|
}
|
|
7575
9419
|
|