Merge origin/master (the explorer pages, the public API check, the CLAUDE.md note) into release-0.3.10; docs, site, observer and ci.yml only
This commit is contained in:
commit
ff873f6d94
37 changed files with 2341 additions and 22 deletions
5
.github/workflows/ci.yml
vendored
5
.github/workflows/ci.yml
vendored
|
|
@ -77,6 +77,11 @@ jobs:
|
|||
run: bash tools/ci/no-secrets-check.sh --self-test && bash tools/ci/no-secrets-check.sh
|
||||
- name: faucet unit tests (validation, the daily limits, the signed transaction; keccak, RLP and secp256k1 vectors)
|
||||
run: node --test site/api/faucet.test.mjs
|
||||
- name: explorer and public stats unit tests (search router, formatters, emission rule against the node's own test values, the documented API fields from a fixture)
|
||||
run: node --test site/lib/explorer.test.mjs site/lib/emission.test.mjs site/api/public-stats.test.mjs
|
||||
- name: public stats API answers with the documented fields (the live site; master only, the endpoints exist there after the merge)
|
||||
if: github.ref == 'refs/heads/master'
|
||||
run: node tools/ci/public-api-check.mjs https://igneum.network
|
||||
- name: ship tool self-test (version bump, the dl-both and public manifest helpers)
|
||||
run: node tools/ship-app.mjs --self-test
|
||||
- name: relay unit tests (parsers, secret compare, the wake endpoint)
|
||||
|
|
|
|||
227
docs/api/public-stats.md
Normal file
227
docs/api/public-stats.md
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
# Public stats API
|
||||
|
||||
5 October 2026. Two JSON endpoints on the site for profitability sites, pool software and anyone who wants the
|
||||
network numbers without running a node: `/api/stats` and `/api/supply`. WhatToMine's listing form asks for an
|
||||
explorer or pool with an API, the reward halving schedule and a source to fetch total coins from; this is that source.
|
||||
A third endpoint, `/api/explorer`, feeds the explorer pages (docs/plans/explorer.md).
|
||||
|
||||
Both are served by Vercel functions (`site/api/stats.mjs`, `site/api/supply.mjs`) that read what the devnet observer
|
||||
(`tools/observer/observer.mjs`) wrote to Neon; no secret is involved beyond the database connection the site already
|
||||
holds. Cached 10 s at the edge (`Cache-Control: public, max-age=10, s-maxage=10`), CORS open (`Access-Control-Allow-Origin: *`),
|
||||
GET only. A failed read answers 500 with `{ok: false, error}` and `Cache-Control: no-store`.
|
||||
|
||||
The contract is the `FIELDS` list exported by each handler. `site/api/public-stats.test.mjs` checks a fixture against
|
||||
it without a database, and `tools/ci/public-api-check.mjs <url>` checks a deployment (CI runs it against
|
||||
https://igneum.network on master).
|
||||
|
||||
## /api/stats
|
||||
|
||||
| Field | Meaning | Source |
|
||||
|---|---|---|
|
||||
| `network`, `chain_id`, `node_version` | Network name, EVM chain id (4461 mainnet, 4462 testnet, 4463 devnet, design 8.1), the node's version | observer `live_state` |
|
||||
| `algorithm` | The lottery hash, named | fixed text |
|
||||
| `stale`, `age_s`, `observer_updated_at` | `stale` when the observer has not written for 30 s; treat every number as last known then | observer |
|
||||
| `height` | The chain block number (the EVM block number): the number of the newest chain block the observer has a shard plan for | `live_blocks.number` |
|
||||
| `block_count`, `header_count` | Every DAG block the node holds | `getBlockDagInfo` |
|
||||
| `daa`, `blue_score` | DAA score and blue score of the newest block | `live_blocks`, `getSinkBlueScore` |
|
||||
| `difficulty` | The node's difficulty (target per block) | `getBlockDagInfo` |
|
||||
| `hashrate`, `hashrate_unit`, `hashrate_source` | H/s: the node's `estimateNetworkHashesPerSecond` over 1,000 blocks, else blue work added per second over 10 min | observer |
|
||||
| `block_time_target_s`, `block_time_measured_s` | 1 s by design (spec 2.1); 60 / DAG blocks in the last 60 s | observer |
|
||||
| `blocks_per_day_target`, `blocks_per_day_measured` | 86,400; the last hour's DAG blocks x 24 (null until the observer has an hour) | observer |
|
||||
| `block_reward` | `E(daa)` of spec 2.5 at the newest DAA score: `sompi` (8 decimals), `ign`, the 80% `miner_ign` and 20% `proving_pool_ign`, `ramp_factor`, `halving_period`, `next_halving_daa`, `next_halving_in_s` | `site/lib/emission.mjs` |
|
||||
| `last_block` | Hash, time, age, blue score, DAA, coinbase address, vote key id, EVM transaction count, whether it is a chain block | `live_blocks` |
|
||||
| `finality` | Finality v2: active, the latest locked checkpoint index and blue score | observer |
|
||||
| `peers`, `mempool`, `miners_10m` | Connected peers, mempool size, distinct vote keys in 10 min (a card runs several) | observer |
|
||||
|
||||
Note for a profitability calculator: `block_reward` is per blue block merged; at the 1 block per second target that is
|
||||
also the reward per DAA second. The 20% proving-pool part is paid to provers, not to the miner of the block, so a
|
||||
miner's expected income per block is `miner_ign`. During the 30-day launch ramp the reward climbs from 10% to 100%
|
||||
of the schedule (`ramp_factor`); `/api/supply` shows where the ramp stands.
|
||||
|
||||
Example, the devnet on 5 October 2026 (through the local preview against a test observer, so `block_time_measured_s`
|
||||
reflects a one-minute window):
|
||||
|
||||
```
|
||||
{
|
||||
"ok": true,
|
||||
"now": "2026-10-05T19:32:09.321Z",
|
||||
"network": "igneum-devnet",
|
||||
"chain_id": 4463,
|
||||
"node_version": "2.1.0",
|
||||
"algorithm": "Igneum lottery hash: random-program GPU hash, new program every hour, generator v2 (docs/spec/01-lottery-hash.md)",
|
||||
"stale": false,
|
||||
"age_s": 0.9,
|
||||
"height": 82145,
|
||||
"block_count": 126358,
|
||||
"header_count": 126358,
|
||||
"daa": 126357,
|
||||
"blue_score": 123504,
|
||||
"difficulty": 125543017.00697394,
|
||||
"hashrate": 258756880,
|
||||
"hashrate_unit": "H/s",
|
||||
"hashrate_source": "the node's estimateNetworkHashesPerSecond over a 1,000-block window; blue work added per second over 10 min when the node refuses the window",
|
||||
"block_time_target_s": 1,
|
||||
"block_time_measured_s": 0.952,
|
||||
"blocks_per_day_target": 86400,
|
||||
"blocks_per_day_measured": 14040,
|
||||
"block_reward": {
|
||||
"sompi": "455909062",
|
||||
"ign": "4.55909062",
|
||||
"miner_ign": "3.6472725",
|
||||
"proving_pool_ign": "0.91181812",
|
||||
"split": "80% block producer, 20% proving pool",
|
||||
"daa_used": 126357,
|
||||
"ramp_factor": 0.143874,
|
||||
"halving_period": 0,
|
||||
"next_halving_daa": 63115200,
|
||||
"next_halving_in_s": 62988843
|
||||
},
|
||||
"last_block": {
|
||||
"hash": "bbec3139d3f38e3517716374707998e77f536a67813edfb619c5bed93a5779ab",
|
||||
"time": "2026-10-05T19:32:06.157Z",
|
||||
"ts_ms": 1791228726157,
|
||||
"age_s": 3.2,
|
||||
"blue_score": 123504,
|
||||
"daa": 126357,
|
||||
"miner": "igneumdev:qrt8nzgrghr2a2xuc7lstzclcnt3n632d2chhc946rphkc6flcyswvkrym8s4",
|
||||
"miner_id": "7b8ef6fd",
|
||||
"tx_count": 0,
|
||||
"chain": true
|
||||
},
|
||||
"finality": {
|
||||
"active": true,
|
||||
"latest_locked_index": 4116,
|
||||
"latest_locked_blue_score": 123480,
|
||||
"chain_id": "igneum-devnet"
|
||||
},
|
||||
"peers": 4,
|
||||
"mempool": 0,
|
||||
"miners_10m": 21,
|
||||
"observer_updated_at": "2026-10-05T19:32:08.394726+00:00",
|
||||
"source": "tools/observer reading one node every 2 s; reward from docs/spec/02-consensus.md 2.5 at the node's DAA score"
|
||||
}
|
||||
```
|
||||
|
||||
## /api/supply
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `unit` | IGN; the coinbase pays in 8-decimal units (open item O-2.6), the EVM shows 18 |
|
||||
| `daa` | The newest DAA score the observer stored |
|
||||
| `max_supply_ign` | 4,000,000,000, the hard cap (spec 2.5, no tail emission: spec 5.10) |
|
||||
| `circulating_ign`, `circulating_sompi` | Minted so far by the rule: `E(t)` summed over every DAA second from 0 to `daa`, exact (floor sum, `mintedByRule`) |
|
||||
| `minted_at_end_ign`, `never_minted_ign` | What the schedule reaches when the per-second rate hits 0 (period 32), and the part of the cap the ramp and the floors never mint |
|
||||
| `emission_per_second_ign`, `block_reward_ign` | `E(daa)` now |
|
||||
| `halving` | Interval 63,115,200 DAA s (two years), current period, the next halving's DAA score, seconds to it, a date estimate at one DAA second per second |
|
||||
| `ramp` | 10% at genesis to 100% at DAA 2,592,000 (30 days), the factor now, whether it is complete |
|
||||
| `schedule` | 33 rows: period, start and end DAA, years from genesis, IGN per second, IGN per block at 1 BPS, minted by the end of the period, share of the cap |
|
||||
| `check` | The observer's hourly comparison of the chain against the rule over its newest 500 blocks: `rule_match` counts blocks whose coinbase payload declares exactly `E(daa)`; `sum_match` counts blocks whose coinbase outputs equal the declared subsidies of the blocks they merge; `examples` names mismatches |
|
||||
| `rule`, `source`, `note` | The formula, where it lives, and the caveat: the chain pays per block merged, so a block rate above target mints above the schedule for as long as it lasts |
|
||||
|
||||
Example (schedule cut to five rows here):
|
||||
|
||||
```
|
||||
{
|
||||
"ok": true,
|
||||
"now": "2026-10-05T19:32:09.374Z",
|
||||
"network": "igneum-devnet",
|
||||
"chain_id": 4463,
|
||||
"unit": {
|
||||
"symbol": "IGN",
|
||||
"decimals_consensus": 8,
|
||||
"decimals_evm": 18,
|
||||
"note": "the coinbase pays in 8-decimal units (open item O-2.6 keeps Kaspa's SOMPI_PER_KASPA); the EVM shows the same amount at 18 decimals"
|
||||
},
|
||||
"daa": 126357,
|
||||
"max_supply_ign": "4000000000",
|
||||
"circulating_ign": "488236.39686436",
|
||||
"circulating_sompi": "48823639686436",
|
||||
"minted_at_end_ign": "3963038988.86765648",
|
||||
"never_minted_ign": "36961011.13234352",
|
||||
"emission_per_second_ign": "4.55909062",
|
||||
"block_reward_ign": "4.55909062",
|
||||
"halving": {
|
||||
"interval_daa_s": 63115200,
|
||||
"interval_years": 2,
|
||||
"period": 0,
|
||||
"next_halving_daa": 63115200,
|
||||
"next_halving_in_s": 62988843,
|
||||
"next_halving_estimate": "2028-10-03T20:26:12.374Z",
|
||||
"estimate_note": "the estimate assumes one DAA second per wall-clock second from now"
|
||||
},
|
||||
"ramp": {
|
||||
"start_percent": 10,
|
||||
"length_daa_s": 2592000,
|
||||
"length_days": 30,
|
||||
"factor_now": 0.143874,
|
||||
"complete": false,
|
||||
"remaining_s": 2465643,
|
||||
"withheld_ign": "36961011.13234352",
|
||||
"withheld_note": "the ramp withholds about 37 million IGN that are never minted; integer floors withhold the rest (spec 2.5)"
|
||||
},
|
||||
"schedule": [
|
||||
{
|
||||
"period": 0,
|
||||
"start_daa": 0,
|
||||
"end_daa": 63115200,
|
||||
"years_from_genesis": "0 to 2",
|
||||
"per_second_ign": "31.68808781",
|
||||
"per_block_ign_at_1bps": "31.68808781",
|
||||
"minted_by_end_ign": "1963038999.85152848",
|
||||
"share_of_cap_by_end": 49.0759
|
||||
},
|
||||
{
|
||||
"period": 1,
|
||||
"start_daa": 63115200,
|
||||
"end_daa": 126230400,
|
||||
"years_from_genesis": "2 to 4",
|
||||
"per_second_ign": "15.8440439",
|
||||
"per_block_ign_at_1bps": "15.8440439",
|
||||
"minted_by_end_ign": "2963038999.40880848",
|
||||
"share_of_cap_by_end": 74.0759
|
||||
},
|
||||
{
|
||||
"period": 2,
|
||||
"start_daa": 126230400,
|
||||
"end_daa": 189345600,
|
||||
"years_from_genesis": "4 to 6",
|
||||
"per_second_ign": "7.92202195",
|
||||
"per_block_ign_at_1bps": "7.92202195",
|
||||
"minted_by_end_ign": "3463038999.18744848",
|
||||
"share_of_cap_by_end": 86.5759
|
||||
},
|
||||
"... 29 more rows ...",
|
||||
{
|
||||
"period": 32,
|
||||
"start_daa": 2019686400,
|
||||
"end_daa": 2082801600,
|
||||
"years_from_genesis": "64 to 66",
|
||||
"per_second_ign": "0",
|
||||
"per_block_ign_at_1bps": "0",
|
||||
"minted_by_end_ign": "3963038988.86765648",
|
||||
"share_of_cap_by_end": 99.0759
|
||||
}
|
||||
],
|
||||
"check": {
|
||||
"bps": 1,
|
||||
"sampled": 470,
|
||||
"examples": [],
|
||||
"sum_match": 466,
|
||||
"checked_at": "2026-10-05T19:30:28.385Z",
|
||||
"rule_match": 470,
|
||||
"sum_skipped": 4,
|
||||
"sum_mismatch": 0,
|
||||
"rule_mismatch": 0
|
||||
},
|
||||
"rule": "E(t) = ramp(t) * floor(10^9 * UNIT / 31,557,600) >> floor(t / 63,115,200); ramp(t) = min(1, 1/10 + 9/10 * t / 2,592,000); t = DAA score / bps, bps = 1",
|
||||
"source": "docs/spec/02-consensus.md 2.5; vendor/igneum-node consensus/core/src/igneum.rs block_subsidy and launch_ramp; circulating = the rule summed over every DAA second from 0 to the node's DAA score (site/lib/emission.mjs mintedByRule, exact)",
|
||||
"note": "circulating is the schedule at this DAA score. The chain pays E per blue block it merges and per red inside the DAA window, which tracks the schedule one block per DAA step; the devnet of 3 October 2026 ran 4.7x the schedule for eight minutes during a retarget lag (spec 2.5). check reports what the observer measured on the newest blocks."
|
||||
}
|
||||
```
|
||||
|
||||
## /api/explorer
|
||||
|
||||
The explorer's own feed, cached 5 s: `?blocks=N[&before=ms]` (latest blocks), `?block=hash`, `?height=N`,
|
||||
`?address=0x..|igneumdev:..`, `?search=q`. Shapes are in `site/api/explorer.mjs`; the pages are the reference client.
|
||||
Balances need `EXPLORER_EVM_RPC` on the deployment (a public EVM JSON-RPC); without it `balance.available` is false
|
||||
with the reason.
|
||||
129
docs/plans/explorer.md
Normal file
129
docs/plans/explorer.md
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
# Explorer: plan and recommendation
|
||||
|
||||
5 October 2026, after Josh read WhatToMine's listing requirements ("build it, and do we build our own explorer? who
|
||||
built etherscan?"). Branch `explorer`. What exists tonight: the public stats API (`docs/api/public-stats.md`) and the
|
||||
first DAG explorer pages on the site, fed by the observer. What is recommended: Blockscout for the EVM side, our own
|
||||
DAG and mining pages, one shared search box.
|
||||
|
||||
## 1. Who built Etherscan, and the open-source routes
|
||||
|
||||
| Explorer | What it is | Licence and cost | Fit for Igneum |
|
||||
|---|---|---|---|
|
||||
| Etherscan | Built and launched in 2015 by Matthew Tan (CEO and founder); the office since January 2017 (etherscan.io/aboutus, read 5 October 2026; the page does not name the city, Josh's brief says Kuala Lumpur). Since 2020 it sells "Explorer as a Service", a white-label instance for other chains, 40 clients by 2025 (same page) | Closed source, a private company; a chain pays for an instance. Price not published; not asked | Not for us: closed, paid, and it would show nothing of the DAG, the finality or the proving layer |
|
||||
| Blockscout | Open-source EVM explorer: blocks, transactions, accounts, verified contracts, token pages, an API in Etherscan's shape. Elixir (Phoenix) backend, PostgreSQL, a separate frontend; "several hundred chains and rollups" use it (README, read 5 October 2026) | "Blockscout Software Licence" (the README badge; the licence text was not read line by line, so what it permits for a hosted instance is unverified) | The EVM side for free: contracts, transactions, logs, tokens, an API developers already know |
|
||||
| Otterscan | "open-source, fast, local, laptop-friendly Ethereum block explorer": a React app over an Erigon archive node, using Erigon's custom `ots_` JSON-RPC methods (github.com/otterscan/otterscan, read 5 October 2026) | MIT (the app); the `ots_` API lives inside Erigon under its licence | Not for us: it needs Erigon's RPC extensions, which the Igneum node does not have, and it has no contract verification |
|
||||
|
||||
## 2. What Blockscout gives us for free, and what it costs to run
|
||||
|
||||
Blockscout indexes through standard JSON-RPC. Its documented requirements (docs.blockscout.com, read 5 October 2026):
|
||||
|
||||
| Item | Blockscout's figure | Igneum node today (`vendor/igneum-node` 0.3.6 fork, `igneum/exec/src/rpc.rs`, 938 lines) |
|
||||
|---|---|---|
|
||||
| Software | Erlang/OTP 26, Elixir 1.15.x, Postgres 14+, Node.js 18.x.x (docs: setup/requirements/requirements) | n/a |
|
||||
| Hardware, the docs' base line | "16 core, 32 thread", "128GB" RAM; the AWS example is one m5a.xlarge (4 vCPU, 16 GB) application server with 8 GB EBS and one db.t3.large RDS Postgres 14+ with 500 GB "depending on chain size" (docs: setup/requirements/resource-requirements) | A devnet at 0 EVM transactions per block needs nothing like the base line; the AWS example is the honest size for a small chain |
|
||||
| Database | Ethereum mainnet 21,000 GiB, Sepolia 5,200 GiB, Ethereum Classic 555 GiB, Gnosis Chiado 470 GiB (docs: setup/requirements/database-storage-requirements, figures dated 23 December 2024) | Unmeasured for Igneum. At one chain block per second with empty blocks the row count is 86,400 blocks a day; the byte size per block is the thing to measure in the first week |
|
||||
| RPC it needs from every client | `eth_blockNumber`, `eth_call`, `eth_getBalance`, `eth_getCode`, `eth_getBlockByHash`, `eth_getBlockByNumber`, `eth_getTransactionByHash`, `eth_getTransactionByBlockHashAndIndex`, `eth_getTransactionByBlockNumberAndIndex`, `eth_getTransactionReceipt`, `eth_getUncleByBlockHashAndIndex`, `eth_getLogs` (docs: setup/requirements/node-tracing-json-rpc-requirements) | The fork answers `eth_blockNumber`, `eth_call`, `eth_getBalance`, `eth_getCode`, `eth_getBlockByHash`, `eth_getBlockByNumber`, `eth_getTransactionByHash`, `eth_getTransactionByBlockNumberAndIndex`, `eth_getTransactionReceipt`, `eth_getLogs` (the match arms of `rpc.rs`). MISSING: `eth_getTransactionByBlockHashAndIndex`, `eth_getUncleByBlockHashAndIndex` (design 8.2 says uncles are "always empty": the method still has to exist). Also absent from the fork but in the design table: `eth_getStorageAt` is present; `eth_getProof`, `eth_feeHistory` present; `web3_clientVersion`, `net_version`, `net_peerCount`, `net_listening`, `eth_syncing`, `eth_mining`, `eth_protocolVersion`, `eth_accounts`, `eth_getBlockReceipts`, `eth_getBlockTransactionCountByNumber`, `eth_maxPriorityFeePerGas`, `eth_gasPrice`, `eth_estimateGas`, `eth_sendRawTransaction` present |
|
||||
| Pending transactions | `txpool_content` (geth, erigon) or `parity_pendingTransactions` | Neither exists in the fork. Blockscout runs without it (the pending view stays empty) |
|
||||
| Internal transactions and block rewards | `debug_traceBlockByNumber` and `debug_traceTransaction` with `callTracer` (geth variant), or `trace_replayBlockTransactions` and `trace_block` (erigon, nethermind) | None of the four exist in the fork. Design 8.2 lists them as "Supported, revm inspectors"; `rpc.rs` has no `debug_` or `trace_` method today. Without them Blockscout shows no internal transactions and no block-reward rows, and the indexer's trace fetcher must be switched off (`INDEXER_DISABLE_INTERNAL_TRANSACTIONS_FETCHER`, Blockscout's env; unverified against the current version) |
|
||||
|
||||
Cost of one instance on Hetzner (the price list the seeds are on, `docs/plans/seed-nodes.md`: cx23 2 vCPU 4 GB at USD 6.49 net a month; larger types not priced here): Blockscout's own AWS example is 4 vCPU 16 GB plus a 2 vCPU 8 GB database. The matching Hetzner shape is one box in the 8 GB to 16 GB class plus Postgres on the same box for a devnet, a second box for the database when the chain carries real traffic. Price it from the Hetzner API when the box is ordered; the figure here is approximate: USD 15 to 40 a month for the single box, under USD 80 for two. Plus an Igneum node on the same box or next to it (Blockscout wants a local, unlimited RPC; the public `rpc.testnet.igneum.network` is rate limited to 20 req/s, `docs/plans/testnet-go.md`).
|
||||
|
||||
What it costs in work, in hours not weeks: the two missing `eth_` methods (small, same shape as their by-number siblings); a decision on tracing (the `debug_` namespace with revm inspectors, design 8.2, is the larger piece and is not needed to run Blockscout without internal transactions); Blockscout's env file and a Docker compose on the box; the chain's entry in its config (chain id 4463 devnet, 4462 testnet, 4461 mainnet, design 8.1); contract verification through Sourcify or Blockscout's own verifier microservice.
|
||||
|
||||
## 3. What Igneum needs that must be ours
|
||||
|
||||
Blockscout shows a chain: numbered blocks, one parent, transactions, accounts. Igneum's execution layer is such a chain (RPC "blocks" are chain blocks, design 8.2), so Blockscout is right for it. Everything the consensus layer adds is invisible to it:
|
||||
|
||||
| Need | Where it comes from | State tonight |
|
||||
|---|---|---|
|
||||
| The DAG: every block, its parents, blue or red, pending, the selected chain, the mergeset of each chain block | the observer's `blockAdded` feed (`live_blocks`, `detail.mergeset`) | `/explorer` table and `/block/<hash>` built; the live DAG picture stays on `/live` |
|
||||
| Blue score and DAA score per block, the miner's vote key, the engine tag | the block header and coinbase | built |
|
||||
| Miners: payout address (the coinbase's `IGNA` tag) and the coinbase address, blocks mined, what they earned, balance | observer columns `evm_miner`, `miner_address`; `eth_getBalance` through `EXPLORER_EVM_RPC` | `/address/<addr>` built; balance shows when the deployment has an EVM RPC (none public for the devnet; `rpc.testnet.igneum.network` for the testnet) |
|
||||
| The lottery program per epoch (which generated program is live, the epoch seed, the VDF) | the node reports `epochSeed` per shard plan (`igneum_getShardPlan`); the program id and the epoch boundary are not in any RPC the observer reads | not built; needs an RPC for the current program id and epoch (open) |
|
||||
| Finality: checkpoints every 30 blue score, locks at two thirds of all 30-day weight, certificates, voter tables, the in-browser verifier | `live_checkpoints`, `live_certificates`, `/api/checkpoint`, `site/verify` | the block page shows a block's checkpoint state and certificate; a checkpoints list page is not built |
|
||||
| Proof records and shards: the plan per chain block, who proved what, lag, payout | `live_proofs`, `igneum_getProofRecords` | the block page shows the shards and the records a block carries; a provers page (per prover: shards, lag, income) is not built |
|
||||
| Pool payouts | a public pool does not exist (section 5) | not built |
|
||||
| The switches: `difficulty_v2_activation_daa`, `proving_v0_activation_daa`, `fees_v1_activation_daa`, `finality_v3_activation_daa` (`/tmp/igneum-devnet/override-v3.json` on the devnet: 33,000, 84,100, 210,000, 135,200) | the params file; `igneum_getProvingStatus.activationDaa`; no RPC lists them all | not built; a "network parameters" card on `/explorer` reading a params RPC is the clean way |
|
||||
|
||||
## 4. Recommendation
|
||||
|
||||
Confirmed from the code and the RPC surface: Blockscout for contracts, transactions and accounts; our own DAG, mining,
|
||||
finality and proving pages on the site, fed by the observer; one search box that routes a transaction or contract to
|
||||
Blockscout and a block hash, a chain block number or a miner to our pages. Two things qualify it:
|
||||
|
||||
1. Blockscout cannot run against the fork as it is: `eth_getTransactionByBlockHashAndIndex` and
|
||||
`eth_getUncleByBlockHashAndIndex` are missing (an hour), and there is no tracing (`debug_traceTransaction`), so
|
||||
internal transactions stay off until the revm inspectors of design 8.2 exist. The execution engineer owns both.
|
||||
2. One box of our own and Postgres on it, next to a node with an unlimited local RPC. Not before the public testnet
|
||||
has transactions worth looking at; the devnet's blocks are empty and our pages already show them.
|
||||
|
||||
The search box: `site/lib/explorer.mjs` `classify()` already routes 64-hex to a block (the API falls through to a
|
||||
transaction hash in the last 24 hours), 0x40 and bech32 to an address, digits to a chain block number. When Blockscout
|
||||
is up, a 64-hex that is not a DAG block and a 0x40 that is a contract go to it instead of a 404.
|
||||
|
||||
## 5. What was built tonight
|
||||
|
||||
| Item | Where |
|
||||
|---|---|
|
||||
| `/api/stats`, `/api/supply` | `site/api/stats.mjs`, `site/api/supply.mjs`, `site/api/_neon.mjs`; the emission rule in `site/lib/emission.mjs`; `docs/api/public-stats.md` with example responses |
|
||||
| `/api/explorer` | `site/api/explorer.mjs`: latest blocks, one block, height, address, search |
|
||||
| Pages | `site/explorer.html`, `site/block.html`, `site/address.html`; `site/vercel.json` rewrites `/block/:id` and `/address/:addr`; the footer carries an Explorer link (the nav is unchanged: its eight items are measured to fit at 941 px, a ninth is a layout decision for the site owner) |
|
||||
| Observer | `tools/observer/observer.mjs`: per-block explorer columns and `detail`, `number` from the shard plan, `rpc_load`, hourly `supply_check` |
|
||||
| Preview | `node tools/site-serve.mjs` (clean URLs, the rewrites, the API functions in-process; `LIVE_TABLE_PREFIX`, `EXPLORER_EVM_RPC`, `PORT`) |
|
||||
| Tests | `site/lib/explorer.test.mjs` (router, formatters), `site/lib/emission.test.mjs` (the node's own test values from `igneum.rs`, a devnet coinbase, the floor sum against a loop), `site/api/public-stats.test.mjs` (every documented field from a fixture); CI runs them, and on master `tools/ci/public-api-check.mjs https://igneum.network` |
|
||||
| Screenshots | `docs/plans/explorer/explorer.png`, `block.png`, `address.png` (local preview against a test observer, 5 October 2026) |
|
||||
|
||||
### Observer load, measured
|
||||
|
||||
Two copies of the observer ran side by side against the observer node (`ws://127.0.0.1:28640`, EVM RPC
|
||||
`http://127.0.0.1:26790`) with test table prefixes, 19:21 to 19:28 UTC on 5 October 2026: master's code with only a
|
||||
call counter added, and this branch. Calls per wall-clock minute, as each process counted them:
|
||||
|
||||
| Minute (UTC) | Before, wRPC | After, wRPC | Before, EVM | After, EVM |
|
||||
|---|---|---|---|---|
|
||||
| 19:21 to 19:22 (partial, 50 s) | 283 | 282 | 776 | 785 |
|
||||
| 19:22 | 211 | 211 | 2,119 | 2,145 |
|
||||
| 19:23 | 252 | 253 | 2,487 | 2,493 |
|
||||
| 19:24 | 232 | 232 | 2,487 | 2,492 |
|
||||
| 19:25 | 266 | 266 | 2,491 | 2,497 |
|
||||
| 19:26 | 210 | 210 | 2,485 | 2,481 |
|
||||
|
||||
Ratio after to before: 1.00 on both. The explorer reads everything from the notification the observer already
|
||||
receives; the only new work is one SQL update per shard-plan batch. The EVM figure is the proving feed's record
|
||||
polling (80 blocks per 2 s tick while proving is active, master behaviour); the observer that is live tonight points
|
||||
at `http://127.0.0.1:26800` (the app's node, down), so its EVM load is zero and its proving feed reads "unreachable".
|
||||
The observer node answers the proving RPCs on 26790 (`eth_chainId` 0x116f, `igneum_getProvingStatus` active); pointing
|
||||
`IGNEUM_EVM_RPC` there when the observer is next restarted is a one-line change to the launch environment, not to the code.
|
||||
|
||||
### Supply check, measured
|
||||
|
||||
The first sample after start: "21 blocks, payload subsidy = rule 21/21, outputs = merged subsidies 20/20 (1 without a
|
||||
verdict)". The rule in `site/lib/emission.mjs` reproduces the node's own test values (`igneum.rs`: 3,168,808,781 at
|
||||
DAA 2,592,000; 1,584,404,390 at the first halving; 0 at the 32nd) and the devnet coinbase of block `2622db76` (payload
|
||||
subsidy 454,486,399 at DAA 125,064; outputs 363,588,240 + 90,897,059 = 454,485,299 = E(125,063), what its merged
|
||||
parent declared: `utxo_validation.rs:176` pays a merged block the subsidy its own payload carries).
|
||||
|
||||
## 6. A public pool with a stats API (separate item, not built)
|
||||
|
||||
The app carries a pool mode in the Hive package: the Flight Sheet's Pool URL is `grpc://<your node>:26610` for solo
|
||||
mining or `local` to run the bundled node on the rig (`packaging/hive/README.md`); "there is no pool". A public pool
|
||||
needs, in order: the pool protocol of `docs/spec/09-pool-protocol.md` implemented (templates, shares, the member's
|
||||
own vote key and votes relayed, vardiff, the `stats` message: member `hashrate`, `workers`, `refusals`, `shares`, pool
|
||||
`members`, `hashrate`, `blocks_24h`, `declared_share`); a pool server on its own box with a node; a payout scheme (the
|
||||
spec leaves PPLNS windows and PPS fees to the pool); a stats API in the shape WhatToMine and MiningPoolStats read
|
||||
(pool hashrate, miners, workers, blocks found with heights and times, fee, minimum payout, luck); and a page on the
|
||||
site. None of this is in the repo; the stats API of this branch is the network side of what those sites ask for.
|
||||
|
||||
## 7. Unverified
|
||||
|
||||
- Vercel's `cleanUrls` with a rewrite destination of `/block` and `/address` (the clean names of `block.html` and
|
||||
`address.html`): checked locally through `tools/site-serve.mjs`, not on a Vercel preview, because nothing was pushed.
|
||||
- `/api/stats` and `/api/supply` on the live tables: the live observer has not been restarted on this code, so the
|
||||
live `live_blocks` has no `number`, `tx_count` or `detail` columns yet; the handlers answer with nulls there until
|
||||
the restart (the schema adds itself on start, `ALTER TABLE ... ADD COLUMN IF NOT EXISTS`). The examples in
|
||||
`docs/api/public-stats.md` come from the test observer's tables.
|
||||
- Blockscout's licence terms for a hosted instance, and the exact env flag that disables its trace fetcher.
|
||||
- The database size per Igneum block in Blockscout, and the Hetzner price of the box: approximate above, measure and
|
||||
price when ordered.
|
||||
- `EXPLORER_EVM_RPC` on Vercel: no public devnet EVM RPC exists, so balances show "no EVM RPC configured" on the devnet
|
||||
deployment; the testnet's `https://rpc.testnet.igneum.network` is the value for the testnet.
|
||||
BIN
docs/plans/explorer/address.png
Normal file
BIN
docs/plans/explorer/address.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 364 KiB |
BIN
docs/plans/explorer/block.png
Normal file
BIN
docs/plans/explorer/block.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 318 KiB |
BIN
docs/plans/explorer/explorer.png
Normal file
BIN
docs/plans/explorer/explorer.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 392 KiB |
|
|
@ -172,6 +172,7 @@ p{margin:0;color:var(--ink-2);max-width:52ch}
|
|||
<nav class="foot-col" aria-label="Follow">
|
||||
<div class="eyebrow">Follow</div>
|
||||
<a href="/live">Live devnet</a>
|
||||
<a href="/explorer">Explorer</a>
|
||||
<a href="/#journey">Journey</a>
|
||||
<a href="/wallet">Add Igneum to MetaMask</a>
|
||||
<a href="https://github.com/igneum-network/spec" rel="noopener"><svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor" aria-hidden="true"><path d="M12 .5C5.7.5.5 5.7.5 12c0 5.1 3.3 9.4 7.9 10.9.6.1.8-.3.8-.6v-2.1c-3.2.7-3.9-1.4-3.9-1.4-.5-1.3-1.3-1.7-1.3-1.7-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.7 1.3 3.4 1 .1-.8.4-1.3.7-1.6-2.6-.3-5.3-1.3-5.3-5.7 0-1.3.5-2.3 1.2-3.1-.1-.3-.5-1.5.1-3.1 0 0 1-.3 3.2 1.2.9-.3 1.9-.4 2.9-.4s2 .1 2.9.4c2.2-1.5 3.2-1.2 3.2-1.2.6 1.6.2 2.8.1 3.1.8.8 1.2 1.8 1.2 3.1 0 4.4-2.7 5.4-5.3 5.7.4.4.8 1.1.8 2.2v3.2c0 .3.2.7.8.6 4.6-1.5 7.9-5.8 7.9-10.9C23.5 5.7 18.3.5 12 .5z"></path></svg>GitHub, spec and vectors</a>
|
||||
|
|
|
|||
297
site/address.html
Normal file
297
site/address.html
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<title>Igneum address</title>
|
||||
<meta name="description" content="One Igneum address: the blocks it mined in the last day, what they earned, and its balance.">
|
||||
<link rel="canonical" href="https://igneum.network/address">
|
||||
<meta name="robots" content="noindex">
|
||||
<meta name="theme-color" content="#0C0C0E">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:site_name" content="Igneum">
|
||||
<meta property="og:title" content="Igneum address">
|
||||
<meta property="og:description" content="One Igneum address: the blocks it mined in the last day, what they earned, and its balance.">
|
||||
<meta property="og:url" content="https://igneum.network/address">
|
||||
<meta property="og:image" content="https://igneum.network/og-small.png?v=3">
|
||||
<meta property="og:image:width" content="256">
|
||||
<meta property="og:image:height" content="256">
|
||||
<meta property="og:image:alt" content="Igneum. Mined by GPUs. Proven by fire.">
|
||||
<meta name="twitter:card" content="summary">
|
||||
<meta name="twitter:title" content="Igneum address">
|
||||
<meta name="twitter:description" content="One Igneum address: the blocks it mined in the last day, what they earned, and its balance.">
|
||||
<meta name="twitter:image" content="https://igneum.network/og-small.png?v=3">
|
||||
<meta name="twitter:image:alt" content="Igneum. Mined by GPUs. Proven by fire.">
|
||||
<link rel="icon" href="/favicon.ico" sizes="48x48">
|
||||
<link rel="icon" href="/favicon-32.png" type="image/png" sizes="32x32">
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180">
|
||||
<link rel="manifest" href="/site.webmanifest">
|
||||
<!-- head:start -->
|
||||
<link rel="preload" href="/fonts/unbounded-900.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<link rel="preload" href="/fonts/plex-sans-400.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<style>
|
||||
/* Shared by every page. Source: site/partials/head.html, injected by site/build.mjs between the head markers. Edit the partial, not the page. */
|
||||
/* Fonts, self-hosted (latin subsets, OFL): Unbounded 500/700/900, IBM Plex Sans 400/500/600, IBM Plex Mono 400/500. Fallbacks carry size and ascent overrides so the swap does not move the layout. */
|
||||
@font-face{font-family:'Unbounded';font-style:normal;font-weight:500;font-display:swap;src:url(/fonts/unbounded-500.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
|
||||
@font-face{font-family:'Unbounded';font-style:normal;font-weight:700;font-display:swap;src:url(/fonts/unbounded-700.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
|
||||
@font-face{font-family:'Unbounded';font-style:normal;font-weight:900;font-display:swap;src:url(/fonts/unbounded-900.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
|
||||
@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:400;font-display:swap;src:url(/fonts/plex-sans-400.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
|
||||
@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:500;font-display:swap;src:url(/fonts/plex-sans-500.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
|
||||
@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:600;font-display:swap;src:url(/fonts/plex-sans-600.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
|
||||
@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:400;font-display:swap;src:url(/fonts/plex-mono-400.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
|
||||
@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:500;font-display:swap;src:url(/fonts/plex-mono-500.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
|
||||
@font-face{font-family:'Unbounded Fallback';src:local('Arial Black'),local('Arial-Black'),local('Impact');size-adjust:114%;ascent-override:87.5%;descent-override:21.5%;line-gap-override:0%}
|
||||
@font-face{font-family:'Plex Sans Fallback';src:local('Arial'),local('Helvetica Neue'),local('Helvetica');size-adjust:100.5%;ascent-override:102%;descent-override:27.4%;line-gap-override:0%}
|
||||
@font-face{font-family:'Plex Mono Fallback';src:local('Courier New'),local('Menlo');size-adjust:100%;ascent-override:102.5%;descent-override:27.5%;line-gap-override:0%}
|
||||
:root{--f-sans:'IBM Plex Sans','Plex Sans Fallback',system-ui,-apple-system,sans-serif;--f-display:'Unbounded','Unbounded Fallback',sans-serif;--f-mono:'IBM Plex Mono','Plex Mono Fallback',ui-monospace,Menlo,monospace;
|
||||
/* site chrome tokens (nav, footer, skip link). Dark by default; the litepaper sets light values and resets these in dark mode */
|
||||
--nav-h:68px;--ui-bg:rgba(12,12,14,.84);--ui-menu:#0C0C0E;--ui-ink:#F4F1EC;--ui-ink-2:#C9C7C2;--ui-ash:#9A9A9E;--ui-line:#2A2A30;--ui-line-2:#3A3A42;--ui-accent:#F2541B;--ui-accent-ink:#0C0C0E;--ui-hot:#FFB35C;--ui-hover:#FF6A2B;--ui-tint:rgba(242,84,27,.12)}
|
||||
html{-webkit-text-size-adjust:100%}
|
||||
h1,h2,h3{text-wrap:balance}
|
||||
p,li,dd,figcaption{text-wrap:pretty}
|
||||
a:focus-visible,button:focus-visible,summary:focus-visible,[tabindex]:focus-visible{outline:2px solid var(--ui-accent);outline-offset:3px;border-radius:6px}
|
||||
.skip{position:absolute;left:12px;top:-80px;z-index:50;background:var(--ui-accent);color:var(--ui-accent-ink);padding:10px 14px;border-radius:10px;font:600 15px/1.2 var(--f-sans);text-decoration:none}
|
||||
.skip:focus{top:12px}
|
||||
/* nav: one bar on every page, the mark in its black square, seven links and the miner button, a menu under 941 px (the eight items need 688 px beside the brand at 15 px, so the bar fits from 941 px up without a wrapped label) */
|
||||
.nav{position:sticky;top:0;z-index:20;background:var(--ui-bg);-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);border-bottom:1px solid var(--ui-line);color:var(--ui-ink)}
|
||||
.nav .wrap{display:flex;align-items:center;justify-content:space-between;gap:16px;min-height:var(--nav-h)}
|
||||
.nav a{color:inherit;text-decoration:none}
|
||||
.brand{display:inline-flex;align-items:center;gap:10px;color:var(--ui-ink);text-decoration:none;flex:0 0 auto}
|
||||
.brand .mark{display:block;flex:0 0 auto}
|
||||
.brand .word{font-family:var(--f-display);font-weight:900;font-size:20px;letter-spacing:.06em;line-height:1}
|
||||
.nav .links{display:flex;align-items:center;gap:clamp(14px,2vw,26px);font-size:15px;font-weight:500}
|
||||
.nav .links a{color:var(--ui-ink-2);padding:4px 0;border-bottom:2px solid transparent;white-space:nowrap;transition:color .15s ease,border-color .15s ease}
|
||||
.nav .links a:hover,.nav .brand:hover{color:var(--ui-hot);text-decoration:none}
|
||||
.nav .brand:hover{color:var(--ui-ink)}
|
||||
.nav .links a[aria-current="page"]{color:var(--ui-ink);border-bottom-color:var(--ui-accent)}
|
||||
.nav .links a.cta{display:inline-flex;align-items:center;justify-content:center;min-height:40px;padding:8px 16px;border-radius:10px;font-weight:600;background:var(--ui-accent);color:var(--ui-accent-ink);border:1px solid var(--ui-accent);transition:background .15s ease,transform .15s ease}
|
||||
.nav .links a.cta:hover{background:var(--ui-hover);border-color:var(--ui-hover);color:var(--ui-accent-ink);transform:translateY(-1px)}
|
||||
.burger{display:none;background:none;border:1px solid var(--ui-line-2);color:var(--ui-ink);border-radius:10px;width:44px;height:44px;align-items:center;justify-content:center;cursor:pointer;padding:0;flex:0 0 auto}
|
||||
.burger .x{display:none}.burger[aria-expanded="true"] .x{display:block}.burger[aria-expanded="true"] .bars{display:none}
|
||||
@media (max-width:940px){
|
||||
.nav .links{display:none;position:absolute;left:0;right:0;top:100%;background:var(--ui-menu);border-bottom:1px solid var(--ui-line);box-shadow:0 24px 40px rgba(0,0,0,.35);padding:10px var(--gutter,16px) 18px;flex-direction:column;align-items:stretch;gap:2px}
|
||||
.nav .links a{padding:13px 10px;border-radius:10px;border-bottom:0;font-size:16px}
|
||||
.nav .links a[aria-current="page"]{background:var(--ui-tint)}
|
||||
.nav .links a.cta{margin-top:8px;min-height:48px}
|
||||
.nav .links.open{display:flex}
|
||||
.burger{display:inline-flex}
|
||||
}
|
||||
/* footer, the same on every page */
|
||||
.foot{border-top:1px solid var(--ui-line);color:var(--ui-ink);margin-top:var(--sec,64px)}
|
||||
.foot .wrap{padding-block:48px 32px}
|
||||
.foot-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,200px),1fr));gap:32px}
|
||||
.foot-brand{display:flex;flex-direction:column;gap:12px;max-width:34ch}
|
||||
.foot-brand .brand{align-self:flex-start}.foot-brand .word{font-size:16px}
|
||||
.foot-brand p{color:var(--ui-ash);font-size:14px;margin:0}
|
||||
.foot-col{display:flex;flex-direction:column;gap:10px;font-size:15px}
|
||||
.foot-col .eyebrow{font-family:var(--f-mono);font-size:12px;letter-spacing:.18em;text-transform:uppercase;color:var(--ui-ash);margin-bottom:4px}
|
||||
.foot-col a{color:var(--ui-ink-2);text-decoration:none;display:inline-flex;align-items:center;gap:6px;align-self:flex-start}
|
||||
.foot-col a:hover{color:var(--ui-hot);text-decoration:none}
|
||||
.foot-base{display:flex;flex-wrap:wrap;justify-content:space-between;gap:8px 24px;margin-top:36px;padding-top:20px;border-top:1px solid var(--ui-line);font-size:13px;color:var(--ui-ash)}
|
||||
.foot-base span{text-wrap:balance}.foot-base .mono{font-family:var(--f-mono)}
|
||||
.foot-imprint{flex-basis:100%;text-wrap:pretty}.foot-base a{color:inherit;text-decoration:none}.foot-base a:hover{color:var(--ui-hot)}
|
||||
@media (prefers-reduced-motion:reduce){*,*::before,*::after{animation-duration:.01ms!important;animation-iteration-count:1!important;transition-duration:.01ms!important;scroll-behavior:auto!important}}
|
||||
</style>
|
||||
<!-- head:end -->
|
||||
<style>
|
||||
/* the explorer pages share this block with block.html and address.html; same tokens as live.html */
|
||||
:root{--obsidian:#0C0C0E;--graphite:#16161A;--line:#2A2A30;--line-2:#3A3A42;--ember:#F2541B;--molten:#FFB35C;--bone:#F4F1EC;--ash:#9A9A9E;--ink-2:#C9C7C2;--ember-ink:#0C0C0E;--blue:#5CB8FF;--green:#3FD39A;
|
||||
--max:1200px;--gutter:clamp(16px,4vw,32px);--sec:clamp(56px,8vw,96px);--head:clamp(40px,6vw,64px);
|
||||
--card-pad:clamp(18px,3vw,28px);--card-r:18px;--tile-pad:18px 20px;--tile-r:14px;--gap:24px;--gap-tile:12px;
|
||||
--fs-h1:clamp(32px,5.5vw,56px);--fs-h3:clamp(18px,2vw,22px);--fs-tile:clamp(20px,2.2vw,26px)}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--obsidian);color:var(--bone);font-family:var(--f-sans);font-size:17px;line-height:1.55;-webkit-font-smoothing:antialiased;overflow-x:hidden}
|
||||
a{color:inherit;text-decoration:none}a:hover{color:var(--molten)}
|
||||
a:focus-visible,button:focus-visible,input:focus-visible{outline:2px solid var(--ember);outline-offset:3px;border-radius:6px}
|
||||
.wrap{max-width:var(--max);margin:0 auto;padding-inline:var(--gutter)}
|
||||
.mono{font-family:var(--f-mono)}
|
||||
.eyebrow{font-family:var(--f-mono);font-size:12px;letter-spacing:.18em;text-transform:uppercase;color:var(--ash)}
|
||||
h1,h2{font-family:var(--f-display);margin:0;line-height:1.08;text-wrap:balance}
|
||||
h1{font-weight:900;font-size:var(--fs-h1);letter-spacing:-.01em;overflow-wrap:anywhere}
|
||||
h2{font-weight:700;font-size:var(--fs-h3)}
|
||||
p{margin:0}
|
||||
.card{background:var(--graphite);border:1px solid var(--line);border-radius:var(--card-r);padding:var(--card-pad);min-width:0;margin-top:var(--gap)}
|
||||
.head{padding-block:var(--head) var(--gap);display:flex;flex-direction:column;gap:14px}
|
||||
.head p{color:var(--ink-2);max-width:64ch;font-size:16px}
|
||||
.search{display:flex;gap:10px;max-width:760px;margin-top:4px}
|
||||
.search input{flex:1;min-width:0;min-height:48px;padding:10px 14px;border-radius:10px;border:1px solid var(--line-2);background:var(--graphite);color:var(--bone);font:500 15px/1.3 var(--f-mono)}
|
||||
.search input::placeholder{color:var(--ash)}
|
||||
.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:48px;padding:12px 22px;border-radius:10px;font-weight:600;font-size:16px;border:1px solid var(--line-2);color:var(--bone);background:transparent;cursor:pointer;font-family:var(--f-sans)}
|
||||
.btn.primary{background:var(--ember);color:var(--ember-ink);border-color:var(--ember)}
|
||||
.btn.primary:hover{background:#FF6A2B;border-color:#FF6A2B;color:var(--ember-ink)}
|
||||
.msg{font-family:var(--f-mono);font-size:13px;color:var(--molten);min-height:18px}
|
||||
.strip{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:var(--gap-tile)}
|
||||
@media (max-width:700px){.strip{grid-template-columns:repeat(2,minmax(0,1fr))}}
|
||||
.cell{background:var(--graphite);border:1px solid var(--line);border-radius:var(--tile-r);padding:var(--tile-pad);display:flex;flex-direction:column;gap:6px;min-width:0}
|
||||
.cell .k{font-family:var(--f-mono);font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--ash)}
|
||||
.cell .v{font-family:var(--f-display);font-weight:700;font-size:var(--fs-tile);line-height:1.1;font-variant-numeric:tabular-nums;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.cell .v small{font-family:var(--f-mono);font-weight:500;font-size:13px;color:var(--ash);margin-left:4px;letter-spacing:.04em}
|
||||
@media (max-width:700px){.cell .v{font-size:19px}}
|
||||
.cell .s{font-family:var(--f-mono);font-size:12px;color:var(--ash);text-wrap:balance}
|
||||
.cell.status .v{display:flex;align-items:center;gap:10px}
|
||||
.dot{width:10px;height:10px;border-radius:50%;background:var(--molten);display:inline-block;flex:0 0 10px;animation:pulse 2s ease-in-out infinite}
|
||||
.off .dot{background:var(--ash);animation:none}.off .cell.status .v{color:var(--ash)}.on .cell.status .v{color:var(--molten)}
|
||||
@keyframes pulse{0%,100%{box-shadow:0 0 0 0 rgba(255,179,92,.5)}50%{box-shadow:0 0 0 8px rgba(255,179,92,0)}}
|
||||
@media (prefers-reduced-motion:reduce){.dot{animation:none}}
|
||||
.viz-head{display:flex;flex-wrap:wrap;justify-content:space-between;align-items:center;gap:10px 20px;margin-bottom:12px}
|
||||
.tbl{overflow-x:auto;margin-top:12px;-webkit-overflow-scrolling:touch}
|
||||
table{border-collapse:collapse;width:100%;font-size:14px}
|
||||
th,td{padding:10px 10px;text-align:left;border-bottom:1px solid var(--line);white-space:nowrap;vertical-align:top}
|
||||
th{font-family:var(--f-mono);font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--ash);font-weight:500}
|
||||
td.n,th.n{text-align:right;font-variant-numeric:tabular-nums}
|
||||
tr:last-child td{border-bottom:0}
|
||||
tbody tr:hover td{background:rgba(255,255,255,.025)}
|
||||
.id{font-family:var(--f-mono);color:var(--molten)}
|
||||
.hash{font-family:var(--f-mono)}
|
||||
.sw{width:10px;height:10px;border-radius:3px;display:inline-block;margin-right:7px;vertical-align:middle;border:1.5px solid var(--ash)}
|
||||
.sw.chain{background:var(--ember);border-color:var(--blue)}.sw.blue{background:rgba(92,184,255,.7);border-color:var(--blue)}.sw.red{background:linear-gradient(45deg,transparent 44%,var(--ash) 44%,var(--ash) 56%,transparent 56%) center/8px 8px no-repeat}
|
||||
.chip{display:inline-block;font-family:var(--f-mono);font-size:11px;letter-spacing:.08em;text-transform:uppercase;padding:3px 8px;border-radius:999px;border:1px solid var(--line-2);color:var(--ink-2);margin-right:6px}
|
||||
.chip.hot{border-color:var(--molten);color:var(--molten)}.chip.ok{border-color:var(--green);color:var(--green)}
|
||||
.legend{display:flex;flex-wrap:wrap;gap:6px 16px;margin-top:14px;font-size:13px;color:var(--ink-2)}
|
||||
.legend span{display:inline-flex;align-items:center;white-space:nowrap}
|
||||
.empty{color:var(--ash);font-size:14px;padding:12px 0}
|
||||
.note{font-size:13px;color:var(--ash);margin-top:14px;max-width:80ch}
|
||||
dl{display:grid;grid-template-columns:max-content minmax(0,1fr);gap:8px 20px;margin:12px 0 0;font-size:14px}
|
||||
dt{font-family:var(--f-mono);font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--ash);padding-top:3px}
|
||||
dd{margin:0;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}
|
||||
@media (max-width:640px){dl{grid-template-columns:1fr;gap:2px 0}dd{margin-bottom:8px}}
|
||||
.two{display:grid;gap:var(--gap);grid-template-columns:1fr}
|
||||
@media (min-width:900px){.two{grid-template-columns:1fr 1fr}.two .card{margin-top:0}}
|
||||
.two-wrap{margin-top:var(--gap)}
|
||||
main{padding-bottom:var(--sec)}
|
||||
.foot{margin-top:0}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- nav:start -->
|
||||
<a class="skip" href="#main">Skip to content</a>
|
||||
<nav class="nav" aria-label="Main">
|
||||
<div class="wrap">
|
||||
<a href="/" class="brand" aria-label="Igneum home">
|
||||
<svg class="mark" viewBox="0 0 1024 1024" width="36" height="36" aria-hidden="true"><rect width="1024" height="1024" fill="#0C0C0E"></rect><g transform="translate(166.95 166.95) scale(6.901)"><polygon points="50,4 74,34 67,58 80,54 61,96 39,96 20,54 33,58 26,34" fill="#F2541B"></polygon><polygon points="50,42 59,58 50,82 41,58" fill="#0C0C0E"></polygon></g></svg>
|
||||
<span class="word">IGNEUM</span>
|
||||
</a>
|
||||
<button class="burger" id="nav-burger" type="button" aria-expanded="false" aria-controls="nav-links" aria-label="Menu"><svg class="bars" viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M4 7h16M4 12h16M4 17h16"></path></svg><svg class="x" viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"></path></svg></button>
|
||||
<div class="links" id="nav-links">
|
||||
<a href="/litepaper" data-nav="litepaper">Litepaper</a>
|
||||
<a href="/live" data-nav="live" aria-current="page">Live devnet</a>
|
||||
<a href="/bench" data-nav="bench">Engineering log</a>
|
||||
<a href="/miner" data-nav="miner">Miner</a>
|
||||
<a href="/wallet" data-nav="wallet">Wallet</a>
|
||||
<a href="/evidence" data-nav="evidence">Evidence</a>
|
||||
<a href="https://github.com/igneum-network/spec" rel="noopener">GitHub</a>
|
||||
<a href="/miner" class="cta">The miner</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<script>
|
||||
(function(){
|
||||
// the menu: open on the button, closed by any link, a tap outside, Escape (focus back on the button) or a resize past 940 px
|
||||
var b=document.getElementById('nav-burger'),l=document.getElementById('nav-links');if(!b||!l)return;
|
||||
function set(o){l.classList.toggle('open',o);b.setAttribute('aria-expanded',o?'true':'false');}
|
||||
b.addEventListener('click',function(){set(!l.classList.contains('open'));});
|
||||
l.addEventListener('click',function(e){if(e.target.closest('a'))set(false);});
|
||||
document.addEventListener('click',function(e){if(l.classList.contains('open')&&!l.contains(e.target)&&!b.contains(e.target))set(false);});
|
||||
document.addEventListener('keydown',function(e){if(e.key==='Escape'&&l.classList.contains('open')){set(false);b.focus();}});
|
||||
var mq=window.matchMedia('(min-width:941px)');if(mq.addEventListener)mq.addEventListener('change',function(){set(false);});
|
||||
})();
|
||||
</script>
|
||||
<!-- nav:end -->
|
||||
<main id="main" class="wrap">
|
||||
<div class="head">
|
||||
<div class="eyebrow"><a href="/explorer">Explorer</a> · address</div>
|
||||
<h1 class="hash" id="title">Address</h1>
|
||||
<div id="chips"></div>
|
||||
<div class="msg" id="msg" aria-live="polite"></div>
|
||||
</div>
|
||||
<div id="body" hidden>
|
||||
<div class="strip">
|
||||
<div class="cell"><div class="k">Balance</div><div class="v" id="a-balance">n/a</div><div class="s" id="a-balance-s">eth_getBalance</div></div>
|
||||
<div class="cell"><div class="k">Blocks, 24 h</div><div class="v" id="a-blocks">0</div><div class="s" id="a-blocks-s"></div></div>
|
||||
<div class="cell"><div class="k">Earned, 24 h</div><div class="v" id="a-earned">0<small>IGN</small></div><div class="s">80% of each blue block's subsidy</div></div>
|
||||
<div class="cell"><div class="k">Last block</div><div class="v" id="a-last">n/a</div><div class="s" id="a-first"></div></div>
|
||||
</div>
|
||||
<div class="card"><div class="viz-head"><h2>Identity</h2></div><dl id="ident"></dl><p class="note">A miner names its EVM payout address in every coinbase (the IGNA tag) beside the coinbase script's own address, so the two forms name the same miner. The vote keys are the finality identities that signed these blocks; a card runs several.</p></div>
|
||||
<div class="card"><div class="viz-head"><h2>Blocks mined</h2><div class="eyebrow">newest 50 of the last 24 hours</div></div>
|
||||
<div class="tbl"><table><thead><tr><th>Block</th><th class="n">Number</th><th class="n">DAA</th><th class="n">Blue score</th><th>Colour</th><th class="n">Subsidy</th><th class="n">Txs</th><th>Time</th></tr></thead><tbody id="blocks"></tbody></table></div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<!-- footer:start -->
|
||||
<footer class="foot">
|
||||
<div class="wrap">
|
||||
<div class="foot-grid">
|
||||
<div class="foot-brand">
|
||||
<a href="/" class="brand" aria-label="Igneum home"><svg class="mark" viewBox="0 0 1024 1024" width="30" height="30" aria-hidden="true"><rect width="1024" height="1024" fill="#0C0C0E"></rect><g transform="translate(166.95 166.95) scale(6.901)"><polygon points="50,4 74,34 67,58 80,54 61,96 39,96 20,54 33,58 26,34" fill="#F2541B"></polygon><polygon points="50,42 59,58 50,82 41,58" fill="#0C0C0E"></polygon></g></svg><span class="word">IGNEUM</span></a>
|
||||
<p>Mined by GPUs. Proven by fire.</p>
|
||||
</div>
|
||||
<nav class="foot-col" aria-label="Read">
|
||||
<div class="eyebrow">Read</div>
|
||||
<a href="/litepaper">Litepaper</a>
|
||||
<a href="/litepaper#limits">What Igneum does not claim</a>
|
||||
<a href="/litepaper#randomx">Igneum vs RandomX</a>
|
||||
<a href="/litepaper#shoulders">Built on the shoulders</a>
|
||||
<a href="/bench">Engineering log</a>
|
||||
<a href="/evidence">Evidence</a>
|
||||
</nav>
|
||||
<nav class="foot-col" aria-label="Run">
|
||||
<div class="eyebrow">Run</div>
|
||||
<a href="/miner">The miner</a>
|
||||
<a href="/wallet">The wallet</a>
|
||||
<a href="/miners">GPU bench table</a>
|
||||
<a href="/miner#fee">The dev fee</a>
|
||||
<a href="/faucet">Testnet faucet</a>
|
||||
</nav>
|
||||
<nav class="foot-col" aria-label="Follow">
|
||||
<div class="eyebrow">Follow</div>
|
||||
<a href="/live">Live devnet</a>
|
||||
<a href="/explorer">Explorer</a>
|
||||
<a href="/#journey">Journey</a>
|
||||
<a href="/wallet">Add Igneum to MetaMask</a>
|
||||
<a href="https://github.com/igneum-network/spec" rel="noopener"><svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor" aria-hidden="true"><path d="M12 .5C5.7.5.5 5.7.5 12c0 5.1 3.3 9.4 7.9 10.9.6.1.8-.3.8-.6v-2.1c-3.2.7-3.9-1.4-3.9-1.4-.5-1.3-1.3-1.7-1.3-1.7-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.7 1.3 3.4 1 .1-.8.4-1.3.7-1.6-2.6-.3-5.3-1.3-5.3-5.7 0-1.3.5-2.3 1.2-3.1-.1-.3-.5-1.5.1-3.1 0 0 1-.3 3.2 1.2.9-.3 1.9-.4 2.9-.4s2 .1 2.9.4c2.2-1.5 3.2-1.2 3.2-1.2.6 1.6.2 2.8.1 3.1.8.8 1.2 1.8 1.2 3.1 0 4.4-2.7 5.4-5.3 5.7.4.4.8 1.1.8 2.2v3.2c0 .3.2.7.8.6 4.6-1.5 7.9-5.8 7.9-10.9C23.5 5.7 18.3.5 12 .5z"></path></svg>GitHub, spec and vectors</a>
|
||||
<a href="/miner#get">Miner downloads: public testnet</a>
|
||||
<a href="mailto:hello@igneum.network">Report a flaw: hello@igneum.network</a>
|
||||
<a href="https://github.com/igneum-network/spec/issues" rel="noopener">Or open an issue on the spec</a>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="foot-base">
|
||||
<span class="foot-imprint">Igneum Labs LTD · Unit IH-00-01-01-OF-01, Level 01, Innovation One, Dubai International Financial Centre · <a href="mailto:hello@igneum.network">hello@igneum.network</a></span>
|
||||
<span>© 2026 Igneum. Nothing on this page is an offer to sell anything.</span>
|
||||
<span class="mono">igneum.network</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
<!-- footer:end -->
|
||||
<script type="module">
|
||||
import { ign, ignFromWei, shortHash, rel, int, utc } from '/lib/explorer.mjs';
|
||||
const $ = id => document.getElementById(id);
|
||||
const esc = s => String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
const link = h => `<a class="hash" href="/block/${esc(h)}" title="${esc(h)}">${esc(shortHash(h, 10, 6))}</a>`;
|
||||
const dl = pairs => pairs.filter(p => p).map(([k, v]) => `<dt>${esc(k)}</dt><dd>${v}</dd>`).join('');
|
||||
const a = decodeURIComponent(location.pathname.split('/').pop() || '');
|
||||
async function load() {
|
||||
const r = await fetch('/api/explorer?address=' + encodeURIComponent(a)); const j = await r.json();
|
||||
$('title').textContent = a;
|
||||
if (!j.ok) { $('msg').textContent = j.error || 'Not found.'; return; }
|
||||
document.title = `Igneum address ${shortHash(a, 8, 6)}`;
|
||||
const now = new Date(j.now).getTime();
|
||||
$('chips').innerHTML = `<span class="chip">${j.kind === 'evm' ? 'EVM payout address' : 'coinbase address'}</span>` + (j.blocks_10m ? '<span class="chip hot">mining now</span>' : '');
|
||||
if (j.balance.available) { $('a-balance').innerHTML = esc(ignFromWei(j.balance.wei, 4)) + '<small>IGN</small>'; $('a-balance-s').textContent = `${j.balance.source}, ${j.network || ''}`; }
|
||||
else { $('a-balance').textContent = 'n/a'; $('a-balance-s').textContent = j.balance.reason; }
|
||||
$('a-blocks').textContent = int(j.blocks_24h); $('a-blocks-s').textContent = `${j.blocks_10m} in 10 min, ${j.blue_24h} blue, ${j.red_24h} red, ${j.chain_24h} on the chain`;
|
||||
$('a-earned').innerHTML = esc(ign(j.earned_24h_sompi, 2)) + '<small>IGN</small>';
|
||||
$('a-last').textContent = j.last_seen ? rel(now - new Date(j.last_seen).getTime()) : 'none in 24 h';
|
||||
$('a-first').textContent = j.first_seen ? 'first in the window ' + rel(now - new Date(j.first_seen).getTime()) : '';
|
||||
$('ident').innerHTML = dl([
|
||||
['EVM address', j.evm_address ? `<a class="hash" href="/address/${esc(j.evm_address)}">${esc(j.evm_address)}</a>` : '<span style="color:var(--ash)">none seen</span>'],
|
||||
['Coinbase address', j.utxo_address ? `<a class="hash" href="/address/${esc(j.utxo_address)}">${esc(j.utxo_address)}</a>` : '<span style="color:var(--ash)">none seen</span>'],
|
||||
['Vote keys', j.miner_ids.length ? j.miner_ids.map(m => `<span class="id">${esc(m.id)}</span> <span style="color:var(--ash)">${m.blocks}</span>`).join(' ') : 'none'],
|
||||
['Earned, 24 h', `${ign(j.earned_24h_sompi, 8)} IGN <span style="color:var(--ash)">${esc(j.earned_note)}</span>`],
|
||||
]);
|
||||
$('blocks').innerHTML = j.recent.length ? j.recent.map(b => `<tr><td><i class="sw ${b.chain ? 'chain' : b.color}"></i>${link(b.hash)}</td><td class="n">${b.number === null ? '<span style="color:var(--ash)">off chain</span>' : int(b.number)}</td><td class="n">${int(b.daa)}</td><td class="n">${int(b.blue_score)}</td><td>${b.chain ? 'chain' : esc(b.color)}</td><td class="n">${b.subsidy_sompi ? ign(b.subsidy_sompi, 4) : 'n/a'}</td><td class="n">${b.tx_count ?? 'n/a'}</td><td title="${new Date(b.ts).toISOString()}">${rel(now - b.ts)}</td></tr>`).join('') : '<tr><td colspan="8" class="empty">No blocks from this address in the last 24 hours.</td></tr>';
|
||||
$('body').hidden = false;
|
||||
}
|
||||
load().catch(e => { $('msg').textContent = 'The API did not answer: ' + e.message; });
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
26
site/api/_neon.mjs
Normal file
26
site/api/_neon.mjs
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
// Shared by the public read endpoints (stats, supply, explorer): Neon's HTTP SQL endpoint over Node's fetch, the
|
||||
// same pattern as live.mjs. Returns rows. A file starting with an underscore is not deployed as a function by Vercel.
|
||||
export function neon(url = process.env.DATABASE_URL) {
|
||||
if (!url) throw new Error('DATABASE_URL is not set');
|
||||
const host = new URL(url).hostname.replace('-pooler', '');
|
||||
return async (query, params = []) => {
|
||||
const r = await fetch(`https://${host}/sql`, {
|
||||
method: 'POST',
|
||||
headers: { 'Neon-Connection-String': url, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ query, params }),
|
||||
});
|
||||
const j = await r.json();
|
||||
if (!r.ok) throw new Error(j.message || JSON.stringify(j));
|
||||
return j.rows || [];
|
||||
};
|
||||
}
|
||||
export const num = v => (v === null || v === undefined ? null : Number(v));
|
||||
export const tablePrefix = () => (process.env.LIVE_TABLE_PREFIX || '').replace(/[^a-z0-9_]/gi, '');
|
||||
/** EVM chain id from the network name (docs/design/execution-layer.md 8.1: 4461 mainnet, 4462 testnet, 4463 devnet). */
|
||||
export function chainIdOf(network) {
|
||||
const n = String(network || '');
|
||||
if (n.includes('devnet')) return 4463;
|
||||
if (n.includes('testnet')) return 4462;
|
||||
if (n.includes('simnet')) return null;
|
||||
return 4461;
|
||||
}
|
||||
152
site/api/explorer.mjs
Normal file
152
site/api/explorer.mjs
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
// Igneum DAG explorer, server half. GET /api/explorer with one of:
|
||||
// ?blocks=N[&before=<ms>] the latest N blocks (10 to 200, default 50), older than `before` when given
|
||||
// ?block=<hash> one block: row, header detail, parents, mergeset, transactions, proof records, shards,
|
||||
// checkpoint and certificate when it is one, children, the chain block that merged it
|
||||
// ?height=N the chain block with that number (the EVM block number), same shape as ?block
|
||||
// ?address=<0x.. | igneum..:bech32> blocks mined, 24-hour and 10-minute counts, what those blocks earned, and the
|
||||
// balance through eth_getBalance when EXPLORER_EVM_RPC names a reachable EVM JSON-RPC
|
||||
// ?search=<q> routes a hash, a height or an address to its page, or says what it is not
|
||||
// Everything is read from what tools/observer wrote to Neon; the only live call is the balance. Cached 5 s.
|
||||
import { neon, num, tablePrefix, chainIdOf } from './_neon.mjs';
|
||||
import { classify } from '../lib/explorer.mjs';
|
||||
|
||||
const STALE_AFTER_S = 30;
|
||||
const ROW = `hash, number, blue_score, daa_score, timestamp_ms, parents, parent_hashes, is_chain_block, vote_key_hash, miner_address, evm_miner, engine,
|
||||
tx_count, proof_records, subsidy_sompi, paid_sompi, selected_parent, color, received_at`;
|
||||
|
||||
function row(b) {
|
||||
return {
|
||||
hash: b.hash, number: num(b.number), blue_score: num(b.blue_score), daa: num(b.daa_score), ts: num(b.timestamp_ms), rx: new Date(b.received_at).getTime(),
|
||||
parents: num(b.parents), parent_hashes: b.parent_hashes || [], chain: !!b.is_chain_block, color: b.color === 'blue' || b.color === 'red' ? b.color : 'pending',
|
||||
miner_id: b.vote_key_hash ? String(b.vote_key_hash).slice(0, 8) : null, vote_key_hash: b.vote_key_hash || null, miner: b.miner_address || null, evm_miner: b.evm_miner || null, engine: b.engine || null,
|
||||
tx_count: num(b.tx_count), proof_records: num(b.proof_records), subsidy_sompi: b.subsidy_sompi === null || b.subsidy_sompi === undefined ? null : String(b.subsidy_sompi),
|
||||
paid_sompi: b.paid_sompi === null || b.paid_sompi === undefined ? null : String(b.paid_sompi), selected_parent: b.selected_parent || null,
|
||||
};
|
||||
}
|
||||
|
||||
async function evmCall(url, method, params) {
|
||||
const r = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }), signal: AbortSignal.timeout(4000) });
|
||||
const j = await r.json();
|
||||
if (j.error) throw new Error(j.error.message || JSON.stringify(j.error));
|
||||
return j.result;
|
||||
}
|
||||
|
||||
export function createHandler({ env = process.env, sql, evm = evmCall } = {}) {
|
||||
return async function handler(req, res) {
|
||||
res.setHeader('Cache-Control', 'public, max-age=5, s-maxage=5');
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
if (req.method !== 'GET') { res.setHeader('Allow', 'GET'); return res.status(405).json({ ok: false, error: 'method not allowed' }); }
|
||||
const q = new URL(req.url || '/', 'http://x').searchParams;
|
||||
const T = tablePrefix();
|
||||
const EVM = env.EXPLORER_EVM_RPC || null;
|
||||
try {
|
||||
sql = sql || neon(env.DATABASE_URL);
|
||||
const head = await sql(`SELECT now() AS now, (SELECT row_to_json(s) FROM ${T}live_state s WHERE s.id = 1) AS state`);
|
||||
const now = new Date(head[0].now).getTime(); const s = head[0].state || null;
|
||||
const updated = s && s.updated_at ? new Date(s.updated_at).getTime() : null;
|
||||
const base = { ok: true, now: new Date(now).toISOString(), network: s ? s.network : null, chain_id: s ? chainIdOf(s.network) : null, stale: updated === null || (now - updated) / 1000 > STALE_AFTER_S, evm_rpc_configured: !!EVM };
|
||||
|
||||
// ---- search ----
|
||||
if (q.has('search')) {
|
||||
const c = classify(q.get('search'));
|
||||
if (c.type === 'none') return res.status(404).json({ ...base, ok: false, error: 'Not a block hash, a transaction hash, a chain block number or an address.' });
|
||||
if (c.type === 'address') return res.status(200).json({ ...base, type: 'address', href: `/address/${c.value}` });
|
||||
if (c.type === 'height') {
|
||||
const r = await sql(`SELECT hash FROM ${T}live_blocks WHERE number = $1 LIMIT 1`, [c.value]);
|
||||
if (!r.length) return res.status(404).json({ ...base, ok: false, error: `No chain block numbered ${c.value} in the last 24 hours.` });
|
||||
return res.status(200).json({ ...base, type: 'block', href: `/block/${r[0].hash}` });
|
||||
}
|
||||
const b = await sql(`SELECT hash FROM ${T}live_blocks WHERE hash = $1`, [c.value]);
|
||||
if (b.length) return res.status(200).json({ ...base, type: 'block', href: `/block/${c.value}` });
|
||||
const t = await sql(`SELECT hash FROM ${T}live_blocks WHERE tx_count > 0 AND detail->'txs' @> $1::jsonb LIMIT 1`, [JSON.stringify([{ hash: '0x' + c.value }])]);
|
||||
if (t.length) return res.status(200).json({ ...base, type: 'tx', href: `/block/${t[0].hash}#tx-${c.value}` });
|
||||
return res.status(404).json({ ...base, ok: false, error: 'No block or transaction with that hash in the last 24 hours.' });
|
||||
}
|
||||
|
||||
// ---- latest blocks ----
|
||||
if (q.has('blocks') || (!q.has('block') && !q.has('height') && !q.has('address'))) {
|
||||
const n = Math.max(10, Math.min(200, Number(q.get('blocks')) || 50));
|
||||
const before = Number(q.get('before')) || 0;
|
||||
const rows = before
|
||||
? await sql(`SELECT ${ROW} FROM ${T}live_blocks WHERE received_at < to_timestamp($2 / 1000.0) ORDER BY received_at DESC LIMIT $1`, [n, before])
|
||||
: await sql(`SELECT ${ROW} FROM ${T}live_blocks ORDER BY received_at DESC LIMIT $1`, [n]);
|
||||
const locked = new Set((await sql(`SELECT hash FROM ${T}live_checkpoints WHERE state = 'locked' ORDER BY index DESC LIMIT 200`).catch(() => [])).map(c => c.hash));
|
||||
return res.status(200).json({ ...base, blocks: rows.map(b => ({ ...row(b), locked: locked.has(b.hash) })) });
|
||||
}
|
||||
|
||||
// ---- one block ----
|
||||
if (q.has('block') || q.has('height')) {
|
||||
let hash = String(q.get('block') || '').replace(/^0x/i, '').toLowerCase();
|
||||
if (q.has('height')) {
|
||||
const r = await sql(`SELECT hash FROM ${T}live_blocks WHERE number = $1 LIMIT 1`, [Number(q.get('height'))]);
|
||||
if (!r.length) return res.status(404).json({ ...base, ok: false, error: `No chain block numbered ${q.get('height')} in the last 24 hours.` });
|
||||
hash = r[0].hash;
|
||||
}
|
||||
if (!/^[0-9a-f]{64}$/.test(hash)) return res.status(400).json({ ...base, ok: false, error: 'A block hash is 64 hex characters.' });
|
||||
const rows = await sql(`SELECT ${ROW}, detail FROM ${T}live_blocks WHERE hash = $1`, [hash]);
|
||||
if (!rows.length) return res.status(404).json({ ...base, ok: false, error: 'No block with that hash in the last 24 hours. The explorer keeps one day; older blocks live in the node.' });
|
||||
const b = rows[0];
|
||||
const [shards, cps, certs, children, mergedBy] = await Promise.all([
|
||||
sql(`SELECT shard, shards, state, prover, lag_daa, payout_wei, pgas, carried_by, carrier_number FROM ${T}live_proofs WHERE block_hash = $1 ORDER BY shard`, [hash]).catch(() => []),
|
||||
sql(`SELECT index, state, signed_weight, active_weight, total_weight, fraction_active, fraction_total, votes_seen, voters, locked_at FROM ${T}live_checkpoints WHERE hash = $1`, [hash]).catch(() => []),
|
||||
sql(`SELECT index, hash, carrier, voter_count, aggregator, total_weight, active_weight, prev_index, prev_hash, headers_complete, created_at FROM ${T}live_certificates WHERE hash = $1 OR carrier = $1`, [hash]).catch(() => []),
|
||||
sql(`SELECT hash FROM ${T}live_blocks WHERE received_at >= $2::timestamptz - interval '10 seconds' AND received_at < $2::timestamptz + interval '10 minutes' AND $1 = ANY(parent_hashes) ORDER BY received_at LIMIT 20`, [hash, b.received_at]),
|
||||
b.is_chain_block ? Promise.resolve([]) : sql(`SELECT hash FROM ${T}live_blocks WHERE is_chain_block AND received_at >= $2::timestamptz - interval '10 seconds' AND received_at < $2::timestamptz + interval '10 minutes' AND (detail->'mergeset'->'blues' ? $1 OR detail->'mergeset'->'reds' ? $1) LIMIT 1`, [hash, b.received_at]),
|
||||
]);
|
||||
let evmView = null; // the EVM's view of a chain block, when the deployment has an EVM RPC
|
||||
if (EVM && b.is_chain_block) {
|
||||
try {
|
||||
const e = await evm(EVM, 'eth_getBlockByHash', ['0x' + hash, true]);
|
||||
if (e) evmView = { number: parseInt(e.number, 16), gas_used: parseInt(e.gasUsed, 16), base_fee_per_gas: e.baseFeePerGas, state_root: e.stateRoot, miner: e.miner,
|
||||
transactions: (e.transactions || []).map(t => ({ hash: t.hash, from: t.from, to: t.to, value: t.value, nonce: parseInt(t.nonce, 16), gas: parseInt(t.gas, 16) })),
|
||||
igneum: e.igneum ? { rewards: e.igneum.rewards, proving_pool_credit: e.igneum.provingPoolCredit, pgas_used: e.igneum.pgasUsed, skipped: e.igneum.skipped } : null };
|
||||
} catch (err) { evmView = { error: String(err.message || err).slice(0, 120) }; }
|
||||
}
|
||||
return res.status(200).json({
|
||||
...base, block: { ...row(b), detail: b.detail || null }, shards: shards.map(x => ({ i: num(x.shard), n: num(x.shards), state: x.state, prover: x.prover, lag: num(x.lag_daa), payout_wei: x.payout_wei, pgas: num(x.pgas), carried_by: x.carried_by, carrier_number: num(x.carrier_number) })),
|
||||
checkpoint: cps[0] ? { index: num(cps[0].index), state: cps[0].state, signed: num(cps[0].signed_weight), active: num(cps[0].active_weight), total: num(cps[0].total_weight), fraction_active: num(cps[0].fraction_active), fraction_total: num(cps[0].fraction_total), votes: num(cps[0].votes_seen), voters: num(cps[0].voters), locked_at: cps[0].locked_at } : null,
|
||||
certificates: certs.map(c => ({ index: num(c.index), checkpoint: c.hash, carrier: c.carrier, role: c.hash === hash ? 'checkpoint' : 'carrier', voter_count: num(c.voter_count), aggregator: c.aggregator, total_weight: num(c.total_weight), active_weight: num(c.active_weight), previous: c.prev_index === null ? null : { index: num(c.prev_index), hash: c.prev_hash }, headers_complete: !!c.headers_complete })),
|
||||
children: children.map(c => c.hash), merged_by: b.is_chain_block ? hash : (mergedBy[0] ? mergedBy[0].hash : null), evm: evmView,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- address ----
|
||||
if (q.has('address')) {
|
||||
const c = classify(q.get('address'));
|
||||
if (c.type !== 'address') return res.status(400).json({ ...base, ok: false, error: 'An address is 0x plus 40 hex characters, or an igneum bech32 address.' });
|
||||
const col = c.kind === 'evm' ? 'evm_miner' : 'miner_address';
|
||||
const [sum, recent, ids] = await Promise.all([
|
||||
sql(`SELECT count(*)::int AS blocks_24h, count(*) FILTER (WHERE received_at > now() - interval '10 minutes')::int AS blocks_10m,
|
||||
count(*) FILTER (WHERE color = 'blue')::int AS blue_24h, count(*) FILTER (WHERE color = 'red')::int AS red_24h, count(*) FILTER (WHERE is_chain_block)::int AS chain_24h,
|
||||
min(timestamp_ms) AS first_ts, max(timestamp_ms) AS last_ts,
|
||||
coalesce(sum(subsidy_sompi) FILTER (WHERE color = 'blue'), 0)::text AS blue_subsidy_sompi,
|
||||
max(evm_miner) AS evm_miner, max(miner_address) AS miner_address
|
||||
FROM ${T}live_blocks WHERE ${col} = $1`, [c.value]),
|
||||
sql(`SELECT ${ROW} FROM ${T}live_blocks WHERE ${col} = $1 ORDER BY received_at DESC LIMIT 50`, [c.value]),
|
||||
sql(`SELECT vote_key_hash, count(*)::int AS n FROM ${T}live_blocks WHERE ${col} = $1 AND vote_key_hash IS NOT NULL GROUP BY 1 ORDER BY n DESC LIMIT 32`, [c.value]),
|
||||
]);
|
||||
const t = sum[0];
|
||||
const evmAddress = c.kind === 'evm' ? c.value : (t.evm_miner || null);
|
||||
let balance = { available: false, reason: EVM ? 'no EVM address known for this coinbase address' : 'no EVM RPC configured on this deployment (EXPLORER_EVM_RPC)' };
|
||||
if (EVM && evmAddress) {
|
||||
try { const wei = await evm(EVM, 'eth_getBalance', [evmAddress, 'latest']); balance = { available: true, wei: BigInt(wei).toString(), address: evmAddress, source: 'eth_getBalance latest' }; }
|
||||
catch (err) { balance = { available: false, reason: String(err.message || err).slice(0, 120) }; }
|
||||
}
|
||||
// what the blocks earned: each blue block pays 80% of the subsidy its payload declares to this miner, through the coinbase of the block that merges it
|
||||
const blue = BigInt(t.blue_subsidy_sompi || 0); const earned = blue * 80n / 100n;
|
||||
return res.status(200).json({
|
||||
...base, address: c.value, kind: c.kind, evm_address: evmAddress, utxo_address: c.kind === 'utxo' ? c.value : (t.miner_address || null),
|
||||
blocks_24h: t.blocks_24h, blocks_10m: t.blocks_10m, blue_24h: t.blue_24h, red_24h: t.red_24h, chain_24h: t.chain_24h,
|
||||
first_seen: t.first_ts ? new Date(num(t.first_ts)).toISOString() : null, last_seen: t.last_ts ? new Date(num(t.last_ts)).toISOString() : null,
|
||||
earned_24h_sompi: earned.toString(), earned_note: '80% of the declared subsidy of every blue block this address mined in the last 24 hours, paid by the coinbase of the block that merged each one (coinbase.rs); red blocks pay the merging miner instead',
|
||||
miner_ids: ids.map(i => ({ id: String(i.vote_key_hash).slice(0, 8), blocks: i.n })), balance, recent: recent.map(row),
|
||||
});
|
||||
}
|
||||
return res.status(400).json({ ...base, ok: false, error: 'blocks, block, height, address or search' });
|
||||
} catch (e) {
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
return res.status(500).json({ ok: false, error: String(e.message || e) });
|
||||
}
|
||||
};
|
||||
}
|
||||
export default createHandler();
|
||||
73
site/api/public-stats.test.mjs
Normal file
73
site/api/public-stats.test.mjs
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
// The public stats contract: /api/stats and /api/supply answer with every field docs/api/public-stats.md documents
|
||||
// (FIELDS in each handler), computed from a fixture of what the observer writes. Runs without a database.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHandler as stats, FIELDS as STATS_FIELDS, REWARD_FIELDS, LAST_BLOCK_FIELDS, rewardAt } from './stats.mjs';
|
||||
import { createHandler as supply, FIELDS as SUPPLY_FIELDS, SCHEDULE_FIELDS } from './supply.mjs';
|
||||
|
||||
const NOW = '2026-10-05T19:21:56.000Z';
|
||||
const state = {
|
||||
id: 1, block_count: 125066, header_count: 125066, blue_score: 122211, difficulty: 7748714.75, hashes_per_second_estimate: 27759288, peers: 7, mempool: 0,
|
||||
node_version: '2.1.0', network: 'igneum-devnet', blocks_60s: 58, blocks_per_minute: Array.from({ length: 60 }, (_, i) => [1791227000000 + i * 60000, 60]),
|
||||
observer_started_at: '2026-10-05T19:01:00.000Z', updated_at: '2026-10-05T19:21:55.000Z',
|
||||
finality: { chain_id: 'igneum-devnet', finality_active: true, latest_locked_index: 4073, latest_locked_blue_score: 122190 },
|
||||
supply_check: { checked_at: NOW, sampled: 500, rule_match: 500, rule_mismatch: 0, sum_match: 480, sum_mismatch: 0, sum_skipped: 20, examples: [], bps: 1 },
|
||||
};
|
||||
const last = { hash: '2622db7698c6825b370bbffba03ea11bba250792040296971d2b790a954203ee', blue_score: 122211, daa_score: 125064, timestamp_ms: 1791227608731,
|
||||
miner_address: 'igneumdev:qr4yfyf9mzn643fj8faksflmxur0wxgtmpg8y7fa6qkm8pcjh3ngzqedfx47e', vote_key_hash: '1c3f1190b777365e419f02fd46ce365f464377d0fdbdf1332da6600d09a75a0e', tx_count: 0, is_chain_block: true, number: 81263 };
|
||||
function fakeSql(rows) { return async (query) => { if (/row_to_json/.test(query) && /max\(daa_score\)/.test(query)) return [{ now: NOW, state, daa: 125064 }]; if (/row_to_json/.test(query)) return [{ now: NOW, state }]; if (/ORDER BY received_at DESC LIMIT 1/.test(query)) return [last]; if (/count\(DISTINCT vote_key_hash\)/.test(query)) return [{ n: 25 }]; return rows || []; }; }
|
||||
function fakeRes() { const r = { code: 0, headers: {}, body: null, status(c) { r.code = c; return r; }, setHeader(k, v) { r.headers[k] = v; }, json(o) { r.body = o; return r; } }; return r; }
|
||||
|
||||
test('/api/stats carries every documented field and the reward of spec 2.5', async () => {
|
||||
const res = fakeRes();
|
||||
await stats({ sql: fakeSql(), env: {} })({ method: 'GET', url: '/api/stats' }, res);
|
||||
assert.equal(res.code, 200);
|
||||
for (const f of STATS_FIELDS) assert.ok(f in res.body, `missing ${f}`);
|
||||
for (const f of REWARD_FIELDS) assert.ok(f in res.body.block_reward, `missing block_reward.${f}`);
|
||||
for (const f of LAST_BLOCK_FIELDS) assert.ok(f in res.body.last_block, `missing last_block.${f}`);
|
||||
assert.equal(res.body.chain_id, 4463);
|
||||
assert.equal(res.body.height, 81263);
|
||||
assert.equal(res.body.block_reward.sompi, '454486399'); // E(125,064), the payload of block 2622db76
|
||||
assert.equal(res.body.block_reward.ign, '4.54486399');
|
||||
assert.equal(res.body.block_reward.miner_ign, '3.6358912'); // 80%: 454486399 - 90897279
|
||||
assert.equal(res.body.block_reward.proving_pool_ign, '0.90897279');
|
||||
assert.equal(res.body.block_time_measured_s, 1.034);
|
||||
assert.equal(res.body.blocks_per_day_measured, 86400);
|
||||
assert.equal(res.body.stale, false);
|
||||
assert.equal(res.headers['Cache-Control'], 'public, max-age=10, s-maxage=10');
|
||||
});
|
||||
test('/api/stats marks a stale observer', async () => {
|
||||
const res = fakeRes();
|
||||
const old = { ...state, updated_at: '2026-10-05T19:00:00.000Z' };
|
||||
const sql = async (q) => (/row_to_json/.test(q) ? [{ now: NOW, state: old }] : /LIMIT 1/.test(q) ? [last] : [{ n: 0 }]);
|
||||
await stats({ sql, env: {} })({ method: 'GET', url: '/api/stats' }, res);
|
||||
assert.equal(res.body.stale, true);
|
||||
assert.ok(res.body.age_s > 1000);
|
||||
});
|
||||
test('rewardAt: halving and ramp bookkeeping', () => {
|
||||
const r = rewardAt(63_115_200);
|
||||
assert.equal(r.sompi, '1584404390'); assert.equal(r.halving_period, 1); assert.equal(r.next_halving_daa, 126_230_400); assert.equal(r.ramp_factor, 1);
|
||||
assert.equal(rewardAt(0).ramp_factor, 0.1);
|
||||
});
|
||||
test('/api/supply carries every documented field, the cap and a 33-row schedule', async () => {
|
||||
const res = fakeRes();
|
||||
await supply({ sql: fakeSql(), env: {} })({ method: 'GET', url: '/api/supply' }, res);
|
||||
assert.equal(res.code, 200);
|
||||
for (const f of SUPPLY_FIELDS) assert.ok(f in res.body, `missing ${f}`);
|
||||
assert.equal(res.body.max_supply_ign, '4000000000');
|
||||
assert.equal(res.body.schedule.length, 33);
|
||||
for (const f of SCHEDULE_FIELDS) assert.ok(f in res.body.schedule[0], `missing schedule.${f}`);
|
||||
assert.equal(res.body.schedule[0].per_second_ign, '31.68808781');
|
||||
assert.equal(res.body.schedule[1].per_second_ign, '15.8440439');
|
||||
assert.equal(res.body.schedule[1].start_daa, 63115200);
|
||||
assert.equal(res.body.minted_at_end_ign, '3963038988.86765648');
|
||||
assert.equal(res.body.circulating_ign, '482350.69732271'); // mintedByRule(125,064): the rule summed over DAA 0 to 125,063
|
||||
assert.equal(res.body.halving.next_halving_daa, 63115200);
|
||||
assert.equal(res.body.ramp.complete, false);
|
||||
assert.equal(res.body.check.rule_match, 500);
|
||||
});
|
||||
test('both refuse anything but GET', async () => {
|
||||
for (const h of [stats({ sql: fakeSql(), env: {} }), supply({ sql: fakeSql(), env: {} })]) {
|
||||
const res = fakeRes(); await h({ method: 'POST', url: '/' }, res); assert.equal(res.code, 405);
|
||||
}
|
||||
});
|
||||
90
site/api/stats.mjs
Normal file
90
site/api/stats.mjs
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
// Igneum public network stats. GET /api/stats: the numbers profitability sites and pool software read (WhatToMine's
|
||||
// listing form asks for an explorer or pool with an API and a source for the block reward and the block time).
|
||||
// Read from what tools/observer wrote to Neon (live_state, newest live_blocks row); the reward is computed from the
|
||||
// emission rule (site/lib/emission.mjs, spec 2.5) at the node's DAA score. Cached 10 s at the edge. Documented with
|
||||
// example responses in docs/api/public-stats.md; FIELDS below is the contract the CI check asserts.
|
||||
import { neon, num, tablePrefix, chainIdOf } from './_neon.mjs';
|
||||
import { blockSubsidy, rampFactor, HALVING_INTERVAL_SECONDS, LAUNCH_RAMP_SECONDS, PROVING_POOL_SHARE_PERCENT, sompiToIgn } from '../lib/emission.mjs';
|
||||
|
||||
export const STALE_AFTER_S = 30;
|
||||
export const FIELDS = ['ok', 'now', 'network', 'chain_id', 'node_version', 'algorithm', 'stale', 'age_s', 'height', 'block_count', 'header_count', 'daa', 'blue_score',
|
||||
'difficulty', 'hashrate', 'hashrate_unit', 'hashrate_source', 'block_time_target_s', 'block_time_measured_s', 'blocks_per_day_target', 'blocks_per_day_measured',
|
||||
'block_reward', 'last_block', 'finality', 'peers', 'mempool', 'miners_10m', 'observer_updated_at', 'source'];
|
||||
export const REWARD_FIELDS = ['sompi', 'ign', 'miner_ign', 'proving_pool_ign', 'split', 'daa_used', 'ramp_factor', 'halving_period', 'next_halving_daa', 'next_halving_in_s'];
|
||||
export const LAST_BLOCK_FIELDS = ['hash', 'time', 'ts_ms', 'age_s', 'blue_score', 'daa', 'miner', 'miner_id', 'tx_count', 'chain'];
|
||||
|
||||
export function rewardAt(daa) {
|
||||
const sompi = blockSubsidy(daa, 1);
|
||||
const pool = sompi * PROVING_POOL_SHARE_PERCENT / 100n;
|
||||
const period = Math.floor(daa / Number(HALVING_INTERVAL_SECONDS));
|
||||
const nextHalving = (period + 1) * Number(HALVING_INTERVAL_SECONDS);
|
||||
return {
|
||||
sompi: sompi.toString(), ign: sompiToIgn(sompi), miner_ign: sompiToIgn(sompi - pool), proving_pool_ign: sompiToIgn(pool),
|
||||
split: '80% block producer, 20% proving pool', daa_used: daa, ramp_factor: Math.round(rampFactor(daa) * 1e6) / 1e6,
|
||||
halving_period: period, next_halving_daa: nextHalving, next_halving_in_s: nextHalving - daa,
|
||||
};
|
||||
}
|
||||
|
||||
export function shape({ now, state: s, last, miners10m }) {
|
||||
const updated = s && s.updated_at ? new Date(s.updated_at).getTime() : null;
|
||||
const age = updated === null ? null : Math.max(0, (now - updated) / 1000);
|
||||
const stale = age === null || age > STALE_AFTER_S;
|
||||
const daa = s ? num(s.daa_score ?? (last && last.daa_score)) : null;
|
||||
const bpm = (s && s.blocks_per_minute) || [];
|
||||
const lastHour = bpm.reduce((a, [, n]) => a + (n || 0), 0);
|
||||
const b60 = s ? num(s.blocks_60s) : null;
|
||||
return {
|
||||
ok: true, now: new Date(now).toISOString(),
|
||||
network: s ? s.network : null, chain_id: s ? chainIdOf(s.network) : null, node_version: s ? s.node_version : null,
|
||||
algorithm: 'Igneum lottery hash: random-program GPU hash, new program every hour, generator v2 (docs/spec/01-lottery-hash.md)',
|
||||
stale, age_s: age === null ? null : Math.round(age * 10) / 10,
|
||||
// height is the chain block number (the EVM block number); block_count counts every DAG block the node holds
|
||||
height: last && last.number !== null && last.number !== undefined ? num(last.number) : null,
|
||||
block_count: s ? num(s.block_count) : null, header_count: s ? num(s.header_count) : null,
|
||||
daa, blue_score: s ? num(s.blue_score) : null, difficulty: s ? num(s.difficulty) : null,
|
||||
hashrate: s ? num(s.hashes_per_second_estimate) : null, hashrate_unit: 'H/s',
|
||||
hashrate_source: 'the node\'s estimateNetworkHashesPerSecond over a 1,000-block window; blue work added per second over 10 min when the node refuses the window',
|
||||
block_time_target_s: 1,
|
||||
block_time_measured_s: b60 ? Math.round(60 / b60 * 1000) / 1000 : null,
|
||||
blocks_per_day_target: 86400,
|
||||
blocks_per_day_measured: bpm.length >= 60 ? lastHour * 24 : null,
|
||||
block_reward: daa === null ? null : rewardAt(daa),
|
||||
last_block: last ? {
|
||||
hash: last.hash, time: new Date(num(last.timestamp_ms)).toISOString(), ts_ms: num(last.timestamp_ms), age_s: Math.round((now - num(last.timestamp_ms)) / 100) / 10,
|
||||
blue_score: num(last.blue_score), daa: num(last.daa_score), miner: last.miner_address || null, miner_id: last.vote_key_hash ? String(last.vote_key_hash).slice(0, 8) : null,
|
||||
tx_count: num(last.tx_count), chain: !!last.is_chain_block,
|
||||
} : null,
|
||||
finality: s && s.finality ? { active: !!s.finality.finality_active, latest_locked_index: num(s.finality.latest_locked_index), latest_locked_blue_score: num(s.finality.latest_locked_blue_score), chain_id: s.finality.chain_id || null } : { active: false, latest_locked_index: null, latest_locked_blue_score: null, chain_id: null },
|
||||
peers: s ? num(s.peers) : null, mempool: s ? num(s.mempool) : null, miners_10m: miners10m,
|
||||
observer_updated_at: s ? s.updated_at : null,
|
||||
source: 'tools/observer reading one node every 2 s; reward from docs/spec/02-consensus.md 2.5 at the node\'s DAA score',
|
||||
};
|
||||
}
|
||||
|
||||
export function createHandler({ env = process.env, sql } = {}) {
|
||||
return async function handler(req, res) {
|
||||
res.setHeader('Cache-Control', 'public, max-age=10, s-maxage=10');
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
if (req.method !== 'GET') { res.setHeader('Allow', 'GET'); return res.status(405).json({ ok: false, error: 'method not allowed' }); }
|
||||
try {
|
||||
sql = sql || neon(env.DATABASE_URL);
|
||||
const T = tablePrefix();
|
||||
const [head, lastRows, minerRows] = await Promise.all([
|
||||
sql(`SELECT now() AS now, (SELECT row_to_json(s) FROM ${T}live_state s WHERE s.id = 1) AS state`),
|
||||
sql(`SELECT hash, blue_score, daa_score, timestamp_ms, miner_address, vote_key_hash, tx_count, is_chain_block,
|
||||
(SELECT max(number) FROM ${T}live_blocks WHERE number IS NOT NULL AND received_at > now() - interval '10 minutes') AS number
|
||||
FROM ${T}live_blocks ORDER BY received_at DESC LIMIT 1`),
|
||||
sql(`SELECT count(DISTINCT vote_key_hash)::int AS n FROM ${T}live_blocks WHERE received_at > now() - interval '10 minutes' AND vote_key_hash IS NOT NULL`),
|
||||
]);
|
||||
const now = new Date(head[0].now).getTime();
|
||||
const s = head[0].state || null;
|
||||
const last = lastRows[0] || null;
|
||||
if (s && last && (s.daa_score === undefined)) s.daa_score = last.daa_score; // live_state has no DAA column; the newest block's is the node's
|
||||
return res.status(200).json(shape({ now, state: s, last, miners10m: minerRows[0] ? minerRows[0].n : 0 }));
|
||||
} catch (e) {
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
return res.status(500).json({ ok: false, error: String(e.message || e) });
|
||||
}
|
||||
};
|
||||
}
|
||||
export default createHandler();
|
||||
76
site/api/supply.mjs
Normal file
76
site/api/supply.mjs
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
// Igneum supply. GET /api/supply: coins minted so far by the emission rule at the node's DAA score, the hard cap, the
|
||||
// halving schedule as a table and the 30-day launch ramp (docs/spec/02-consensus.md 2.5, consensus/core/src/igneum.rs),
|
||||
// plus the observer's hourly check of the chain's coinbase sums against that rule (live_state.supply_check). Cached
|
||||
// 10 s at the edge. Documented in docs/api/public-stats.md.
|
||||
import { neon, num, tablePrefix, chainIdOf } from './_neon.mjs';
|
||||
import * as E from '../lib/emission.mjs';
|
||||
|
||||
export const FIELDS = ['ok', 'now', 'network', 'chain_id', 'unit', 'daa', 'max_supply_ign', 'circulating_ign', 'circulating_sompi', 'minted_at_end_ign', 'never_minted_ign',
|
||||
'emission_per_second_ign', 'block_reward_ign', 'halving', 'ramp', 'schedule', 'check', 'rule', 'source', 'note'];
|
||||
export const SCHEDULE_FIELDS = ['period', 'start_daa', 'end_daa', 'years_from_genesis', 'per_second_ign', 'per_block_ign_at_1bps', 'minted_by_end_ign', 'share_of_cap_by_end'];
|
||||
const S = Number(E.SECONDS_PER_YEAR);
|
||||
|
||||
export function shape({ now, state: s, daa }) {
|
||||
const minted = daa === null ? null : E.mintedByRule(daa);
|
||||
const end = E.mintedAtEnd();
|
||||
const period = daa === null ? null : Math.floor(daa / Number(E.HALVING_INTERVAL_SECONDS));
|
||||
const nextHalving = period === null ? null : (period + 1) * Number(E.HALVING_INTERVAL_SECONDS);
|
||||
const ramp = daa === null ? null : Number(E.LAUNCH_RAMP_SECONDS) - daa;
|
||||
const schedule = E.halvingTable().map(r => ({
|
||||
period: r.period, start_daa: Number(r.start_daa), end_daa: Number(r.end_daa),
|
||||
years_from_genesis: `${Number(r.start_daa) / S} to ${Number(r.end_daa) / S}`,
|
||||
per_second_ign: E.sompiToIgn(r.per_second_sompi), per_block_ign_at_1bps: E.sompiToIgn(r.per_second_sompi),
|
||||
minted_by_end_ign: E.sompiToIgn(r.minted_by_end_sompi),
|
||||
share_of_cap_by_end: Math.round(Number(r.minted_by_end_sompi * 1_000_000n / E.SUPPLY_CAP_SOMPI)) / 10_000,
|
||||
}));
|
||||
return {
|
||||
ok: true, now: new Date(now).toISOString(),
|
||||
network: s ? s.network : null, chain_id: s ? chainIdOf(s.network) : null,
|
||||
unit: { symbol: 'IGN', decimals_consensus: 8, decimals_evm: 18, note: 'the coinbase pays in 8-decimal units (open item O-2.6 keeps Kaspa\'s SOMPI_PER_KASPA); the EVM shows the same amount at 18 decimals' },
|
||||
daa,
|
||||
max_supply_ign: E.sompiToIgn(E.SUPPLY_CAP_SOMPI),
|
||||
circulating_ign: minted === null ? null : E.sompiToIgn(minted),
|
||||
circulating_sompi: minted === null ? null : minted.toString(),
|
||||
minted_at_end_ign: E.sompiToIgn(end),
|
||||
never_minted_ign: E.sompiToIgn(E.SUPPLY_CAP_SOMPI - end),
|
||||
emission_per_second_ign: daa === null ? null : E.sompiToIgn(E.blockSubsidy(daa, 1)),
|
||||
block_reward_ign: daa === null ? null : E.sompiToIgn(E.blockSubsidy(daa, 1)),
|
||||
halving: {
|
||||
interval_daa_s: Number(E.HALVING_INTERVAL_SECONDS), interval_years: 2, period, next_halving_daa: nextHalving,
|
||||
next_halving_in_s: nextHalving === null ? null : nextHalving - daa,
|
||||
next_halving_estimate: nextHalving === null ? null : new Date(now + (nextHalving - daa) * 1000).toISOString(),
|
||||
estimate_note: 'the estimate assumes one DAA second per wall-clock second from now',
|
||||
},
|
||||
ramp: {
|
||||
start_percent: Number(E.LAUNCH_RAMP_START_PERCENT), length_daa_s: Number(E.LAUNCH_RAMP_SECONDS), length_days: 30,
|
||||
factor_now: daa === null ? null : Math.round(E.rampFactor(daa) * 1e6) / 1e6, complete: daa === null ? null : daa >= Number(E.LAUNCH_RAMP_SECONDS),
|
||||
remaining_s: ramp === null ? null : Math.max(0, ramp),
|
||||
withheld_ign: E.sompiToIgn(E.SUPPLY_CAP_SOMPI - end), withheld_note: 'the ramp withholds about 37 million IGN that are never minted; integer floors withhold the rest (spec 2.5)',
|
||||
},
|
||||
schedule,
|
||||
check: s && s.supply_check ? s.supply_check : null,
|
||||
rule: 'E(t) = ramp(t) * floor(10^9 * UNIT / 31,557,600) >> floor(t / 63,115,200); ramp(t) = min(1, 1/10 + 9/10 * t / 2,592,000); t = DAA score / bps, bps = 1',
|
||||
source: 'docs/spec/02-consensus.md 2.5; vendor/igneum-node consensus/core/src/igneum.rs block_subsidy and launch_ramp; circulating = the rule summed over every DAA second from 0 to the node\'s DAA score (site/lib/emission.mjs mintedByRule, exact)',
|
||||
note: 'circulating is the schedule at this DAA score. The chain pays E per blue block it merges and per red inside the DAA window, which tracks the schedule one block per DAA step; the devnet of 3 October 2026 ran 4.7x the schedule for eight minutes during a retarget lag (spec 2.5). check reports what the observer measured on the newest blocks.',
|
||||
};
|
||||
}
|
||||
|
||||
export function createHandler({ env = process.env, sql } = {}) {
|
||||
return async function handler(req, res) {
|
||||
res.setHeader('Cache-Control', 'public, max-age=10, s-maxage=10');
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
if (req.method !== 'GET') { res.setHeader('Allow', 'GET'); return res.status(405).json({ ok: false, error: 'method not allowed' }); }
|
||||
try {
|
||||
sql = sql || neon(env.DATABASE_URL);
|
||||
const T = tablePrefix();
|
||||
const rows = await sql(`SELECT now() AS now, (SELECT row_to_json(s) FROM ${T}live_state s WHERE s.id = 1) AS state,
|
||||
(SELECT max(daa_score) FROM ${T}live_blocks WHERE received_at > now() - interval '10 minutes') AS daa`);
|
||||
const now = new Date(rows[0].now).getTime();
|
||||
return res.status(200).json(shape({ now, state: rows[0].state || null, daa: num(rows[0].daa) }));
|
||||
} catch (e) {
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
return res.status(500).json({ ok: false, error: String(e.message || e) });
|
||||
}
|
||||
};
|
||||
}
|
||||
export default createHandler();
|
||||
|
|
@ -582,6 +582,7 @@ th{font-family:var(--f-mono);font-size:12px;letter-spacing:.12em;text-transform:
|
|||
<nav class="foot-col" aria-label="Follow">
|
||||
<div class="eyebrow">Follow</div>
|
||||
<a href="/live">Live devnet</a>
|
||||
<a href="/explorer">Explorer</a>
|
||||
<a href="/#journey">Journey</a>
|
||||
<a href="/wallet">Add Igneum to MetaMask</a>
|
||||
<a href="https://github.com/igneum-network/spec" rel="noopener"><svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor" aria-hidden="true"><path d="M12 .5C5.7.5.5 5.7.5 12c0 5.1 3.3 9.4 7.9 10.9.6.1.8-.3.8-.6v-2.1c-3.2.7-3.9-1.4-3.9-1.4-.5-1.3-1.3-1.7-1.3-1.7-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.7 1.3 3.4 1 .1-.8.4-1.3.7-1.6-2.6-.3-5.3-1.3-5.3-5.7 0-1.3.5-2.3 1.2-3.1-.1-.3-.5-1.5.1-3.1 0 0 1-.3 3.2 1.2.9-.3 1.9-.4 2.9-.4s2 .1 2.9.4c2.2-1.5 3.2-1.2 3.2-1.2.6 1.6.2 2.8.1 3.1.8.8 1.2 1.8 1.2 3.1 0 4.4-2.7 5.4-5.3 5.7.4.4.8 1.1.8 2.2v3.2c0 .3.2.7.8.6 4.6-1.5 7.9-5.8 7.9-10.9C23.5 5.7 18.3.5 12 .5z"></path></svg>GitHub, spec and vectors</a>
|
||||
|
|
|
|||
330
site/block.html
Normal file
330
site/block.html
Normal file
|
|
@ -0,0 +1,330 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<title>Igneum block</title>
|
||||
<meta name="description" content="One Igneum block: header, parents, mergeset, transactions, proof records and, when it is a checkpoint, its finality certificate.">
|
||||
<link rel="canonical" href="https://igneum.network/block">
|
||||
<meta name="robots" content="noindex">
|
||||
<meta name="theme-color" content="#0C0C0E">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:site_name" content="Igneum">
|
||||
<meta property="og:title" content="Igneum block">
|
||||
<meta property="og:description" content="One Igneum block: header, parents, mergeset, transactions, proof records and its finality certificate.">
|
||||
<meta property="og:url" content="https://igneum.network/block">
|
||||
<meta property="og:image" content="https://igneum.network/og-small.png?v=3">
|
||||
<meta property="og:image:width" content="256">
|
||||
<meta property="og:image:height" content="256">
|
||||
<meta property="og:image:alt" content="Igneum. Mined by GPUs. Proven by fire.">
|
||||
<meta name="twitter:card" content="summary">
|
||||
<meta name="twitter:title" content="Igneum block">
|
||||
<meta name="twitter:description" content="One Igneum block: header, parents, mergeset, transactions, proof records and its finality certificate.">
|
||||
<meta name="twitter:image" content="https://igneum.network/og-small.png?v=3">
|
||||
<meta name="twitter:image:alt" content="Igneum. Mined by GPUs. Proven by fire.">
|
||||
<link rel="icon" href="/favicon.ico" sizes="48x48">
|
||||
<link rel="icon" href="/favicon-32.png" type="image/png" sizes="32x32">
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180">
|
||||
<link rel="manifest" href="/site.webmanifest">
|
||||
<!-- head:start -->
|
||||
<link rel="preload" href="/fonts/unbounded-900.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<link rel="preload" href="/fonts/plex-sans-400.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<style>
|
||||
/* Shared by every page. Source: site/partials/head.html, injected by site/build.mjs between the head markers. Edit the partial, not the page. */
|
||||
/* Fonts, self-hosted (latin subsets, OFL): Unbounded 500/700/900, IBM Plex Sans 400/500/600, IBM Plex Mono 400/500. Fallbacks carry size and ascent overrides so the swap does not move the layout. */
|
||||
@font-face{font-family:'Unbounded';font-style:normal;font-weight:500;font-display:swap;src:url(/fonts/unbounded-500.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
|
||||
@font-face{font-family:'Unbounded';font-style:normal;font-weight:700;font-display:swap;src:url(/fonts/unbounded-700.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
|
||||
@font-face{font-family:'Unbounded';font-style:normal;font-weight:900;font-display:swap;src:url(/fonts/unbounded-900.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
|
||||
@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:400;font-display:swap;src:url(/fonts/plex-sans-400.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
|
||||
@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:500;font-display:swap;src:url(/fonts/plex-sans-500.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
|
||||
@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:600;font-display:swap;src:url(/fonts/plex-sans-600.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
|
||||
@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:400;font-display:swap;src:url(/fonts/plex-mono-400.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
|
||||
@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:500;font-display:swap;src:url(/fonts/plex-mono-500.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
|
||||
@font-face{font-family:'Unbounded Fallback';src:local('Arial Black'),local('Arial-Black'),local('Impact');size-adjust:114%;ascent-override:87.5%;descent-override:21.5%;line-gap-override:0%}
|
||||
@font-face{font-family:'Plex Sans Fallback';src:local('Arial'),local('Helvetica Neue'),local('Helvetica');size-adjust:100.5%;ascent-override:102%;descent-override:27.4%;line-gap-override:0%}
|
||||
@font-face{font-family:'Plex Mono Fallback';src:local('Courier New'),local('Menlo');size-adjust:100%;ascent-override:102.5%;descent-override:27.5%;line-gap-override:0%}
|
||||
:root{--f-sans:'IBM Plex Sans','Plex Sans Fallback',system-ui,-apple-system,sans-serif;--f-display:'Unbounded','Unbounded Fallback',sans-serif;--f-mono:'IBM Plex Mono','Plex Mono Fallback',ui-monospace,Menlo,monospace;
|
||||
/* site chrome tokens (nav, footer, skip link). Dark by default; the litepaper sets light values and resets these in dark mode */
|
||||
--nav-h:68px;--ui-bg:rgba(12,12,14,.84);--ui-menu:#0C0C0E;--ui-ink:#F4F1EC;--ui-ink-2:#C9C7C2;--ui-ash:#9A9A9E;--ui-line:#2A2A30;--ui-line-2:#3A3A42;--ui-accent:#F2541B;--ui-accent-ink:#0C0C0E;--ui-hot:#FFB35C;--ui-hover:#FF6A2B;--ui-tint:rgba(242,84,27,.12)}
|
||||
html{-webkit-text-size-adjust:100%}
|
||||
h1,h2,h3{text-wrap:balance}
|
||||
p,li,dd,figcaption{text-wrap:pretty}
|
||||
a:focus-visible,button:focus-visible,summary:focus-visible,[tabindex]:focus-visible{outline:2px solid var(--ui-accent);outline-offset:3px;border-radius:6px}
|
||||
.skip{position:absolute;left:12px;top:-80px;z-index:50;background:var(--ui-accent);color:var(--ui-accent-ink);padding:10px 14px;border-radius:10px;font:600 15px/1.2 var(--f-sans);text-decoration:none}
|
||||
.skip:focus{top:12px}
|
||||
/* nav: one bar on every page, the mark in its black square, seven links and the miner button, a menu under 941 px (the eight items need 688 px beside the brand at 15 px, so the bar fits from 941 px up without a wrapped label) */
|
||||
.nav{position:sticky;top:0;z-index:20;background:var(--ui-bg);-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);border-bottom:1px solid var(--ui-line);color:var(--ui-ink)}
|
||||
.nav .wrap{display:flex;align-items:center;justify-content:space-between;gap:16px;min-height:var(--nav-h)}
|
||||
.nav a{color:inherit;text-decoration:none}
|
||||
.brand{display:inline-flex;align-items:center;gap:10px;color:var(--ui-ink);text-decoration:none;flex:0 0 auto}
|
||||
.brand .mark{display:block;flex:0 0 auto}
|
||||
.brand .word{font-family:var(--f-display);font-weight:900;font-size:20px;letter-spacing:.06em;line-height:1}
|
||||
.nav .links{display:flex;align-items:center;gap:clamp(14px,2vw,26px);font-size:15px;font-weight:500}
|
||||
.nav .links a{color:var(--ui-ink-2);padding:4px 0;border-bottom:2px solid transparent;white-space:nowrap;transition:color .15s ease,border-color .15s ease}
|
||||
.nav .links a:hover,.nav .brand:hover{color:var(--ui-hot);text-decoration:none}
|
||||
.nav .brand:hover{color:var(--ui-ink)}
|
||||
.nav .links a[aria-current="page"]{color:var(--ui-ink);border-bottom-color:var(--ui-accent)}
|
||||
.nav .links a.cta{display:inline-flex;align-items:center;justify-content:center;min-height:40px;padding:8px 16px;border-radius:10px;font-weight:600;background:var(--ui-accent);color:var(--ui-accent-ink);border:1px solid var(--ui-accent);transition:background .15s ease,transform .15s ease}
|
||||
.nav .links a.cta:hover{background:var(--ui-hover);border-color:var(--ui-hover);color:var(--ui-accent-ink);transform:translateY(-1px)}
|
||||
.burger{display:none;background:none;border:1px solid var(--ui-line-2);color:var(--ui-ink);border-radius:10px;width:44px;height:44px;align-items:center;justify-content:center;cursor:pointer;padding:0;flex:0 0 auto}
|
||||
.burger .x{display:none}.burger[aria-expanded="true"] .x{display:block}.burger[aria-expanded="true"] .bars{display:none}
|
||||
@media (max-width:940px){
|
||||
.nav .links{display:none;position:absolute;left:0;right:0;top:100%;background:var(--ui-menu);border-bottom:1px solid var(--ui-line);box-shadow:0 24px 40px rgba(0,0,0,.35);padding:10px var(--gutter,16px) 18px;flex-direction:column;align-items:stretch;gap:2px}
|
||||
.nav .links a{padding:13px 10px;border-radius:10px;border-bottom:0;font-size:16px}
|
||||
.nav .links a[aria-current="page"]{background:var(--ui-tint)}
|
||||
.nav .links a.cta{margin-top:8px;min-height:48px}
|
||||
.nav .links.open{display:flex}
|
||||
.burger{display:inline-flex}
|
||||
}
|
||||
/* footer, the same on every page */
|
||||
.foot{border-top:1px solid var(--ui-line);color:var(--ui-ink);margin-top:var(--sec,64px)}
|
||||
.foot .wrap{padding-block:48px 32px}
|
||||
.foot-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,200px),1fr));gap:32px}
|
||||
.foot-brand{display:flex;flex-direction:column;gap:12px;max-width:34ch}
|
||||
.foot-brand .brand{align-self:flex-start}.foot-brand .word{font-size:16px}
|
||||
.foot-brand p{color:var(--ui-ash);font-size:14px;margin:0}
|
||||
.foot-col{display:flex;flex-direction:column;gap:10px;font-size:15px}
|
||||
.foot-col .eyebrow{font-family:var(--f-mono);font-size:12px;letter-spacing:.18em;text-transform:uppercase;color:var(--ui-ash);margin-bottom:4px}
|
||||
.foot-col a{color:var(--ui-ink-2);text-decoration:none;display:inline-flex;align-items:center;gap:6px;align-self:flex-start}
|
||||
.foot-col a:hover{color:var(--ui-hot);text-decoration:none}
|
||||
.foot-base{display:flex;flex-wrap:wrap;justify-content:space-between;gap:8px 24px;margin-top:36px;padding-top:20px;border-top:1px solid var(--ui-line);font-size:13px;color:var(--ui-ash)}
|
||||
.foot-base span{text-wrap:balance}.foot-base .mono{font-family:var(--f-mono)}
|
||||
.foot-imprint{flex-basis:100%;text-wrap:pretty}.foot-base a{color:inherit;text-decoration:none}.foot-base a:hover{color:var(--ui-hot)}
|
||||
@media (prefers-reduced-motion:reduce){*,*::before,*::after{animation-duration:.01ms!important;animation-iteration-count:1!important;transition-duration:.01ms!important;scroll-behavior:auto!important}}
|
||||
</style>
|
||||
<!-- head:end -->
|
||||
<style>
|
||||
/* the explorer pages share this block with block.html and address.html; same tokens as live.html */
|
||||
:root{--obsidian:#0C0C0E;--graphite:#16161A;--line:#2A2A30;--line-2:#3A3A42;--ember:#F2541B;--molten:#FFB35C;--bone:#F4F1EC;--ash:#9A9A9E;--ink-2:#C9C7C2;--ember-ink:#0C0C0E;--blue:#5CB8FF;--green:#3FD39A;
|
||||
--max:1200px;--gutter:clamp(16px,4vw,32px);--sec:clamp(56px,8vw,96px);--head:clamp(40px,6vw,64px);
|
||||
--card-pad:clamp(18px,3vw,28px);--card-r:18px;--tile-pad:18px 20px;--tile-r:14px;--gap:24px;--gap-tile:12px;
|
||||
--fs-h1:clamp(32px,5.5vw,56px);--fs-h3:clamp(18px,2vw,22px);--fs-tile:clamp(20px,2.2vw,26px)}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--obsidian);color:var(--bone);font-family:var(--f-sans);font-size:17px;line-height:1.55;-webkit-font-smoothing:antialiased;overflow-x:hidden}
|
||||
a{color:inherit;text-decoration:none}a:hover{color:var(--molten)}
|
||||
a:focus-visible,button:focus-visible,input:focus-visible{outline:2px solid var(--ember);outline-offset:3px;border-radius:6px}
|
||||
.wrap{max-width:var(--max);margin:0 auto;padding-inline:var(--gutter)}
|
||||
.mono{font-family:var(--f-mono)}
|
||||
.eyebrow{font-family:var(--f-mono);font-size:12px;letter-spacing:.18em;text-transform:uppercase;color:var(--ash)}
|
||||
h1,h2{font-family:var(--f-display);margin:0;line-height:1.08;text-wrap:balance}
|
||||
h1{font-weight:900;font-size:var(--fs-h1);letter-spacing:-.01em;overflow-wrap:anywhere}
|
||||
h2{font-weight:700;font-size:var(--fs-h3)}
|
||||
p{margin:0}
|
||||
.card{background:var(--graphite);border:1px solid var(--line);border-radius:var(--card-r);padding:var(--card-pad);min-width:0;margin-top:var(--gap)}
|
||||
.head{padding-block:var(--head) var(--gap);display:flex;flex-direction:column;gap:14px}
|
||||
.head p{color:var(--ink-2);max-width:64ch;font-size:16px}
|
||||
.search{display:flex;gap:10px;max-width:760px;margin-top:4px}
|
||||
.search input{flex:1;min-width:0;min-height:48px;padding:10px 14px;border-radius:10px;border:1px solid var(--line-2);background:var(--graphite);color:var(--bone);font:500 15px/1.3 var(--f-mono)}
|
||||
.search input::placeholder{color:var(--ash)}
|
||||
.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:48px;padding:12px 22px;border-radius:10px;font-weight:600;font-size:16px;border:1px solid var(--line-2);color:var(--bone);background:transparent;cursor:pointer;font-family:var(--f-sans)}
|
||||
.btn.primary{background:var(--ember);color:var(--ember-ink);border-color:var(--ember)}
|
||||
.btn.primary:hover{background:#FF6A2B;border-color:#FF6A2B;color:var(--ember-ink)}
|
||||
.msg{font-family:var(--f-mono);font-size:13px;color:var(--molten);min-height:18px}
|
||||
.strip{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:var(--gap-tile)}
|
||||
@media (max-width:700px){.strip{grid-template-columns:repeat(2,minmax(0,1fr))}}
|
||||
.cell{background:var(--graphite);border:1px solid var(--line);border-radius:var(--tile-r);padding:var(--tile-pad);display:flex;flex-direction:column;gap:6px;min-width:0}
|
||||
.cell .k{font-family:var(--f-mono);font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--ash)}
|
||||
.cell .v{font-family:var(--f-display);font-weight:700;font-size:var(--fs-tile);line-height:1.1;font-variant-numeric:tabular-nums;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.cell .v small{font-family:var(--f-mono);font-weight:500;font-size:13px;color:var(--ash);margin-left:4px;letter-spacing:.04em}
|
||||
@media (max-width:700px){.cell .v{font-size:19px}}
|
||||
.cell .s{font-family:var(--f-mono);font-size:12px;color:var(--ash);text-wrap:balance}
|
||||
.cell.status .v{display:flex;align-items:center;gap:10px}
|
||||
.dot{width:10px;height:10px;border-radius:50%;background:var(--molten);display:inline-block;flex:0 0 10px;animation:pulse 2s ease-in-out infinite}
|
||||
.off .dot{background:var(--ash);animation:none}.off .cell.status .v{color:var(--ash)}.on .cell.status .v{color:var(--molten)}
|
||||
@keyframes pulse{0%,100%{box-shadow:0 0 0 0 rgba(255,179,92,.5)}50%{box-shadow:0 0 0 8px rgba(255,179,92,0)}}
|
||||
@media (prefers-reduced-motion:reduce){.dot{animation:none}}
|
||||
.viz-head{display:flex;flex-wrap:wrap;justify-content:space-between;align-items:center;gap:10px 20px;margin-bottom:12px}
|
||||
.tbl{overflow-x:auto;margin-top:12px;-webkit-overflow-scrolling:touch}
|
||||
table{border-collapse:collapse;width:100%;font-size:14px}
|
||||
th,td{padding:10px 10px;text-align:left;border-bottom:1px solid var(--line);white-space:nowrap;vertical-align:top}
|
||||
th{font-family:var(--f-mono);font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--ash);font-weight:500}
|
||||
td.n,th.n{text-align:right;font-variant-numeric:tabular-nums}
|
||||
tr:last-child td{border-bottom:0}
|
||||
tbody tr:hover td{background:rgba(255,255,255,.025)}
|
||||
.id{font-family:var(--f-mono);color:var(--molten)}
|
||||
.hash{font-family:var(--f-mono)}
|
||||
.sw{width:10px;height:10px;border-radius:3px;display:inline-block;margin-right:7px;vertical-align:middle;border:1.5px solid var(--ash)}
|
||||
.sw.chain{background:var(--ember);border-color:var(--blue)}.sw.blue{background:rgba(92,184,255,.7);border-color:var(--blue)}.sw.red{background:linear-gradient(45deg,transparent 44%,var(--ash) 44%,var(--ash) 56%,transparent 56%) center/8px 8px no-repeat}
|
||||
.chip{display:inline-block;font-family:var(--f-mono);font-size:11px;letter-spacing:.08em;text-transform:uppercase;padding:3px 8px;border-radius:999px;border:1px solid var(--line-2);color:var(--ink-2);margin-right:6px}
|
||||
.chip.hot{border-color:var(--molten);color:var(--molten)}.chip.ok{border-color:var(--green);color:var(--green)}
|
||||
.legend{display:flex;flex-wrap:wrap;gap:6px 16px;margin-top:14px;font-size:13px;color:var(--ink-2)}
|
||||
.legend span{display:inline-flex;align-items:center;white-space:nowrap}
|
||||
.empty{color:var(--ash);font-size:14px;padding:12px 0}
|
||||
.note{font-size:13px;color:var(--ash);margin-top:14px;max-width:80ch}
|
||||
dl{display:grid;grid-template-columns:max-content minmax(0,1fr);gap:8px 20px;margin:12px 0 0;font-size:14px}
|
||||
dt{font-family:var(--f-mono);font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--ash);padding-top:3px}
|
||||
dd{margin:0;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}
|
||||
@media (max-width:640px){dl{grid-template-columns:1fr;gap:2px 0}dd{margin-bottom:8px}}
|
||||
.two{display:grid;gap:var(--gap);grid-template-columns:1fr}
|
||||
@media (min-width:900px){.two{grid-template-columns:1fr 1fr}.two .card{margin-top:0}}
|
||||
.two-wrap{margin-top:var(--gap)}
|
||||
main{padding-bottom:var(--sec)}
|
||||
.foot{margin-top:0}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- nav:start -->
|
||||
<a class="skip" href="#main">Skip to content</a>
|
||||
<nav class="nav" aria-label="Main">
|
||||
<div class="wrap">
|
||||
<a href="/" class="brand" aria-label="Igneum home">
|
||||
<svg class="mark" viewBox="0 0 1024 1024" width="36" height="36" aria-hidden="true"><rect width="1024" height="1024" fill="#0C0C0E"></rect><g transform="translate(166.95 166.95) scale(6.901)"><polygon points="50,4 74,34 67,58 80,54 61,96 39,96 20,54 33,58 26,34" fill="#F2541B"></polygon><polygon points="50,42 59,58 50,82 41,58" fill="#0C0C0E"></polygon></g></svg>
|
||||
<span class="word">IGNEUM</span>
|
||||
</a>
|
||||
<button class="burger" id="nav-burger" type="button" aria-expanded="false" aria-controls="nav-links" aria-label="Menu"><svg class="bars" viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M4 7h16M4 12h16M4 17h16"></path></svg><svg class="x" viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"></path></svg></button>
|
||||
<div class="links" id="nav-links">
|
||||
<a href="/litepaper" data-nav="litepaper">Litepaper</a>
|
||||
<a href="/live" data-nav="live" aria-current="page">Live devnet</a>
|
||||
<a href="/bench" data-nav="bench">Engineering log</a>
|
||||
<a href="/miner" data-nav="miner">Miner</a>
|
||||
<a href="/wallet" data-nav="wallet">Wallet</a>
|
||||
<a href="/evidence" data-nav="evidence">Evidence</a>
|
||||
<a href="https://github.com/igneum-network/spec" rel="noopener">GitHub</a>
|
||||
<a href="/miner" class="cta">The miner</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<script>
|
||||
(function(){
|
||||
// the menu: open on the button, closed by any link, a tap outside, Escape (focus back on the button) or a resize past 940 px
|
||||
var b=document.getElementById('nav-burger'),l=document.getElementById('nav-links');if(!b||!l)return;
|
||||
function set(o){l.classList.toggle('open',o);b.setAttribute('aria-expanded',o?'true':'false');}
|
||||
b.addEventListener('click',function(){set(!l.classList.contains('open'));});
|
||||
l.addEventListener('click',function(e){if(e.target.closest('a'))set(false);});
|
||||
document.addEventListener('click',function(e){if(l.classList.contains('open')&&!l.contains(e.target)&&!b.contains(e.target))set(false);});
|
||||
document.addEventListener('keydown',function(e){if(e.key==='Escape'&&l.classList.contains('open')){set(false);b.focus();}});
|
||||
var mq=window.matchMedia('(min-width:941px)');if(mq.addEventListener)mq.addEventListener('change',function(){set(false);});
|
||||
})();
|
||||
</script>
|
||||
<!-- nav:end -->
|
||||
<main id="main" class="wrap">
|
||||
<div class="head">
|
||||
<div class="eyebrow"><a href="/explorer">Explorer</a> · block</div>
|
||||
<h1 class="hash" id="title">Block</h1>
|
||||
<div id="chips"></div>
|
||||
<div class="msg" id="msg" aria-live="polite"></div>
|
||||
</div>
|
||||
<div id="body" hidden>
|
||||
<div class="two two-wrap">
|
||||
<div class="card"><div class="viz-head"><h2>Summary</h2></div><dl id="summary"></dl></div>
|
||||
<div class="card"><div class="viz-head"><h2>Header</h2></div><dl id="header"></dl></div>
|
||||
</div>
|
||||
<div class="two two-wrap">
|
||||
<div class="card"><div class="viz-head"><h2>Parents</h2><div class="eyebrow" id="parents-n"></div></div><div id="parents"></div><div class="viz-head" style="margin-top:18px"><h2>Children</h2><div class="eyebrow">seen within 10 min</div></div><div id="children"></div></div>
|
||||
<div class="card"><div class="viz-head"><h2>Mergeset</h2><div class="eyebrow">what this block merges</div></div><div id="mergeset"></div><p class="note">A chain block merges the blocks in its past that no earlier chain block merged. Blue ones are paid by this block's coinbase; red ones are excluded from the order and their reward goes to this block's miner.</p></div>
|
||||
</div>
|
||||
<div class="card"><div class="viz-head"><h2>Transactions</h2><div class="eyebrow" id="tx-n"></div></div><div id="coinbase"></div><div class="tbl" id="txs"></div></div>
|
||||
<div class="card"><div class="viz-head"><h2>Proof records and shards</h2><div class="eyebrow" id="proof-eyebrow"></div></div><div id="proofs"></div></div>
|
||||
<div class="card"><div class="viz-head"><h2>Finality</h2><div class="eyebrow" id="fin-eyebrow"></div></div><div id="finality"></div></div>
|
||||
</div>
|
||||
</main>
|
||||
<!-- footer:start -->
|
||||
<footer class="foot">
|
||||
<div class="wrap">
|
||||
<div class="foot-grid">
|
||||
<div class="foot-brand">
|
||||
<a href="/" class="brand" aria-label="Igneum home"><svg class="mark" viewBox="0 0 1024 1024" width="30" height="30" aria-hidden="true"><rect width="1024" height="1024" fill="#0C0C0E"></rect><g transform="translate(166.95 166.95) scale(6.901)"><polygon points="50,4 74,34 67,58 80,54 61,96 39,96 20,54 33,58 26,34" fill="#F2541B"></polygon><polygon points="50,42 59,58 50,82 41,58" fill="#0C0C0E"></polygon></g></svg><span class="word">IGNEUM</span></a>
|
||||
<p>Mined by GPUs. Proven by fire.</p>
|
||||
</div>
|
||||
<nav class="foot-col" aria-label="Read">
|
||||
<div class="eyebrow">Read</div>
|
||||
<a href="/litepaper">Litepaper</a>
|
||||
<a href="/litepaper#limits">What Igneum does not claim</a>
|
||||
<a href="/litepaper#randomx">Igneum vs RandomX</a>
|
||||
<a href="/litepaper#shoulders">Built on the shoulders</a>
|
||||
<a href="/bench">Engineering log</a>
|
||||
<a href="/evidence">Evidence</a>
|
||||
</nav>
|
||||
<nav class="foot-col" aria-label="Run">
|
||||
<div class="eyebrow">Run</div>
|
||||
<a href="/miner">The miner</a>
|
||||
<a href="/wallet">The wallet</a>
|
||||
<a href="/miners">GPU bench table</a>
|
||||
<a href="/miner#fee">The dev fee</a>
|
||||
<a href="/faucet">Testnet faucet</a>
|
||||
</nav>
|
||||
<nav class="foot-col" aria-label="Follow">
|
||||
<div class="eyebrow">Follow</div>
|
||||
<a href="/live">Live devnet</a>
|
||||
<a href="/explorer">Explorer</a>
|
||||
<a href="/#journey">Journey</a>
|
||||
<a href="/wallet">Add Igneum to MetaMask</a>
|
||||
<a href="https://github.com/igneum-network/spec" rel="noopener"><svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor" aria-hidden="true"><path d="M12 .5C5.7.5.5 5.7.5 12c0 5.1 3.3 9.4 7.9 10.9.6.1.8-.3.8-.6v-2.1c-3.2.7-3.9-1.4-3.9-1.4-.5-1.3-1.3-1.7-1.3-1.7-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.7 1.3 3.4 1 .1-.8.4-1.3.7-1.6-2.6-.3-5.3-1.3-5.3-5.7 0-1.3.5-2.3 1.2-3.1-.1-.3-.5-1.5.1-3.1 0 0 1-.3 3.2 1.2.9-.3 1.9-.4 2.9-.4s2 .1 2.9.4c2.2-1.5 3.2-1.2 3.2-1.2.6 1.6.2 2.8.1 3.1.8.8 1.2 1.8 1.2 3.1 0 4.4-2.7 5.4-5.3 5.7.4.4.8 1.1.8 2.2v3.2c0 .3.2.7.8.6 4.6-1.5 7.9-5.8 7.9-10.9C23.5 5.7 18.3.5 12 .5z"></path></svg>GitHub, spec and vectors</a>
|
||||
<a href="/miner#get">Miner downloads: public testnet</a>
|
||||
<a href="mailto:hello@igneum.network">Report a flaw: hello@igneum.network</a>
|
||||
<a href="https://github.com/igneum-network/spec/issues" rel="noopener">Or open an issue on the spec</a>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="foot-base">
|
||||
<span class="foot-imprint">Igneum Labs LTD · Unit IH-00-01-01-OF-01, Level 01, Innovation One, Dubai International Financial Centre · <a href="mailto:hello@igneum.network">hello@igneum.network</a></span>
|
||||
<span>© 2026 Igneum. Nothing on this page is an offer to sell anything.</span>
|
||||
<span class="mono">igneum.network</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
<!-- footer:end -->
|
||||
<script type="module">
|
||||
import { ign, ignFromWei, shortHash, rel, int, utc } from '/lib/explorer.mjs';
|
||||
const $ = id => document.getElementById(id);
|
||||
const esc = s => String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
const link = h => `<a class="hash" href="/block/${esc(h)}" title="${esc(h)}">${esc(shortHash(h, 12, 8))}</a>`;
|
||||
const addr = a => a ? `<a class="hash" href="/address/${esc(a)}">${esc(a)}</a>` : 'n/a';
|
||||
const dl = pairs => pairs.filter(p => p).map(([k, v]) => `<dt>${esc(k)}</dt><dd>${v}</dd>`).join('');
|
||||
const id = decodeURIComponent(location.pathname.split('/').pop() || '');
|
||||
const query = /^\d{1,12}$/.test(id) ? 'height=' + id : 'block=' + encodeURIComponent(id.replace(/^0x/i, ''));
|
||||
async function load() {
|
||||
const r = await fetch('/api/explorer?' + query); const j = await r.json();
|
||||
if (!j.ok) { $('msg').textContent = j.error || 'Not found.'; $('title').textContent = /^\d/.test(id) ? 'Chain block ' + id : shortHash(id, 10, 8); return; }
|
||||
const b = j.block, d = b.detail || {}, now = new Date(j.now).getTime();
|
||||
document.title = `Igneum block ${shortHash(b.hash, 8, 6)}`;
|
||||
$('title').textContent = b.hash;
|
||||
const chips = [b.chain ? '<span class="chip hot">chain block</span>' : `<span class="chip">${esc(b.color)}</span>`];
|
||||
if (j.checkpoint) chips.push(`<span class="chip ${j.checkpoint.state === 'locked' ? 'ok' : ''}">checkpoint ${j.checkpoint.index}, ${esc(j.checkpoint.state)}</span>`);
|
||||
if (b.proof_records) chips.push(`<span class="chip">${b.proof_records} proof record${b.proof_records === 1 ? '' : 's'}</span>`);
|
||||
if (b.tx_count) chips.push(`<span class="chip">${b.tx_count} transaction${b.tx_count === 1 ? '' : 's'}</span>`);
|
||||
$('chips').innerHTML = chips.join('');
|
||||
$('summary').innerHTML = dl([
|
||||
['Number', b.number === null ? '<span style="color:var(--ash)">none, not a chain block</span>' : int(b.number)],
|
||||
['Time', `${utc(b.ts)} <span style="color:var(--ash)">(${rel(now - b.ts)})</span>`],
|
||||
['DAA score', int(b.daa)], ['Blue score', int(b.blue_score)],
|
||||
['Miner', addr(b.evm_miner)], ['Coinbase address', addr(b.miner)],
|
||||
['Vote key', `<span class="id">${esc(b.miner_id || 'n/a')}</span> <span class="hash" style="color:var(--ash);font-size:12px">${esc(b.vote_key_hash || '')}</span>`],
|
||||
b.engine ? ['Engine', esc(b.engine)] : null,
|
||||
['Difficulty', d.difficulty ? int(d.difficulty) : 'n/a'],
|
||||
['Declared subsidy', b.subsidy_sompi ? `${ign(b.subsidy_sompi, 8)} IGN <span style="color:var(--ash)">E(DAA) of spec 2.5, paid when a later chain block merges this one</span>` : 'n/a'],
|
||||
['Coinbase paid', b.paid_sompi ? `${ign(b.paid_sompi, 8)} IGN <span style="color:var(--ash)">to the blocks it merges</span>` : 'n/a'],
|
||||
['Merged by', b.chain ? 'itself (chain block)' : (j.merged_by ? link(j.merged_by) : '<span style="color:var(--ash)">not yet</span>')],
|
||||
['Selected parent', b.selected_parent ? link(b.selected_parent) : 'n/a'],
|
||||
]);
|
||||
$('header').innerHTML = dl([
|
||||
['Hash', `<span class="hash">${esc(b.hash)}</span>`], ['Version', esc(d.version)], ['Bits', esc(d.bits)], ['Nonce', `<span class="hash">${esc(d.nonce)}</span>`],
|
||||
['Blue work', `<span class="hash">${esc(d.blue_work)}</span>`], ['Hash merkle root', `<span class="hash">${esc(d.hash_merkle_root)}</span>`],
|
||||
['Accepted id merkle root', `<span class="hash">${esc(d.accepted_id_merkle_root)}</span>`], ['UTXO commitment', `<span class="hash">${esc(d.utxo_commitment)}</span>`],
|
||||
['Pruning point', `<span class="hash">${esc(d.pruning_point)}</span>`], ['Parent levels', esc(d.parents_by_level ?? 'n/a')],
|
||||
]);
|
||||
$('parents-n').textContent = `${b.parent_hashes.length} at level 0`;
|
||||
$('parents').innerHTML = b.parent_hashes.length ? '<div class="hash" style="display:flex;flex-direction:column;gap:6px">' + b.parent_hashes.map(link).join('') + '</div>' : '<div class="empty">Genesis has no parents.</div>';
|
||||
$('children').innerHTML = j.children.length ? '<div class="hash" style="display:flex;flex-direction:column;gap:6px">' + j.children.map(link).join('') + '</div>' : '<div class="empty">No child seen yet.</div>';
|
||||
const ms = d.mergeset || { blues: [], reds: [] };
|
||||
$('mergeset').innerHTML = (ms.blues.length || ms.reds.length) ? `<div style="display:flex;flex-direction:column;gap:6px">${ms.blues.map(h => `<div><i class="sw blue"></i>${link(h)}</div>`).join('')}${ms.reds.map(h => `<div><i class="sw red"></i>${link(h)}</div>`).join('')}</div>` : '<div class="empty">Empty.</div>';
|
||||
const cb = d.coinbase;
|
||||
$('tx-n').textContent = `${b.tx_count ?? 0} EVM, 1 coinbase`;
|
||||
$('coinbase').innerHTML = cb ? `<dl>${dl([['Coinbase', `<span class="hash">${esc(cb.id)}</span>`], ['Outputs', cb.outputs.map(o => `${o.pool ? '<span style="color:var(--ash)">proving pool (burns on the UTXO side, credited on the EVM side)</span>' : addr(o.address)} <b>${ign(o.value, 8)} IGN</b>`).join('<br>') || 'none'], ['Carries', `${cb.records} proof record${cb.records === 1 ? '' : 's'}${cb.reveal ? ', a key reveal' : ''}${(d.certificates || []).length ? `, certificate ${d.certificates.join(', ')}` : ''}`]])}</dl>` : '<div class="empty">No coinbase decoded.</div>';
|
||||
const txs = d.txs || []; const ev = j.evm && j.evm.transactions ? new Map(j.evm.transactions.map(t => [t.hash, t])) : null;
|
||||
$('txs').innerHTML = txs.length ? `<table><thead><tr><th>Transaction</th><th>From</th><th>To</th><th class="n">Value</th><th class="n">Bytes</th></tr></thead><tbody>${txs.map(t => { const e = ev && ev.get(t.hash); return `<tr id="tx-${esc((t.hash || '').replace(/^0x/, ''))}"><td class="hash">${esc(t.hash || '?')}</td><td>${e ? addr(e.from) : '<span style="color:var(--ash)">needs an EVM RPC</span>'}</td><td>${e ? addr(e.to) : ''}</td><td class="n">${e ? ignFromWei(e.value) + ' IGN' : ''}</td><td class="n">${t.size ?? ''}</td></tr>`; }).join('')}</tbody></table>` : '<div class="empty" style="margin-top:8px">No EVM transactions in this block.</div>';
|
||||
if (j.evm && !j.evm.error) $('txs').insertAdjacentHTML('beforeend', `<p class="note">EVM view of this chain block: gas used ${int(j.evm.gas_used)}, state root <span class="hash">${esc(shortHash(j.evm.state_root, 10, 6))}</span>${j.evm.igneum ? `, rewards ${j.evm.igneum.rewards.map(r => ignFromWei(r.wei) + ' IGN to ' + shortHash(r.miner, 6, 4)).join(', ')}, proving pool credit ${ignFromWei(j.evm.igneum.proving_pool_credit)} IGN` : ''}.</p>`);
|
||||
const sh = j.shards;
|
||||
$('proof-eyebrow').textContent = b.chain ? `${sh.length} shard${sh.length === 1 ? '' : 's'} planned for this chain block` : 'not a chain block';
|
||||
$('proofs').innerHTML = `<p style="font-size:14px;color:var(--ink-2)">This block carries <b>${b.proof_records ?? 0}</b> proof record${b.proof_records === 1 ? '' : 's'} for earlier chain blocks (274 bytes each, in the coinbase extra data).</p>` +
|
||||
(sh.length ? `<div class="tbl"><table><thead><tr><th class="n">Shard</th><th>State</th><th>Prover</th><th class="n">pgas</th><th class="n">Proof lag, DAA</th><th>Carried by</th><th class="n">Payout</th></tr></thead><tbody>${sh.map(s => `<tr><td class="n">${s.i} of ${s.n}</td><td><span class="chip ${s.state === 'paid' ? 'ok' : s.state === 'verified' ? 'hot' : ''}">${esc(s.state)}</span></td><td class="id">${esc(s.prover || '')}</td><td class="n">${int(s.pgas)}</td><td class="n">${s.lag === null ? '' : int(s.lag)}</td><td>${s.carried_by ? link(s.carried_by) : ''}</td><td class="n">${s.payout_wei ? ignFromWei(s.payout_wei, 6) + ' IGN' : ''}</td></tr>`).join('')}</tbody></table></div>` : (b.chain ? '<div class="empty">No shard plan recorded (the proving feed was not reading this block).</div>' : ''));
|
||||
const cp = j.checkpoint, certs = j.certificates || [];
|
||||
$('fin-eyebrow').textContent = cp ? `checkpoint ${cp.index}` : (certs.length ? 'carries a certificate' : 'not a checkpoint');
|
||||
$('finality').innerHTML = (cp ? `<dl>${dl([['State', `<span class="chip ${cp.state === 'locked' ? 'ok' : ''}">${esc(cp.state)}</span>`], ['Signed weight', `${int(cp.signed)} of ${int(cp.total)} (${(cp.fraction_total * 100).toFixed(1)}% of all weight, ${(cp.fraction_active * 100).toFixed(1)}% of active)`], ['Votes', `${int(cp.votes)} of ${int(cp.voters)} voters`], cp.locked_at ? ['Locked', utc(new Date(cp.locked_at).getTime())] : null])}</dl>` : '') +
|
||||
certs.map(c => `<dl style="margin-top:14px">${dl([[c.role === 'checkpoint' ? 'Certificate' : 'Carries certificate', `index ${c.index}, ${c.voter_count} voters, aggregator <span class="id">${esc(shortHash(c.aggregator, 8, 0))}</span>, ${c.role === 'checkpoint' ? 'carried by ' + link(c.carrier) : 'for checkpoint ' + link(c.checkpoint)}`], ['Weight', `${int(c.total_weight)} total, ${int(c.active_weight)} active at the lock`], c.previous ? ['Previous lock', `index ${c.previous.index}, ${link(c.previous.hash)}${c.headers_complete ? ', header chain stored' : ''}`] : null, ['Verify', `<a href="/api/checkpoint">/api/checkpoint</a> serves the newest certificate for the in-browser verifier`]])}</dl>`).join('') +
|
||||
(!cp && !certs.length ? '<div class="empty">This block is neither a checkpoint nor a certificate carrier. Checkpoints are every 30 blue score; a lock needs two thirds of all 30-day weight.</div>' : '');
|
||||
$('body').hidden = false;
|
||||
}
|
||||
load().catch(e => { $('msg').textContent = 'The API did not answer: ' + e.message; });
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -312,7 +312,9 @@ function stampDownloads(html, file, dl) {
|
|||
const downloads = await loadDownloads();
|
||||
built.push(`downloads (${downloads.source}: ${Object.keys(downloads.files || {}).join(', ') || 'none'})`);
|
||||
|
||||
const PAGES = [['index.html', ''], ['litepaper.html', 'litepaper'], ['live.html', 'live'], ['evidence.html', 'evidence'], ['miner.html', 'miner'], ['wallet.html', 'wallet'], ['metamask.html', ''], ['faucet.html', ''], ['404.html', '']];
|
||||
const PAGES = [['index.html', ''], ['litepaper.html', 'litepaper'], ['live.html', 'live'], ['evidence.html', 'evidence'], ['miner.html', 'miner'], ['wallet.html', 'wallet'], ['metamask.html', ''], ['faucet.html', ''], ['404.html', ''],
|
||||
// the DAG explorer (5 Oct 2026): /explorer, /block/<hash> and /address/<addr> (vercel.json rewrites the last two)
|
||||
['explorer.html', 'live'], ['block.html', 'live'], ['address.html', 'live']];
|
||||
for (const [file, active] of PAGES) {
|
||||
const p = join(here, file);
|
||||
if (!existsSync(p)) throw new Error(`missing page ${file}`);
|
||||
|
|
|
|||
|
|
@ -261,6 +261,7 @@ code{font-family:var(--f-mono);font-size:.92em;background:var(--obsidian);paddin
|
|||
<nav class="foot-col" aria-label="Follow">
|
||||
<div class="eyebrow">Follow</div>
|
||||
<a href="/live">Live devnet</a>
|
||||
<a href="/explorer">Explorer</a>
|
||||
<a href="/#journey">Journey</a>
|
||||
<a href="/wallet">Add Igneum to MetaMask</a>
|
||||
<a href="https://github.com/igneum-network/spec" rel="noopener"><svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor" aria-hidden="true"><path d="M12 .5C5.7.5.5 5.7.5 12c0 5.1 3.3 9.4 7.9 10.9.6.1.8-.3.8-.6v-2.1c-3.2.7-3.9-1.4-3.9-1.4-.5-1.3-1.3-1.7-1.3-1.7-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.7 1.3 3.4 1 .1-.8.4-1.3.7-1.6-2.6-.3-5.3-1.3-5.3-5.7 0-1.3.5-2.3 1.2-3.1-.1-.3-.5-1.5.1-3.1 0 0 1-.3 3.2 1.2.9-.3 1.9-.4 2.9-.4s2 .1 2.9.4c2.2-1.5 3.2-1.2 3.2-1.2.6 1.6.2 2.8.1 3.1.8.8 1.2 1.8 1.2 3.1 0 4.4-2.7 5.4-5.3 5.7.4.4.8 1.1.8 2.2v3.2c0 .3.2.7.8.6 4.6-1.5 7.9-5.8 7.9-10.9C23.5 5.7 18.3.5 12 .5z"></path></svg>GitHub, spec and vectors</a>
|
||||
|
|
|
|||
347
site/explorer.html
Normal file
347
site/explorer.html
Normal file
|
|
@ -0,0 +1,347 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<title>Igneum explorer</title>
|
||||
<meta name="description" content="The Igneum block DAG, block by block: hash, DAA score, blue score, miner, transactions and proof records, with the network numbers and the supply.">
|
||||
<link rel="canonical" href="https://igneum.network/explorer">
|
||||
<meta name="theme-color" content="#0C0C0E">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:site_name" content="Igneum">
|
||||
<meta property="og:title" content="Igneum explorer">
|
||||
<meta property="og:description" content="The Igneum block DAG, block by block: hash, DAA score, blue score, miner, transactions and proof records, with the network numbers and the supply.">
|
||||
<meta property="og:url" content="https://igneum.network/explorer">
|
||||
<meta property="og:image" content="https://igneum.network/og-small.png?v=3">
|
||||
<meta property="og:image:width" content="256">
|
||||
<meta property="og:image:height" content="256">
|
||||
<meta property="og:image:alt" content="Igneum. Mined by GPUs. Proven by fire.">
|
||||
<meta name="twitter:card" content="summary">
|
||||
<meta name="twitter:title" content="Igneum explorer">
|
||||
<meta name="twitter:description" content="The Igneum block DAG, block by block, with the network numbers and the supply.">
|
||||
<meta name="twitter:image" content="https://igneum.network/og-small.png?v=3">
|
||||
<meta name="twitter:image:alt" content="Igneum. Mined by GPUs. Proven by fire.">
|
||||
<link rel="icon" href="/favicon.ico" sizes="48x48">
|
||||
<link rel="icon" href="/favicon-32.png" type="image/png" sizes="32x32">
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180">
|
||||
<link rel="manifest" href="/site.webmanifest">
|
||||
<!-- head:start -->
|
||||
<link rel="preload" href="/fonts/unbounded-900.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<link rel="preload" href="/fonts/plex-sans-400.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<style>
|
||||
/* Shared by every page. Source: site/partials/head.html, injected by site/build.mjs between the head markers. Edit the partial, not the page. */
|
||||
/* Fonts, self-hosted (latin subsets, OFL): Unbounded 500/700/900, IBM Plex Sans 400/500/600, IBM Plex Mono 400/500. Fallbacks carry size and ascent overrides so the swap does not move the layout. */
|
||||
@font-face{font-family:'Unbounded';font-style:normal;font-weight:500;font-display:swap;src:url(/fonts/unbounded-500.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
|
||||
@font-face{font-family:'Unbounded';font-style:normal;font-weight:700;font-display:swap;src:url(/fonts/unbounded-700.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
|
||||
@font-face{font-family:'Unbounded';font-style:normal;font-weight:900;font-display:swap;src:url(/fonts/unbounded-900.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
|
||||
@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:400;font-display:swap;src:url(/fonts/plex-sans-400.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
|
||||
@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:500;font-display:swap;src:url(/fonts/plex-sans-500.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
|
||||
@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:600;font-display:swap;src:url(/fonts/plex-sans-600.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
|
||||
@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:400;font-display:swap;src:url(/fonts/plex-mono-400.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
|
||||
@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:500;font-display:swap;src:url(/fonts/plex-mono-500.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
|
||||
@font-face{font-family:'Unbounded Fallback';src:local('Arial Black'),local('Arial-Black'),local('Impact');size-adjust:114%;ascent-override:87.5%;descent-override:21.5%;line-gap-override:0%}
|
||||
@font-face{font-family:'Plex Sans Fallback';src:local('Arial'),local('Helvetica Neue'),local('Helvetica');size-adjust:100.5%;ascent-override:102%;descent-override:27.4%;line-gap-override:0%}
|
||||
@font-face{font-family:'Plex Mono Fallback';src:local('Courier New'),local('Menlo');size-adjust:100%;ascent-override:102.5%;descent-override:27.5%;line-gap-override:0%}
|
||||
:root{--f-sans:'IBM Plex Sans','Plex Sans Fallback',system-ui,-apple-system,sans-serif;--f-display:'Unbounded','Unbounded Fallback',sans-serif;--f-mono:'IBM Plex Mono','Plex Mono Fallback',ui-monospace,Menlo,monospace;
|
||||
/* site chrome tokens (nav, footer, skip link). Dark by default; the litepaper sets light values and resets these in dark mode */
|
||||
--nav-h:68px;--ui-bg:rgba(12,12,14,.84);--ui-menu:#0C0C0E;--ui-ink:#F4F1EC;--ui-ink-2:#C9C7C2;--ui-ash:#9A9A9E;--ui-line:#2A2A30;--ui-line-2:#3A3A42;--ui-accent:#F2541B;--ui-accent-ink:#0C0C0E;--ui-hot:#FFB35C;--ui-hover:#FF6A2B;--ui-tint:rgba(242,84,27,.12)}
|
||||
html{-webkit-text-size-adjust:100%}
|
||||
h1,h2,h3{text-wrap:balance}
|
||||
p,li,dd,figcaption{text-wrap:pretty}
|
||||
a:focus-visible,button:focus-visible,summary:focus-visible,[tabindex]:focus-visible{outline:2px solid var(--ui-accent);outline-offset:3px;border-radius:6px}
|
||||
.skip{position:absolute;left:12px;top:-80px;z-index:50;background:var(--ui-accent);color:var(--ui-accent-ink);padding:10px 14px;border-radius:10px;font:600 15px/1.2 var(--f-sans);text-decoration:none}
|
||||
.skip:focus{top:12px}
|
||||
/* nav: one bar on every page, the mark in its black square, seven links and the miner button, a menu under 941 px (the eight items need 688 px beside the brand at 15 px, so the bar fits from 941 px up without a wrapped label) */
|
||||
.nav{position:sticky;top:0;z-index:20;background:var(--ui-bg);-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);border-bottom:1px solid var(--ui-line);color:var(--ui-ink)}
|
||||
.nav .wrap{display:flex;align-items:center;justify-content:space-between;gap:16px;min-height:var(--nav-h)}
|
||||
.nav a{color:inherit;text-decoration:none}
|
||||
.brand{display:inline-flex;align-items:center;gap:10px;color:var(--ui-ink);text-decoration:none;flex:0 0 auto}
|
||||
.brand .mark{display:block;flex:0 0 auto}
|
||||
.brand .word{font-family:var(--f-display);font-weight:900;font-size:20px;letter-spacing:.06em;line-height:1}
|
||||
.nav .links{display:flex;align-items:center;gap:clamp(14px,2vw,26px);font-size:15px;font-weight:500}
|
||||
.nav .links a{color:var(--ui-ink-2);padding:4px 0;border-bottom:2px solid transparent;white-space:nowrap;transition:color .15s ease,border-color .15s ease}
|
||||
.nav .links a:hover,.nav .brand:hover{color:var(--ui-hot);text-decoration:none}
|
||||
.nav .brand:hover{color:var(--ui-ink)}
|
||||
.nav .links a[aria-current="page"]{color:var(--ui-ink);border-bottom-color:var(--ui-accent)}
|
||||
.nav .links a.cta{display:inline-flex;align-items:center;justify-content:center;min-height:40px;padding:8px 16px;border-radius:10px;font-weight:600;background:var(--ui-accent);color:var(--ui-accent-ink);border:1px solid var(--ui-accent);transition:background .15s ease,transform .15s ease}
|
||||
.nav .links a.cta:hover{background:var(--ui-hover);border-color:var(--ui-hover);color:var(--ui-accent-ink);transform:translateY(-1px)}
|
||||
.burger{display:none;background:none;border:1px solid var(--ui-line-2);color:var(--ui-ink);border-radius:10px;width:44px;height:44px;align-items:center;justify-content:center;cursor:pointer;padding:0;flex:0 0 auto}
|
||||
.burger .x{display:none}.burger[aria-expanded="true"] .x{display:block}.burger[aria-expanded="true"] .bars{display:none}
|
||||
@media (max-width:940px){
|
||||
.nav .links{display:none;position:absolute;left:0;right:0;top:100%;background:var(--ui-menu);border-bottom:1px solid var(--ui-line);box-shadow:0 24px 40px rgba(0,0,0,.35);padding:10px var(--gutter,16px) 18px;flex-direction:column;align-items:stretch;gap:2px}
|
||||
.nav .links a{padding:13px 10px;border-radius:10px;border-bottom:0;font-size:16px}
|
||||
.nav .links a[aria-current="page"]{background:var(--ui-tint)}
|
||||
.nav .links a.cta{margin-top:8px;min-height:48px}
|
||||
.nav .links.open{display:flex}
|
||||
.burger{display:inline-flex}
|
||||
}
|
||||
/* footer, the same on every page */
|
||||
.foot{border-top:1px solid var(--ui-line);color:var(--ui-ink);margin-top:var(--sec,64px)}
|
||||
.foot .wrap{padding-block:48px 32px}
|
||||
.foot-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,200px),1fr));gap:32px}
|
||||
.foot-brand{display:flex;flex-direction:column;gap:12px;max-width:34ch}
|
||||
.foot-brand .brand{align-self:flex-start}.foot-brand .word{font-size:16px}
|
||||
.foot-brand p{color:var(--ui-ash);font-size:14px;margin:0}
|
||||
.foot-col{display:flex;flex-direction:column;gap:10px;font-size:15px}
|
||||
.foot-col .eyebrow{font-family:var(--f-mono);font-size:12px;letter-spacing:.18em;text-transform:uppercase;color:var(--ui-ash);margin-bottom:4px}
|
||||
.foot-col a{color:var(--ui-ink-2);text-decoration:none;display:inline-flex;align-items:center;gap:6px;align-self:flex-start}
|
||||
.foot-col a:hover{color:var(--ui-hot);text-decoration:none}
|
||||
.foot-base{display:flex;flex-wrap:wrap;justify-content:space-between;gap:8px 24px;margin-top:36px;padding-top:20px;border-top:1px solid var(--ui-line);font-size:13px;color:var(--ui-ash)}
|
||||
.foot-base span{text-wrap:balance}.foot-base .mono{font-family:var(--f-mono)}
|
||||
.foot-imprint{flex-basis:100%;text-wrap:pretty}.foot-base a{color:inherit;text-decoration:none}.foot-base a:hover{color:var(--ui-hot)}
|
||||
@media (prefers-reduced-motion:reduce){*,*::before,*::after{animation-duration:.01ms!important;animation-iteration-count:1!important;transition-duration:.01ms!important;scroll-behavior:auto!important}}
|
||||
</style>
|
||||
<!-- head:end -->
|
||||
<style>
|
||||
/* the explorer pages share this block with block.html and address.html; same tokens as live.html */
|
||||
:root{--obsidian:#0C0C0E;--graphite:#16161A;--line:#2A2A30;--line-2:#3A3A42;--ember:#F2541B;--molten:#FFB35C;--bone:#F4F1EC;--ash:#9A9A9E;--ink-2:#C9C7C2;--ember-ink:#0C0C0E;--blue:#5CB8FF;--green:#3FD39A;
|
||||
--max:1200px;--gutter:clamp(16px,4vw,32px);--sec:clamp(56px,8vw,96px);--head:clamp(40px,6vw,64px);
|
||||
--card-pad:clamp(18px,3vw,28px);--card-r:18px;--tile-pad:18px 20px;--tile-r:14px;--gap:24px;--gap-tile:12px;
|
||||
--fs-h1:clamp(32px,5.5vw,56px);--fs-h3:clamp(18px,2vw,22px);--fs-tile:clamp(20px,2.2vw,26px)}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--obsidian);color:var(--bone);font-family:var(--f-sans);font-size:17px;line-height:1.55;-webkit-font-smoothing:antialiased;overflow-x:hidden}
|
||||
a{color:inherit;text-decoration:none}a:hover{color:var(--molten)}
|
||||
a:focus-visible,button:focus-visible,input:focus-visible{outline:2px solid var(--ember);outline-offset:3px;border-radius:6px}
|
||||
.wrap{max-width:var(--max);margin:0 auto;padding-inline:var(--gutter)}
|
||||
.mono{font-family:var(--f-mono)}
|
||||
.eyebrow{font-family:var(--f-mono);font-size:12px;letter-spacing:.18em;text-transform:uppercase;color:var(--ash)}
|
||||
h1,h2{font-family:var(--f-display);margin:0;line-height:1.08;text-wrap:balance}
|
||||
h1{font-weight:900;font-size:var(--fs-h1);letter-spacing:-.01em;overflow-wrap:anywhere}
|
||||
h2{font-weight:700;font-size:var(--fs-h3)}
|
||||
p{margin:0}
|
||||
.card{background:var(--graphite);border:1px solid var(--line);border-radius:var(--card-r);padding:var(--card-pad);min-width:0;margin-top:var(--gap)}
|
||||
.head{padding-block:var(--head) var(--gap);display:flex;flex-direction:column;gap:14px}
|
||||
.head p{color:var(--ink-2);max-width:64ch;font-size:16px}
|
||||
.search{display:flex;gap:10px;max-width:760px;margin-top:4px}
|
||||
.search input{flex:1;min-width:0;min-height:48px;padding:10px 14px;border-radius:10px;border:1px solid var(--line-2);background:var(--graphite);color:var(--bone);font:500 15px/1.3 var(--f-mono)}
|
||||
.search input::placeholder{color:var(--ash)}
|
||||
.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:48px;padding:12px 22px;border-radius:10px;font-weight:600;font-size:16px;border:1px solid var(--line-2);color:var(--bone);background:transparent;cursor:pointer;font-family:var(--f-sans)}
|
||||
.btn.primary{background:var(--ember);color:var(--ember-ink);border-color:var(--ember)}
|
||||
.btn.primary:hover{background:#FF6A2B;border-color:#FF6A2B;color:var(--ember-ink)}
|
||||
.msg{font-family:var(--f-mono);font-size:13px;color:var(--molten);min-height:18px}
|
||||
.strip{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:var(--gap-tile)}
|
||||
@media (max-width:700px){.strip{grid-template-columns:repeat(2,minmax(0,1fr))}}
|
||||
.cell{background:var(--graphite);border:1px solid var(--line);border-radius:var(--tile-r);padding:var(--tile-pad);display:flex;flex-direction:column;gap:6px;min-width:0}
|
||||
.cell .k{font-family:var(--f-mono);font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--ash)}
|
||||
.cell .v{font-family:var(--f-display);font-weight:700;font-size:var(--fs-tile);line-height:1.1;font-variant-numeric:tabular-nums;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.cell .v small{font-family:var(--f-mono);font-weight:500;font-size:13px;color:var(--ash);margin-left:4px;letter-spacing:.04em}
|
||||
@media (max-width:700px){.cell .v{font-size:19px}}
|
||||
.cell .s{font-family:var(--f-mono);font-size:12px;color:var(--ash);text-wrap:balance}
|
||||
.cell.status .v{display:flex;align-items:center;gap:10px}
|
||||
.dot{width:10px;height:10px;border-radius:50%;background:var(--molten);display:inline-block;flex:0 0 10px;animation:pulse 2s ease-in-out infinite}
|
||||
.off .dot{background:var(--ash);animation:none}.off .cell.status .v{color:var(--ash)}.on .cell.status .v{color:var(--molten)}
|
||||
@keyframes pulse{0%,100%{box-shadow:0 0 0 0 rgba(255,179,92,.5)}50%{box-shadow:0 0 0 8px rgba(255,179,92,0)}}
|
||||
@media (prefers-reduced-motion:reduce){.dot{animation:none}}
|
||||
.viz-head{display:flex;flex-wrap:wrap;justify-content:space-between;align-items:center;gap:10px 20px;margin-bottom:12px}
|
||||
.tbl{overflow-x:auto;margin-top:12px;-webkit-overflow-scrolling:touch}
|
||||
table{border-collapse:collapse;width:100%;font-size:14px}
|
||||
th,td{padding:10px 10px;text-align:left;border-bottom:1px solid var(--line);white-space:nowrap;vertical-align:top}
|
||||
th{font-family:var(--f-mono);font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--ash);font-weight:500}
|
||||
td.n,th.n{text-align:right;font-variant-numeric:tabular-nums}
|
||||
tr:last-child td{border-bottom:0}
|
||||
tbody tr:hover td{background:rgba(255,255,255,.025)}
|
||||
.id{font-family:var(--f-mono);color:var(--molten)}
|
||||
.hash{font-family:var(--f-mono)}
|
||||
.sw{width:10px;height:10px;border-radius:3px;display:inline-block;margin-right:7px;vertical-align:middle;border:1.5px solid var(--ash)}
|
||||
.sw.chain{background:var(--ember);border-color:var(--blue)}.sw.blue{background:rgba(92,184,255,.7);border-color:var(--blue)}.sw.red{background:linear-gradient(45deg,transparent 44%,var(--ash) 44%,var(--ash) 56%,transparent 56%) center/8px 8px no-repeat}
|
||||
.chip{display:inline-block;font-family:var(--f-mono);font-size:11px;letter-spacing:.08em;text-transform:uppercase;padding:3px 8px;border-radius:999px;border:1px solid var(--line-2);color:var(--ink-2);margin-right:6px}
|
||||
.chip.hot{border-color:var(--molten);color:var(--molten)}.chip.ok{border-color:var(--green);color:var(--green)}
|
||||
.legend{display:flex;flex-wrap:wrap;gap:6px 16px;margin-top:14px;font-size:13px;color:var(--ink-2)}
|
||||
.legend span{display:inline-flex;align-items:center;white-space:nowrap}
|
||||
.empty{color:var(--ash);font-size:14px;padding:12px 0}
|
||||
.note{font-size:13px;color:var(--ash);margin-top:14px;max-width:80ch}
|
||||
dl{display:grid;grid-template-columns:max-content minmax(0,1fr);gap:8px 20px;margin:12px 0 0;font-size:14px}
|
||||
dt{font-family:var(--f-mono);font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--ash);padding-top:3px}
|
||||
dd{margin:0;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}
|
||||
@media (max-width:640px){dl{grid-template-columns:1fr;gap:2px 0}dd{margin-bottom:8px}}
|
||||
.two{display:grid;gap:var(--gap);grid-template-columns:1fr}
|
||||
@media (min-width:900px){.two{grid-template-columns:1fr 1fr}.two .card{margin-top:0}}
|
||||
.two-wrap{margin-top:var(--gap)}
|
||||
main{padding-bottom:var(--sec)}
|
||||
.foot{margin-top:0}
|
||||
</style>
|
||||
</head>
|
||||
<body class="off">
|
||||
<!-- nav:start -->
|
||||
<a class="skip" href="#main">Skip to content</a>
|
||||
<nav class="nav" aria-label="Main">
|
||||
<div class="wrap">
|
||||
<a href="/" class="brand" aria-label="Igneum home">
|
||||
<svg class="mark" viewBox="0 0 1024 1024" width="36" height="36" aria-hidden="true"><rect width="1024" height="1024" fill="#0C0C0E"></rect><g transform="translate(166.95 166.95) scale(6.901)"><polygon points="50,4 74,34 67,58 80,54 61,96 39,96 20,54 33,58 26,34" fill="#F2541B"></polygon><polygon points="50,42 59,58 50,82 41,58" fill="#0C0C0E"></polygon></g></svg>
|
||||
<span class="word">IGNEUM</span>
|
||||
</a>
|
||||
<button class="burger" id="nav-burger" type="button" aria-expanded="false" aria-controls="nav-links" aria-label="Menu"><svg class="bars" viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M4 7h16M4 12h16M4 17h16"></path></svg><svg class="x" viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"></path></svg></button>
|
||||
<div class="links" id="nav-links">
|
||||
<a href="/litepaper" data-nav="litepaper">Litepaper</a>
|
||||
<a href="/live" data-nav="live" aria-current="page">Live devnet</a>
|
||||
<a href="/bench" data-nav="bench">Engineering log</a>
|
||||
<a href="/miner" data-nav="miner">Miner</a>
|
||||
<a href="/wallet" data-nav="wallet">Wallet</a>
|
||||
<a href="/evidence" data-nav="evidence">Evidence</a>
|
||||
<a href="https://github.com/igneum-network/spec" rel="noopener">GitHub</a>
|
||||
<a href="/miner" class="cta">The miner</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<script>
|
||||
(function(){
|
||||
// the menu: open on the button, closed by any link, a tap outside, Escape (focus back on the button) or a resize past 940 px
|
||||
var b=document.getElementById('nav-burger'),l=document.getElementById('nav-links');if(!b||!l)return;
|
||||
function set(o){l.classList.toggle('open',o);b.setAttribute('aria-expanded',o?'true':'false');}
|
||||
b.addEventListener('click',function(){set(!l.classList.contains('open'));});
|
||||
l.addEventListener('click',function(e){if(e.target.closest('a'))set(false);});
|
||||
document.addEventListener('click',function(e){if(l.classList.contains('open')&&!l.contains(e.target)&&!b.contains(e.target))set(false);});
|
||||
document.addEventListener('keydown',function(e){if(e.key==='Escape'&&l.classList.contains('open')){set(false);b.focus();}});
|
||||
var mq=window.matchMedia('(min-width:941px)');if(mq.addEventListener)mq.addEventListener('change',function(){set(false);});
|
||||
})();
|
||||
</script>
|
||||
<!-- nav:end -->
|
||||
<main id="main" class="wrap">
|
||||
<div class="head">
|
||||
<div class="eyebrow">devnet, last 24 hours</div>
|
||||
<h1>Explorer</h1>
|
||||
<p>Every block the devnet made in the last day, read from a node by the observer. Paste a block hash, a transaction hash, a chain block number or an address.</p>
|
||||
<form class="search" id="search" role="search">
|
||||
<input id="q" type="search" autocomplete="off" spellcheck="false" placeholder="block hash, transaction hash, chain block number, 0x address or igneumdev:… address" aria-label="Search the explorer">
|
||||
<button class="btn primary" type="submit">Search</button>
|
||||
</form>
|
||||
<div class="msg" id="msg" aria-live="polite"></div>
|
||||
</div>
|
||||
<div class="strip" aria-live="polite">
|
||||
<div class="cell status"><div class="k">Status</div><div class="v"><span class="dot"></span><span id="st-status">OFFLINE</span></div><div class="s" id="st-age">no observer update yet</div></div>
|
||||
<div class="cell"><div class="k">Height</div><div class="v" id="st-height">0</div><div class="s" id="st-count">chain block number, 0 blocks in the DAG</div></div>
|
||||
<div class="cell"><div class="k">DAA score</div><div class="v" id="st-daa">0</div><div class="s" id="st-blue">blue score 0</div></div>
|
||||
<div class="cell"><div class="k">Difficulty</div><div class="v" id="st-diff">0</div><div class="s" id="st-bt">target per block</div></div>
|
||||
<div class="cell"><div class="k">Hash rate</div><div class="v" id="st-hps">0</div><div class="s">network estimate</div></div>
|
||||
<div class="cell"><div class="k">Block reward</div><div class="v" id="st-reward">0<small>IGN</small></div><div class="s" id="st-split">80% miner, 20% proving pool</div></div>
|
||||
<div class="cell"><div class="k">Circulating</div><div class="v" id="st-supply">0<small>IGN</small></div><div class="s" id="st-cap">of 4,000,000,000 by the rule</div></div>
|
||||
<div class="cell"><div class="k">Next halving</div><div class="v" id="st-halving">0<small>d</small></div><div class="s" id="st-ramp">ramp</div></div>
|
||||
</div>
|
||||
<div class="card" aria-label="Latest blocks">
|
||||
<div class="viz-head"><h2>Latest blocks</h2><div class="eyebrow" id="blk-eyebrow">newest first, every 5 s</div></div>
|
||||
<div class="tbl">
|
||||
<table>
|
||||
<thead><tr><th>Block</th><th class="n">Number</th><th class="n">DAA</th><th class="n">Blue score</th><th>Miner</th><th class="n">Txs</th><th class="n">Proof records</th><th>Time</th></tr></thead>
|
||||
<tbody id="blocks"><tr><td colspan="8" class="empty">Loading.</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="legend"><span><i class="sw chain"></i>chain block</span><span><i class="sw blue"></i>blue, merged and paid</span><span><i class="sw red"></i>red, excluded</span><span><i class="sw"></i>pending, not merged yet</span><span>★ locked checkpoint</span></div>
|
||||
<p class="note">Number is the chain block number, the same number the EVM reports; a block off the selected chain has none. Miner is the payout address the coinbase names. Proof records is how many shard proofs the block carries for earlier blocks. Click a row for the block.</p>
|
||||
<button class="btn" id="more" type="button" style="margin-top:14px">Older blocks</button>
|
||||
</div>
|
||||
<div class="card" aria-label="Public API">
|
||||
<div class="viz-head"><h2>Public API</h2><div class="eyebrow">JSON, cached 10 s</div></div>
|
||||
<dl>
|
||||
<dt>/api/stats</dt><dd><a href="/api/stats" class="hash">network hash rate, difficulty, block time, block reward now, blocks per day, DAA, blue score, height, last block, chain id, version</a></dd>
|
||||
<dt>/api/supply</dt><dd><a href="/api/supply" class="hash">circulating by the emission rule, the 4,000,000,000 cap, the halving table, the 30-day ramp, the observer's coinbase check</a></dd>
|
||||
<dt>/api/explorer</dt><dd><a href="/api/explorer?blocks=20" class="hash">?blocks=N, ?block=hash, ?height=N, ?address=addr, ?search=q</a></dd>
|
||||
</dl>
|
||||
<p class="note">Reward and supply come from the emission rule of the specification (section 2.5) at the node's DAA score; the observer checks the chain's coinbase sums against it every hour and the result is in /api/supply under check.</p>
|
||||
</div>
|
||||
</main>
|
||||
<!-- footer:start -->
|
||||
<footer class="foot">
|
||||
<div class="wrap">
|
||||
<div class="foot-grid">
|
||||
<div class="foot-brand">
|
||||
<a href="/" class="brand" aria-label="Igneum home"><svg class="mark" viewBox="0 0 1024 1024" width="30" height="30" aria-hidden="true"><rect width="1024" height="1024" fill="#0C0C0E"></rect><g transform="translate(166.95 166.95) scale(6.901)"><polygon points="50,4 74,34 67,58 80,54 61,96 39,96 20,54 33,58 26,34" fill="#F2541B"></polygon><polygon points="50,42 59,58 50,82 41,58" fill="#0C0C0E"></polygon></g></svg><span class="word">IGNEUM</span></a>
|
||||
<p>Mined by GPUs. Proven by fire.</p>
|
||||
</div>
|
||||
<nav class="foot-col" aria-label="Read">
|
||||
<div class="eyebrow">Read</div>
|
||||
<a href="/litepaper">Litepaper</a>
|
||||
<a href="/litepaper#limits">What Igneum does not claim</a>
|
||||
<a href="/litepaper#randomx">Igneum vs RandomX</a>
|
||||
<a href="/litepaper#shoulders">Built on the shoulders</a>
|
||||
<a href="/bench">Engineering log</a>
|
||||
<a href="/evidence">Evidence</a>
|
||||
</nav>
|
||||
<nav class="foot-col" aria-label="Run">
|
||||
<div class="eyebrow">Run</div>
|
||||
<a href="/miner">The miner</a>
|
||||
<a href="/wallet">The wallet</a>
|
||||
<a href="/miners">GPU bench table</a>
|
||||
<a href="/miner#fee">The dev fee</a>
|
||||
<a href="/faucet">Testnet faucet</a>
|
||||
</nav>
|
||||
<nav class="foot-col" aria-label="Follow">
|
||||
<div class="eyebrow">Follow</div>
|
||||
<a href="/live">Live devnet</a>
|
||||
<a href="/explorer">Explorer</a>
|
||||
<a href="/#journey">Journey</a>
|
||||
<a href="/wallet">Add Igneum to MetaMask</a>
|
||||
<a href="https://github.com/igneum-network/spec" rel="noopener"><svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor" aria-hidden="true"><path d="M12 .5C5.7.5.5 5.7.5 12c0 5.1 3.3 9.4 7.9 10.9.6.1.8-.3.8-.6v-2.1c-3.2.7-3.9-1.4-3.9-1.4-.5-1.3-1.3-1.7-1.3-1.7-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.7 1.3 3.4 1 .1-.8.4-1.3.7-1.6-2.6-.3-5.3-1.3-5.3-5.7 0-1.3.5-2.3 1.2-3.1-.1-.3-.5-1.5.1-3.1 0 0 1-.3 3.2 1.2.9-.3 1.9-.4 2.9-.4s2 .1 2.9.4c2.2-1.5 3.2-1.2 3.2-1.2.6 1.6.2 2.8.1 3.1.8.8 1.2 1.8 1.2 3.1 0 4.4-2.7 5.4-5.3 5.7.4.4.8 1.1.8 2.2v3.2c0 .3.2.7.8.6 4.6-1.5 7.9-5.8 7.9-10.9C23.5 5.7 18.3.5 12 .5z"></path></svg>GitHub, spec and vectors</a>
|
||||
<a href="/miner#get">Miner downloads: public testnet</a>
|
||||
<a href="mailto:hello@igneum.network">Report a flaw: hello@igneum.network</a>
|
||||
<a href="https://github.com/igneum-network/spec/issues" rel="noopener">Or open an issue on the spec</a>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="foot-base">
|
||||
<span class="foot-imprint">Igneum Labs LTD · Unit IH-00-01-01-OF-01, Level 01, Innovation One, Dubai International Financial Centre · <a href="mailto:hello@igneum.network">hello@igneum.network</a></span>
|
||||
<span>© 2026 Igneum. Nothing on this page is an offer to sell anything.</span>
|
||||
<span class="mono">igneum.network</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
<!-- footer:end -->
|
||||
<script type="module">
|
||||
import { classify, hrefFor, ign, shortHash, hashrate, rel, int } from '/lib/explorer.mjs';
|
||||
const $ = id => document.getElementById(id);
|
||||
const esc = s => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
let oldest = 0, serverNow = 0, serverAt = 0;
|
||||
const vnow = () => serverNow + (performance.now() - serverAt);
|
||||
$('search').addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
const c = classify($('q').value);
|
||||
if (c.type === 'none') { $('msg').textContent = 'Not a block hash, a transaction hash, a chain block number or an address.'; return; }
|
||||
if (c.type === 'address') { location.href = hrefFor(c); return; }
|
||||
$('msg').textContent = 'Looking.';
|
||||
try {
|
||||
const r = await fetch('/api/explorer?search=' + encodeURIComponent($('q').value.trim()));
|
||||
const j = await r.json();
|
||||
if (j.ok && j.href) location.href = j.href; else $('msg').textContent = j.error || 'Not found.';
|
||||
} catch { $('msg').textContent = 'The API did not answer.'; }
|
||||
});
|
||||
function rowHtml(b) {
|
||||
const sw = b.chain ? 'chain' : b.color;
|
||||
const miner = b.evm_miner ? `<a class="hash" href="/address/${b.evm_miner}" title="${esc(b.evm_miner)}">${esc(shortHash(b.evm_miner, 6, 4))}</a>` : (b.miner ? `<a class="hash" href="/address/${esc(b.miner)}">${esc(shortHash(b.miner, 14, 4))}</a>` : '<span class="id">' + esc(b.miner_id || '?') + '</span>');
|
||||
return `<tr data-href="/block/${b.hash}"><td><i class="sw ${sw}" title="${b.chain ? 'chain block' : b.color}"></i><a class="hash" href="/block/${b.hash}">${esc(shortHash(b.hash, 10, 6))}</a>${b.locked ? ' ★' : ''}</td><td class="n">${b.number === null ? '<span style="color:var(--ash)">off chain</span>' : int(b.number)}</td><td class="n">${int(b.daa)}</td><td class="n">${int(b.blue_score)}</td><td>${miner}<span class="id" style="margin-left:8px;font-size:12px">${esc(b.miner_id || '')}</span></td><td class="n">${b.tx_count === null ? 'n/a' : b.tx_count}</td><td class="n">${b.proof_records === null ? 'n/a' : b.proof_records}</td><td title="${new Date(b.ts).toISOString()}">${rel(vnow() - b.ts)}</td></tr>`;
|
||||
}
|
||||
async function loadBlocks(before) {
|
||||
const r = await fetch('/api/explorer?blocks=50' + (before ? '&before=' + before : ''));
|
||||
const j = await r.json(); if (!j.ok) throw new Error(j.error);
|
||||
serverNow = new Date(j.now).getTime(); serverAt = performance.now();
|
||||
const html = j.blocks.map(rowHtml).join('');
|
||||
if (before) $('blocks').insertAdjacentHTML('beforeend', html); else $('blocks').innerHTML = html || '<tr><td colspan="8" class="empty">No blocks in the last 24 hours.</td></tr>';
|
||||
if (j.blocks.length) oldest = j.blocks[j.blocks.length - 1].rx;
|
||||
$('blk-eyebrow').textContent = (j.stale ? 'observer offline, last known' : 'newest first, every 5 s') + (j.network ? ', ' + j.network : '');
|
||||
}
|
||||
$('blocks').addEventListener('click', e => { const tr = e.target.closest('tr[data-href]'); if (tr && !e.target.closest('a')) location.href = tr.dataset.href; });
|
||||
$('more').addEventListener('click', () => { $('more').disabled = true; loadBlocks(oldest).catch(() => {}).finally(() => { $('more').disabled = false; clearInterval(timer); }); });
|
||||
async function loadStats() {
|
||||
const [s, p] = await Promise.all([fetch('/api/stats').then(r => r.json()), fetch('/api/supply').then(r => r.json())]);
|
||||
document.body.classList.toggle('on', !s.stale); document.body.classList.toggle('off', !!s.stale);
|
||||
$('st-status').textContent = s.stale ? 'OFFLINE' : 'LIVE';
|
||||
$('st-age').textContent = s.age_s === null ? 'no observer update yet' : (s.stale ? `last update ${rel(s.age_s * 1000)}` : `${s.network || ''}, node ${s.node_version || '?'}`);
|
||||
$('st-height').textContent = s.height === null ? 'n/a' : int(s.height);
|
||||
$('st-count').textContent = `chain block number, ${int(s.block_count)} blocks in the DAG`;
|
||||
$('st-daa').textContent = int(s.daa); $('st-blue').textContent = 'blue score ' + int(s.blue_score);
|
||||
$('st-diff').textContent = s.difficulty === null ? 'n/a' : (s.difficulty >= 1e6 ? (s.difficulty / 1e6).toFixed(2) + ' M' : int(s.difficulty));
|
||||
$('st-bt').textContent = s.block_time_measured_s === null ? 'target 1 s per block' : `${s.block_time_measured_s} s per block over 60 s, target 1 s`;
|
||||
$('st-hps').textContent = hashrate(s.hashrate);
|
||||
if (s.block_reward) { $('st-reward').innerHTML = esc(Number(s.block_reward.ign).toFixed(4)) + '<small>IGN</small>'; $('st-split').textContent = `${Number(s.block_reward.miner_ign).toFixed(4)} miner, ${Number(s.block_reward.proving_pool_ign).toFixed(4)} proving pool`; }
|
||||
if (p.ok) {
|
||||
$('st-supply').innerHTML = esc(int(Number(p.circulating_ign))) + '<small>IGN</small>';
|
||||
$('st-cap').textContent = `of ${int(Number(p.max_supply_ign))}, ${(Number(p.circulating_ign) / Number(p.max_supply_ign) * 100).toFixed(3)}% by the rule`;
|
||||
const d = p.halving.next_halving_in_s / 86400;
|
||||
$('st-halving').innerHTML = esc(d >= 1 ? Math.floor(d).toLocaleString('en-GB') : (p.halving.next_halving_in_s / 3600).toFixed(1)) + '<small>' + (d >= 1 ? 'days' : 'h') + '</small>';
|
||||
$('st-ramp').textContent = p.ramp.complete ? 'launch ramp complete' : `launch ramp at ${(p.ramp.factor_now * 100).toFixed(1)}%, full at DAA ${int(p.ramp.length_daa_s)}`;
|
||||
}
|
||||
}
|
||||
const timer = setInterval(() => { loadBlocks().catch(() => {}); }, 5000);
|
||||
setInterval(() => { loadStats().catch(() => {}); }, 10000);
|
||||
loadBlocks().catch(e => { $('blocks').innerHTML = `<tr><td colspan="8" class="empty">${esc(e.message)}</td></tr>`; });
|
||||
loadStats().catch(() => {});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -227,6 +227,7 @@ dt{color:var(--ash)}dd{margin:0;font-family:var(--f-mono);font-size:14px;overflo
|
|||
<nav class="foot-col" aria-label="Follow">
|
||||
<div class="eyebrow">Follow</div>
|
||||
<a href="/live">Live devnet</a>
|
||||
<a href="/explorer">Explorer</a>
|
||||
<a href="/#journey">Journey</a>
|
||||
<a href="/wallet">Add Igneum to MetaMask</a>
|
||||
<a href="https://github.com/igneum-network/spec" rel="noopener"><svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor" aria-hidden="true"><path d="M12 .5C5.7.5.5 5.7.5 12c0 5.1 3.3 9.4 7.9 10.9.6.1.8-.3.8-.6v-2.1c-3.2.7-3.9-1.4-3.9-1.4-.5-1.3-1.3-1.7-1.3-1.7-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.7 1.3 3.4 1 .1-.8.4-1.3.7-1.6-2.6-.3-5.3-1.3-5.3-5.7 0-1.3.5-2.3 1.2-3.1-.1-.3-.5-1.5.1-3.1 0 0 1-.3 3.2 1.2.9-.3 1.9-.4 2.9-.4s2 .1 2.9.4c2.2-1.5 3.2-1.2 3.2-1.2.6 1.6.2 2.8.1 3.1.8.8 1.2 1.8 1.2 3.1 0 4.4-2.7 5.4-5.3 5.7.4.4.8 1.1.8 2.2v3.2c0 .3.2.7.8.6 4.6-1.5 7.9-5.8 7.9-10.9C23.5 5.7 18.3.5 12 .5z"></path></svg>GitHub, spec and vectors</a>
|
||||
|
|
|
|||
|
|
@ -644,6 +644,7 @@ pre{margin:0;font-family:var(--f-mono);font-size:13px;line-height:1.6;color:var(
|
|||
<nav class="foot-col" aria-label="Follow">
|
||||
<div class="eyebrow">Follow</div>
|
||||
<a href="/live">Live devnet</a>
|
||||
<a href="/explorer">Explorer</a>
|
||||
<a href="/#journey">Journey</a>
|
||||
<a href="/wallet">Add Igneum to MetaMask</a>
|
||||
<a href="https://github.com/igneum-network/spec" rel="noopener"><svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor" aria-hidden="true"><path d="M12 .5C5.7.5.5 5.7.5 12c0 5.1 3.3 9.4 7.9 10.9.6.1.8-.3.8-.6v-2.1c-3.2.7-3.9-1.4-3.9-1.4-.5-1.3-1.3-1.7-1.3-1.7-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.7 1.3 3.4 1 .1-.8.4-1.3.7-1.6-2.6-.3-5.3-1.3-5.3-5.7 0-1.3.5-2.3 1.2-3.1-.1-.3-.5-1.5.1-3.1 0 0 1-.3 3.2 1.2.9-.3 1.9-.4 2.9-.4s2 .1 2.9.4c2.2-1.5 3.2-1.2 3.2-1.2.6 1.6.2 2.8.1 3.1.8.8 1.2 1.8 1.2 3.1 0 4.4-2.7 5.4-5.3 5.7.4.4.8 1.1.8 2.2v3.2c0 .3.2.7.8.6 4.6-1.5 7.9-5.8 7.9-10.9C23.5 5.7 18.3.5 12 .5z"></path></svg>GitHub, spec and vectors</a>
|
||||
|
|
|
|||
122
site/lib/emission.mjs
Normal file
122
site/lib/emission.mjs
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
// Igneum emission, spec 2.5 (docs/spec/02-consensus.md) as the node computes it
|
||||
// (vendor/igneum-node/consensus/core/src/igneum.rs: block_subsidy, launch_ramp). Shared by the observer's coinbase
|
||||
// check (tools/observer/observer.mjs) and the public /api/supply. BigInt throughout; sompi is the 8-decimal unit the
|
||||
// devnet pays in (open item O-2.6 keeps Kaspa's SOMPI_PER_KASPA), the EVM side shows the same amount at 18 decimals.
|
||||
|
||||
export const SOMPI_PER_IGN = 100_000_000n;
|
||||
export const SECONDS_PER_YEAR = 31_557_600n; // 365.25 days
|
||||
export const HALVING_INTERVAL_SECONDS = 2n * SECONDS_PER_YEAR; // 63,115,200
|
||||
export const YEAR_ONE_EMISSION_SOMPI = 1_000_000_000n * SOMPI_PER_IGN;
|
||||
export const BASE_SUBSIDY_PER_SECOND_SOMPI = YEAR_ONE_EMISSION_SOMPI / SECONDS_PER_YEAR; // 3,168,808,781 (floor)
|
||||
export const SUPPLY_CAP_SOMPI = 4_000_000_000n * SOMPI_PER_IGN;
|
||||
export const LAUNCH_RAMP_SECONDS = 2_592_000n; // 30 days
|
||||
export const LAUNCH_RAMP_START_PERCENT = 10n;
|
||||
export const PROVING_POOL_SHARE_PERCENT = 20n;
|
||||
export const SUBSIDY_PERIODS = 33; // period 32 pays 0
|
||||
|
||||
/** Per-second subsidy of two-year period i: BASE >> i (igneum.rs SUBSIDY_PER_SECOND_BY_PERIOD). */
|
||||
export function subsidyPerSecond(period) {
|
||||
const i = Math.min(Math.max(0, period), SUBSIDY_PERIODS - 1);
|
||||
return BASE_SUBSIDY_PER_SECOND_SOMPI >> BigInt(i);
|
||||
}
|
||||
|
||||
/** igneum.rs launch_ramp: linear from 10% at second 0 to 100% at LAUNCH_RAMP_SECONDS, integer floor. */
|
||||
export function launchRamp(fullSubsidy, secondsSinceGenesis) {
|
||||
const s = BigInt(secondsSinceGenesis);
|
||||
if (s >= LAUNCH_RAMP_SECONDS) return fullSubsidy;
|
||||
const numerator = LAUNCH_RAMP_START_PERCENT * LAUNCH_RAMP_SECONDS + (100n - LAUNCH_RAMP_START_PERCENT) * s;
|
||||
const denominator = 100n * LAUNCH_RAMP_SECONDS;
|
||||
return fullSubsidy * numerator / denominator;
|
||||
}
|
||||
|
||||
/** igneum.rs block_subsidy(daa_score, bps): per-block subsidy in sompi paid for each blue block merged at this DAA score. */
|
||||
export function blockSubsidy(daaScore, bps = 1) {
|
||||
const b = BigInt(bps);
|
||||
const seconds = BigInt(daaScore) / b;
|
||||
const period = Number(seconds / HALVING_INTERVAL_SECONDS);
|
||||
const perBlock = subsidyPerSecond(period) / b;
|
||||
return launchRamp(perBlock, seconds);
|
||||
}
|
||||
|
||||
/** The ramp factor at a DAA score as a decimal in [0.1, 1], for display. */
|
||||
export function rampFactor(daaScore) {
|
||||
const s = BigInt(daaScore);
|
||||
if (s >= LAUNCH_RAMP_SECONDS) return 1;
|
||||
return Number(LAUNCH_RAMP_START_PERCENT * LAUNCH_RAMP_SECONDS + (100n - LAUNCH_RAMP_START_PERCENT) * s) / Number(100n * LAUNCH_RAMP_SECONDS);
|
||||
}
|
||||
|
||||
// sum_{i=0}^{n-1} floor((a*i + b) / m), exact in O(log) (the classic floor-sum; a, b >= 0, m > 0, BigInt).
|
||||
export function floorSum(n, m, a, b) {
|
||||
let ans = 0n;
|
||||
for (;;) {
|
||||
if (a >= m) { ans += (n - 1n) * n / 2n * (a / m); a %= m; }
|
||||
if (b >= m) { ans += n * (b / m); b %= m; }
|
||||
const yMax = a * n + b;
|
||||
if (yMax < m) return ans;
|
||||
const n2 = yMax / m, b2 = yMax % m;
|
||||
[n, m, a, b] = [n2, a, m, b2];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Coins minted by the rule from DAA 0 up to (not including) `daaScore`, in sompi, at `bps` = 1: the sum of
|
||||
* blockSubsidy(t) over every DAA second t. Exact: the ramp part is a floor-sum, the rest is per-period arithmetic.
|
||||
* This is the schedule; the chain pays blockSubsidy per blue block it merges (and per red inside the window), so
|
||||
* the paid total tracks it one block per DAA step. The observer's coinbase check (live_state.supply_check) reports
|
||||
* the difference it sees.
|
||||
*/
|
||||
export function mintedByRule(daaScore) {
|
||||
let t = BigInt(daaScore);
|
||||
if (t <= 0n) return 0n;
|
||||
let total = 0n;
|
||||
// ramp: per-second = floor(B * (10R + 90 s) / (100 R)) = floor((90B s + 10RB) / (100R))
|
||||
const B = BASE_SUBSIDY_PER_SECOND_SOMPI, R = LAUNCH_RAMP_SECONDS;
|
||||
const rampSeconds = t < R ? t : R;
|
||||
total += floorSum(rampSeconds, 100n * R, 90n * B, 10n * R * B);
|
||||
if (t <= R) return total;
|
||||
// after the ramp, period by period
|
||||
let from = R;
|
||||
while (from < t) {
|
||||
const period = Number(from / HALVING_INTERVAL_SECONDS);
|
||||
const periodEnd = BigInt(period + 1) * HALVING_INTERVAL_SECONDS;
|
||||
const to = t < periodEnd ? t : periodEnd;
|
||||
total += (to - from) * subsidyPerSecond(period);
|
||||
from = to;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/** Minted by the rule when every period has paid out: the cap minus the ramp's withheld part and the floors. */
|
||||
export function mintedAtEnd() {
|
||||
return mintedByRule(BigInt(SUBSIDY_PERIODS) * HALVING_INTERVAL_SECONDS);
|
||||
}
|
||||
|
||||
/** The halving table: one row per two-year period until the per-second subsidy is 0. */
|
||||
export function halvingTable(maxRows = SUBSIDY_PERIODS) {
|
||||
const rows = [];
|
||||
for (let i = 0; i < Math.min(maxRows, SUBSIDY_PERIODS); i++) {
|
||||
const start = BigInt(i) * HALVING_INTERVAL_SECONDS;
|
||||
rows.push({
|
||||
period: i,
|
||||
start_daa: start,
|
||||
end_daa: start + HALVING_INTERVAL_SECONDS,
|
||||
per_second_sompi: subsidyPerSecond(i),
|
||||
minted_by_end_sompi: mintedByRule(start + HALVING_INTERVAL_SECONDS),
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** sompi -> decimal IGN string with up to 8 places, no exponent, no rounding. */
|
||||
export function sompiToIgn(sompi, places = 8) {
|
||||
const v = BigInt(sompi); const neg = v < 0n; const a = neg ? -v : v;
|
||||
const whole = a / SOMPI_PER_IGN, frac = (a % SOMPI_PER_IGN).toString().padStart(8, '0').slice(0, places).replace(/0+$/, '');
|
||||
return (neg ? '-' : '') + whole.toString() + (frac ? '.' + frac : '');
|
||||
}
|
||||
|
||||
/** wei (18 decimals) -> decimal IGN string, up to `places` places. */
|
||||
export function weiToIgn(wei, places = 6) {
|
||||
const v = BigInt(wei); const neg = v < 0n; const a = neg ? -v : v; const U = 10n ** 18n;
|
||||
const whole = a / U, frac = (a % U).toString().padStart(18, '0').slice(0, places).replace(/0+$/, '');
|
||||
return (neg ? '-' : '') + whole.toString() + (frac ? '.' + frac : '');
|
||||
}
|
||||
56
site/lib/emission.test.mjs
Normal file
56
site/lib/emission.test.mjs
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as E from './emission.mjs';
|
||||
|
||||
test('constants match igneum.rs and spec 2.5', () => {
|
||||
assert.equal(E.BASE_SUBSIDY_PER_SECOND_SOMPI, 3_168_808_781n); // igneum.rs test: assert_eq!(BASE_SUBSIDY_PER_SECOND_SOMPI, 3_168_808_781)
|
||||
assert.equal(E.HALVING_INTERVAL_SECONDS, 63_115_200n);
|
||||
assert.equal(E.LAUNCH_RAMP_SECONDS, 2_592_000n);
|
||||
assert.equal(E.SUPPLY_CAP_SOMPI, 400_000_000_000_000_000n);
|
||||
});
|
||||
test('blockSubsidy reproduces the node tests (igneum.rs)', () => {
|
||||
assert.equal(E.blockSubsidy(2_592_000, 1), 3_168_808_781n); // ramp over
|
||||
assert.equal(E.blockSubsidy(63_115_200 - 1, 1), 3_168_808_781n);
|
||||
assert.equal(E.blockSubsidy(63_115_200, 1), 1_584_404_390n);
|
||||
assert.equal(E.blockSubsidy(2 * 63_115_200, 1), 792_202_195n);
|
||||
assert.equal(E.blockSubsidy(31 * 63_115_200, 1), 1n);
|
||||
assert.equal(E.blockSubsidy(32 * 63_115_200, 1), 0n);
|
||||
assert.equal(E.blockSubsidy(2_592_000 * 10, 10), 316_880_878n);
|
||||
assert.equal(E.blockSubsidy(63_115_200 * 10, 10), 158_440_439n);
|
||||
assert.equal(E.launchRamp(1_000_000n, 1_296_000), 550_000n);
|
||||
assert.equal(E.launchRamp(1_000_000n, 2_591_999), 999_999n);
|
||||
});
|
||||
test('blockSubsidy matches a devnet coinbase payload (block 2622db76, DAA 125,064, 5 Oct 2026)', () => {
|
||||
assert.equal(E.blockSubsidy(125_064, 1), 454_486_399n); // payload bytes 8..16 = 0x1b16e97f
|
||||
assert.equal(E.blockSubsidy(125_063, 1), 454_485_299n); // what its merged parent declared and it paid: 363,588,240 + 90,897,059
|
||||
});
|
||||
test('mintedByRule equals the brute-force sum and respects the cap', () => {
|
||||
let s = 0n; for (let t = 0; t < 3000; t++) { if (t === 777) assert.equal(E.mintedByRule(777), s); s += E.blockSubsidy(t, 1); }
|
||||
assert.equal(E.mintedByRule(3000), s);
|
||||
assert.equal(E.mintedByRule(0), 0n);
|
||||
const end = E.mintedAtEnd();
|
||||
assert.ok(end < E.SUPPLY_CAP_SOMPI);
|
||||
assert.equal(E.sompiToIgn(end), '3963038988.86765648'); // the cap less the ramp's withheld part (about 37 million) and the floors
|
||||
// the ramp withholds 0.45 x 2,592,000 / 31,557,600 x 10^9 = 36,961,... IGN (spec 2.5 "about 37 million")
|
||||
const withheld = E.SUPPLY_CAP_SOMPI - end;
|
||||
assert.ok(withheld > 36_900_000n * E.SOMPI_PER_IGN && withheld < 37_000_000n * E.SOMPI_PER_IGN, withheld.toString());
|
||||
});
|
||||
test('floorSum against a loop', () => {
|
||||
for (const [n, m, a, b] of [[10n, 7n, 3n, 5n], [1000n, 13n, 17n, 0n], [5n, 1n, 0n, 0n], [333n, 1000n, 999n, 999n]]) {
|
||||
let s = 0n; for (let i = 0n; i < n; i++) s += (a * i + b) / m;
|
||||
assert.equal(E.floorSum(n, m, a, b), s);
|
||||
}
|
||||
});
|
||||
test('halvingTable has 33 rows and halves per second', () => {
|
||||
const t = E.halvingTable();
|
||||
assert.equal(t.length, 33);
|
||||
assert.equal(t[0].per_second_sompi, 3_168_808_781n);
|
||||
assert.equal(t[1].per_second_sompi, 1_584_404_390n);
|
||||
assert.equal(t[32].per_second_sompi, 0n);
|
||||
assert.equal(t[1].start_daa, 63_115_200n);
|
||||
});
|
||||
test('sompiToIgn and weiToIgn', () => {
|
||||
assert.equal(E.sompiToIgn(454_485_299n), '4.54485299');
|
||||
assert.equal(E.sompiToIgn(100_000_000n), '1');
|
||||
assert.equal(E.weiToIgn('0x3275f7e5ad621000'), '3.636084');
|
||||
});
|
||||
90
site/lib/explorer.mjs
Normal file
90
site/lib/explorer.mjs
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
// Igneum explorer: the search router and the formatters shared by /api/explorer and the pages (the pages inline the
|
||||
// same functions; site/lib/explorer.test.mjs checks both). Zero dependencies.
|
||||
|
||||
const HEX64 = /^(0x)?[0-9a-f]{64}$/i;
|
||||
const HEX40 = /^0x[0-9a-f]{40}$/i;
|
||||
const BECH = /^(igneum|igneumdev|igneumtest|igneumsim):[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{20,}$/i;
|
||||
const DIGITS = /^\d{1,12}$/;
|
||||
|
||||
/**
|
||||
* Classifies what someone typed in the search box.
|
||||
* 64 hex (0x optional) -> {type: 'hash'} a DAG block hash, or an EVM transaction hash; the API tries the block first
|
||||
* 0x + 40 hex -> {type: 'address'} an EVM payout address
|
||||
* igneum...:bech32 -> {type: 'address'} the coinbase's UTXO address
|
||||
* digits -> {type: 'height'} a chain block number (the EVM block number)
|
||||
* anything else -> {type: 'none'}
|
||||
* `value` is the normalised form: hashes without 0x and lower case, 0x addresses lower case, numbers as Number.
|
||||
*/
|
||||
export function classify(input) {
|
||||
const q = String(input || '').trim();
|
||||
if (!q) return { type: 'none', value: null };
|
||||
if (HEX64.test(q)) return { type: 'hash', value: q.replace(/^0x/i, '').toLowerCase() };
|
||||
if (HEX40.test(q)) return { type: 'address', value: q.toLowerCase(), kind: 'evm' };
|
||||
if (BECH.test(q)) return { type: 'address', value: q.toLowerCase(), kind: 'utxo' };
|
||||
if (DIGITS.test(q)) return { type: 'height', value: Number(q) };
|
||||
return { type: 'none', value: null };
|
||||
}
|
||||
|
||||
/** The page a classified search leads to. A hash goes to /block/<hash>; the API answers 404 with a hint when it is not a block. */
|
||||
export function hrefFor(c) {
|
||||
if (c.type === 'hash') return `/block/${c.value}`;
|
||||
if (c.type === 'address') return `/address/${c.value}`;
|
||||
if (c.type === 'height') return `/block/${c.value}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** sompi (8 decimals) as IGN text, trailing zeros trimmed, at most `places` places. */
|
||||
export function ign(sompi, places = 4) {
|
||||
if (sompi === null || sompi === undefined) return 'n/a';
|
||||
let v; try { v = BigInt(sompi); } catch { return 'n/a'; }
|
||||
const neg = v < 0n; if (neg) v = -v;
|
||||
const whole = v / 100000000n, frac = (v % 100000000n).toString().padStart(8, '0').slice(0, places).replace(/0+$/, '');
|
||||
return (neg ? '-' : '') + whole.toLocaleString('en-GB') + (frac ? '.' + frac : '');
|
||||
}
|
||||
|
||||
/** wei (18 decimals) as IGN text. */
|
||||
export function ignFromWei(wei, places = 4) {
|
||||
if (wei === null || wei === undefined) return 'n/a';
|
||||
let v; try { v = BigInt(wei); } catch { return 'n/a'; }
|
||||
const neg = v < 0n; if (neg) v = -v; const U = 10n ** 18n;
|
||||
const whole = v / U, frac = (v % U).toString().padStart(18, '0').slice(0, places).replace(/0+$/, '');
|
||||
return (neg ? '-' : '') + whole.toLocaleString('en-GB') + (frac ? '.' + frac : '');
|
||||
}
|
||||
|
||||
/** First and last characters of a hash or address: 8 and 6 by default. */
|
||||
export function shortHash(h, head = 8, tail = 6) {
|
||||
const s = String(h || ''); if (!s) return '';
|
||||
if (s.length <= head + tail + 1) return s;
|
||||
return s.slice(0, head) + '…' + s.slice(-tail);
|
||||
}
|
||||
|
||||
/** A hash rate in H/s with a unit: 27,759,288 -> "27.8 MH/s". */
|
||||
export function hashrate(n) {
|
||||
if (n === null || n === undefined || isNaN(n)) return 'n/a';
|
||||
const u = ['H/s', 'kH/s', 'MH/s', 'GH/s', 'TH/s', 'PH/s']; let i = 0; let v = Number(n);
|
||||
while (v >= 1000 && i < u.length - 1) { v /= 1000; i++; }
|
||||
return (i ? v.toFixed(1) : String(Math.round(v))) + ' ' + u[i];
|
||||
}
|
||||
|
||||
/** A relative time from a millisecond difference: "12 s ago", "3 min ago", "2 h 5 min ago", "4 d ago". */
|
||||
export function rel(ms) {
|
||||
if (ms === null || ms === undefined || isNaN(ms)) return 'n/a';
|
||||
const s = Math.max(0, Math.round(ms / 1000));
|
||||
if (s < 60) return `${s} s ago`;
|
||||
const m = Math.floor(s / 60); if (m < 60) return `${m} min ago`;
|
||||
const h = Math.floor(m / 60); if (h < 24) return `${h} h ${m % 60} min ago`;
|
||||
return `${Math.floor(h / 24)} d ago`;
|
||||
}
|
||||
|
||||
/** Thousands-separated integer, or n/a. */
|
||||
export function int(n) {
|
||||
if (n === null || n === undefined || isNaN(n)) return 'n/a';
|
||||
return Math.round(Number(n)).toLocaleString('en-GB');
|
||||
}
|
||||
|
||||
/** A millisecond timestamp as "2026-10-05 19:21:56 UTC". */
|
||||
export function utc(ms) {
|
||||
if (!ms) return 'n/a';
|
||||
const d = new Date(Number(ms)); if (isNaN(d)) return 'n/a';
|
||||
return d.toISOString().slice(0, 19).replace('T', ' ') + ' UTC';
|
||||
}
|
||||
58
site/lib/explorer.test.mjs
Normal file
58
site/lib/explorer.test.mjs
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { classify, hrefFor, ign, ignFromWei, shortHash, hashrate, rel, int, utc } from './explorer.mjs';
|
||||
|
||||
const H = '2622db7698c6825b370bbffba03ea11bba250792040296971d2b790a954203ee';
|
||||
|
||||
test('classify: a block hash with or without 0x, any case', () => {
|
||||
assert.deepEqual(classify(H), { type: 'hash', value: H });
|
||||
assert.deepEqual(classify('0x' + H.toUpperCase()), { type: 'hash', value: H });
|
||||
assert.deepEqual(classify(' ' + H + '\n'), { type: 'hash', value: H });
|
||||
});
|
||||
test('classify: an EVM address and a bech32 address', () => {
|
||||
assert.deepEqual(classify('0x37B55C8531053a5cef7fe1f19ba67012719773f9'), { type: 'address', value: '0x37b55c8531053a5cef7fe1f19ba67012719773f9', kind: 'evm' });
|
||||
const b = 'igneumdev:qr4yfyf9mzn643fj8faksflmxur0wxgtmpg8y7fa6qkm8pcjh3ngzqedfx47e';
|
||||
assert.deepEqual(classify(b), { type: 'address', value: b, kind: 'utxo' });
|
||||
assert.equal(classify('igneum:qr4yfyf9mzn643fj8faksflmxur0wxgtmpg8y7fa6qkm8pcjh3ngzqedfx47e').kind, 'utxo');
|
||||
});
|
||||
test('classify: digits are a height, the rest is nothing', () => {
|
||||
assert.deepEqual(classify('81263'), { type: 'height', value: 81263 });
|
||||
assert.equal(classify('0x1234').type, 'none');
|
||||
assert.equal(classify('').type, 'none');
|
||||
assert.equal(classify('hello').type, 'none');
|
||||
assert.equal(classify('0x' + 'a'.repeat(63)).type, 'none');
|
||||
assert.equal(classify('1'.repeat(13)).type, 'none');
|
||||
});
|
||||
test('hrefFor routes hash, height and address', () => {
|
||||
assert.equal(hrefFor(classify(H)), '/block/' + H);
|
||||
assert.equal(hrefFor(classify('12')), '/block/12');
|
||||
assert.equal(hrefFor(classify('0x37b55c8531053a5cef7fe1f19ba67012719773f9')), '/address/0x37b55c8531053a5cef7fe1f19ba67012719773f9');
|
||||
assert.equal(hrefFor(classify('nope')), null);
|
||||
});
|
||||
test('ign formats sompi, trims zeros, keeps places', () => {
|
||||
assert.equal(ign('454485299'), '4.5448');
|
||||
assert.equal(ign('454485299', 8), '4.54485299');
|
||||
assert.equal(ign(100000000n), '1');
|
||||
assert.equal(ign('123456789012345678', 2), '1,234,567,890.12');
|
||||
assert.equal(ign(null), 'n/a');
|
||||
assert.equal(ign('x'), 'n/a');
|
||||
});
|
||||
test('ignFromWei formats wei', () => {
|
||||
assert.equal(ignFromWei('0x3275f7e5ad621000'), '3.636');
|
||||
assert.equal(ignFromWei('3636084840000000000', 9), '3.63608484');
|
||||
assert.equal(ignFromWei(0), '0');
|
||||
});
|
||||
test('shortHash, hashrate, rel, int, utc', () => {
|
||||
assert.equal(shortHash(H), '2622db76…4203ee');
|
||||
assert.equal(shortHash('abc'), 'abc');
|
||||
assert.equal(hashrate(27759288), '27.8 MH/s');
|
||||
assert.equal(hashrate(999), '999 H/s');
|
||||
assert.equal(hashrate(null), 'n/a');
|
||||
assert.equal(rel(12_400), '12 s ago');
|
||||
assert.equal(rel(3 * 60_000), '3 min ago');
|
||||
assert.equal(rel(125 * 60_000), '2 h 5 min ago');
|
||||
assert.equal(rel(49 * 3_600_000), '2 d ago');
|
||||
assert.equal(int(1234567.4), '1,234,567');
|
||||
assert.equal(utc(1791227608731), '2026-10-05 19:13:28 UTC');
|
||||
assert.equal(utc(null), 'n/a');
|
||||
});
|
||||
|
|
@ -771,6 +771,7 @@ body.all .pager{display:none}
|
|||
<nav class="foot-col" aria-label="Follow">
|
||||
<div class="eyebrow">Follow</div>
|
||||
<a href="/live">Live devnet</a>
|
||||
<a href="/explorer">Explorer</a>
|
||||
<a href="/#journey">Journey</a>
|
||||
<a href="/wallet">Add Igneum to MetaMask</a>
|
||||
<a href="https://github.com/igneum-network/spec" rel="noopener"><svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor" aria-hidden="true"><path d="M12 .5C5.7.5.5 5.7.5 12c0 5.1 3.3 9.4 7.9 10.9.6.1.8-.3.8-.6v-2.1c-3.2.7-3.9-1.4-3.9-1.4-.5-1.3-1.3-1.7-1.3-1.7-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.7 1.3 3.4 1 .1-.8.4-1.3.7-1.6-2.6-.3-5.3-1.3-5.3-5.7 0-1.3.5-2.3 1.2-3.1-.1-.3-.5-1.5.1-3.1 0 0 1-.3 3.2 1.2.9-.3 1.9-.4 2.9-.4s2 .1 2.9.4c2.2-1.5 3.2-1.2 3.2-1.2.6 1.6.2 2.8.1 3.1.8.8 1.2 1.8 1.2 3.1 0 4.4-2.7 5.4-5.3 5.7.4.4.8 1.1.8 2.2v3.2c0 .3.2.7.8.6 4.6-1.5 7.9-5.8 7.9-10.9C23.5 5.7 18.3.5 12 .5z"></path></svg>GitHub, spec and vectors</a>
|
||||
|
|
|
|||
|
|
@ -298,6 +298,7 @@ main{padding-bottom:var(--sec)}
|
|||
<nav class="foot-col" aria-label="Follow">
|
||||
<div class="eyebrow">Follow</div>
|
||||
<a href="/live">Live devnet</a>
|
||||
<a href="/explorer">Explorer</a>
|
||||
<a href="/#journey">Journey</a>
|
||||
<a href="/wallet">Add Igneum to MetaMask</a>
|
||||
<a href="https://github.com/igneum-network/spec" rel="noopener"><svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor" aria-hidden="true"><path d="M12 .5C5.7.5.5 5.7.5 12c0 5.1 3.3 9.4 7.9 10.9.6.1.8-.3.8-.6v-2.1c-3.2.7-3.9-1.4-3.9-1.4-.5-1.3-1.3-1.7-1.3-1.7-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.7 1.3 3.4 1 .1-.8.4-1.3.7-1.6-2.6-.3-5.3-1.3-5.3-5.7 0-1.3.5-2.3 1.2-3.1-.1-.3-.5-1.5.1-3.1 0 0 1-.3 3.2 1.2.9-.3 1.9-.4 2.9-.4s2 .1 2.9.4c2.2-1.5 3.2-1.2 3.2-1.2.6 1.6.2 2.8.1 3.1.8.8 1.2 1.8 1.2 3.1 0 4.4-2.7 5.4-5.3 5.7.4.4.8 1.1.8 2.2v3.2c0 .3.2.7.8.6 4.6-1.5 7.9-5.8 7.9-10.9C23.5 5.7 18.3.5 12 .5z"></path></svg>GitHub, spec and vectors</a>
|
||||
|
|
|
|||
|
|
@ -260,6 +260,7 @@ pre{margin:0 0 16px;padding:16px 18px;background:var(--graphite);border-radius:v
|
|||
<nav class="foot-col" aria-label="Follow">
|
||||
<div class="eyebrow">Follow</div>
|
||||
<a href="/live">Live devnet</a>
|
||||
<a href="/explorer">Explorer</a>
|
||||
<a href="/#journey">Journey</a>
|
||||
<a href="/wallet">Add Igneum to MetaMask</a>
|
||||
<a href="https://github.com/igneum-network/spec" rel="noopener"><svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor" aria-hidden="true"><path d="M12 .5C5.7.5.5 5.7.5 12c0 5.1 3.3 9.4 7.9 10.9.6.1.8-.3.8-.6v-2.1c-3.2.7-3.9-1.4-3.9-1.4-.5-1.3-1.3-1.7-1.3-1.7-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.7 1.3 3.4 1 .1-.8.4-1.3.7-1.6-2.6-.3-5.3-1.3-5.3-5.7 0-1.3.5-2.3 1.2-3.1-.1-.3-.5-1.5.1-3.1 0 0 1-.3 3.2 1.2.9-.3 1.9-.4 2.9-.4s2 .1 2.9.4c2.2-1.5 3.2-1.2 3.2-1.2.6 1.6.2 2.8.1 3.1.8.8 1.2 1.8 1.2 3.1 0 4.4-2.7 5.4-5.3 5.7.4.4.8 1.1.8 2.2v3.2c0 .3.2.7.8.6 4.6-1.5 7.9-5.8 7.9-10.9C23.5 5.7 18.3.5 12 .5z"></path></svg>GitHub, spec and vectors</a>
|
||||
|
|
|
|||
|
|
@ -513,6 +513,7 @@ pre b{color:var(--molten);font-weight:500}
|
|||
<nav class="foot-col" aria-label="Follow">
|
||||
<div class="eyebrow">Follow</div>
|
||||
<a href="/live">Live devnet</a>
|
||||
<a href="/explorer">Explorer</a>
|
||||
<a href="/#journey">Journey</a>
|
||||
<a href="/wallet">Add Igneum to MetaMask</a>
|
||||
<a href="https://github.com/igneum-network/spec" rel="noopener"><svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor" aria-hidden="true"><path d="M12 .5C5.7.5.5 5.7.5 12c0 5.1 3.3 9.4 7.9 10.9.6.1.8-.3.8-.6v-2.1c-3.2.7-3.9-1.4-3.9-1.4-.5-1.3-1.3-1.7-1.3-1.7-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.7 1.3 3.4 1 .1-.8.4-1.3.7-1.6-2.6-.3-5.3-1.3-5.3-5.7 0-1.3.5-2.3 1.2-3.1-.1-.3-.5-1.5.1-3.1 0 0 1-.3 3.2 1.2.9-.3 1.9-.4 2.9-.4s2 .1 2.9.4c2.2-1.5 3.2-1.2 3.2-1.2.6 1.6.2 2.8.1 3.1.8.8 1.2 1.8 1.2 3.1 0 4.4-2.7 5.4-5.3 5.7.4.4.8 1.1.8 2.2v3.2c0 .3.2.7.8.6 4.6-1.5 7.9-5.8 7.9-10.9C23.5 5.7 18.3.5 12 .5z"></path></svg>GitHub, spec and vectors</a>
|
||||
|
|
|
|||
|
|
@ -217,6 +217,7 @@ th{font-family:var(--f-mono);font-size:12px;letter-spacing:.12em;text-transform:
|
|||
<nav class="foot-col" aria-label="Follow">
|
||||
<div class="eyebrow">Follow</div>
|
||||
<a href="/live">Live devnet</a>
|
||||
<a href="/explorer">Explorer</a>
|
||||
<a href="/#journey">Journey</a>
|
||||
<a href="/wallet">Add Igneum to MetaMask</a>
|
||||
<a href="https://github.com/igneum-network/spec" rel="noopener"><svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor" aria-hidden="true"><path d="M12 .5C5.7.5.5 5.7.5 12c0 5.1 3.3 9.4 7.9 10.9.6.1.8-.3.8-.6v-2.1c-3.2.7-3.9-1.4-3.9-1.4-.5-1.3-1.3-1.7-1.3-1.7-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.7 1.3 3.4 1 .1-.8.4-1.3.7-1.6-2.6-.3-5.3-1.3-5.3-5.7 0-1.3.5-2.3 1.2-3.1-.1-.3-.5-1.5.1-3.1 0 0 1-.3 3.2 1.2.9-.3 1.9-.4 2.9-.4s2 .1 2.9.4c2.2-1.5 3.2-1.2 3.2-1.2.6 1.6.2 2.8.1 3.1.8.8 1.2 1.8 1.2 3.1 0 4.4-2.7 5.4-5.3 5.7.4.4.8 1.1.8 2.2v3.2c0 .3.2.7.8.6 4.6-1.5 7.9-5.8 7.9-10.9C23.5 5.7 18.3.5 12 .5z"></path></svg>GitHub, spec and vectors</a>
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@
|
|||
<nav class="foot-col" aria-label="Follow">
|
||||
<div class="eyebrow">Follow</div>
|
||||
<a href="/live">Live devnet</a>
|
||||
<a href="/explorer">Explorer</a>
|
||||
<a href="/#journey">Journey</a>
|
||||
<a href="/wallet">Add Igneum to MetaMask</a>
|
||||
<a href="https://github.com/igneum-network/spec" rel="noopener"><svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor" aria-hidden="true"><path d="M12 .5C5.7.5.5 5.7.5 12c0 5.1 3.3 9.4 7.9 10.9.6.1.8-.3.8-.6v-2.1c-3.2.7-3.9-1.4-3.9-1.4-.5-1.3-1.3-1.7-1.3-1.7-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.7 1.3 3.4 1 .1-.8.4-1.3.7-1.6-2.6-.3-5.3-1.3-5.3-5.7 0-1.3.5-2.3 1.2-3.1-.1-.3-.5-1.5.1-3.1 0 0 1-.3 3.2 1.2.9-.3 1.9-.4 2.9-.4s2 .1 2.9.4c2.2-1.5 3.2-1.2 3.2-1.2.6 1.6.2 2.8.1 3.1.8.8 1.2 1.8 1.2 3.1 0 4.4-2.7 5.4-5.3 5.7.4.4.8 1.1.8 2.2v3.2c0 .3.2.7.8.6 4.6-1.5 7.9-5.8 7.9-10.9C23.5 5.7 18.3.5 12 .5z"></path></svg>GitHub, spec and vectors</a>
|
||||
|
|
|
|||
|
|
@ -1,18 +1,17 @@
|
|||
{
|
||||
"cleanUrls": true,
|
||||
"trailingSlash": false,
|
||||
"redirects": [ { "source": "/ledger", "destination": "/", "permanent": false } ],
|
||||
"redirects": [{"source": "/ledger", "destination": "/", "permanent": false}],
|
||||
"rewrites": [{"source": "/block/:id", "destination": "/block"}, {"source": "/address/:addr", "destination": "/address"}],
|
||||
"headers": [
|
||||
{ "source": "/(.*)", "headers": [
|
||||
{ "key": "X-Content-Type-Options", "value": "nosniff" },
|
||||
{ "key": "X-Frame-Options", "value": "DENY" },
|
||||
{ "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" },
|
||||
{ "key": "Strict-Transport-Security", "value": "max-age=63072000; includeSubDomains; preload" }
|
||||
] },
|
||||
{ "source": "/fonts/(.*)", "headers": [ { "key": "Cache-Control", "value": "public, max-age=31536000, immutable" } ] },
|
||||
{ "source": "/(.*)\\.(png|ico)", "headers": [ { "key": "Cache-Control", "value": "public, max-age=86400, stale-while-revalidate=604800" } ] },
|
||||
{ "source": "/site.webmanifest", "headers": [ { "key": "Cache-Control", "value": "public, max-age=86400, stale-while-revalidate=604800" } ] },
|
||||
{ "source": "/journey.json", "headers": [ { "key": "Cache-Control", "value": "public, max-age=300" } ] },
|
||||
{ "source": "/api/live", "headers": [ { "key": "Cache-Control", "value": "public, max-age=1" } ] }
|
||||
{"source": "/(.*)", "headers": [{"key": "X-Content-Type-Options", "value": "nosniff"}, {"key": "X-Frame-Options", "value": "DENY"}, {"key": "Referrer-Policy", "value": "strict-origin-when-cross-origin"}, {"key": "Strict-Transport-Security", "value": "max-age=63072000; includeSubDomains; preload"}]},
|
||||
{"source": "/fonts/(.*)", "headers": [{"key": "Cache-Control", "value": "public, max-age=31536000, immutable"}]},
|
||||
{"source": "/(.*)\\.(png|ico)", "headers": [{"key": "Cache-Control", "value": "public, max-age=86400, stale-while-revalidate=604800"}]},
|
||||
{"source": "/site.webmanifest", "headers": [{"key": "Cache-Control", "value": "public, max-age=86400, stale-while-revalidate=604800"}]},
|
||||
{"source": "/journey.json", "headers": [{"key": "Cache-Control", "value": "public, max-age=300"}]},
|
||||
{"source": "/api/live", "headers": [{"key": "Cache-Control", "value": "public, max-age=1"}]},
|
||||
{"source": "/api/stats", "headers": [{"key": "Cache-Control", "value": "public, max-age=10, s-maxage=10"}]},
|
||||
{"source": "/api/supply", "headers": [{"key": "Cache-Control", "value": "public, max-age=10, s-maxage=10"}]},
|
||||
{"source": "/api/explorer", "headers": [{"key": "Cache-Control", "value": "public, max-age=5, s-maxage=5"}]}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -415,6 +415,7 @@ td.num{font-variant-numeric:tabular-nums;white-space:nowrap}
|
|||
<nav class="foot-col" aria-label="Follow">
|
||||
<div class="eyebrow">Follow</div>
|
||||
<a href="/live">Live devnet</a>
|
||||
<a href="/explorer">Explorer</a>
|
||||
<a href="/#journey">Journey</a>
|
||||
<a href="/wallet">Add Igneum to MetaMask</a>
|
||||
<a href="https://github.com/igneum-network/spec" rel="noopener"><svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor" aria-hidden="true"><path d="M12 .5C5.7.5.5 5.7.5 12c0 5.1 3.3 9.4 7.9 10.9.6.1.8-.3.8-.6v-2.1c-3.2.7-3.9-1.4-3.9-1.4-.5-1.3-1.3-1.7-1.3-1.7-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.7 1.3 3.4 1 .1-.8.4-1.3.7-1.6-2.6-.3-5.3-1.3-5.3-5.7 0-1.3.5-2.3 1.2-3.1-.1-.3-.5-1.5.1-3.1 0 0 1-.3 3.2 1.2.9-.3 1.9-.4 2.9-.4s2 .1 2.9.4c2.2-1.5 3.2-1.2 3.2-1.2.6 1.6.2 2.8.1 3.1.8.8 1.2 1.8 1.2 3.1 0 4.4-2.7 5.4-5.3 5.7.4.4.8 1.1.8 2.2v3.2c0 .3.2.7.8.6 4.6-1.5 7.9-5.8 7.9-10.9C23.5 5.7 18.3.5 12 .5z"></path></svg>GitHub, spec and vectors</a>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
// Internal link check of the site: every href="..." and src="..." in site/*.html that is not an external URL,
|
||||
// an anchor, a mailto or a data URI must resolve to a file under site/ (clean URLs: /bench -> bench.html).
|
||||
// Fragment links (#id) inside a page must name an element id in that page.
|
||||
// Fragment links (#id) inside a page must name an element id in that page. Template literals (${...}) in scripts are
|
||||
// skipped; /api/<name> resolves to the function file site/api/<name>.mjs.
|
||||
// node tools/ci/link-check.mjs exit 1 on the first broken link, listing every one
|
||||
import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
|
|
@ -15,6 +16,9 @@ function resolves(target) {
|
|||
const clean = target.replace(/[?#].*$/, '');
|
||||
if (clean === '' || clean === '/') return true;
|
||||
const rel = clean.replace(/^\//, '');
|
||||
// /api/<name> is a function: site/api/<name>.mjs (Vercel routes the path to it)
|
||||
const api = /^api\/([a-z0-9_-]+)$/.exec(rel);
|
||||
if (api) return existsSync(join(site, 'api', `${api[1]}.mjs`));
|
||||
const candidates = [join(site, rel), join(site, `${rel}.html`), join(site, rel, 'index.html')];
|
||||
return candidates.some(c => existsSync(c) && statSync(c).isFile());
|
||||
}
|
||||
|
|
@ -25,6 +29,8 @@ for (const page of pages) {
|
|||
for (const m of html.matchAll(/\s(?:href|src)="([^"]*)"/g)) {
|
||||
const t = m[1];
|
||||
if (/^(https?:|mailto:|data:|tel:|javascript:)/i.test(t) || t.startsWith('//')) continue;
|
||||
// a template literal inside a page's script builds the href at run time (the explorer pages: /block/${hash})
|
||||
if (t.includes('${')) continue;
|
||||
checked++;
|
||||
if (t.startsWith('#')) { if (t.length > 1 && !ids.has(t.slice(1))) broken.push(`${page}: fragment ${t}`); continue; }
|
||||
const [path, frag] = t.split('#');
|
||||
|
|
|
|||
27
tools/ci/public-api-check.mjs
Normal file
27
tools/ci/public-api-check.mjs
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
#!/usr/bin/env node
|
||||
// Checks that a deployed site answers /api/stats and /api/supply with every documented field (the FIELDS lists in
|
||||
// site/api/stats.mjs and site/api/supply.mjs, the same lists docs/api/public-stats.md is written from).
|
||||
// node tools/ci/public-api-check.mjs [https://igneum.network]
|
||||
// Exit 1 on a missing field, a non-200 answer or a stale observer older than 10 minutes (the numbers are what pool
|
||||
// software and profitability sites read; a stale answer is a wrong answer).
|
||||
import { FIELDS as STATS, REWARD_FIELDS, LAST_BLOCK_FIELDS } from '../../site/api/stats.mjs';
|
||||
import { FIELDS as SUPPLY, SCHEDULE_FIELDS } from '../../site/api/supply.mjs';
|
||||
|
||||
const base = (process.argv[2] || process.env.PUBLIC_API_URL || 'https://igneum.network').replace(/\/$/, '');
|
||||
const missing = [];
|
||||
async function get(path) {
|
||||
const r = await fetch(base + path, { headers: { 'cache-control': 'no-cache' }, signal: AbortSignal.timeout(15000) });
|
||||
const j = await r.json().catch(() => null);
|
||||
if (r.status !== 200 || !j || j.ok !== true) { console.error(`${path}: HTTP ${r.status} ${j ? JSON.stringify(j).slice(0, 200) : ''}`); process.exit(1); }
|
||||
return j;
|
||||
}
|
||||
const s = await get('/api/stats');
|
||||
for (const f of STATS) if (!(f in s)) missing.push(`stats.${f}`);
|
||||
if (s.block_reward) for (const f of REWARD_FIELDS) if (!(f in s.block_reward)) missing.push(`stats.block_reward.${f}`);
|
||||
if (s.last_block) for (const f of LAST_BLOCK_FIELDS) if (!(f in s.last_block)) missing.push(`stats.last_block.${f}`);
|
||||
const p = await get('/api/supply');
|
||||
for (const f of SUPPLY) if (!(f in p)) missing.push(`supply.${f}`);
|
||||
if (p.schedule && p.schedule[0]) for (const f of SCHEDULE_FIELDS) if (!(f in p.schedule[0])) missing.push(`supply.schedule.${f}`);
|
||||
if (missing.length) { console.error(`public api check: ${missing.length} missing field(s): ${missing.join(', ')}`); process.exit(1); }
|
||||
if (s.stale && s.age_s > 600) { console.error(`public api check: the observer is stale (${s.age_s} s)`); process.exit(1); }
|
||||
console.log(`public api check: ${base} /api/stats (${STATS.length} fields, height ${s.height}, reward ${s.block_reward && s.block_reward.ign} IGN, ${s.stale ? 'STALE ' + s.age_s + ' s' : 'live'}) and /api/supply (${SUPPLY.length} fields, circulating ${p.circulating_ign} IGN of ${p.max_supply_ign}) answer with every documented field`);
|
||||
|
|
@ -34,6 +34,7 @@ IGNEUM_RPC=ws://127.0.0.1:28640 node tools/observer/observer.mjs
|
|||
- `engine` is the miner's tag in the coinbase extra data after the node's version prefix. The node exposes no engine name over RPC, so this is null on devnet v0.
|
||||
- When the node refuses the hash-rate estimate (it needs a 1,000-block window) the observer reports blue work added per second over the last 10 minutes instead.
|
||||
- Proving v0 (4 Oct 2026, spec 7.7): every chain block (the `isChainBlock` flag of a new block, or a `virtualChainChanged` addition) has its shard plan read over `IGNEUM_EVM_RPC` as `{blockHash}` (the chain block hash is the same hash on both layers), one `live_proofs` row per shard in state `planned`; a plan the EVM node has not executed yet is retried with a growing delay (up to 30 tries). The proof records of the chain blocks of the last 10 minutes are polled in rotation (80 blocks per 2 s tick while active, 10 before activation, four calls in flight); a shard moves to `proving` (a record in the node's pool), `verified` (the SP1 proof verified by the node's verifier, or the record carried by a block and checked by consensus, which is what happens before activation) or `paid` (a carrying segment paid it). `lag_daa` is the carrier's DAA score minus the block's; `prover` is the first 8 hex characters of the record's vote key hash. A block whose every shard is paid, or older than 10 minutes, leaves the rotation. Events: `proving` (activation reached, first paid shard seen) and `prover_seen` (one per prover per run). A reorg drops the removed chain blocks from the rotation.
|
||||
- Explorer (5 Oct 2026, `docs/plans/explorer.md`): every block row also carries what `/explorer`, `/block/<hash>` and `/address/<addr>` show, all read from the `blockAdded` notification itself (no extra RPC per block, measured: 282 against 283 wRPC calls per minute, 785 against 776 EVM calls in the same minute, before and after): `tx_count` (EVM transactions in the block), `evm_miner` (the coinbase's `IGNA` payout address, else the vote key hash's low 20 bytes as `consensus/core/src/evm.rs` falls back to), `proof_records` (records in the `IGNP` section, 274 bytes each), `subsidy_sompi` (the `E(daa)` the payload declares), `paid_sompi` (the coinbase outputs' sum), `selected_parent`, `number` (the chain block number, filled in when the shard plan arrives) and `detail` (header fields, mergeset, coinbase outputs, EVM transaction hashes, certificate indices). `live_state.rpc_load` counts this process's RPC calls per minute; `live_state.supply_check` is the hourly comparison of the newest 500 blocks with the emission rule (`site/lib/emission.mjs`, spec 2.5): the declared subsidy against `blockSubsidy(daa, 1)`, and each block's outputs against the declared subsidies of the blocks it merges. The observer now imports `site/lib/emission.mjs` and `site/lib/eth.mjs` (keccak for transaction hashes); autosync restarts only on `observer.mjs` and `run.sh` changes, so a change to those two libraries needs a restart by hand.
|
||||
- Finality v2 (3 Oct 2026): subscribes to `FinalityLock` (the node's lock event) and polls `getFinalityCheckpoints` every 2 s and `getFinalityWeights` every 10 s. Every checkpoint the node reports is upserted into `live_checkpoints`; a checkpoint turning `locked` writes the event `checkpoint N locked (xx% of weight, yy% of active, v votes of n voters) at block h`. The weights snapshot (total, active, per key) goes into `live_state.finality`. A node from before the finality layer answers the RPC with an error; the observer then logs once and skips finality.
|
||||
|
||||
## Tables
|
||||
|
|
@ -42,7 +43,7 @@ Created on start if missing.
|
|||
|
||||
| Table | Rows | Columns |
|
||||
|---|---|---|
|
||||
| `live_blocks` | one per block, kept `LIVE_RETAIN_HOURS` | `hash`, `blue_score`, `daa_score`, `timestamp_ms`, `parents` (count), `parent_hashes`, `is_chain_block`, `vote_key_hash`, `miner_address`, `engine`, `received_at`. Indexes on `received_at`, `timestamp_ms`, `(vote_key_hash, received_at)`. |
|
||||
| `live_blocks` | one per block, kept `LIVE_RETAIN_HOURS` | `hash`, `blue_score`, `daa_score`, `timestamp_ms`, `parents` (count), `parent_hashes`, `is_chain_block`, `vote_key_hash`, `miner_address`, `engine`, `received_at`, `color`; explorer: `tx_count`, `evm_miner`, `proof_records`, `subsidy_sompi`, `paid_sompi`, `selected_parent`, `number`, `detail` (jsonb). Indexes on `received_at`, `timestamp_ms`, `(vote_key_hash, received_at)`, `(evm_miner, received_at)`, `(miner_address, received_at)`, `number`. |
|
||||
| `live_state` | one row, updated every 2 s | `block_count`, `header_count`, `blue_score`, `difficulty`, `hashes_per_second_estimate`, `peers`, `mempool`, `node_version`, `network`, `blocks_60s`, `blocks_per_minute` (60 pairs of minute epoch ms and count), `observer_started_at`, `updated_at` |
|
||||
| `live_events` | one per event, kept 7 days | `ts`, `kind`, `text`. Kinds: `observer`, `miner_seen`, `miner_quiet`, `miner_back`, `peer_joined`, `peer_left`, `difficulty` (step over 5%), `checkpoint_locked`. |
|
||||
| `live_checkpoints` | one per checkpoint index, kept 7 days | `index`, `hash`, `blue_score`, `daa_score`, `state` (proposed, certified, locked), `signed_weight`, `active_weight`, `total_weight`, `fraction_active`, `fraction_total`, `votes_seen`, `voters`, `aggregators` (key hashes whose sortition proof made them aggregators), `locked_at`, `first_seen_at`, `updated_at`. |
|
||||
|
|
@ -56,4 +57,4 @@ Created on start if missing.
|
|||
|
||||
## Reading it
|
||||
|
||||
`site/api/live.mjs` serves `/api/live` from these tables in five indexed queries (`proving` from `live_state.proving`; every block carries `shards: [{i, n, state, prover, lag, payout, pgas}]` and `proven`). `LIVE_TABLE_PREFIX` on the API reads a test observer's tables. `site/live.html` polls it every 2 s. The site shows OFFLINE when `live_state.updated_at` is older than 30 s.
|
||||
`site/api/stats.mjs`, `site/api/supply.mjs` and `site/api/explorer.mjs` serve `/api/stats`, `/api/supply` and `/api/explorer` (docs/api/public-stats.md). `site/api/live.mjs` serves `/api/live` from these tables in five indexed queries (`proving` from `live_state.proving`; every block carries `shards: [{i, n, state, prover, lag, payout, pgas}]` and `proven`). `LIVE_TABLE_PREFIX` on the API reads a test observer's tables. `site/live.html` polls it every 2 s. The site shows OFFLINE when `live_state.updated_at` is older than 30 s.
|
||||
|
|
|
|||
|
|
@ -24,6 +24,14 @@
|
|||
// every 2 s; writes the checkpoints table and emits "checkpoint N locked (xx% of weight)" events.
|
||||
// Light client (site/api/checkpoint.mjs, site/verify/): every certificate a block carries is stored with the voter
|
||||
// table the node reports and the header chain from the previous locked checkpoint, so a browser can verify it.
|
||||
// Explorer (5 October 2026, docs/plans/explorer.md): every block row also carries what /explorer, /block/<hash> and
|
||||
// /address/<addr> show: the EVM transaction count and hashes, the miner's EVM payout address (the coinbase's IGNA
|
||||
// tag, else the vote key hash's low 20 bytes as consensus/core/src/evm.rs falls back to), the number of proof records
|
||||
// carried (the IGNP section), the subsidy the payload declares and the outputs paid, the header fields, the mergeset
|
||||
// and the certificate indices, all read from the blockAdded notification itself: no extra RPC per block. The chain
|
||||
// block number comes with the shard plan the proving feed already fetches. live_state.rpc_load counts this
|
||||
// process's RPC calls per minute (wRPC and EVM) so the load of a change can be measured; live_state.supply_check is
|
||||
// the hourly comparison of the coinbase sums with the emission rule (site/lib/emission.mjs, spec 2.5).
|
||||
// Zero dependencies: Node 22 WebSocket and fetch, Neon's HTTP SQL endpoint.
|
||||
//
|
||||
// Ingest path (4 Oct 2026, after the observer fell 80 min behind under machine load 200 to 300): notifications
|
||||
|
|
@ -36,6 +44,8 @@
|
|||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { blockSubsidy } from '../../site/lib/emission.mjs';
|
||||
import { keccak256 } from '../../site/lib/eth.mjs';
|
||||
|
||||
const RPC = process.env.IGNEUM_RPC || 'ws://127.0.0.1:28610';
|
||||
const RETAIN_HOURS = Number(process.env.LIVE_RETAIN_HOURS || 24);
|
||||
|
|
@ -91,6 +101,18 @@ async function setupSchema() {
|
|||
`CREATE INDEX IF NOT EXISTS ${TB}_vote_key_received ON ${TB} (vote_key_hash, received_at)`,
|
||||
// GHOSTDAG colour (4 Oct 2026, additive): pending until a chain block merges it, then blue (paid) or red (excluded)
|
||||
`ALTER TABLE ${TB} ADD COLUMN IF NOT EXISTS color text NOT NULL DEFAULT 'pending'`,
|
||||
// Explorer (5 Oct 2026, additive): what the block pages show, from the notification; number from the shard plan
|
||||
`ALTER TABLE ${TB} ADD COLUMN IF NOT EXISTS tx_count int`,
|
||||
`ALTER TABLE ${TB} ADD COLUMN IF NOT EXISTS evm_miner text`,
|
||||
`ALTER TABLE ${TB} ADD COLUMN IF NOT EXISTS proof_records int`,
|
||||
`ALTER TABLE ${TB} ADD COLUMN IF NOT EXISTS subsidy_sompi bigint`,
|
||||
`ALTER TABLE ${TB} ADD COLUMN IF NOT EXISTS paid_sompi bigint`,
|
||||
`ALTER TABLE ${TB} ADD COLUMN IF NOT EXISTS selected_parent text`,
|
||||
`ALTER TABLE ${TB} ADD COLUMN IF NOT EXISTS number bigint`,
|
||||
`ALTER TABLE ${TB} ADD COLUMN IF NOT EXISTS detail jsonb`,
|
||||
`CREATE INDEX IF NOT EXISTS ${TB}_evm_miner_received ON ${TB} (evm_miner, received_at)`,
|
||||
`CREATE INDEX IF NOT EXISTS ${TB}_miner_address_received ON ${TB} (miner_address, received_at)`,
|
||||
`CREATE INDEX IF NOT EXISTS ${TB}_number ON ${TB} (number)`,
|
||||
`CREATE TABLE IF NOT EXISTS ${TS} (
|
||||
id int PRIMARY KEY DEFAULT 1 CHECK (id = 1),
|
||||
block_count bigint, header_count bigint, blue_score bigint, difficulty double precision,
|
||||
|
|
@ -103,6 +125,9 @@ async function setupSchema() {
|
|||
`ALTER TABLE ${TS} ADD COLUMN IF NOT EXISTS queue_depth int`,
|
||||
// Proving v0 (4 Oct 2026, additive): {supported, active, activation_daa, tip_daa, blocks_fully_proven_10m, ...}
|
||||
`ALTER TABLE ${TS} ADD COLUMN IF NOT EXISTS proving jsonb`,
|
||||
// Explorer: this process's RPC calls per minute, and the hourly coinbase-versus-rule comparison
|
||||
`ALTER TABLE ${TS} ADD COLUMN IF NOT EXISTS rpc_load jsonb`,
|
||||
`ALTER TABLE ${TS} ADD COLUMN IF NOT EXISTS supply_check jsonb`,
|
||||
// One row per planned shard of a chain block (spec 7.7 item 8): the plan as the block joins the chain, then the
|
||||
// record's progress. prover is the first 8 hex characters of the record's vote key hash (never the full key, R4.6.2).
|
||||
`CREATE TABLE IF NOT EXISTS ${TP} (
|
||||
|
|
@ -266,6 +291,7 @@ class Rpc {
|
|||
});
|
||||
}
|
||||
call(method, params = {}) {
|
||||
rpcLoad.wrpc++;
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.open) return reject(new Error('rpc not connected'));
|
||||
const id = ++this.id; this.pending.set(id, { resolve, reject });
|
||||
|
|
@ -279,6 +305,89 @@ class Rpc {
|
|||
const log = (...a) => console.log(new Date().toISOString(), ...a);
|
||||
const short = h => (h || '').slice(0, 8);
|
||||
|
||||
// RPC load (5 Oct 2026): calls this process makes, counted per wall-clock minute, the last five minutes kept. Logged
|
||||
// once a minute and written to live_state.rpc_load, so a change to the observer can be measured against the rule
|
||||
// in docs/plans/explorer.md (the explorer must stay under 2x of the load before it).
|
||||
const rpcLoad = { wrpc: 0, evm: 0, minutes: [], minuteStart: Date.now() };
|
||||
function rollRpcLoad(now) {
|
||||
if (now - rpcLoad.minuteStart < 60_000) return;
|
||||
const secs = (now - rpcLoad.minuteStart) / 1000;
|
||||
const m = { at: new Date(rpcLoad.minuteStart).toISOString(), wrpc: Math.round(rpcLoad.wrpc * 60 / secs), evm: Math.round(rpcLoad.evm * 60 / secs) };
|
||||
rpcLoad.minutes.push(m); if (rpcLoad.minutes.length > 5) rpcLoad.minutes.shift();
|
||||
log(`rpc load: wrpc ${m.wrpc}/min, evm ${m.evm}/min`);
|
||||
rpcLoad.wrpc = 0; rpcLoad.evm = 0; rpcLoad.minuteStart = now;
|
||||
}
|
||||
function rpcLoadState() {
|
||||
const ms = rpcLoad.minutes; if (!ms.length) return null;
|
||||
const avg = k => Math.round(ms.reduce((a, m) => a + m[k], 0) / ms.length);
|
||||
return { wrpc_per_min: ms[ms.length - 1].wrpc, evm_per_min: ms[ms.length - 1].evm, wrpc_per_min_5m: avg('wrpc'), evm_per_min_5m: avg('evm'), minutes: ms.length, evm_rpc: EVM };
|
||||
}
|
||||
|
||||
// Explorer detail of a block, all from the notification (header, coinbase, verbose data, EVM transactions).
|
||||
// Coinbase payload: blue score u64 LE, subsidy u64 LE (the full E(daa) of this block, coinbase.rs), script version
|
||||
// u16 LE, script length u8, script, extra data. Extra data: the node's version tag, the miner's tag, the key reveal
|
||||
// (IGNK, finality.rs), the payout address (IGNA || 40 hex, evm.rs), the proof-record section (records || len_le32 ||
|
||||
// IGNP, proving.rs, 274 bytes each) and the finality section (items || len_le32 || IGNF).
|
||||
const PROOF_RECORD_LEN = 274;
|
||||
function coinbaseDetail(block, prefix) {
|
||||
const tx = block.transactions && block.transactions[0];
|
||||
if (!tx) return null;
|
||||
const b = payloadBytes(tx.payload);
|
||||
if (b.length < 19) return null;
|
||||
const dv = new DataView(b.buffer, b.byteOffset, b.byteLength);
|
||||
const subsidy = dv.getBigUint64(8, true);
|
||||
const extra = b.subarray(19 + b[18]);
|
||||
const text = Buffer.from(extra).toString('latin1');
|
||||
const a = text.indexOf('IGNA');
|
||||
const evmMiner = a >= 0 && /^[0-9a-f]{40}$/.test(text.slice(a + 4, a + 44)) ? '0x' + text.slice(a + 4, a + 44) : null;
|
||||
// proof records: strip the finality section, then read the IGNP trailer
|
||||
let body = extra; let n = body.length;
|
||||
if (n >= 8 && String.fromCharCode(...body.subarray(n - 4)) === 'IGNF') { const len = new DataView(body.buffer, body.byteOffset, n).getUint32(n - 8, true); if (len + 8 <= n) body = body.subarray(0, n - 8 - len); }
|
||||
n = body.length; let records = 0;
|
||||
if (n >= 8 && String.fromCharCode(...body.subarray(n - 4)) === 'IGNP') { const len = new DataView(body.buffer, body.byteOffset, n).getUint32(n - 8, true); if (len + 8 <= n) records = Math.floor(len / PROOF_RECORD_LEN); }
|
||||
const outputs = (tx.outputs || []).map(o => {
|
||||
const spk = typeof o.scriptPublicKey === 'string' ? o.scriptPublicKey : (o.scriptPublicKey && o.scriptPublicKey.script) || '';
|
||||
const script = Uint8Array.from(Buffer.from(String(spk).slice(4), 'hex')); // 2-byte version prefix, then the script
|
||||
const addr = (o.verboseData && o.verboseData.scriptPublicKeyAddress) || scriptToAddress(prefix, script);
|
||||
const pool = !addr && script.length > 2 && script[0] === 0x6a; // OP_RETURN "igneum-proving-pool-v0": the pool output burns on the UTXO side
|
||||
return { address: addr, value: String(o.value), pool };
|
||||
});
|
||||
const paid = outputs.reduce((t, o) => t + BigInt(o.value), 0n);
|
||||
return { id: tx.verboseData && tx.verboseData.transactionId, subsidy: subsidy.toString(), paid: paid.toString(), outputs, evm_miner: evmMiner, records, reveal: text.includes('IGNK') };
|
||||
}
|
||||
// EVM transactions ride in the block as raw bytes (RpcBlock.evm_transactions, Vec<Vec<u8>>): the hash is keccak-256 of
|
||||
// the envelope. Decoding fields from the RLP is left to the execution layer (/api/explorer reads them from the EVM RPC
|
||||
// when one is configured); here the count and the hashes are enough for the table and the block page.
|
||||
function evmTxs(block) {
|
||||
const list = block.evmTransactions || [];
|
||||
return list.map(t => {
|
||||
const raw = typeof t === 'string' ? Buffer.from(t.replace(/^0x/, ''), 'hex') : Array.isArray(t) ? Buffer.from(t) : null;
|
||||
if (!raw) return { hash: null, size: null };
|
||||
return { hash: '0x' + Buffer.from(keccak256(raw)).toString('hex'), size: raw.length };
|
||||
});
|
||||
}
|
||||
function blockDetail(block, prefix) {
|
||||
const h = block.header, vd = block.verboseData || {};
|
||||
const cb = coinbaseDetail(block, prefix);
|
||||
const txs = evmTxs(block);
|
||||
const certs = certificatesIn(block).map(c => c.index);
|
||||
const vk = h.voteKeyHash || null;
|
||||
const evmMiner = (cb && cb.evm_miner) || (vk ? '0x' + vk.slice(24, 64) : null); // evm.rs miner_evm_address: bytes 12..32 of the vote key hash
|
||||
return {
|
||||
row: {
|
||||
tx_count: txs.length, evm_miner: evmMiner, proof_records: cb ? cb.records : 0,
|
||||
subsidy_sompi: cb ? cb.subsidy : null, paid_sompi: cb ? cb.paid : null, selected_parent: vd.selectedParentHash || null,
|
||||
},
|
||||
detail: {
|
||||
version: h.version, bits: h.bits, nonce: String(h.nonce), blue_work: h.blueWork, hash_merkle_root: h.hashMerkleRoot,
|
||||
accepted_id_merkle_root: h.acceptedIdMerkleRoot, utxo_commitment: h.utxoCommitment, pruning_point: h.pruningPoint,
|
||||
difficulty: vd.difficulty ?? null, parents_by_level: Array.isArray(h.parentsByLevel) ? h.parentsByLevel.length : null,
|
||||
mergeset: { blues: vd.mergeSetBluesHashes || [], reds: vd.mergeSetRedsHashes || [] },
|
||||
coinbase: cb, txs, certificates: certs,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const pendingBlocks = []; // rows waiting for the next flush
|
||||
const pendingChain = { add: new Set(), remove: new Set() };
|
||||
const pendingMerge = new Map(); // chain block hash -> { blues, reds } from its verbose data, or null to fetch with getBlock
|
||||
|
|
@ -363,11 +472,15 @@ function onBlock(block) {
|
|||
}
|
||||
if (vd && vd.isChainBlock) queuePlan(h.hash);
|
||||
lastBlockAt = now; blockCountAtLastBlock = lastDagBlockCount; resubscribeAttempts = 0;
|
||||
let ex = null; try { ex = blockDetail(block, addressPrefix); } catch (e) { log('block detail failed', short(h.hash), e.message); }
|
||||
pendingBlocks.push({
|
||||
hash: h.hash, blue_score: h.blueScore, daa_score: h.daaScore, timestamp_ms: h.timestamp,
|
||||
parents: parents.length, parent_hashes: parents,
|
||||
is_chain_block: !!(block.verboseData && block.verboseData.isChainBlock),
|
||||
vote_key_hash: vk, miner_address: miner.address, engine: miner.extra,
|
||||
tx_count: ex ? ex.row.tx_count : null, evm_miner: ex ? ex.row.evm_miner : null, proof_records: ex ? ex.row.proof_records : null,
|
||||
subsidy_sompi: ex ? ex.row.subsidy_sompi : null, paid_sompi: ex ? ex.row.paid_sompi : null, selected_parent: ex ? ex.row.selected_parent : null,
|
||||
detail: ex ? JSON.stringify(ex.detail) : null,
|
||||
});
|
||||
noteArrival(now, Number(h.timestamp) || now);
|
||||
noteWork(now, h.blueWork);
|
||||
|
|
@ -383,11 +496,13 @@ function pgArray(list) { return `{${list.map(s => `"${String(s).replace(/["\\]/g
|
|||
async function flushBlocks() {
|
||||
if (!pendingBlocks.length) return;
|
||||
const rows = pendingBlocks.splice(0, 200);
|
||||
const cols = ['hash', 'blue_score', 'daa_score', 'timestamp_ms', 'parents', 'parent_hashes', 'is_chain_block', 'vote_key_hash', 'miner_address', 'engine'];
|
||||
const cols = ['hash', 'blue_score', 'daa_score', 'timestamp_ms', 'parents', 'parent_hashes', 'is_chain_block', 'vote_key_hash', 'miner_address', 'engine',
|
||||
'tx_count', 'evm_miner', 'proof_records', 'subsidy_sompi', 'paid_sompi', 'selected_parent', 'detail'];
|
||||
const cast = { parent_hashes: '::text[]', detail: '::jsonb', subsidy_sompi: '::bigint', paid_sompi: '::bigint' };
|
||||
const params = []; const values = [];
|
||||
for (const r of rows) {
|
||||
const ph = [];
|
||||
for (const c of cols) { params.push(c === 'parent_hashes' ? pgArray(r[c]) : r[c]); ph.push(`$${params.length}${c === 'parent_hashes' ? '::text[]' : ''}`); }
|
||||
for (const c of cols) { params.push(c === 'parent_hashes' ? pgArray(r[c]) : r[c]); ph.push(`$${params.length}${cast[c] || ''}`); }
|
||||
values.push(`(${ph.join(',')})`);
|
||||
}
|
||||
try {
|
||||
|
|
@ -444,6 +559,7 @@ async function tick(rpc) {
|
|||
}
|
||||
async function tickInner(rpc) {
|
||||
const now = Date.now();
|
||||
rollRpcLoad(now);
|
||||
let dag, info, peersRes, hps;
|
||||
try {
|
||||
[dag, info, peersRes, hps] = await Promise.all([
|
||||
|
|
@ -500,17 +616,19 @@ async function tickInner(rpc) {
|
|||
|
||||
try {
|
||||
await sql(`INSERT INTO ${TS} (id, block_count, header_count, blue_score, difficulty, hashes_per_second_estimate, peers, mempool,
|
||||
node_version, network, blocks_60s, blocks_per_minute, observer_started_at, updated_at, finality, observer_lag_s, queue_depth, proving)
|
||||
VALUES (1, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb, $12, now(), $13::jsonb, $14, $15, $16::jsonb)
|
||||
node_version, network, blocks_60s, blocks_per_minute, observer_started_at, updated_at, finality, observer_lag_s, queue_depth, proving, rpc_load, supply_check)
|
||||
VALUES (1, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb, $12, now(), $13::jsonb, $14, $15, $16::jsonb, $17::jsonb, $18::jsonb)
|
||||
ON CONFLICT (id) DO UPDATE SET block_count = EXCLUDED.block_count, header_count = EXCLUDED.header_count, blue_score = EXCLUDED.blue_score,
|
||||
difficulty = EXCLUDED.difficulty, hashes_per_second_estimate = EXCLUDED.hashes_per_second_estimate, peers = EXCLUDED.peers,
|
||||
mempool = EXCLUDED.mempool, node_version = EXCLUDED.node_version, network = EXCLUDED.network, blocks_60s = EXCLUDED.blocks_60s,
|
||||
blocks_per_minute = EXCLUDED.blocks_per_minute, observer_started_at = EXCLUDED.observer_started_at, updated_at = now(),
|
||||
finality = EXCLUDED.finality, observer_lag_s = EXCLUDED.observer_lag_s, queue_depth = EXCLUDED.queue_depth, proving = EXCLUDED.proving`,
|
||||
finality = EXCLUDED.finality, observer_lag_s = EXCLUDED.observer_lag_s, queue_depth = EXCLUDED.queue_depth, proving = EXCLUDED.proving,
|
||||
rpc_load = EXCLUDED.rpc_load, supply_check = EXCLUDED.supply_check`,
|
||||
[dag.blockCount, dag.headerCount, (await rpc.call('getSinkBlueScore', {}).catch(() => ({}))).blueScore ?? null, diff,
|
||||
hps ? hps.networkHashesPerSecond : localHashesPerSecond(), peers.length, info.mempoolSize ?? 0,
|
||||
nodeVersion, network, blocks60s(now), JSON.stringify(blocksPerMinute(now)), STARTED_AT.toISOString(),
|
||||
finality ? JSON.stringify(finality) : null, lagS === null ? null : Math.round(lagS * 10) / 10, queueDepth, JSON.stringify(proving)]);
|
||||
finality ? JSON.stringify(finality) : null, lagS === null ? null : Math.round(lagS * 10) / 10, queueDepth, JSON.stringify(proving),
|
||||
JSON.stringify(rpcLoadState()), lastSupplyCheck ? JSON.stringify(lastSupplyCheck) : null]);
|
||||
} catch (e) { log('state write failed', e.message); }
|
||||
}
|
||||
|
||||
|
|
@ -741,6 +859,7 @@ const hx = v => (v === null || v === undefined ? null : (typeof v === 'string' &
|
|||
const weiStr = v => { if (v === null || v === undefined) return null; try { return BigInt(v).toString(); } catch { return null; } };
|
||||
|
||||
async function evm(method, params = []) {
|
||||
rpcLoad.evm++;
|
||||
const r = await fetch(EVM, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }), signal: AbortSignal.timeout(8000) });
|
||||
const j = await r.json();
|
||||
if (j.error) { const e = new Error(`${method}: ${j.error.message}`); e.code = j.error.code; throw e; }
|
||||
|
|
@ -802,6 +921,9 @@ async function flushPlans() {
|
|||
// block_ts comes from the block table (the header time the page's axis uses), one update for the batch
|
||||
await sql(`INSERT INTO ${TP} (block_hash, shard, shards, block_number, block_daa, pgas) VALUES ${values.join(',')} ON CONFLICT (block_hash, shard) DO NOTHING`, params);
|
||||
await sql(`UPDATE ${TP} p SET block_ts = b.timestamp_ms FROM ${TB} b WHERE b.hash = p.block_hash AND p.block_ts IS NULL AND p.block_hash = ANY($1::text[])`, [pgArray([...new Set(rows.map(r => r.hash))])]).catch(() => { });
|
||||
// explorer: the chain block's number (the EVM block number), one update for the batch, no extra RPC
|
||||
const nums = [...new Map(rows.map(r => [r.hash, r.number]))];
|
||||
await sql(`UPDATE ${TB} b SET number = v.n::bigint FROM (SELECT unnest($1::text[]) AS h, unnest($2::text[]) AS n) v WHERE b.hash = v.h AND b.number IS NULL`, [pgArray(nums.map(x => x[0])), pgArray(nums.map(x => String(x[1])))]).catch(e => log('number update failed', e.message));
|
||||
}
|
||||
} catch (e) { log('plan flush failed', e.message); }
|
||||
finally { plansBusy = false; }
|
||||
|
|
@ -880,6 +1002,33 @@ async function provingState() {
|
|||
};
|
||||
}
|
||||
|
||||
// ---------- Supply check (spec 2.5 against the chain) ----------
|
||||
// Two comparisons over the newest stored blocks, once an hour and 20 s after start: (a) the subsidy each payload
|
||||
// declares equals blockSubsidy(daa, 1) of site/lib/emission.mjs; (b) each block's outputs sum to the declared subsidy
|
||||
// of every block it merges (utxo_validation.rs:176 pays a merged block what its own payload declares; blues and reds
|
||||
// inside the DAA window alike). Written to live_state.supply_check; /api/supply shows it beside the rule's total.
|
||||
const SUPPLY_CHECK_EVERY_MS = 60 * 60_000, SUPPLY_SAMPLE = 500;
|
||||
let lastSupplyCheck = null;
|
||||
async function supplyCheck() {
|
||||
try {
|
||||
const rows = await sql(`SELECT hash, daa_score, subsidy_sompi, paid_sompi, detail->'mergeset' AS mergeset FROM ${TB}
|
||||
WHERE subsidy_sompi IS NOT NULL ORDER BY received_at DESC LIMIT ${SUPPLY_SAMPLE}`);
|
||||
const byHash = new Map(rows.map(r => [r.hash, r]));
|
||||
let ruleOk = 0, ruleBad = 0, sumOk = 0, sumBad = 0, sumSkipped = 0; const examples = [];
|
||||
for (const r of rows) {
|
||||
const want = blockSubsidy(Number(r.daa_score), 1);
|
||||
if (BigInt(r.subsidy_sompi) === want) ruleOk++; else { ruleBad++; if (examples.length < 3) examples.push({ kind: 'rule', block: short(r.hash), daa: Number(r.daa_score), declared: String(r.subsidy_sompi), rule: want.toString() }); }
|
||||
const ms = r.mergeset || { blues: [], reds: [] };
|
||||
const merged = [...(ms.blues || []), ...(ms.reds || [])];
|
||||
if (!merged.length || merged.some(h => !byHash.has(h))) { sumSkipped++; continue; } // a merged block outside the sample: no verdict
|
||||
const expect = merged.reduce((t, h) => t + BigInt(byHash.get(h).subsidy_sompi), 0n);
|
||||
if (BigInt(r.paid_sompi) === expect) sumOk++; else { sumBad++; if (examples.length < 3) examples.push({ kind: 'sum', block: short(r.hash), paid: String(r.paid_sompi), expected: expect.toString(), merged: merged.length }); }
|
||||
}
|
||||
lastSupplyCheck = { checked_at: new Date().toISOString(), sampled: rows.length, rule_match: ruleOk, rule_mismatch: ruleBad, sum_match: sumOk, sum_mismatch: sumBad, sum_skipped: sumSkipped, examples, bps: 1 };
|
||||
log(`supply check: ${rows.length} blocks, payload subsidy = rule ${ruleOk}/${ruleOk + ruleBad}, outputs = merged subsidies ${sumOk}/${sumOk + sumBad} (${sumSkipped} without a verdict)`);
|
||||
} catch (e) { log('supply check failed', e.message); }
|
||||
}
|
||||
|
||||
async function prune() {
|
||||
try {
|
||||
await sql(`DELETE FROM ${TB} WHERE received_at < now() - ($1 || ' hours')::interval`, [String(RETAIN_HOURS)]);
|
||||
|
|
@ -988,6 +1137,7 @@ async function main() {
|
|||
setInterval(pollRecords, STATE_EVERY_MS);
|
||||
setInterval(() => tick(rpc), STATE_EVERY_MS);
|
||||
setInterval(prune, PRUNE_EVERY_MS);
|
||||
setInterval(supplyCheck, SUPPLY_CHECK_EVERY_MS); setTimeout(supplyCheck, 20_000);
|
||||
tick(rpc); prune();
|
||||
}
|
||||
|
||||
|
|
|
|||
44
tools/site-serve.mjs
Normal file
44
tools/site-serve.mjs
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
#!/usr/bin/env node
|
||||
// Local preview of the site with its API functions: serves site/ the way Vercel does (clean URLs, the rewrites and
|
||||
// headers of site/vercel.json) and runs site/api/*.mjs in-process with a small req/res shim. DATABASE_URL comes from
|
||||
// the environment or ~/.config/igneum/env, like the observer. Options through the environment:
|
||||
// PORT (default 4173), LIVE_TABLE_PREFIX (a test observer's tables), EXPLORER_EVM_RPC (an EVM JSON-RPC for balances)
|
||||
// node tools/site-serve.mjs
|
||||
import { createServer } from 'node:http';
|
||||
import { readFileSync, existsSync, statSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join, dirname, extname } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
const site = join(dirname(fileURLToPath(import.meta.url)), '..', 'site');
|
||||
const PORT = Number(process.env.PORT || 4173);
|
||||
if (!process.env.DATABASE_URL) {
|
||||
try { const m = /^DATABASE_URL=(.*)$/m.exec(readFileSync(`${homedir()}/.config/igneum/env`, 'utf8')); if (m) process.env.DATABASE_URL = m[1].trim().replace(/^['"]|['"]$/g, ''); } catch { }
|
||||
}
|
||||
const cfg = JSON.parse(readFileSync(join(site, 'vercel.json'), 'utf8'));
|
||||
const rewrites = (cfg.rewrites || []).map(r => ({ re: new RegExp('^' + r.source.replace(/:[a-z]+/g, '[^/]+') + '$'), to: r.destination }));
|
||||
const TYPES = { '.html': 'text/html; charset=utf-8', '.css': 'text/css', '.js': 'text/javascript', '.mjs': 'text/javascript', '.json': 'application/json', '.png': 'image/png', '.ico': 'image/x-icon', '.svg': 'image/svg+xml', '.woff2': 'font/woff2', '.webp': 'image/webp', '.txt': 'text/plain', '.xml': 'application/xml', '.webmanifest': 'application/manifest+json' };
|
||||
const handlers = new Map();
|
||||
async function api(name) {
|
||||
if (!handlers.has(name)) handlers.set(name, (await import(pathToFileURL(join(site, 'api', name + '.mjs')).href)).default);
|
||||
return handlers.get(name);
|
||||
}
|
||||
function shim(res) {
|
||||
const r = { status(c) { res.statusCode = c; return r; }, setHeader: (k, v) => res.setHeader(k, v), json(o) { res.setHeader('content-type', 'application/json'); res.end(JSON.stringify(o)); }, end: (b) => res.end(b) };
|
||||
return r;
|
||||
}
|
||||
createServer(async (req, res) => {
|
||||
const url = new URL(req.url, 'http://x'); let p = decodeURIComponent(url.pathname);
|
||||
for (const rw of rewrites) if (rw.re.test(p)) { p = rw.to; break; }
|
||||
if (p.startsWith('/api/')) {
|
||||
const name = p.slice(5).replace(/[^a-z0-9_-]/gi, '');
|
||||
if (!existsSync(join(site, 'api', name + '.mjs'))) { res.statusCode = 404; return res.end('no such function'); }
|
||||
try { const h = await api(name); return await h({ method: req.method, url: req.url, headers: req.headers, query: Object.fromEntries(url.searchParams), socket: req.socket }, shim(res)); }
|
||||
catch (e) { res.statusCode = 500; return res.end(String(e.stack || e)); }
|
||||
}
|
||||
const candidates = [join(site, p), join(site, p + '.html'), join(site, p, 'index.html')];
|
||||
const file = candidates.find(f => existsSync(f) && statSync(f).isFile());
|
||||
if (!file || !file.startsWith(site)) { res.statusCode = 404; res.setHeader('content-type', 'text/html; charset=utf-8'); return res.end(readFileSync(join(site, '404.html'))); }
|
||||
res.setHeader('content-type', TYPES[extname(file)] || 'application/octet-stream');
|
||||
res.end(readFileSync(file));
|
||||
}).listen(PORT, '127.0.0.1', () => console.log(`site preview on http://127.0.0.1:${PORT} (prefix '${process.env.LIVE_TABLE_PREFIX || ''}', evm rpc ${process.env.EXPLORER_EVM_RPC || 'none'})`));
|
||||
Loading…
Reference in a new issue