${plans.length > 0 ? 'Select a plan above to see live pricing.' : 'You don’t have a plan yet.'}
${plans.length > 0
@@ -12758,9 +13019,13 @@
if (d2.granted > 0) {
_autoGrantSessionCount += d2.granted;
toast(`Auto-granted ${d2.granted} new subscriber${d2.granted > 1 ? 's' : ''} (${_autoGrantSessionCount} this session)`, 'success');
- invalidateCache('/api/feegrant/grants');
invalidateCache('/api/wallet');
- renderFeeGrants();
+ // New grants index a block or two later — reconcile after the window.
+ optimisticThenReconcile('/api/feegrant/grants', {
+ mutate: () => {},
+ render: () => { if (S.page === 'fee-grants') renderFeeGrants(); },
+ page: 'fee-grants',
+ });
}
}
} catch (err) {
@@ -13018,10 +13283,17 @@
if (d.granted > 0) _autoGrantSessionCount += d.granted;
toast(`Granted: ${d.granted}, skipped: ${d.skipped || 0}${d.errors ? ', errors: ' + d.errors.length : ''}`, d.errors ? 'error' : 'success');
if (d.granted > 0) {
- invalidateCache('/api/feegrant/grants');
invalidateCache('/api/wallet');
+ // New grants take a block or two to index — reconcile after the window so
+ // the list reflects them instead of showing the pre-grant snapshot.
+ optimisticThenReconcile('/api/feegrant/grants', {
+ mutate: () => {},
+ render: () => { if (S.page === 'fee-grants') renderFeeGrants(); },
+ page: 'fee-grants',
+ });
+ } else if (S.page === 'fee-grants') {
+ renderFeeGrants();
}
- if (S.page === 'fee-grants') renderFeeGrants();
} catch (e) {
toast('Grant failed: ' + e.message, 'error');
}
@@ -13041,9 +13313,14 @@
if (d.error) throw new Error(d.error);
toast(`Fee grant issued! TX: ${d.txHash?.slice(0, 12)}...`, 'success');
document.getElementById('fgSingleAddr').value = '';
- invalidateCache('/api/feegrant/grants');
invalidateCache('/api/wallet');
- if (S.page === 'fee-grants') renderFeeGrants();
+ // New grant indexes a block or two after broadcast — reconcile after the
+ // window rather than refetching immediately into a pre-grant snapshot.
+ optimisticThenReconcile('/api/feegrant/grants', {
+ mutate: () => {},
+ render: () => { if (S.page === 'fee-grants') renderFeeGrants(); },
+ page: 'fee-grants',
+ });
} catch (e) {
toast('Grant failed: ' + e.message, 'error');
}
@@ -13066,9 +13343,14 @@
});
if (d.error) throw new Error(d.error);
toast(`Grant revoked! TX: ${d.txHash?.slice(0, 12)}...`, 'success');
- invalidateCache('/api/feegrant/grants');
invalidateCache('/api/wallet');
- if (S.page === 'fee-grants') renderFeeGrants();
+ // Optimistically drop the row so it doesn't re-appear (chain still lists the
+ // revoked grant for a block or two); reconcile after the indexing window.
+ optimisticThenReconcile('/api/feegrant/grants', {
+ mutate: (data) => { if (Array.isArray(data.allowances)) data.allowances = data.allowances.filter(g => g.grantee !== grantee); },
+ render: () => { if (S.page === 'fee-grants') renderFeeGrants(); },
+ page: 'fee-grants',
+ });
} catch (e) {
toast('Revoke failed: ' + e.message, 'error');
}
@@ -13092,9 +13374,12 @@
});
if (d.error) throw new Error(d.error);
toast(`Revoked ${d.revoked} grants${d.errors ? ', errors: ' + d.errors.length : ''}`, d.errors ? 'error' : 'success');
- invalidateCache('/api/feegrant/grants');
invalidateCache('/api/wallet');
- if (S.page === 'fee-grants') renderFeeGrants();
+ optimisticThenReconcile('/api/feegrant/grants', {
+ mutate: (data) => { data.allowances = []; },
+ render: () => { if (S.page === 'fee-grants') renderFeeGrants(); },
+ page: 'fee-grants',
+ });
} catch (e) {
toast('Revoke failed: ' + e.message, 'error');
} finally {
@@ -13130,12 +13415,18 @@
if (d.errors && d.errors.length) parts.push(`${d.errors.length} failed`);
const variant = d.errors && d.errors.length ? 'error' : 'success';
toast(`Cleanup: ${parts.join(' · ') || 'nothing to do'}`, variant);
+ // Only invalidate/reconcile on success — running this on TX failure wiped a
+ // healthy cache and forced a needless cold refetch (Finding 7 logic bug).
+ invalidateCache('/api/wallet');
+ const revoked = new Set(grantees);
+ optimisticThenReconcile('/api/feegrant/grants', {
+ mutate: (data) => { if (Array.isArray(data.allowances)) data.allowances = data.allowances.filter(g => !revoked.has(g.grantee)); },
+ render: () => { if (S.page === 'fee-grants') renderFeeGrants(); },
+ page: 'fee-grants',
+ });
} catch (e) {
toast('Cleanup failed: ' + e.message, 'error');
}
- invalidateCache('/api/feegrant/grants');
- invalidateCache('/api/wallet');
- if (S.page === 'fee-grants') renderFeeGrants();
}
// ─── Coming Soon placeholder ─────────────────────────────────────────────────
diff --git a/server.js b/server.js
index ed65331..521c163 100644
--- a/server.js
+++ b/server.js
@@ -99,6 +99,21 @@ function invalidatePlanSubs(planId) {
_planSubsKeys.delete(id);
}
+// Drop the operator-scoped read caches that derive from a plan's subscriber set
+// (members list, own-subscription banner, plan stats, unique-wallet count) so
+// they reflect a just-committed subscribe / add-subscriber instead of serving a
+// pre-mutation snapshot for up to the 30-120s TTL. Keyed by getAddr() at call
+// time, matching how those caches were written.
+function invalidateSubscriberCaches(planId) {
+ const id = Number(planId);
+ if (!Number.isFinite(id)) return;
+ const op = getAddr() || '_anon';
+ cacheInvalidate(`planMembers:${id}:${op}`);
+ cacheInvalidate(`ownSub:${id}:${op}`);
+ cacheInvalidate(`planStats:${id}:${op}`);
+ cacheInvalidate(`uniqueWallets:${id}`);
+}
+
// ─── Demo Mode ────────────────────────────────────────────────────────────────
// Read-only browse: any visitor sees the UI mounted on a watch-only address
// without supplying a mnemonic. Every TX-broadcasting endpoint returns 403.
@@ -778,7 +793,15 @@ async function discoverPlanIds() {
return [...ids].sort((a, b) => a - b);
}
+// Cached wrapper — this is hit on every GET /api/plans/:id (card expand / detail)
+// and fires a full up-to-10k subscription query each time. Same data, same churn
+// rate as planStats, so share its 120s TTL. Keyed by plan only (the subscriber
+// set is identical regardless of which operator is viewing).
async function getUniqueWallets(planId) {
+ return cached(`uniqueWallets:${planId}`, 120_000, () => _getUniqueWalletsImpl(planId));
+}
+
+async function _getUniqueWalletsImpl(planId) {
const wallets = new Set();
// RPC-first: single protobuf call returns the full set (~912x faster than paginated LCD).
@@ -2804,6 +2827,11 @@ app.post('/api/plan/subscribe', async (req, res) => {
}
resp.subscriptionId = subscriptionId;
console.log(`Subscribed: subscription_id=${subscriptionId} tx=${resp.txHash}`);
+ // Drop the operator-scoped read caches so the own-subscription banner,
+ // members list and subscriber totals reflect the new sub on next fetch
+ // instead of serving the pre-subscribe snapshot for up to 30-120s.
+ invalidatePlanSubs(planId);
+ invalidateSubscriberCaches(planId);
res.json(resp);
} else {
console.log(`Subscribe FAIL: code=${resp.code} ${resp.rawLog}`);
@@ -2971,6 +2999,7 @@ app.post('/api/plan/add-subscriber', async (req, res) => {
const result = await _addSubscriberViaShare(parseInt(planId), address, { denom, allocBytes });
console.log(`Added ${address}: sub=${result.subscriptionId} subTx=${result.subTx} shareTx=${result.shareTx}`);
invalidatePlanSubs(parseInt(planId, 10));
+ invalidateSubscriberCaches(parseInt(planId, 10));
res.json(result);
} catch (err) {
if (relayKeplrSign(err, res)) return;
@@ -3001,6 +3030,7 @@ app.post('/api/plan/add-subscribers', async (req, res) => {
}
}
invalidatePlanSubs(parseInt(planId, 10));
+ invalidateSubscriberCaches(parseInt(planId, 10));
const added = results.filter(r => r.ok).length;
res.json({ ok: added > 0, added, failed: results.length - added, results });
} catch (err) {