Initial SvelteKit block explorer for Triangles blockchain
Full-featured explorer consuming the daemon REST API: - Home dashboard with chain stats, latest blocks, staking info - Block list with pagination and block detail pages - Transaction detail with inputs/outputs tables - Address lookup with balance, UTXOs, and tx history - Staking dashboard with difficulty chart (Chart.js) - Network info with peer table - Mempool viewer - API documentation page for all 30+ REST endpoints - Search by block hash, height, txid, or address - Dark theme with Tailwind CSS v4 - SSR via adapter-node for SEO Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
TRIANGLES_API_URL=http://127.0.0.1:19112
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules
|
||||||
|
.svelte-kit
|
||||||
|
build
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
Generated
+2495
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "triangles-explorer",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite dev",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@sveltejs/adapter-node": "^5.5.4",
|
||||||
|
"@sveltejs/kit": "^2.21.0",
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||||
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
|
"svelte": "^5.20.0",
|
||||||
|
"svelte-check": "^4.1.0",
|
||||||
|
"tailwindcss": "^4.0.0",
|
||||||
|
"tslib": "^2.8.1",
|
||||||
|
"typescript": "^5.7.0",
|
||||||
|
"vite": "^6.2.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"chart.js": "^4.4.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
@import 'tailwindcss';
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--color-tri-bg: #0f1117;
|
||||||
|
--color-tri-surface: #1a1d27;
|
||||||
|
--color-tri-border: #2a2d3a;
|
||||||
|
--color-tri-accent: #6366f1;
|
||||||
|
--color-tri-accent-light: #818cf8;
|
||||||
|
--color-tri-green: #22c55e;
|
||||||
|
--color-tri-red: #ef4444;
|
||||||
|
--color-tri-yellow: #eab308;
|
||||||
|
--color-tri-text: #e2e8f0;
|
||||||
|
--color-tri-muted: #94a3b8;
|
||||||
|
}
|
||||||
Vendored
+13
@@ -0,0 +1,13 @@
|
|||||||
|
// See https://svelte.dev/docs/kit/types#app
|
||||||
|
// for information about these interfaces
|
||||||
|
declare global {
|
||||||
|
namespace App {
|
||||||
|
// interface Error {}
|
||||||
|
// interface Locals {}
|
||||||
|
// interface PageData {}
|
||||||
|
// interface PageState {}
|
||||||
|
// interface Platform {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<link rel="icon" href="%sveltekit.assets%/favicon.ico" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
%sveltekit.head%
|
||||||
|
</head>
|
||||||
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { env } from '$env/dynamic/private';
|
||||||
|
import type {
|
||||||
|
ChainInfo, Block, BlockHeader, Transaction, Difficulty,
|
||||||
|
StakingInfo, MiningInfo, NetworkInfo, PeerInfo,
|
||||||
|
AddressBalance, AddressUtxo, SupplyInfo, ValidationResult
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
const BASE_URL = env.TRIANGLES_API_URL || 'http://127.0.0.1:19112';
|
||||||
|
|
||||||
|
async function get<T>(path: string): Promise<T> {
|
||||||
|
const res = await fetch(`${BASE_URL}/rest/${path}`);
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.text();
|
||||||
|
throw new Error(`API error ${res.status}: ${body}`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chain info
|
||||||
|
export const getChainInfo = () => get<ChainInfo>('chaininfo');
|
||||||
|
|
||||||
|
// Blocks
|
||||||
|
export const getBlock = (hash: string) => get<Block>(`block/${hash}`);
|
||||||
|
export const getBlockHeader = (hash: string) => get<BlockHeader>(`blockheader/${hash}`);
|
||||||
|
export const getBlockByHeight = (n: number) => get<Block>(`blockbyheight/${n}`);
|
||||||
|
export const getBlockHashByHeight = (n: number) => get<{ blockhash: string }>(`blockhashbyheight/${n}`);
|
||||||
|
|
||||||
|
// Transactions
|
||||||
|
export const getTransaction = (txid: string) => get<Transaction>(`tx/${txid}`);
|
||||||
|
|
||||||
|
// Mempool
|
||||||
|
export const getMempool = () => get<string[]>('mempool');
|
||||||
|
|
||||||
|
// Network
|
||||||
|
export const getDifficulty = () => get<Difficulty>('difficulty');
|
||||||
|
export const getSupply = () => get<SupplyInfo>('supply');
|
||||||
|
export const getStaking = () => get<StakingInfo>('staking');
|
||||||
|
export const getMining = () => get<MiningInfo>('mining');
|
||||||
|
export const getSubsidy = () => get<number>('subsidy');
|
||||||
|
export const getEstimateFee = () => get<number>('estimatefee');
|
||||||
|
export const getCheckpoint = () => get<Record<string, unknown>>('checkpoint');
|
||||||
|
export const getNetwork = () => get<NetworkInfo>('network');
|
||||||
|
export const getPeers = () => get<PeerInfo[]>('peers');
|
||||||
|
|
||||||
|
// Address (requires -addressindex=1)
|
||||||
|
export const getAddressBalance = (addr: string) => get<AddressBalance>(`address/${addr}/balance`);
|
||||||
|
export const getAddressUtxos = (addr: string) => get<AddressUtxo[]>(`address/${addr}/utxos`);
|
||||||
|
export const getAddressTxids = (addr: string, start?: number, end?: number) => {
|
||||||
|
let path = `address/${addr}/txids`;
|
||||||
|
const params: string[] = [];
|
||||||
|
if (start !== undefined) params.push(`start=${start}`);
|
||||||
|
if (end !== undefined) params.push(`end=${end}`);
|
||||||
|
if (params.length) path += '?' + params.join('&');
|
||||||
|
return get<string[]>(path);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
export const validateAddress = (addr: string) => get<ValidationResult>(`validate/${addr}`);
|
||||||
|
|
||||||
|
// Helper: get latest N blocks
|
||||||
|
export async function getLatestBlocks(count: number): Promise<Block[]> {
|
||||||
|
const chain = await getChainInfo();
|
||||||
|
const blocks: Block[] = [];
|
||||||
|
for (let i = 0; i < count && chain.blocks - i >= 0; i++) {
|
||||||
|
try {
|
||||||
|
blocks.push(await getBlockByHeight(chain.blocks - i));
|
||||||
|
} catch { break; }
|
||||||
|
}
|
||||||
|
return blocks;
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Block } from '$lib/types';
|
||||||
|
import { truncateHash, timeAgo, blockType, formatDifficulty } from '$lib/utils';
|
||||||
|
|
||||||
|
let { blocks }: { blocks: Block[] } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="text-tri-muted text-xs uppercase tracking-wider border-b border-tri-border">
|
||||||
|
<th class="text-left py-3 px-3">Height</th>
|
||||||
|
<th class="text-left py-3 px-3">Hash</th>
|
||||||
|
<th class="text-left py-3 px-3">Age</th>
|
||||||
|
<th class="text-center py-3 px-3">Txs</th>
|
||||||
|
<th class="text-center py-3 px-3">Type</th>
|
||||||
|
<th class="text-right py-3 px-3">Difficulty</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each blocks as block}
|
||||||
|
<tr class="border-b border-tri-border/50 hover:bg-tri-surface/50 transition-colors">
|
||||||
|
<td class="py-2.5 px-3">
|
||||||
|
<a href="/block/{block.hash}" class="text-tri-accent hover:text-tri-accent-light">
|
||||||
|
{block.height}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td class="py-2.5 px-3 font-mono text-xs">
|
||||||
|
<a href="/block/{block.hash}" class="text-tri-text hover:text-tri-accent-light">
|
||||||
|
{truncateHash(block.hash, 10)}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td class="py-2.5 px-3 text-tri-muted">{timeAgo(block.time)}</td>
|
||||||
|
<td class="py-2.5 px-3 text-center">{block.tx.length}</td>
|
||||||
|
<td class="py-2.5 px-3 text-center">
|
||||||
|
<span class="inline-block px-2 py-0.5 rounded text-xs font-medium {block.flags.includes('proof-of-stake') ? 'bg-tri-green/20 text-tri-green' : 'bg-tri-yellow/20 text-tri-yellow'}">
|
||||||
|
{blockType(block.flags)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="py-2.5 px-3 text-right font-mono text-xs">{formatDifficulty(block.difficulty)}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let { page, hasNext, basePath }: { page: number; hasNext: boolean; basePath: string } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between mt-6">
|
||||||
|
{#if page > 1}
|
||||||
|
<a
|
||||||
|
href="{basePath}?page={page - 1}"
|
||||||
|
class="bg-tri-surface border border-tri-border text-tri-text px-4 py-2 rounded-lg text-sm hover:border-tri-accent transition-colors"
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
<div></div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<span class="text-tri-muted text-sm">Page {page}</span>
|
||||||
|
|
||||||
|
{#if hasNext}
|
||||||
|
<a
|
||||||
|
href="{basePath}?page={page + 1}"
|
||||||
|
class="bg-tri-surface border border-tri-border text-tri-text px-4 py-2 rounded-lg text-sm hover:border-tri-accent transition-colors"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
<div></div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { PeerInfo } from '$lib/types';
|
||||||
|
import { timeAgo } from '$lib/utils';
|
||||||
|
|
||||||
|
let { peers }: { peers: PeerInfo[] } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="text-tri-muted text-xs uppercase tracking-wider border-b border-tri-border">
|
||||||
|
<th class="text-left py-3 px-3">Address</th>
|
||||||
|
<th class="text-left py-3 px-3">Version</th>
|
||||||
|
<th class="text-center py-3 px-3">Direction</th>
|
||||||
|
<th class="text-right py-3 px-3">Height</th>
|
||||||
|
<th class="text-right py-3 px-3">Connected</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each peers as peer}
|
||||||
|
<tr class="border-b border-tri-border/50 hover:bg-tri-surface/50 transition-colors">
|
||||||
|
<td class="py-2.5 px-3 font-mono text-xs">{peer.addr}</td>
|
||||||
|
<td class="py-2.5 px-3 text-xs text-tri-muted">{peer.subver}</td>
|
||||||
|
<td class="py-2.5 px-3 text-center">
|
||||||
|
<span class="inline-block px-2 py-0.5 rounded text-xs font-medium {peer.inbound ? 'bg-tri-green/20 text-tri-green' : 'bg-tri-accent/20 text-tri-accent-light'}">
|
||||||
|
{peer.inbound ? 'In' : 'Out'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="py-2.5 px-3 text-right">{peer.startingheight}</td>
|
||||||
|
<td class="py-2.5 px-3 text-right text-tri-muted">{timeAgo(peer.conntime)}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { isBlockHash, isNumeric, isAddress } from '$lib/utils';
|
||||||
|
|
||||||
|
let query = $state('');
|
||||||
|
|
||||||
|
function handleSearch() {
|
||||||
|
const q = query.trim();
|
||||||
|
if (!q) return;
|
||||||
|
|
||||||
|
if (isNumeric(q)) {
|
||||||
|
goto(`/blocks/${q}`);
|
||||||
|
} else if (isBlockHash(q)) {
|
||||||
|
goto(`/search?q=${q}`);
|
||||||
|
} else if (isAddress(q)) {
|
||||||
|
goto(`/address/${q}`);
|
||||||
|
} else {
|
||||||
|
goto(`/search?q=${q}`);
|
||||||
|
}
|
||||||
|
query = '';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<form onsubmit={handleSearch} class="flex w-full max-w-xl">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={query}
|
||||||
|
placeholder="Search block hash, height, txid, or address..."
|
||||||
|
class="flex-1 bg-tri-surface border border-tri-border rounded-l-lg px-4 py-2 text-sm text-tri-text placeholder-tri-muted focus:outline-none focus:border-tri-accent"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="bg-tri-accent hover:bg-tri-accent-light text-white px-4 py-2 rounded-r-lg text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Search
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let { label, value, sub = '' }: { label: string; value: string; sub?: string } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="bg-tri-surface border border-tri-border rounded-lg p-4">
|
||||||
|
<div class="text-tri-muted text-xs uppercase tracking-wider mb-1">{label}</div>
|
||||||
|
<div class="text-tri-text text-lg font-semibold">{value}</div>
|
||||||
|
{#if sub}
|
||||||
|
<div class="text-tri-muted text-xs mt-1">{sub}</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { truncateHash } from '$lib/utils';
|
||||||
|
|
||||||
|
let { txids, limit = 0 }: { txids: string[]; limit?: number } = $props();
|
||||||
|
const displayed = limit > 0 ? txids.slice(0, limit) : txids;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="text-tri-muted text-xs uppercase tracking-wider border-b border-tri-border">
|
||||||
|
<th class="text-left py-3 px-3">#</th>
|
||||||
|
<th class="text-left py-3 px-3">Transaction ID</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each displayed as txid, i}
|
||||||
|
<tr class="border-b border-tri-border/50 hover:bg-tri-surface/50 transition-colors">
|
||||||
|
<td class="py-2.5 px-3 text-tri-muted">{i + 1}</td>
|
||||||
|
<td class="py-2.5 px-3 font-mono text-xs">
|
||||||
|
<a href="/tx/{txid}" class="text-tri-accent hover:text-tri-accent-light">
|
||||||
|
{truncateHash(txid, 16)}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
export interface ChainInfo {
|
||||||
|
chain: string;
|
||||||
|
blocks: number;
|
||||||
|
bestblockhash: string;
|
||||||
|
difficulty: {
|
||||||
|
'proof-of-work': number;
|
||||||
|
'proof-of-stake': number;
|
||||||
|
};
|
||||||
|
moneysupply: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Block {
|
||||||
|
hash: string;
|
||||||
|
confirmations: number;
|
||||||
|
size: number;
|
||||||
|
height: number;
|
||||||
|
version: number;
|
||||||
|
merkleroot: string;
|
||||||
|
mint: number;
|
||||||
|
time: number;
|
||||||
|
nonce: number;
|
||||||
|
bits: string;
|
||||||
|
difficulty: number;
|
||||||
|
blocktrust: string;
|
||||||
|
chaintrust: string;
|
||||||
|
previousblockhash?: string;
|
||||||
|
nextblockhash?: string;
|
||||||
|
flags: string;
|
||||||
|
proofhash: string;
|
||||||
|
entropybit: number;
|
||||||
|
modifier: string;
|
||||||
|
tx: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BlockHeader {
|
||||||
|
hash: string;
|
||||||
|
confirmations: number;
|
||||||
|
height: number;
|
||||||
|
version: number;
|
||||||
|
merkleroot: string;
|
||||||
|
time: number;
|
||||||
|
nonce: number;
|
||||||
|
bits: string;
|
||||||
|
difficulty: number;
|
||||||
|
flags: string;
|
||||||
|
previousblockhash?: string;
|
||||||
|
nextblockhash?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Transaction {
|
||||||
|
txid: string;
|
||||||
|
version: number;
|
||||||
|
time: number;
|
||||||
|
locktime: number;
|
||||||
|
vin: TxInput[];
|
||||||
|
vout: TxOutput[];
|
||||||
|
blockhash?: string;
|
||||||
|
confirmations?: number;
|
||||||
|
blocktime?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TxInput {
|
||||||
|
txid?: string;
|
||||||
|
vout?: number;
|
||||||
|
scriptSig?: {
|
||||||
|
asm: string;
|
||||||
|
hex: string;
|
||||||
|
};
|
||||||
|
coinbase?: string;
|
||||||
|
sequence: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TxOutput {
|
||||||
|
value: number;
|
||||||
|
n: number;
|
||||||
|
scriptPubKey: {
|
||||||
|
asm: string;
|
||||||
|
hex: string;
|
||||||
|
reqSigs?: number;
|
||||||
|
type: string;
|
||||||
|
addresses?: string[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Difficulty {
|
||||||
|
'proof-of-work': number;
|
||||||
|
'proof-of-stake': number;
|
||||||
|
'search-interval': number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StakingInfo {
|
||||||
|
enabled: boolean;
|
||||||
|
staking: boolean;
|
||||||
|
errors: string;
|
||||||
|
currentblocksize: number;
|
||||||
|
currentblocktx: number;
|
||||||
|
difficulty: number;
|
||||||
|
'search-interval': number;
|
||||||
|
weight: number;
|
||||||
|
netstakeweight: number;
|
||||||
|
expectedtime: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MiningInfo {
|
||||||
|
blocks: number;
|
||||||
|
currentblocksize: number;
|
||||||
|
currentblocktx: number;
|
||||||
|
difficulty: number;
|
||||||
|
blockvalue: number;
|
||||||
|
netmhashps: number;
|
||||||
|
netstakeweight: number;
|
||||||
|
errors: string;
|
||||||
|
pooledtx: number;
|
||||||
|
stakedifficulty: number;
|
||||||
|
stakeinterest: number;
|
||||||
|
testnet: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NetworkInfo {
|
||||||
|
version: number;
|
||||||
|
protocolversion: number;
|
||||||
|
connections: number;
|
||||||
|
proxy: string;
|
||||||
|
testnet: boolean;
|
||||||
|
errors: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PeerInfo {
|
||||||
|
addr: string;
|
||||||
|
services: string;
|
||||||
|
lastsend: number;
|
||||||
|
lastrecv: number;
|
||||||
|
conntime: number;
|
||||||
|
version: number;
|
||||||
|
subver: string;
|
||||||
|
inbound: boolean;
|
||||||
|
startingheight: number;
|
||||||
|
banscore: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AddressBalance {
|
||||||
|
balance: number;
|
||||||
|
received: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AddressUtxo {
|
||||||
|
address: string;
|
||||||
|
txid: string;
|
||||||
|
outputIndex: number;
|
||||||
|
satoshis: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SupplyInfo {
|
||||||
|
height: number;
|
||||||
|
bestblock: string;
|
||||||
|
total_amount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ValidationResult {
|
||||||
|
isvalid: boolean;
|
||||||
|
address?: string;
|
||||||
|
ismine?: boolean;
|
||||||
|
account?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
export function timeAgo(timestamp: number): string {
|
||||||
|
const seconds = Math.floor(Date.now() / 1000 - timestamp);
|
||||||
|
if (seconds < 60) return `${seconds}s ago`;
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
if (minutes < 60) return `${minutes}m ago`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
if (hours < 24) return `${hours}h ago`;
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
return `${days}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function truncateHash(hash: string, chars = 8): string {
|
||||||
|
if (hash.length <= chars * 2 + 3) return hash;
|
||||||
|
return `${hash.slice(0, chars)}...${hash.slice(-chars)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatAmount(amount: number): string {
|
||||||
|
return amount.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 8 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDifficulty(diff: number): string {
|
||||||
|
if (diff >= 1e12) return (diff / 1e12).toFixed(2) + 'T';
|
||||||
|
if (diff >= 1e9) return (diff / 1e9).toFixed(2) + 'G';
|
||||||
|
if (diff >= 1e6) return (diff / 1e6).toFixed(2) + 'M';
|
||||||
|
if (diff >= 1e3) return (diff / 1e3).toFixed(2) + 'K';
|
||||||
|
return diff.toFixed(4);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatNumber(n: number): string {
|
||||||
|
return n.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTimestamp(ts: number): string {
|
||||||
|
return new Date(ts * 1000).toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isBlockHash(input: string): boolean {
|
||||||
|
return /^[0-9a-fA-F]{64}$/.test(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isNumeric(input: string): boolean {
|
||||||
|
return /^\d+$/.test(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAddress(input: string): boolean {
|
||||||
|
return /^[A-Za-z1-9]{25,34}$/.test(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function blockType(flags: string): string {
|
||||||
|
if (flags.includes('proof-of-stake')) return 'PoS';
|
||||||
|
return 'PoW';
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/state';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Error {page.status} - Triangles Explorer</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="flex flex-col items-center justify-center py-20">
|
||||||
|
<div class="text-6xl font-bold text-tri-accent mb-4">{page.status}</div>
|
||||||
|
<div class="text-tri-text text-xl mb-2">
|
||||||
|
{#if page.status === 404}
|
||||||
|
Not Found
|
||||||
|
{:else}
|
||||||
|
Something went wrong
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<p class="text-tri-muted text-sm mb-6">{page.error?.message || 'An unexpected error occurred'}</p>
|
||||||
|
<a href="/" class="bg-tri-accent hover:bg-tri-accent-light text-white px-6 py-2 rounded-lg text-sm transition-colors">
|
||||||
|
Back to Home
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { getChainInfo, getNetwork } from '$lib/api';
|
||||||
|
import type { LayoutServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: LayoutServerLoad = async () => {
|
||||||
|
try {
|
||||||
|
const [chain, network] = await Promise.all([getChainInfo(), getNetwork()]);
|
||||||
|
return { chain, network };
|
||||||
|
} catch {
|
||||||
|
return { chain: null, network: null };
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import '../app.css';
|
||||||
|
import SearchBar from '$lib/components/SearchBar.svelte';
|
||||||
|
import { formatNumber, formatAmount, formatDifficulty } from '$lib/utils';
|
||||||
|
|
||||||
|
let { data, children } = $props();
|
||||||
|
|
||||||
|
const navLinks = [
|
||||||
|
{ href: '/', label: 'Home' },
|
||||||
|
{ href: '/blocks', label: 'Blocks' },
|
||||||
|
{ href: '/staking', label: 'Staking' },
|
||||||
|
{ href: '/network', label: 'Network' },
|
||||||
|
{ href: '/mempool', label: 'Mempool' },
|
||||||
|
{ href: '/api', label: 'API' }
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Triangles Block Explorer</title>
|
||||||
|
<meta name="description" content="Block explorer for the Triangles (TRI) cryptocurrency" />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="min-h-screen bg-tri-bg text-tri-text flex flex-col">
|
||||||
|
<!-- Header -->
|
||||||
|
<header class="border-b border-tri-border bg-tri-surface/50 backdrop-blur-sm sticky top-0 z-50">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 py-3">
|
||||||
|
<div class="flex items-center justify-between gap-4 flex-wrap">
|
||||||
|
<a href="/" class="text-xl font-bold text-white flex items-center gap-2 shrink-0">
|
||||||
|
<span class="text-tri-accent">▲</span> Triangles Explorer
|
||||||
|
</a>
|
||||||
|
<SearchBar />
|
||||||
|
</div>
|
||||||
|
<nav class="flex gap-1 mt-2 overflow-x-auto">
|
||||||
|
{#each navLinks as link}
|
||||||
|
<a
|
||||||
|
href={link.href}
|
||||||
|
class="px-3 py-1.5 text-sm text-tri-muted hover:text-white hover:bg-tri-border/50 rounded transition-colors whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Main -->
|
||||||
|
<main class="flex-1 max-w-7xl mx-auto w-full px-4 py-6">
|
||||||
|
{@render children()}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<footer class="border-t border-tri-border bg-tri-surface/30 py-4">
|
||||||
|
<div class="max-w-7xl mx-auto px-4">
|
||||||
|
{#if data.chain}
|
||||||
|
<div class="flex flex-wrap gap-6 text-xs text-tri-muted justify-center">
|
||||||
|
<span>Height: <strong class="text-tri-text">{formatNumber(data.chain.blocks)}</strong></span>
|
||||||
|
<span>Supply: <strong class="text-tri-text">{formatAmount(data.chain.moneysupply)} TRI</strong></span>
|
||||||
|
<span>PoS Diff: <strong class="text-tri-text">{formatDifficulty(data.chain.difficulty['proof-of-stake'])}</strong></span>
|
||||||
|
{#if data.network}
|
||||||
|
<span>Peers: <strong class="text-tri-text">{data.network.connections}</strong></span>
|
||||||
|
{/if}
|
||||||
|
<span>Chain: <strong class="text-tri-text">{data.chain.chain}</strong></span>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="text-center text-xs text-tri-muted">Daemon unavailable</div>
|
||||||
|
{/if}
|
||||||
|
<div class="text-center text-xs text-tri-muted mt-2">
|
||||||
|
Triangles Block Explorer — <a href="https://cryptographic-triangles.org" class="text-tri-accent hover:underline">cryptographic-triangles.org</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { getChainInfo, getLatestBlocks, getStaking, getMining } from '$lib/api';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async () => {
|
||||||
|
try {
|
||||||
|
const [chain, blocks, staking, mining] = await Promise.all([
|
||||||
|
getChainInfo(),
|
||||||
|
getLatestBlocks(10),
|
||||||
|
getStaking(),
|
||||||
|
getMining()
|
||||||
|
]);
|
||||||
|
return { chain, blocks, staking, mining };
|
||||||
|
} catch (e) {
|
||||||
|
return { chain: null, blocks: [], staking: null, mining: null, error: String(e) };
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import StatsCard from '$lib/components/StatsCard.svelte';
|
||||||
|
import BlockTable from '$lib/components/BlockTable.svelte';
|
||||||
|
import { formatNumber, formatAmount, formatDifficulty, truncateHash } from '$lib/utils';
|
||||||
|
|
||||||
|
let { data } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Triangles Block Explorer - Dashboard</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
{#if data.error}
|
||||||
|
<div class="bg-tri-red/10 border border-tri-red/30 rounded-lg p-4 mb-6 text-tri-red text-sm">
|
||||||
|
Unable to connect to daemon: {data.error}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if data.chain}
|
||||||
|
<!-- Stats Grid -->
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||||
|
<StatsCard label="Block Height" value={formatNumber(data.chain.blocks)} />
|
||||||
|
<StatsCard
|
||||||
|
label="Supply"
|
||||||
|
value="{formatAmount(data.chain.moneysupply)} TRI"
|
||||||
|
sub="of 120,000 max"
|
||||||
|
/>
|
||||||
|
<StatsCard
|
||||||
|
label="PoS Difficulty"
|
||||||
|
value={formatDifficulty(data.chain.difficulty['proof-of-stake'])}
|
||||||
|
/>
|
||||||
|
<StatsCard
|
||||||
|
label="Best Block"
|
||||||
|
value={truncateHash(data.chain.bestblockhash, 8)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Staking + Mining stats -->
|
||||||
|
{#if data.staking || data.mining}
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||||
|
{#if data.staking}
|
||||||
|
<StatsCard
|
||||||
|
label="Staking"
|
||||||
|
value={data.staking.staking ? 'Active' : 'Inactive'}
|
||||||
|
sub="Network weight: {formatNumber(data.staking.netstakeweight)}"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{#if data.mining}
|
||||||
|
<StatsCard
|
||||||
|
label="Stake Interest"
|
||||||
|
value="{data.mining.stakeinterest}%"
|
||||||
|
sub="Annual rate"
|
||||||
|
/>
|
||||||
|
<StatsCard
|
||||||
|
label="Network Hash"
|
||||||
|
value="{data.mining.netmhashps.toFixed(2)} MH/s"
|
||||||
|
/>
|
||||||
|
<StatsCard
|
||||||
|
label="Pooled Txs"
|
||||||
|
value={formatNumber(data.mining.pooledtx)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Latest Blocks -->
|
||||||
|
<div class="bg-tri-surface border border-tri-border rounded-lg overflow-hidden">
|
||||||
|
<div class="flex items-center justify-between px-4 py-3 border-b border-tri-border">
|
||||||
|
<h2 class="text-white font-semibold">Latest Blocks</h2>
|
||||||
|
<a href="/blocks" class="text-tri-accent text-sm hover:text-tri-accent-light">View all →</a>
|
||||||
|
</div>
|
||||||
|
{#if data.blocks.length > 0}
|
||||||
|
<BlockTable blocks={data.blocks} />
|
||||||
|
{:else}
|
||||||
|
<div class="p-8 text-center text-tri-muted">No blocks available</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { getAddressBalance, getAddressUtxos, getAddressTxids, validateAddress } from '$lib/api';
|
||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ params }) => {
|
||||||
|
try {
|
||||||
|
const [validation, balance, utxos, txids] = await Promise.all([
|
||||||
|
validateAddress(params.addr),
|
||||||
|
getAddressBalance(params.addr),
|
||||||
|
getAddressUtxos(params.addr),
|
||||||
|
getAddressTxids(params.addr)
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!validation.isvalid) {
|
||||||
|
error(400, 'Invalid address');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { address: params.addr, balance, utxos, txids };
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
if (msg.includes('Address index not enabled')) {
|
||||||
|
return { address: params.addr, balance: null, utxos: [], txids: [], indexError: true };
|
||||||
|
}
|
||||||
|
error(404, 'Address not found');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import TxTable from '$lib/components/TxTable.svelte';
|
||||||
|
import { formatAmount, truncateHash } from '$lib/utils';
|
||||||
|
|
||||||
|
let { data } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Address {data.address} - Triangles Explorer</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<h1 class="text-2xl font-bold text-white">Address</h1>
|
||||||
|
<p class="text-tri-accent text-sm font-mono mt-1 break-all">{data.address}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if data.indexError}
|
||||||
|
<div class="bg-tri-yellow/10 border border-tri-yellow/30 rounded-lg p-4 text-tri-yellow text-sm">
|
||||||
|
Address index is not enabled on this node. Start the daemon with <code class="bg-tri-surface px-1 rounded">-addressindex=1</code> to enable address lookups.
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Balance -->
|
||||||
|
{#if data.balance}
|
||||||
|
<div class="bg-tri-surface border border-tri-border rounded-lg p-6 mb-6">
|
||||||
|
<div class="text-tri-muted text-xs uppercase tracking-wider mb-1">Balance</div>
|
||||||
|
<div class="text-3xl font-bold text-white">
|
||||||
|
{formatAmount(data.balance.balance / 100000000)} <span class="text-tri-muted text-lg">TRI</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-tri-muted text-sm mt-1">
|
||||||
|
Total received: {formatAmount(data.balance.received / 100000000)} TRI
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- UTXOs -->
|
||||||
|
{#if data.utxos.length > 0}
|
||||||
|
<div class="bg-tri-surface border border-tri-border rounded-lg mb-6 overflow-hidden">
|
||||||
|
<div class="px-4 py-3 border-b border-tri-border">
|
||||||
|
<h2 class="text-white font-semibold">Unspent Outputs ({data.utxos.length})</h2>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="text-tri-muted text-xs uppercase tracking-wider border-b border-tri-border">
|
||||||
|
<th class="text-left py-3 px-3">Txid</th>
|
||||||
|
<th class="text-center py-3 px-3">Index</th>
|
||||||
|
<th class="text-right py-3 px-3">Amount</th>
|
||||||
|
<th class="text-right py-3 px-3">Height</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each data.utxos as utxo}
|
||||||
|
<tr class="border-b border-tri-border/50">
|
||||||
|
<td class="py-2.5 px-3 font-mono text-xs">
|
||||||
|
<a href="/tx/{utxo.txid}" class="text-tri-accent hover:text-tri-accent-light">{truncateHash(utxo.txid, 10)}</a>
|
||||||
|
</td>
|
||||||
|
<td class="py-2.5 px-3 text-center">{utxo.outputIndex}</td>
|
||||||
|
<td class="py-2.5 px-3 text-right font-mono text-tri-green">{formatAmount(utxo.satoshis / 100000000)} TRI</td>
|
||||||
|
<td class="py-2.5 px-3 text-right">{utxo.height}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Transaction History -->
|
||||||
|
<div class="bg-tri-surface border border-tri-border rounded-lg overflow-hidden">
|
||||||
|
<div class="px-4 py-3 border-b border-tri-border">
|
||||||
|
<h2 class="text-white font-semibold">Transactions ({data.txids.length})</h2>
|
||||||
|
</div>
|
||||||
|
{#if data.txids.length > 0}
|
||||||
|
<TxTable txids={data.txids} limit={50} />
|
||||||
|
{#if data.txids.length > 50}
|
||||||
|
<div class="px-4 py-3 text-center text-tri-muted text-sm border-t border-tri-border">
|
||||||
|
Showing 50 of {data.txids.length} transactions
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<div class="p-8 text-center text-tri-muted">No transactions found</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
const endpoints = [
|
||||||
|
{ method: 'GET', path: '/rest/chaininfo', desc: 'Chain state: height, best block hash, supply, difficulty' },
|
||||||
|
{ method: 'GET', path: '/rest/block/{hash}', desc: 'Block details by hash. Append .hex for raw hex.' },
|
||||||
|
{ method: 'GET', path: '/rest/blockheader/{hash}', desc: 'Block header (lightweight, no tx list)' },
|
||||||
|
{ method: 'GET', path: '/rest/blockbyheight/{n}', desc: 'Block details by height' },
|
||||||
|
{ method: 'GET', path: '/rest/blockhashbyheight/{n}', desc: 'Get block hash at a given height' },
|
||||||
|
{ method: 'GET', path: '/rest/tx/{txid}', desc: 'Transaction details. Append .hex for raw hex.' },
|
||||||
|
{ method: 'GET', path: '/rest/mempool', desc: 'List of unconfirmed transaction IDs' },
|
||||||
|
{ method: 'GET', path: '/rest/difficulty', desc: 'Current PoW and PoS difficulty' },
|
||||||
|
{ method: 'GET', path: '/rest/supply', desc: 'UTXO set statistics and total supply' },
|
||||||
|
{ method: 'GET', path: '/rest/staking', desc: 'Staking network stats (weight, difficulty, expected time)' },
|
||||||
|
{ method: 'GET', path: '/rest/mining', desc: 'Mining info (hashrate, block value, stake interest)' },
|
||||||
|
{ method: 'GET', path: '/rest/subsidy', desc: 'Current block subsidy/reward' },
|
||||||
|
{ method: 'GET', path: '/rest/estimatefee', desc: 'Estimated fee per kilobyte' },
|
||||||
|
{ method: 'GET', path: '/rest/checkpoint', desc: 'Sync checkpoint information' },
|
||||||
|
{ method: 'GET', path: '/rest/network', desc: 'Network info (version, connections, proxy)' },
|
||||||
|
{ method: 'GET', path: '/rest/peers', desc: 'List of connected peers with details' },
|
||||||
|
{ method: 'GET', path: '/rest/validate/{addr}', desc: 'Validate a Triangles address' },
|
||||||
|
{ method: 'GET', path: '/rest/address/{addr}/balance', desc: 'Address balance (requires -addressindex=1)' },
|
||||||
|
{ method: 'GET', path: '/rest/address/{addr}/utxos', desc: 'Address unspent outputs (requires -addressindex=1)' },
|
||||||
|
{ method: 'GET', path: '/rest/address/{addr}/txids', desc: 'Address transaction history. Supports ?start=N&end=N (requires -addressindex=1)' },
|
||||||
|
{ method: 'POST', path: '/rest/tx/decode', desc: 'Decode a raw transaction. Body: {"hex":"..."}' },
|
||||||
|
{ method: 'POST', path: '/rest/tx/send', desc: 'Broadcast a raw transaction. Body: {"hex":"..."}' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const walletEndpoints = [
|
||||||
|
{ method: 'GET', path: '/rest/wallet/info', desc: 'Wallet summary (balance, tx count, keypool)' },
|
||||||
|
{ method: 'GET', path: '/rest/wallet/balance', desc: 'Wallet balance' },
|
||||||
|
{ method: 'GET', path: '/rest/wallet/transactions', desc: 'Recent wallet transactions. Supports ?count=N&skip=N' },
|
||||||
|
{ method: 'GET', path: '/rest/wallet/transaction/{txid}', desc: 'Wallet transaction detail' },
|
||||||
|
{ method: 'GET', path: '/rest/wallet/unspent', desc: 'Wallet unspent outputs. Supports ?minconf=N&maxconf=N' },
|
||||||
|
{ method: 'GET', path: '/rest/wallet/addresses', desc: 'Wallet address groupings' },
|
||||||
|
{ method: 'GET', path: '/rest/wallet/staking', desc: 'Staking info for this wallet' },
|
||||||
|
{ method: 'POST', path: '/rest/wallet/address/new', desc: 'Generate a new receiving address' },
|
||||||
|
{ method: 'POST', path: '/rest/wallet/send', desc: 'Send coins. Body: {"address":"...", "amount":N}' },
|
||||||
|
{ method: 'POST', path: '/rest/wallet/sendmany', desc: 'Send to multiple addresses. Body: {"recipients":{"addr":amount}}' },
|
||||||
|
{ method: 'POST', path: '/rest/wallet/unlock', desc: 'Unlock wallet. Body: {"passphrase":"...", "timeout":N}' },
|
||||||
|
{ method: 'POST', path: '/rest/wallet/lock', desc: 'Lock the wallet' },
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>API Documentation - Triangles Explorer</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<h1 class="text-2xl font-bold text-white mb-2">REST API Documentation</h1>
|
||||||
|
<p class="text-tri-muted text-sm mb-8">
|
||||||
|
The Triangles daemon exposes a REST API on the RPC port (default 19112) when started with <code class="bg-tri-surface px-1 rounded">-rest=1</code>.
|
||||||
|
All responses are JSON. Public endpoints require no authentication.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Public Endpoints -->
|
||||||
|
<div class="bg-tri-surface border border-tri-border rounded-lg mb-8 overflow-hidden">
|
||||||
|
<div class="px-4 py-3 border-b border-tri-border">
|
||||||
|
<h2 class="text-white font-semibold">Public Endpoints</h2>
|
||||||
|
<p class="text-tri-muted text-xs mt-1">No authentication required. Rate limited.</p>
|
||||||
|
</div>
|
||||||
|
<div class="divide-y divide-tri-border/50">
|
||||||
|
{#each endpoints as ep}
|
||||||
|
<div class="px-4 py-3 hover:bg-tri-bg/30 transition-colors">
|
||||||
|
<div class="flex items-center gap-2 mb-1">
|
||||||
|
<span class="inline-block px-2 py-0.5 rounded text-xs font-bold {ep.method === 'GET' ? 'bg-tri-green/20 text-tri-green' : 'bg-tri-accent/20 text-tri-accent-light'}">
|
||||||
|
{ep.method}
|
||||||
|
</span>
|
||||||
|
<code class="text-tri-text text-sm">{ep.path}</code>
|
||||||
|
</div>
|
||||||
|
<p class="text-tri-muted text-xs ml-14">{ep.desc}</p>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Wallet Endpoints -->
|
||||||
|
<div class="bg-tri-surface border border-tri-border rounded-lg overflow-hidden">
|
||||||
|
<div class="px-4 py-3 border-b border-tri-border">
|
||||||
|
<h2 class="text-white font-semibold">Wallet Endpoints</h2>
|
||||||
|
<p class="text-tri-muted text-xs mt-1">Require HTTP Basic Auth (RPC credentials) or Bearer token (<code>-restapikey</code>).</p>
|
||||||
|
</div>
|
||||||
|
<div class="divide-y divide-tri-border/50">
|
||||||
|
{#each walletEndpoints as ep}
|
||||||
|
<div class="px-4 py-3 hover:bg-tri-bg/30 transition-colors">
|
||||||
|
<div class="flex items-center gap-2 mb-1">
|
||||||
|
<span class="inline-block px-2 py-0.5 rounded text-xs font-bold {ep.method === 'GET' ? 'bg-tri-green/20 text-tri-green' : 'bg-tri-accent/20 text-tri-accent-light'}">
|
||||||
|
{ep.method}
|
||||||
|
</span>
|
||||||
|
<code class="text-tri-text text-sm">{ep.path}</code>
|
||||||
|
</div>
|
||||||
|
<p class="text-tri-muted text-xs ml-14">{ep.desc}</p>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Configuration -->
|
||||||
|
<div class="bg-tri-surface border border-tri-border rounded-lg mt-8 p-6">
|
||||||
|
<h2 class="text-white font-semibold mb-4">Configuration</h2>
|
||||||
|
<div class="space-y-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<code class="text-tri-accent">-rest=1</code>
|
||||||
|
<span class="text-tri-muted ml-2">Enable the REST API</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<code class="text-tri-accent">-addressindex=1</code>
|
||||||
|
<span class="text-tri-muted ml-2">Enable address index (required for /address/* endpoints)</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<code class="text-tri-accent">-restcorsorigin=*</code>
|
||||||
|
<span class="text-tri-muted ml-2">Set CORS allowed origin (default: *)</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<code class="text-tri-accent">-restapikey=<token></code>
|
||||||
|
<span class="text-tri-muted ml-2">Bearer token for wallet endpoint authentication</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<code class="text-tri-accent">-restratelimit=30</code>
|
||||||
|
<span class="text-tri-muted ml-2">Rate limit per IP per second (default: 30, 0=disabled)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { getBlock } from '$lib/api';
|
||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ params }) => {
|
||||||
|
try {
|
||||||
|
const block = await getBlock(params.hash);
|
||||||
|
return { block };
|
||||||
|
} catch {
|
||||||
|
error(404, 'Block not found');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import TxTable from '$lib/components/TxTable.svelte';
|
||||||
|
import { formatTimestamp, formatDifficulty, blockType, truncateHash } from '$lib/utils';
|
||||||
|
|
||||||
|
let { data } = $props();
|
||||||
|
const block = data.block;
|
||||||
|
|
||||||
|
const fields: [string, string][] = [
|
||||||
|
['Height', String(block.height)],
|
||||||
|
['Confirmations', String(block.confirmations)],
|
||||||
|
['Timestamp', formatTimestamp(block.time)],
|
||||||
|
['Type', blockType(block.flags)],
|
||||||
|
['Difficulty', formatDifficulty(block.difficulty)],
|
||||||
|
['Transactions', String(block.tx.length)],
|
||||||
|
['Merkle Root', truncateHash(block.merkleroot, 16)],
|
||||||
|
['Nonce', String(block.nonce)],
|
||||||
|
['Bits', block.bits],
|
||||||
|
['Size', block.size ? `${block.size} bytes` : 'N/A'],
|
||||||
|
['Mint', String(block.mint)],
|
||||||
|
['Flags', block.flags]
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Block {block.height} - Triangles Explorer</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<!-- Navigation -->
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-white">Block {block.height}</h1>
|
||||||
|
<p class="text-tri-muted text-xs font-mono mt-1 break-all">{block.hash}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 shrink-0">
|
||||||
|
{#if block.previousblockhash}
|
||||||
|
<a href="/block/{block.previousblockhash}" class="bg-tri-surface border border-tri-border text-tri-text px-3 py-1.5 rounded text-sm hover:border-tri-accent transition-colors">← Prev</a>
|
||||||
|
{/if}
|
||||||
|
{#if block.nextblockhash}
|
||||||
|
<a href="/block/{block.nextblockhash}" class="bg-tri-surface border border-tri-border text-tri-text px-3 py-1.5 rounded text-sm hover:border-tri-accent transition-colors">Next →</a>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Block Info -->
|
||||||
|
<div class="bg-tri-surface border border-tri-border rounded-lg mb-6">
|
||||||
|
<div class="px-4 py-3 border-b border-tri-border">
|
||||||
|
<h2 class="text-white font-semibold">Block Details</h2>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-px bg-tri-border">
|
||||||
|
{#each fields as [label, value]}
|
||||||
|
<div class="bg-tri-surface px-4 py-3">
|
||||||
|
<span class="text-tri-muted text-xs uppercase tracking-wider">{label}</span>
|
||||||
|
<div class="text-tri-text text-sm mt-0.5 font-mono break-all">{value}</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Transactions -->
|
||||||
|
<div class="bg-tri-surface border border-tri-border rounded-lg overflow-hidden">
|
||||||
|
<div class="px-4 py-3 border-b border-tri-border">
|
||||||
|
<h2 class="text-white font-semibold">Transactions ({block.tx.length})</h2>
|
||||||
|
</div>
|
||||||
|
<TxTable txids={block.tx} />
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { getChainInfo, getBlockByHeight } from '$lib/api';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
import type { Block } from '$lib/types';
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ url }) => {
|
||||||
|
const page = parseInt(url.searchParams.get('page') || '1');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const chain = await getChainInfo();
|
||||||
|
const startHeight = chain.blocks - (page - 1) * PAGE_SIZE;
|
||||||
|
|
||||||
|
const blocks: Block[] = [];
|
||||||
|
for (let i = 0; i < PAGE_SIZE && startHeight - i >= 0; i++) {
|
||||||
|
try {
|
||||||
|
blocks.push(await getBlockByHeight(startHeight - i));
|
||||||
|
} catch { break; }
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasNext = startHeight - PAGE_SIZE >= 0;
|
||||||
|
return { blocks, page, hasNext, totalBlocks: chain.blocks };
|
||||||
|
} catch (e) {
|
||||||
|
return { blocks: [], page, hasNext: false, totalBlocks: 0, error: String(e) };
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import BlockTable from '$lib/components/BlockTable.svelte';
|
||||||
|
import Pagination from '$lib/components/Pagination.svelte';
|
||||||
|
import { formatNumber } from '$lib/utils';
|
||||||
|
|
||||||
|
let { data } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Blocks - Triangles Explorer</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<h1 class="text-2xl font-bold text-white">Blocks</h1>
|
||||||
|
<p class="text-tri-muted text-sm mt-1">Total: {formatNumber(data.totalBlocks)} blocks</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-tri-surface border border-tri-border rounded-lg overflow-hidden">
|
||||||
|
{#if data.blocks.length > 0}
|
||||||
|
<BlockTable blocks={data.blocks} />
|
||||||
|
{:else}
|
||||||
|
<div class="p-8 text-center text-tri-muted">No blocks available</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Pagination page={data.page} hasNext={data.hasNext} basePath="/blocks" />
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { getBlockByHeight } from '$lib/api';
|
||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ params }) => {
|
||||||
|
const height = parseInt(params.height);
|
||||||
|
const block = await getBlockByHeight(height);
|
||||||
|
redirect(301, `/block/${block.hash}`);
|
||||||
|
};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<p class="text-tri-muted">Redirecting...</p>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { getMempool } from '$lib/api';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async () => {
|
||||||
|
try {
|
||||||
|
const txids = await getMempool();
|
||||||
|
return { txids };
|
||||||
|
} catch (e) {
|
||||||
|
return { txids: [], error: String(e) };
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import TxTable from '$lib/components/TxTable.svelte';
|
||||||
|
|
||||||
|
let { data } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Mempool - Triangles Explorer</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<h1 class="text-2xl font-bold text-white">Mempool</h1>
|
||||||
|
<p class="text-tri-muted text-sm mt-1">{data.txids.length} unconfirmed transaction{data.txids.length !== 1 ? 's' : ''}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-tri-surface border border-tri-border rounded-lg overflow-hidden">
|
||||||
|
{#if data.txids.length > 0}
|
||||||
|
<TxTable txids={data.txids} />
|
||||||
|
{:else}
|
||||||
|
<div class="p-8 text-center text-tri-muted">Mempool is empty</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { getNetwork, getPeers } from '$lib/api';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async () => {
|
||||||
|
try {
|
||||||
|
const [network, peers] = await Promise.all([getNetwork(), getPeers()]);
|
||||||
|
return { network, peers };
|
||||||
|
} catch (e) {
|
||||||
|
return { network: null, peers: [], error: String(e) };
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import StatsCard from '$lib/components/StatsCard.svelte';
|
||||||
|
import PeerTable from '$lib/components/PeerTable.svelte';
|
||||||
|
|
||||||
|
let { data } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Network - Triangles Explorer</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<h1 class="text-2xl font-bold text-white mb-6">Network</h1>
|
||||||
|
|
||||||
|
{#if data.network}
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||||
|
<StatsCard label="Connections" value={String(data.network.connections)} />
|
||||||
|
<StatsCard label="Protocol Version" value={String(data.network.protocolversion)} />
|
||||||
|
<StatsCard label="Client Version" value={String(data.network.version)} />
|
||||||
|
<StatsCard label="Network" value={data.network.testnet ? 'Testnet' : 'Mainnet'} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if data.network.errors}
|
||||||
|
<div class="bg-tri-yellow/10 border border-tri-yellow/30 rounded-lg p-4 mb-6 text-tri-yellow text-sm">
|
||||||
|
{data.network.errors}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Peers -->
|
||||||
|
<div class="bg-tri-surface border border-tri-border rounded-lg overflow-hidden">
|
||||||
|
<div class="px-4 py-3 border-b border-tri-border">
|
||||||
|
<h2 class="text-white font-semibold">Connected Peers ({data.peers.length})</h2>
|
||||||
|
</div>
|
||||||
|
{#if data.peers.length > 0}
|
||||||
|
<PeerTable peers={data.peers} />
|
||||||
|
{:else}
|
||||||
|
<div class="p-8 text-center text-tri-muted">No peers connected</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { getBlock, getTransaction, getBlockHashByHeight } from '$lib/api';
|
||||||
|
import { redirect, error } from '@sveltejs/kit';
|
||||||
|
import { isNumeric, isBlockHash } from '$lib/utils';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ url }) => {
|
||||||
|
const q = url.searchParams.get('q')?.trim();
|
||||||
|
if (!q) error(400, 'No search query provided');
|
||||||
|
|
||||||
|
// Numeric: treat as block height
|
||||||
|
if (isNumeric(q)) {
|
||||||
|
try {
|
||||||
|
const { blockhash } = await getBlockHashByHeight(parseInt(q));
|
||||||
|
redirect(302, `/block/${blockhash}`);
|
||||||
|
} catch {
|
||||||
|
error(404, `Block at height ${q} not found`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 64-char hex: try block hash, then txid
|
||||||
|
if (isBlockHash(q)) {
|
||||||
|
try {
|
||||||
|
await getBlock(q);
|
||||||
|
redirect(302, `/block/${q}`);
|
||||||
|
} catch {
|
||||||
|
// Not a block, try as txid
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await getTransaction(q);
|
||||||
|
redirect(302, `/tx/${q}`);
|
||||||
|
} catch {
|
||||||
|
error(404, `No block or transaction found for ${q}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise: try as address
|
||||||
|
redirect(302, `/address/${q}`);
|
||||||
|
};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<p class="text-tri-muted">Searching...</p>
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { getStaking, getMining, getSubsidy, getDifficulty, getChainInfo, getBlockHeader, getBlockHashByHeight } from '$lib/api';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
import type { BlockHeader } from '$lib/types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async () => {
|
||||||
|
try {
|
||||||
|
const [staking, mining, subsidy, difficulty, chain] = await Promise.all([
|
||||||
|
getStaking(),
|
||||||
|
getMining(),
|
||||||
|
getSubsidy(),
|
||||||
|
getDifficulty(),
|
||||||
|
getChainInfo()
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Fetch last 50 block headers for difficulty chart
|
||||||
|
const headers: BlockHeader[] = [];
|
||||||
|
for (let i = 0; i < 50 && chain.blocks - i >= 0; i++) {
|
||||||
|
try {
|
||||||
|
const { blockhash } = await getBlockHashByHeight(chain.blocks - i);
|
||||||
|
headers.push(await getBlockHeader(blockhash));
|
||||||
|
} catch { break; }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { staking, mining, subsidy, difficulty, headers: headers.reverse() };
|
||||||
|
} catch (e) {
|
||||||
|
return { staking: null, mining: null, subsidy: null, difficulty: null, headers: [], error: String(e) };
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import StatsCard from '$lib/components/StatsCard.svelte';
|
||||||
|
import { formatNumber, formatDifficulty } from '$lib/utils';
|
||||||
|
import { Chart, registerables } from 'chart.js';
|
||||||
|
|
||||||
|
let { data } = $props();
|
||||||
|
let chartCanvas: HTMLCanvasElement;
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (data.headers.length > 0) {
|
||||||
|
Chart.register(...registerables);
|
||||||
|
new Chart(chartCanvas, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: data.headers.map(h => h.height),
|
||||||
|
datasets: [{
|
||||||
|
label: 'PoS Difficulty',
|
||||||
|
data: data.headers.filter(h => h.flags.includes('proof-of-stake')).map(h => h.difficulty),
|
||||||
|
borderColor: '#22c55e',
|
||||||
|
backgroundColor: 'rgba(34, 197, 94, 0.1)',
|
||||||
|
fill: true,
|
||||||
|
tension: 0.3,
|
||||||
|
pointRadius: 0
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
plugins: { legend: { labels: { color: '#94a3b8' } } },
|
||||||
|
scales: {
|
||||||
|
x: { ticks: { color: '#94a3b8', maxTicksLimit: 10 }, grid: { color: '#2a2d3a' } },
|
||||||
|
y: { ticks: { color: '#94a3b8' }, grid: { color: '#2a2d3a' } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Staking - Triangles Explorer</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<h1 class="text-2xl font-bold text-white mb-6">Staking Dashboard</h1>
|
||||||
|
|
||||||
|
{#if data.staking && data.mining}
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||||
|
<StatsCard label="Staking Active" value={data.staking.staking ? 'Yes' : 'No'} />
|
||||||
|
<StatsCard label="PoS Difficulty" value={formatDifficulty(data.staking.difficulty)} />
|
||||||
|
<StatsCard label="Network Stake Weight" value={formatNumber(data.staking.netstakeweight)} />
|
||||||
|
<StatsCard label="Stake Interest" value="{data.mining.stakeinterest}% annual" />
|
||||||
|
<StatsCard label="Block Subsidy" value={data.subsidy != null ? String(data.subsidy) : 'N/A'} />
|
||||||
|
<StatsCard label="Net Hash Rate" value="{data.mining.netmhashps.toFixed(2)} MH/s" />
|
||||||
|
<StatsCard label="PoW Difficulty" value={data.difficulty ? formatDifficulty(data.difficulty['proof-of-work']) : 'N/A'} />
|
||||||
|
<StatsCard
|
||||||
|
label="Expected Time"
|
||||||
|
value={data.staking.expectedtime > 0 ? `${Math.floor(data.staking.expectedtime / 3600)}h ${Math.floor((data.staking.expectedtime % 3600) / 60)}m` : 'N/A'}
|
||||||
|
sub="Until next stake"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Difficulty Chart -->
|
||||||
|
{#if data.headers.length > 0}
|
||||||
|
<div class="bg-tri-surface border border-tri-border rounded-lg p-4">
|
||||||
|
<h2 class="text-white font-semibold mb-4">PoS Difficulty (Last 50 blocks)</h2>
|
||||||
|
<canvas bind:this={chartCanvas}></canvas>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<div class="bg-tri-red/10 border border-tri-red/30 rounded-lg p-4 text-tri-red text-sm">
|
||||||
|
Unable to load staking data. Is the daemon running?
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { getTransaction } from '$lib/api';
|
||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ params }) => {
|
||||||
|
try {
|
||||||
|
const tx = await getTransaction(params.txid);
|
||||||
|
return { tx };
|
||||||
|
} catch {
|
||||||
|
error(404, 'Transaction not found');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { formatTimestamp, formatAmount, truncateHash } from '$lib/utils';
|
||||||
|
|
||||||
|
let { data } = $props();
|
||||||
|
const tx = data.tx;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Tx {truncateHash(tx.txid, 8)} - Triangles Explorer</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<h1 class="text-2xl font-bold text-white">Transaction</h1>
|
||||||
|
<p class="text-tri-muted text-xs font-mono mt-1 break-all">{tx.txid}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tx Info -->
|
||||||
|
<div class="bg-tri-surface border border-tri-border rounded-lg mb-6">
|
||||||
|
<div class="px-4 py-3 border-b border-tri-border">
|
||||||
|
<h2 class="text-white font-semibold">Details</h2>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-px bg-tri-border">
|
||||||
|
{#if tx.blockhash}
|
||||||
|
<div class="bg-tri-surface px-4 py-3">
|
||||||
|
<span class="text-tri-muted text-xs uppercase tracking-wider">Block</span>
|
||||||
|
<div class="text-sm mt-0.5">
|
||||||
|
<a href="/block/{tx.blockhash}" class="text-tri-accent hover:text-tri-accent-light font-mono">{truncateHash(tx.blockhash, 12)}</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="bg-tri-surface px-4 py-3">
|
||||||
|
<span class="text-tri-muted text-xs uppercase tracking-wider">Confirmations</span>
|
||||||
|
<div class="text-tri-text text-sm mt-0.5">{tx.confirmations ?? 'Unconfirmed'}</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-tri-surface px-4 py-3">
|
||||||
|
<span class="text-tri-muted text-xs uppercase tracking-wider">Timestamp</span>
|
||||||
|
<div class="text-tri-text text-sm mt-0.5 font-mono">{formatTimestamp(tx.time)}</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-tri-surface px-4 py-3">
|
||||||
|
<span class="text-tri-muted text-xs uppercase tracking-wider">Version</span>
|
||||||
|
<div class="text-tri-text text-sm mt-0.5">{tx.version}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Inputs -->
|
||||||
|
<div class="bg-tri-surface border border-tri-border rounded-lg mb-6 overflow-hidden">
|
||||||
|
<div class="px-4 py-3 border-b border-tri-border">
|
||||||
|
<h2 class="text-white font-semibold">Inputs ({tx.vin.length})</h2>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="text-tri-muted text-xs uppercase tracking-wider border-b border-tri-border">
|
||||||
|
<th class="text-left py-3 px-3">#</th>
|
||||||
|
<th class="text-left py-3 px-3">Source</th>
|
||||||
|
<th class="text-left py-3 px-3">Script</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each tx.vin as vin, i}
|
||||||
|
<tr class="border-b border-tri-border/50">
|
||||||
|
<td class="py-2.5 px-3 text-tri-muted">{i}</td>
|
||||||
|
<td class="py-2.5 px-3 font-mono text-xs">
|
||||||
|
{#if vin.coinbase}
|
||||||
|
<span class="text-tri-green">Coinbase / Staking reward</span>
|
||||||
|
{:else if vin.txid}
|
||||||
|
<a href="/tx/{vin.txid}" class="text-tri-accent hover:text-tri-accent-light">
|
||||||
|
{truncateHash(vin.txid, 10)}:{vin.vout}
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td class="py-2.5 px-3 font-mono text-xs text-tri-muted max-w-xs truncate">
|
||||||
|
{vin.scriptSig?.asm || vin.coinbase || ''}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Outputs -->
|
||||||
|
<div class="bg-tri-surface border border-tri-border rounded-lg overflow-hidden">
|
||||||
|
<div class="px-4 py-3 border-b border-tri-border">
|
||||||
|
<h2 class="text-white font-semibold">Outputs ({tx.vout.length})</h2>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="text-tri-muted text-xs uppercase tracking-wider border-b border-tri-border">
|
||||||
|
<th class="text-left py-3 px-3">#</th>
|
||||||
|
<th class="text-left py-3 px-3">Address</th>
|
||||||
|
<th class="text-right py-3 px-3">Amount</th>
|
||||||
|
<th class="text-left py-3 px-3">Type</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each tx.vout as vout}
|
||||||
|
<tr class="border-b border-tri-border/50">
|
||||||
|
<td class="py-2.5 px-3 text-tri-muted">{vout.n}</td>
|
||||||
|
<td class="py-2.5 px-3 font-mono text-xs">
|
||||||
|
{#if vout.scriptPubKey.addresses}
|
||||||
|
{#each vout.scriptPubKey.addresses as addr}
|
||||||
|
<a href="/address/{addr}" class="text-tri-accent hover:text-tri-accent-light">{addr}</a>
|
||||||
|
{/each}
|
||||||
|
{:else}
|
||||||
|
<span class="text-tri-muted">N/A</span>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td class="py-2.5 px-3 text-right font-mono text-tri-green">{formatAmount(vout.value)} TRI</td>
|
||||||
|
<td class="py-2.5 px-3 text-xs text-tri-muted">{vout.scriptPubKey.type}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import adapter from '@sveltejs/adapter-node';
|
||||||
|
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||||
|
|
||||||
|
/** @type {import('@sveltejs/kit').Config} */
|
||||||
|
const config = {
|
||||||
|
preprocess: vitePreprocess(),
|
||||||
|
kit: {
|
||||||
|
adapter: adapter()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"extends": "./.svelte-kit/tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"allowJs": true,
|
||||||
|
"checkJs": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"strict": true,
|
||||||
|
"moduleResolution": "bundler"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { sveltekit } from '@sveltejs/kit/vite';
|
||||||
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [tailwindcss(), sveltekit()]
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user