Skip to content

Commit b453da1

Browse files
AIQnetLabclaude
andcommitted
Light/reward UX + explorer fixes; light-eligibility restart resilience
Explorer: split LightNodeEligibilityBitmap/ping out of the "Heartbeat" category into "Light Eligibility"; render tx_type_data on the TX detail page (epoch/genesis_id/eligible_count); count active light over the last 3 sealed epochs to de-flicker a single intermittent mobile node. Node RPC status (display-only): pace needs_attention/is_reward_eligible to elapsed subwindows so a healthy super is not flagged mid-epoch; light flag from online+recency; drop the fabricated pool breakdown. Node: persist per-epoch light attestations (zero-padded key, O(1) prune; rebuilt at boot) so a genesis restart mid-epoch keeps its shard's eligibility for the boundary bitmap TX — no silent light reward loss. Distinct skip log when no eligible light (no TX emitted). App: remove the dead 404 legacy reward path (getNodeRewards / /api/rewards/*) that would gate light claims at 0 if re-wired; working claim path untouched. Deferred (need coordinated/fresh-genesis deploy, reward-determinism): mid-epoch light roster eligibility + ping/reward shard unification. 187 lib tests pass; explorer tsc clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 5155f23 commit b453da1

15 files changed

Lines changed: 203 additions & 268 deletions

File tree

applications/qnet-explorer/frontend/lib/db.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,8 @@ const DISPLAY_TYPE_TO_DB: Record<string, string[]> = {
156156
'Transfer': ['Transfer', 'BatchTransfers'],
157157
'Reward': ['RewardDistribution', 'BatchRewardClaims', 'SystemReward', 'SystemRewards', 'SystemEmission', 'Emission', 'Reward'],
158158
'Swap': ['Swap'],
159-
'Heartbeat': ['Heartbeat', 'HeartbeatCommitment', 'PingCommitmentWithSampling', 'LightNodeEligibilityBitmap', 'BitmapCommitment', 'PingAttestation'],
159+
'Heartbeat': ['Heartbeat', 'HeartbeatCommitment'],
160+
'Light Eligibility': ['LightNodeEligibilityBitmap', 'BitmapCommitment', 'PingAttestation', 'PingCommitmentWithSampling'],
160161
'Registration': ['NodeRegistration', 'Registration'],
161162
'Activation': ['NodeActivation', 'BatchNodeActivations'],
162163
'Contract': ['ContractDeploy', 'ContractCall'],

applications/qnet-explorer/frontend/src/app/api/activity/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ export async function GET(request: NextRequest) {
9393
const typesParam = searchParams.get('types');
9494
let displayTypes: string[] | undefined = undefined;
9595
if (typesParam) {
96-
displayTypes = typesParam.split(',').filter(t => /^[a-zA-Z]+$/.test(t.trim())).map(t => t.trim());
96+
displayTypes = typesParam.split(',').map(t => t.trim()).filter(t => /^[a-zA-Z ]+$/.test(t));
9797
if (displayTypes.length === 0) displayTypes = undefined;
9898
}
9999

applications/qnet-explorer/frontend/src/app/api/blocks/[hash]/route.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,11 @@ function getTransactionType(txType: unknown, fromAddress?: string): string {
3030
'NodeActivation': 'Activation',
3131
'RewardDistribution': 'Reward',
3232
'CreateAccount': 'System',
33-
'PingAttestation': 'Heartbeat',
34-
'PingCommitmentWithSampling': 'Heartbeat',
33+
'PingAttestation': 'Light Eligibility',
34+
'PingCommitmentWithSampling': 'Light Eligibility',
3535
'HeartbeatCommitment': 'Heartbeat',
36-
'LightNodeEligibilityBitmap': 'Heartbeat',
36+
'LightNodeEligibilityBitmap': 'Light Eligibility',
37+
'BitmapCommitment': 'Light Eligibility',
3738
'Swap': 'Swap',
3839
'ContractDeploy': 'Contract',
3940
'ContractCall': 'Contract',

applications/qnet-explorer/frontend/src/app/api/network/stats/route.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -60,12 +60,18 @@ export async function GET() {
6060
AND t.block >= ep.pe * 14400 AND t.block < ep.pe * 14400 + 14400
6161
`),
6262

63-
// Active LIGHT nodes = sealed eligible_count summed over the per-genesis bitmaps of the previous epoch.
63+
// Active LIGHT nodes = MAX over the last 3 sealed epochs of (per-epoch SUM of eligible_count across genesis shards).
64+
// Per-epoch SUM across shards = that epoch's light total (each light is in exactly one shard); MAX de-flickers a light
65+
// that missed only the most-recent epoch's ping window.
6466
pool.query(`${prevEpochCte}
65-
SELECT COALESCE(SUM((t.tx_type_data->>'eligible_count')::bigint), 0) AS active_light
66-
FROM transactions t, ep
67-
WHERE t.tx_type = 'LightNodeEligibilityBitmap'
68-
AND t.block >= ep.pe * 14400 AND t.block < ep.pe * 14400 + 14400
67+
SELECT COALESCE(MAX(epoch_light), 0) AS active_light FROM (
68+
SELECT FLOOR(t.block / 14400)::bigint AS epoch,
69+
SUM((t.tx_type_data->>'eligible_count')::bigint) AS epoch_light
70+
FROM transactions t, ep
71+
WHERE t.tx_type = 'LightNodeEligibilityBitmap'
72+
AND t.block >= GREATEST(ep.pe - 2, 0) * 14400 AND t.block < (ep.pe + 1) * 14400
73+
GROUP BY FLOOR(t.block / 14400)
74+
) per_epoch
6975
`)
7076
]);
7177

applications/qnet-explorer/frontend/src/app/api/tx/[hash]/route.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,17 @@ function getNodeRpcUrl(): string {
3636

3737
const NODE_RPC_URL = getNodeRpcUrl();
3838

39+
// Normalize type-specific public data (JSONB object or JSON string) → object|null; null if empty.
40+
function parseTxTypeData(raw: unknown): Record<string, unknown> | null {
41+
if (!raw) return null;
42+
let obj: unknown = raw;
43+
if (typeof raw === 'string') {
44+
try { obj = JSON.parse(raw); } catch { return null; }
45+
}
46+
if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) return null;
47+
return Object.keys(obj).length > 0 ? (obj as Record<string, unknown>) : null;
48+
}
49+
3950
// Fetch TX from Node RPC (fallback if not in DB)
4051
async function fetchTransaction(hash: string): Promise<Record<string, unknown> | null> {
4152
try {
@@ -259,6 +270,7 @@ export async function GET(
259270
dilithium_signature: dbTx.dilithium_signature,
260271
dilithium_public_key: dbTx.dilithium_public_key,
261272
data: dbTx.data,
273+
tx_type_data: parseTxTypeData(dbTx.tx_type_data),
262274
},
263275
}, {
264276
headers: {
@@ -370,6 +382,7 @@ export async function GET(
370382
dilithium_signature: tx.dilithium_signature as string | undefined,
371383
dilithium_public_key: tx.dilithium_public_key as string | undefined,
372384
data: tx.data as string | undefined,
385+
tx_type_data: parseTxTypeData(tx.tx_type_data),
373386
},
374387
}, {
375388
headers: {

applications/qnet-explorer/frontend/src/app/explorer/ExplorerClient.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ const ActivityRow = memo(function ActivityRow({ item }: { item: ActivityItem })
9393
});
9494

9595
const ITEMS_PER_PAGE = 50;
96-
const TX_TYPES = ['Transfer', 'Reward', 'Swap', 'Heartbeat', 'Registration', 'Activation', 'Contract', 'System'];
96+
const TX_TYPES = ['Transfer', 'Reward', 'Swap', 'Heartbeat', 'Light Eligibility', 'Registration', 'Activation', 'Contract', 'System'];
9797

9898
export default function ExplorerClient({ initialData, initialHeight, initialTotal }: ExplorerClientProps) {
9999
// ========== STATE: initialized from SSR data — table renders INSTANTLY ==========

applications/qnet-explorer/frontend/src/app/explorer/tx/[hash]/page.tsx

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ interface TransactionData {
2727
is_quantum_signed?: boolean;
2828
dilithium_signature?: string;
2929
dilithium_public_key?: string;
30+
tx_type_data?: Record<string, unknown> | null;
3031
}
3132

3233
// Truncate
@@ -53,6 +54,17 @@ const formatTime = (ts: number | string | undefined): string => {
5354
return `${dd}.${mm}.${yyyy}, ${hh}:${min}:${ss}`;
5455
};
5556

57+
// snake_case/genesis_id → "Genesis Id" for the data card labels
58+
const humanizeKey = (key: string): string =>
59+
key.replace(/[_-]+/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
60+
61+
// Render any tx_type_data value as a string (objects/arrays → JSON)
62+
const formatDataValue = (val: unknown): string => {
63+
if (val === null || val === undefined) return 'N/A';
64+
if (typeof val === 'object') return JSON.stringify(val);
65+
return String(val);
66+
};
67+
5668
// Copy button
5769
const CopyBtn = ({ text }: { text: string }) => {
5870
const [copied, setCopied] = useState(false);
@@ -218,6 +230,21 @@ export default function TransactionPage() {
218230
</div>
219231
</div>
220232
</div>
233+
234+
{/* Type-specific public data (bitmap epoch/eligible_count, reward pool, etc.) */}
235+
{tx.tx_type_data && Object.keys(tx.tx_type_data).length > 0 && (
236+
<div className="block-card">
237+
<h2 className="card-title">Transaction Data</h2>
238+
<div className="details-grid">
239+
{Object.entries(tx.tx_type_data).map(([key, value]) => (
240+
<div className="detail-row" key={key}>
241+
<span className="detail-label">{humanizeKey(key)}</span>
242+
<span className="detail-value">{formatDataValue(value)}</span>
243+
</div>
244+
))}
245+
</div>
246+
</div>
247+
)}
221248
</div>
222249
);
223250
}

applications/qnet-explorer/frontend/src/app/globals.css

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2441,6 +2441,11 @@ a[href*="play.google.com"] img {
24412441
color: #ff80b0;
24422442
}
24432443

2444+
.type-badge.type-light-eligibility {
2445+
background: rgba(120,200,255,0.15);
2446+
color: #80c0ff;
2447+
}
2448+
24442449
.type-badge.type-contract {
24452450
background: rgba(240,130,130,0.15);
24462451
color: #f09090;

applications/qnet-explorer/frontend/src/lib/tx-mapping.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,14 +34,16 @@ export function mapTxType(type: string | object | undefined, fromAddress?: strin
3434
emission: 'Reward',
3535
reward: 'Reward',
3636

37-
// Heartbeat (all node activity attestations)
37+
// Heartbeat (super-node liveness attestation)
3838
heartbeatcommitment: 'Heartbeat',
39-
pingcommitmentwithsampling: 'Heartbeat',
40-
lightnodeeligibilitybitmap: 'Heartbeat',
41-
bitmapcommitment: 'Heartbeat',
42-
pingattestation: 'Heartbeat',
4339
heartbeat: 'Heartbeat',
4440

41+
// Light eligibility (ping/bitmap attestations)
42+
lightnodeeligibilitybitmap: 'Light Eligibility',
43+
bitmapcommitment: 'Light Eligibility',
44+
pingattestation: 'Light Eligibility',
45+
pingcommitmentwithsampling: 'Light Eligibility',
46+
4547
// Smart Contracts
4648
contractdeploy: 'Contract',
4749
contractcall: 'Contract',

applications/qnet-mobile/src/components/WalletManager.js

Lines changed: 0 additions & 120 deletions
Original file line numberDiff line numberDiff line change
@@ -5365,126 +5365,6 @@ export class WalletManager {
53655365
}
53665366
}
53675367

5368-
// Get validator node metrics from blockchain
5369-
// v3.35: Added retry logic with different nodes for reliability
5370-
async getNodeRewards(nodeType, activationCode, walletAddress, maxRetries = 3) {
5371-
let lastError = null;
5372-
const triedNodes = new Set();
5373-
5374-
for (let attempt = 0; attempt < maxRetries; attempt++) {
5375-
try {
5376-
// v3.35: Get random node, avoid retrying same node
5377-
let apiUrl = this.getRandomBootstrapNode();
5378-
let retryCount = 0;
5379-
while (triedNodes.has(apiUrl) && retryCount < 5) {
5380-
apiUrl = this.getRandomBootstrapNode();
5381-
retryCount++;
5382-
}
5383-
triedNodes.add(apiUrl);
5384-
5385-
// Get rewards periods from blockchain
5386-
const controller = new AbortController();
5387-
const timeoutId = setTimeout(() => controller.abort(), 8000);
5388-
5389-
const periodsResponse = await fetch(`${apiUrl}/api/rewards/periods`, {
5390-
method: 'GET',
5391-
headers: { 'Content-Type': 'application/json' },
5392-
signal: controller.signal
5393-
});
5394-
5395-
clearTimeout(timeoutId);
5396-
5397-
if (!periodsResponse.ok) {
5398-
throw new Error(`HTTP ${periodsResponse.status}`);
5399-
}
5400-
5401-
const periods = await periodsResponse.json();
5402-
const currentPeriod = periods?.periods?.[0];
5403-
5404-
// Get reward proof for current period
5405-
const proofController = new AbortController();
5406-
const proofTimeoutId = setTimeout(() => proofController.abort(), 8000);
5407-
5408-
const proofResponse = await fetch(`${apiUrl}/api/rewards/proof?address=${walletAddress}&period_id=${currentPeriod?.id || 'current'}`, {
5409-
method: 'GET',
5410-
headers: { 'Content-Type': 'application/json' },
5411-
signal: proofController.signal
5412-
});
5413-
5414-
clearTimeout(proofTimeoutId);
5415-
5416-
let rewardData = {};
5417-
if (proofResponse.ok) {
5418-
rewardData = await proofResponse.json();
5419-
}
5420-
5421-
// Get node ping status from storage
5422-
const lastPingTime = await AsyncStorage.getItem(`node_last_ping_${walletAddress}`);
5423-
const lastPing = lastPingTime ? parseInt(lastPingTime) : null;
5424-
const fourHoursAgo = Date.now() - (4 * 60 * 60 * 1000);
5425-
const isActive = lastPing && lastPing > fourHoursAgo;
5426-
5427-
// Daily rates by node type
5428-
// Light nodes are NOT real nodes - just mobile app users
5429-
const dailyRates = {
5430-
super: 500, // Only Super/Genesis nodes earn rewards
5431-
};
5432-
5433-
// Get stored rewards data
5434-
const storedRewardsStr = await AsyncStorage.getItem('qnet_node_rewards');
5435-
let storedRewards = {};
5436-
if (storedRewardsStr) {
5437-
try {
5438-
storedRewards = JSON.parse(storedRewardsStr);
5439-
} catch (e) {
5440-
// Error parsing stored rewards
5441-
}
5442-
}
5443-
5444-
// Calculate validator activity metrics
5445-
const dailyRate = dailyRates[nodeType] || 10;
5446-
const totalEarned = rewardData?.total_earned || storedRewards.totalEarned || 0;
5447-
const totalClaimed = rewardData?.total_claimed || storedRewards.totalClaimed || 0;
5448-
const unclaimed = rewardData?.unclaimed || (totalEarned - totalClaimed);
5449-
5450-
// Return validator metrics (rewards are managed automatically by blockchain protocol)
5451-
return {
5452-
dailyRate,
5453-
totalEarned, // Total on-chain validations
5454-
totalClaimed, // Confirmed validations
5455-
unclaimed, // Pending validations
5456-
lastPing,
5457-
isActive,
5458-
nextClaim: storedRewards.lastClaim
5459-
? storedRewards.lastClaim + (24 * 60 * 60 * 1000)
5460-
: null,
5461-
merkleProof: rewardData?.merkle_proof || [],
5462-
periodId: currentPeriod?.id || null
5463-
};
5464-
} catch (error) {
5465-
lastError = error;
5466-
// v3.35: Wait before retry (exponential backoff)
5467-
if (attempt < maxRetries - 1) {
5468-
await new Promise(r => setTimeout(r, (attempt + 1) * 500));
5469-
}
5470-
}
5471-
}
5472-
5473-
// All retries failed - return default metrics
5474-
console.warn(`[REWARDS] Failed after ${maxRetries} retries:`, lastError?.message);
5475-
return {
5476-
dailyRate: 10,
5477-
totalEarned: 0,
5478-
totalClaimed: 0,
5479-
unclaimed: 0,
5480-
lastPing: null,
5481-
isActive: false,
5482-
nextClaim: null,
5483-
merkleProof: [],
5484-
periodId: null
5485-
};
5486-
}
5487-
54885368
// Query any node to verify that walletAddress has an active registration on-chain.
54895369
// Returns { verified: true, node_id, node_type } or { verified: false }.
54905370
async checkOnChainActivation(walletAddress) {

0 commit comments

Comments
 (0)