aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/ark/manager.js20
-rw-r--r--src/features/ark.js20
-rw-r--r--tools/ark-offline-init.test.js22
3 files changed, 58 insertions, 4 deletions
diff --git a/src/ark/manager.js b/src/ark/manager.js
index df199af..33bac55 100644
--- a/src/ark/manager.js
+++ b/src/ark/manager.js
@@ -208,8 +208,12 @@ export function renewPolicy(tier, network) {
}
export class ArkManager {
- constructor({ account, storage, arkUrl, esploraUrl, network = 'regtest', onUpdate, lnQuoteUrl, statusHint, honorsExpired = false }) {
+ constructor({ account, storage, arkUrl, esploraUrl, network = 'regtest', onUpdate, lnQuoteUrl, statusHint, honorsExpired = false, infoHint = null }) {
this.account = account; // HDKey node; ark keys derived beneath it
+ // The server's last published info, kept by the app: lets the wallet open
+ // when the server can't be reached (see init)
+ this.infoHint = infoHint || null;
+ this.offline = false;
// the server cosigns spends of a coin past its timelock (coinos): such a
// coin stays money here — spendable, receivable — just not exitable
this.honorsExpired = !!honorsExpired;
@@ -275,7 +279,19 @@ export class ArkManager {
seen.add(m.vtxoId);
return true;
});
- this.info = await getArkInfo(this.arkUrl);
+ try {
+ this.info = await getArkInfo(this.arkUrl);
+ this.offline = false;
+ } catch (e) {
+ // The server is unreachable. With its last published info in hand the
+ // wallet still opens on its own records: coins read, a unilateral exit
+ // can start (that needs the chain, not the server), and anything that
+ // does need the server fails when it asks. Without that info there is
+ // no server key to check anything against — then it really is closed.
+ if (!this.infoHint) throw e;
+ this.info = this.infoHint;
+ this.offline = true;
+ }
const hs = await handshake(this.arkUrl).catch(() => null);
this.psa = hs?.psa;
this.serverPub = hex.decode(this.info.serverPubkey);
diff --git a/src/features/ark.js b/src/features/ark.js
index 6952fc0..94dfc09 100644
--- a/src/features/ark.js
+++ b/src/features/ark.js
@@ -494,6 +494,7 @@ export function arkFeature(ctx) {
}
let arkTimer = null;
let arkConnectPromise = null;
+ let arkInitPromise = null; // the manager's own init, apart from the snapshot gate
let arkInitGen = 0; // guards against a stale init() resolving after a wallet switch
function stopArk() {
@@ -514,6 +515,7 @@ export function arkFeature(ctx) {
ark = null;
arkInited = null;
arkConnectPromise = null;
+ arkInitPromise = null;
// The renewal warning describes the coins of the wallet we're leaving.
// Switching accounts used to carry it onto the next one — with the old
// wallet's amount and deadline, over the new wallet's balance — and the
@@ -663,6 +665,9 @@ export function arkFeature(ctx) {
esploraUrl: cfg.esplora,
network: getNetwork(),
honorsExpired: !!cfg.honorsExpired,
+ // the server's last published info: the wallet opens on it when the
+ // server is gone, so coins can still be read and exited
+ infoHint: (cachedArkInfo(cfg.ark) || {}).info || null,
// the wallet's own tx table as a second witness for confirmations —
// its data source is user-configurable and often healthier than the
// ark preset's esplora from this device
@@ -679,9 +684,10 @@ export function arkFeature(ctx) {
const inited = mgr.init().then(() => {
if (gen !== arkInitGen) return;
arkInited = mgr; // readable now; `ark` (and the mailbox) wait for the snapshot
- rememberArkInfo(cfg.ark, mgr.info, mgr._tipH);
+ if (!mgr.offline) rememberArkInfo(cfg.ark, mgr.info, mgr._tipH);
render();
});
+ arkInitPromise = inited;
// The tip: expiries and exit costs are judged against it, and init alone
// never asks for it (the first sync does, after the snapshot gate). Ask
// now, off the connect path, and remember it for the next refresh.
@@ -2938,7 +2944,17 @@ export function arkFeature(ctx) {
async function doArkExit(ids = null) {
ui.arkBusy = 'exit'; ui.arkError = ''; render();
try {
- const mgr = await connectArk();
+ // The manager's own init is enough for an exit — the server may be
+ // gone (that is what a unilateral exit is for), and the snapshot gate
+ // the full connect also waits on may never clear without the relays.
+ let mgr = arkView();
+ if (!mgr) {
+ connectArk().catch(() => {});
+ for (let i = 0; i < 50 && !arkInitPromise; i++) await new Promise((r) => setTimeout(r, 100));
+ try { await arkInitPromise; } catch {}
+ mgr = arkView();
+ }
+ if (!mgr) throw new Error(t('arkNotConnected'));
const spendables = exitableCoins(mgr.state, mgr._tipH || 0).filter((v) => !ids || ids.includes(v.id));
if (!spendables.length) throw new Error(freeCoins(mgr.state).length ? t('arkExitNoneExitable') : t('arkNotConnected'));
// Fees are paid hop by hop from Savings as the exit unrolls — an exit
diff --git a/tools/ark-offline-init.test.js b/tools/ark-offline-init.test.js
new file mode 100644
index 0000000..922682d
--- /dev/null
+++ b/tools/ark-offline-init.test.js
@@ -0,0 +1,22 @@
+// bun test tools/ark-offline-init.test.js — the Ark server gone: with the
+// server's last published info in hand the manager still opens (coins read,
+// exits can start); without it, init fails as before.
+import { test, expect, mock } from 'bun:test';
+import * as proto from '../src/ark/proto.js';
+mock.module('../src/ark/proto.js', () => ({ ...proto,
+ getArkInfo: async () => { throw new Error('fetch failed'); },
+ handshake: async () => { throw new Error('fetch failed'); },
+}));
+const { ArkManager } = await import('../src/ark/manager.js');
+const serverPubkey = '02' + 'ab'.repeat(32);
+const hint = { serverPubkey, vtxoExpiryDelta: 8640, vtxoExitDelta: 144, refreshFees: { baseFeeSat: 300, ppmExpiryTable: [] }, offboardFees: { baseFeeSat: 0, fixedAdditionalVb: 0, ppmExpiryTable: [] }, lnSendFees: {}, lnReceiveFees: {} };
+const mk = (infoHint) => new ArkManager({ account: null, storage: { load: () => ({ v: 1, serverPubkey, vtxos: [], actions: [], movements: [], scheduled: [] }), save() {} }, arkUrl: 'http://x', esploraUrl: 'http://y', network: 'mainnet', infoHint });
+test('with cached info the manager opens offline', async () => {
+ const m = await mk(hint).init();
+ expect(m.offline).toBe(true);
+ expect(m.info.refreshFees.baseFeeSat).toBe(300);
+ expect(Buffer.from(m.serverPub).toString('hex')).toBe(serverPubkey);
+});
+test('without cached info the old failure stands', async () => {
+ await expect(mk(null).init()).rejects.toThrow('fetch failed');
+});