beignet 0.9.2 → 0.9.3

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.
Files changed (34) hide show
  1. package/README.md +1 -1
  2. package/dist/cli/auth.js +2 -0
  3. package/dist/cli/auth.js.map +1 -1
  4. package/dist/cli/beignet-node.js +688 -43
  5. package/dist/cli/beignet-node.js.map +1 -1
  6. package/dist/cli/cli.js +25 -2
  7. package/dist/cli/cli.js.map +1 -1
  8. package/dist/cli/config.js +6 -1
  9. package/dist/cli/config.js.map +1 -1
  10. package/dist/cli/daemon.js +49 -2
  11. package/dist/cli/daemon.js.map +1 -1
  12. package/dist/cli/openapi.js +47 -4
  13. package/dist/cli/openapi.js.map +1 -1
  14. package/dist/lightning/node/lightning-node.js +18 -0
  15. package/dist/lightning/node/lightning-node.js.map +1 -1
  16. package/dist/lightning/node/types.js.map +1 -1
  17. package/dist/lightning/recovery/assembly.js +67 -7
  18. package/dist/lightning/recovery/assembly.js.map +1 -1
  19. package/dist/lightning/recovery/capsule.js +73 -5
  20. package/dist/lightning/recovery/capsule.js.map +1 -1
  21. package/dist/lightning/recovery/guardian-client.js +5 -1
  22. package/dist/lightning/recovery/guardian-client.js.map +1 -1
  23. package/dist/lightning/recovery/startup-gate.js +42 -18
  24. package/dist/lightning/recovery/startup-gate.js.map +1 -1
  25. package/dist/types/cli/beignet-node.d.ts +83 -2
  26. package/dist/types/cli/types.d.ts +5 -1
  27. package/dist/types/lightning/node/lightning-node.d.ts +3 -0
  28. package/dist/types/lightning/node/types.d.ts +2 -0
  29. package/dist/types/lightning/recovery/assembly.d.ts +13 -0
  30. package/dist/types/lightning/recovery/capsule.d.ts +5 -0
  31. package/dist/types/lightning/recovery/guardian-client.d.ts +1 -0
  32. package/dist/types/lightning/recovery/startup-gate.d.ts +2 -0
  33. package/docs/RECOVERY-PROTOCOL.md +102 -1
  34. package/package.json +1 -1
@@ -54,6 +54,8 @@ const electrum_backend_1 = require("../lightning/chain/electrum-backend");
54
54
  const types_2 = require("../lightning/invoice/types");
55
55
  const wallet_keys_1 = require("../lightning/keys/wallet-keys");
56
56
  const recovery_1 = require("../lightning/recovery");
57
+ const ecdh_1 = require("../lightning/crypto/ecdh");
58
+ const auth_1 = require("./auth");
57
59
  const types_3 = require("../lightning/channel/types");
58
60
  const decode_1 = require("../lightning/invoice/decode");
59
61
  const decode_2 = require("../lightning/offer/decode");
@@ -68,6 +70,40 @@ function parseRecoveryMode(value) {
68
70
  : 'off';
69
71
  }
70
72
  exports.parseRecoveryMode = parseRecoveryMode;
73
+ function redactUrl(url) {
74
+ try {
75
+ const parsed = new URL(url);
76
+ if (parsed.username === '' && parsed.password === '')
77
+ return url;
78
+ parsed.username = '';
79
+ parsed.password = '';
80
+ return parsed.toString();
81
+ }
82
+ catch {
83
+ return '<invalid url>';
84
+ }
85
+ }
86
+ function redactGuardians(descriptors) {
87
+ return descriptors.map((g) => ({
88
+ guardianId: g.guardianId,
89
+ transports: g.transports.map((t) => ({
90
+ type: t.type,
91
+ url: redactUrl(t.url)
92
+ }))
93
+ }));
94
+ }
95
+ function sameGuardianIds(a, b) {
96
+ if (a.length !== b.length)
97
+ return false;
98
+ const sortedA = a.map((id) => id.toLowerCase()).sort();
99
+ const sortedB = b.map((id) => id.toLowerCase()).sort();
100
+ return sortedA.every((id, i) => id === sortedB[i]);
101
+ }
102
+ function writeFileAtomic(filePath, content) {
103
+ const tmp = `${filePath}.tmp`;
104
+ fs.writeFileSync(tmp, content);
105
+ fs.renameSync(tmp, filePath);
106
+ }
71
107
  const RESTORE_ERROR_CODES = {
72
108
  'no-quorum': 'RESTORE_NO_QUORUM',
73
109
  'unknown-namespace': 'RESTORE_UNKNOWN_NAMESPACE',
@@ -224,6 +260,8 @@ class BeignetNode extends events_1.EventEmitter {
224
260
  this.recoveryGuardianSet = [];
225
261
  this._restorePending = false;
226
262
  this._restoreInFlight = false;
263
+ this._leaseCheckInFlight = false;
264
+ this._leaseCheckIntervalMs = BeignetNode.DEFAULT_LEASE_CHECK_INTERVAL_MS;
227
265
  this._lockPath = null;
228
266
  this._lockExitHandler = null;
229
267
  this.destroyed = false;
@@ -246,6 +284,10 @@ class BeignetNode extends events_1.EventEmitter {
246
284
  this._draining = false;
247
285
  this.peerStorageEnabled = true;
248
286
  this._peerRetrievedScb = null;
287
+ this._peerRetrievedCapsules = new Map();
288
+ this._pushedChannelBackup = false;
289
+ this._restartRequired = false;
290
+ this._recoveryFenceRelayed = false;
249
291
  this.mnemonic = mnemonic;
250
292
  this.networkName = networkName;
251
293
  this.dataDir = dataDir;
@@ -277,6 +319,14 @@ class BeignetNode extends events_1.EventEmitter {
277
319
  this.peerStorageEnabled = opts.peerStorageEnabled ?? true;
278
320
  this.rapidGossipSync = opts.rapidGossipSync ?? true;
279
321
  this.rapidGossipSyncUrl = opts.rapidGossipSyncUrl;
322
+ if (opts.recoveryLeaseCheckIntervalMs !== undefined) {
323
+ const ms = opts.recoveryLeaseCheckIntervalMs;
324
+ if (!Number.isInteger(ms) || ms < 0 || ms > BeignetNode.MAX_TIMER_MS) {
325
+ throw new errors_1.BeignetError('INVALID_PARAMS', `recoveryLeaseCheckIntervalMs must be an integer between 0 and ` +
326
+ `${BeignetNode.MAX_TIMER_MS} (got ${String(ms)})`);
327
+ }
328
+ this._leaseCheckIntervalMs = ms;
329
+ }
280
330
  const networkName = this.networkName;
281
331
  if (!opts.allowMultipleInstances) {
282
332
  const lockPath = path.join(this.dataDir, `${networkName}.lock`);
@@ -316,6 +366,8 @@ class BeignetNode extends events_1.EventEmitter {
316
366
  if (opts.storageEncryption ?? true) {
317
367
  encryptionKey = (0, encryption_1.deriveStorageKey)(bip39.mnemonicToSeedSync(this.mnemonic));
318
368
  }
369
+ this._storageEncryptionKey = encryptionKey;
370
+ this.finishStagedCapsuleRestore(dbPath);
319
371
  this.storage = new sqlite_storage_1.SqliteStorage(dbPath, (err) => {
320
372
  this.log('warn', 'Skipped corrupted storage row during load', {
321
373
  error: err instanceof Error ? err.message : String(err)
@@ -701,16 +753,7 @@ class BeignetNode extends events_1.EventEmitter {
701
753
  this.node.on('recovery:fenced', (superseding) => {
702
754
  this.log('error', 'Recovery fenced: a newer writer epoch owns this namespace; ' +
703
755
  'this device must not send another channel message');
704
- this.emit('recovery:fenced', {
705
- supersededBy: superseding
706
- ? {
707
- epoch: superseding.lease.epoch.toString(),
708
- writerPublicKey: superseding.lease.writerPublicKey.toString('hex'),
709
- sequence: superseding.logHead.sequence.toString(),
710
- frameHash: superseding.logHead.frameHash.toString('hex')
711
- }
712
- : null
713
- });
756
+ this.relayRecoveryFenced(superseding);
714
757
  });
715
758
  this.node.on('recovery:backfill-lost', (detail) => {
716
759
  this.log('error', 'Recovery backfill lost', { detail });
@@ -796,13 +839,11 @@ class BeignetNode extends events_1.EventEmitter {
796
839
  this.recoveryNodeConfig = { enabled: true, durability: 'local' };
797
840
  return;
798
841
  }
799
- const guardians = (opts.recoveryGuardians ?? []).map(recovery_1.parseGuardianUri);
842
+ const guardians = (opts.recoveryGuardians ?? []).map(recovery_1.parseGuardianEntry);
800
843
  this.recoveryGuardianSet = guardians;
801
- const coinType = this.toCoinType(this.networkName);
802
- const keys = (0, wallet_keys_1.deriveLightningKeysFromMnemonic)(this.mnemonic, undefined, coinType);
803
844
  const decision = await (0, recovery_1.buildGuardianRecovery)({
804
845
  storage: this.storage,
805
- nodeSecret: keys.nodePrivateKey,
846
+ nodeSecret: this.nodeSecret(),
806
847
  durability: mode,
807
848
  guardians,
808
849
  onBarrierEvent: (event) => {
@@ -855,19 +896,11 @@ class BeignetNode extends events_1.EventEmitter {
855
896
  this.log('info', 'Recovery gate confirmed; peer traffic released', {
856
897
  confirming: outcome.confirming
857
898
  });
899
+ this.startRecoveryLeaseCheck();
858
900
  return;
859
901
  }
860
902
  if (outcome.state === 'fenced') {
861
- this.emit('recovery:fenced', {
862
- supersededBy: outcome.supersededBy
863
- ? {
864
- epoch: outcome.supersededBy.lease.epoch.toString(),
865
- writerPublicKey: outcome.supersededBy.lease.writerPublicKey.toString('hex'),
866
- sequence: outcome.supersededBy.logHead.sequence.toString(),
867
- frameHash: outcome.supersededBy.logHead.frameHash.toString('hex')
868
- }
869
- : null
870
- });
903
+ this.relayRecoveryFenced(outcome.supersededBy);
871
904
  return;
872
905
  }
873
906
  this.log('warn', 'Recovery gate still quarantined; retrying', {
@@ -889,6 +922,57 @@ class BeignetNode extends events_1.EventEmitter {
889
922
  };
890
923
  void attempt();
891
924
  }
925
+ relayRecoveryFenced(supersededBy) {
926
+ this.stopRecoveryLeaseCheck();
927
+ if (this._recoveryFenceRelayed)
928
+ return;
929
+ this._recoveryFenceRelayed = true;
930
+ this.emit('recovery:fenced', {
931
+ supersededBy: supersededBy
932
+ ? {
933
+ epoch: supersededBy.lease.epoch.toString(),
934
+ writerPublicKey: supersededBy.lease.writerPublicKey.toString('hex'),
935
+ sequence: supersededBy.logHead.sequence.toString(),
936
+ frameHash: supersededBy.logHead.frameHash.toString('hex')
937
+ }
938
+ : null
939
+ });
940
+ }
941
+ startRecoveryLeaseCheck() {
942
+ const run = this._recoveryRun;
943
+ if (!run || this._leaseCheckTimer || this._leaseCheckIntervalMs <= 0) {
944
+ return;
945
+ }
946
+ this._leaseCheckTimer = setInterval(() => {
947
+ if (this.destroyed || this._leaseCheckInFlight)
948
+ return;
949
+ this._leaseCheckInFlight = true;
950
+ run
951
+ .recheck()
952
+ .then((outcome) => {
953
+ if (outcome.state !== 'fenced')
954
+ return;
955
+ this.log('error', 'Recovery lease check: a newer writer epoch owns this ' +
956
+ 'namespace; this device is fenced');
957
+ this.relayRecoveryFenced(outcome.supersededBy);
958
+ })
959
+ .catch((err) => {
960
+ this.log('debug', 'Recovery lease check failed', {
961
+ error: err instanceof Error ? err.message : String(err)
962
+ });
963
+ })
964
+ .finally(() => {
965
+ this._leaseCheckInFlight = false;
966
+ });
967
+ }, this._leaseCheckIntervalMs);
968
+ this._leaseCheckTimer.unref?.();
969
+ }
970
+ stopRecoveryLeaseCheck() {
971
+ if (this._leaseCheckTimer) {
972
+ clearInterval(this._leaseCheckTimer);
973
+ this._leaseCheckTimer = undefined;
974
+ }
975
+ }
892
976
  get restorePending() {
893
977
  return this._restorePending;
894
978
  }
@@ -900,13 +984,30 @@ class BeignetNode extends events_1.EventEmitter {
900
984
  const profile = this.recoveryMode === 'async-remote' || this.recoveryMode === 'quorum'
901
985
  ? 'crash-v1'
902
986
  : null;
987
+ const capsules = this.describeRetrievedCapsules();
988
+ const restore = {
989
+ inProgress: this._restoreInFlight,
990
+ ...(this._lastRestoreEvent ? { lastEvent: this._lastRestoreEvent } : {})
991
+ };
992
+ if (this._restartRequired) {
993
+ return {
994
+ mode: this.recoveryMode,
995
+ profile,
996
+ guardians,
997
+ state: 'restart-required',
998
+ node: null,
999
+ capsules,
1000
+ restore
1001
+ };
1002
+ }
903
1003
  if (this.recoveryMode === 'off') {
904
1004
  return {
905
1005
  mode: 'off',
906
1006
  profile: null,
907
1007
  guardians: [],
908
1008
  state: 'disabled',
909
- node: null
1009
+ node: null,
1010
+ capsules
910
1011
  };
911
1012
  }
912
1013
  if (this._restorePending) {
@@ -916,12 +1017,8 @@ class BeignetNode extends events_1.EventEmitter {
916
1017
  guardians,
917
1018
  state: this._restoreInFlight ? 'restoring' : 'restore-required',
918
1019
  node: null,
919
- restore: {
920
- inProgress: this._restoreInFlight,
921
- ...(this._lastRestoreEvent
922
- ? { lastEvent: this._lastRestoreEvent }
923
- : {})
924
- }
1020
+ capsules,
1021
+ restore
925
1022
  };
926
1023
  }
927
1024
  const node = this.node.getRecoveryStatus();
@@ -930,9 +1027,402 @@ class BeignetNode extends events_1.EventEmitter {
930
1027
  profile,
931
1028
  guardians,
932
1029
  state: node.fenced || node.gate === 'fenced' ? 'fenced' : 'running',
933
- node
1030
+ node,
1031
+ capsules
934
1032
  };
935
1033
  }
1034
+ get restartRequired() {
1035
+ return this._restartRequired;
1036
+ }
1037
+ describeRetrievedCapsules() {
1038
+ let best = null;
1039
+ for (const [fromPeer, c] of this._peerRetrievedCapsules) {
1040
+ if (!best ||
1041
+ c.writerEpoch > best.epoch ||
1042
+ (c.writerEpoch === best.epoch && c.latestSequence > best.sequence) ||
1043
+ (c.writerEpoch === best.epoch &&
1044
+ c.latestSequence === best.sequence &&
1045
+ c.inline &&
1046
+ !best.inline)) {
1047
+ best = {
1048
+ epoch: c.writerEpoch,
1049
+ sequence: c.latestSequence,
1050
+ writerEpoch: c.writerEpoch.toString(),
1051
+ latestSequence: c.latestSequence.toString(),
1052
+ inline: c.inline,
1053
+ channelCount: c.channelCount,
1054
+ guardians: redactGuardians(c.guardians),
1055
+ fromPeer,
1056
+ receivedAt: c.receivedAt
1057
+ };
1058
+ }
1059
+ }
1060
+ if (!best)
1061
+ return { candidates: 0, best: null };
1062
+ const { epoch: _e, sequence: _s, ...rest } = best;
1063
+ return { candidates: this._peerRetrievedCapsules.size, best: rest };
1064
+ }
1065
+ revealCapsuleGuardians() {
1066
+ const best = this.describeRetrievedCapsules().best;
1067
+ if (!best) {
1068
+ throw new errors_1.BeignetError('CAPSULE_RESTORE_NO_CANDIDATES', 'No storage peer has returned a Recovery Capsule this session. ' +
1069
+ 'Connect to the peers this node had channels with and retry.');
1070
+ }
1071
+ const held = this._peerRetrievedCapsules.get(best.fromPeer);
1072
+ return {
1073
+ fromPeer: best.fromPeer,
1074
+ head: {
1075
+ writerEpoch: best.writerEpoch,
1076
+ latestSequence: best.latestSequence
1077
+ },
1078
+ guardians: held.guardians,
1079
+ entries: held.guardians.map((g) => ({
1080
+ guardianId: g.guardianId,
1081
+ url: g.transports[0].url,
1082
+ ...(g.auth ? { auth: g.auth } : {})
1083
+ }))
1084
+ };
1085
+ }
1086
+ async restoreFromCapsules(options = {}) {
1087
+ if (this._restoreInFlight) {
1088
+ throw new errors_1.BeignetError('RESTORE_IN_PROGRESS', 'A restore is already running; poll the status route.');
1089
+ }
1090
+ if (options.unfenced !== undefined &&
1091
+ typeof options.unfenced !== 'boolean') {
1092
+ throw new errors_1.BeignetError('INVALID_PARAMS', 'unfenced must be a boolean');
1093
+ }
1094
+ const unfencedRequested = options.unfenced === true;
1095
+ if (this.recoveryMode !== 'peer-storage' || this._restorePending) {
1096
+ throw new errors_1.BeignetError('CAPSULE_RESTORE_UNSUPPORTED', 'Capsule restore applies to peer-storage mode only. Guardian ' +
1097
+ 'modes restore through the guardian set (the /recovery/restore ' +
1098
+ 'route); with recovery off there is no journal to restore, so ' +
1099
+ 'use the SCB a peer returned (/backup/peer-retrieved) with the ' +
1100
+ 'SCB restore route instead.');
1101
+ }
1102
+ if (this._peerRetrievedCapsules.size === 0) {
1103
+ throw new errors_1.BeignetError('CAPSULE_RESTORE_NO_CANDIDATES', 'No storage peer has returned a Recovery Capsule this session. ' +
1104
+ 'Connect to the peers this node had channels with and retry.');
1105
+ }
1106
+ try {
1107
+ (0, recovery_1.assertEmptyTarget)(this.storage);
1108
+ }
1109
+ catch (err) {
1110
+ throw new errors_1.BeignetError('CAPSULE_RESTORE_TARGET_DIRTY', 'This database already holds channel, payment or invoice state ' +
1111
+ 'that a restore would discard; run the restore from a fresh ' +
1112
+ 'data directory (' +
1113
+ (err instanceof Error ? err.message : String(err)) +
1114
+ ')');
1115
+ }
1116
+ this._restoreInFlight = true;
1117
+ const progress = (type, detail) => {
1118
+ this._lastRestoreEvent = { type, detail };
1119
+ this.log('info', `Capsule restore: ${type}`, { detail });
1120
+ this.emit('recovery:restore-progress', { type, detail });
1121
+ };
1122
+ const stagedPath = path.join(this.dataDir, `${this.networkName}.db.capsule-restore`);
1123
+ const dropStaged = () => {
1124
+ for (const suffix of ['', '-wal', '-shm']) {
1125
+ try {
1126
+ fs.unlinkSync(`${stagedPath}${suffix}`);
1127
+ }
1128
+ catch {
1129
+ }
1130
+ }
1131
+ };
1132
+ try {
1133
+ const blobs = [...this._peerRetrievedCapsules.values()].map((c) => c.blob);
1134
+ progress('capsule:selecting', `${blobs.length} candidate capsule(s) from storage peers`);
1135
+ dropStaged();
1136
+ const installFailure = (err) => err instanceof recovery_1.CapsuleCandidateError
1137
+ ? new errors_1.BeignetError('CAPSULE_RESTORE_FAILED', `No retrieved capsule could be restored: ${err.message}`)
1138
+ : new errors_1.BeignetError('CAPSULE_RESTORE_INSTALL_FAILED', `Could not write the restored database: ${err instanceof Error ? err.message : String(err)}`);
1139
+ let staged;
1140
+ try {
1141
+ staged = new sqlite_storage_1.SqliteStorage(stagedPath, () => {
1142
+ }, this._storageEncryptionKey
1143
+ ? { encryptionKey: this._storageEncryptionKey }
1144
+ : undefined);
1145
+ staged.open();
1146
+ }
1147
+ catch (err) {
1148
+ dropStaged();
1149
+ throw installFailure(err);
1150
+ }
1151
+ let result;
1152
+ try {
1153
+ result = (0, recovery_1.restoreBestRecoveryCapsule)(blobs, staged, this.nodeSecret(), {
1154
+ scratchStorage: () => {
1155
+ const scratch = new sqlite_storage_1.SqliteStorage(':memory:');
1156
+ scratch.open();
1157
+ return scratch;
1158
+ }
1159
+ });
1160
+ const expectedNetwork = this.toLnNetwork(this.networkName);
1161
+ if (result.scb.network !== expectedNetwork) {
1162
+ throw new recovery_1.CapsuleCandidateError(`capsule SCB network "${result.scb.network}" does not match ` +
1163
+ `this node's network "${expectedNetwork}"`);
1164
+ }
1165
+ }
1166
+ catch (err) {
1167
+ staged.close();
1168
+ dropStaged();
1169
+ throw installFailure(err);
1170
+ }
1171
+ const head = {
1172
+ writerEpoch: result.capsule.writerEpoch.toString(),
1173
+ latestSequence: result.capsule.latestSequence.toString()
1174
+ };
1175
+ const newestSeenHead = {
1176
+ writerEpoch: result.newestSeenHead.writerEpoch.toString(),
1177
+ latestSequence: result.newestSeenHead.latestSequence.toString()
1178
+ };
1179
+ if (result.tier === 2 &&
1180
+ (0, recovery_1.chainPromisedQuorum)(staged, (0, recovery_1.deriveRecoveryMasterKey)(this.nodeSecret()), (0, ecdh_1.getPublicKey)(this.nodeSecret()))) {
1181
+ staged.close();
1182
+ dropStaged();
1183
+ throw new errors_1.BeignetError('CAPSULE_RESTORE_QUORUM_NAMESPACE', 'The best capsule carries a quorum-durability journal, which ' +
1184
+ 'cannot boot without its guardian quorum even unfenced; ' +
1185
+ 'restore through the guardian set, or recover the channels ' +
1186
+ 'from the peer-retrieved backup with the SCB restore route.');
1187
+ }
1188
+ let unfenced;
1189
+ if (result.capsule.guardians.length > 0) {
1190
+ const locators = redactGuardians(result.capsule.guardians);
1191
+ if (!unfencedRequested) {
1192
+ staged.close();
1193
+ dropStaged();
1194
+ this.log('warn', 'Capsule restore refused: capsule names a guardian set', {
1195
+ guardians: locators
1196
+ });
1197
+ throw new errors_1.BeignetError('CAPSULE_RESTORE_GUARDIAN_BACKED', `The best capsule names ${locators.length} guardian(s): its state ` +
1198
+ 'belongs to a guardian-backed namespace, which restores through ' +
1199
+ 'the guardian set with fencing, not from peer storage. Restart ' +
1200
+ 'in that recovery mode with the guardians the capsule names ' +
1201
+ '(capsules.best.guardians on the status route, or the ' +
1202
+ 'capsule-guardians handoff when they need credentials); for an ' +
1203
+ 'emergency SCB-only recovery use the peer-retrieved backup with ' +
1204
+ 'the SCB restore route. An operator whose guardian set is gone ' +
1205
+ 'can pass unfenced: true, which cannot fence the old writer.');
1206
+ }
1207
+ unfenced = { guardians: locators };
1208
+ this.log('warn', 'UNFENCED capsule restore: the capsule names a guardian set and ' +
1209
+ 'the previous writer is not fenced; if it still runs, it keeps ' +
1210
+ 'acting on these channels', { guardians: locators });
1211
+ progress('capsule:unfenced', `restoring a guardian-backed capsule without fencing: the previous writer is NOT fenced (${locators.length} guardian(s) named)`);
1212
+ }
1213
+ const common = {
1214
+ channelCount: result.scb.channels.length,
1215
+ framesApplied: result.framesApplied,
1216
+ head,
1217
+ newestSeenHead,
1218
+ rejectedCandidates: result.rejectedCandidates,
1219
+ ...(unfenced ? { unfenced } : {})
1220
+ };
1221
+ if (result.tier === 1) {
1222
+ staged.close();
1223
+ dropStaged();
1224
+ progress('capsule:tier1', `no inline journal validated; recovering ${common.channelCount} channel(s) from the embedded SCB`);
1225
+ const { recovering, skipped } = await this.node.recoverFromStaticChannelBackup(result.scb.channels);
1226
+ const info = {
1227
+ tier: 1,
1228
+ ...common,
1229
+ restartRequired: false,
1230
+ recovering,
1231
+ skipped
1232
+ };
1233
+ progress('restore:complete', `Tier 1: ${recovering.length} channel(s) recovering, ${skipped.length} skipped`);
1234
+ this.emit('recovery:restored', {
1235
+ exact: false,
1236
+ framesApplied: 0,
1237
+ guardiansRepaired: 0,
1238
+ epoch: head.writerEpoch,
1239
+ tier: 1,
1240
+ restartRequired: false
1241
+ });
1242
+ return info;
1243
+ }
1244
+ try {
1245
+ this.carryDaemonState(this.storage, staged);
1246
+ }
1247
+ catch (err) {
1248
+ staged.close();
1249
+ dropStaged();
1250
+ throw installFailure(err);
1251
+ }
1252
+ staged.close();
1253
+ progress('capsule:installed', `Tier 2: ${result.framesApplied} frame(s) replayed at epoch ${head.writerEpoch} sequence ${head.latestSequence}`);
1254
+ const dbPath = path.join(this.dataDir, `${this.networkName}.db`);
1255
+ const keptPath = `${dbPath}.pre-capsule-restore-${new Date()
1256
+ .toISOString()
1257
+ .replace(/[:.]/g, '-')}`;
1258
+ const marker = {
1259
+ version: 1,
1260
+ stagedAt: Date.now(),
1261
+ staged: path.basename(stagedPath),
1262
+ keep: path.basename(keptPath),
1263
+ head,
1264
+ tier: 2
1265
+ };
1266
+ try {
1267
+ writeFileAtomic(this.capsuleRestoreMarkerPath(), JSON.stringify(marker));
1268
+ }
1269
+ catch (err) {
1270
+ dropStaged();
1271
+ throw installFailure(err);
1272
+ }
1273
+ this._restartRequired = true;
1274
+ this.teardownNodeForRestart();
1275
+ try {
1276
+ this.finishStagedCapsuleRestore(dbPath);
1277
+ }
1278
+ catch (err) {
1279
+ this.log('error', 'Capsule restore swap did not complete', {
1280
+ error: err instanceof Error ? err.message : String(err)
1281
+ });
1282
+ throw new errors_1.BeignetError('CAPSULE_RESTORE_INSTALL_FAILED', 'The restored database was written but the file swap did not ' +
1283
+ 'complete; restart the daemon, which finishes the install (' +
1284
+ (err instanceof Error ? err.message : String(err)) +
1285
+ ')');
1286
+ }
1287
+ const info = {
1288
+ tier: 2,
1289
+ ...common,
1290
+ restartRequired: true
1291
+ };
1292
+ progress('restore:complete', `restored database installed (previous kept at ${path.basename(keptPath)}); restart the daemon to resume ${common.channelCount} channel(s)`);
1293
+ this.emit('recovery:restored', {
1294
+ exact: true,
1295
+ framesApplied: result.framesApplied,
1296
+ guardiansRepaired: 0,
1297
+ epoch: head.writerEpoch,
1298
+ tier: 2,
1299
+ restartRequired: true
1300
+ });
1301
+ return info;
1302
+ }
1303
+ finally {
1304
+ this._restoreInFlight = false;
1305
+ }
1306
+ }
1307
+ teardownNodeForRestart() {
1308
+ if (this.backupTimer) {
1309
+ clearInterval(this.backupTimer);
1310
+ this.backupTimer = undefined;
1311
+ }
1312
+ if (this._sweepRefreshTimer) {
1313
+ clearInterval(this._sweepRefreshTimer);
1314
+ this._sweepRefreshTimer = undefined;
1315
+ }
1316
+ if (this._fallbackRecoveryTimer) {
1317
+ clearInterval(this._fallbackRecoveryTimer);
1318
+ this._fallbackRecoveryTimer = undefined;
1319
+ }
1320
+ this.paymentQueue?.removeAllListeners();
1321
+ this.node.destroy();
1322
+ void this.wallet?.stop().catch(() => {
1323
+ });
1324
+ }
1325
+ capsuleRestoreMarkerPath() {
1326
+ return path.join(this.dataDir, `${this.networkName}.capsule-restore.json`);
1327
+ }
1328
+ carryDaemonState(from, to) {
1329
+ const overrides = from.loadWalletData(auth_1.AUTH_KEY_OVERRIDES_STORAGE_KEY);
1330
+ if (overrides !== null) {
1331
+ to.saveWalletData(auth_1.AUTH_KEY_OVERRIDES_STORAGE_KEY, overrides);
1332
+ }
1333
+ try {
1334
+ for (const hook of from.loadAllWebhooks()) {
1335
+ to.saveWebhook(hook.id, hook.url, hook.events, hook.secretHash, hook.createdAt);
1336
+ }
1337
+ }
1338
+ catch (err) {
1339
+ this.log('warn', 'Could not carry webhooks into the restore', {
1340
+ error: err instanceof Error ? err.message : String(err)
1341
+ });
1342
+ }
1343
+ try {
1344
+ for (const peer of from.loadAllPeerAddresses()) {
1345
+ to.savePeerAddress(peer.pubkey, peer.host, peer.port);
1346
+ }
1347
+ }
1348
+ catch (err) {
1349
+ this.log('warn', 'Could not carry peer addresses into the restore', {
1350
+ error: err instanceof Error ? err.message : String(err)
1351
+ });
1352
+ }
1353
+ }
1354
+ finishStagedCapsuleRestore(dbPath) {
1355
+ const markerPath = this.capsuleRestoreMarkerPath();
1356
+ if (!fs.existsSync(markerPath))
1357
+ return;
1358
+ let marker;
1359
+ try {
1360
+ marker = JSON.parse(fs.readFileSync(markerPath, 'utf8'));
1361
+ if (marker.version !== 1 ||
1362
+ typeof marker.staged !== 'string' ||
1363
+ typeof marker.keep !== 'string') {
1364
+ throw new Error('unrecognized marker shape');
1365
+ }
1366
+ }
1367
+ catch (err) {
1368
+ throw new errors_1.BeignetError('CAPSULE_RESTORE_INSTALL_FAILED', `Unreadable capsule restore marker at ${markerPath} (${err instanceof Error ? err.message : String(err)}); inspect the data directory before starting`);
1369
+ }
1370
+ const stagedPath = path.join(this.dataDir, marker.staged);
1371
+ const keptPath = path.join(this.dataDir, marker.keep);
1372
+ const sidecars = ['-wal', '-shm'];
1373
+ const moveAside = () => {
1374
+ const current = new sqlite_storage_1.SqliteStorage(dbPath, () => {
1375
+ }, this._storageEncryptionKey
1376
+ ? { encryptionKey: this._storageEncryptionKey }
1377
+ : undefined);
1378
+ current.open();
1379
+ try {
1380
+ (0, recovery_1.assertEmptyTarget)(current);
1381
+ }
1382
+ finally {
1383
+ current.close();
1384
+ }
1385
+ fs.renameSync(dbPath, keptPath);
1386
+ for (const suffix of sidecars) {
1387
+ try {
1388
+ fs.renameSync(`${dbPath}${suffix}`, `${keptPath}${suffix}`);
1389
+ }
1390
+ catch {
1391
+ }
1392
+ }
1393
+ };
1394
+ const stagedExists = fs.existsSync(stagedPath);
1395
+ const dbExists = fs.existsSync(dbPath);
1396
+ if (stagedExists) {
1397
+ if (dbExists)
1398
+ moveAside();
1399
+ for (const suffix of sidecars) {
1400
+ try {
1401
+ fs.unlinkSync(`${stagedPath}${suffix}`);
1402
+ }
1403
+ catch {
1404
+ }
1405
+ }
1406
+ fs.renameSync(stagedPath, dbPath);
1407
+ this.log('info', 'Capsule restore installed', {
1408
+ head: marker.head,
1409
+ previous: marker.keep
1410
+ });
1411
+ }
1412
+ else if (!dbExists) {
1413
+ fs.unlinkSync(markerPath);
1414
+ throw new errors_1.BeignetError('CAPSULE_RESTORE_INSTALL_FAILED', `Capsule restore marker found but neither ${marker.staged} nor ` +
1415
+ `the database exists; the previous database may be at ` +
1416
+ `${marker.keep}. Inspect the data directory before starting`);
1417
+ }
1418
+ fs.unlinkSync(markerPath);
1419
+ }
1420
+ nodeSecret() {
1421
+ if (!this._nodeSecret) {
1422
+ this._nodeSecret = (0, wallet_keys_1.deriveLightningKeysFromMnemonic)(this.mnemonic, undefined, this.toCoinType(this.networkName)).nodePrivateKey;
1423
+ }
1424
+ return this._nodeSecret;
1425
+ }
936
1426
  async restoreFromGuardians() {
937
1427
  if (this._restoreInFlight) {
938
1428
  throw new errors_1.BeignetError('RESTORE_IN_PROGRESS', 'A guardian restore is already running; watch ' +
@@ -3404,6 +3894,7 @@ class BeignetNode extends events_1.EventEmitter {
3404
3894
  ? 'Channel balances are healthy'
3405
3895
  : `All ${readyChannels.length} channel(s) are >90% depleted in one direction`
3406
3896
  });
3897
+ checks.push(this.channelBackupReadiness());
3407
3898
  let score = 100;
3408
3899
  let hasCriticalFailure = false;
3409
3900
  for (const check of checks) {
@@ -3425,6 +3916,65 @@ class BeignetNode extends events_1.EventEmitter {
3425
3916
  checks
3426
3917
  };
3427
3918
  }
3919
+ channelBackupReadiness() {
3920
+ const name = 'CHANNEL_BACKUP';
3921
+ const mode = this.recoveryMode;
3922
+ if (mode === 'off') {
3923
+ return {
3924
+ name,
3925
+ status: 'WARN',
3926
+ severity: 'WARNING',
3927
+ message: 'No recovery mode: a lost device can only force-close its ' +
3928
+ 'channels from the SCB (set BEIGNET_RECOVERY_MODE)'
3929
+ };
3930
+ }
3931
+ const status = this.node.getRecoveryStatus();
3932
+ if (status.fenced || status.gate === 'fenced') {
3933
+ return {
3934
+ name,
3935
+ status: 'FAIL',
3936
+ severity: 'CRITICAL',
3937
+ message: 'Another device owns this recovery namespace; this node is ' +
3938
+ 'fenced and must not operate'
3939
+ };
3940
+ }
3941
+ if (status.backfillLost) {
3942
+ return {
3943
+ name,
3944
+ status: 'FAIL',
3945
+ severity: 'CRITICAL',
3946
+ message: 'This recovery namespace lost its guardian backfill; no ' +
3947
+ 'further channel state can be proven durable'
3948
+ };
3949
+ }
3950
+ if (mode === 'peer-storage') {
3951
+ return {
3952
+ name,
3953
+ status: 'WARN',
3954
+ severity: 'WARNING',
3955
+ message: 'Recovery capsules go to storage peers only (Tier 2 restore ' +
3956
+ 'via /recovery/restore-capsule); no guardian quorum, so no ' +
3957
+ 'fencing between devices'
3958
+ };
3959
+ }
3960
+ if (status.gate !== 'confirmed') {
3961
+ return {
3962
+ name,
3963
+ status: 'WARN',
3964
+ severity: 'WARNING',
3965
+ message: `Guardian mode ${mode}: the quorum has not confirmed this ` +
3966
+ 'lease yet; channels stay quarantined'
3967
+ };
3968
+ }
3969
+ return {
3970
+ name,
3971
+ status: 'PASS',
3972
+ severity: 'WARNING',
3973
+ message: `Channel state replicated to a guardian quorum (${mode}, durable ` +
3974
+ `through ${status.lastDurableSequence}); a lost device resumes ` +
3975
+ 'its channels'
3976
+ };
3977
+ }
3428
3978
  getLiquiditySnapshot() {
3429
3979
  const snapshot = this.node.getLiquiditySnapshot();
3430
3980
  let reserveMsat = 0n;
@@ -3954,15 +4504,21 @@ class BeignetNode extends events_1.EventEmitter {
3954
4504
  return { recovering, skipped, channelCount: backup.channels.length };
3955
4505
  }
3956
4506
  refreshStaticChannelBackup() {
3957
- if (this.destroyed)
4507
+ if (this.destroyed || this._restartRequired)
3958
4508
  return;
3959
4509
  try {
3960
- const { encoded } = this.exportStaticChannelBackup();
3961
- if (this.peerStorageEnabled) {
3962
- const sent = this.node.distributePeerStorage(Buffer.from(encoded, 'utf8'));
3963
- if (sent > 0) {
3964
- this.log('debug', 'Pushed SCB to peer storage', { peers: sent });
3965
- }
4510
+ const { encoded, channelCount } = this.exportStaticChannelBackup();
4511
+ if (!this.peerStorageEnabled)
4512
+ return;
4513
+ if (this.recoveryNodeConfig?.enabled)
4514
+ return;
4515
+ if (channelCount === 0 && !this._pushedChannelBackup)
4516
+ return;
4517
+ const sent = this.node.distributePeerStorage(Buffer.from(encoded, 'utf8'));
4518
+ if (channelCount > 0)
4519
+ this._pushedChannelBackup = true;
4520
+ if (sent > 0) {
4521
+ this.log('debug', 'Pushed SCB to peer storage', { peers: sent });
3966
4522
  }
3967
4523
  }
3968
4524
  catch (err) {
@@ -3972,6 +4528,66 @@ class BeignetNode extends events_1.EventEmitter {
3972
4528
  }
3973
4529
  }
3974
4530
  handleRetrievedPeerStorage(peerPubkey, blob) {
4531
+ const capsule = (0, recovery_1.decodeRecoveryCapsuleBlob)(blob, this.nodeSecret());
4532
+ if (capsule) {
4533
+ let embedded;
4534
+ try {
4535
+ embedded = (0, scb_1.decodeScb)(capsule.encryptedScb, this.nodeSecret());
4536
+ }
4537
+ catch {
4538
+ this.log('debug', 'Ignoring capsule whose embedded SCB does not decode', {
4539
+ fromPeer: peerPubkey
4540
+ });
4541
+ return;
4542
+ }
4543
+ if (!this.retrievedBackupMatchesNetwork(embedded, peerPubkey, 'capsule')) {
4544
+ return;
4545
+ }
4546
+ const held = this._peerRetrievedCapsules.get(peerPubkey);
4547
+ const inline = capsule.inlineRecoveryState !== undefined;
4548
+ if (!held ||
4549
+ capsule.writerEpoch > held.writerEpoch ||
4550
+ (capsule.writerEpoch === held.writerEpoch &&
4551
+ capsule.latestSequence > held.latestSequence) ||
4552
+ (capsule.writerEpoch === held.writerEpoch &&
4553
+ capsule.latestSequence === held.latestSequence &&
4554
+ (inline || !held.inline))) {
4555
+ this._peerRetrievedCapsules.set(peerPubkey, {
4556
+ blob: Buffer.from(blob),
4557
+ writerEpoch: capsule.writerEpoch,
4558
+ latestSequence: capsule.latestSequence,
4559
+ inline,
4560
+ channelCount: embedded.channels.length,
4561
+ guardians: capsule.guardians,
4562
+ receivedAt: Date.now()
4563
+ });
4564
+ this.log('info', 'Recovered capsule from peer storage', {
4565
+ fromPeer: peerPubkey,
4566
+ writerEpoch: capsule.writerEpoch.toString(),
4567
+ latestSequence: capsule.latestSequence.toString(),
4568
+ inline,
4569
+ channelCount: embedded.channels.length,
4570
+ guardians: capsule.guardians.length
4571
+ });
4572
+ if (this.recoveryGuardianSet.length > 0 &&
4573
+ !sameGuardianIds(capsule.guardians.map((g) => g.guardianId), this.recoveryGuardianSet.map((g) => g.guardianId.toString('hex'))) &&
4574
+ (!held ||
4575
+ !sameGuardianIds(capsule.guardians.map((g) => g.guardianId), held.guardians.map((g) => g.guardianId)))) {
4576
+ this.log('warn', 'Retrieved capsule names a guardian set that differs from ' +
4577
+ 'the configured one; the configured set stays in force', {
4578
+ fromPeer: peerPubkey,
4579
+ capsule: redactGuardians(capsule.guardians),
4580
+ configured: this.recoveryGuardianSet.map((g) => ({
4581
+ guardianId: g.guardianId.toString('hex'),
4582
+ url: g.url
4583
+ }))
4584
+ });
4585
+ }
4586
+ }
4587
+ const seed = bip39.mnemonicToSeedSync(this.mnemonic);
4588
+ this.offerRetrievedScb((0, scb_1.encodeScb)(embedded, seed), embedded, peerPubkey, 'capsule');
4589
+ return;
4590
+ }
3975
4591
  let backup;
3976
4592
  const encoded = blob.toString('utf8');
3977
4593
  try {
@@ -3984,17 +4600,42 @@ class BeignetNode extends events_1.EventEmitter {
3984
4600
  });
3985
4601
  return;
3986
4602
  }
3987
- if (this._peerRetrievedScb &&
3988
- this._peerRetrievedScb.createdAt >= backup.createdAt) {
4603
+ if (!this.retrievedBackupMatchesNetwork(backup, peerPubkey, 'scb'))
3989
4604
  return;
4605
+ this.offerRetrievedScb(encoded, backup, peerPubkey, 'scb');
4606
+ }
4607
+ retrievedBackupMatchesNetwork(backup, fromPeer, source) {
4608
+ const expected = this.toLnNetwork(this.networkName);
4609
+ if (backup.network === expected)
4610
+ return true;
4611
+ this.log('debug', 'Ignoring peer storage backup from another network', {
4612
+ fromPeer,
4613
+ source,
4614
+ network: backup.network,
4615
+ expected
4616
+ });
4617
+ return false;
4618
+ }
4619
+ offerRetrievedScb(encoded, backup, fromPeer, source) {
4620
+ const held = this._peerRetrievedScb;
4621
+ if (held) {
4622
+ if (backup.channels.length === 0 && held.channelCount > 0)
4623
+ return;
4624
+ if (held.createdAt >= backup.createdAt &&
4625
+ !(held.channelCount === 0 && backup.channels.length > 0)) {
4626
+ return;
4627
+ }
3990
4628
  }
3991
4629
  this._peerRetrievedScb = {
3992
4630
  encoded,
3993
4631
  createdAt: backup.createdAt,
3994
- fromPeer: peerPubkey
4632
+ fromPeer,
4633
+ channelCount: backup.channels.length,
4634
+ source
3995
4635
  };
3996
4636
  this.log('info', 'Recovered SCB from peer storage', {
3997
- fromPeer: peerPubkey,
4637
+ fromPeer,
4638
+ source,
3998
4639
  createdAt: backup.createdAt,
3999
4640
  channelCount: backup.channels.length
4000
4641
  });
@@ -4036,6 +4677,7 @@ class BeignetNode extends events_1.EventEmitter {
4036
4677
  clearTimeout(this._confirmTimer);
4037
4678
  this._confirmTimer = undefined;
4038
4679
  }
4680
+ this.stopRecoveryLeaseCheck();
4039
4681
  if (this._backupPromise) {
4040
4682
  await this._backupPromise.catch(() => {
4041
4683
  });
@@ -4070,6 +4712,7 @@ class BeignetNode extends events_1.EventEmitter {
4070
4712
  clearTimeout(this._confirmTimer);
4071
4713
  this._confirmTimer = undefined;
4072
4714
  }
4715
+ this.stopRecoveryLeaseCheck();
4073
4716
  this.paymentQueue?.removeAllListeners();
4074
4717
  this.node?.destroy();
4075
4718
  this.storage.close();
@@ -4139,4 +4782,6 @@ class BeignetNode extends events_1.EventEmitter {
4139
4782
  }
4140
4783
  exports.BeignetNode = BeignetNode;
4141
4784
  BeignetNode.INITIAL_GOSSIP_PRIME_TIMEOUT_MS = 90000;
4785
+ BeignetNode.DEFAULT_LEASE_CHECK_INTERVAL_MS = 5 * 60000;
4786
+ BeignetNode.MAX_TIMER_MS = 2147483647;
4142
4787
  //# sourceMappingURL=beignet-node.js.map