Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ LICENSE
.env.local
.wallet.json
wallet.json
.session-key
privy-wallets.json
auto-grant.json
an.json
my-plans.json
nodes-cache.json
sellers.json
Expand Down
14 changes: 10 additions & 4 deletions public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -7164,20 +7164,20 @@ <h3 id="securityFaqTitle">
if (!mount) return;
_privyMounted = true;

mount.innerHTML = `<div style="padding:14px;color:var(--muted);font-size:13px">Loading email sign-in…</div>`;
mount.innerHTML = `<div style="padding:14px;color:var(--text-muted);font-size:13px">Loading email sign-in…</div>`;

let cfg;
try {
const r = await fetch('/api/wallet/privy-config', { headers: { 'X-Requested-With': 'XMLHttpRequest' } });
cfg = await r.json();
} catch (e) {
mount.innerHTML = `<div style="padding:18px;border:1px dashed rgba(255,80,80,0.35);border-radius:10px;color:var(--muted);font-size:13px">Could not reach the Privy config endpoint: ${e.message}</div>`;
mount.innerHTML = `<div style="padding:18px;border:1px dashed rgba(255,80,80,0.35);border-radius:10px;color:var(--text-muted);font-size:13px">Could not reach the Privy config endpoint: ${escapeHtml(e && e.message || String(e))}</div>`;
return;
}

if (!cfg?.enabled) {
mount.innerHTML = `
<div style="padding:18px;border:1px dashed rgba(255,255,255,0.16);border-radius:10px;font-size:13px;line-height:1.55;color:var(--muted)">
<div style="padding:18px;border:1px dashed rgba(255,255,255,0.16);border-radius:10px;font-size:13px;line-height:1.55;color:var(--text-muted)">
<strong style="color:var(--text)">Email sign-in is not configured on this server.</strong><br><br>
To enable: get a Privy app from
<a href="https://dashboard.privy.io" target="_blank" rel="noopener" style="color:var(--accent)">dashboard.privy.io</a>,
Expand Down Expand Up @@ -7207,7 +7207,7 @@ <h3 id="securityFaqTitle">
Verify and sign in
</button>
</form>
<div id="privyStatus" style="margin-top:10px;color:var(--muted);font-size:12px"></div>
<div id="privyStatus" style="margin-top:10px;color:var(--text-muted);font-size:12px"></div>
`;

const status = $('privyStatus');
Expand Down Expand Up @@ -7531,9 +7531,15 @@ <h3 id="securityFaqTitle">
headers: { 'X-Requested-With': 'XMLHttpRequest', ...(opts.headers || {}) },
});
const data = await res.json().catch(() => ({}));
// Keplr-sign relay is a 200 with a special body — handle before the ok-check.
if (data && data.mode === 'keplr-sign' && data.signDoc) {
return await signAndBroadcast(data.signDoc);
}
// Surface HTTP errors instead of returning {} as if the call succeeded — a
// swallowed 400/500 made callers act on an empty object and fail silently.
if (!res.ok) {
throw new Error((data && data.error) || `Request failed (${res.status})`);
}
return data;
}

Expand Down
78 changes: 60 additions & 18 deletions server.js
Original file line number Diff line number Diff line change
Expand Up @@ -596,6 +596,14 @@ loadNodeCacheFromDisk();
// Always kick a fresh scan on startup so the disk seed is replaced with on-chain truth ASAP.
runNodeScan().catch(err => console.error('Initial node scan failed:', err.message));

// Chain price fields arrive as strings ("1000000") but can be missing or
// malformed; bare parseInt then yields NaN which silently poisons every
// downstream price/total. Coerce to a finite integer, falling back to 0.
function safeInt(v, fallback = 0) {
const n = parseInt(v, 10);
return Number.isFinite(n) ? n : fallback;
}

// Adapter: handles both shapes simultaneously —
// chain catalog (snake_case: gigabyte_prices, remote_url, no country)
// probe-enriched (camelCase: gigabytePrices, remoteUrl, country, city, etc.)
Expand All @@ -608,8 +616,8 @@ function nodeCacheToAllNodes(raw) {
return {
address: n.address,
remoteUrl: n.remoteUrl || n.remote_url || '',
gbPriceUdvpn: gbPrice ? parseInt(gbPrice.quote_value) : 0,
hrPriceUdvpn: hrPrice ? parseInt(hrPrice.quote_value) : 0,
gbPriceUdvpn: gbPrice ? safeInt(gbPrice.quote_value) : 0,
hrPriceUdvpn: hrPrice ? safeInt(hrPrice.quote_value) : 0,
status: 'active',
protocol: n.serviceType || null,
country: n.country || n.location?.country || null,
Expand Down Expand Up @@ -1043,7 +1051,7 @@ async function _getPlanStatsImpl(planId) {

// null quote_value => price unknown (read failed); keep dvpnAmount null so the
// UI shows "—" instead of a fabricated 0. Only a real on-chain value divides.
const quoteNum = price.quote_value != null ? parseInt(price.quote_value) : null;
const quoteNum = price.quote_value != null ? safeInt(price.quote_value, null) : null;
return {
planId,
totalSubscriptions: onchainSubsCount + addedMembers,
Expand All @@ -1062,7 +1070,7 @@ async function _getPlanStatsImpl(planId) {
denom: p.denom,
quoteValue: p.quote_value,
baseValue: p.base_value,
dvpnAmount: p.denom === 'udvpn' ? (parseInt(p.quote_value || '0') / 1e6) : null,
dvpnAmount: p.denom === 'udvpn' ? (safeInt(p.quote_value) / 1e6) : null,
})),
renewalPolicy,
activeSubs: activeSubs + activeAddedMembers,
Expand Down Expand Up @@ -1182,8 +1190,8 @@ async function getAllNodeInfo() {
const hourlyPrice = (n.hourly_prices || []).find(p => p.denom === 'udvpn');
const gbPrice = (n.gigabyte_prices || []).find(p => p.denom === 'udvpn');
nodeMap[n.address] = {
hourlyUdvpn: hourlyPrice ? parseInt(hourlyPrice.quote_value) : 0,
gbUdvpn: gbPrice ? parseInt(gbPrice.quote_value) : 0,
hourlyUdvpn: hourlyPrice ? safeInt(hourlyPrice.quote_value) : 0,
gbUdvpn: gbPrice ? safeInt(gbPrice.quote_value) : 0,
};
}
console.log(`[RPC] getAllNodeInfo: ${Object.keys(nodeMap).length} nodes loaded`);
Expand All @@ -1202,8 +1210,8 @@ async function getAllNodeInfo() {
const hourlyPrice = (n.hourly_prices || []).find(p => p.denom === 'udvpn');
const gbPrice = (n.gigabyte_prices || []).find(p => p.denom === 'udvpn');
nodeMap[n.address] = {
hourlyUdvpn: hourlyPrice ? parseInt(hourlyPrice.quote_value) : 0,
gbUdvpn: gbPrice ? parseInt(gbPrice.quote_value) : 0,
hourlyUdvpn: hourlyPrice ? safeInt(hourlyPrice.quote_value) : 0,
gbUdvpn: gbPrice ? safeInt(gbPrice.quote_value) : 0,
};
}
nextKey = d.pagination?.next_key || null;
Expand Down Expand Up @@ -1280,7 +1288,7 @@ async function buildLeaseMsg(nodeAddress, hours = 24) {
const hp = (nodeInfo.hourly_prices || []).find(p => p.denom === 'udvpn');
if (!hp) throw new Error('Node has no udvpn hourly price');

const totalCost = (parseInt(hp.quote_value) * hours / 1e6).toFixed(1);
const totalCost = (safeInt(hp.quote_value) * hours / 1e6).toFixed(1);
console.log(`[LEASE] Lease msg for ${nodeAddress} ${hours}h (${hp.quote_value} udvpn/hr = ~${totalCost} P2P)`);

return {
Expand Down Expand Up @@ -1889,10 +1897,14 @@ app.post('/api/wallet/privy-login', rateLimit('plogin', 20, 60_000), async (req,
// server-side so frontend-only failures (e.g. keplr.signDirect throwing before
// the broadcast POST fires) show up in server-out.log. Remove once Keplr create
// flow is verified.
app.post('/api/_clientlog', (req, res) => {
// The global CSRF middleware already blocks cross-site callers; rate-limit on
// top so a same-origin page can't flood server logs. Tag is clamped too so a
// caller can't pad each line to the 1500-char data cap.
app.post('/api/_clientlog', rateLimit('clog', 120, 60_000), (req, res) => {
try {
const { tag, data } = req.body || {};
console.log('[clientlog] %s %s', tag || '?', JSON.stringify(data ?? {}).slice(0, 1500));
const safeTag = String(tag ?? '?').slice(0, 80);
console.log('[clientlog] %s %s', safeTag, JSON.stringify(data ?? {}).slice(0, 1500));
} catch (e) {
console.warn('[clientlog] failed:', e.message);
}
Expand Down Expand Up @@ -2964,13 +2976,24 @@ app.post('/api/plan/add-subscriber', async (req, res) => {
try {
const { planId, address, denom, allocBytes } = req.body;
if (!planId) return res.status(400).json({ error: 'planId required' });
if (!address || !String(address).startsWith('sent1')) {
if (!address || !SENT_ADDR_RE.test(String(address).trim())) {
return res.status(400).json({ error: 'valid sent1... address required' });
}
console.log(`Adding subscriber ${address} to plan ${planId} via self-subscribe + share...`);
const result = await _addSubscriberViaShare(parseInt(planId), address, { denom, allocBytes });

const planIdNum = parseInt(planId, 10);
if (!Number.isFinite(planIdNum) || planIdNum <= 0) return res.status(400).json({ error: 'planId must be a positive integer' });

// Ownership gate: _addSubscriberViaShare broadcasts a subscribe TX paid from
// THIS operator's wallet, so reject add-subscriber against a plan we don't own
// — otherwise any authenticated caller could drain our balance via a foreign
// planId.
const ownErr = await assertPlanOwnership(planIdNum);
if (ownErr) return res.status(ownErr.status).json({ error: ownErr.error });

console.log(`Adding subscriber ${address} to plan ${planIdNum} via self-subscribe + share...`);
const result = await _addSubscriberViaShare(planIdNum, address, { denom, allocBytes });
console.log(`Added ${address}: sub=${result.subscriptionId} subTx=${result.subTx} shareTx=${result.shareTx}`);
invalidatePlanSubs(parseInt(planId, 10));
invalidatePlanSubs(planIdNum);
res.json(result);
} catch (err) {
if (relayKeplrSign(err, res)) return;
Expand All @@ -2984,23 +3007,36 @@ app.post('/api/plan/add-subscribers', async (req, res) => {
try {
const { planId, addresses, denom, allocBytes } = req.body;
if (!planId) return res.status(400).json({ error: 'planId required' });

const planIdNum = parseInt(planId, 10);
if (!Number.isFinite(planIdNum) || planIdNum <= 0) return res.status(400).json({ error: 'planId must be a positive integer' });

const list = Array.isArray(addresses)
? addresses.map(a => String(a).trim()).filter(a => a.startsWith('sent1'))
? addresses.map(a => String(a).trim()).filter(a => SENT_ADDR_RE.test(a))
: [];
if (!list.length) return res.status(400).json({ error: 'addresses[] with valid sent1... entries required' });
// Cap the batch: each entry triggers a serialized subscribe+share broadcast
// from this wallet, so an unbounded list would pin the broadcast queue for
// minutes and block every other caller (and risk gas exhaustion).
if (list.length > 500) return res.status(400).json({ error: 'Maximum 500 addresses per request' });

// Ownership gate: every entry broadcasts a subscribe TX from THIS operator's
// wallet, so reject bulk-add against a plan we don't own.
const ownErr = await assertPlanOwnership(planIdNum);
if (ownErr) return res.status(ownErr.status).json({ error: ownErr.error });

const results = [];
// Sequential — each address needs its own subscribe+share, and back-to-back
// signing from one wallet must serialize to avoid account-sequence collisions.
for (const addr of list) {
try {
const r = await _addSubscriberViaShare(parseInt(planId), addr, { denom, allocBytes });
const r = await _addSubscriberViaShare(planIdNum, addr, { denom, allocBytes });
results.push({ address: addr, ok: true, subscriptionId: r.subscriptionId, reused: r.reused, subTx: r.subTx, shareTx: r.shareTx });
} catch (e) {
results.push({ address: addr, ok: false, error: parseChainError(e.message) });
}
}
invalidatePlanSubs(parseInt(planId, 10));
invalidatePlanSubs(planIdNum);
const added = results.filter(r => r.ok).length;
res.json({ ok: added > 0, added, failed: results.length - added, results });
} catch (err) {
Expand Down Expand Up @@ -3551,6 +3587,9 @@ app.post('/api/plan-manager/batch-link', async (req, res) => {
const planIdNum = parseInt(planId, 10);
if (!Number.isFinite(planIdNum) || planIdNum <= 0) return res.status(400).json({ error: 'planId must be a positive integer' });
const addrs = [...new Set(nodeAddresses)];
// Cap batch size to keep one TX under the chain block-gas limit and avoid
// saturating the RPC pool. 100 keeps a single bundled TX well under limit.
if (addrs.length > 100) return res.status(400).json({ error: 'Maximum 100 nodes per batch' });
const badAddr = addrs.find(a => !NODE_ADDR_RE.test(a));
if (badAddr) return res.status(400).json({ error: `invalid node address: ${badAddr}` });
const ownErr = await assertPlanOwnership(planIdNum);
Expand Down Expand Up @@ -3690,6 +3729,9 @@ app.post('/api/plan-manager/batch-unlink', async (req, res) => {
const planIdNum = parseInt(planId, 10);
if (!Number.isFinite(planIdNum) || planIdNum <= 0) return res.status(400).json({ error: 'planId must be a positive integer' });
const addrs = [...new Set(nodeAddresses)];
// Cap batch size to keep one TX under the chain block-gas limit and avoid
// saturating the RPC pool. 100 keeps a single bundled TX well under limit.
if (addrs.length > 100) return res.status(400).json({ error: 'Maximum 100 nodes per batch' });
const badAddr = addrs.find(a => !NODE_ADDR_RE.test(a));
if (badAddr) return res.status(400).json({ error: `invalid node address: ${badAddr}` });
const ownErr = await assertPlanOwnership(planIdNum);
Expand Down