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