Files
app.zeusln.zeus/utils/ChannelMigrationUtils.ts
T

880 lines
29 KiB
TypeScript

import { Alert, Platform } from 'react-native';
import RNFS from 'react-native-fs';
import Share from 'react-native-share';
import RNRestart from 'react-native-restart';
import ReactNativeBlobUtil from 'react-native-blob-util';
import { localeString } from './LocaleUtils';
import { stopLnd } from './LndMobileUtils';
import BackendUtils from './BackendUtils';
import { signMessageNodePubkey } from '../lndmobile/wallet';
import Base64Utils from './Base64Utils';
import { sleep } from './SleepUtils';
import { zipFolder, unzipFile, encryptFile, decryptFile } from './ZipUtils';
import { BACKUPS_HOST } from '../stores/ChannelBackupStore';
import Storage from '../storage';
export const CHANNEL_MIGRATION_ACTIVE = 'channel_migration_active';
const VALID_CHANNEL_DB_EXTENSIONS = ['.zip'];
const getGraphDir = (lndDir: string, isTestnet: boolean): string => {
const network = isTestnet ? 'testnet' : 'mainnet';
const rootPath = Platform.select({
android: RNFS.DocumentDirectoryPath,
ios: `${RNFS.LibraryDirectoryPath}/Application Support`
});
return `${rootPath}/${lndDir}/data/graph/${network}`;
};
const stopLndSafely = async (): Promise<void> => {
try {
await stopLnd();
await sleep(5000);
} catch (e: any) {
if (e?.message?.includes?.('closed')) return;
throw e;
}
};
const resolveToLocalPath = async (uri: string): Promise<string> => {
const ext = uri.toLowerCase().endsWith('.db') ? '.db' : '.zip';
const tempPath = `${RNFS.CachesDirectoryPath}/zeus-import-temp${ext}`;
if (Platform.OS === 'android' && uri.startsWith('content://')) {
await RNFS.copyFile(uri, tempPath);
return tempPath;
}
if (Platform.OS === 'ios') {
let filePath = uri;
if (filePath.startsWith('file://')) {
filePath = decodeURIComponent(filePath.replace('file://', ''));
}
if (await RNFS.exists(filePath)) {
if (await RNFS.exists(tempPath)) {
await RNFS.unlink(tempPath);
}
await RNFS.copyFile(filePath, tempPath);
return tempPath;
}
if (await RNFS.exists(tempPath)) {
return tempPath;
}
return filePath;
}
return uri;
};
/**
* Validates a channel backup file before import
* Checks extension, file existence, and non-empty size
*/
export const validateChannelBackupFile = async (
fileUri: string,
fileName: string
): Promise<{ valid: boolean; error?: string }> => {
const lowerFileName = fileName.toLowerCase();
const hasValidExtension = VALID_CHANNEL_DB_EXTENSIONS.some((ext) =>
lowerFileName.endsWith(ext)
);
if (!hasValidExtension) {
return {
valid: false,
error: localeString('views.Tools.migration.import.invalidExtension')
};
}
try {
const localPath = await resolveToLocalPath(fileUri);
const stat = await RNFS.stat(localPath);
if (!stat.size || stat.size === 0) {
return {
valid: false,
error: localeString('views.Tools.migration.import.emptyFile')
};
}
} catch (e) {
return {
valid: false,
error: localeString('views.Tools.migration.import.fileNotFound')
};
}
return { valid: true };
};
/**
* Uploads the graph data to Olympus
*/
export const uploadChannelBackupToOlympus = async (
lndDir: string,
isTestnet: boolean,
pubkey: string,
seedArray: string,
setStatus?: (message: string | null) => void
) => {
try {
if (setStatus)
setStatus(
localeString('views.Tools.migration.export.authenticating')
);
const graphDir = getGraphDir(lndDir, isTestnet);
if (!(await RNFS.exists(graphDir))) {
Alert.alert(
localeString('general.error'),
localeString('views.Tools.migration.export.dbNotFound')
);
if (setStatus) setStatus(null);
return;
}
// 1. Authentication for status to check for existing backup
console.log('Authenticating for status check...');
const statusAuthResponse = await ReactNativeBlobUtil.fetch(
'POST',
`${BACKUPS_HOST}/api/auth`,
{ 'Content-Type': 'application/json' },
JSON.stringify({ pubkey })
);
if (statusAuthResponse.info().status !== 200) {
throw new Error('Authentication failed');
}
const statusAuth = statusAuthResponse.json();
if (!statusAuth.success || !statusAuth.verification)
throw new Error('Invalid auth response');
console.log('Signing status challenge...');
const statusSignData = await BackendUtils.signMessage(
statusAuth.verification
);
const statusSignature =
statusSignData.zbase || statusSignData.signature;
if (setStatus)
setStatus(
localeString('views.Tools.migration.export.checkingStatus')
);
console.log('Checking backup status...');
const statusResponse = await ReactNativeBlobUtil.fetch(
'POST',
`${BACKUPS_HOST}/api/status`,
{ 'Content-Type': 'application/json' },
JSON.stringify({
pubkey,
signature: statusSignature,
message: statusAuth.verification
})
);
if (statusResponse.info().status !== 200) {
throw new Error('Status check failed');
}
const statusJson = statusResponse.json();
if (!statusJson.success)
throw new Error(statusJson.error || 'Status check failed');
const last_backup_at = statusJson.last_backup_at;
const proceedToUpload = async () => {
try {
if (setStatus)
setStatus(
localeString(
'views.Tools.migration.export.authenticating'
)
);
console.log('Authenticating for uploading backup...');
const uploadAuthResponse = await ReactNativeBlobUtil.fetch(
'POST',
`${BACKUPS_HOST}/api/auth`,
{ 'Content-Type': 'application/json' },
JSON.stringify({ pubkey })
);
if (uploadAuthResponse.info().status !== 200) {
throw new Error('Authentication failed');
}
const uploadAuth = uploadAuthResponse.json();
if (!uploadAuth.success || !uploadAuth.verification)
throw new Error('Invalid auth response');
console.log('Signing upload challenge...');
const uploadSignData = await BackendUtils.signMessage(
uploadAuth.verification
);
const uploadSignature =
uploadSignData.zbase || uploadSignData.signature;
if (setStatus)
setStatus(
localeString('views.Tools.migration.export.stoppingLnd')
);
try {
await stopLndSafely();
} catch (e: any) {
console.error('Failed to stop LND:', e.message);
if (setStatus) setStatus(null);
Alert.alert(
localeString('general.error'),
localeString(
'views.Tools.migration.export.failedToStopLnd'
)
);
return;
}
if (setStatus)
setStatus(
localeString(
'views.Tools.migration.export.zippingBackup'
)
);
const timestamp = Date.now();
const tempZipPath = `${RNFS.CachesDirectoryPath}/zeus-olympus-backup-${timestamp}.zip`;
const tempEncPath = `${RNFS.CachesDirectoryPath}/zeus-olympus-backup-${timestamp}.enc`;
await zipFolder(graphDir, tempZipPath);
if (setStatus)
setStatus(
localeString('views.Tools.migration.export.encrypting')
);
await encryptFile(tempZipPath, tempEncPath, seedArray);
await RNFS.unlink(tempZipPath);
const encryptedBase64 = await ReactNativeBlobUtil.fs.readFile(
tempEncPath,
'base64'
);
await RNFS.unlink(tempEncPath);
// upload to the server
if (setStatus)
setStatus(
localeString('views.Tools.migration.export.uploading')
);
console.log('Uploading encrypted backup...');
const backupResponse = await ReactNativeBlobUtil.fetch(
'POST',
`${BACKUPS_HOST}/api/channels-backup`,
{ 'Content-Type': 'application/json' },
JSON.stringify({
pubkey,
message: uploadAuth.verification,
signature: uploadSignature,
backup: encryptedBase64
})
);
const status = backupResponse.info().status;
if (status === 413) {
throw new Error('Backup too large for server');
}
let json;
try {
json = backupResponse.json();
} catch (e) {
throw new Error(
`Server returned non-JSON response (HTTP ${status})`
);
}
console.log('Upload response:', json);
if (setStatus) setStatus(null);
if (status === 200 && json.success) {
await Storage.setItem(
CHANNEL_MIGRATION_ACTIVE,
JSON.stringify({ migrationStatus: true, lndDir })
);
Alert.alert(
localeString('views.Tools.migration.export.success'),
localeString(
'views.Tools.migration.export.success.text'
),
[
{
text: localeString('views.Wallet.restart'),
onPress: () => RNRestart.Restart()
}
],
{ cancelable: false }
);
} else {
Alert.alert(
localeString('general.error'),
json.error ||
localeString(
'views.Tools.migration.export.failedToUpload'
),
[
{
text: localeString('views.Wallet.restart'),
onPress: () => RNRestart.Restart()
}
],
{ cancelable: false }
);
}
} catch (e) {
console.error('Upload failed:', e);
if (setStatus) setStatus(null);
Alert.alert(
localeString('general.error'),
localeString('views.Tools.migration.export.failedToUpload'),
[
{
text: localeString('views.Wallet.restart'),
onPress: () => RNRestart.Restart()
}
],
{ cancelable: false }
);
}
};
if (last_backup_at) {
const dateStr = new Date(last_backup_at).toLocaleString();
Alert.alert(
localeString(
'views.Tools.migration.export.existingBackupFound'
),
localeString('views.Tools.migration.export.replaceBackup', {
date: dateStr
}),
[
{
text: localeString('general.cancel'),
style: 'cancel',
onPress: () => {
if (setStatus) setStatus(null);
}
},
{
text: localeString(
'views.Tools.migration.export.replace'
),
style: 'destructive',
onPress: proceedToUpload
}
]
);
} else {
await proceedToUpload();
}
} catch (error) {
console.error(error);
if (setStatus) setStatus(null);
Alert.alert(
localeString('general.error'),
localeString('views.Tools.migration.export.failedToUpload')
);
}
};
/**
* Downloads and restores the graph data from Olympus
*/
export const restoreChannelBackupFromOlympus = async (
lndDir: string,
isTestnet: boolean,
pubkey: string,
seedArray: string
): Promise<boolean> => {
try {
// 1. Authentication for status to check for existing backup
console.log('Authenticating for status check...');
const statusAuthResponse = await ReactNativeBlobUtil.fetch(
'POST',
`${BACKUPS_HOST}/api/auth`,
{ 'Content-Type': 'application/json' },
JSON.stringify({ pubkey })
);
if (statusAuthResponse.info().status !== 200) {
throw new Error('Authentication failed');
}
const statusAuth = statusAuthResponse.json();
if (!statusAuth.success || !statusAuth.verification)
throw new Error('Invalid auth response');
console.log('Signing status challenge...');
const statusSignData = await signMessageNodePubkey(
Base64Utils.stringToUint8Array(statusAuth.verification)
);
const statusSignature = statusSignData.signature;
console.log('Checking backup status...');
const statusResponse = await ReactNativeBlobUtil.fetch(
'POST',
`${BACKUPS_HOST}/api/status`,
{ 'Content-Type': 'application/json' },
JSON.stringify({
pubkey,
signature: statusSignature,
message: statusAuth.verification
})
);
if (statusResponse.info().status !== 200) {
throw new Error('Status check failed');
}
const statusJson = statusResponse.json();
if (!statusJson.success)
throw new Error(statusJson.error || 'Status check failed');
const last_backup_at = statusJson.last_backup_at;
if (!last_backup_at) {
await new Promise<void>((resolve) => {
Alert.alert(
localeString('general.error'),
localeString(
'views.Tools.migration.import.noBackupFoundOlympus'
),
[
{
text: localeString(
'views.Tools.migration.import.noBackup'
),
onPress: () => resolve()
}
],
{ cancelable: false }
);
});
return false;
}
const userConfirmed = await new Promise<boolean>((resolve) => {
const dateStr = new Date(last_backup_at).toLocaleString();
Alert.alert(
localeString('views.Tools.migration.export.backupFound'),
localeString(
'views.Tools.migration.import.backupFoundMessage',
{ date: dateStr }
),
[
{
text: localeString('general.cancel'),
style: 'cancel',
onPress: () => resolve(false)
},
{
text: localeString(
'views.Settings.EmbeddedNode.restoreChannelBackups.restore'
),
onPress: () => resolve(true)
}
],
{ cancelable: false }
);
});
if (!userConfirmed) {
return false;
}
// 2. Authenticatication for restoring backup
console.log('Authenticating for restore...');
const restoreAuthResponse = await ReactNativeBlobUtil.fetch(
'POST',
`${BACKUPS_HOST}/api/auth`,
{ 'Content-Type': 'application/json' },
JSON.stringify({ pubkey })
);
if (restoreAuthResponse.info().status !== 200) {
throw new Error('Authentication failed');
}
const restoreAuth = restoreAuthResponse.json();
if (!restoreAuth.success || !restoreAuth.verification)
throw new Error('Invalid auth response');
console.log('Signing restore challenge...');
const restoreSignData = await signMessageNodePubkey(
Base64Utils.stringToUint8Array(restoreAuth.verification)
);
const restoreSignature = restoreSignData.signature;
// 3. Download the encrypted backup
console.log('Downloading encrypted backup...');
const timestamp = Date.now();
const tempEncPath = `${RNFS.CachesDirectoryPath}/zeus-olympus-restore-${timestamp}.enc`;
const tempZipPath = `${RNFS.CachesDirectoryPath}/zeus-olympus-restore-${timestamp}.zip`;
const restoreResponse = await ReactNativeBlobUtil.fetch(
'POST',
`${BACKUPS_HOST}/api/restore-channels`,
{ 'Content-Type': 'application/json' },
JSON.stringify({
pubkey,
message: restoreAuth.verification,
signature: restoreSignature
})
);
const restoreStatus = restoreResponse.info().status;
if (restoreStatus !== 200) {
let errorMsg = 'Download failed';
try {
const errorJson = restoreResponse.json();
if (errorJson.error) errorMsg = errorJson.error;
} catch (e) {}
throw new Error(errorMsg);
}
// The server stores and returns the backup as a base64 string
// (it was uploaded as base64 inside a JSON body). Decode it
// back to raw binary before decrypting.
const encryptedBase64Response = await restoreResponse.text();
await ReactNativeBlobUtil.fs.writeFile(
tempEncPath,
encryptedBase64Response,
'base64'
);
// 4. Decrypt the payload
console.log('Decrypting backup...');
await decryptFile(tempEncPath, tempZipPath, seedArray);
await RNFS.unlink(tempEncPath);
const MAX_RECOVERY_WAIT_ATTEMPTS = 60;
for (let attempt = 1; ; attempt++) {
try {
await stopLnd();
await sleep(5000);
break;
} catch (e: any) {
if (e?.message?.includes?.('closed')) break;
if (e?.message?.includes?.('wallet recovery in progress')) {
if (attempt >= MAX_RECOVERY_WAIT_ATTEMPTS) {
throw new Error(
localeString(
'views.Tools.migration.export.failedToStopLnd'
)
);
}
console.log(
`Wallet recovery in progress, waiting 5s (attempt ${attempt}/${MAX_RECOVERY_WAIT_ATTEMPTS})...`
);
await sleep(5000);
continue;
}
throw new Error(
localeString('views.Tools.migration.export.failedToStopLnd')
);
}
}
const destFolder = getGraphDir(lndDir, isTestnet);
if (!(await RNFS.exists(destFolder))) {
await RNFS.mkdir(destFolder);
}
try {
const existing = await RNFS.readDir(destFolder);
for (const item of existing) {
if (item.isFile()) await RNFS.unlink(item.path);
}
} catch (e) {}
await unzipFile(tempZipPath, destFolder);
await RNFS.unlink(tempZipPath);
return true;
} catch (error: any) {
console.error('Restore Flow Failed', error);
throw error;
}
};
/**
* Exports the graph data as a .zip file
* On Android, saves directly to Downloads; on iOS, opens share sheet
*/
export const exportChannelDb = async (
lndDir: string,
isTestnet: boolean,
setStatus?: (message: string | null) => void
) => {
try {
const graphDir = getGraphDir(lndDir, isTestnet);
if (!(await RNFS.exists(graphDir))) {
Alert.alert(
localeString('general.error'),
localeString('views.Tools.migration.databaseNotFound')
);
if (setStatus) setStatus(null);
return;
}
if (setStatus)
setStatus(localeString('views.Tools.migration.export.stoppingLnd'));
try {
await stopLndSafely();
} catch (e: any) {
console.error('Failed to stop LND:', e.message);
if (setStatus) setStatus(null);
Alert.alert(
localeString('general.error'),
localeString('views.Tools.migration.export.failedToStopLnd')
);
return;
}
const network = isTestnet ? 'testnet' : 'mainnet';
const backupFileName = `zeus-lnd-${network}-${Date.now()}.zip`;
const stagingDir =
Platform.OS === 'android'
? RNFS.CachesDirectoryPath
: RNFS.DocumentDirectoryPath;
const stagingPath = `${stagingDir}/${backupFileName}`;
if (await RNFS.exists(stagingPath)) {
await RNFS.unlink(stagingPath);
}
if (setStatus)
setStatus(
localeString('views.Tools.migration.export.zippingBackup')
);
await zipFolder(graphDir, stagingPath);
if (setStatus)
setStatus(localeString('views.Tools.migration.export.savingFile'));
const finishExport = async () => {
if (setStatus) setStatus(null);
await Storage.setItem(
CHANNEL_MIGRATION_ACTIVE,
JSON.stringify({ migrationStatus: true, lndDir })
);
Alert.alert(
localeString('views.Tools.migration.export.success'),
localeString(
Platform.OS === 'android'
? 'views.Tools.migration.export.success.text.android'
: 'views.Tools.migration.export.success.text'
),
[
{
text: localeString('views.Wallet.restart'),
onPress: () => RNRestart.Restart()
}
],
{ cancelable: false }
);
};
if (Platform.OS === 'android') {
const downloadPath = `${RNFS.DownloadDirectoryPath}/${backupFileName}`;
await RNFS.copyFile(stagingPath, downloadPath);
await RNFS.unlink(stagingPath);
await finishExport();
return;
}
try {
const shareResult = await Share.open({
title: localeString('views.Tools.migration.export.title'),
url: `file://${stagingPath}`,
type: 'application/octet-stream',
filename: backupFileName,
failOnCancel: false
});
const isDismissed =
shareResult.dismissedAction || shareResult.success === false;
if (isDismissed) {
await RNFS.unlink(stagingPath);
Alert.alert(
localeString('views.Tools.migration.export.cancelled'),
localeString('views.Tools.migration.export.cancelled.text'),
[
{
text: localeString('views.Wallet.restart'),
onPress: () => RNRestart.Restart()
}
],
{ cancelable: false }
);
return;
}
await RNFS.unlink(stagingPath);
await finishExport();
} catch (err: any) {
if (await RNFS.exists(stagingPath)) {
await RNFS.unlink(stagingPath);
}
const errorMsg = err?.message || String(err);
if (
errorMsg.includes('User did not share') ||
errorMsg.includes('cancel')
) {
Alert.alert(
localeString('views.Tools.migration.export.cancelled'),
localeString('views.Tools.migration.export.cancelled.text'),
[
{
text: localeString('views.Wallet.restart'),
onPress: () => RNRestart.Restart()
}
],
{ cancelable: false }
);
return;
}
throw err;
}
} catch (error) {
console.error('Export Failed:', error);
if (setStatus) setStatus(null);
Alert.alert(
localeString('general.error'),
undefined,
[
{
text: localeString('views.Wallet.restart'),
onPress: () => RNRestart.Restart()
}
],
{ cancelable: false }
);
}
};
/**
* Prompts the user to export channel backup — either to Olympus (SQLite only)
* or as a local zip file.
*/
export const handleExportChannels = ({
isSqlite,
lndDir,
isTestnet,
pubkey,
seedPhrase,
setStatus
}: {
isSqlite: boolean;
lndDir: string;
isTestnet: boolean;
pubkey: string;
seedPhrase: string;
setStatus: (msg: string | null) => void;
}) => {
const warningText =
`${localeString('views.Tools.migration.export.text1')}\n\n` +
`⚠️ ${localeString('views.Tools.migration.export.text2')}`;
if (isSqlite) {
Alert.alert(
localeString('views.Tools.migration.export.title'),
warningText,
[
{
text: localeString('general.cancel'),
style: 'cancel'
},
{
text: localeString('views.Tools.migration.export.olympus'),
style: 'default',
onPress: async () => {
await uploadChannelBackupToOlympus(
lndDir,
isTestnet,
pubkey,
seedPhrase,
setStatus
);
}
},
{
text: localeString('views.Tools.migration.export.local'),
style: 'default',
onPress: async () => {
await exportChannelDb(lndDir, isTestnet, setStatus);
}
}
]
);
} else {
Alert.alert(
localeString('views.Tools.migration.export.title'),
warningText,
[
{
text: localeString('general.cancel'),
style: 'cancel'
},
{
text: localeString('general.ok'),
style: 'default',
onPress: async () => {
await exportChannelDb(lndDir, isTestnet, setStatus);
}
}
]
);
}
};
/**
* Imports a channel backup zip file (works for both SQLite and bolt DB).
* Clears existing files in the graph directory and unzips the backup.
*/
export const importChannelDb = async (
sourceUri: string,
fileName: string,
lndDir: string,
isTestnet: boolean
) => {
const validation = await validateChannelBackupFile(sourceUri, fileName);
if (!validation.valid) {
throw new Error(validation.error);
}
const localPath = await resolveToLocalPath(sourceUri);
const destFolder = getGraphDir(lndDir, isTestnet);
if (!(await RNFS.exists(destFolder))) {
await RNFS.mkdir(destFolder);
}
try {
const existing = await RNFS.readDir(destFolder);
for (const item of existing) {
if (item.isFile()) {
await RNFS.unlink(item.path);
}
}
} catch (e) {}
await unzipFile(localPath, destFolder);
};