Merge build/master into reference-apps (checkpoint.mjs: master's dn3 default and ?index, the read service's exports kept; built pages rebuilt)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
commit
cb7bf85dd9
33 changed files with 2813 additions and 180 deletions
18
contracts/bridge/foundry.toml
Normal file
18
contracts/bridge/foundry.toml
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
[profile.default]
|
||||||
|
src = "src"
|
||||||
|
test = "test"
|
||||||
|
script = "script"
|
||||||
|
out = "out"
|
||||||
|
libs = []
|
||||||
|
solc_version = "0.8.28"
|
||||||
|
# The verifier calls the BLS12-381 precompiles of EIP-2537 (live on Sepolia and mainnet since Pectra), so the test EVM
|
||||||
|
# runs the Osaka rules, the fork Sepolia is on (EIP-7883 modexp pricing counts here).
|
||||||
|
evm_version = "osaka"
|
||||||
|
optimizer = true
|
||||||
|
optimizer_runs = 200
|
||||||
|
via_ir = true
|
||||||
|
fs_permissions = [{ access = "read", path = "./test/vectors" }, { access = "read-write", path = "./deploy-out.json" }]
|
||||||
|
auto_detect_remappings = false
|
||||||
|
|
||||||
|
[rpc_endpoints]
|
||||||
|
sepolia = "https://ethereum-sepolia-rpc.publicnode.com"
|
||||||
46
contracts/bridge/script/Deploy.s.sol
Normal file
46
contracts/bridge/script/Deploy.s.sol
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
pragma solidity ^0.8.24;
|
||||||
|
|
||||||
|
import {Vm, VM_ADDRESS} from "../test/Vm.sol";
|
||||||
|
import {IgneumCertificateVerifier} from "../src/IgneumCertificateVerifier.sol";
|
||||||
|
|
||||||
|
/// Deploys the verifier on Sepolia from BRIDGE_DEPLOYER_KEY (environment, never printed), installs the voter table
|
||||||
|
/// from the vectors file named in BRIDGE_TABLE_JSON (a gen.mjs output: keys, weights, index, chain_id) and, when the
|
||||||
|
/// file carries a certificate (bitmap, signature), submits it, so the contract records one checkpoint final from the start.
|
||||||
|
///
|
||||||
|
/// BRIDGE_TABLE_JSON=test/vectors/chain.json forge script script/Deploy.s.sol:Deploy --rpc-url sepolia --broadcast --sig "run()"
|
||||||
|
contract Deploy {
|
||||||
|
Vm constant vm = Vm(VM_ADDRESS);
|
||||||
|
|
||||||
|
function run() external {
|
||||||
|
uint256 key = vm.envUint("BRIDGE_DEPLOYER_KEY");
|
||||||
|
string memory j = vm.readFile(vm.envOr("BRIDGE_TABLE_JSON", "test/vectors/chain.json"));
|
||||||
|
bytes[] memory keys = vm.parseJsonBytesArray(j, ".keys");
|
||||||
|
uint256[] memory w = vm.parseJsonUintArray(j, ".weights");
|
||||||
|
bytes memory packed;
|
||||||
|
uint64[] memory weights = new uint64[](w.length);
|
||||||
|
for (uint256 i = 0; i < keys.length; i++) {
|
||||||
|
packed = abi.encodePacked(packed, keys[i]);
|
||||||
|
weights[i] = uint64(w[i]);
|
||||||
|
}
|
||||||
|
uint64 index = uint64(vm.parseJsonUint(j, ".index"));
|
||||||
|
|
||||||
|
vm.startBroadcast(key);
|
||||||
|
IgneumCertificateVerifier v = new IgneumCertificateVerifier(vm.parseJsonString(j, ".chain_id"));
|
||||||
|
v.installTable(index, packed, weights);
|
||||||
|
bool hasCert = vm.keyExistsJson(j, ".bitmap");
|
||||||
|
if (hasCert) {
|
||||||
|
v.submitCertificate(index, vm.parseJsonBytes32(j, ".checkpoint"), vm.parseJsonBytes(j, ".bitmap"), vm.parseJsonBytes(j, ".signature"));
|
||||||
|
}
|
||||||
|
vm.stopBroadcast();
|
||||||
|
|
||||||
|
vm.writeFile(
|
||||||
|
"deploy-out.json",
|
||||||
|
string.concat(
|
||||||
|
"{\n \"IgneumCertificateVerifier\": \"", vm.toString(address(v)), "\",\n \"chain_id\": \"", vm.parseJsonString(j, ".chain_id"),
|
||||||
|
"\",\n \"table_index\": ", vm.toString(uint256(index)), ",\n \"voters\": ", vm.toString(keys.length), ",\n \"table_id\": \"",
|
||||||
|
vm.toString(v.tableId()), "\",\n \"final_checkpoint\": \"", hasCert ? vm.toString(vm.parseJsonBytes32(j, ".checkpoint")) : "none yet", "\"\n}\n"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
22
contracts/bridge/script/Send.s.sol
Normal file
22
contracts/bridge/script/Send.s.sol
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
pragma solidity ^0.8.24;
|
||||||
|
|
||||||
|
import {Vm, VM_ADDRESS} from "../test/Vm.sol";
|
||||||
|
|
||||||
|
/// Sends SEND_WEI of the chain's coin from the key in BRIDGE_DEPLOYER_KEY to SEND_TO (Sepolia test ETH between the
|
||||||
|
/// lanes' throwaway deployers). The key is read from the environment and never printed.
|
||||||
|
///
|
||||||
|
/// SEND_TO=0x.. SEND_WEI=20000000000000000 forge script script/Send.s.sol:Send --rpc-url sepolia --broadcast --sig "run()"
|
||||||
|
contract Send {
|
||||||
|
Vm constant vm = Vm(VM_ADDRESS);
|
||||||
|
|
||||||
|
function run() external {
|
||||||
|
uint256 key = vm.envUint("BRIDGE_DEPLOYER_KEY");
|
||||||
|
address to = vm.envAddress("SEND_TO");
|
||||||
|
uint256 wei_ = vm.envUint("SEND_WEI");
|
||||||
|
vm.startBroadcast(key);
|
||||||
|
(bool ok,) = to.call{value: wei_}("");
|
||||||
|
require(ok, "send failed");
|
||||||
|
vm.stopBroadcast();
|
||||||
|
}
|
||||||
|
}
|
||||||
113
contracts/bridge/src/BLS12381.sol
Normal file
113
contracts/bridge/src/BLS12381.sol
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
pragma solidity ^0.8.24;
|
||||||
|
|
||||||
|
/// BLS12-381 through the EIP-2537 precompiles (Ethereum mainnet and Sepolia since Pectra): the hash-to-curve of
|
||||||
|
/// RFC 9380 (BLS12381G2_XMD:SHA-256_SSWU_RO_) with the caller's domain separation tag, public-key aggregation in G1
|
||||||
|
/// and the two-pairing check of a "minimal public key" signature (keys in G1, signatures in G2), the scheme of
|
||||||
|
/// Igneum's finality votes (consensus/core/src/finality.rs, blst "min_pk").
|
||||||
|
///
|
||||||
|
/// Encodings are the precompiles' own: a field element is 64 bytes (16 zero bytes then the 48-byte big-endian
|
||||||
|
/// value), a G1 point 128 bytes (x, y), a G2 point 256 bytes (x.c0, x.c1, y.c0, y.c1). Compressed chain forms
|
||||||
|
/// (48-byte keys, 96-byte signatures) are decompressed off chain by the submitter; the pairing precompile refuses
|
||||||
|
/// a point off the curve or outside the prime-order subgroup, so a wrong decompression fails the check.
|
||||||
|
library BLS12381 {
|
||||||
|
address internal constant G1ADD = address(0x0b);
|
||||||
|
address internal constant G2ADD = address(0x0d);
|
||||||
|
address internal constant PAIRING = address(0x0f);
|
||||||
|
address internal constant MAP_FP2_TO_G2 = address(0x11);
|
||||||
|
address internal constant MODEXP = address(0x05);
|
||||||
|
|
||||||
|
uint256 internal constant G1_LEN = 128;
|
||||||
|
uint256 internal constant G2_LEN = 256;
|
||||||
|
|
||||||
|
/// The field modulus p, big-endian, 48 bytes (the modexp precompile's modulus).
|
||||||
|
bytes internal constant P = hex"1a0111ea397fe69a4b1ba7b6434bacd764774b84f38512bf6730d2a0f6b0f6241eabfffeb153ffffb9feffffffffaaab";
|
||||||
|
|
||||||
|
/// The G1 generator with its y negated (p - y), in the 128-byte encoding, for the pairing check
|
||||||
|
/// e(pk, H(m)) * e(-G1, sig) == 1.
|
||||||
|
bytes internal constant NEG_G1 =
|
||||||
|
hex"0000000000000000000000000000000017f1d3a73197d7942695638c4fa9ac0fc3688c4f9774b905a14e3a3f171bac586c55e83ff97a1aeffb3af00adb22c6bb"
|
||||||
|
hex"00000000000000000000000000000000114d1d6855d545a8aa7d76c8cf2e21f267816aef1db507c96655b9d5caac42364e6f38ba0ecb751bad54dcd6b939c2ca";
|
||||||
|
|
||||||
|
error PrecompileFailed(address which);
|
||||||
|
error BadLength(string what);
|
||||||
|
|
||||||
|
// ---- hash to curve ----
|
||||||
|
|
||||||
|
/// expand_message_xmd with SHA-256 (RFC 9380 section 5.3.1) to `len` bytes; len at most 255 * 32.
|
||||||
|
function expandMessageXmd(bytes memory msg_, bytes memory dst, uint256 len) internal pure returns (bytes memory out) {
|
||||||
|
require(dst.length <= 255, "BLS: DST too long");
|
||||||
|
uint256 ell = (len + 31) / 32;
|
||||||
|
require(ell <= 255 && len > 0, "BLS: bad length");
|
||||||
|
bytes memory dstPrime = abi.encodePacked(dst, uint8(dst.length));
|
||||||
|
bytes32 b0 = sha256(abi.encodePacked(new bytes(64), msg_, uint16(len), uint8(0), dstPrime));
|
||||||
|
bytes32 bi = sha256(abi.encodePacked(b0, uint8(1), dstPrime));
|
||||||
|
out = new bytes(ell * 32);
|
||||||
|
assembly {
|
||||||
|
mstore(add(out, 32), bi)
|
||||||
|
}
|
||||||
|
for (uint256 i = 2; i <= ell; i++) {
|
||||||
|
bi = sha256(abi.encodePacked(b0 ^ bi, uint8(i), dstPrime));
|
||||||
|
assembly {
|
||||||
|
mstore(add(add(out, 32), mul(sub(i, 1), 32)), bi)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assembly {
|
||||||
|
mstore(out, len)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A 64-byte big-endian integer reduced mod p and returned in the precompiles' 64-byte field encoding.
|
||||||
|
function reduce64(bytes memory chunk, uint256 offset) internal view returns (bytes memory fe) {
|
||||||
|
require(chunk.length >= offset + 64, "BLS: chunk");
|
||||||
|
bytes memory base = new bytes(64);
|
||||||
|
for (uint256 i = 0; i < 64; i++) {
|
||||||
|
base[i] = chunk[offset + i];
|
||||||
|
}
|
||||||
|
// modexp(base^1 mod p): lengths 64, 1, 48
|
||||||
|
bytes memory input = abi.encodePacked(uint256(64), uint256(1), uint256(48), base, uint8(1), P);
|
||||||
|
(bool ok, bytes memory r) = MODEXP.staticcall(input);
|
||||||
|
if (!ok || r.length != 48) revert PrecompileFailed(MODEXP);
|
||||||
|
fe = abi.encodePacked(bytes16(0), r);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// hash_to_curve for G2: two field elements of Fp2 from a 256-byte expansion, each mapped by the precompile
|
||||||
|
/// (which clears the cofactor), then added.
|
||||||
|
function hashToG2(bytes memory msg_, bytes memory dst) internal view returns (bytes memory point) {
|
||||||
|
bytes memory u = expandMessageXmd(msg_, dst, 256);
|
||||||
|
bytes memory q0 = mapFp2ToG2(abi.encodePacked(reduce64(u, 0), reduce64(u, 64)));
|
||||||
|
bytes memory q1 = mapFp2ToG2(abi.encodePacked(reduce64(u, 128), reduce64(u, 192)));
|
||||||
|
point = g2Add(q0, q1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapFp2ToG2(bytes memory fp2) internal view returns (bytes memory point) {
|
||||||
|
if (fp2.length != 128) revert BadLength("fp2");
|
||||||
|
(bool ok, bytes memory r) = MAP_FP2_TO_G2.staticcall(fp2);
|
||||||
|
if (!ok || r.length != G2_LEN) revert PrecompileFailed(MAP_FP2_TO_G2);
|
||||||
|
point = r;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- group operations ----
|
||||||
|
|
||||||
|
function g1Add(bytes memory a, bytes memory b) internal view returns (bytes memory c) {
|
||||||
|
if (a.length != G1_LEN || b.length != G1_LEN) revert BadLength("g1");
|
||||||
|
(bool ok, bytes memory r) = G1ADD.staticcall(abi.encodePacked(a, b));
|
||||||
|
if (!ok || r.length != G1_LEN) revert PrecompileFailed(G1ADD);
|
||||||
|
c = r;
|
||||||
|
}
|
||||||
|
|
||||||
|
function g2Add(bytes memory a, bytes memory b) internal view returns (bytes memory c) {
|
||||||
|
if (a.length != G2_LEN || b.length != G2_LEN) revert BadLength("g2");
|
||||||
|
(bool ok, bytes memory r) = G2ADD.staticcall(abi.encodePacked(a, b));
|
||||||
|
if (!ok || r.length != G2_LEN) revert PrecompileFailed(G2ADD);
|
||||||
|
c = r;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// e(pk, hm) * e(-G1, sig) == 1, which holds exactly when sig = sk * hm for pk = sk * G1.
|
||||||
|
function verifyMinPk(bytes memory pk, bytes memory hm, bytes memory sig) internal view returns (bool) {
|
||||||
|
if (pk.length != G1_LEN || hm.length != G2_LEN || sig.length != G2_LEN) revert BadLength("pairing");
|
||||||
|
(bool ok, bytes memory r) = PAIRING.staticcall(abi.encodePacked(pk, hm, NEG_G1, sig));
|
||||||
|
if (!ok || r.length != 32) return false;
|
||||||
|
return abi.decode(r, (uint256)) == 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
155
contracts/bridge/src/IgneumCertificateVerifier.sol
Normal file
155
contracts/bridge/src/IgneumCertificateVerifier.sol
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
pragma solidity ^0.8.24;
|
||||||
|
|
||||||
|
import {BLS12381} from "./BLS12381.sol";
|
||||||
|
import {MerklePatricia} from "./MerklePatricia.sol";
|
||||||
|
|
||||||
|
interface IIgneumCertificateVerifier {
|
||||||
|
function chainId() external view returns (string memory);
|
||||||
|
function tableId() external view returns (bytes32);
|
||||||
|
function verifyCertificate(uint64 index, bytes32 checkpoint, bytes calldata bitmap, bytes calldata signature)
|
||||||
|
external
|
||||||
|
view
|
||||||
|
returns (bool ok, uint256 signedWeight, uint256 totalWeight);
|
||||||
|
function submitCertificate(uint64 index, bytes32 checkpoint, bytes calldata bitmap, bytes calldata signature) external;
|
||||||
|
function finalCheckpoint(uint64 index) external view returns (bytes32);
|
||||||
|
function isFinal(bytes32 checkpoint) external view returns (bool);
|
||||||
|
function verifyAccount(bytes32 stateRoot, address account, bytes[] calldata proof)
|
||||||
|
external
|
||||||
|
pure
|
||||||
|
returns (bool exists, uint256 nonce, uint256 balance, bytes32 storageRoot, bytes32 codeHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Igneum light-client bridge primitive on Ethereum: verifies a Devnet 3 finality certificate (the aggregate
|
||||||
|
/// BLS signature of the canonical voter list over the vote message, under the 2/3-of-total-weight rule) against an
|
||||||
|
/// installed voter table, records the checkpoint hashes it proved final, and verifies an Ethereum-shape account
|
||||||
|
/// proof against a state root. What it proves and what it does not: docs/bridge/light-client-bridge.md.
|
||||||
|
///
|
||||||
|
/// Devnet 3, test tokens, no value.
|
||||||
|
contract IgneumCertificateVerifier is IIgneumCertificateVerifier {
|
||||||
|
using MerklePatricia for bytes32;
|
||||||
|
|
||||||
|
string public constant VOTE_PREFIX = "igneum-vote-v1/";
|
||||||
|
bytes public constant DST_VOTE = "IGNEUM_VOTE_V1_BLS12381G2_XMD:SHA-256_SSWU_RO_NUL_";
|
||||||
|
|
||||||
|
string private _chainId;
|
||||||
|
address public owner;
|
||||||
|
|
||||||
|
/// The canonical voter list at the installed checkpoint index: 128-byte G1 keys in the node's canonical order
|
||||||
|
/// (sorted by key hash, every key above dust and not stripped) and their weights (blue blocks in the window).
|
||||||
|
bytes[] private _keys;
|
||||||
|
uint64[] private _weights;
|
||||||
|
uint256 public totalWeight;
|
||||||
|
uint64 public tableIndex;
|
||||||
|
bytes32 public override tableId;
|
||||||
|
|
||||||
|
mapping(uint64 => bytes32) public override finalCheckpoint;
|
||||||
|
mapping(bytes32 => bool) public override isFinal;
|
||||||
|
|
||||||
|
event TableInstalled(uint64 indexed atIndex, uint256 voters, uint256 totalWeight, bytes32 tableId);
|
||||||
|
event CheckpointFinal(uint64 indexed index, bytes32 checkpoint, uint256 signedWeight, uint256 totalWeight, uint256 signers);
|
||||||
|
|
||||||
|
error NotOwner();
|
||||||
|
error NoTable();
|
||||||
|
error BadCertificate(string why);
|
||||||
|
|
||||||
|
constructor(string memory chainId_) {
|
||||||
|
_chainId = chainId_;
|
||||||
|
owner = msg.sender;
|
||||||
|
}
|
||||||
|
|
||||||
|
function chainId() external view override returns (string memory) {
|
||||||
|
return _chainId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function voterCount() external view returns (uint256) {
|
||||||
|
return _keys.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function voter(uint256 i) external view returns (bytes memory key, uint64 weight) {
|
||||||
|
return (_keys[i], _weights[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Installs the voter table read from a Devnet 3 node (igneum_getFinalityWeights at `atIndex`): `keys` is the
|
||||||
|
/// concatenation of 128-byte uncompressed G1 keys in canonical order, `weights` their weights. The table is a
|
||||||
|
/// trusted input of this first version (see the doc); only the installer may replace it.
|
||||||
|
function installTable(uint64 atIndex, bytes calldata keys, uint64[] calldata weights) external {
|
||||||
|
if (msg.sender != owner) revert NotOwner();
|
||||||
|
if (keys.length != weights.length * BLS12381.G1_LEN || weights.length == 0) revert BadCertificate("table shape");
|
||||||
|
delete _keys;
|
||||||
|
delete _weights;
|
||||||
|
uint256 total;
|
||||||
|
for (uint256 i = 0; i < weights.length; i++) {
|
||||||
|
_keys.push(keys[i * BLS12381.G1_LEN:(i + 1) * BLS12381.G1_LEN]);
|
||||||
|
_weights.push(weights[i]);
|
||||||
|
total += weights[i];
|
||||||
|
}
|
||||||
|
totalWeight = total;
|
||||||
|
tableIndex = atIndex;
|
||||||
|
tableId = keccak256(abi.encodePacked(atIndex, keys, abi.encodePacked(weights)));
|
||||||
|
emit TableInstalled(atIndex, weights.length, total, tableId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The bytes every voter signs for (index, checkpoint): "igneum-vote-v1/" chain_id 0x00 index_le64 checkpoint.
|
||||||
|
function voteMessage(uint64 index, bytes32 checkpoint) public view returns (bytes memory) {
|
||||||
|
return abi.encodePacked(VOTE_PREFIX, _chainId, bytes1(0), le64(index), checkpoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyCertificate(uint64 index, bytes32 checkpoint, bytes calldata bitmap, bytes calldata signature)
|
||||||
|
public
|
||||||
|
view
|
||||||
|
override
|
||||||
|
returns (bool ok, uint256 signedWeight, uint256 totalWeight_)
|
||||||
|
{
|
||||||
|
uint256 n = _keys.length;
|
||||||
|
if (n == 0) revert NoTable();
|
||||||
|
if (bitmap.length != (n + 7) / 8) revert BadCertificate("bitmap length");
|
||||||
|
if (signature.length != BLS12381.G2_LEN) revert BadCertificate("signature length");
|
||||||
|
bytes memory agg;
|
||||||
|
uint256 signers;
|
||||||
|
for (uint256 p = 0; p < n; p++) {
|
||||||
|
if (uint8(bitmap[p >> 3]) & uint8(1 << (p & 7)) == 0) continue;
|
||||||
|
signedWeight += _weights[p];
|
||||||
|
signers++;
|
||||||
|
agg = agg.length == 0 ? _keys[p] : BLS12381.g1Add(agg, _keys[p]);
|
||||||
|
}
|
||||||
|
totalWeight_ = totalWeight;
|
||||||
|
if (signers == 0) return (false, 0, totalWeight_);
|
||||||
|
// the rule decided 4 October 2026: signed weight at least two thirds of the whole window's weight
|
||||||
|
if (3 * signedWeight < 2 * totalWeight_) return (false, signedWeight, totalWeight_);
|
||||||
|
bytes memory hm = BLS12381.hashToG2(voteMessage(index, checkpoint), DST_VOTE);
|
||||||
|
ok = BLS12381.verifyMinPk(agg, hm, signature);
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitCertificate(uint64 index, bytes32 checkpoint, bytes calldata bitmap, bytes calldata signature) external override {
|
||||||
|
(bool ok, uint256 signed, uint256 total) = verifyCertificate(index, checkpoint, bitmap, signature);
|
||||||
|
if (!ok) revert BadCertificate("certificate does not verify");
|
||||||
|
bytes32 known = finalCheckpoint[index];
|
||||||
|
if (known != bytes32(0) && known != checkpoint) revert BadCertificate("a different checkpoint is final at this index");
|
||||||
|
finalCheckpoint[index] = checkpoint;
|
||||||
|
isFinal[checkpoint] = true;
|
||||||
|
uint256 signers;
|
||||||
|
for (uint256 p = 0; p < _keys.length; p++) {
|
||||||
|
if (uint8(bitmap[p >> 3]) & uint8(1 << (p & 7)) != 0) signers++;
|
||||||
|
}
|
||||||
|
emit CheckpointFinal(index, checkpoint, signed, total, signers);
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyAccount(bytes32 stateRoot, address account, bytes[] calldata proof)
|
||||||
|
external
|
||||||
|
pure
|
||||||
|
override
|
||||||
|
returns (bool exists, uint256 nonce, uint256 balance, bytes32 storageRoot, bytes32 codeHash)
|
||||||
|
{
|
||||||
|
MerklePatricia.Account memory a = MerklePatricia.verifyAccount(stateRoot, account, proof);
|
||||||
|
return (a.exists, a.nonce, a.balance, a.storageRoot, a.codeHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
function le64(uint64 v) internal pure returns (bytes8 out) {
|
||||||
|
uint64 r;
|
||||||
|
for (uint256 i = 0; i < 8; i++) {
|
||||||
|
r = (r << 8) | ((v >> (8 * i)) & 0xff);
|
||||||
|
}
|
||||||
|
out = bytes8(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
214
contracts/bridge/src/MerklePatricia.sol
Normal file
214
contracts/bridge/src/MerklePatricia.sol
Normal file
|
|
@ -0,0 +1,214 @@
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
pragma solidity ^0.8.24;
|
||||||
|
|
||||||
|
/// An Ethereum account proof (the eth_getProof shape) checked against a state root: the keccak-keyed Merkle
|
||||||
|
/// Patricia trie of reth's layout, which Igneum's executor uses for its stateRoot (igneum/exec/src/state.rs,
|
||||||
|
/// alloy_trie::root::state_root over keccak256(address) keys and RLP(nonce, balance, storageRoot, codeHash)
|
||||||
|
/// values). A proof is the list of RLP nodes from the root to the account's leaf, or to the branch or leaf that
|
||||||
|
/// shows the account absent.
|
||||||
|
library MerklePatricia {
|
||||||
|
struct Account {
|
||||||
|
bool exists;
|
||||||
|
uint256 nonce;
|
||||||
|
uint256 balance;
|
||||||
|
bytes32 storageRoot;
|
||||||
|
bytes32 codeHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
error BadProof(string why);
|
||||||
|
|
||||||
|
/// Verifies `proof` for `account` under `stateRoot`; reverts when a node does not hash to its reference or
|
||||||
|
/// the path is malformed, returns exists=false when the trie shows no such account.
|
||||||
|
function verifyAccount(bytes32 stateRoot, address account, bytes[] memory proof) internal pure returns (Account memory out) {
|
||||||
|
bytes memory value = verifyPath(stateRoot, abi.encodePacked(keccak256(abi.encodePacked(account))), proof);
|
||||||
|
if (value.length == 0) return out;
|
||||||
|
(uint256 off, uint256 len, bool isList) = decode(value, 0);
|
||||||
|
if (!isList) revert BadProof("account value is not a list");
|
||||||
|
uint256 end = off + len;
|
||||||
|
uint256 p = off;
|
||||||
|
(uint256 o1, uint256 l1,) = decode(value, p);
|
||||||
|
out.nonce = toUint(value, o1, l1);
|
||||||
|
p = o1 + l1;
|
||||||
|
(uint256 o2, uint256 l2,) = decode(value, p);
|
||||||
|
out.balance = toUint(value, o2, l2);
|
||||||
|
p = o2 + l2;
|
||||||
|
(uint256 o3, uint256 l3,) = decode(value, p);
|
||||||
|
if (l3 != 32) revert BadProof("storage root length");
|
||||||
|
out.storageRoot = toBytes32(value, o3);
|
||||||
|
p = o3 + l3;
|
||||||
|
(uint256 o4, uint256 l4,) = decode(value, p);
|
||||||
|
if (l4 != 32) revert BadProof("code hash length");
|
||||||
|
out.codeHash = toBytes32(value, o4);
|
||||||
|
if (o4 + l4 != end) revert BadProof("account value has extra fields");
|
||||||
|
out.exists = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Walks the proof for `key` (32 bytes, hashed already) from `root`; returns the value found, or empty bytes
|
||||||
|
/// when the trie proves the key absent.
|
||||||
|
function verifyPath(bytes32 root, bytes memory key, bytes[] memory proof) internal pure returns (bytes memory value) {
|
||||||
|
bytes memory nibbles = toNibbles(key);
|
||||||
|
uint256 pos = 0;
|
||||||
|
bytes32 want = root;
|
||||||
|
bytes memory embedded;
|
||||||
|
for (uint256 i = 0; i < proof.length; i++) {
|
||||||
|
bytes memory node = proof[i];
|
||||||
|
if (embedded.length != 0) {
|
||||||
|
if (keccak256(node) != keccak256(embedded)) revert BadProof("embedded node mismatch");
|
||||||
|
embedded = "";
|
||||||
|
} else if (keccak256(node) != want) {
|
||||||
|
revert BadProof("node hash mismatch");
|
||||||
|
}
|
||||||
|
(uint256 off, uint256 len, bool isList) = decode(node, 0);
|
||||||
|
if (!isList) revert BadProof("node is not a list");
|
||||||
|
uint256 count = itemCount(node, off, len);
|
||||||
|
if (count == 17) {
|
||||||
|
if (pos == nibbles.length) {
|
||||||
|
// the branch's own value slot
|
||||||
|
(uint256 vo, uint256 vl,) = itemAt(node, off, 16);
|
||||||
|
return slice(node, vo, vl);
|
||||||
|
}
|
||||||
|
uint8 nib = uint8(nibbles[pos]);
|
||||||
|
(uint256 co, uint256 cl, bool clist) = itemAt(node, off, nib);
|
||||||
|
if (cl == 0 && !clist) return ""; // empty slot: the key is absent
|
||||||
|
pos++;
|
||||||
|
if (clist) {
|
||||||
|
embedded = slice(node, co - headerLen(node, co, cl, true), cl + headerLen(node, co, cl, true));
|
||||||
|
} else {
|
||||||
|
if (cl != 32) revert BadProof("child reference length");
|
||||||
|
want = toBytes32(node, co);
|
||||||
|
}
|
||||||
|
} else if (count == 2) {
|
||||||
|
(uint256 po, uint256 pl,) = itemAt(node, off, 0);
|
||||||
|
(bytes memory path, bool isLeaf) = decodePath(slice(node, po, pl));
|
||||||
|
if (!matches(nibbles, pos, path)) return ""; // diverging path: the key is absent
|
||||||
|
pos += path.length;
|
||||||
|
(uint256 vo, uint256 vl, bool vlist) = itemAt(node, off, 1);
|
||||||
|
if (isLeaf) {
|
||||||
|
if (pos != nibbles.length) revert BadProof("leaf before the key's end");
|
||||||
|
return slice(node, vo, vl);
|
||||||
|
}
|
||||||
|
if (vlist) {
|
||||||
|
embedded = slice(node, vo - headerLen(node, vo, vl, true), vl + headerLen(node, vo, vl, true));
|
||||||
|
} else {
|
||||||
|
if (vl != 32) revert BadProof("extension reference length");
|
||||||
|
want = toBytes32(node, vo);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
revert BadProof("node arity");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
revert BadProof("proof ends before the key");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- paths ----
|
||||||
|
|
||||||
|
function toNibbles(bytes memory key) internal pure returns (bytes memory n) {
|
||||||
|
n = new bytes(key.length * 2);
|
||||||
|
for (uint256 i = 0; i < key.length; i++) {
|
||||||
|
n[2 * i] = bytes1(uint8(key[i]) >> 4);
|
||||||
|
n[2 * i + 1] = bytes1(uint8(key[i]) & 0x0f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hex-prefix decoding of a leaf or extension path.
|
||||||
|
function decodePath(bytes memory hp) internal pure returns (bytes memory path, bool isLeaf) {
|
||||||
|
if (hp.length == 0) revert BadProof("empty path");
|
||||||
|
uint8 flag = uint8(hp[0]) >> 4;
|
||||||
|
isLeaf = flag >= 2;
|
||||||
|
bool odd = flag % 2 == 1;
|
||||||
|
uint256 n = (hp.length - 1) * 2 + (odd ? 1 : 0);
|
||||||
|
path = new bytes(n);
|
||||||
|
uint256 w = 0;
|
||||||
|
if (odd) path[w++] = bytes1(uint8(hp[0]) & 0x0f);
|
||||||
|
for (uint256 i = 1; i < hp.length; i++) {
|
||||||
|
path[w++] = bytes1(uint8(hp[i]) >> 4);
|
||||||
|
path[w++] = bytes1(uint8(hp[i]) & 0x0f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function matches(bytes memory nibbles, uint256 pos, bytes memory path) internal pure returns (bool) {
|
||||||
|
if (pos + path.length > nibbles.length) return false;
|
||||||
|
for (uint256 i = 0; i < path.length; i++) {
|
||||||
|
if (nibbles[pos + i] != path[i]) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- RLP ----
|
||||||
|
|
||||||
|
/// The item at `p`: the offset of its payload, the payload length and whether it is a list.
|
||||||
|
function decode(bytes memory b, uint256 p) internal pure returns (uint256 off, uint256 len, bool isList) {
|
||||||
|
if (p >= b.length) revert BadProof("rlp out of range");
|
||||||
|
uint8 first = uint8(b[p]);
|
||||||
|
if (first < 0x80) return (p, 1, false);
|
||||||
|
if (first < 0xb8) return (p + 1, first - 0x80, false);
|
||||||
|
if (first < 0xc0) {
|
||||||
|
uint256 n = first - 0xb7;
|
||||||
|
return (p + 1 + n, readLen(b, p + 1, n), false);
|
||||||
|
}
|
||||||
|
if (first < 0xf8) return (p + 1, first - 0xc0, true);
|
||||||
|
uint256 m = first - 0xf7;
|
||||||
|
return (p + 1 + m, readLen(b, p + 1, m), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function headerLen(bytes memory b, uint256 off, uint256 len, bool isList) private pure returns (uint256) {
|
||||||
|
// the header length of an item whose payload starts at off: single bytes under 0x80 have none
|
||||||
|
if (!isList && len == 1 && uint8(b[off]) < 0x80) return 0;
|
||||||
|
if (len < 56) return 1;
|
||||||
|
uint256 n = 0;
|
||||||
|
uint256 l = len;
|
||||||
|
while (l > 0) {
|
||||||
|
n++;
|
||||||
|
l >>= 8;
|
||||||
|
}
|
||||||
|
return 1 + n;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readLen(bytes memory b, uint256 p, uint256 n) private pure returns (uint256 len) {
|
||||||
|
if (n == 0 || n > 32 || p + n > b.length) revert BadProof("rlp length");
|
||||||
|
for (uint256 i = 0; i < n; i++) {
|
||||||
|
len = (len << 8) | uint8(b[p + i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemCount(bytes memory b, uint256 off, uint256 len) private pure returns (uint256 n) {
|
||||||
|
uint256 p = off;
|
||||||
|
uint256 end = off + len;
|
||||||
|
while (p < end) {
|
||||||
|
(uint256 o, uint256 l,) = decode(b, p);
|
||||||
|
p = o + l;
|
||||||
|
n++;
|
||||||
|
}
|
||||||
|
if (p != end) revert BadProof("rlp list overrun");
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemAt(bytes memory b, uint256 off, uint256 index) private pure returns (uint256 o, uint256 l, bool isList) {
|
||||||
|
uint256 p = off;
|
||||||
|
for (uint256 i = 0; ; i++) {
|
||||||
|
(o, l, isList) = decode(b, p);
|
||||||
|
if (i == index) return (o, l, isList);
|
||||||
|
p = o + l;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toUint(bytes memory b, uint256 off, uint256 len) private pure returns (uint256 v) {
|
||||||
|
if (len > 32) revert BadProof("integer too long");
|
||||||
|
for (uint256 i = 0; i < len; i++) {
|
||||||
|
v = (v << 8) | uint8(b[off + i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toBytes32(bytes memory b, uint256 off) private pure returns (bytes32 v) {
|
||||||
|
assembly {
|
||||||
|
v := mload(add(add(b, 32), off))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function slice(bytes memory b, uint256 off, uint256 len) private pure returns (bytes memory out) {
|
||||||
|
if (off + len > b.length) revert BadProof("slice out of range");
|
||||||
|
out = new bytes(len);
|
||||||
|
for (uint256 i = 0; i < len; i++) {
|
||||||
|
out[i] = b[off + i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
144
contracts/bridge/test/Verifier.t.sol
Normal file
144
contracts/bridge/test/Verifier.t.sol
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
pragma solidity ^0.8.24;
|
||||||
|
|
||||||
|
import {Vm, VM_ADDRESS} from "./Vm.sol";
|
||||||
|
import {IgneumCertificateVerifier} from "../src/IgneumCertificateVerifier.sol";
|
||||||
|
import {BLS12381} from "../src/BLS12381.sol";
|
||||||
|
|
||||||
|
/// The verifier on Foundry's Prague EVM (the EIP-2537 precompiles): the synthetic vectors made by
|
||||||
|
/// test/vectors/gen.mjs (five keys, a certificate by four of them, a small account trie) and, when present, a real
|
||||||
|
/// certificate from the chain (test/vectors/chain.json, as /api/checkpoint serves it, decompressed by gen.mjs).
|
||||||
|
contract VerifierTest {
|
||||||
|
Vm constant vm = Vm(VM_ADDRESS);
|
||||||
|
|
||||||
|
string json;
|
||||||
|
IgneumCertificateVerifier v;
|
||||||
|
|
||||||
|
function setUp() public {
|
||||||
|
json = vm.readFile("test/vectors/synthetic.json");
|
||||||
|
v = new IgneumCertificateVerifier(vm.parseJsonString(json, ".chain_id"));
|
||||||
|
_install(v, json);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _install(IgneumCertificateVerifier target, string memory j) internal {
|
||||||
|
bytes[] memory keys = vm.parseJsonBytesArray(j, ".keys");
|
||||||
|
uint256[] memory w = vm.parseJsonUintArray(j, ".weights");
|
||||||
|
bytes memory packed;
|
||||||
|
uint64[] memory weights = new uint64[](w.length);
|
||||||
|
for (uint256 i = 0; i < keys.length; i++) {
|
||||||
|
packed = abi.encodePacked(packed, keys[i]);
|
||||||
|
weights[i] = uint64(w[i]);
|
||||||
|
}
|
||||||
|
target.installTable(uint64(vm.parseJsonUint(j, ".index")), packed, weights);
|
||||||
|
}
|
||||||
|
|
||||||
|
function test_vote_message_matches_the_node() public view {
|
||||||
|
bytes memory want = vm.parseJsonBytes(json, ".vote_message");
|
||||||
|
bytes memory got = v.voteMessage(uint64(vm.parseJsonUint(json, ".index")), vm.parseJsonBytes32(json, ".checkpoint"));
|
||||||
|
require(keccak256(want) == keccak256(got), "vote message");
|
||||||
|
}
|
||||||
|
|
||||||
|
function test_expand_message_xmd_known_answer() public view {
|
||||||
|
// RFC 9380 appendix K.1 (expand_message_xmd with SHA-256, DST "QUUX-V01-CS02-with-expander-SHA256-128"): the
|
||||||
|
// empty message at 32 bytes is the appendix's own first answer; the "abc" at 128 bytes answer comes from noble
|
||||||
|
bytes memory dst = bytes(vm.parseJsonString(json, ".xmd_dst"));
|
||||||
|
bytes memory out = BLS12381.expandMessageXmd("", dst, 32);
|
||||||
|
require(keccak256(out) == keccak256(hex"68a985b87eb6b46952128911f2a4412bbc302a9d759667f87f7a21d803f07235"), "xmd 32 (RFC)");
|
||||||
|
require(keccak256(out) == keccak256(vm.parseJsonBytes(json, ".xmd_empty_32")), "xmd 32 (noble)");
|
||||||
|
bytes memory out2 = BLS12381.expandMessageXmd("abc", dst, 128);
|
||||||
|
require(keccak256(out2) == keccak256(vm.parseJsonBytes(json, ".xmd_abc_128")), "xmd 128 (noble)");
|
||||||
|
}
|
||||||
|
|
||||||
|
function test_certificate_verifies() public view {
|
||||||
|
(bool ok, uint256 signed, uint256 total) = v.verifyCertificate(
|
||||||
|
uint64(vm.parseJsonUint(json, ".index")), vm.parseJsonBytes32(json, ".checkpoint"), vm.parseJsonBytes(json, ".bitmap"), vm.parseJsonBytes(json, ".signature")
|
||||||
|
);
|
||||||
|
require(ok, "certificate");
|
||||||
|
require(signed == vm.parseJsonUint(json, ".signed_weight") && total == vm.parseJsonUint(json, ".total_weight"), "weights");
|
||||||
|
}
|
||||||
|
|
||||||
|
function test_certificate_under_two_thirds_is_refused() public view {
|
||||||
|
(bool ok, uint256 signed,) = v.verifyCertificate(
|
||||||
|
uint64(vm.parseJsonUint(json, ".index")), vm.parseJsonBytes32(json, ".checkpoint"), vm.parseJsonBytes(json, ".weak_bitmap"), vm.parseJsonBytes(json, ".weak_signature")
|
||||||
|
);
|
||||||
|
require(!ok && signed * 3 < vm.parseJsonUint(json, ".total_weight") * 2, "weak certificate accepted");
|
||||||
|
}
|
||||||
|
|
||||||
|
function test_wrong_checkpoint_or_index_fails() public view {
|
||||||
|
bytes32 cp = vm.parseJsonBytes32(json, ".checkpoint");
|
||||||
|
uint64 index = uint64(vm.parseJsonUint(json, ".index"));
|
||||||
|
bytes memory bm = vm.parseJsonBytes(json, ".bitmap");
|
||||||
|
bytes memory sig = vm.parseJsonBytes(json, ".signature");
|
||||||
|
(bool ok1,,) = v.verifyCertificate(index, cp ^ bytes32(uint256(1)), bm, sig);
|
||||||
|
(bool ok2,,) = v.verifyCertificate(index + 1, cp, bm, sig);
|
||||||
|
require(!ok1 && !ok2, "forged certificate accepted");
|
||||||
|
// the right signers' weight with a bitmap naming a different signer set does not match the signature
|
||||||
|
bytes memory other = vm.parseJsonBytes(json, ".weak_bitmap");
|
||||||
|
other[0] = bytes1(uint8(other[0]) | 0x1f);
|
||||||
|
(bool ok3,,) = v.verifyCertificate(index, cp, other, sig);
|
||||||
|
require(!ok3, "wrong signer set accepted");
|
||||||
|
}
|
||||||
|
|
||||||
|
function test_submit_records_the_checkpoint() public {
|
||||||
|
bytes32 cp = vm.parseJsonBytes32(json, ".checkpoint");
|
||||||
|
uint64 index = uint64(vm.parseJsonUint(json, ".index"));
|
||||||
|
v.submitCertificate(index, cp, vm.parseJsonBytes(json, ".bitmap"), vm.parseJsonBytes(json, ".signature"));
|
||||||
|
require(v.isFinal(cp) && v.finalCheckpoint(index) == cp, "not recorded");
|
||||||
|
vm.expectRevert(abi.encodeWithSelector(IgneumCertificateVerifier.BadCertificate.selector, "certificate does not verify"));
|
||||||
|
v.submitCertificate(index, cp, vm.parseJsonBytes(json, ".weak_bitmap"), vm.parseJsonBytes(json, ".weak_signature"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function test_account_proof_present_and_absent() public view {
|
||||||
|
bytes32 root = vm.parseJsonBytes32(json, ".state_root");
|
||||||
|
(bool exists, uint256 nonce, uint256 balance, bytes32 sroot, bytes32 chash) =
|
||||||
|
v.verifyAccount(root, vm.parseJsonAddress(json, ".account"), vm.parseJsonBytesArray(json, ".account_proof"));
|
||||||
|
require(exists, "account absent");
|
||||||
|
require(nonce == vm.parseJsonUint(json, ".account_nonce") && balance == vm.parseJsonUint(json, ".account_balance"), "account fields");
|
||||||
|
require(sroot == vm.parseJsonBytes32(json, ".account_storage_root") && chash == vm.parseJsonBytes32(json, ".account_code_hash"), "account roots");
|
||||||
|
(bool exists2,,,,) = v.verifyAccount(root, vm.parseJsonAddress(json, ".absent_account"), vm.parseJsonBytesArray(json, ".absent_proof"));
|
||||||
|
require(!exists2, "absent account present");
|
||||||
|
}
|
||||||
|
|
||||||
|
function test_account_proof_against_a_wrong_root_reverts() public {
|
||||||
|
bytes32 root = vm.parseJsonBytes32(json, ".state_root") ^ bytes32(uint256(1));
|
||||||
|
bytes[] memory proof = vm.parseJsonBytesArray(json, ".account_proof");
|
||||||
|
address a = vm.parseJsonAddress(json, ".account");
|
||||||
|
vm.expectRevert(abi.encodeWithSelector(bytes4(keccak256("BadProof(string)")), "node hash mismatch"));
|
||||||
|
v.verifyAccount(root, a, proof);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A certificate the chain actually carried (test/vectors/chain.json; skipped when the file is absent).
|
||||||
|
function test_chain_certificate_verifies() public {
|
||||||
|
string memory j;
|
||||||
|
try vm.readFile("test/vectors/chain.json") returns (string memory s) {
|
||||||
|
j = s;
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
IgneumCertificateVerifier c = new IgneumCertificateVerifier(vm.parseJsonString(j, ".chain_id"));
|
||||||
|
_install(c, j);
|
||||||
|
(bool ok, uint256 signed, uint256 total) = c.verifyCertificate(
|
||||||
|
uint64(vm.parseJsonUint(j, ".index")), vm.parseJsonBytes32(j, ".checkpoint"), vm.parseJsonBytes(j, ".bitmap"), vm.parseJsonBytes(j, ".signature")
|
||||||
|
);
|
||||||
|
require(signed == vm.parseJsonUint(j, ".signed_weight") && total == vm.parseJsonUint(j, ".total_weight"), "chain weights");
|
||||||
|
require(ok, "the chain's certificate does not verify");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One Devnet 3 account under a real Devnet 3 state root (test/vectors/dn3-account.json from a node's eth_getProof;
|
||||||
|
/// skipped when the file is absent). The root is the chain's own; the link from a certified checkpoint to that root
|
||||||
|
/// is the gap the doc names.
|
||||||
|
function test_devnet3_account_balance_proven() public {
|
||||||
|
string memory j;
|
||||||
|
try vm.readFile("test/vectors/dn3-account.json") returns (string memory s) {
|
||||||
|
j = s;
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
(bool exists, uint256 nonce, uint256 balance, bytes32 sroot, bytes32 chash) = v.verifyAccount(
|
||||||
|
vm.parseJsonBytes32(j, ".state_root"), vm.parseJsonAddress(j, ".account"), vm.parseJsonBytesArray(j, ".account_proof")
|
||||||
|
);
|
||||||
|
require(exists, "the Devnet 3 account is absent under its root");
|
||||||
|
require(nonce == vm.parseJsonUint(j, ".account_nonce") && balance == vm.parseJsonUint(j, ".account_balance"), "Devnet 3 account fields");
|
||||||
|
require(sroot == vm.parseJsonBytes32(j, ".account_storage_root") && chash == vm.parseJsonBytes32(j, ".account_code_hash"), "Devnet 3 account roots");
|
||||||
|
}
|
||||||
|
}
|
||||||
34
contracts/bridge/test/Vm.sol
Normal file
34
contracts/bridge/test/Vm.sol
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
pragma solidity ^0.8.24;
|
||||||
|
|
||||||
|
/// The Foundry cheatcodes this project uses, declared here so the tree needs no remote dependency.
|
||||||
|
interface Vm {
|
||||||
|
function startBroadcast(uint256 privateKey) external;
|
||||||
|
function stopBroadcast() external;
|
||||||
|
function envUint(string calldata name) external view returns (uint256);
|
||||||
|
function envAddress(string calldata name) external view returns (address);
|
||||||
|
function envOr(string calldata name, string calldata defaultValue) external view returns (string memory);
|
||||||
|
function toString(address value) external pure returns (string memory);
|
||||||
|
function toString(uint256 value) external pure returns (string memory);
|
||||||
|
function toString(bytes32 value) external pure returns (string memory);
|
||||||
|
function toString(bytes calldata value) external pure returns (string memory);
|
||||||
|
function readFile(string calldata path) external view returns (string memory);
|
||||||
|
function writeFile(string calldata path, string calldata data) external;
|
||||||
|
function parseJsonBytes(string calldata json, string calldata key) external pure returns (bytes memory);
|
||||||
|
function parseJsonBytes32(string calldata json, string calldata key) external pure returns (bytes32);
|
||||||
|
function parseJsonUint(string calldata json, string calldata key) external pure returns (uint256);
|
||||||
|
function parseJsonString(string calldata json, string calldata key) external pure returns (string memory);
|
||||||
|
function parseJsonBytesArray(string calldata json, string calldata key) external pure returns (bytes[] memory);
|
||||||
|
function parseJsonUintArray(string calldata json, string calldata key) external pure returns (uint256[] memory);
|
||||||
|
function parseJsonAddress(string calldata json, string calldata key) external pure returns (address);
|
||||||
|
function keyExistsJson(string calldata json, string calldata key) external view returns (bool);
|
||||||
|
function deal(address who, uint256 newBalance) external;
|
||||||
|
function prank(address msgSender) external;
|
||||||
|
function startPrank(address msgSender) external;
|
||||||
|
function stopPrank() external;
|
||||||
|
function warp(uint256 newTimestamp) external;
|
||||||
|
function expectRevert(bytes calldata revertData) external;
|
||||||
|
function addr(uint256 privateKey) external pure returns (address);
|
||||||
|
}
|
||||||
|
|
||||||
|
address constant VM_ADDRESS = address(uint160(uint256(keccak256("hevm cheat code"))));
|
||||||
9
contracts/bridge/test/vectors/.gitignore
vendored
Normal file
9
contracts/bridge/test/vectors/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
node_modules
|
||||||
|
synthetic.json
|
||||||
|
chain.json
|
||||||
|
checkpoint-live.json
|
||||||
|
checkpoint-dn3.json
|
||||||
|
weights-dn3.json
|
||||||
|
checkpoints-dn3.json
|
||||||
|
dn3-table.json
|
||||||
|
dn3-account.json
|
||||||
116
contracts/bridge/test/vectors/gen.mjs
Normal file
116
contracts/bridge/test/vectors/gen.mjs
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
// Test vectors for the Igneum certificate verifier. Two sources:
|
||||||
|
// node gen.mjs synthetic > synthetic.json five vote keys made here (noble BLS12-381), a certificate signed by four
|
||||||
|
// of them over the Devnet 3 vote message, a small account trie with proofs
|
||||||
|
// node gen.mjs table <weights.json> > dn3-table.json the voter table alone from a node's igneum_getFinalityWeights answer
|
||||||
|
// (voters in the canonical order: sorted by key hash; weight = blocks)
|
||||||
|
// node gen.mjs account <getProof.json> <stateRoot> <blockNumber> > dn3-account.json an eth_getProof answer from a Devnet 3
|
||||||
|
// node (the reference-apps lane's reader) as the suite's real-root account vector
|
||||||
|
// node gen.mjs chain <checkpoint.json> > dn3.json a real certificate as igneum.network/api/checkpoint?source=dn3 serves it:
|
||||||
|
// the voter table and the aggregate signature decompressed to the
|
||||||
|
// precompiles' encodings (the verifier checks the same bytes the node signed)
|
||||||
|
// Encodings: field element 64 bytes (16 zero bytes then 48), G1 128 bytes, G2 256 bytes (x.c0, x.c1, y.c0, y.c1).
|
||||||
|
// The DST and the vote message follow consensus/core/src/finality.rs and site/verify/core.js.
|
||||||
|
import { bls12_381 } from '@noble/curves/bls12-381';
|
||||||
|
import { expand_message_xmd } from '@noble/curves/abstract/hash-to-curve';
|
||||||
|
import { sha256 } from '@noble/hashes/sha256';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { createRequire } from 'node:module';
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
|
||||||
|
const DST = 'IGNEUM_VOTE_V1_BLS12381G2_XMD:SHA-256_SSWU_RO_NUL_';
|
||||||
|
const CHAIN_ID = 'igneum-devnet-3';
|
||||||
|
const te = new TextEncoder();
|
||||||
|
const hex = b => '0x' + Array.from(b, x => x.toString(16).padStart(2, '0')).join('');
|
||||||
|
const unhex = h => Uint8Array.from(Buffer.from(h.replace(/^0x/, ''), 'hex'));
|
||||||
|
const be = (n, len) => { const out = new Uint8Array(len); let v = BigInt(n); for (let i = len - 1; i >= 0; i--) { out[i] = Number(v & 0xffn); v >>= 8n; } return out; };
|
||||||
|
const fe = n => { const out = new Uint8Array(64); out.set(be(n, 48), 16); return out; };
|
||||||
|
const concat = parts => { const n = parts.reduce((a, p) => a + p.length, 0); const out = new Uint8Array(n); let o = 0; for (const p of parts) { out.set(p, o); o += p.length; } return out; };
|
||||||
|
const u64le = n => { const out = new Uint8Array(8); let v = BigInt(n); for (let i = 0; i < 8; i++) { out[i] = Number(v & 0xffn); v >>= 8n; } return out; };
|
||||||
|
|
||||||
|
const G1 = bls12_381.G1.ProjectivePoint, G2 = bls12_381.G2.ProjectivePoint;
|
||||||
|
function g1Enc(p) { const a = p.toAffine(); return concat([fe(a.x), fe(a.y)]); }
|
||||||
|
function g2Enc(p) { const a = p.toAffine(); return concat([fe(a.x.c0), fe(a.x.c1), fe(a.y.c0), fe(a.y.c1)]); }
|
||||||
|
function voteMessage(index, checkpointHex) { return concat([te.encode('igneum-vote-v1/' + CHAIN_ID), new Uint8Array([0]), u64le(index), unhex(checkpointHex)]); }
|
||||||
|
function bitmapOf(positions, n) { const bm = new Uint8Array(Math.ceil(n / 8)); for (const p of positions) bm[p >> 3] |= 1 << (p & 7); return bm; }
|
||||||
|
|
||||||
|
async function synthetic() {
|
||||||
|
const sks = [1, 2, 3, 4, 5].map(i => { const s = new Uint8Array(32); s[31] = i; s[0] = 0x11 * i; return bls12_381.utils.randomPrivateKey ? bls12_381.G1.normPrivateKeyToScalar(s) : s; });
|
||||||
|
const keys = sks.map(sk => G1.BASE.multiply(sk));
|
||||||
|
const weights = [100, 250, 400, 300, 150];
|
||||||
|
const index = 1234, checkpoint = '0x' + 'ab'.repeat(32);
|
||||||
|
const msg = voteMessage(index, checkpoint);
|
||||||
|
const hm = bls12_381.G2.hashToCurve(msg, { DST });
|
||||||
|
const signers = [0, 1, 2, 3]; // 1,050 of 1,200: above two thirds
|
||||||
|
const weakSigners = [0, 2, 4]; // 650 of 1,200: under two thirds
|
||||||
|
const sign = who => who.map(i => hm.multiply(sks[i])).reduce((a, b) => a.add(b));
|
||||||
|
const sig = sign(signers), weak = sign(weakSigners);
|
||||||
|
// the account trie: three accounts, proofs for one present and one absent
|
||||||
|
const { Trie } = require('@ethereumjs/trie');
|
||||||
|
const { RLP } = require('@ethereumjs/rlp');
|
||||||
|
const { keccak256 } = require('ethereum-cryptography/keccak');
|
||||||
|
const trie = new Trie({ useKeyHashing: true });
|
||||||
|
const accounts = [
|
||||||
|
{ address: '0x07dd4dbca5c1a66755af28bacca1d901a2d209aa', nonce: 7n, balance: 999174011168718479514n, storageRoot: '0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421', codeHash: '0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470' },
|
||||||
|
{ address: '0x9a6fa842c4e58a87aef1f3ad15233d99283002b7', nonce: 1n, balance: 0n, storageRoot: '0x' + '11'.repeat(32), codeHash: '0x' + '22'.repeat(32) },
|
||||||
|
{ address: '0x53fe98022c2ac26d5d721457fb1c374b4d56144b', nonce: 3n, balance: 2580n * 10n ** 18n, storageRoot: '0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421', codeHash: '0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470' },
|
||||||
|
];
|
||||||
|
for (const a of accounts) {
|
||||||
|
const v = RLP.encode([a.nonce === 0n ? new Uint8Array() : be(a.nonce, Math.ceil(a.nonce.toString(2).length / 8)), a.balance === 0n ? new Uint8Array() : be(a.balance, Math.ceil(a.balance.toString(2).length / 8)), unhex(a.storageRoot), unhex(a.codeHash)]);
|
||||||
|
await trie.put(unhex(a.address), v);
|
||||||
|
}
|
||||||
|
const root = hex(trie.root());
|
||||||
|
const proofFor = async addr => (await trie.createProof(unhex(addr))).map(hex);
|
||||||
|
const absent = '0x000000000000000000000000000000000000dead';
|
||||||
|
return {
|
||||||
|
chain_id: CHAIN_ID, dst: DST, index, checkpoint,
|
||||||
|
keys: keys.map(k => hex(g1Enc(k))), weights, total_weight: weights.reduce((a, b) => a + b, 0),
|
||||||
|
bitmap: hex(bitmapOf(signers, keys.length)), signature: hex(g2Enc(sig)), signed_weight: signers.reduce((a, i) => a + weights[i], 0),
|
||||||
|
weak_bitmap: hex(bitmapOf(weakSigners, keys.length)), weak_signature: hex(g2Enc(weak)),
|
||||||
|
vote_message: hex(msg),
|
||||||
|
// RFC 9380 expand_message_xmd(SHA-256) answers, computed by noble, for the Solidity port's own check
|
||||||
|
xmd_dst: 'QUUX-V01-CS02-with-expander-SHA256-128',
|
||||||
|
xmd_abc_128: hex(expand_message_xmd(te.encode('abc'), te.encode('QUUX-V01-CS02-with-expander-SHA256-128'), 128, sha256)),
|
||||||
|
xmd_empty_32: hex(expand_message_xmd(new Uint8Array(), te.encode('QUUX-V01-CS02-with-expander-SHA256-128'), 32, sha256)),
|
||||||
|
state_root: root,
|
||||||
|
account: accounts[0].address, account_nonce: accounts[0].nonce.toString(), account_balance: accounts[0].balance.toString(),
|
||||||
|
account_storage_root: accounts[0].storageRoot, account_code_hash: accounts[0].codeHash,
|
||||||
|
account_proof: await proofFor(accounts[0].address),
|
||||||
|
absent_account: absent, absent_proof: await proofFor(absent),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function chain(file) {
|
||||||
|
const d = JSON.parse(readFileSync(file, 'utf8'));
|
||||||
|
const voters = d.voters.map(v => ({ key: hex(g1Enc(G1.fromHex(v.pubkey_hex.replace(/^0x/, '')))), weight: Math.round(Number(v.weight)) }));
|
||||||
|
const sig = G2.fromHex(d.certificate.aggregate_signature_hex.replace(/^0x/, ''));
|
||||||
|
const positions = []; const bm = unhex(d.certificate.bitmap_hex);
|
||||||
|
for (let p = 0; p < voters.length; p++) if (bm[p >> 3] & (1 << (p & 7))) positions.push(p);
|
||||||
|
return {
|
||||||
|
source: d.source, chain_id: d.chain_id, dst: DST, index: d.index, checkpoint: '0x' + d.hash,
|
||||||
|
keys: voters.map(v => v.key), weights: voters.map(v => v.weight), total_weight: voters.reduce((a, v) => a + v.weight, 0),
|
||||||
|
bitmap: '0x' + d.certificate.bitmap_hex, signature: hex(g2Enc(sig)), signed_weight: positions.reduce((a, p) => a + voters[p].weight, 0),
|
||||||
|
signers: positions.length, voters_at_index: d.voters_at_index, stored_at: d.stored_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function table(file) {
|
||||||
|
const d = JSON.parse(readFileSync(file, 'utf8'));
|
||||||
|
const r = d.result || d;
|
||||||
|
const voters = r.keys.filter(k => k.voter === true || k.voter === 'True').map(k => ({ keyHash: String(k.keyHash).replace(/^0x/, ''), key: hex(g1Enc(G1.fromHex(String(k.pubkey).replace(/^0x/, '')))), weight: Number(BigInt(k.blocks)) }));
|
||||||
|
voters.sort((a, b) => (a.keyHash < b.keyHash ? -1 : a.keyHash > b.keyHash ? 1 : 0));
|
||||||
|
const total = voters.reduce((a, v) => a + v.weight, 0);
|
||||||
|
return { chain_id: process.env.IGNEUM_CHAIN_ID || CHAIN_ID, dst: DST, index: Number(BigInt(r.checkpointIndex)), checkpoint_at_index: '0x' + String(r.checkpointHash).replace(/^0x/, ''), keys: voters.map(v => v.key), weights: voters.map(v => v.weight), total_weight: total, total_weight_node: Number(BigInt(r.totalWeight)), voters: voters.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
function account(file, stateRoot, blockNumber) {
|
||||||
|
const d = JSON.parse(readFileSync(file, 'utf8'));
|
||||||
|
const r = d.result || d;
|
||||||
|
return { chain_id: CHAIN_ID, state_root: stateRoot, block_number: Number(blockNumber), account: r.address, account_nonce: BigInt(r.nonce).toString(), account_balance: BigInt(r.balance).toString(), account_storage_root: r.storageHash, account_code_hash: r.codeHash, account_proof: r.accountProof };
|
||||||
|
}
|
||||||
|
|
||||||
|
const mode = process.argv[2];
|
||||||
|
if (mode === 'synthetic') synthetic().then(v => console.log(JSON.stringify(v, null, 1)));
|
||||||
|
else if (mode === 'chain') console.log(JSON.stringify(chain(process.argv[3]), null, 1));
|
||||||
|
else if (mode === 'table') console.log(JSON.stringify(table(process.argv[3]), null, 1));
|
||||||
|
else if (mode === 'account') console.log(JSON.stringify(account(process.argv[3], process.argv[4], process.argv[5]), null, 1));
|
||||||
|
else { console.error('usage: gen.mjs synthetic | table <weights.json> | account <getProof.json> <stateRoot> <blockNumber> | chain <checkpoint.json>'); process.exit(2); }
|
||||||
122
docs/bridge/light-client-bridge.md
Normal file
122
docs/bridge/light-client-bridge.md
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
# The light-client bridge primitive (Devnet 3 to Ethereum Sepolia), 8 October 2026
|
||||||
|
|
||||||
|
Devnet 3, test tokens, no value. This is a design plus one working contract. It moves nothing. It proves two things on
|
||||||
|
Sepolia and states, below, what it does not prove.
|
||||||
|
|
||||||
|
## What exists
|
||||||
|
|
||||||
|
| Piece | Where | What it does |
|
||||||
|
|---|---|---|
|
||||||
|
| The verifier contract | `contracts/bridge/src/IgneumCertificateVerifier.sol`, deployed on Sepolia (address in `docs/contracts/sepolia.json`) | holds a Devnet 3 voter table; verifies a finality certificate against it and records the checkpoint hash as final; verifies an Ethereum account proof against a state root |
|
||||||
|
| BLS12-381 | `contracts/bridge/src/BLS12381.sol` | hash-to-curve for G2 (RFC 9380, the node's DST), key aggregation in G1, the two-pairing check, all through the EIP-2537 precompiles |
|
||||||
|
| The account proof | `contracts/bridge/src/MerklePatricia.sol` | walks an eth_getProof-shaped proof (RLP nodes, hex-prefix paths, embedded children) to the account's RLP value or to its absence |
|
||||||
|
| The suite | `contracts/bridge/test/Verifier.t.sol`, vectors from `test/vectors/gen.mjs` | 9 tests on Foundry's Prague EVM: the vote message byte for byte, RFC 9380 expand_message_xmd answers, a certificate by four of five made-up keys, one under two thirds refused, forged index and checkpoint refused, the recorded checkpoint, an account present and an account absent under one root, a wrong root refused, and one certificate the chain actually carried |
|
||||||
|
| The vectors | `gen.mjs synthetic`, `gen.mjs table <weights.json>`, `gen.mjs chain <checkpoint.json>` | made-up keys and a three-account trie; the Devnet 3 voter table from a node's `igneum_getFinalityWeights`; a real certificate as `/api/checkpoint` serves it, keys and signature decompressed to the precompiles' encodings |
|
||||||
|
|
||||||
|
The suite ran green on build-3 (9 of 9) before the deploy; the Sepolia address and the deploy transactions are in
|
||||||
|
`docs/contracts/sepolia.json`.
|
||||||
|
|
||||||
|
## What the certificate check proves
|
||||||
|
|
||||||
|
A certificate is `(index, checkpoint hash, bitmap, aggregate signature)` as `consensus/core/src/finality.rs` writes it
|
||||||
|
into a block's coinbase (`Certificate::write`: index_le64, checkpoint, voter_count_le32, bitmap_len_le32, bitmap,
|
||||||
|
signature, aggregator, aggregator proof). The contract takes the first four fields; the signature is the 96-byte G2
|
||||||
|
point decompressed off chain to the precompiles' 256-byte form (a wrong decompression is a point the pairing
|
||||||
|
precompile refuses).
|
||||||
|
|
||||||
|
The contract holds a voter table installed by its deployer: the canonical voter list at one checkpoint index (every
|
||||||
|
key above dust and not stripped, sorted by key hash, as the node's `igneum_getFinalityWeights` reports it), each key a
|
||||||
|
128-byte uncompressed G1 point, each weight the key's blue blocks in the 30-day window. `tableId` is the keccak of
|
||||||
|
`(index, keys, weights)`.
|
||||||
|
|
||||||
|
`verifyCertificate(index, checkpoint, bitmap, signature)` holds exactly when:
|
||||||
|
|
||||||
|
1. bit `p` of the bitmap names voter `p` of the table, and the sum of the named voters' weights is at least two thirds
|
||||||
|
of the table's total weight (the rule decided 4 October 2026: lock = 2/3 of all 30-day weight; stricter than the
|
||||||
|
17/30 floor plus 2/3 of active that the node line still carries, so every certificate the node locks under the new
|
||||||
|
rule passes here and some the old rule locked would not);
|
||||||
|
2. the aggregate of the named keys (G1 additions) verifies the signature over the vote message
|
||||||
|
`"igneum-vote-v1/" || chain_id || 0x00 || index_le64 || checkpoint` under the domain separation tag
|
||||||
|
`IGNEUM_VOTE_V1_BLS12381G2_XMD:SHA-256_SSWU_RO_NUL_`, with `chain_id` the string the contract was built with
|
||||||
|
(`igneum-devnet-3`): e(aggregate key, H(message)) * e(-G1, signature) = 1.
|
||||||
|
|
||||||
|
So a recorded `finalCheckpoint(index)` means: the keys in the installed table that hold at least two thirds of that
|
||||||
|
table's weight signed this checkpoint hash at this index on this chain id. That is the finality rule's own statement,
|
||||||
|
checked with the node's own bytes, by a contract on another chain.
|
||||||
|
|
||||||
|
Measured on the test EVM: a 29-voter table costs about 5.3 million gas to install; a certificate with 12 signers
|
||||||
|
verifies in about 3.9 million gas (the pairing and the two map-to-curve calls dominate; a G1 addition per signer is
|
||||||
|
375 gas). On Sepolia at a 1 gwei tip that is under 0.01 ETH per certificate.
|
||||||
|
|
||||||
|
## What the account proof proves
|
||||||
|
|
||||||
|
`verifyAccount(stateRoot, account, proof)` walks an Ethereum account proof (the `eth_getProof` shape: RLP nodes from
|
||||||
|
the root, keys hashed with keccak, the hex-prefix leaf and extension paths, children by hash or embedded when under 32
|
||||||
|
bytes) and returns the account's nonce, balance, storage root and code hash, or `exists = false` when the trie shows the
|
||||||
|
account absent. This is the trie Igneum's executor commits to: `igneum/exec/src/state.rs` computes `stateRoot` with
|
||||||
|
`alloy_trie::root::state_root` over `keccak256(address)` and `RLP(nonce, balance, storage_root, code_hash)`, EIP-161
|
||||||
|
empty accounts left out, reth's layout, the same as Ethereum's.
|
||||||
|
|
||||||
|
So a verified account proof means: under this state root, this account has these fields.
|
||||||
|
|
||||||
|
## What is NOT proven, in order of weight
|
||||||
|
|
||||||
|
1. **The link from a certified checkpoint to a state root.** Nothing the contract verifies ties a checkpoint hash to an
|
||||||
|
EVM state root. The Kaspa-shaped header the voters sign has no execution root (its `utxo_commitment` is the UTXO
|
||||||
|
multiset, `accepted_id_merkle_root` the accepted transaction ids); the executor runs behind consensus as a follower
|
||||||
|
and the EVM block hash is the DAG block hash, not a hash of the EVM header. The binding that exists today is in the
|
||||||
|
coinbase of later blocks: the IGNS segment record (`BlockStatement.post_root` for its last chain block, signed by
|
||||||
|
the aggregator's vote key) and the proof records (`ProofRecord.statement` = keccak of a `ShardOutput` carrying
|
||||||
|
`pre_root` and `post_root`, signed by the prover's vote key), both under the carrier block's `hash_merkle_root`. A
|
||||||
|
carried record that fails a node's native veto is ignored rather than faulting the block, so even that binding
|
||||||
|
rests on the aggregator's and prover's keys and on the nodes' native check, not on a header field. Today a caller
|
||||||
|
gives the verifier a state root as a stated input beside a verified certificate; the contract does not know they
|
||||||
|
belong together. The reference-apps lane's oracle verifies the coinbase path (BLAKE2b header hash, the merkle
|
||||||
|
branch, the record parse) on Sepolia and shares this verifier for the certificate; the two together are the full
|
||||||
|
chain once the record's signature check lands there.
|
||||||
|
2. **The voter table itself.** The table is installed by the deployer from a node's `igneum_getFinalityWeights` read:
|
||||||
|
the keys, their canonical order (sorted by `BLAKE2b-256("IgneumVoteKeyHash", key)`, which the contract does not
|
||||||
|
recompute: no BLAKE2b on chain today) and their weights are trusted. A wrong table makes a wrong verdict in both
|
||||||
|
directions. A light client proper tracks the table from the chain: every weight is a count of blue blocks whose
|
||||||
|
headers name the key, so the table follows from headers; and the node's `FinalityWeights` RPC reports the table at
|
||||||
|
the certificate's own index (`voters_at_index`). The next step is a table update that takes a certified checkpoint
|
||||||
|
plus the headers between locks, the shape `/api/checkpoint` already serves to the browser verifier
|
||||||
|
(`site/verify/core.js` checks exactly that path, in JavaScript).
|
||||||
|
3. **The checkpoint index is a number the submitter gives.** The contract records one hash per index and refuses a
|
||||||
|
second, but it does not know the chain's current index; an old certificate at an old index verifies for ever
|
||||||
|
against the table it was signed under. A consumer should read `finalCheckpoint` at an index it already knows from
|
||||||
|
the chain, not treat the newest recorded index as the chain's tip.
|
||||||
|
4. **Equivocation and stripping.** The node strips a key's weight for 30 days on equivocation evidence. The installed
|
||||||
|
table carries the stripping as of its read and nothing after it.
|
||||||
|
5. **Proof of work, the DAG order, execution correctness.** None of it is checked here. The certificate's claim is the
|
||||||
|
voters' signature, and the voters are the miners who proved their blocks; the ZK proofs of execution (the shard
|
||||||
|
proofs the records name) are verified by the nodes, not by this contract. A proof-carrying bridge (the chain's SP1
|
||||||
|
shard proofs verified on Ethereum) is the design's end state and is not today's primitive.
|
||||||
|
6. **The state root's age and the account's current balance.** An account proof says what the balance was under that
|
||||||
|
root; the root is one block's. Nothing here prevents a stale root from being presented.
|
||||||
|
|
||||||
|
## How one balance gets proven on Sepolia today, and what each step rests on
|
||||||
|
|
||||||
|
| Step | Source | Rests on |
|
||||||
|
|---|---|---|
|
||||||
|
| the voter table at index N | a Devnet 3 node's `igneum_getFinalityWeights` | the node, the installer (trusted today) |
|
||||||
|
| the certificate for checkpoint C at index N | `/api/checkpoint?source=dn3` (the observer's `dn3_live_certificates`, the bytes a block carried) | verified on chain: the signature and the two-thirds rule |
|
||||||
|
| the state root R of chain block B | `eth_getBlockByNumber` on Devnet 3 | stated, not proven against C (gap 1) |
|
||||||
|
| the account proof for A under R | `eth_getProof` on the reference-apps lane's reader node (fork branch light-apps-node, the Devnet 3 pin plus a read-only RPC) | verified on chain against R |
|
||||||
|
|
||||||
|
The test `test_account_proof_present_and_absent` proves the account under a made-up root; the Devnet 3 account
|
||||||
|
proof against a real root goes into the suite as `test/vectors/dn3-account.json` the moment the reader node serves
|
||||||
|
`eth_getProof` (the proof shape is Ethereum's, so the contract needs no change).
|
||||||
|
|
||||||
|
## The design from here
|
||||||
|
|
||||||
|
1. The header path on chain: BLAKE2b-256 through the EIP-152 precompile for the keyed header hash and the key hashes,
|
||||||
|
the merkle branch to the coinbase, the segment and proof records parsed from the coinbase payload. This closes gap 1
|
||||||
|
and lets the contract recompute canonical order (gap 2's order).
|
||||||
|
2. The table update from certified headers: weights counted from the blue blocks between two locks, so the table
|
||||||
|
follows the chain instead of an installer. This closes gap 2.
|
||||||
|
3. A tip rule: the contract keeps the highest index it has recorded and a consumer reads only at or below it; the
|
||||||
|
relayer submits each lock as it lands (one transaction per 30-second checkpoint is affordable on Sepolia, not on
|
||||||
|
mainnet; mainnet gets one certificate per epoch).
|
||||||
|
4. The proof-carrying bridge: the shard proofs' aggregate verified on Ethereum (the prover network's own product),
|
||||||
|
which replaces trust in the native veto with a verified execution root.
|
||||||
22
docs/contracts/sepolia.json
Normal file
22
docs/contracts/sepolia.json
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
{
|
||||||
|
"network": "ethereum-sepolia",
|
||||||
|
"chain_id": 11155111,
|
||||||
|
"note": "The Igneum light-client bridge primitive: a verifier for Devnet 3 finality certificates and Ethereum account proofs. Devnet 3, test tokens, no value; Sepolia, test ether, no value. What it proves and what it does not: docs/bridge/light-client-bridge.md.",
|
||||||
|
"rpc": "https://ethereum-sepolia-rpc.publicnode.com",
|
||||||
|
"deployed_at": "2026-10-08T12:36:00Z",
|
||||||
|
"deployer": "0x0C896A191D6b76c37d704454b35B2D61276b7471",
|
||||||
|
"source": "contracts/bridge (Foundry, solc 0.8.28, evm_version osaka, via-IR, optimizer 200 runs)",
|
||||||
|
"contracts": {
|
||||||
|
"IgneumCertificateVerifier": {
|
||||||
|
"address": "0xAf74f3F512081291D663Bb1d6b6d37E99e37D744",
|
||||||
|
"create_tx": "0x55351cbbccda0f78311c22feb1174d99c5d8d9fe3488cb60ff8656f65007e079",
|
||||||
|
"create_block": 11869731,
|
||||||
|
"igneum_chain_id": "igneum-devnet-3",
|
||||||
|
"table": { "tx": "0x6ec1045dbc0abe981944e9e15987089d3f0cdc2e8e467f688b7b0c63e907b26b", "block": 11869732, "index": 2127, "voters": 29, "total_weight": 7164, "table_id": "0xabfcc2bc54ca92ec506beee8a44dced17f15cf1e7c518a6221bf2819b0d1cbd6", "read_from": "igneum_getFinalityWeights on a Devnet 3 node at checkpoint index 2127 (hash 0xcb21b52c…), 12:1x UTC 8 October 2026" },
|
||||||
|
"final_checkpoints": "none yet: the first Devnet 3 certificate is submitted when /api/checkpoint serves dn3_live_certificates",
|
||||||
|
"interface": "IIgneumCertificateVerifier in contracts/bridge/src/IgneumCertificateVerifier.sol: chainId(), tableId(), verifyCertificate(uint64 index, bytes32 checkpoint, bytes bitmap, bytes signature) view returns (bool ok, uint256 signedWeight, uint256 totalWeight), submitCertificate(...), finalCheckpoint(uint64) view returns (bytes32), isFinal(bytes32) view returns (bool), verifyAccount(bytes32 stateRoot, address account, bytes[] proof) pure returns (bool exists, uint256 nonce, uint256 balance, bytes32 storageRoot, bytes32 codeHash)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"first_deploy_note": "A first creation at 0xD7dd375E14F92A4CE4782040325189e6d6E394b6 (tx 0x4db10565…, block 11869633) ran out of gas at Foundry's Prague-spec estimate of 3,323,884; Sepolia runs Osaka pricing (EIP-7883 modexp), so the test EVM and the deploy now use osaka and the RPC's own estimate. That address holds no code.",
|
||||||
|
"tests": "contracts/bridge/test/Verifier.t.sol: 9 of 9 green on build-3 (Foundry, osaka), 12:3x UTC 8 October 2026"
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -127,3 +127,21 @@ site. None of this is in the repo; the stats API of this branch is the network s
|
||||||
price when ordered.
|
price when ordered.
|
||||||
- `EXPLORER_EVM_RPC` on Vercel: no public devnet EVM RPC exists, so balances show "no EVM RPC configured" on the devnet
|
- `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.
|
deployment; the testnet's `https://rpc.testnet.igneum.network` is the value for the testnet.
|
||||||
|
|
||||||
|
## 8. Devnet 3 explorer (8 October 2026, the explorer lane; the founder's order of 11:00 UK: a public explorer the same day)
|
||||||
|
|
||||||
|
What serves, all at igneum.network (the host explorer.igneum.network is a CNAME to the same Vercel project with a root rewrite to /explorer):
|
||||||
|
|
||||||
|
| Page | What it shows | Data |
|
||||||
|
|---|---|---|
|
||||||
|
| `/explorer` | the Devnet 3 strip (status, height, DAA, hash rate, chain rate, finality, program class, proving), the DAG picture (newest 150 blocks, one lane per miner, parents drawn, lock and paid marks), latest blocks (class, shards paid, final), latest transactions, the proving summary, the API list | `/api/explorer?stats=1`, `?dag=150`, `?blocks=50`, `?txs=30`, `?proving=1` |
|
||||||
|
| `/block/<hash>` or `/block/<number>` | header, parents, children, mergeset, coinbase, the EVM transactions it executed (from the indexer), the shard plan with each shard's state and the node's verdict, the checkpoint and certificate, the lock that covers it, the class; **the verified badge**: for a block under a lock the browser fetches `/api/checkpoint?index=N` and runs `site/verify/core.js` (header hashes recomputed, BLS keys aggregated, the signature checked over the weight rule) and the badge reads "verified under lock N in this browser" or the failure reason | `?block=`, `?height=`, `/api/checkpoint?index=` |
|
||||||
|
| `/tx/<hash>` | status, value, fee, gas, nonce, type, input, the receipt's Igneum section (miner tip, both burns, pgas, app share, including block and miner), the executing block's lock and shard state, the logs | `?tx=` |
|
||||||
|
| `/address/<addr>` | balance, nonce, code (contract or account), sent and received counts, the transactions of the last 7 days, the blocks mined and what they earned | `?address=` (`&before=<number>` pages the transactions) |
|
||||||
|
| `/proving` | the node's proving state, the newest 60 chain blocks with their shards by state and median lag, the provers of 24 h with payouts, the block-to-paid latency of the last hour (p50, p90) and the 24-hour totals by state | `?proving=1` |
|
||||||
|
|
||||||
|
The indexer: `tools/observer/explorer-indexer.mjs`, the observer's sibling on build-1 (unit `igneum-explorer-dn3`, User build, the same env file, `LIVE_TABLE_PREFIX=dn3_`, `IGNEUM_EVM_RPC` on loopback). It reads `eth_getBlockByNumber` with transactions and `eth_getBlockReceipts` per chain block, refreshes `eth_getBalance`, `eth_getTransactionCount` and `eth_getCode` for every address a block touches and for the observer's miners of the last 10 minutes, and keeps `igneum_getNodeInfo` (the class floors and every activation) in `dn3_explorer_state`. Tables `dn3_live_txs`, `dn3_live_accounts`, `dn3_explorer_blocks`, `dn3_explorer_state`; 7 days of transactions. Measured 10:04 to 10:07 UTC: 26,847 chain blocks and 71,218 transactions from genesis in 3 minutes; steady state 0.4 chain blocks per second at 16 percent of one core. Two classes met the same morning, each with its guard: the unit's first start read the first devnet's node for 25 s (the env file's `IGNEUM_EVM_RPC`), so the indexer now refuses any node whose `igneum_getNodeInfo.network` is not `EXPLORER_NETWORK`; and the observer node's restart at 10:21 UTC on 0324-5b673577 re-executed from block 0, so a tip below the indexed number re-reads from the tip and the API settles `chain_now` against the highest number ever indexed, not the current tip.
|
||||||
|
|
||||||
|
Read against the EVM: a block the observer called a chain block whose number the EVM later gave to another block (tip reorgs on Devnet 3 run to depth 18, `igneum_getBudgets.deepestReorg` at 10:0x UTC) reads "replaced" on the list and the block page. The table prefix default of `/api/explorer`, `/api/stats`, `/api/supply` and `/api/checkpoint` moved to `dn3_` (the live deploy had served the first devnet on those four while `/api/live` served Devnet 3).
|
||||||
|
|
||||||
|
Not yet: the STARK itself is not re-run in the browser (the node's verdict is shown as the node's; the explorer-verify branch's binding check of proof bytes to records is not merged); `eth_sendRawTransaction` and live balances from the public RPC (`rpc.devnet.igneum.network`, up since 11:08 UK) are not wired into the pages (`EXPLORER_EVM_RPC` on the deployment switches the balance read to live); a checkpoints list page; the program id per epoch (no RPC); internal transactions (no tracing RPC).
|
||||||
|
|
|
||||||
|
|
@ -3,15 +3,15 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
<title>Igneum address</title>
|
<title>Igneum Devnet 3 address</title>
|
||||||
<meta name="description" content="One Igneum address: the blocks it mined in the last day, what they earned, and its balance.">
|
<meta name="description" content="One Devnet 3 address: its balance, nonce and code, its transactions of the last seven days, the blocks it mined in the last day and what they earned.">
|
||||||
<link rel="canonical" href="https://igneum.network/address">
|
<link rel="canonical" href="https://igneum.network/address">
|
||||||
<meta name="robots" content="noindex">
|
<meta name="robots" content="noindex">
|
||||||
<meta name="theme-color" content="#0C0C0E">
|
<meta name="theme-color" content="#0C0C0E">
|
||||||
<meta property="og:type" content="website">
|
<meta property="og:type" content="website">
|
||||||
<meta property="og:site_name" content="Igneum">
|
<meta property="og:site_name" content="Igneum">
|
||||||
<meta property="og:title" content="Igneum address">
|
<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:description" content="One Devnet 3 address: its balance, nonce and code, its transactions of the last seven days, the blocks it mined in the last day and what they earned.">
|
||||||
<meta property="og:url" content="https://igneum.network/address">
|
<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" content="https://igneum.network/og-small.png?v=3">
|
||||||
<meta property="og:image:width" content="256">
|
<meta property="og:image:width" content="256">
|
||||||
|
|
@ -19,7 +19,7 @@
|
||||||
<meta property="og:image:alt" content="Igneum. Mined by GPUs. Proven by fire.">
|
<meta property="og:image:alt" content="Igneum. Mined by GPUs. Proven by fire.">
|
||||||
<meta name="twitter:card" content="summary">
|
<meta name="twitter:card" content="summary">
|
||||||
<meta name="twitter:title" content="Igneum address">
|
<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:description" content="One Devnet 3 address: its balance, nonce and code, its transactions of the last seven days, the blocks it mined in the last day and what they earned.">
|
||||||
<meta name="twitter:image" content="https://igneum.network/og-small.png?v=3">
|
<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.">
|
<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.ico" sizes="48x48">
|
||||||
|
|
@ -92,6 +92,12 @@ dd{margin:0;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}
|
||||||
@media (min-width:900px){.two{grid-template-columns:1fr 1fr}.two .card{margin-top:0}}
|
@media (min-width:900px){.two{grid-template-columns:1fr 1fr}.two .card{margin-top:0}}
|
||||||
.two-wrap{margin-top:var(--gap)}
|
.two-wrap{margin-top:var(--gap)}
|
||||||
main{padding-bottom:100px}.card{background:var(--row);border:1px solid var(--line);border-radius:16px;padding:25px;margin-top:24px}.card .viz-head h2{font-size:19px}.card .viz-head .eyebrow{margin:0}
|
main{padding-bottom:100px}.card{background:var(--row);border:1px solid var(--line);border-radius:16px;padding:25px;margin-top:24px}.card .viz-head h2{font-size:19px}.card .viz-head .eyebrow{margin:0}
|
||||||
|
.badge{display:inline-flex;align-items:center;gap:6px;font-family:var(--f-mono);font-size:11px;letter-spacing:.06em;text-transform:uppercase;padding:4px 9px;border-radius:999px;border:1px solid var(--line-2);color:var(--ink-2);margin:0 6px 6px 0}
|
||||||
|
.badge.ok{border-color:var(--green,#5CC48A);color:var(--green,#5CC48A)}.badge.hot{border-color:var(--molten);color:var(--molten)}.badge.bad{border-color:var(--excluded);color:var(--excluded)}.badge.wait{color:var(--ash)}
|
||||||
|
.bar{display:inline-block;width:72px;height:7px;border-radius:4px;background:var(--line);vertical-align:middle;margin-right:7px;overflow:hidden}.bar i{display:block;height:100%;background:var(--green,#5CC48A)}.bar i.part{background:var(--molten)}
|
||||||
|
.dagbox{position:relative;width:100%;height:320px;border:1px solid var(--line);border-radius:12px;background:var(--obsidian);overflow:hidden}.dagbox canvas{width:100%;height:100%;display:block;cursor:pointer}
|
||||||
|
.dagtip{position:absolute;pointer-events:none;font:11px/1.5 var(--mono);color:var(--bone);background:var(--graphite);border:1px solid var(--line-2);border-radius:8px;padding:8px 10px;max-width:300px;display:none}
|
||||||
|
.kv{color:var(--ash)}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
@ -131,8 +137,8 @@ main{padding-bottom:100px}.card{background:var(--row);border:1px solid var(--lin
|
||||||
</div>
|
</div>
|
||||||
<div class="nav-panel" id="nav-panel-network" data-group="network" role="region" aria-labelledby="nav-btn-network" hidden>
|
<div class="nav-panel" id="nav-panel-network" data-group="network" role="region" aria-labelledby="nav-btn-network" hidden>
|
||||||
<div class="panel-list">
|
<div class="panel-list">
|
||||||
<a href="/live" data-nav="live" aria-current="page"><b>Live devnet</b><span>The chain as it grows, one lane per miner.</span></a>
|
<a href="/live" data-nav="live"><b>Live devnet</b><span>The chain as it grows, one lane per miner.</span></a>
|
||||||
<a href="/explorer" data-nav="explorer"><b>Explorer</b><span>Look a block or an address up.</span></a>
|
<a href="/explorer" data-nav="explorer" aria-current="page"><b>Explorer</b><span>Look a block or an address up.</span></a>
|
||||||
<a href="/journey" data-nav="journey"><b>The journey</b><span>Six phases, four gates, the log behind them.</span></a>
|
<a href="/journey" data-nav="journey"><b>The journey</b><span>Six phases, four gates, the log behind them.</span></a>
|
||||||
<a href="/bench" data-nav="bench"><b>Engineering log</b><span>Every measurement, with the commands that made it.</span></a>
|
<a href="/bench" data-nav="bench"><b>Engineering log</b><span>Every measurement, with the commands that made it.</span></a>
|
||||||
<a href="/evidence" data-nav="evidence"><b>Evidence</b><span>Every claim and how far it is tested.</span></a>
|
<a href="/evidence" data-nav="evidence"><b>Evidence</b><span>Every claim and how far it is tested.</span></a>
|
||||||
|
|
@ -173,8 +179,8 @@ main{padding-bottom:100px}.card{background:var(--row);border:1px solid var(--lin
|
||||||
<a href="/metamask" data-nav="metamask"><b>Add to MetaMask</b><span>Igneum as a network in your wallet.</span></a>
|
<a href="/metamask" data-nav="metamask"><b>Add to MetaMask</b><span>Igneum as a network in your wallet.</span></a>
|
||||||
</div>
|
</div>
|
||||||
<div class="sheet-group"><div class="sheet-head">Network</div>
|
<div class="sheet-group"><div class="sheet-head">Network</div>
|
||||||
<a href="/live" data-nav="live" aria-current="page"><b>Live devnet</b><span>The chain as it grows, one lane per miner.</span></a>
|
<a href="/live" data-nav="live"><b>Live devnet</b><span>The chain as it grows, one lane per miner.</span></a>
|
||||||
<a href="/explorer" data-nav="explorer"><b>Explorer</b><span>Look a block or an address up.</span></a>
|
<a href="/explorer" data-nav="explorer" aria-current="page"><b>Explorer</b><span>Look a block or an address up.</span></a>
|
||||||
<a href="/journey" data-nav="journey"><b>The journey</b><span>Six phases, four gates, the log behind them.</span></a>
|
<a href="/journey" data-nav="journey"><b>The journey</b><span>Six phases, four gates, the log behind them.</span></a>
|
||||||
<a href="/bench" data-nav="bench"><b>Engineering log</b><span>Every measurement, with the commands that made it.</span></a>
|
<a href="/bench" data-nav="bench"><b>Engineering log</b><span>Every measurement, with the commands that made it.</span></a>
|
||||||
<a href="/evidence" data-nav="evidence"><b>Evidence</b><span>Every claim and how far it is tested.</span></a>
|
<a href="/evidence" data-nav="evidence"><b>Evidence</b><span>Every claim and how far it is tested.</span></a>
|
||||||
|
|
@ -252,7 +258,7 @@ main{padding-bottom:100px}.card{background:var(--row);border:1px solid var(--lin
|
||||||
<main id="main" class="wrap">
|
<main id="main" class="wrap">
|
||||||
<div class="head">
|
<div class="head">
|
||||||
<div class="breadcrumb"><a href="/">Igneum</a><span>/</span><a href="/explorer">Explorer</a><span>/</span><span>Address</span></div>
|
<div class="breadcrumb"><a href="/">Igneum</a><span>/</span><a href="/explorer">Explorer</a><span>/</span><span>Address</span></div>
|
||||||
<div class="eyebrow"><a href="/explorer">Explorer</a> · address</div>
|
<div class="eyebrow"><a href="/explorer">Devnet 3 explorer</a> · address</div>
|
||||||
<h1 class="hash" id="title">Address</h1>
|
<h1 class="hash" id="title">Address</h1>
|
||||||
<div id="chips"></div>
|
<div id="chips"></div>
|
||||||
<div class="msg" id="msg" aria-live="polite"></div>
|
<div class="msg" id="msg" aria-live="polite"></div>
|
||||||
|
|
@ -260,11 +266,19 @@ main{padding-bottom:100px}.card{background:var(--row);border:1px solid var(--lin
|
||||||
<div id="body" hidden>
|
<div id="body" hidden>
|
||||||
<div class="strip">
|
<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">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">Nonce</div><div class="v" id="a-nonce">n/a</div><div class="s" id="a-nonce-s">transactions sent from this account</div></div>
|
||||||
|
<div class="cell"><div class="k">Transactions, 7 d</div><div class="v" id="a-txs">0</div><div class="s" id="a-txs-s">sent and received, as indexed</div></div>
|
||||||
|
<div class="cell"><div class="k">Code</div><div class="v" id="a-code">n/a</div><div class="s" id="a-code-s">eth_getCode</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">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">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 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 class="cell"><div class="k">Network</div><div class="v">Devnet 3</div><div class="s">chain id 4463, coins have no value</div></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>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>Transactions</h2><div class="eyebrow" id="tx-eyebrow">newest 50 of the last 7 days</div></div>
|
||||||
|
<div class="tbl"><table><thead><tr><th>Transaction</th><th class="n">Block</th><th>Direction</th><th>Counterparty</th><th class="n">Value</th><th>Status</th><th>Time</th></tr></thead><tbody id="txs"></tbody></table></div>
|
||||||
|
<button class="btn" id="more-tx" type="button" style="margin-top:14px">Older transactions</button>
|
||||||
|
</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="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 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>
|
||||||
|
|
@ -347,26 +361,43 @@ const esc = s => String(s ?? '').replace(/&/g, '&').replace(/</g, '<').re
|
||||||
const link = h => `<a class="hash" href="/block/${esc(h)}" title="${esc(h)}">${esc(shortHash(h, 10, 6))}</a>`;
|
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 dl = pairs => pairs.filter(p => p).map(([k, v]) => `<dt>${esc(k)}</dt><dd>${v}</dd>`).join('');
|
||||||
const a = decodeURIComponent(location.pathname.split('/').pop() || '');
|
const a = decodeURIComponent(location.pathname.split('/').pop() || '');
|
||||||
|
let now = 0, oldestTx = 0, me = null;
|
||||||
|
function txRow(t) {
|
||||||
|
const out = me && t.from === me; const cp = out ? t.to : t.from;
|
||||||
|
return `<tr><td><a class="hash" href="/tx/${esc(t.hash)}">${esc(shortHash(t.hash, 10, 6))}</a></td><td class="n"><a href="/block/${esc(t.block_hash)}">${int(t.number)}</a></td><td>${out ? '<span class="chip">out</span>' : '<span class="chip ok">in</span>'}</td><td>${cp ? `<a class="hash" href="/address/${esc(cp)}" title="${esc(cp)}">${esc(shortHash(cp, 8, 6))}</a>` : (t.contract_address ? `<span class="kv">created</span> <a class="hash" href="/address/${esc(t.contract_address)}">${esc(shortHash(t.contract_address, 8, 6))}</a>` : '<span class="kv">none</span>')}</td><td class="n">${ignFromWei(t.value_wei, 4)} IGN</td><td>${t.status === 'success' ? '<span class="chip ok">ok</span>' : t.status === 'failed' ? '<span class="chip" style="border-color:var(--excluded);color:var(--excluded)">failed</span>' : '<span class="kv">no receipt</span>'}</td><td title="${t.ts ? new Date(t.ts).toISOString() : ''}">${t.ts ? rel(now - t.ts) : 'n/a'}</td></tr>`;
|
||||||
|
}
|
||||||
|
async function loadMore() {
|
||||||
|
if (!oldestTx) return;
|
||||||
|
const j = await fetch('/api/explorer?address=' + encodeURIComponent(a) + '&before=' + oldestTx).then(r => r.json());
|
||||||
|
if (!j.ok) return; $('txs').insertAdjacentHTML('beforeend', j.txs.map(txRow).join('')); if (j.txs.length) oldestTx = j.txs[j.txs.length - 1].number; else $('more-tx').disabled = true;
|
||||||
|
}
|
||||||
|
$('more-tx').addEventListener('click', () => { loadMore().catch(() => {}); });
|
||||||
async function load() {
|
async function load() {
|
||||||
const r = await fetch('/api/explorer?address=' + encodeURIComponent(a)); const j = await r.json();
|
const r = await fetch('/api/explorer?address=' + encodeURIComponent(a)); const j = await r.json();
|
||||||
$('title').textContent = a;
|
$('title').textContent = a;
|
||||||
if (!j.ok) { $('msg').textContent = j.error || 'Not found.'; return; }
|
if (!j.ok) { $('msg').textContent = j.error || 'Not found.'; return; }
|
||||||
document.title = `Igneum address ${shortHash(a, 8, 6)}`;
|
document.title = `Devnet 3 address ${shortHash(a, 8, 6)}`;
|
||||||
const now = new Date(j.now).getTime();
|
now = new Date(j.now).getTime(); me = j.evm_address; const ac = j.account;
|
||||||
$('chips').innerHTML = `<span class="chip">${j.kind === 'evm' ? 'EVM payout address' : 'coinbase address'}</span>` + (j.blocks_10m ? '<span class="chip hot">mining now</span>' : '');
|
$('chips').innerHTML = `<span class="chip">${j.kind === 'evm' ? (ac && ac.is_contract ? 'contract' : 'EVM account') : 'coinbase address'}</span>` + (j.blocks_10m ? '<span class="chip hot">mining now</span>' : '') + (ac && ac.miner ? '<span class="chip">miner payout address</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 || ''}`; }
|
if (j.balance.available) { $('a-balance').innerHTML = esc(ignFromWei(j.balance.wei, 4)) + '<small>IGN</small>'; $('a-balance-s').textContent = j.balance.source === 'indexer' ? `read by the indexer ${j.balance.refreshed_at ? rel(now - new Date(j.balance.refreshed_at).getTime()) : ''}` : `${j.balance.source}, live`; }
|
||||||
else { $('a-balance').textContent = 'n/a'; $('a-balance-s').textContent = j.balance.reason; }
|
else { $('a-balance').textContent = 'n/a'; $('a-balance-s').textContent = j.balance.reason; }
|
||||||
|
$('a-nonce').textContent = ac && ac.nonce !== null ? int(ac.nonce) : 'n/a';
|
||||||
|
$('a-txs').textContent = ac ? int((ac.sent || 0) + (ac.received || 0)) : '0'; $('a-txs-s').textContent = ac ? `${int(ac.sent)} sent, ${int(ac.received)} received, as indexed` : 'none indexed';
|
||||||
|
$('a-code').textContent = ac && ac.code_bytes !== null ? (ac.is_contract ? int(ac.code_bytes) + ' B' : 'none') : 'n/a'; $('a-code-s').textContent = ac && ac.is_contract ? 'a contract: bytes of code at this address' : 'an externally owned account';
|
||||||
$('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-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-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-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()) : '';
|
$('a-first').textContent = j.first_seen ? 'first in the window ' + rel(now - new Date(j.first_seen).getTime()) : '';
|
||||||
$('ident').innerHTML = dl([
|
$('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>'],
|
['EVM address', j.evm_address ? `<a class="hash" href="/address/${esc(j.evm_address)}">${esc(j.evm_address)}</a>` : '<span class="kv">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>'],
|
['Coinbase address', j.utxo_address ? `<a class="hash" href="/address/${esc(j.utxo_address)}">${esc(j.utxo_address)}</a>` : '<span class="kv">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'],
|
ac ? ['First and last seen', `${ac.first_seen_number === null ? 'no transaction indexed' : `chain block ${int(ac.first_seen_number)} to ${int(ac.last_seen_number)}`}`] : null,
|
||||||
['Earned, 24 h', `${ign(j.earned_24h_sompi, 8)} IGN <span style="color:var(--ash)">${esc(j.earned_note)}</span>`],
|
['Vote keys', j.miner_ids.length ? j.miner_ids.map(m => `<span class="id">${esc(m.id)}</span> <span class="kv">${m.blocks}</span>`).join(' ') : 'none'],
|
||||||
|
['Earned, 24 h', `${ign(j.earned_24h_sompi, 8)} IGN <span class="kv">${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>';
|
$('txs').innerHTML = j.txs.length ? j.txs.map(txRow).join('') : `<tr><td colspan="7" class="empty">${j.evm_address ? 'No transaction indexed for this address in the last 7 days.' : 'A coinbase address has no EVM transactions; its EVM payout address does.'}</td></tr>`;
|
||||||
|
if (j.txs.length) oldestTx = j.txs[j.txs.length - 1].number; else $('more-tx').hidden = true;
|
||||||
|
$('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 class="kv">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) + ' IGN' : '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 block mined by this address in the last 24 hours.</td></tr>';
|
||||||
$('body').hidden = false;
|
$('body').hidden = false;
|
||||||
}
|
}
|
||||||
load().catch(e => { $('msg').textContent = 'The API did not answer: ' + e.message; });
|
load().catch(e => { $('msg').textContent = 'The API did not answer: ' + e.message; });
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,10 @@ export function neon(url = process.env.DATABASE_URL) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
export const num = v => (v === null || v === undefined ? null : Number(v));
|
export const num = v => (v === null || v === undefined ? null : Number(v));
|
||||||
export const tablePrefix = () => (process.env.LIVE_TABLE_PREFIX || '').replace(/[^a-z0-9_]/gi, '');
|
// LIVE_TABLE_PREFIX names the observer whose tables are read; unset means dn3_ (Devnet 3, the igneum-observer-dn3 unit on the build
|
||||||
|
// box, the same default as live.mjs since 8 October 2026: before that the explorer, stats and supply read the first devnet's tables
|
||||||
|
// while /api/live read Devnet 3). An empty string reads the unprefixed tables.
|
||||||
|
export const tablePrefix = () => (process.env.LIVE_TABLE_PREFIX === undefined ? 'dn3_' : 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). */
|
/** EVM chain id from the network name (docs/design/execution-layer.md 8.1: 4461 mainnet, 4462 testnet, 4463 devnet). */
|
||||||
export function chainIdOf(network) {
|
export function chainIdOf(network) {
|
||||||
const n = String(network || '');
|
const n = String(network || '');
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,10 @@
|
||||||
// selected-chain headers from the previous locked checkpoint to this one. Read from what tools/observer wrote to
|
// selected-chain headers from the previous locked checkpoint to this one. Read from what tools/observer wrote to
|
||||||
// Neon (table live_certificates, or fintest_live_certificates for the test network).
|
// Neon (table live_certificates, or fintest_live_certificates for the test network).
|
||||||
//
|
//
|
||||||
// ?source=live the live devnet (default; falls back to the test network while the live chain has no lock yet)
|
// ?source=live Devnet 3 (default: the dn3_ tables, or LIVE_TABLE_PREFIX; falls back to the test network while the chain has no lock yet)
|
||||||
// ?source=test tonight's finality test network (igneum-devnet-7)
|
// ?source=dn3 the same as live (the Devnet 3 name, for callers that want to say so)
|
||||||
// ?source=dn3 Devnet 3 (igneum-devnet-3, the dn3_ tables the Devnet 3 observer writes; the reference apps read it)
|
// ?source=test the finality test network's tables (fintest_live_certificates)
|
||||||
|
// ?index=N one certified checkpoint by index instead of the newest (the explorer's per-block re-check, 8 October 2026)
|
||||||
//
|
//
|
||||||
// Zero dependencies: Neon's HTTP SQL endpoint over Node's built-in fetch, like live.mjs.
|
// Zero dependencies: Neon's HTTP SQL endpoint over Node's built-in fetch, like live.mjs.
|
||||||
|
|
||||||
|
|
@ -30,9 +31,10 @@ function neon() {
|
||||||
|
|
||||||
const num = v => (v === null || v === undefined ? null : Number(v));
|
const num = v => (v === null || v === undefined ? null : Number(v));
|
||||||
|
|
||||||
async function latest(sql, table) {
|
const tablePrefix = () => (process.env.LIVE_TABLE_PREFIX === undefined ? 'dn3_' : process.env.LIVE_TABLE_PREFIX).replace(/[^a-z0-9_]/gi, '');
|
||||||
|
async function latest(sql, table, index = null) {
|
||||||
// A table the observer has not created yet (the live chain before the cut-over) reads as "no certificate"
|
// A table the observer has not created yet (the live chain before the cut-over) reads as "no certificate"
|
||||||
const rows = await sql(`SELECT * FROM ${table} ORDER BY index DESC LIMIT 1`).catch(() => []);
|
const rows = await (index === null ? sql(`SELECT * FROM ${table} ORDER BY index DESC LIMIT 1`) : sql(`SELECT * FROM ${table} WHERE index = $1 LIMIT 1`, [index])).catch(() => []);
|
||||||
return rows[0] || null;
|
return rows[0] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -90,11 +92,12 @@ export async function earliestCheckpointAbove(sql, number, chainNumberOf, table
|
||||||
return full[0] ? shape(full[0], 'dn3') : null;
|
return full[0] ? shape(full[0], 'dn3') : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The latest certified checkpoint of `want` ('live', 'test' or 'dn3') through `sql`, shaped for the verifier, or null. */
|
/** The latest certified checkpoint of `want` ('live', 'test' or 'dn3') through `sql`, shaped for the verifier, or null
|
||||||
|
* (the reference apps' read service calls this off Vercel: 'dn3' names the dn3_ tables whatever LIVE_TABLE_PREFIX says). */
|
||||||
export async function readCheckpoint(sql, want = 'live') {
|
export async function readCheckpoint(sql, want = 'live') {
|
||||||
let row = null, source = null;
|
let row = null, source = null;
|
||||||
if (want === 'dn3') { row = await latest(sql, 'dn3_live_certificates'); if (row) source = 'dn3'; }
|
if (want === 'dn3') { row = await latest(sql, 'dn3_live_certificates'); if (row) source = 'dn3'; }
|
||||||
if (!row && want !== 'test' && want !== 'dn3') { row = await latest(sql, 'live_certificates'); if (row) source = 'live'; }
|
if (!row && want !== 'test' && want !== 'dn3') { row = await latest(sql, `${tablePrefix()}live_certificates`); if (row) source = 'live'; }
|
||||||
if (!row && want !== 'dn3') { row = await latest(sql, 'fintest_live_certificates'); if (row) source = 'test'; }
|
if (!row && want !== 'dn3') { row = await latest(sql, 'fintest_live_certificates'); if (row) source = 'test'; }
|
||||||
return row ? shape(row, source) : null;
|
return row ? shape(row, source) : null;
|
||||||
}
|
}
|
||||||
|
|
@ -110,8 +113,11 @@ export default async function handler(req, res) {
|
||||||
try {
|
try {
|
||||||
const sql = neon();
|
const sql = neon();
|
||||||
const want = String((req.query && req.query.source) || 'live');
|
const want = String((req.query && req.query.source) || 'live');
|
||||||
const cp = await readCheckpoint(sql, want);
|
const idx = req.query && req.query.index !== undefined && /^\d{1,12}$/.test(String(req.query.index)) ? Number(req.query.index) : null;
|
||||||
if (!cp) {
|
let row = null, source = null;
|
||||||
|
if (want !== 'test') { row = await latest(sql, `${tablePrefix()}live_certificates`, idx); if (row) source = 'live'; }
|
||||||
|
if (!row && idx === null) { row = await latest(sql, 'fintest_live_certificates'); if (row) source = 'test'; }
|
||||||
|
if (!row) {
|
||||||
res.setHeader('Cache-Control', 'no-store');
|
res.setHeader('Cache-Control', 'no-store');
|
||||||
return res.status(404).json({ ok: false, error: 'no certified checkpoint stored yet' });
|
return res.status(404).json({ ok: false, error: 'no certified checkpoint stored yet' });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,47 @@
|
||||||
// Igneum DAG explorer, server half. GET /api/explorer with one of:
|
// Igneum Devnet 3 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
|
// ?blocks=N[&before=<ms>] the latest N blocks (10 to 200, default 50), older than `before` when given; each with its shard
|
||||||
// ?block=<hash> one block: row, header detail, parents, mergeset, transactions, proof records, shards,
|
// summary (planned, paid), its lock state and its program class
|
||||||
// checkpoint and certificate when it is one, children, the chain block that merged it
|
// ?block=<hash> one block: row, header detail, parents, mergeset, transactions (from the indexer), proof records,
|
||||||
|
// shards, checkpoint and certificate when it is one, children, the chain block that merged it,
|
||||||
|
// the lock that covers it, its class
|
||||||
// ?height=N the chain block with that number (the EVM block number), same shape as ?block
|
// ?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
|
// ?tx=<hash> one EVM transaction: the indexed row (receipt, logs, the igneum fee split) and its block's state
|
||||||
// balance through eth_getBalance when EXPLORER_EVM_RPC names a reachable EVM JSON-RPC
|
// ?txs=N[&before=<number>] the latest N transactions (10 to 200, default 50), from chain blocks numbered below `before`
|
||||||
|
// ?address=<0x.. | igneum..:bech32> the account (balance, nonce, code, from the indexer), its transactions, the blocks it
|
||||||
|
// mined, 24-hour and 10-minute counts, what those blocks earned; the balance comes live through
|
||||||
|
// eth_getBalance when EXPLORER_EVM_RPC names a reachable EVM JSON-RPC, else from the indexer's read
|
||||||
|
// ?proving=1 the proving view: the node's proving state, the last 60 chain blocks with their shards, the
|
||||||
|
// provers of the last 24 hours with their payouts, the block-to-paid latency
|
||||||
|
// ?stats=1 the network: hash rate, difficulty, DAA, blue score, chain rate, voters and weight, the newest
|
||||||
|
// lock, the class floor and every activation the node reports, the indexer's state
|
||||||
|
// ?dag=N the last N blocks (20 to 400, default 120) with parents, colour, miner, shards and lock, for the DAG view
|
||||||
// ?search=<q> routes a hash, a height or an address to its page, or says what it is not
|
// ?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.
|
// Everything is read from what tools/observer and tools/observer/explorer-indexer wrote to Neon (table prefix dn3_ by default,
|
||||||
|
// LIVE_TABLE_PREFIX otherwise); the only live call is the balance. Cached 5 s. Every answer names the network: Devnet 3.
|
||||||
import { neon, num, tablePrefix, chainIdOf } from './_neon.mjs';
|
import { neon, num, tablePrefix, chainIdOf } from './_neon.mjs';
|
||||||
import { classify } from '../lib/explorer.mjs';
|
import { classify } from '../lib/explorer.mjs';
|
||||||
|
|
||||||
const STALE_AFTER_S = 30;
|
const STALE_AFTER_S = 30;
|
||||||
|
export const NETWORK_LABEL = 'Devnet 3';
|
||||||
const ROW = `hash, number, blue_score, daa_score, timestamp_ms, parents, parent_hashes, is_chain_block, vote_key_hash, miner_address, evm_miner, engine,
|
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`;
|
tx_count, proof_records, subsidy_sompi, paid_sompi, selected_parent, color, received_at`;
|
||||||
|
// the EVM's own hash for the block's number (the indexer's explorer_blocks): a block the observer called a chain block that a tip
|
||||||
|
// reorg later replaced keeps its number in live_blocks, so the page reads it against this and says "replaced" (Devnet 3 reorgs at the
|
||||||
|
// tip run to depth 18, igneum_getBudgets.deepestReorg read 8 October 2026 10:0x UTC)
|
||||||
|
const evmHash = T => `(SELECT e.hash FROM ${T}explorer_blocks e WHERE e.number = b.number LIMIT 1) AS evm_hash`;
|
||||||
|
const TXROW = `hash, block_hash, block_number, tx_index, from_addr, to_addr, value_wei, nonce, gas, gas_price, gas_used, effective_gas_price, status,
|
||||||
|
contract_address, input_bytes, selector, logs_count, tx_type, ts_ms`;
|
||||||
|
|
||||||
|
/** The program class a block of this DAA score was mined under, from the node's parameters (program_class_v5_activation_daa). */
|
||||||
|
export function classAt(daa, params) {
|
||||||
|
const p = params || {};
|
||||||
|
const floors = Object.entries(p).filter(([k]) => /^program_class_v(\d+)_activation_daa$/.test(k)).map(([k, v]) => ({ v: Number(k.match(/v(\d+)/)[1]), daa: Number(v) })).filter(x => Number.isFinite(x.daa)).sort((a, b) => a.v - b.v);
|
||||||
|
let cls = 4; // class v4 is the floor of Devnet 3 (every activation at 0 in its network object)
|
||||||
|
for (const f of floors) if (daa !== null && daa !== undefined && daa >= f.daa && f.v > cls) cls = f.v;
|
||||||
|
return { class: `v${cls}`, floors: floors.map(f => ({ class: `v${f.v}`, daa: f.daa })) };
|
||||||
|
}
|
||||||
|
|
||||||
|
let settledBelow = -1; // numbers above this are still moving at the tip; chain_now is left null for them (set per request from the indexer's number)
|
||||||
function row(b) {
|
function row(b) {
|
||||||
return {
|
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(),
|
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(),
|
||||||
|
|
@ -21,8 +49,28 @@ function row(b) {
|
||||||
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,
|
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),
|
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,
|
paid_sompi: b.paid_sompi === null || b.paid_sompi === undefined ? null : String(b.paid_sompi), selected_parent: b.selected_parent || null,
|
||||||
|
// chain_now: still the chain block of its number by the EVM's reading (null when the indexer has not reached the number)
|
||||||
|
chain_now: b.is_chain_block ? (b.evm_hash === undefined || b.evm_hash === null || num(b.number) > settledBelow ? null : b.evm_hash === b.hash) : false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
function txrow(t) {
|
||||||
|
return {
|
||||||
|
hash: t.hash, block_hash: t.block_hash, number: num(t.block_number), index: num(t.tx_index), from: t.from_addr, to: t.to_addr, value_wei: t.value_wei === null || t.value_wei === undefined ? '0' : String(t.value_wei),
|
||||||
|
nonce: num(t.nonce), gas: num(t.gas), gas_price: t.gas_price === null || t.gas_price === undefined ? null : String(t.gas_price), gas_used: num(t.gas_used),
|
||||||
|
effective_gas_price: t.effective_gas_price === null || t.effective_gas_price === undefined ? null : String(t.effective_gas_price),
|
||||||
|
status: t.status === null || t.status === undefined ? null : (Number(t.status) === 1 ? 'success' : 'failed'), contract_address: t.contract_address || null,
|
||||||
|
input_bytes: num(t.input_bytes), selector: t.selector || null, logs_count: num(t.logs_count), type: num(t.tx_type), ts: num(t.ts_ms),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const shardSummary = s => ({ planned: num(s.planned) || 0, proving: num(s.proving) || 0, verified: num(s.verified) || 0, paid: num(s.paid) || 0, total: num(s.total) || 0, median_lag: s.median_lag === null || s.median_lag === undefined ? null : Number(s.median_lag) });
|
||||||
|
|
||||||
|
/** Lock state of a block against the newest lock: a chain block at or under the lock's blue score is final; a merged block is final with its merger. */
|
||||||
|
function lockState(b, lock, mergerBlue) {
|
||||||
|
if (!lock) return { final: false, reason: 'no lock yet' };
|
||||||
|
if (b.chain) return b.blue_score <= lock.blue_score ? { final: true, index: lock.index, reason: `chain block under lock ${lock.index}` } : { final: false, index: lock.index, reason: `above lock ${lock.index} (blue score ${lock.blue_score})` };
|
||||||
|
if (mergerBlue !== null && mergerBlue !== undefined) return mergerBlue <= lock.blue_score ? { final: true, index: lock.index, reason: `merged by a chain block under lock ${lock.index}` } : { final: false, index: lock.index, reason: `merged above lock ${lock.index}` };
|
||||||
|
return { final: false, index: lock.index, reason: 'not merged yet' };
|
||||||
|
}
|
||||||
|
|
||||||
async function evmCall(url, method, params) {
|
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 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) });
|
||||||
|
|
@ -41,10 +89,22 @@ export function createHandler({ env = process.env, sql, evm = evmCall } = {}) {
|
||||||
const EVM = env.EXPLORER_EVM_RPC || null;
|
const EVM = env.EXPLORER_EVM_RPC || null;
|
||||||
try {
|
try {
|
||||||
sql = sql || neon(env.DATABASE_URL);
|
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 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;
|
(SELECT row_to_json(x) FROM ${T}explorer_state x WHERE x.id = 1) AS indexer, (SELECT max(number) FROM ${T}explorer_blocks) AS indexed_max`).catch(async e => {
|
||||||
|
if (/explorer_state/.test(String(e.message))) return sql(`SELECT now() AS now, (SELECT row_to_json(s) FROM ${T}live_state s WHERE s.id = 1) AS state, NULL AS indexer`);
|
||||||
|
throw e;
|
||||||
|
});
|
||||||
|
const now = new Date(head[0].now).getTime(); const s = head[0].state || null; const ix = head[0].indexer || null;
|
||||||
const updated = s && s.updated_at ? new Date(s.updated_at).getTime() : 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 };
|
const params = ix && ix.node_info && ix.node_info.params ? ix.node_info.params : null;
|
||||||
|
const indexer = ix ? { indexed_number: num(ix.indexed_number), tip_number: num(ix.tip_number), backlog: num(ix.backlog), txs_total: num(ix.txs_total), accounts_total: num(ix.accounts_total), blocks_per_s: num(ix.blocks_per_s), updated_at: ix.updated_at, stale: !ix.updated_at || (now - new Date(ix.updated_at).getTime()) / 1000 > STALE_AFTER_S, last_error: ix.last_error || null } : { stale: true, reason: 'the explorer indexer has not written yet' };
|
||||||
|
// the highest number the indexer has ever read, less three: a node re-syncing from a snapshot reports a low tip for a while
|
||||||
|
// (10:21 UTC on 8 October 2026: the observer node restarted and its tip read 1,981 against 27,145 indexed) and must not unsettle the table
|
||||||
|
settledBelow = head[0].indexed_max === null || head[0].indexed_max === undefined ? -1 : Number(head[0].indexed_max) - 3;
|
||||||
|
const base = { ok: true, now: new Date(now).toISOString(), network: s ? s.network : null, network_label: NETWORK_LABEL, chain_id: ix && ix.chain_id ? num(ix.chain_id) : (s ? chainIdOf(s.network) : null), stale: updated === null || (now - updated) / 1000 > STALE_AFTER_S, evm_rpc_configured: !!EVM, indexer };
|
||||||
|
const lockRow = await sql(`SELECT index, hash, blue_score, daa_score, fraction_total, voters, locked_at FROM ${T}live_checkpoints WHERE state = 'locked' ORDER BY index DESC LIMIT 1`).catch(() => []);
|
||||||
|
const lock = lockRow[0] ? { index: num(lockRow[0].index), hash: lockRow[0].hash, blue_score: num(lockRow[0].blue_score), daa: num(lockRow[0].daa_score), fraction_total: num(lockRow[0].fraction_total), voters: num(lockRow[0].voters), locked_at: lockRow[0].locked_at } : null;
|
||||||
|
base.lock = lock;
|
||||||
|
|
||||||
// ---- search ----
|
// ---- search ----
|
||||||
if (q.has('search')) {
|
if (q.has('search')) {
|
||||||
|
|
@ -58,20 +118,110 @@ export function createHandler({ env = process.env, sql, evm = evmCall } = {}) {
|
||||||
}
|
}
|
||||||
const b = await sql(`SELECT hash FROM ${T}live_blocks WHERE hash = $1`, [c.value]);
|
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}` });
|
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 }])]);
|
const t = await sql(`SELECT hash FROM ${T}live_txs WHERE hash = $1`, ['0x' + c.value]).catch(() => []);
|
||||||
if (t.length) return res.status(200).json({ ...base, type: 'tx', href: `/block/${t[0].hash}#tx-${c.value}` });
|
if (t.length) return res.status(200).json({ ...base, type: 'tx', href: `/tx/0x${c.value}` });
|
||||||
return res.status(404).json({ ...base, ok: false, error: 'No block or transaction with that hash in the last 24 hours.' });
|
return res.status(404).json({ ...base, ok: false, error: 'No block in the last 24 hours and no transaction in the last 7 days with that hash.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- stats ----
|
||||||
|
if (q.has('stats')) {
|
||||||
|
const [chain10, fin] = await Promise.all([
|
||||||
|
sql(`SELECT count(*)::int AS blocks_10m, count(*) FILTER (WHERE is_chain_block)::int AS chain_10m, count(DISTINCT vote_key_hash)::int AS miners_10m, max(daa_score) AS daa, max(number) AS height FROM ${T}live_blocks WHERE received_at > now() - interval '10 minutes'`),
|
||||||
|
Promise.resolve(s && s.finality ? s.finality : null),
|
||||||
|
]);
|
||||||
|
const c = chain10[0] || {};
|
||||||
|
const w = fin && fin.weights ? fin.weights : null;
|
||||||
|
const activations = params ? Object.entries(params).filter(([k, v]) => /_activation_daa$/.test(k) && Number.isFinite(Number(v))).map(([k, v]) => ({ name: k.replace(/_activation_daa$/, ''), daa: Number(v) })) : [];
|
||||||
|
return res.status(200).json({
|
||||||
|
...base,
|
||||||
|
node_version: s ? s.node_version : null, height: num(c.height), daa: num(c.daa), blue_score: s ? num(s.blue_score) : null, block_count: s ? num(s.block_count) : null,
|
||||||
|
difficulty: s ? num(s.difficulty) : null, hashrate: s ? num(s.hashes_per_second_estimate) : null, peers: s ? num(s.peers) : null, mempool: s ? num(s.mempool) : null,
|
||||||
|
blocks_10m: num(c.blocks_10m), chain_blocks_10m: num(c.chain_10m), blocks_per_s_10m: c.blocks_10m === null ? null : Math.round(Number(c.blocks_10m) / 600 * 100) / 100, chain_blocks_per_s_10m: c.chain_10m === null ? null : Math.round(Number(c.chain_10m) / 600 * 100) / 100,
|
||||||
|
miners_10m: num(c.miners_10m),
|
||||||
|
finality: fin ? { active: !!fin.finality_active, voters: w ? num(w.voters) : null, total_weight: w ? num(w.total_weight) : null, active_weight: w ? num(w.active_weight) : null, keys: w && Array.isArray(w.keys) ? w.keys.length : null, latest_locked_index: num(fin.latest_locked_index), latest_locked_blue_score: num(fin.latest_locked_blue_score), params: fin.params || null } : null,
|
||||||
|
proving: s && s.proving ? s.proving : null,
|
||||||
|
class: classAt(num(c.daa), params), activations, digest: ix && ix.node_info ? ix.node_info.digest : null, genesis: ix && ix.node_info ? ix.node_info.genesis : null,
|
||||||
|
budgets: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- proving view ----
|
||||||
|
if (q.has('proving')) {
|
||||||
|
const [blocks, provers24, provers1, lat, totals] = await Promise.all([
|
||||||
|
sql(`SELECT b.hash, b.number, b.daa_score, b.blue_score, b.timestamp_ms, b.vote_key_hash, b.evm_miner, b.tx_count, b.proof_records,
|
||||||
|
count(p.shard)::int AS total, count(*) FILTER (WHERE p.state = 'planned')::int AS planned, count(*) FILTER (WHERE p.state = 'proving')::int AS proving,
|
||||||
|
count(*) FILTER (WHERE p.state = 'verified')::int AS verified, count(*) FILTER (WHERE p.state = 'paid')::int AS paid,
|
||||||
|
percentile_cont(0.5) WITHIN GROUP (ORDER BY p.lag_daa) FILTER (WHERE p.state = 'paid') AS median_lag, sum(p.pgas) AS pgas
|
||||||
|
FROM ${T}live_blocks b LEFT JOIN ${T}live_proofs p ON p.block_hash = b.hash
|
||||||
|
WHERE b.is_chain_block AND b.number IS NOT NULL AND b.received_at > now() - interval '24 hours'
|
||||||
|
GROUP BY b.hash, b.number, b.daa_score, b.blue_score, b.timestamp_ms, b.vote_key_hash, b.evm_miner, b.tx_count, b.proof_records ORDER BY b.number DESC LIMIT 60`),
|
||||||
|
sql(`SELECT prover, count(*)::int AS shards, sum(payout_wei::numeric)::text AS payout_wei, percentile_cont(0.5) WITHIN GROUP (ORDER BY lag_daa) AS median_lag, max(updated_at) AS last_paid
|
||||||
|
FROM ${T}live_proofs WHERE state = 'paid' AND updated_at > now() - interval '24 hours' AND prover IS NOT NULL GROUP BY prover ORDER BY shards DESC LIMIT 64`),
|
||||||
|
sql(`SELECT prover, count(*)::int AS shards, sum(payout_wei::numeric)::text AS payout_wei FROM ${T}live_proofs WHERE state = 'paid' AND updated_at > now() - interval '1 hour' AND prover IS NOT NULL GROUP BY prover`),
|
||||||
|
sql(`SELECT count(*)::int AS paid_1h, percentile_cont(0.5) WITHIN GROUP (ORDER BY lag_daa) AS p50, percentile_cont(0.9) WITHIN GROUP (ORDER BY lag_daa) AS p90, max(lag_daa) AS max_lag, min(lag_daa) AS min_lag
|
||||||
|
FROM ${T}live_proofs WHERE state = 'paid' AND updated_at > now() - interval '1 hour'`),
|
||||||
|
sql(`SELECT state, count(*)::int AS n, sum(payout_wei::numeric)::text AS payout_wei FROM ${T}live_proofs WHERE received_at > now() - interval '24 hours' GROUP BY state`),
|
||||||
|
]);
|
||||||
|
const hour = new Map(provers1.map(p => [p.prover, p]));
|
||||||
|
return res.status(200).json({
|
||||||
|
...base, proving: s && s.proving ? s.proving : null,
|
||||||
|
blocks: blocks.map(b => ({ hash: b.hash, number: num(b.number), daa: num(b.daa_score), blue_score: num(b.blue_score), ts: num(b.timestamp_ms), miner_id: b.vote_key_hash ? String(b.vote_key_hash).slice(0, 8) : null, evm_miner: b.evm_miner, tx_count: num(b.tx_count), proof_records: num(b.proof_records), pgas: num(b.pgas), shards: shardSummary(b), class: classAt(num(b.daa_score), params).class })),
|
||||||
|
provers: provers24.map(p => ({ id: p.prover, shards_24h: num(p.shards), payout_wei_24h: p.payout_wei || '0', median_lag_24h: p.median_lag === null ? null : Number(p.median_lag), shards_1h: hour.has(p.prover) ? num(hour.get(p.prover).shards) : 0, payout_wei_1h: hour.has(p.prover) ? hour.get(p.prover).payout_wei || '0' : '0', last_paid: p.last_paid })),
|
||||||
|
latency_1h: lat[0] ? { paid: num(lat[0].paid_1h), p50_daa: lat[0].p50 === null ? null : Number(lat[0].p50), p90_daa: lat[0].p90 === null ? null : Number(lat[0].p90), min_daa: num(lat[0].min_lag), max_daa: num(lat[0].max_lag), note: 'lag in DAA score from the proven block to the block whose coinbase paid the shard; Devnet 3 targets one DAA step per second' } : null,
|
||||||
|
totals_24h: Object.fromEntries(totals.map(t => [t.state, { shards: num(t.n), payout_wei: t.payout_wei || '0' }])),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the DAG ----
|
||||||
|
if (q.has('dag')) {
|
||||||
|
const n = Math.max(20, Math.min(400, Number(q.get('dag')) || 120));
|
||||||
|
const rows = await sql(`SELECT ${ROW}, ${evmHash(T)}, (SELECT json_build_object('total', count(*), 'paid', count(*) FILTER (WHERE state = 'paid'), 'planned', count(*) FILTER (WHERE state = 'planned'), 'proving', count(*) FILTER (WHERE state = 'proving'), 'verified', count(*) FILTER (WHERE state = 'verified')) FROM ${T}live_proofs p WHERE p.block_hash = b.hash) AS sh
|
||||||
|
FROM ${T}live_blocks b ORDER BY received_at DESC LIMIT $1`, [n]).catch(() => sql(`SELECT ${ROW} FROM ${T}live_blocks b ORDER BY received_at DESC LIMIT $1`, [n]));
|
||||||
|
const cps = await sql(`SELECT hash, index, state FROM ${T}live_checkpoints ORDER BY index DESC LIMIT 400`).catch(() => []);
|
||||||
|
const cp = new Map(cps.map(c => [c.hash, { index: num(c.index), state: c.state }]));
|
||||||
|
return res.status(200).json({ ...base, blocks: rows.map(b => { const r = row(b); return { ...r, shards: b.sh ? shardSummary(b.sh) : null, checkpoint: cp.get(b.hash) || null, final: lockState(r, lock, null).final, class: classAt(r.daa, params).class }; }).reverse() });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- latest transactions ----
|
||||||
|
if (q.has('txs')) {
|
||||||
|
const n = Math.max(10, Math.min(200, Number(q.get('txs')) || 50));
|
||||||
|
const before = Number(q.get('before')) || 0;
|
||||||
|
const rows = before
|
||||||
|
? await sql(`SELECT ${TXROW} FROM ${T}live_txs WHERE block_number < $2 ORDER BY block_number DESC, tx_index ASC LIMIT $1`, [n, before])
|
||||||
|
: await sql(`SELECT ${TXROW} FROM ${T}live_txs ORDER BY block_number DESC, tx_index ASC LIMIT $1`, [n]);
|
||||||
|
return res.status(200).json({ ...base, txs: rows.map(txrow) });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- one transaction ----
|
||||||
|
if (q.has('tx')) {
|
||||||
|
const h = String(q.get('tx') || '').toLowerCase().replace(/^(0x)?/, '0x');
|
||||||
|
if (!/^0x[0-9a-f]{64}$/.test(h)) return res.status(400).json({ ...base, ok: false, error: 'A transaction hash is 0x plus 64 hex characters.' });
|
||||||
|
const rows = await sql(`SELECT ${TXROW}, logs, igneum FROM ${T}live_txs WHERE hash = $1`, [h]);
|
||||||
|
if (!rows.length) return res.status(404).json({ ...base, ok: false, error: 'No transaction with that hash in the last 7 days. The explorer keeps seven days of transactions; older ones live in the node.' });
|
||||||
|
const t = rows[0];
|
||||||
|
const [blk, sh, acct] = await Promise.all([
|
||||||
|
sql(`SELECT ${ROW}, ${evmHash(T)} FROM ${T}live_blocks b WHERE hash = $1`, [t.block_hash]),
|
||||||
|
sql(`SELECT count(*)::int AS total, count(*) FILTER (WHERE state = 'planned')::int AS planned, count(*) FILTER (WHERE state = 'proving')::int AS proving, count(*) FILTER (WHERE state = 'verified')::int AS verified, count(*) FILTER (WHERE state = 'paid')::int AS paid, percentile_cont(0.5) WITHIN GROUP (ORDER BY lag_daa) FILTER (WHERE state = 'paid') AS median_lag FROM ${T}live_proofs WHERE block_hash = $1`, [t.block_hash]).catch(() => []),
|
||||||
|
sql(`SELECT address, is_contract, code_bytes FROM ${T}live_accounts WHERE address = ANY($1::text[])`, [[t.from_addr, t.to_addr, t.contract_address].filter(Boolean)]).catch(() => []),
|
||||||
|
]);
|
||||||
|
const b = blk[0] ? row(blk[0]) : null;
|
||||||
|
const contracts = Object.fromEntries(acct.filter(a => a.is_contract).map(a => [a.address, num(a.code_bytes)]));
|
||||||
|
return res.status(200).json({
|
||||||
|
...base, tx: { ...txrow(t), logs: t.logs || [], igneum: t.igneum || null, contracts },
|
||||||
|
block: b ? { ...b, class: classAt(b.daa, params).class, shards: sh[0] ? shardSummary(sh[0]) : null, lock: lockState(b, lock, null), in_observer: true } : { hash: t.block_hash, number: num(t.block_number), in_observer: false, note: 'the observer keeps one day of blocks; the transaction is older or its block left the observer window' },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- latest blocks ----
|
// ---- latest blocks ----
|
||||||
if (q.has('blocks') || (!q.has('block') && !q.has('height') && !q.has('address'))) {
|
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 n = Math.max(10, Math.min(200, Number(q.get('blocks')) || 50));
|
||||||
const before = Number(q.get('before')) || 0;
|
const before = Number(q.get('before')) || 0;
|
||||||
|
const sel = `SELECT ${ROW}, ${evmHash(T)}, (SELECT json_build_object('total', count(*), 'paid', count(*) FILTER (WHERE state = 'paid'), 'planned', count(*) FILTER (WHERE state = 'planned'), 'proving', count(*) FILTER (WHERE state = 'proving'), 'verified', count(*) FILTER (WHERE state = 'verified')) FROM ${T}live_proofs p WHERE p.block_hash = b.hash) AS sh FROM ${T}live_blocks b`;
|
||||||
const rows = before
|
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(`${sel} 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]);
|
: await sql(`${sel} 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));
|
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) })) });
|
return res.status(200).json({ ...base, blocks: rows.map(b => { const r = row(b); return { ...r, locked: locked.has(b.hash), shards: b.sh ? shardSummary(b.sh) : null, final: lockState(r, lock, null).final, class: classAt(r.daa, params).class }; }) });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- one block ----
|
// ---- one block ----
|
||||||
|
|
@ -83,30 +233,34 @@ export function createHandler({ env = process.env, sql, evm = evmCall } = {}) {
|
||||||
hash = r[0].hash;
|
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.' });
|
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]);
|
const rows = await sql(`SELECT ${ROW}, ${evmHash(T)}, detail FROM ${T}live_blocks b WHERE hash = $1`, [hash]).catch(() => 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.' });
|
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 of blocks; older blocks live in the node.' });
|
||||||
const b = rows[0];
|
const b = rows[0];
|
||||||
const [shards, cps, certs, children, mergedBy] = await Promise.all([
|
const [shards, cps, certs, children, mergedBy, txs] = 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 shard, shards, state, prover, lag_daa, payout_wei, pgas, carried_by, carrier_number, verified 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, 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 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]),
|
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]),
|
b.is_chain_block ? Promise.resolve([]) : sql(`SELECT hash, blue_score 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]),
|
||||||
|
sql(`SELECT ${TXROW} FROM ${T}live_txs WHERE block_hash = $1 ORDER BY tx_index LIMIT 500`, [hash]).catch(() => []),
|
||||||
]);
|
]);
|
||||||
let evmView = null; // the EVM's view of a chain block, when the deployment has an EVM RPC
|
let evmView = null; // the EVM's view of a chain block, when the deployment has an EVM RPC
|
||||||
if (EVM && b.is_chain_block) {
|
if (EVM && b.is_chain_block) {
|
||||||
try {
|
try {
|
||||||
const e = await evm(EVM, 'eth_getBlockByHash', ['0x' + hash, true]);
|
const e = await evm(EVM, 'eth_getBlockByHash', ['0x' + hash, false]);
|
||||||
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,
|
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 };
|
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) }; }
|
} catch (err) { evmView = { error: String(err.message || err).slice(0, 120) }; }
|
||||||
}
|
}
|
||||||
|
const r = row(b);
|
||||||
|
const mergerBlue = b.is_chain_block ? null : (mergedBy[0] ? num(mergedBy[0].blue_score) : null);
|
||||||
|
const counts = { total: shards.length, planned: shards.filter(x => x.state === 'planned').length, proving: shards.filter(x => x.state === 'proving').length, verified: shards.filter(x => x.state === 'verified').length, paid: shards.filter(x => x.state === 'paid').length };
|
||||||
return res.status(200).json({
|
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) })),
|
...base, block: { ...r, detail: b.detail || null, class: classAt(r.daa, params), lock: lockState(r, lock, mergerBlue), shard_summary: counts },
|
||||||
|
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), node_verified: x.verified === null || x.verified === undefined ? null : !!x.verified })),
|
||||||
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,
|
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 })),
|
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,
|
children: children.map(c => c.hash), merged_by: b.is_chain_block ? hash : (mergedBy[0] ? mergedBy[0].hash : null), evm: evmView, txs: txs.map(txrow),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -122,30 +276,40 @@ export function createHandler({ env = process.env, sql, evm = evmCall } = {}) {
|
||||||
coalesce(sum(subsidy_sompi) FILTER (WHERE color = 'blue'), 0)::text AS blue_subsidy_sompi,
|
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
|
max(evm_miner) AS evm_miner, max(miner_address) AS miner_address
|
||||||
FROM ${T}live_blocks WHERE ${col} = $1`, [c.value]),
|
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 ${ROW}, ${evmHash(T)} FROM ${T}live_blocks b 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]),
|
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 t = sum[0];
|
||||||
const evmAddress = c.kind === 'evm' ? c.value : (t.evm_miner || null);
|
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)' };
|
let account = null, txs = [];
|
||||||
|
if (evmAddress) {
|
||||||
|
const before = Number(q.get('before')) || 0;
|
||||||
|
const [a, tx] = await Promise.all([
|
||||||
|
sql(`SELECT address, balance_wei, nonce, code_bytes, is_contract, sent, received, first_seen_number, last_seen_number, miner, refreshed_at FROM ${T}live_accounts WHERE address = $1`, [evmAddress]).catch(() => []),
|
||||||
|
sql(`SELECT ${TXROW} FROM ${T}live_txs WHERE (from_addr = $1 OR to_addr = $1) ${before ? 'AND block_number < $2' : ''} ORDER BY block_number DESC, tx_index ASC LIMIT 50`, before ? [evmAddress, before] : [evmAddress]).catch(() => []),
|
||||||
|
]);
|
||||||
|
if (a[0]) account = { address: a[0].address, balance_wei: a[0].balance_wei === null ? null : String(a[0].balance_wei), nonce: num(a[0].nonce), code_bytes: num(a[0].code_bytes), is_contract: !!a[0].is_contract, sent: num(a[0].sent), received: num(a[0].received), first_seen_number: num(a[0].first_seen_number), last_seen_number: num(a[0].last_seen_number), miner: !!a[0].miner, refreshed_at: a[0].refreshed_at, source: 'the explorer indexer (eth_getBalance, eth_getTransactionCount, eth_getCode on the observer node)' };
|
||||||
|
txs = tx.map(txrow);
|
||||||
|
}
|
||||||
|
let balance = account && account.balance_wei !== null ? { available: true, wei: account.balance_wei, address: evmAddress, source: 'indexer', refreshed_at: account.refreshed_at } : { available: false, reason: evmAddress ? 'not read yet: the indexer reads an address after its first transaction or mined block' : 'no EVM address known for this coinbase address' };
|
||||||
if (EVM && evmAddress) {
|
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' }; }
|
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) }; }
|
catch (err) { if (!balance.available) 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;
|
const blue = BigInt(t.blue_subsidy_sompi || 0); const earned = blue * 80n / 100n;
|
||||||
return res.status(200).json({
|
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),
|
...base, address: c.value, kind: c.kind, evm_address: evmAddress, utxo_address: c.kind === 'utxo' ? c.value : (t.miner_address || null),
|
||||||
|
account, txs,
|
||||||
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,
|
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,
|
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',
|
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),
|
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' });
|
return res.status(400).json({ ...base, ok: false, error: 'blocks, block, height, tx, txs, address, proving, stats, dag or search' });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
res.setHeader('Cache-Control', 'no-store');
|
res.setHeader('Cache-Control', 'no-store');
|
||||||
return res.status(500).json({ ok: false, error: String(e.message || e) });
|
return res.status(500).json({ ok: false, network_label: NETWORK_LABEL, error: String(e.message || e) });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
<title>Igneum block</title>
|
<title>Igneum Devnet 3 block</title>
|
||||||
<meta name="description" content="One Igneum block: header, parents, mergeset, transactions, proof records and, when it is a checkpoint, its finality certificate.">
|
<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">
|
<link rel="canonical" href="https://igneum.network/block">
|
||||||
<meta name="robots" content="noindex">
|
<meta name="robots" content="noindex">
|
||||||
|
|
@ -92,6 +92,12 @@ dd{margin:0;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}
|
||||||
@media (min-width:900px){.two{grid-template-columns:1fr 1fr}.two .card{margin-top:0}}
|
@media (min-width:900px){.two{grid-template-columns:1fr 1fr}.two .card{margin-top:0}}
|
||||||
.two-wrap{margin-top:var(--gap)}
|
.two-wrap{margin-top:var(--gap)}
|
||||||
main{padding-bottom:100px}.card{background:var(--row);border:1px solid var(--line);border-radius:16px;padding:25px;margin-top:24px}.card .viz-head h2{font-size:19px}.card .viz-head .eyebrow{margin:0}
|
main{padding-bottom:100px}.card{background:var(--row);border:1px solid var(--line);border-radius:16px;padding:25px;margin-top:24px}.card .viz-head h2{font-size:19px}.card .viz-head .eyebrow{margin:0}
|
||||||
|
.badge{display:inline-flex;align-items:center;gap:6px;font-family:var(--f-mono);font-size:11px;letter-spacing:.06em;text-transform:uppercase;padding:4px 9px;border-radius:999px;border:1px solid var(--line-2);color:var(--ink-2);margin:0 6px 6px 0}
|
||||||
|
.badge.ok{border-color:var(--green,#5CC48A);color:var(--green,#5CC48A)}.badge.hot{border-color:var(--molten);color:var(--molten)}.badge.bad{border-color:var(--excluded);color:var(--excluded)}.badge.wait{color:var(--ash)}
|
||||||
|
.bar{display:inline-block;width:72px;height:7px;border-radius:4px;background:var(--line);vertical-align:middle;margin-right:7px;overflow:hidden}.bar i{display:block;height:100%;background:var(--green,#5CC48A)}.bar i.part{background:var(--molten)}
|
||||||
|
.dagbox{position:relative;width:100%;height:320px;border:1px solid var(--line);border-radius:12px;background:var(--obsidian);overflow:hidden}.dagbox canvas{width:100%;height:100%;display:block;cursor:pointer}
|
||||||
|
.dagtip{position:absolute;pointer-events:none;font:11px/1.5 var(--mono);color:var(--bone);background:var(--graphite);border:1px solid var(--line-2);border-radius:8px;padding:8px 10px;max-width:300px;display:none}
|
||||||
|
.kv{color:var(--ash)}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
@ -131,8 +137,8 @@ main{padding-bottom:100px}.card{background:var(--row);border:1px solid var(--lin
|
||||||
</div>
|
</div>
|
||||||
<div class="nav-panel" id="nav-panel-network" data-group="network" role="region" aria-labelledby="nav-btn-network" hidden>
|
<div class="nav-panel" id="nav-panel-network" data-group="network" role="region" aria-labelledby="nav-btn-network" hidden>
|
||||||
<div class="panel-list">
|
<div class="panel-list">
|
||||||
<a href="/live" data-nav="live" aria-current="page"><b>Live devnet</b><span>The chain as it grows, one lane per miner.</span></a>
|
<a href="/live" data-nav="live"><b>Live devnet</b><span>The chain as it grows, one lane per miner.</span></a>
|
||||||
<a href="/explorer" data-nav="explorer"><b>Explorer</b><span>Look a block or an address up.</span></a>
|
<a href="/explorer" data-nav="explorer" aria-current="page"><b>Explorer</b><span>Look a block or an address up.</span></a>
|
||||||
<a href="/journey" data-nav="journey"><b>The journey</b><span>Six phases, four gates, the log behind them.</span></a>
|
<a href="/journey" data-nav="journey"><b>The journey</b><span>Six phases, four gates, the log behind them.</span></a>
|
||||||
<a href="/bench" data-nav="bench"><b>Engineering log</b><span>Every measurement, with the commands that made it.</span></a>
|
<a href="/bench" data-nav="bench"><b>Engineering log</b><span>Every measurement, with the commands that made it.</span></a>
|
||||||
<a href="/evidence" data-nav="evidence"><b>Evidence</b><span>Every claim and how far it is tested.</span></a>
|
<a href="/evidence" data-nav="evidence"><b>Evidence</b><span>Every claim and how far it is tested.</span></a>
|
||||||
|
|
@ -173,8 +179,8 @@ main{padding-bottom:100px}.card{background:var(--row);border:1px solid var(--lin
|
||||||
<a href="/metamask" data-nav="metamask"><b>Add to MetaMask</b><span>Igneum as a network in your wallet.</span></a>
|
<a href="/metamask" data-nav="metamask"><b>Add to MetaMask</b><span>Igneum as a network in your wallet.</span></a>
|
||||||
</div>
|
</div>
|
||||||
<div class="sheet-group"><div class="sheet-head">Network</div>
|
<div class="sheet-group"><div class="sheet-head">Network</div>
|
||||||
<a href="/live" data-nav="live" aria-current="page"><b>Live devnet</b><span>The chain as it grows, one lane per miner.</span></a>
|
<a href="/live" data-nav="live"><b>Live devnet</b><span>The chain as it grows, one lane per miner.</span></a>
|
||||||
<a href="/explorer" data-nav="explorer"><b>Explorer</b><span>Look a block or an address up.</span></a>
|
<a href="/explorer" data-nav="explorer" aria-current="page"><b>Explorer</b><span>Look a block or an address up.</span></a>
|
||||||
<a href="/journey" data-nav="journey"><b>The journey</b><span>Six phases, four gates, the log behind them.</span></a>
|
<a href="/journey" data-nav="journey"><b>The journey</b><span>Six phases, four gates, the log behind them.</span></a>
|
||||||
<a href="/bench" data-nav="bench"><b>Engineering log</b><span>Every measurement, with the commands that made it.</span></a>
|
<a href="/bench" data-nav="bench"><b>Engineering log</b><span>Every measurement, with the commands that made it.</span></a>
|
||||||
<a href="/evidence" data-nav="evidence"><b>Evidence</b><span>Every claim and how far it is tested.</span></a>
|
<a href="/evidence" data-nav="evidence"><b>Evidence</b><span>Every claim and how far it is tested.</span></a>
|
||||||
|
|
@ -252,7 +258,7 @@ main{padding-bottom:100px}.card{background:var(--row);border:1px solid var(--lin
|
||||||
<main id="main" class="wrap">
|
<main id="main" class="wrap">
|
||||||
<div class="head">
|
<div class="head">
|
||||||
<div class="breadcrumb"><a href="/">Igneum</a><span>/</span><a href="/explorer">Explorer</a><span>/</span><span>Block</span></div>
|
<div class="breadcrumb"><a href="/">Igneum</a><span>/</span><a href="/explorer">Explorer</a><span>/</span><span>Block</span></div>
|
||||||
<div class="eyebrow"><a href="/explorer">Explorer</a> · block</div>
|
<div class="eyebrow"><a href="/explorer">Devnet 3 explorer</a> · block</div>
|
||||||
<h1 class="hash" id="title">Block</h1>
|
<h1 class="hash" id="title">Block</h1>
|
||||||
<div id="chips"></div>
|
<div id="chips"></div>
|
||||||
<div class="msg" id="msg" aria-live="polite"></div>
|
<div class="msg" id="msg" aria-live="polite"></div>
|
||||||
|
|
@ -267,8 +273,8 @@ main{padding-bottom:100px}.card{background:var(--row);border:1px solid var(--lin
|
||||||
<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 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>
|
||||||
<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>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>Shard plan and proofs</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 class="card"><div class="viz-head"><h2>Finality</h2><div class="eyebrow" id="fin-eyebrow"></div></div><div id="finality"></div><div id="recheck"></div></div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
<!-- footer:start -->
|
<!-- footer:start -->
|
||||||
|
|
@ -350,28 +356,47 @@ const addr = a => a ? `<a class="hash" href="/address/${esc(a)}">${esc(a)}</a>`
|
||||||
const dl = pairs => pairs.filter(p => p).map(([k, v]) => `<dt>${esc(k)}</dt><dd>${v}</dd>`).join('');
|
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 id = decodeURIComponent(location.pathname.split('/').pop() || '');
|
||||||
const query = /^\d{1,12}$/.test(id) ? 'height=' + id : 'block=' + encodeURIComponent(id.replace(/^0x/i, ''));
|
const query = /^\d{1,12}$/.test(id) ? 'height=' + id : 'block=' + encodeURIComponent(id.replace(/^0x/i, ''));
|
||||||
|
async function recheck(index, blockHash, isCheckpoint) {
|
||||||
|
// the verified badge: this browser fetches the certificate that covers the block and checks it with the site's light client
|
||||||
|
const el = $('recheck'); el.innerHTML = `<p class="note" id="rc-line">Re-checking certificate ${int(index)} in this browser.</p>`;
|
||||||
|
const badge = document.createElement('span'); badge.className = 'badge wait'; badge.textContent = 'checking the certificate'; $('chips').appendChild(badge);
|
||||||
|
try {
|
||||||
|
const { fetchCheckpoint, verify } = await import('/verify/verify.js');
|
||||||
|
const data = await fetchCheckpoint('/api/checkpoint?index=' + index);
|
||||||
|
const r = verify(data);
|
||||||
|
if (r.verified) {
|
||||||
|
badge.className = 'badge ok'; badge.textContent = isCheckpoint ? 'verified in this browser' : `verified under lock ${int(index)} in this browser`;
|
||||||
|
$('rc-line').innerHTML = `Certificate ${int(index)} re-checked in this browser in ${r.ms} ms: ${int(r.headers_checked)} header hashes recomputed, ${int(r.signers)} of ${int(r.voters)} voters' BLS keys aggregated, the signature verifies over ${(r.weight_fraction_total * 100).toFixed(1)}% of all weight. ${isCheckpoint ? 'This block is the certified checkpoint.' : 'This block sits under that lock on the selected chain.'} The light client is <a href="/verify/core.js">/verify/core.js</a>; the data it checked is <a href="/api/checkpoint?index=${index}">/api/checkpoint?index=${index}</a>.`;
|
||||||
|
} else { badge.className = 'badge bad'; badge.textContent = 'certificate did not verify'; $('rc-line').textContent = `Certificate ${int(index)} did not verify in this browser: ${r.error || 'unknown reason'}.`; }
|
||||||
|
} catch (e) { badge.className = 'badge wait'; badge.textContent = 're-check unavailable'; $('rc-line').textContent = 'The re-check could not run: ' + (e.message || e); }
|
||||||
|
}
|
||||||
async function load() {
|
async function load() {
|
||||||
const r = await fetch('/api/explorer?' + query); const j = await r.json();
|
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; }
|
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();
|
const b = j.block, d = b.detail || {}, now = new Date(j.now).getTime(), sum = b.shard_summary || { total: 0, paid: 0 };
|
||||||
document.title = `Igneum block ${shortHash(b.hash, 8, 6)}`;
|
document.title = `Devnet 3 block ${shortHash(b.hash, 8, 6)}`;
|
||||||
$('title').textContent = b.hash;
|
$('title').textContent = b.hash;
|
||||||
const chips = [b.chain ? '<span class="chip hot">chain block</span>' : `<span class="chip">${esc(b.color)}</span>`];
|
const chips = [b.chain ? (b.chain_now === false ? '<span class="chip" title="the observer saw it as the chain block of its number; a tip reorg gave the number to another block">chain block, replaced</span>' : '<span class="chip hot">chain block</span>') : `<span class="chip">${esc(b.color)}</span>`, `<span class="chip" title="the hash program family this block was mined under">class ${esc(b.class ? b.class.class : 'v4')}</span>`];
|
||||||
if (j.checkpoint) chips.push(`<span class="chip ${j.checkpoint.state === 'locked' ? 'ok' : ''}">checkpoint ${j.checkpoint.index}, ${esc(j.checkpoint.state)}</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>`);
|
chips.push(b.lock && b.lock.final ? `<span class="chip ok" title="${esc(b.lock.reason)}">final under lock ${int(b.lock.index)}</span>` : `<span class="chip" title="${esc(b.lock ? b.lock.reason : '')}">not under a lock yet</span>`);
|
||||||
if (b.tx_count) chips.push(`<span class="chip">${b.tx_count} transaction${b.tx_count === 1 ? '' : 's'}</span>`);
|
if (b.chain) chips.push(sum.total ? `<span class="chip ${sum.paid === sum.total ? 'ok' : ''}">${sum.paid} of ${sum.total} shards paid</span>` : '<span class="chip">plan not read yet</span>');
|
||||||
|
if (b.proof_records) chips.push(`<span class="chip">carries ${b.proof_records} proof record${b.proof_records === 1 ? '' : 's'}</span>`);
|
||||||
|
if (j.txs.length) chips.push(`<span class="chip">${j.txs.length} transaction${j.txs.length === 1 ? '' : 's'} executed</span>`);
|
||||||
$('chips').innerHTML = chips.join('');
|
$('chips').innerHTML = chips.join('');
|
||||||
$('summary').innerHTML = dl([
|
$('summary').innerHTML = dl([
|
||||||
['Number', b.number === null ? '<span style="color:var(--ash)">none, not a chain block</span>' : int(b.number)],
|
['Network', 'Devnet 3, chain id ' + esc(j.chain_id ?? 4463)],
|
||||||
['Time', `${utc(b.ts)} <span style="color:var(--ash)">(${rel(now - b.ts)})</span>`],
|
['Number', b.number === null ? '<span class="kv">none, not a chain block</span>' : (b.chain_now === false ? `${int(b.number)} <span class="kv">as the observer first saw it; a tip reorg gave this number to another block, so the EVM executed that one</span>` : int(b.number))],
|
||||||
|
['Time', `${utc(b.ts)} <span class="kv">(${rel(now - b.ts)})</span>`],
|
||||||
['DAA score', int(b.daa)], ['Blue score', int(b.blue_score)],
|
['DAA score', int(b.daa)], ['Blue score', int(b.blue_score)],
|
||||||
|
['Class', `${esc(b.class ? b.class.class : 'v4')} <span class="kv">${b.class && b.class.floors ? b.class.floors.map(f => `${f.class} from DAA ${int(f.daa)}`).join(', ') : ''}</span>`],
|
||||||
|
['Lock state', b.lock ? `${b.lock.final ? 'final' : 'not final'} <span class="kv">${esc(b.lock.reason)}${j.lock ? `; newest lock ${int(j.lock.index)} at blue score ${int(j.lock.blue_score)}, ${(j.lock.fraction_total * 100).toFixed(1)}% of all weight` : ''}</span>` : 'n/a'],
|
||||||
['Miner', addr(b.evm_miner)], ['Coinbase address', addr(b.miner)],
|
['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>`],
|
['Vote key', `<span class="id">${esc(b.miner_id || 'n/a')}</span> <span class="hash kv" style="font-size:12px">${esc(b.vote_key_hash || '')}</span>`],
|
||||||
b.engine ? ['Engine', esc(b.engine)] : null,
|
b.engine ? ['Engine', esc(b.engine)] : null,
|
||||||
['Difficulty', d.difficulty ? int(d.difficulty) : 'n/a'],
|
['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'],
|
['Declared subsidy', b.subsidy_sompi ? `${ign(b.subsidy_sompi, 8)} IGN <span class="kv">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'],
|
['Coinbase paid', b.paid_sompi ? `${ign(b.paid_sompi, 8)} IGN <span class="kv">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>')],
|
['Merged by', b.chain ? 'itself (chain block)' : (j.merged_by ? link(j.merged_by) : '<span class="kv">not yet</span>')],
|
||||||
['Selected parent', b.selected_parent ? link(b.selected_parent) : 'n/a'],
|
['Selected parent', b.selected_parent ? link(b.selected_parent) : 'n/a'],
|
||||||
]);
|
]);
|
||||||
$('header').innerHTML = dl([
|
$('header').innerHTML = dl([
|
||||||
|
|
@ -386,21 +411,25 @@ async function load() {
|
||||||
const ms = d.mergeset || { blues: [], reds: [] };
|
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>';
|
$('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;
|
const cb = d.coinbase;
|
||||||
$('tx-n').textContent = `${b.tx_count ?? 0} EVM, 1 coinbase`;
|
$('tx-n').textContent = `${b.tx_count ?? 0} carried, ${j.txs.length} executed by the 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>';
|
$('coinbase').innerHTML = cb ? `<dl>${dl([['Coinbase', `<span class="hash">${esc(cb.id)}</span>`], ['Outputs', cb.outputs.map(o => `${o.pool ? '<span class="kv">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;
|
const carried = d.txs || [];
|
||||||
$('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>';
|
$('txs').innerHTML = j.txs.length ? `<table><thead><tr><th>Transaction</th><th>From</th><th>To</th><th class="n">Value</th><th>Status</th><th class="n">Gas used</th></tr></thead><tbody>${j.txs.map(t => `<tr id="tx-${esc(t.hash.replace(/^0x/, ''))}"><td><a class="hash" href="/tx/${esc(t.hash)}">${esc(shortHash(t.hash, 12, 8))}</a></td><td>${t.from ? `<a class="hash" href="/address/${esc(t.from)}">${esc(shortHash(t.from, 8, 6))}</a>` : ''}</td><td>${t.to ? `<a class="hash" href="/address/${esc(t.to)}">${esc(shortHash(t.to, 8, 6))}</a>` : (t.contract_address ? `<span class="kv">created</span> <a class="hash" href="/address/${esc(t.contract_address)}">${esc(shortHash(t.contract_address, 8, 6))}</a>` : '<span class="kv">none</span>')}</td><td class="n">${ignFromWei(t.value_wei, 6)} IGN</td><td>${t.status === 'success' ? '<span class="chip ok">ok</span>' : t.status === 'failed' ? '<span class="chip" style="border-color:var(--excluded);color:var(--excluded)">failed</span>' : '<span class="kv">no receipt</span>'}</td><td class="n">${int(t.gas_used)}</td></tr>`).join('')}</tbody></table>` :
|
||||||
|
(carried.length ? `<p class="note">This block carries ${carried.length} transaction${carried.length === 1 ? '' : 's'}; ${b.chain ? 'the indexer has not read the block yet, or every one had already been executed in an earlier chain block' : 'they execute in the chain block that merges this one'}.</p>` : '<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>`);
|
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;
|
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';
|
$('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>` +
|
$('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).${b.chain ? ` Its own execution is split into <b>${sum.total}</b> shard${sum.total === 1 ? '' : 's'}: ${sum.paid} paid, ${sum.verified} verified, ${sum.proving} proving, ${sum.planned} planned.` : ''}</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>' : ''));
|
(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><th>Node verdict</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' || s.state === 'proving' ? '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><td>${s.node_verified === true ? 'proof verified by the node' : s.node_verified === false ? 'not verified' : '<span class="kv">no record yet</span>'}</td></tr>`).join('')}</tbody></table></div><p class="note">State follows the record: planned when the node published the shard plan, proving when a prover's record is in the pool, verified when the node's SP1 verifier accepted the proof, paid when a later coinbase carried and paid it. The explorer does not re-run the STARK itself; the node's verdict is shown as the node's. Every state, number and record on this page is read from the observer node, the chain the finality certificates follow.</p>` : (b.chain ? '<div class="empty">No shard plan recorded (the proving feed has not read this block).</div>' : ''));
|
||||||
const cp = j.checkpoint, certs = j.certificates || [];
|
const cp = j.checkpoint, certs = j.certificates || [];
|
||||||
$('fin-eyebrow').textContent = cp ? `checkpoint ${cp.index}` : (certs.length ? 'carries a certificate' : 'not a checkpoint');
|
$('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>` : '') +
|
$('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('') +
|
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, ['Data', `<a href="/api/checkpoint?index=${c.index}">/api/checkpoint?index=${c.index}</a> serves this 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>' : '');
|
(!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.${b.lock && b.lock.final ? ` This block is under lock ${int(b.lock.index)}.` : ''}</div>` : '');
|
||||||
$('body').hidden = false;
|
$('body').hidden = false;
|
||||||
|
const ownCert = certs.find(c => c.role === 'checkpoint');
|
||||||
|
if (ownCert) recheck(ownCert.index, b.hash, true);
|
||||||
|
else if (b.lock && b.lock.final && b.lock.index !== undefined) recheck(b.lock.index, b.hash, false);
|
||||||
}
|
}
|
||||||
load().catch(e => { $('msg').textContent = 'The API did not answer: ' + e.message; });
|
load().catch(e => { $('msg').textContent = 'The API did not answer: ' + e.message; });
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -406,8 +406,9 @@ function renderEvidence(html) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const PAGES = [['index.html', ''], ['download.html', 'download'], ['income.html', 'income'], ['litepaper.html', 'litepaper'], ['live.html', 'live'], ['evidence.html', 'evidence'], ['miner.html', 'miner'], ['app.html', 'app'], ['wallet.html', 'wallet'], ['ledger.html', 'ledger'], ['metamask.html', 'metamask'], ['faucet.html', 'faucet'], ['swap.html', 'swap'], ['404.html', ''],
|
const PAGES = [['index.html', ''], ['download.html', 'download'], ['income.html', 'income'], ['litepaper.html', 'litepaper'], ['live.html', 'live'], ['evidence.html', 'evidence'], ['miner.html', 'miner'], ['app.html', 'app'], ['wallet.html', 'wallet'], ['ledger.html', 'ledger'], ['metamask.html', 'metamask'], ['faucet.html', 'faucet'], ['swap.html', 'swap'], ['404.html', ''],
|
||||||
// the DAG explorer (5 Oct 2026): /explorer, /block/<hash> and /address/<addr> (vercel.json rewrites the last two)
|
// the Devnet 3 explorer (5 Oct 2026, extended 8 Oct 2026): /explorer, /block/<hash>, /address/<addr>, /tx/<hash> (vercel.json rewrites
|
||||||
['explorer.html', 'live'], ['block.html', 'live'], ['address.html', 'live'],
|
// the three) and /proving; the Explorer entry of the Network panel is the current one on all five
|
||||||
|
['explorer.html', 'explorer'], ['block.html', 'explorer'], ['address.html', 'explorer'], ['tx.html', 'explorer'], ['proving.html', 'explorer'],
|
||||||
// the scenes lab (6 Oct 2026): three animated views of the live feed, unlinked, noindex
|
// the scenes lab (6 Oct 2026): three animated views of the live feed, unlinked, noindex
|
||||||
['scenes.html', ''],
|
['scenes.html', ''],
|
||||||
// the reference apps (8 Oct 2026): the light wallet, the payment receipt, the Sepolia state oracle
|
// the reference apps (8 Oct 2026): the light wallet, the payment receipt, the Sepolia state oracle
|
||||||
|
|
|
||||||
|
|
@ -3,22 +3,22 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
<title>Igneum explorer</title>
|
<title>Igneum Devnet 3 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.">
|
<meta name="description" content="Devnet 3, block by block: the DAG, every transaction, every account, the shard plan and proof of each block, the finality lock that covers it. A test network: coins have no value.">
|
||||||
<link rel="canonical" href="https://igneum.network/explorer">
|
<link rel="canonical" href="https://igneum.network/explorer">
|
||||||
<meta name="theme-color" content="#0C0C0E">
|
<meta name="theme-color" content="#0C0C0E">
|
||||||
<meta property="og:type" content="website">
|
<meta property="og:type" content="website">
|
||||||
<meta property="og:site_name" content="Igneum">
|
<meta property="og:site_name" content="Igneum">
|
||||||
<meta property="og:title" content="Igneum explorer">
|
<meta property="og:title" content="Igneum Devnet 3 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:description" content="Devnet 3, block by block: the DAG, every transaction, every account, the shard plan and proof of each block, the finality lock that covers it. A test network: coins have no value.">
|
||||||
<meta property="og:url" content="https://igneum.network/explorer">
|
<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" content="https://igneum.network/og-small.png?v=3">
|
||||||
<meta property="og:image:width" content="256">
|
<meta property="og:image:width" content="256">
|
||||||
<meta property="og:image:height" content="256">
|
<meta property="og:image:height" content="256">
|
||||||
<meta property="og:image:alt" content="Igneum. Mined by GPUs. Proven by fire.">
|
<meta property="og:image:alt" content="Igneum. Mined by GPUs. Proven by fire.">
|
||||||
<meta name="twitter:card" content="summary">
|
<meta name="twitter:card" content="summary">
|
||||||
<meta name="twitter:title" content="Igneum explorer">
|
<meta name="twitter:title" content="Igneum Devnet 3 explorer">
|
||||||
<meta name="twitter:description" content="The Igneum block DAG, block by block, with the network numbers and the supply.">
|
<meta name="twitter:description" content="Devnet 3, block by block: the DAG, transactions, accounts, proofs and finality. A test network.">
|
||||||
<meta name="twitter:image" content="https://igneum.network/og-small.png?v=3">
|
<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.">
|
<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.ico" sizes="48x48">
|
||||||
|
|
@ -91,6 +91,12 @@ dd{margin:0;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}
|
||||||
@media (min-width:900px){.two{grid-template-columns:1fr 1fr}.two .card{margin-top:0}}
|
@media (min-width:900px){.two{grid-template-columns:1fr 1fr}.two .card{margin-top:0}}
|
||||||
.two-wrap{margin-top:var(--gap)}
|
.two-wrap{margin-top:var(--gap)}
|
||||||
main{padding-bottom:100px}.card{background:var(--row);border:1px solid var(--line);border-radius:16px;padding:25px;margin-top:24px}.card .viz-head h2{font-size:19px}.card .viz-head .eyebrow{margin:0}
|
main{padding-bottom:100px}.card{background:var(--row);border:1px solid var(--line);border-radius:16px;padding:25px;margin-top:24px}.card .viz-head h2{font-size:19px}.card .viz-head .eyebrow{margin:0}
|
||||||
|
.badge{display:inline-flex;align-items:center;gap:6px;font-family:var(--f-mono);font-size:11px;letter-spacing:.06em;text-transform:uppercase;padding:4px 9px;border-radius:999px;border:1px solid var(--line-2);color:var(--ink-2);margin:0 6px 6px 0}
|
||||||
|
.badge.ok{border-color:var(--green,#5CC48A);color:var(--green,#5CC48A)}.badge.hot{border-color:var(--molten);color:var(--molten)}.badge.bad{border-color:var(--excluded);color:var(--excluded)}.badge.wait{color:var(--ash)}
|
||||||
|
.bar{display:inline-block;width:72px;height:7px;border-radius:4px;background:var(--line);vertical-align:middle;margin-right:7px;overflow:hidden}.bar i{display:block;height:100%;background:var(--green,#5CC48A)}.bar i.part{background:var(--molten)}
|
||||||
|
.dagbox{position:relative;width:100%;height:320px;border:1px solid var(--line);border-radius:12px;background:var(--obsidian);overflow:hidden}.dagbox canvas{width:100%;height:100%;display:block;cursor:pointer}
|
||||||
|
.dagtip{position:absolute;pointer-events:none;font:11px/1.5 var(--mono);color:var(--bone);background:var(--graphite);border:1px solid var(--line-2);border-radius:8px;padding:8px 10px;max-width:300px;display:none}
|
||||||
|
.kv{color:var(--ash)}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body class="off">
|
<body class="off">
|
||||||
|
|
@ -130,8 +136,8 @@ main{padding-bottom:100px}.card{background:var(--row);border:1px solid var(--lin
|
||||||
</div>
|
</div>
|
||||||
<div class="nav-panel" id="nav-panel-network" data-group="network" role="region" aria-labelledby="nav-btn-network" hidden>
|
<div class="nav-panel" id="nav-panel-network" data-group="network" role="region" aria-labelledby="nav-btn-network" hidden>
|
||||||
<div class="panel-list">
|
<div class="panel-list">
|
||||||
<a href="/live" data-nav="live" aria-current="page"><b>Live devnet</b><span>The chain as it grows, one lane per miner.</span></a>
|
<a href="/live" data-nav="live"><b>Live devnet</b><span>The chain as it grows, one lane per miner.</span></a>
|
||||||
<a href="/explorer" data-nav="explorer"><b>Explorer</b><span>Look a block or an address up.</span></a>
|
<a href="/explorer" data-nav="explorer" aria-current="page"><b>Explorer</b><span>Look a block or an address up.</span></a>
|
||||||
<a href="/journey" data-nav="journey"><b>The journey</b><span>Six phases, four gates, the log behind them.</span></a>
|
<a href="/journey" data-nav="journey"><b>The journey</b><span>Six phases, four gates, the log behind them.</span></a>
|
||||||
<a href="/bench" data-nav="bench"><b>Engineering log</b><span>Every measurement, with the commands that made it.</span></a>
|
<a href="/bench" data-nav="bench"><b>Engineering log</b><span>Every measurement, with the commands that made it.</span></a>
|
||||||
<a href="/evidence" data-nav="evidence"><b>Evidence</b><span>Every claim and how far it is tested.</span></a>
|
<a href="/evidence" data-nav="evidence"><b>Evidence</b><span>Every claim and how far it is tested.</span></a>
|
||||||
|
|
@ -172,8 +178,8 @@ main{padding-bottom:100px}.card{background:var(--row);border:1px solid var(--lin
|
||||||
<a href="/metamask" data-nav="metamask"><b>Add to MetaMask</b><span>Igneum as a network in your wallet.</span></a>
|
<a href="/metamask" data-nav="metamask"><b>Add to MetaMask</b><span>Igneum as a network in your wallet.</span></a>
|
||||||
</div>
|
</div>
|
||||||
<div class="sheet-group"><div class="sheet-head">Network</div>
|
<div class="sheet-group"><div class="sheet-head">Network</div>
|
||||||
<a href="/live" data-nav="live" aria-current="page"><b>Live devnet</b><span>The chain as it grows, one lane per miner.</span></a>
|
<a href="/live" data-nav="live"><b>Live devnet</b><span>The chain as it grows, one lane per miner.</span></a>
|
||||||
<a href="/explorer" data-nav="explorer"><b>Explorer</b><span>Look a block or an address up.</span></a>
|
<a href="/explorer" data-nav="explorer" aria-current="page"><b>Explorer</b><span>Look a block or an address up.</span></a>
|
||||||
<a href="/journey" data-nav="journey"><b>The journey</b><span>Six phases, four gates, the log behind them.</span></a>
|
<a href="/journey" data-nav="journey"><b>The journey</b><span>Six phases, four gates, the log behind them.</span></a>
|
||||||
<a href="/bench" data-nav="bench"><b>Engineering log</b><span>Every measurement, with the commands that made it.</span></a>
|
<a href="/bench" data-nav="bench"><b>Engineering log</b><span>Every measurement, with the commands that made it.</span></a>
|
||||||
<a href="/evidence" data-nav="evidence"><b>Evidence</b><span>Every claim and how far it is tested.</span></a>
|
<a href="/evidence" data-nav="evidence"><b>Evidence</b><span>Every claim and how far it is tested.</span></a>
|
||||||
|
|
@ -251,11 +257,11 @@ main{padding-bottom:100px}.card{background:var(--row);border:1px solid var(--lin
|
||||||
<main id="main" class="wrap">
|
<main id="main" class="wrap">
|
||||||
<div class="head">
|
<div class="head">
|
||||||
<div class="breadcrumb"><a href="/">Igneum</a><span>/</span><span>Explorer</span></div>
|
<div class="breadcrumb"><a href="/">Igneum</a><span>/</span><span>Explorer</span></div>
|
||||||
<div class="eyebrow"><span class="line"></span>devnet, last 24 hours</div>
|
<div class="eyebrow"><span class="line"></span>Devnet 3, chain id 4463</div>
|
||||||
<h1>Explorer<span class="accent">.</span></h1>
|
<h1>Devnet 3 explorer<span class="accent">.</span></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, or click any row.</p>
|
<p>Every block Devnet 3 made in the last day and every transaction of the last seven days, read from a node by the observer and the indexer. Paste a block hash, a transaction hash, a chain block number or an address, or click any row. Devnet 3 is a test network: its coins have no value and the chain may reset. Every number here comes from one node, the observer node on the build box, the chain the finality certificates follow; the indexer reads that node's execution RPC and nothing else.</p>
|
||||||
<form class="search" id="search" role="search">
|
<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">
|
<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 Devnet 3 explorer">
|
||||||
<button class="btn primary" type="submit">Search</button>
|
<button class="btn primary" type="submit">Search</button>
|
||||||
</form>
|
</form>
|
||||||
<div class="msg" id="msg" aria-live="polite"></div>
|
<div class="msg" id="msg" aria-live="polite"></div>
|
||||||
|
|
@ -264,32 +270,53 @@ main{padding-bottom:100px}.card{background:var(--row);border:1px solid var(--lin
|
||||||
<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 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">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">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" id="st-diff">difficulty 0</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">Chain rate, 10 min</div><div class="v" id="st-rate">0<small>blocks/s</small></div><div class="s" id="st-rate-s">DAG blocks per second; chain blocks per second</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">Finality</div><div class="v" id="st-fin">n/a</div><div class="s" id="st-fin-s">voters and weight</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">Program class</div><div class="v" id="st-class">v4</div><div class="s" id="st-class-s">the hash program family at the tip</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 class="cell"><div class="k">Proving</div><div class="v" id="st-prov">0<small>paid</small></div><div class="s" id="st-prov-s">shards paid in 10 min</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="card" aria-label="The DAG">
|
||||||
|
<div class="viz-head"><h2>The DAG</h2><div class="eyebrow" id="dag-eyebrow">the newest 150 blocks, one lane per miner</div></div>
|
||||||
|
<div class="dagbox"><canvas id="dag" aria-label="The last 150 blocks of Devnet 3 as a graph: time left to right, one lane per miner, lines to parents"></canvas><div class="dagtip" id="dag-tip"></div></div>
|
||||||
|
<div class="legend" style="margin-top:12px"><span><i class="sw chain"></i>chain block</span><span><i class="sw blue"></i>blue, merged</span><span><i class="sw red"></i>red, excluded</span><span><i class="sw"></i>pending</span><span>◆ under a lock</span><span>● every shard paid</span></div>
|
||||||
|
<p class="note">Left to right is arrival order at the observer; a line joins a block to each parent in the picture. Blue blocks are merged and paid; red blocks are excluded from the order. A diamond outline marks a block under a finality lock, a filled dot a block whose every planned shard has been paid. Click a block for its page.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="card" aria-label="Latest blocks">
|
<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="viz-head"><h2>Latest blocks</h2><div class="eyebrow" id="blk-eyebrow">newest first, every 5 s</div></div>
|
||||||
<div class="tbl">
|
<div class="tbl">
|
||||||
<table>
|
<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>
|
<thead><tr><th>Block</th><th class="n">Number</th><th class="n">DAA</th><th class="n">Blue score</th><th>Class</th><th>Miner</th><th class="n">Txs</th><th>Shards paid</th><th>Final</th><th>Time</th></tr></thead>
|
||||||
<tbody id="blocks"><tr><td colspan="8" class="empty">Loading.</td></tr></tbody>
|
<tbody id="blocks"><tr><td colspan="10" class="empty">Loading.</td></tr></tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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>
|
<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>
|
<p class="note">Number is the chain block number, the same number the EVM reports; a block off the selected chain has none. Class is the hash program family the block was mined under. Txs is what the block carries; the EVM executes each transaction once, in the first chain block that includes it. Shards paid counts the block's proof shards a later coinbase has paid. Final means a finality lock covers the block.</p>
|
||||||
<button class="btn" id="more" type="button" style="margin-top:14px">Older blocks</button>
|
<button class="btn" id="more" type="button" style="margin-top:14px">Older blocks</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="card" aria-label="Public API">
|
<div class="card" aria-label="Latest transactions">
|
||||||
<div class="viz-head"><h2>Public API</h2><div class="eyebrow">JSON, cached 10 s</div></div>
|
<div class="viz-head"><h2>Latest transactions</h2><div class="eyebrow" id="tx-eyebrow">newest first, every 5 s</div></div>
|
||||||
<dl>
|
<div class="tbl">
|
||||||
<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>
|
<table>
|
||||||
<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>
|
<thead><tr><th>Transaction</th><th class="n">Block</th><th>From</th><th>To</th><th class="n">Value</th><th>Status</th><th>Time</th></tr></thead>
|
||||||
<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>
|
<tbody id="txs"><tr><td colspan="7" class="empty">Loading.</td></tr></tbody>
|
||||||
</dl>
|
</table>
|
||||||
<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>
|
||||||
|
<p class="note">EVM transactions as the indexer read them from the node's receipts: seven days are kept. Value is in IGN (18 decimals on the EVM side).</p>
|
||||||
|
<button class="btn" id="more-tx" type="button" style="margin-top:14px">Older transactions</button>
|
||||||
|
</div>
|
||||||
|
<div class="two two-wrap">
|
||||||
|
<div class="card" aria-label="Proving"><div class="viz-head"><h2>Proving</h2><div class="eyebrow" id="pv-eyebrow">shards, provers, latency</div></div><dl id="pv"></dl><p class="note"><a href="/proving">The proving view</a>: every chain block's shard plan, who proved what, and the block-to-paid latency.</p></div>
|
||||||
|
<div class="card" aria-label="Public API"><div class="viz-head"><h2>Public API</h2><div class="eyebrow">JSON, cached 5 to 10 s</div></div>
|
||||||
|
<dl>
|
||||||
|
<dt>/api/stats</dt><dd><a href="/api/stats" class="hash">hash rate, difficulty, block time, reward, DAA, blue score, height</a></dd>
|
||||||
|
<dt>/api/supply</dt><dd><a href="/api/supply" class="hash">circulating by the emission rule, the cap, the halving table</a></dd>
|
||||||
|
<dt>/api/explorer</dt><dd><a href="/api/explorer?blocks=20" class="hash">?blocks=N, ?block=hash, ?height=N, ?tx=hash, ?txs=N, ?address=addr, ?proving=1, ?stats=1, ?dag=N, ?search=q</a></dd>
|
||||||
|
<dt>/api/checkpoint</dt><dd><a href="/api/checkpoint" class="hash">the newest certified checkpoint with its voter table and headers; ?index=N for one</a></dd>
|
||||||
|
<dt>/api/live</dt><dd><a href="/api/live" class="hash">the observer's feed: blocks of the last 90 s, miners, events, finality, proving</a></dd>
|
||||||
|
</dl>
|
||||||
|
<p class="note">Every answer names the network (Devnet 3, chain id 4463). The RPC itself is at rpc.devnet.igneum.network (read methods and eth_sendRawTransaction, 20 requests per second per address).</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
<!-- footer:start -->
|
<!-- footer:start -->
|
||||||
|
|
@ -363,11 +390,12 @@ main{padding-bottom:100px}.card{background:var(--row);border:1px solid var(--lin
|
||||||
</script>
|
</script>
|
||||||
<!-- footer:end -->
|
<!-- footer:end -->
|
||||||
<script type="module">
|
<script type="module">
|
||||||
import { classify, hrefFor, ign, shortHash, hashrate, rel, int } from '/lib/explorer.mjs';
|
import { classify, hrefFor, ign, ignFromWei, shortHash, hashrate, rel, int } from '/lib/explorer.mjs';
|
||||||
const $ = id => document.getElementById(id);
|
const $ = id => document.getElementById(id);
|
||||||
const esc = s => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
const esc = s => String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
let oldest = 0, serverNow = 0, serverAt = 0;
|
let oldest = 0, oldestTx = 0, serverNow = 0, serverAt = 0;
|
||||||
const vnow = () => serverNow + (performance.now() - serverAt);
|
const vnow = () => serverNow + (performance.now() - serverAt);
|
||||||
|
const stamp = iso => iso ? new Date(iso).toISOString().slice(11, 19) + ' UTC' : '';
|
||||||
$('search').addEventListener('submit', async e => {
|
$('search').addEventListener('submit', async e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const c = classify($('q').value);
|
const c = classify($('q').value);
|
||||||
|
|
@ -380,46 +408,109 @@ $('search').addEventListener('submit', async e => {
|
||||||
if (j.ok && j.href) location.href = j.href; else $('msg').textContent = j.error || 'Not found.';
|
if (j.ok && j.href) location.href = j.href; else $('msg').textContent = j.error || 'Not found.';
|
||||||
} catch { $('msg').textContent = 'The API did not answer.'; }
|
} catch { $('msg').textContent = 'The API did not answer.'; }
|
||||||
});
|
});
|
||||||
|
const minerCell = b => (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>')) + `<span class="id" style="margin-left:8px;font-size:12px">${esc(b.miner_id || '')}</span>`;
|
||||||
|
function shardsCell(b) {
|
||||||
|
const s = b.shards; if (!s || !s.total) return b.chain ? '<span class="kv">no plan yet</span>' : '<span class="kv">not a chain block</span>';
|
||||||
|
const pct = Math.round(s.paid / s.total * 100);
|
||||||
|
return `<span class="bar" title="${s.paid} paid, ${s.verified} verified, ${s.proving} proving, ${s.planned} planned"><i class="${s.paid === s.total ? '' : 'part'}" style="width:${pct}%"></i></span>${s.paid} of ${s.total}`;
|
||||||
|
}
|
||||||
function rowHtml(b) {
|
function rowHtml(b) {
|
||||||
const sw = b.chain ? 'chain' : b.color;
|
const replaced = b.chain && b.chain_now === false, sw = replaced ? '' : (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="${replaced ? 'was the chain block of its number, replaced by a tip reorg' : (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 class="kv">off chain</span>' : (replaced ? `<span class="kv" title="a tip reorg gave this number to another block">${int(b.number)}, replaced</span>` : int(b.number))}</td><td class="n">${int(b.daa)}</td><td class="n">${int(b.blue_score)}</td><td><span class="chip">${esc(b.class || 'v4')}</span></td><td>${minerCell(b)}</td><td class="n">${b.tx_count === null ? 'n/a' : b.tx_count}</td><td>${shardsCell(b)}</td><td>${b.final ? '<span class="chip ok">final</span>' : '<span class="kv">not yet</span>'}</td><td title="${new Date(b.ts).toISOString()}">${rel(vnow() - b.ts)}</td></tr>`;
|
||||||
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) {
|
async function loadBlocks(before) {
|
||||||
const r = await fetch('/api/explorer?blocks=50' + (before ? '&before=' + 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);
|
const j = await r.json(); if (!j.ok) throw new Error(j.error);
|
||||||
serverNow = new Date(j.now).getTime(); serverAt = performance.now();
|
serverNow = new Date(j.now).getTime(); serverAt = performance.now();
|
||||||
const html = j.blocks.map(rowHtml).join('');
|
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 (before) $('blocks').insertAdjacentHTML('beforeend', html); else $('blocks').innerHTML = html || '<tr><td colspan="10" class="empty">No blocks in the last 24 hours.</td></tr>';
|
||||||
if (j.blocks.length) oldest = j.blocks[j.blocks.length - 1].rx;
|
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 : '');
|
$('blk-eyebrow').textContent = (j.stale ? 'observer offline, last known' : 'newest first, every 5 s') + (j.lock ? `, lock ${int(j.lock.index)} at blue score ${int(j.lock.blue_score)}` : ', no lock yet');
|
||||||
}
|
}
|
||||||
$('blocks').addEventListener('click', e => { const tr = e.target.closest('tr[data-href]'); if (tr && !e.target.closest('a')) location.href = tr.dataset.href; });
|
function txHtml(t) {
|
||||||
|
return `<tr data-href="/tx/${t.hash}"><td><a class="hash" href="/tx/${t.hash}">${esc(shortHash(t.hash, 10, 6))}</a></td><td class="n"><a href="/block/${t.block_hash}">${int(t.number)}</a></td><td><a class="hash" href="/address/${t.from}" title="${esc(t.from)}">${esc(shortHash(t.from, 6, 4))}</a></td><td>${t.to ? `<a class="hash" href="/address/${t.to}" title="${esc(t.to)}">${esc(shortHash(t.to, 6, 4))}</a>` : (t.contract_address ? `<span class="kv">created </span><a class="hash" href="/address/${t.contract_address}">${esc(shortHash(t.contract_address, 6, 4))}</a>` : '<span class="kv">none</span>')}</td><td class="n">${ignFromWei(t.value_wei, 4)} IGN</td><td>${t.status === 'success' ? '<span class="chip ok">ok</span>' : t.status === 'failed' ? '<span class="chip" style="border-color:var(--excluded);color:var(--excluded)">failed</span>' : '<span class="kv">no receipt</span>'}</td><td title="${t.ts ? new Date(t.ts).toISOString() : ''}">${t.ts ? rel(vnow() - t.ts) : 'n/a'}</td></tr>`;
|
||||||
|
}
|
||||||
|
async function loadTxs(before) {
|
||||||
|
const r = await fetch('/api/explorer?txs=30' + (before ? '&before=' + before : ''));
|
||||||
|
const j = await r.json(); if (!j.ok) throw new Error(j.error);
|
||||||
|
const html = j.txs.map(txHtml).join('');
|
||||||
|
if (before) $('txs').insertAdjacentHTML('beforeend', html); else $('txs').innerHTML = html || '<tr><td colspan="7" class="empty">No transactions indexed yet.</td></tr>';
|
||||||
|
if (j.txs.length) oldestTx = j.txs[j.txs.length - 1].number;
|
||||||
|
const ix = j.indexer || {};
|
||||||
|
$('tx-eyebrow').textContent = ix.stale ? 'indexer stale, last known' : `newest first, every 5 s; ${int(ix.txs_total)} transactions and ${int(ix.accounts_total)} accounts in seven days, indexed to chain block ${int(ix.indexed_number)}`;
|
||||||
|
}
|
||||||
|
for (const [tb] of [['blocks'], ['txs']]) $(tb).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); }); });
|
$('more').addEventListener('click', () => { $('more').disabled = true; loadBlocks(oldest).catch(() => {}).finally(() => { $('more').disabled = false; clearInterval(timer); }); });
|
||||||
|
$('more-tx').addEventListener('click', () => { $('more-tx').disabled = true; loadTxs(oldestTx).catch(() => {}).finally(() => { $('more-tx').disabled = false; clearInterval(timerTx); }); });
|
||||||
async function loadStats() {
|
async function loadStats() {
|
||||||
const [s, p] = await Promise.all([fetch('/api/stats').then(r => r.json()), fetch('/api/supply').then(r => r.json())]);
|
const s = await fetch('/api/explorer?stats=1').then(r => r.json()); if (!s.ok) throw new Error(s.error);
|
||||||
document.body.classList.toggle('on', !s.stale); document.body.classList.toggle('off', !!s.stale);
|
document.body.classList.toggle('on', !s.stale); document.body.classList.toggle('off', !!s.stale);
|
||||||
$('st-status').textContent = s.stale ? 'OFFLINE' : 'LIVE';
|
$('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-age').textContent = s.stale ? 'observer stale' : `${s.network || 'Devnet 3'}, node ${s.node_version || '?'}, read ${stamp(s.now)}`;
|
||||||
$('st-height').textContent = s.height === null ? 'n/a' : int(s.height);
|
$('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-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-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-hps').textContent = hashrate(s.hashrate); $('st-diff').textContent = 'difficulty ' + (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-rate').innerHTML = esc(s.blocks_per_s_10m === null ? 'n/a' : s.blocks_per_s_10m.toFixed(2)) + '<small>blocks/s</small>';
|
||||||
$('st-hps').textContent = hashrate(s.hashrate);
|
$('st-rate-s').textContent = `${s.chain_blocks_per_s_10m === null ? 'n/a' : s.chain_blocks_per_s_10m.toFixed(2)} chain blocks per second, ${int(s.miners_10m)} miners in 10 min`;
|
||||||
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`; }
|
const f = s.finality;
|
||||||
if (p.ok) {
|
if (f) { $('st-fin').innerHTML = f.latest_locked_index === null ? 'no lock' : `lock ${int(f.latest_locked_index)}`; $('st-fin-s').textContent = `${int(f.voters)} voters, ${int(f.total_weight)} blocks of weight${s.lock ? `, ${(s.lock.fraction_total * 100).toFixed(1)}% signed the newest lock` : ''}${f.active ? '' : ', finality paused'}`; }
|
||||||
$('st-supply').innerHTML = esc(int(Number(p.circulating_ign))) + '<small>IGN</small>';
|
$('st-class').textContent = s.class ? s.class.class : 'v4';
|
||||||
$('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 next = s.class && s.class.floors ? s.class.floors.find(x => x.daa > (s.daa || 0)) : null;
|
||||||
const d = p.halving.next_halving_in_s / 86400;
|
$('st-class-s').textContent = next ? `${next.class} from DAA ${int(next.daa)}` : (s.class && s.class.floors && s.class.floors.length ? `since DAA ${int(s.class.floors[s.class.floors.length - 1].daa)}` : 'the hash program family at the tip');
|
||||||
$('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>';
|
const p = s.proving || {};
|
||||||
$('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)}`;
|
$('st-prov').innerHTML = esc(int(p.shards_paid_10m ?? 0)) + '<small>paid</small>';
|
||||||
}
|
$('st-prov-s').textContent = p.supported === false ? 'the node reports no proving layer' : `shards paid in 10 min, ${int(p.provers_10m ?? 0)} provers, ${int(p.open_blocks ?? 0)} open blocks`;
|
||||||
}
|
}
|
||||||
|
async function loadProving() {
|
||||||
|
const j = await fetch('/api/explorer?proving=1').then(r => r.json()); if (!j.ok) throw new Error(j.error);
|
||||||
|
const p = j.proving || {}, l = j.latency_1h || {}, t = j.totals_24h || {};
|
||||||
|
const dl = pairs => pairs.map(([k, v]) => `<dt>${esc(k)}</dt><dd>${v}</dd>`).join('');
|
||||||
|
$('pv-eyebrow').textContent = `read ${stamp(j.now)}`;
|
||||||
|
$('pv').innerHTML = dl([
|
||||||
|
['Paid, 24 h', `${int(t.paid ? t.paid.shards : 0)} shards, ${ignFromWei(t.paid ? t.paid.payout_wei : '0', 2)} IGN to provers`],
|
||||||
|
['Open now', `${int(t.planned ? t.planned.shards : 0)} planned, ${int(t.proving ? t.proving.shards : 0)} proving, ${int(t.verified ? t.verified.shards : 0)} verified and unpaid`],
|
||||||
|
['Provers, 24 h', `${int(j.provers.length)} keys paid`],
|
||||||
|
['Latency, 1 h', l.paid ? `${int(l.paid)} paid; p50 ${int(l.p50_daa)} DAA, p90 ${int(l.p90_daa)} DAA from the block to its payer` : 'no shard paid in the last hour'],
|
||||||
|
['Verifier', p.verifier ? 'the observer node checks every record it sees' : 'n/a'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
// the DAG picture: arrival order left to right, one lane per miner, links to parents in the picture
|
||||||
|
const dagTip = $('dag-tip'); let dagBlocks = [], dagPos = new Map();
|
||||||
|
function drawDag() {
|
||||||
|
const cv = $('dag'); if (!cv || !dagBlocks.length) return;
|
||||||
|
const dpr = Math.min(devicePixelRatio || 1, 2), W = cv.clientWidth, H = cv.clientHeight; cv.width = W * dpr; cv.height = H * dpr;
|
||||||
|
const ctx = cv.getContext('2d'); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, W, H);
|
||||||
|
const cs = getComputedStyle(document.documentElement), col = k => cs.getPropertyValue(k).trim();
|
||||||
|
const lanes = []; const laneOf = new Map();
|
||||||
|
for (const b of dagBlocks) { const m = b.miner_id || '?'; if (!laneOf.has(m)) { laneOf.set(m, lanes.length); lanes.push(m); } }
|
||||||
|
const n = dagBlocks.length, padX = 18, padY = 16, lh = Math.max(14, (H - 2 * padY) / Math.max(1, lanes.length)), xs = (W - 2 * padX) / Math.max(1, n - 1);
|
||||||
|
dagPos = new Map(); dagBlocks.forEach((b, i) => dagPos.set(b.hash, { x: padX + i * xs, y: padY + laneOf.get(b.miner_id || '?') * lh + lh / 2, b }));
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
for (const b of dagBlocks) { const p = dagPos.get(b.hash); for (const ph of b.parent_hashes || []) { const q = dagPos.get(ph); if (!q) continue; ctx.strokeStyle = b.chain && ph === b.selected_parent ? col('--ember') : 'rgba(154,154,158,.35)'; ctx.beginPath(); ctx.moveTo(p.x, p.y); ctx.lineTo(q.x, q.y); ctx.stroke(); } }
|
||||||
|
for (const b of dagBlocks) {
|
||||||
|
const p = dagPos.get(b.hash), r = b.chain ? 5 : 3.5;
|
||||||
|
ctx.fillStyle = b.chain && b.chain_now !== false ? col('--ember') : b.color === 'blue' ? col('--included') : b.color === 'red' ? col('--excluded') : col('--ash');
|
||||||
|
ctx.beginPath(); ctx.arc(p.x, p.y, r, 0, Math.PI * 2); ctx.fill();
|
||||||
|
if (b.final) { ctx.strokeStyle = col('--molten'); ctx.beginPath(); ctx.moveTo(p.x, p.y - r - 4); ctx.lineTo(p.x + r + 4, p.y); ctx.lineTo(p.x, p.y + r + 4); ctx.lineTo(p.x - r - 4, p.y); ctx.closePath(); ctx.stroke(); }
|
||||||
|
if (b.shards && b.shards.total && b.shards.paid === b.shards.total) { ctx.fillStyle = col('--bone'); ctx.beginPath(); ctx.arc(p.x, p.y, 1.6, 0, Math.PI * 2); ctx.fill(); }
|
||||||
|
}
|
||||||
|
ctx.font = '10px ' + (col('--mono') || 'monospace'); ctx.fillStyle = col('--ash');
|
||||||
|
lanes.forEach((m, i) => ctx.fillText(m, 4, padY + i * lh + lh / 2 - 7));
|
||||||
|
}
|
||||||
|
function nearest(ev) { const cv = $('dag'), r = cv.getBoundingClientRect(), x = ev.clientX - r.left, y = ev.clientY - r.top; let best = null, bd = 144; for (const p of dagPos.values()) { const d = (p.x - x) ** 2 + (p.y - y) ** 2; if (d < bd) { bd = d; best = p; } } return best; }
|
||||||
|
$('dag').addEventListener('mousemove', ev => { const p = nearest(ev); if (!p) { dagTip.style.display = 'none'; return; } const b = p.b, s = b.shards; dagTip.style.display = 'block'; dagTip.style.left = Math.min(p.x + 12, $('dag').clientWidth - 310) + 'px'; dagTip.style.top = (p.y + 12) + 'px'; dagTip.innerHTML = `${esc(shortHash(b.hash, 10, 6))}<br>${b.chain ? (b.chain_now === false ? 'replaced chain block ' + int(b.number) : 'chain block ' + int(b.number)) : esc(b.color)}, blue score ${int(b.blue_score)}, DAA ${int(b.daa)}<br>miner ${esc(b.miner_id || '?')}, class ${esc(b.class || 'v4')}, ${b.parents} parent${b.parents === 1 ? '' : 's'}<br>${s && s.total ? `${s.paid} of ${s.total} shards paid` : (b.chain ? 'plan not read yet' : 'no shards (not a chain block)')}${b.final ? ', under a lock' : ''}`; });
|
||||||
|
$('dag').addEventListener('mouseleave', () => { dagTip.style.display = 'none'; });
|
||||||
|
$('dag').addEventListener('click', ev => { const p = nearest(ev); if (p) location.href = '/block/' + p.b.hash; });
|
||||||
|
async function loadDag() { const j = await fetch('/api/explorer?dag=150').then(r => r.json()); if (!j.ok) throw new Error(j.error); dagBlocks = j.blocks; $('dag-eyebrow').textContent = `the newest ${j.blocks.length} blocks, one lane per miner, read ${stamp(j.now)}`; drawDag(); }
|
||||||
|
window.addEventListener('resize', drawDag); document.addEventListener('igneum-theme', drawDag);
|
||||||
const timer = setInterval(() => { loadBlocks().catch(() => {}); }, 5000);
|
const timer = setInterval(() => { loadBlocks().catch(() => {}); }, 5000);
|
||||||
setInterval(() => { loadStats().catch(() => {}); }, 10000);
|
const timerTx = setInterval(() => { loadTxs().catch(() => {}); }, 5000);
|
||||||
loadBlocks().catch(e => { $('blocks').innerHTML = `<tr><td colspan="8" class="empty">${esc(e.message)}</td></tr>`; });
|
setInterval(() => { loadStats().catch(() => {}); loadProving().catch(() => {}); }, 10000);
|
||||||
loadStats().catch(() => {});
|
setInterval(() => { loadDag().catch(() => {}); }, 4000);
|
||||||
|
loadBlocks().catch(e => { $('blocks').innerHTML = `<tr><td colspan="10" class="empty">${esc(e.message)}</td></tr>`; });
|
||||||
|
loadTxs().catch(e => { $('txs').innerHTML = `<tr><td colspan="7" class="empty">${esc(e.message)}</td></tr>`; });
|
||||||
|
loadStats().catch(() => {}); loadProving().catch(() => {}); loadDag().catch(() => {});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -279,12 +279,12 @@
|
||||||
<article class="record"><div class="record-top"><time datetime="2026-10-05">5 Oct 2026</time><span class="badge">log</span></div><h3>Live devnet: real transactions, the first non-empty shard proven and…</h3><p>Live devnet: real transactions, the first non-empty shard proven and paid, and the exporter's block structure fixed</p></article>
|
<article class="record"><div class="record-top"><time datetime="2026-10-05">5 Oct 2026</time><span class="badge">log</span></div><h3>Live devnet: real transactions, the first non-empty shard proven and…</h3><p>Live devnet: real transactions, the first non-empty shard proven and paid, and the exporter's block structure fixed</p></article>
|
||||||
<article class="record"><div class="record-top"><time datetime="2026-10-05">5 Oct 2026</time><span class="badge">log</span></div><h3>The program id split</h3><p>The program id split: why the Apple M5 Max rejected the RTX 5090 Windows rig's proofs, and the verifier at 114 s</p></article>
|
<article class="record"><div class="record-top"><time datetime="2026-10-05">5 Oct 2026</time><span class="badge">log</span></div><h3>The program id split</h3><p>The program id split: why the Apple M5 Max rejected the RTX 5090 Windows rig's proofs, and the verifier at 114 s</p></article>
|
||||||
<article class="record"><div class="record-top"><time datetime="2026-10-05">5 Oct 2026</time><span class="badge">log</span></div><h3>Live devnet: the first shards proven, verified and paid</h3></article>
|
<article class="record"><div class="record-top"><time datetime="2026-10-05">5 Oct 2026</time><span class="badge">log</span></div><h3>Live devnet: the first shards proven, verified and paid</h3></article>
|
||||||
<article class="record"><div class="record-top"><time datetime="2026-10-04">4 Oct 2026</time><span class="badge">log</span></div><h3>Economy simulation: mining versus proving under stress</h3><p>Sim/economy: mining versus proving under stress, agent-based</p></article>
|
<article class="record"><div class="record-top"><time datetime="2026-10-04">4 Oct 2026</time><span class="badge">log</span></div><h3>Devnet v4 live: generator v2, two-thirds floor, fresh chain</h3><p>Devnet v4 cut-over: generator v2, 2/3 floor, three nodes and a seed on a fresh chain</p></article>
|
||||||
<article class="record"><div class="record-top"><time datetime="2026-10-04">4 Oct 2026</time><span class="badge">log</span></div><h3>Difficulty rule attacked seven ways</h3><p>Difficulty rule under attack: pool hopping, pulsed rental, timestamp stretching, short-lane oscillation, epoch games, polluted window, block flood</p></article>
|
<article class="record"><div class="record-top"><time datetime="2026-10-04">4 Oct 2026</time><span class="badge">log</span></div><h3>One-click Windows workers</h3><p>One-click Windows workers: what the Apple M5 Max could measure</p></article>
|
||||||
<article class="record"><div class="record-top"><time datetime="2026-10-04">4 Oct 2026</time><span class="badge">log</span></div><h3>Timestamp attack on the difficulty rule fixed</h3><p>Difficulty rule: timestamp attack fixed , simulator regression, 3-node forger test</p></article>
|
<article class="record"><div class="record-top"><time datetime="2026-10-04">4 Oct 2026</time><span class="badge">log</span></div><h3>First live hourly swap: no pause on Mac, NVIDIA or AMD</h3><p>First hourly program swap on the live devnet: compile-ahead, no pause, two cards</p></article>
|
||||||
<article class="record"><div class="record-top"><time datetime="2026-10-04">4 Oct 2026</time><span class="badge">log</span></div><h3>Devnet v4: nine branches merged into one node</h3><p>Devnet-v4 integration: nine branches merged, 3-node test network on the merged node, Windows cross-build</p></article>
|
<article class="record"><div class="record-top"><time datetime="2026-10-04">4 Oct 2026</time><span class="badge">log</span></div><h3>Proving: devnet v4 shards on the Apple M5 Max CPU, loaded machine</h3></article>
|
||||||
<article class="record"><div class="record-top"><time datetime="2026-10-04">4 Oct 2026</time><span class="badge">log</span></div><h3>Generator v2 adopted: every hash does 128 distinct reads</h3><p>Generator version 2 adopted: exact load count, fresh-source loads, program acceptance; every vector re-cut, three workers re-checked, 20,000-program census, devnet-v4 binaries rebuilt</p></article>
|
<article class="record"><div class="record-top"><time datetime="2026-10-04">4 Oct 2026</time><span class="badge">log</span></div><h3>First live finality lock: 77.4% of weight, 17 voters</h3><p>First finality lock on the live devnet: checkpoint 242 at 77.4% of all weight, two hours after genesis</p></article>
|
||||||
<article class="record"><div class="record-top"><time datetime="2026-10-04">4 Oct 2026</time><span class="badge">log</span></div><h3>First GPU proof of an Igneum block: 1.4 s on an RTX 5090</h3><p>Proving v0 on the RTX 5090: first GPU proof of an Igneum block</p></article></div>
|
<article class="record"><div class="record-top"><time datetime="2026-10-04">4 Oct 2026</time><span class="badge">log</span></div><h3>The gfx1036 worker fault and what the Apple M5 Max could and could…</h3><p>The gfx1036 worker fault and what the Apple M5 Max could and could not reproduce</p></article></div>
|
||||||
<p class="empty-state" data-filter-empty="log" hidden>Nothing in the log matches that.</p>
|
<p class="empty-state" data-filter-empty="log" hidden>Nothing in the log matches that.</p>
|
||||||
<p class="note">Every entry is a dated heading of <a href="/bench">the engineering log</a>, where the commands and the hardware are. The phases and their gates are the litepaper’s <a href="/litepaper#roadmap">roadmap</a>.</p>
|
<p class="note">Every entry is a dated heading of <a href="/bench">the engineering log</a>, where the commands and the hardware are. The phases and their gates are the litepaper’s <a href="/litepaper#roadmap">roadmap</a>.</p>
|
||||||
<div class="onward"><a href="/bench" class="btn">The engineering log</a><a href="/evidence" class="btn">Every claim and its status</a><a href="/live" class="btn">The live devnet</a></div>
|
<div class="onward"><a href="/bench" class="btn">The engineering log</a><a href="/evidence" class="btn">Every claim and its status</a><a href="/live" class="btn">The live devnet</a></div>
|
||||||
|
|
|
||||||
|
|
@ -222,33 +222,33 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "2026-10-04",
|
"date": "2026-10-04",
|
||||||
"text": "Sim/economy: mining versus proving under stress, agent-based",
|
"text": "Devnet v4 cut-over: generator v2, 2/3 floor, three nodes and a seed on a fresh chain",
|
||||||
"short": "Economy simulation: mining versus proving under stress"
|
"short": "Devnet v4 live: generator v2, two-thirds floor, fresh chain"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "2026-10-04",
|
"date": "2026-10-04",
|
||||||
"text": "Difficulty rule under attack: pool hopping, pulsed rental, timestamp stretching, short-lane oscillation, epoch games, polluted window, block flood",
|
"text": "One-click Windows workers: what the Apple M5 Max could measure",
|
||||||
"short": "Difficulty rule attacked seven ways"
|
"short": "One-click Windows workers"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "2026-10-04",
|
"date": "2026-10-04",
|
||||||
"text": "Difficulty rule: timestamp attack fixed , simulator regression, 3-node forger test",
|
"text": "First hourly program swap on the live devnet: compile-ahead, no pause, two cards",
|
||||||
"short": "Timestamp attack on the difficulty rule fixed"
|
"short": "First live hourly swap: no pause on Mac, NVIDIA or AMD"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "2026-10-04",
|
"date": "2026-10-04",
|
||||||
"text": "Devnet-v4 integration: nine branches merged, 3-node test network on the merged node, Windows cross-build",
|
"text": "Proving: devnet v4 shards on the Apple M5 Max CPU, loaded machine",
|
||||||
"short": "Devnet v4: nine branches merged into one node"
|
"short": "Proving: devnet v4 shards on the Apple M5 Max CPU, loaded machine"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "2026-10-04",
|
"date": "2026-10-04",
|
||||||
"text": "Generator version 2 adopted: exact load count, fresh-source loads, program acceptance; every vector re-cut, three workers re-checked, 20,000-program census, devnet-v4 binaries rebuilt",
|
"text": "First finality lock on the live devnet: checkpoint 242 at 77.4% of all weight, two hours after genesis",
|
||||||
"short": "Generator v2 adopted: every hash does 128 distinct reads"
|
"short": "First live finality lock: 77.4% of weight, 17 voters"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "2026-10-04",
|
"date": "2026-10-04",
|
||||||
"text": "Proving v0 on the RTX 5090: first GPU proof of an Igneum block",
|
"text": "The gfx1036 worker fault and what the Apple M5 Max could and could not reproduce",
|
||||||
"short": "First GPU proof of an Igneum block: 1.4 s on an RTX 5090"
|
"short": "The gfx1036 worker fault and what the Apple M5 Max could and could…"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
403
site/proving.html
Normal file
403
site/proving.html
Normal file
|
|
@ -0,0 +1,403 @@
|
||||||
|
<!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 Devnet 3 proving</title>
|
||||||
|
<meta name="description" content="Proving on Devnet 3: the shard plan of every chain block, who proved what, the payouts and the block-to-paid latency.">
|
||||||
|
<link rel="canonical" href="https://igneum.network/proving">
|
||||||
|
<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 Devnet 3 proving">
|
||||||
|
<meta property="og:description" content="Proving on Devnet 3: the shard plan of every chain block, who proved what, the payouts and the block-to-paid latency.">
|
||||||
|
<meta property="og:url" content="https://igneum.network/proving">
|
||||||
|
<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 Devnet 3 proving">
|
||||||
|
<meta name="twitter:description" content="Proving on Devnet 3: the shard plan of every chain block, who proved what, the payouts and the block-to-paid latency.">
|
||||||
|
<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>
|
||||||
|
<meta name="color-scheme" content="dark light">
|
||||||
|
<link rel="stylesheet" href="/site.css">
|
||||||
|
<style>
|
||||||
|
/* Shared by every page: the head partial, injected by the build 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. The tokens, type and components are in /site.css (docs/plans/site-ui-3.md). */
|
||||||
|
@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%}
|
||||||
|
</style>
|
||||||
|
<script>
|
||||||
|
/* theme before first paint: ?theme=light|dark for one view (the capture tool), else the stored choice under igneum-theme, else the system setting; ?motion=off marks the page data-motion=off so every .reveal is drawn at once (a headless render never scrolls) */
|
||||||
|
(function(){var t=null;try{t=new URLSearchParams(location.search).get("theme");}catch(e){}if(t!=="light"&&t!=="dark"){try{t=localStorage.getItem("igneum-theme");}catch(e){t=null;}}if(t!=="light"&&t!=="dark")t=window.matchMedia&&window.matchMedia("(prefers-color-scheme: light)").matches?"light":"dark";document.documentElement.setAttribute("data-theme",t);try{if(new URLSearchParams(location.search).get("motion")==="off")document.documentElement.setAttribute("data-motion","off");}catch(e){}})();
|
||||||
|
</script>
|
||||||
|
<!-- head:end -->
|
||||||
|
<style>
|
||||||
|
/* page block; tokens, type and components are in /site.css */
|
||||||
|
/* the explorer pages share this block with block.html and address.html; same tokens as live.html */
|
||||||
|
--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)}
|
||||||
|
a:focus-visible,button:focus-visible,input:focus-visible{outline:2px solid var(--ember);outline-offset:3px;border-radius:6px}
|
||||||
|
.mono{font-family:var(--f-mono)}
|
||||||
|
h1,h2{font-family:var(--f-display);margin:0;line-height:1.08;text-wrap:balance}
|
||||||
|
.head{padding:65px 0 45px;display:flex;flex-direction:column;gap:14px}
|
||||||
|
.head p{color:var(--ink-2);max-width:67ch;font-size:18px;line-height:1.65}
|
||||||
|
.search{display:flex;gap:10px;max-width:760px;margin-top:4px;align-items:center;padding:12px 16px;border:1px solid var(--line-2);border-radius:8px;background:var(--row)}
|
||||||
|
.search input{flex:1;min-width:0;border:0;outline:none;background:transparent;color:var(--bone);font-size:14px}
|
||||||
|
.search input::placeholder{color:var(--ash)}
|
||||||
|
.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));border:1px solid var(--line);border-radius:12px;overflow:hidden;margin-bottom:24px;background:var(--row)}
|
||||||
|
@media (max-width:700px){.strip{grid-template-columns:repeat(2,minmax(0,1fr))}.cell{border-right:1px solid var(--line)!important}.cell:nth-child(2n){border-right:0!important}.cell:nth-last-child(-n+4){border-bottom:1px solid var(--line)}.cell:nth-last-child(-n+2){border-bottom:0}}
|
||||||
|
.cell{padding:22px 25px;border-right:1px solid var(--line);border-bottom:1px solid var(--line);display:flex;flex-direction:column;gap:6px;min-width:0}.cell:nth-child(4n){border-right:0}.cell:nth-last-child(-n+4){border-bottom:0}
|
||||||
|
.cell .k{font:10px var(--mono);letter-spacing:.1em;text-transform:uppercase;color:var(--ash);margin-bottom:5px}
|
||||||
|
.cell .v{font:700 clamp(21px,2.2vw,29px)/1.1 var(--display);letter-spacing:-.04em;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:10px/1.5 var(--mono);color:var(--ash);text-wrap:balance;margin-top:4px}
|
||||||
|
.cell.status .v{display:flex;align-items:center;gap:10px}
|
||||||
|
.off .dot{background:var(--ash);animation:none}.off .cell.status .v{color:var(--ash)}.on .cell.status .v{color:var(--molten)}
|
||||||
|
@media (prefers-reduced-motion:reduce){.dot{animation:none}}
|
||||||
|
td.n,th.n{text-align:right;font-variant-numeric:tabular-nums}
|
||||||
|
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.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)}
|
||||||
|
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:100px}.card{background:var(--row);border:1px solid var(--line);border-radius:16px;padding:25px;margin-top:24px}.card .viz-head h2{font-size:19px}.card .viz-head .eyebrow{margin:0}
|
||||||
|
.badge{display:inline-flex;align-items:center;gap:6px;font-family:var(--f-mono);font-size:11px;letter-spacing:.06em;text-transform:uppercase;padding:4px 9px;border-radius:999px;border:1px solid var(--line-2);color:var(--ink-2);margin:0 6px 6px 0}
|
||||||
|
.badge.ok{border-color:var(--green,#5CC48A);color:var(--green,#5CC48A)}.badge.hot{border-color:var(--molten);color:var(--molten)}.badge.bad{border-color:var(--excluded);color:var(--excluded)}.badge.wait{color:var(--ash)}
|
||||||
|
.bar{display:inline-block;width:72px;height:7px;border-radius:4px;background:var(--line);vertical-align:middle;margin-right:7px;overflow:hidden}.bar i{display:block;height:100%;background:var(--green,#5CC48A)}.bar i.part{background:var(--molten)}
|
||||||
|
.dagbox{position:relative;width:100%;height:320px;border:1px solid var(--line);border-radius:12px;background:var(--obsidian);overflow:hidden}.dagbox canvas{width:100%;height:100%;display:block;cursor:pointer}
|
||||||
|
.dagtip{position:absolute;pointer-events:none;font:11px/1.5 var(--mono);color:var(--bone);background:var(--graphite);border:1px solid var(--line-2);border-radius:8px;padding:8px 10px;max-width:300px;display:none}
|
||||||
|
.kv{color:var(--ash)}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- nav:start -->
|
||||||
|
<a class="skip" href="#main">Skip to content</a>
|
||||||
|
<nav class="nav" aria-label="Main" data-nav-version="5">
|
||||||
|
<div class="container nav-row">
|
||||||
|
<a href="/" class="logo" aria-label="Igneum home">
|
||||||
|
<svg class="brand-mark" viewBox="0 0 1024 1024" aria-hidden="true"><rect width="1024" height="1024" rx="160" 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>
|
||||||
|
<span class="devnet" id="nav-devnet" title="igneum-devnet-3, chain id 4463. Coins have no value. The chain may reset."><span class="dot off" id="foot-dot"></span><span id="foot-state">devnet</span></span>
|
||||||
|
<div class="nav-groups" id="nav-groups" role="list">
|
||||||
|
<div class="nav-group" role="listitem"><button type="button" class="group-btn" id="nav-btn-mine" data-group="mine" aria-expanded="false" aria-controls="nav-panel-mine" aria-haspopup="true">Mine<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="m7 10 5 5 5-5"/></svg></button></div>
|
||||||
|
<div class="nav-group" role="listitem"><button type="button" class="group-btn" id="nav-btn-network" data-group="network" data-active aria-expanded="false" aria-controls="nav-panel-network" aria-haspopup="true">Network<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="m7 10 5 5 5-5"/></svg></button></div>
|
||||||
|
<div class="nav-group" role="listitem"><button type="button" class="group-btn" id="nav-btn-learn" data-group="learn" aria-expanded="false" aria-controls="nav-panel-learn" aria-haspopup="true">Learn<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="m7 10 5 5 5-5"/></svg></button></div>
|
||||||
|
<div class="nav-group" role="listitem"><button type="button" class="group-btn" id="nav-btn-build" data-group="build" aria-expanded="false" aria-controls="nav-panel-build" aria-haspopup="true">Build<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="m7 10 5 5 5-5"/></svg></button></div>
|
||||||
|
</div>
|
||||||
|
<div class="nav-controls">
|
||||||
|
<button class="btn icon-btn" type="button" data-theme-toggle aria-label="Switch to light theme"><svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v2m0 16v2M2 12h2m16 0h2M5 5l1 1m12 12 1 1M5 19l1-1M18 6l1-1"/></svg></button>
|
||||||
|
<a href="/download" class="btn primary small cta" data-nav="download">Download</a>
|
||||||
|
<button class="burger" id="nav-burger" type="button" aria-expanded="false" aria-controls="nav-sheet" aria-label="Open the menu" aria-haspopup="true"><svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path class="bars" d="M4 6h16M4 12h16M4 18h16"/><path class="cross" d="m6 6 12 12M6 18 18 6"/></svg></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="panels" id="nav-panels">
|
||||||
|
<div class="panel-wrap container">
|
||||||
|
<div class="nav-panel" id="nav-panel-mine" data-group="mine" role="region" aria-labelledby="nav-btn-mine" hidden>
|
||||||
|
<div class="panel-list">
|
||||||
|
<a href="/miner" data-nav="miner"><b>Ember, the miner</b><span>One click installs the node, the miner and the prover.</span></a>
|
||||||
|
<a href="/download" data-nav="download"><b>Download</b><span>Windows, macOS, Linux and HiveOS.</span></a>
|
||||||
|
<a href="/app" data-nav="app"><b>The app</b><span>What you see while the card mines.</span></a>
|
||||||
|
<a href="/wallet" data-nav="wallet"><b>The wallet</b><span>Your coins. Final means final.</span></a>
|
||||||
|
<a href="/miners" data-nav="miners"><b>GPU bench table</b><span>Measured rates, card by card.</span></a>
|
||||||
|
<a href="/dev-fee" data-nav="dev-fee"><b>The dev fee</b><span>The optional one, in full view, off with one flag.</span></a>
|
||||||
|
<a href="/metamask" data-nav="metamask"><b>Add to MetaMask</b><span>Igneum as a network in your wallet.</span></a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="nav-panel" id="nav-panel-network" data-group="network" role="region" aria-labelledby="nav-btn-network" hidden>
|
||||||
|
<div class="panel-list">
|
||||||
|
<a href="/live" data-nav="live"><b>Live devnet</b><span>The chain as it grows, one lane per miner.</span></a>
|
||||||
|
<a href="/explorer" data-nav="explorer" aria-current="page"><b>Explorer</b><span>Look a block or an address up.</span></a>
|
||||||
|
<a href="/journey" data-nav="journey"><b>The journey</b><span>Six phases, four gates, the log behind them.</span></a>
|
||||||
|
<a href="/bench" data-nav="bench"><b>Engineering log</b><span>Every measurement, with the commands that made it.</span></a>
|
||||||
|
<a href="/evidence" data-nav="evidence"><b>Evidence</b><span>Every claim and how far it is tested.</span></a>
|
||||||
|
<a href="/light" data-nav="light"><b>Light wallet</b><span>A balance your browser proves, trusting no node.</span></a>
|
||||||
|
<a href="/receipt" data-nav="receipt"><b>Payment receipt</b><span>A receipt any third party re-verifies offline.</span></a>
|
||||||
|
<a href="/oracle" data-nav="oracle"><b>State oracle on Sepolia</b><span>Devnet 3 balances read from another chain.</span></a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="nav-panel" id="nav-panel-learn" data-group="learn" role="region" aria-labelledby="nav-btn-learn" hidden>
|
||||||
|
<div class="panel-list">
|
||||||
|
<a href="/litepaper" data-nav="litepaper"><b>Litepaper</b><span>The design, as published.</span></a>
|
||||||
|
<a href="/income" data-nav="income"><b>Income per tier</b><span>IGN a day per card at three network sizes, and the electricity.</span></a>
|
||||||
|
<a href="/ledger" data-nav="ledger"><b>Ledger</b><span>Every criticism, answered or conceded.</span></a>
|
||||||
|
<a href="/claims" data-nav="claims"><b>What Igneum does not claim</b><span>The limits, stated first.</span></a>
|
||||||
|
<a href="/randomx" data-nav="randomx"><b>Igneum vs RandomX</b><span>What was kept and what was rebuilt for GPUs.</span></a>
|
||||||
|
<a href="/provenance" data-nav="provenance"><b>Built on the shoulders</b><span>Every borrowed part, credited.</span></a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="nav-panel" id="nav-panel-build" data-group="build" role="region" aria-labelledby="nav-btn-build" hidden>
|
||||||
|
<div class="panel-list">
|
||||||
|
<a href="/build" data-nav="build"><b>Build on Igneum</b><span>Chain ids, RPC, a contract in five minutes.</span></a>
|
||||||
|
<a href="/faucet" data-nav="faucet"><b>Devnet faucet</b><span>10 IGN of devnet coin, once a day.</span></a>
|
||||||
|
<a href="/swap" data-nav="swap"><b>Swap</b><span>Test tokens on Devnet 3; every swap is a proven block.</span></a>
|
||||||
|
<a href="/grants" data-nav="grants"><b>Grants</b><span>Tooling, apps, infrastructure, research.</span></a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="nav-sheet" id="nav-sheet" role="dialog" aria-modal="true" aria-label="Menu" hidden>
|
||||||
|
<div class="sheet-scroll">
|
||||||
|
<div class="sheet-group"><div class="sheet-head">Mine</div>
|
||||||
|
<a href="/miner" data-nav="miner"><b>Ember, the miner</b><span>One click installs the node, the miner and the prover.</span></a>
|
||||||
|
<a href="/download" data-nav="download"><b>Download</b><span>Windows, macOS, Linux and HiveOS.</span></a>
|
||||||
|
<a href="/app" data-nav="app"><b>The app</b><span>What you see while the card mines.</span></a>
|
||||||
|
<a href="/wallet" data-nav="wallet"><b>The wallet</b><span>Your coins. Final means final.</span></a>
|
||||||
|
<a href="/miners" data-nav="miners"><b>GPU bench table</b><span>Measured rates, card by card.</span></a>
|
||||||
|
<a href="/dev-fee" data-nav="dev-fee"><b>The dev fee</b><span>The optional one, in full view, off with one flag.</span></a>
|
||||||
|
<a href="/metamask" data-nav="metamask"><b>Add to MetaMask</b><span>Igneum as a network in your wallet.</span></a>
|
||||||
|
</div>
|
||||||
|
<div class="sheet-group"><div class="sheet-head">Network</div>
|
||||||
|
<a href="/live" data-nav="live"><b>Live devnet</b><span>The chain as it grows, one lane per miner.</span></a>
|
||||||
|
<a href="/explorer" data-nav="explorer" aria-current="page"><b>Explorer</b><span>Look a block or an address up.</span></a>
|
||||||
|
<a href="/journey" data-nav="journey"><b>The journey</b><span>Six phases, four gates, the log behind them.</span></a>
|
||||||
|
<a href="/bench" data-nav="bench"><b>Engineering log</b><span>Every measurement, with the commands that made it.</span></a>
|
||||||
|
<a href="/evidence" data-nav="evidence"><b>Evidence</b><span>Every claim and how far it is tested.</span></a>
|
||||||
|
<a href="/light" data-nav="light"><b>Light wallet</b><span>A balance your browser proves, trusting no node.</span></a>
|
||||||
|
<a href="/receipt" data-nav="receipt"><b>Payment receipt</b><span>A receipt any third party re-verifies offline.</span></a>
|
||||||
|
<a href="/oracle" data-nav="oracle"><b>State oracle on Sepolia</b><span>Devnet 3 balances read from another chain.</span></a>
|
||||||
|
</div>
|
||||||
|
<div class="sheet-group"><div class="sheet-head">Learn</div>
|
||||||
|
<a href="/litepaper" data-nav="litepaper"><b>Litepaper</b><span>The design, as published.</span></a>
|
||||||
|
<a href="/income" data-nav="income"><b>Income per tier</b><span>IGN a day per card at three network sizes, and the electricity.</span></a>
|
||||||
|
<a href="/ledger" data-nav="ledger"><b>Ledger</b><span>Every criticism, answered or conceded.</span></a>
|
||||||
|
<a href="/claims" data-nav="claims"><b>What Igneum does not claim</b><span>The limits, stated first.</span></a>
|
||||||
|
<a href="/randomx" data-nav="randomx"><b>Igneum vs RandomX</b><span>What was kept and what was rebuilt for GPUs.</span></a>
|
||||||
|
<a href="/provenance" data-nav="provenance"><b>Built on the shoulders</b><span>Every borrowed part, credited.</span></a>
|
||||||
|
</div>
|
||||||
|
<div class="sheet-group"><div class="sheet-head">Build</div>
|
||||||
|
<a href="/build" data-nav="build"><b>Build on Igneum</b><span>Chain ids, RPC, a contract in five minutes.</span></a>
|
||||||
|
<a href="/faucet" data-nav="faucet"><b>Devnet faucet</b><span>10 IGN of devnet coin, once a day.</span></a>
|
||||||
|
<a href="/swap" data-nav="swap"><b>Swap</b><span>Test tokens on Devnet 3; every swap is a proven block.</span></a>
|
||||||
|
<a href="/grants" data-nav="grants"><b>Grants</b><span>Tooling, apps, infrastructure, research.</span></a>
|
||||||
|
</div>
|
||||||
|
<div class="sheet-foot">
|
||||||
|
<a href="/download" class="btn primary big" data-nav="download">Download</a>
|
||||||
|
<div class="sheet-social"><a href="https://discord.gg/igneum" target="_blank" rel="noopener">Discord</a><a href="https://git.igneum.network/igneum-network/spec" target="_blank" rel="noopener">Source</a><a href="https://www.reddit.com/user/Igneum_network/" target="_blank" rel="noopener">Reddit</a></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
// the slim bar (site-ui-5, 7 Oct 2026): three group buttons open one panel each; the burger opens the sheet on phones.
|
||||||
|
// Keyboard: Enter or Space opens a group, Escape closes it and returns focus to its button, arrows move between the
|
||||||
|
// group buttons, Tab walks the open panel's links, a click outside closes. The theme toggle flips data-theme and stores it.
|
||||||
|
var n=document.querySelector('.nav');if(!n)return;
|
||||||
|
var btns=[].slice.call(n.querySelectorAll('.group-btn')),panels=[].slice.call(n.querySelectorAll('.nav-panel')),wrap=n.querySelector('.panels');
|
||||||
|
var burger=document.getElementById('nav-burger'),sheet=document.getElementById('nav-sheet');
|
||||||
|
function panelOf(g){return panels.filter(function(p){return p.getAttribute('data-group')===g;})[0];}
|
||||||
|
function closeAll(focusBack){var was=null;btns.forEach(function(b){if(b.getAttribute('aria-expanded')==='true')was=b;b.setAttribute('aria-expanded','false');});panels.forEach(function(p){p.hidden=true;});n.classList.remove('panel-open');if(focusBack&&was)was.focus();}
|
||||||
|
function open(g){closeAll(false);var b=btns.filter(function(x){return x.getAttribute('data-group')===g;})[0],p=panelOf(g);if(!b||!p)return;b.setAttribute('aria-expanded','true');p.hidden=false;n.classList.add('panel-open');anchor(b,p);}
|
||||||
|
// the panel sits under its button; a panel that would run past the container's right edge aligns its right edge to the button's right edge instead (never past the viewport)
|
||||||
|
function anchor(b,p){var pw=n.querySelector('.panel-wrap');if(!pw)return;var cr=pw.getBoundingClientRect(),br=b.getBoundingClientRect(),w=p.offsetWidth,left=br.left-cr.left;if(left+w>cr.width)left=(br.right-cr.left)-w;if(left<0)left=0;p.style.setProperty('--panel-left',Math.round(left)+'px');}
|
||||||
|
window.addEventListener('resize',function(){var o=btns.filter(function(x){return x.getAttribute('aria-expanded')==='true';})[0];if(o)anchor(o,panelOf(o.getAttribute('data-group')));}); btns.forEach(function(b,i){
|
||||||
|
b.addEventListener('click',function(){var on=b.getAttribute('aria-expanded')==='true';if(on)closeAll(false);else open(b.getAttribute('data-group'));});
|
||||||
|
b.addEventListener('keydown',function(e){
|
||||||
|
var k=e.key;
|
||||||
|
if(k==='ArrowRight'||k==='ArrowLeft'){e.preventDefault();var j=(i+(k==='ArrowRight'?1:btns.length-1))%btns.length;btns[j].focus();if(n.classList.contains('panel-open'))open(btns[j].getAttribute('data-group'));}
|
||||||
|
else if(k==='ArrowDown'){e.preventDefault();open(b.getAttribute('data-group'));var f=panelOf(b.getAttribute('data-group')).querySelector('a');if(f)f.focus();}
|
||||||
|
else if(k==='Escape'){closeAll(true);}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if(wrap)wrap.addEventListener('keydown',function(e){if(e.key==='Escape'){e.stopPropagation();closeAll(true);}});
|
||||||
|
document.addEventListener('click',function(e){if(!n.classList.contains('panel-open'))return;if(e.target.closest('.group-btn')||e.target.closest('.nav-panel'))return;closeAll(false);});
|
||||||
|
document.addEventListener('focusin',function(e){if(!n.classList.contains('panel-open'))return;if(n.contains(e.target))return;closeAll(false);});
|
||||||
|
document.addEventListener('keydown',function(e){if(e.key==='Escape'&&n.classList.contains('panel-open'))closeAll(true);});
|
||||||
|
// the sheet on phones
|
||||||
|
function setSheet(o){if(!burger||!sheet)return;n.classList.toggle('open',o);sheet.hidden=!o;burger.setAttribute('aria-expanded',o?'true':'false');burger.setAttribute('aria-label',o?'Close the menu':'Open the menu');document.documentElement.classList.toggle('nav-locked',o);if(o){var f=sheet.querySelector('a');if(f)f.focus();}}
|
||||||
|
if(burger&&sheet){
|
||||||
|
burger.addEventListener('click',function(){setSheet(!n.classList.contains('open'));});
|
||||||
|
sheet.addEventListener('click',function(e){if(e.target.closest('a'))setSheet(false);});
|
||||||
|
sheet.addEventListener('keydown',function(e){
|
||||||
|
if(e.key==='Escape'){setSheet(false);burger.focus();return;}
|
||||||
|
if(e.key!=='Tab')return;var f=sheet.querySelectorAll('a,button');if(!f.length)return;var first=f[0],last=f[f.length-1];
|
||||||
|
if(e.shiftKey&&document.activeElement===first){e.preventDefault();burger.focus();}else if(!e.shiftKey&&document.activeElement===last){e.preventDefault();burger.focus();}
|
||||||
|
});
|
||||||
|
burger.addEventListener('keydown',function(e){if(e.key==='Tab'&&!e.shiftKey&&n.classList.contains('open')){e.preventDefault();var f=sheet.querySelector('a');if(f)f.focus();}});
|
||||||
|
var mq=window.matchMedia('(min-width:861px)');if(mq.addEventListener)mq.addEventListener('change',function(){setSheet(false);});
|
||||||
|
}
|
||||||
|
// the theme toggle
|
||||||
|
var t=n.querySelector('[data-theme-toggle]');
|
||||||
|
function paintToggle(){var dark=document.documentElement.getAttribute('data-theme')!=='light';t.setAttribute('aria-label',dark?'Switch to light theme':'Switch to dark theme');t.innerHTML=dark?'<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v2m0 16v2M2 12h2m16 0h2M5 5l1 1m12 12 1 1M5 19l1-1M18 6l1-1"/></svg>':'<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M20.7 13.3A8.5 8.5 0 0 1 10.7 3.3 8.5 8.5 0 1 0 20.7 13.3Z"/></svg>';}
|
||||||
|
if(t){t.addEventListener('click',function(){var v=document.documentElement.getAttribute('data-theme')==='light'?'dark':'light';document.documentElement.setAttribute('data-theme',v);try{localStorage.setItem('igneum-theme',v);}catch(e){}document.dispatchEvent(new CustomEvent('igneum-theme'));});paintToggle();document.addEventListener('igneum-theme',paintToggle);}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<!-- nav:end -->
|
||||||
|
<main id="main" class="wrap">
|
||||||
|
<div class="head">
|
||||||
|
<div class="breadcrumb"><a href="/">Igneum</a><span>/</span><a href="/explorer">Explorer</a><span>/</span><span>Proving</span></div>
|
||||||
|
<div class="eyebrow"><a href="/explorer">Devnet 3 explorer</a> · proving</div>
|
||||||
|
<h1>Proving on Devnet 3<span class="accent">.</span></h1>
|
||||||
|
<p>Every chain block's execution is split into shards. Miners prove them, the node verifies the proofs, and a later coinbase pays the prover. This page reads the observer's record of that cycle: the plan per block, who proved what, and how long a block waits to be paid.</p>
|
||||||
|
<div class="msg" id="msg" aria-live="polite"></div>
|
||||||
|
</div>
|
||||||
|
<div class="strip" aria-live="polite">
|
||||||
|
<div class="cell status"><div class="k">Proving</div><div class="v"><span class="dot"></span><span id="p-status">OFFLINE</span></div><div class="s" id="p-status-s">the node's proving layer</div></div>
|
||||||
|
<div class="cell"><div class="k">Paid, 10 min</div><div class="v" id="p-paid10">0</div><div class="s" id="p-paid10-s">shards</div></div>
|
||||||
|
<div class="cell"><div class="k">Paid, 24 h</div><div class="v" id="p-paid24">0</div><div class="s" id="p-paid24-s">shards, IGN to provers</div></div>
|
||||||
|
<div class="cell"><div class="k">Open blocks</div><div class="v" id="p-open">0</div><div class="s" id="p-open-s">chain blocks with unpaid shards</div></div>
|
||||||
|
<div class="cell"><div class="k">Provers, 24 h</div><div class="v" id="p-provers">0</div><div class="s" id="p-provers-s">vote keys paid for a shard</div></div>
|
||||||
|
<div class="cell"><div class="k">Latency, 1 h</div><div class="v" id="p-lat">n/a</div><div class="s" id="p-lat-s">median DAA from block to payment</div></div>
|
||||||
|
<div class="cell"><div class="k">Pool</div><div class="v" id="p-pool">0</div><div class="s" id="p-pool-s">records in the node's pool</div></div>
|
||||||
|
<div class="cell"><div class="k">Shard budget</div><div class="v" id="p-budget">0<small>pgas</small></div><div class="s">proving gas per shard</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="card" aria-label="Chain blocks and their shards">
|
||||||
|
<div class="viz-head"><h2>Chain blocks</h2><div class="eyebrow" id="b-eyebrow">newest 60, every 5 s</div></div>
|
||||||
|
<div class="tbl">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th class="n">Number</th><th>Block</th><th class="n">DAA</th><th>Class</th><th>Miner</th><th class="n">Txs</th><th class="n">pgas</th><th>Shards paid</th><th>States</th><th class="n">Median lag, DAA</th><th>Time</th></tr></thead>
|
||||||
|
<tbody id="blocks"><tr><td colspan="11" class="empty">Loading.</td></tr></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p class="note">States count the block's shards as planned (the node published the plan), proving (a record is in the pool), verified (the node's verifier accepted the proof) and paid (a later coinbase carried the record). Lag is the DAA score of the paying block minus the block's own; Devnet 3 targets one DAA step per second.</p>
|
||||||
|
</div>
|
||||||
|
<div class="two two-wrap">
|
||||||
|
<div class="card" aria-label="Provers">
|
||||||
|
<div class="viz-head"><h2>Provers, 24 h</h2><div class="eyebrow" id="pr-eyebrow">by shards paid</div></div>
|
||||||
|
<div class="tbl"><table><thead><tr><th>Prover</th><th class="n">Shards</th><th class="n">Paid, IGN</th><th class="n">Median lag</th><th class="n">Last hour</th><th>Last paid</th></tr></thead><tbody id="provers"></tbody></table></div>
|
||||||
|
<p class="note">A prover is a vote key (its first 8 hex characters). A card runs several keys, so one machine can be several rows.</p>
|
||||||
|
</div>
|
||||||
|
<div class="card" aria-label="Latency">
|
||||||
|
<div class="viz-head"><h2>Block to paid</h2><div class="eyebrow" id="l-eyebrow">the last hour</div></div>
|
||||||
|
<dl id="latency"></dl>
|
||||||
|
<p class="note">Measured on the shards paid in the window, from the proven block's DAA score to the paying block's. The 24-hour totals by state are below the hour.</p>
|
||||||
|
<dl id="totals"></dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<!-- footer:start -->
|
||||||
|
<footer class="site-footer">
|
||||||
|
<div class="container">
|
||||||
|
<div class="footer-grid">
|
||||||
|
<div class="footer-brand">
|
||||||
|
<a href="/" class="logo" aria-label="Igneum home"><svg class="brand-mark" viewBox="0 0 1024 1024" aria-hidden="true"><rect width="1024" height="1024" rx="160" 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><span class="stop">.</span></a>
|
||||||
|
<p>Mined by GPUs. Proven by fire.</p>
|
||||||
|
<div class="social" aria-label="Igneum elsewhere">
|
||||||
|
<a href="https://git.igneum.network/igneum-network/spec" target="_blank" rel="noopener" aria-label="Igneum source: the spec, the vectors and the issues"><svg viewBox="0 0 24 24" width="22" height="22" fill="currentColor" aria-hidden="true"><path d="M8.6 17.6 3 12l5.6-5.6 1.4 1.4L5.8 12l4.2 4.2zm6.8 0-1.4-1.4L18.2 12 14 7.8l1.4-1.4L21 12z"></path></svg></a>
|
||||||
|
<a href="https://discord.gg/igneum" target="_blank" rel="noopener" aria-label="The Igneum Discord"><svg viewBox="0 0 24 24" width="22" height="22" fill="currentColor" aria-hidden="true"><path d="M19.6 5.3A17 17 0 0 0 15.4 4l-.2.4a15.6 15.6 0 0 1 3.9 1.9 13.6 13.6 0 0 0-14.2 0A15.6 15.6 0 0 1 8.8 4.4L8.6 4a17 17 0 0 0-4.2 1.3C1.8 9.2 1.1 13 1.4 16.7a17.1 17.1 0 0 0 5.2 2.6l1.1-1.8a10.8 10.8 0 0 1-1.7-.8l.4-.3a12.2 12.2 0 0 0 11.2 0l.4.3-1.7.8 1.1 1.8a17 17 0 0 0 5.2-2.6c.4-4.3-.7-8-3-11.4zM8.7 14.4c-1 0-1.8-.9-1.8-2.1s.8-2.1 1.8-2.1 1.9 1 1.8 2.1c0 1.2-.8 2.1-1.8 2.1zm6.6 0c-1 0-1.8-.9-1.8-2.1s.8-2.1 1.8-2.1 1.9 1 1.8 2.1c0 1.2-.8 2.1-1.8 2.1z"></path></svg></a>
|
||||||
|
<a data-social="reddit" href="https://www.reddit.com/user/Igneum_network/" target="_blank" rel="noopener" aria-label="Igneum on Reddit"><svg viewBox="0 0 24 24" width="22" height="22" fill="currentColor" aria-hidden="true"><path d="M22 12.1a2.2 2.2 0 0 0-3.7-1.6 10.8 10.8 0 0 0-5.8-1.8l1-4.6 3.2.7a1.5 1.5 0 1 0 .2-.9l-3.6-.8a.5.5 0 0 0-.5.4l-1.1 5.2a10.8 10.8 0 0 0-5.9 1.8A2.2 2.2 0 1 0 3.4 14a4.3 4.3 0 0 0 0 .7c0 3.4 3.9 6.1 8.6 6.1s8.6-2.7 8.6-6.1a4.3 4.3 0 0 0 0-.7 2.2 2.2 0 0 0 1.4-1.9zM7 13.6a1.5 1.5 0 1 1 1.5 1.5A1.5 1.5 0 0 1 7 13.6zm8.6 4.1a5.7 5.7 0 0 1-3.6 1.1 5.7 5.7 0 0 1-3.6-1.1.4.4 0 0 1 .6-.6 4.9 4.9 0 0 0 3 .9 4.9 4.9 0 0 0 3-.9.4.4 0 0 1 .6.6zm-.3-2.6a1.5 1.5 0 1 1 1.5-1.5 1.5 1.5 0 0 1-1.5 1.5z"></path></svg></a>
|
||||||
|
</div>
|
||||||
|
<div class="footer-dl" aria-label="Download Ember"><a href="/download#windows"><span class="osmark mini" data-os="windows" title="Windows"><svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true" focusable="false" fill="currentColor"><path d="M3 5.6l7.3-1v7.1H3zM11.4 4.4L21 3v8.7h-9.6zM3 12.3h7.3v7.1L3 18.4zM11.4 12.3H21V21l-9.6-1.4z"/></svg></span>Windows</a><a href="/download#mac"><span class="osmark mini" data-os="mac" title="macOS"><svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true" focusable="false" fill="currentColor"><path d="M16.4 12.6c0-2.5 2-3.6 2.1-3.7-1.2-1.7-3-1.9-3.6-2-1.5-.2-3 .9-3.8.9-.8 0-2-.9-3.3-.8-1.7 0-3.2 1-4.1 2.5-1.8 3-.5 7.6 1.3 10.1.9 1.2 1.9 2.6 3.2 2.5 1.3 0 1.8-.8 3.3-.8 1.6 0 2 .8 3.3.8 1.4 0 2.3-1.2 3.1-2.5 1-1.4 1.4-2.8 1.4-2.9 0 0-2.7-1-2.9-4.1zM13.9 5.3c.7-.8 1.2-2 1-3.2-1 0-2.2.7-2.9 1.5-.6.7-1.2 1.9-1 3 1.1.1 2.2-.5 2.9-1.3z"/></svg></span>macOS</a><a href="/download#linux"><span class="osmark mini" data-os="linux" title="Linux"><svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true" focusable="false" fill="currentColor"><path d="M12 2c-2.4 0-4 1.9-4 4.6 0 1.2.2 2 0 2.8-.6 1.4-2.1 2.9-2.6 4.9-.3 1.1-.1 2 .3 2.6-.6.4-1.3 1-1.1 1.7.3 1 2.1 1.2 3.2 1.8.7.4 1.5.6 2.1.1.6.2 1.3.3 2.1.3s1.5-.1 2.1-.3c.6.5 1.4.3 2.1-.1 1.1-.6 2.9-.8 3.2-1.8.2-.7-.5-1.3-1.1-1.7.4-.6.6-1.5.3-2.6-.5-2-2-3.5-2.6-4.9-.2-.8 0-1.6 0-2.8C16 3.9 14.4 2 12 2zm-1.4 4.2c.5 0 .8.5.8 1.2s-.3 1.2-.8 1.2-.8-.5-.8-1.2.3-1.2.8-1.2zm2.8 0c.5 0 .8.5.8 1.2s-.3 1.2-.8 1.2-.8-.5-.8-1.2.3-1.2.8-1.2zM12 9.3c.9 0 1.9.5 1.9 1s-1 1.2-1.9 1.2-1.9-.7-1.9-1.2 1-1 1.9-1zm0 3.4c2.2 0 3.6 2.6 3.6 4.4 0 1.5-1.6 2.3-3.6 2.3s-3.6-.8-3.6-2.3c0-1.8 1.4-4.4 3.6-4.4z"/></svg></span>Linux</a><a href="/download#hive"><span class="osmark mini" data-os="hive" title="HiveOS"><svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round" stroke-linecap="round"><path d="M12 2.6 20.2 7.3v9.4L12 21.4 3.8 16.7V7.3z"/><path d="M12 7.4 16 9.7v4.6L12 16.6 8 14.3V9.7z"/><path d="M12 7.4V2.6M16 9.7l4.2-2.4M16 14.3l4.2 2.4M12 16.6v4.8M8 14.3l-4.2 2.4M8 9.7 3.8 7.3"/></svg></span>HiveOS</a></div>
|
||||||
|
<a href="https://git.igneum.network/igneum-network/spec" target="_blank" rel="noopener" class="text-link">Specification and test vectors<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M6 18 18 6M6 6h12v12"/></svg></a>
|
||||||
|
</div>
|
||||||
|
<div class="footer-column"><h4>Run</h4><div>
|
||||||
|
<a href="/download">Download Ember</a>
|
||||||
|
<a href="/miner">The miner</a>
|
||||||
|
<a href="/app">The app</a>
|
||||||
|
<a href="/wallet">The wallet</a>
|
||||||
|
<a href="/miners">GPU bench table</a>
|
||||||
|
<a href="/dev-fee">The dev fee</a>
|
||||||
|
<a href="/faucet">Devnet faucet</a>
|
||||||
|
<a href="/metamask">Add Igneum to MetaMask</a>
|
||||||
|
</div></div>
|
||||||
|
<div class="footer-column"><h4>Read</h4><div>
|
||||||
|
<a href="/litepaper">Litepaper</a>
|
||||||
|
<a href="/evidence">Evidence</a>
|
||||||
|
<a href="/bench">Engineering log</a>
|
||||||
|
<a href="/ledger">Ledger: every criticism</a>
|
||||||
|
<a href="/claims">What Igneum does not claim</a>
|
||||||
|
<a href="/randomx">Igneum vs RandomX</a>
|
||||||
|
<a href="/provenance">Built on the shoulders</a>
|
||||||
|
</div></div>
|
||||||
|
<div class="footer-column"><h4>Explore</h4><div>
|
||||||
|
<a href="/live">Live devnet</a>
|
||||||
|
<a href="/explorer">Explorer</a>
|
||||||
|
<a href="/build">Build on Igneum</a>
|
||||||
|
<a href="/swap">Swap</a>
|
||||||
|
<a href="/grants">Grants</a>
|
||||||
|
<a href="/journey">Journey</a>
|
||||||
|
<a href="/download">Downloads: devnet build</a>
|
||||||
|
<a href="https://discord.gg/igneum" target="_blank" rel="noopener">Community Discord</a>
|
||||||
|
<a href="mailto:hello@igneum.network">hello@igneum.network</a>
|
||||||
|
<a href="https://git.igneum.network/igneum-network/spec/issues" rel="noopener">An issue on the spec</a>
|
||||||
|
</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="footer-bottom">
|
||||||
|
<p>Igneum Labs LTD · Unit IH-00-01-01-OF-01, Level 01, Innovation One, Dubai International Financial Centre. Experimental software. Nothing on this site is an offer to sell anything.<br>Report a flaw by email or by an issue on the spec. Nobody from Igneum will ask for your seed. © 2026 Igneum · not an offer to sell anything</p>
|
||||||
|
<div class="theme-group" role="group" aria-label="Theme"><button type="button" data-theme-set="system" aria-pressed="true">System</button><button type="button" data-theme-set="light" aria-pressed="false">Light</button><button type="button" data-theme-set="dark" aria-pressed="false">Dark</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
// theme control: stores light or dark under igneum-theme; system clears it and follows the OS. The head applied the value before paint.
|
||||||
|
var bs=document.querySelectorAll('[data-theme-set]');if(!bs.length)return;
|
||||||
|
var media=window.matchMedia('(prefers-color-scheme: light)');
|
||||||
|
function stored(){try{var t=localStorage.getItem('igneum-theme');return t==='light'||t==='dark'?t:'system';}catch(e){return 'system';}}
|
||||||
|
function apply(){var c=stored();document.documentElement.setAttribute('data-theme',c==='system'?(media.matches?'light':'dark'):c);bs.forEach(function(b){b.setAttribute('aria-pressed',b.getAttribute('data-theme-set')===c?'true':'false');});document.dispatchEvent(new CustomEvent('igneum-theme'));}
|
||||||
|
bs.forEach(function(b){b.addEventListener('click',function(){var v=b.getAttribute('data-theme-set');try{if(v==='system')localStorage.removeItem('igneum-theme');else localStorage.setItem('igneum-theme',v);}catch(e){}apply();});});
|
||||||
|
if(media.addEventListener)media.addEventListener('change',function(){if(stored()==='system')apply();});
|
||||||
|
document.addEventListener('igneum-theme',function(){var c=stored();bs.forEach(function(b){b.setAttribute('aria-pressed',b.getAttribute('data-theme-set')===c?'true':'false');});});
|
||||||
|
bs.forEach(function(b){b.setAttribute('aria-pressed',b.getAttribute('data-theme-set')===stored()?'true':'false');});
|
||||||
|
// the network state word in the status bar: one read of the observer per page view
|
||||||
|
var fs=document.getElementById('foot-state'),fd=document.getElementById('foot-dot');if(fs&&fd){fetch('/api/live?window=30',{cache:'no-store'}).then(function(r){return r.json();}).then(function(d){var on=d&&d.ok&&d.state&&!d.state.stale;fs.textContent=on?'devnet live':'devnet, observer stale';fd.className='dot '+(on?'live':'off');}).catch(function(){fs.textContent='devnet';});}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<!-- footer:end -->
|
||||||
|
<script type="module">
|
||||||
|
import { ignFromWei, shortHash, rel, int } from '/lib/explorer.mjs';
|
||||||
|
const $ = id => document.getElementById(id);
|
||||||
|
const esc = s => String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
const dl = pairs => pairs.filter(p => p).map(([k, v]) => `<dt>${esc(k)}</dt><dd>${v}</dd>`).join('');
|
||||||
|
const stamp = iso => iso ? new Date(iso).toISOString().slice(11, 19) + ' UTC' : '';
|
||||||
|
function bar(s) { if (!s || !s.total) return '<span class="kv">no plan yet</span>'; const pct = Math.round(s.paid / s.total * 100); return `<span class="bar"><i class="${s.paid === s.total ? '' : 'part'}" style="width:${pct}%"></i></span>${s.paid} of ${s.total}`; }
|
||||||
|
async function load() {
|
||||||
|
const j = await fetch('/api/explorer?proving=1').then(r => r.json()); if (!j.ok) throw new Error(j.error);
|
||||||
|
const now = new Date(j.now).getTime(), p = j.proving || {}, l = j.latency_1h || {}, t = j.totals_24h || {};
|
||||||
|
const on = p.supported !== false && !j.stale;
|
||||||
|
document.body.classList.toggle('on', on); document.body.classList.toggle('off', !on);
|
||||||
|
$('p-status').textContent = j.stale ? 'OFFLINE' : (p.supported === false ? 'NO LAYER' : (p.active ? 'ACTIVE' : 'INACTIVE'));
|
||||||
|
$('p-status-s').textContent = j.stale ? 'observer stale' : `read ${stamp(j.now)}, tip DAA ${int(p.tip_daa)}${p.paused_since_ms ? ', paused' : ''}`;
|
||||||
|
$('p-paid10').textContent = int(p.shards_paid_10m ?? 0); $('p-paid10-s').textContent = `shards; ${int(p.blocks_fully_proven_10m ?? 0)} of ${int(p.blocks_10m ?? 0)} blocks of the window fully proven`;
|
||||||
|
$('p-paid24').textContent = int(t.paid ? t.paid.shards : 0); $('p-paid24-s').textContent = `shards, ${ignFromWei(t.paid ? t.paid.payout_wei : '0', 2)} IGN to provers`;
|
||||||
|
$('p-open').textContent = int(p.open_blocks ?? 0); $('p-open-s').textContent = `chain blocks with unpaid shards, ${int(p.pending_plans ?? 0)} plans not read yet`;
|
||||||
|
$('p-provers').textContent = int(j.provers.length); $('p-provers-s').textContent = `vote keys paid for a shard; ${int(p.provers_10m ?? 0)} in the last 10 min`;
|
||||||
|
$('p-lat').textContent = l.paid ? int(l.p50_daa) : 'n/a'; $('p-lat-s').textContent = l.paid ? `median DAA from block to payment over ${int(l.paid)} shards, p90 ${int(l.p90_daa)}` : 'no shard paid in the last hour';
|
||||||
|
const pool = p.pool || {}; $('p-pool').textContent = int(pool.entries ?? 0); $('p-pool-s').textContent = `records in the node's pool: ${int(pool.pending ?? 0)} pending, ${int(pool.verified ?? 0)} verified, ${int(pool.failed ?? 0)} failed`;
|
||||||
|
$('p-budget').innerHTML = esc(int(p.shard_budget_pgas ?? 0)) + '<small>pgas</small>';
|
||||||
|
$('b-eyebrow').textContent = `newest ${j.blocks.length}, every 5 s, read ${stamp(j.now)}`;
|
||||||
|
$('blocks').innerHTML = j.blocks.length ? j.blocks.map(b => `<tr data-href="/block/${b.hash}"><td class="n">${int(b.number)}</td><td><a class="hash" href="/block/${b.hash}">${esc(shortHash(b.hash, 10, 6))}</a></td><td class="n">${int(b.daa)}</td><td><span class="chip">${esc(b.class)}</span></td><td>${b.evm_miner ? `<a class="hash" href="/address/${b.evm_miner}">${esc(shortHash(b.evm_miner, 6, 4))}</a>` : ''} <span class="id" style="font-size:12px">${esc(b.miner_id || '')}</span></td><td class="n">${int(b.tx_count)}</td><td class="n">${int(b.pgas)}</td><td>${bar(b.shards)}</td><td class="kv" style="font-size:12px">${b.shards && b.shards.total ? `${b.shards.planned} planned, ${b.shards.proving} proving, ${b.shards.verified} verified, ${b.shards.paid} paid` : ''}</td><td class="n">${b.shards && b.shards.median_lag !== null && b.shards.median_lag !== undefined ? int(b.shards.median_lag) : ''}</td><td title="${new Date(b.ts).toISOString()}">${rel(now - b.ts)}</td></tr>`).join('') : '<tr><td colspan="11" class="empty">No chain block in the last 24 hours.</td></tr>';
|
||||||
|
$('pr-eyebrow').textContent = `${int(j.provers.length)} keys, by shards paid`;
|
||||||
|
$('provers').innerHTML = j.provers.length ? j.provers.map(x => `<tr><td class="id">${esc(x.id)}</td><td class="n">${int(x.shards_24h)}</td><td class="n">${ignFromWei(x.payout_wei_24h, 2)}</td><td class="n">${x.median_lag_24h === null ? '' : int(x.median_lag_24h)}</td><td class="n">${int(x.shards_1h)}</td><td title="${x.last_paid ? new Date(x.last_paid).toISOString() : ''}">${x.last_paid ? rel(now - new Date(x.last_paid).getTime()) : ''}</td></tr>`).join('') : '<tr><td colspan="6" class="empty">No shard paid in the last 24 hours.</td></tr>';
|
||||||
|
$('l-eyebrow').textContent = `the last hour, read ${stamp(j.now)}`;
|
||||||
|
$('latency').innerHTML = l.paid ? dl([['Shards paid', int(l.paid)], ['Median', `${int(l.p50_daa)} DAA`], ['p90', `${int(l.p90_daa)} DAA`], ['Fastest', `${int(l.min_daa)} DAA`], ['Slowest', `${int(l.max_daa)} DAA`]]) : '<div class="empty">No shard paid in the last hour.</div>';
|
||||||
|
$('totals').innerHTML = dl(Object.entries(t).sort().map(([k, v]) => [`${k}, 24 h`, `${int(v.shards)} shards${k === 'paid' ? `, ${ignFromWei(v.payout_wei, 2)} IGN` : ''}`]));
|
||||||
|
}
|
||||||
|
$('blocks').addEventListener('click', e => { const tr = e.target.closest('tr[data-href]'); if (tr && !e.target.closest('a')) location.href = tr.dataset.href; });
|
||||||
|
setInterval(() => { load().catch(() => {}); }, 5000);
|
||||||
|
load().catch(e => { $('msg').textContent = 'The API did not answer: ' + e.message; });
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -6,7 +6,8 @@
|
||||||
<url><loc>https://igneum.network/app</loc><lastmod>2026-10-07</lastmod><changefreq>weekly</changefreq><priority>0.8</priority></url>
|
<url><loc>https://igneum.network/app</loc><lastmod>2026-10-07</lastmod><changefreq>weekly</changefreq><priority>0.8</priority></url>
|
||||||
<url><loc>https://igneum.network/wallet</loc><lastmod>2026-10-07</lastmod><changefreq>monthly</changefreq><priority>0.6</priority></url>
|
<url><loc>https://igneum.network/wallet</loc><lastmod>2026-10-07</lastmod><changefreq>monthly</changefreq><priority>0.6</priority></url>
|
||||||
<url><loc>https://igneum.network/live</loc><lastmod>2026-10-07</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
<url><loc>https://igneum.network/live</loc><lastmod>2026-10-07</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||||
<url><loc>https://igneum.network/explorer</loc><lastmod>2026-10-07</lastmod><changefreq>daily</changefreq><priority>0.5</priority></url>
|
<url><loc>https://igneum.network/explorer</loc><lastmod>2026-10-08</lastmod><changefreq>daily</changefreq><priority>0.5</priority></url>
|
||||||
|
<url><loc>https://igneum.network/proving</loc><lastmod>2026-10-08</lastmod><changefreq>daily</changefreq><priority>0.4</priority></url>
|
||||||
<url><loc>https://igneum.network/ledger</loc><lastmod>2026-10-07</lastmod><changefreq>weekly</changefreq><priority>0.6</priority></url>
|
<url><loc>https://igneum.network/ledger</loc><lastmod>2026-10-07</lastmod><changefreq>weekly</changefreq><priority>0.6</priority></url>
|
||||||
<url><loc>https://igneum.network/bench</loc><lastmod>2026-10-07</lastmod><changefreq>weekly</changefreq><priority>0.6</priority></url>
|
<url><loc>https://igneum.network/bench</loc><lastmod>2026-10-07</lastmod><changefreq>weekly</changefreq><priority>0.6</priority></url>
|
||||||
<url><loc>https://igneum.network/evidence</loc><lastmod>2026-10-07</lastmod><changefreq>weekly</changefreq><priority>0.6</priority></url>
|
<url><loc>https://igneum.network/evidence</loc><lastmod>2026-10-07</lastmod><changefreq>weekly</changefreq><priority>0.6</priority></url>
|
||||||
|
|
|
||||||
406
site/tx.html
Normal file
406
site/tx.html
Normal file
|
|
@ -0,0 +1,406 @@
|
||||||
|
<!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 Devnet 3 transaction</title>
|
||||||
|
<meta name="description" content="One Devnet 3 transaction: status, value, fees and their split, the block that executed it, its proof state and finality, its logs.">
|
||||||
|
<link rel="canonical" href="https://igneum.network/tx">
|
||||||
|
<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 Devnet 3 transaction">
|
||||||
|
<meta property="og:description" content="One Devnet 3 transaction: status, value, fees and their split, the block that executed it, its proof state and finality, its logs.">
|
||||||
|
<meta property="og:url" content="https://igneum.network/tx">
|
||||||
|
<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 Devnet 3 transaction">
|
||||||
|
<meta name="twitter:description" content="One Devnet 3 transaction: status, value, fees and their split, the block that executed it, its proof state and finality, its logs.">
|
||||||
|
<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>
|
||||||
|
<meta name="color-scheme" content="dark light">
|
||||||
|
<link rel="stylesheet" href="/site.css">
|
||||||
|
<style>
|
||||||
|
/* Shared by every page: the head partial, injected by the build 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. The tokens, type and components are in /site.css (docs/plans/site-ui-3.md). */
|
||||||
|
@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%}
|
||||||
|
</style>
|
||||||
|
<script>
|
||||||
|
/* theme before first paint: ?theme=light|dark for one view (the capture tool), else the stored choice under igneum-theme, else the system setting; ?motion=off marks the page data-motion=off so every .reveal is drawn at once (a headless render never scrolls) */
|
||||||
|
(function(){var t=null;try{t=new URLSearchParams(location.search).get("theme");}catch(e){}if(t!=="light"&&t!=="dark"){try{t=localStorage.getItem("igneum-theme");}catch(e){t=null;}}if(t!=="light"&&t!=="dark")t=window.matchMedia&&window.matchMedia("(prefers-color-scheme: light)").matches?"light":"dark";document.documentElement.setAttribute("data-theme",t);try{if(new URLSearchParams(location.search).get("motion")==="off")document.documentElement.setAttribute("data-motion","off");}catch(e){}})();
|
||||||
|
</script>
|
||||||
|
<!-- head:end -->
|
||||||
|
<style>
|
||||||
|
/* page block; tokens, type and components are in /site.css */
|
||||||
|
/* the explorer pages share this block with block.html and address.html; same tokens as live.html */
|
||||||
|
--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)}
|
||||||
|
a:focus-visible,button:focus-visible,input:focus-visible{outline:2px solid var(--ember);outline-offset:3px;border-radius:6px}
|
||||||
|
.mono{font-family:var(--f-mono)}
|
||||||
|
h1,h2{font-family:var(--f-display);margin:0;line-height:1.08;text-wrap:balance}
|
||||||
|
.head{padding:65px 0 45px;display:flex;flex-direction:column;gap:14px}
|
||||||
|
.head p{color:var(--ink-2);max-width:67ch;font-size:18px;line-height:1.65}
|
||||||
|
.search{display:flex;gap:10px;max-width:760px;margin-top:4px;align-items:center;padding:12px 16px;border:1px solid var(--line-2);border-radius:8px;background:var(--row)}
|
||||||
|
.search input{flex:1;min-width:0;border:0;outline:none;background:transparent;color:var(--bone);font-size:14px}
|
||||||
|
.search input::placeholder{color:var(--ash)}
|
||||||
|
.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));border:1px solid var(--line);border-radius:12px;overflow:hidden;margin-bottom:24px;background:var(--row)}
|
||||||
|
@media (max-width:700px){.strip{grid-template-columns:repeat(2,minmax(0,1fr))}.cell{border-right:1px solid var(--line)!important}.cell:nth-child(2n){border-right:0!important}.cell:nth-last-child(-n+4){border-bottom:1px solid var(--line)}.cell:nth-last-child(-n+2){border-bottom:0}}
|
||||||
|
.cell{padding:22px 25px;border-right:1px solid var(--line);border-bottom:1px solid var(--line);display:flex;flex-direction:column;gap:6px;min-width:0}.cell:nth-child(4n){border-right:0}.cell:nth-last-child(-n+4){border-bottom:0}
|
||||||
|
.cell .k{font:10px var(--mono);letter-spacing:.1em;text-transform:uppercase;color:var(--ash);margin-bottom:5px}
|
||||||
|
.cell .v{font:700 clamp(21px,2.2vw,29px)/1.1 var(--display);letter-spacing:-.04em;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:10px/1.5 var(--mono);color:var(--ash);text-wrap:balance;margin-top:4px}
|
||||||
|
.cell.status .v{display:flex;align-items:center;gap:10px}
|
||||||
|
.off .dot{background:var(--ash);animation:none}.off .cell.status .v{color:var(--ash)}.on .cell.status .v{color:var(--molten)}
|
||||||
|
@media (prefers-reduced-motion:reduce){.dot{animation:none}}
|
||||||
|
td.n,th.n{text-align:right;font-variant-numeric:tabular-nums}
|
||||||
|
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.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)}
|
||||||
|
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:100px}.card{background:var(--row);border:1px solid var(--line);border-radius:16px;padding:25px;margin-top:24px}.card .viz-head h2{font-size:19px}.card .viz-head .eyebrow{margin:0}
|
||||||
|
.badge{display:inline-flex;align-items:center;gap:6px;font-family:var(--f-mono);font-size:11px;letter-spacing:.06em;text-transform:uppercase;padding:4px 9px;border-radius:999px;border:1px solid var(--line-2);color:var(--ink-2);margin:0 6px 6px 0}
|
||||||
|
.badge.ok{border-color:var(--green,#5CC48A);color:var(--green,#5CC48A)}.badge.hot{border-color:var(--molten);color:var(--molten)}.badge.bad{border-color:var(--excluded);color:var(--excluded)}.badge.wait{color:var(--ash)}
|
||||||
|
.bar{display:inline-block;width:72px;height:7px;border-radius:4px;background:var(--line);vertical-align:middle;margin-right:7px;overflow:hidden}.bar i{display:block;height:100%;background:var(--green,#5CC48A)}.bar i.part{background:var(--molten)}
|
||||||
|
.dagbox{position:relative;width:100%;height:320px;border:1px solid var(--line);border-radius:12px;background:var(--obsidian);overflow:hidden}.dagbox canvas{width:100%;height:100%;display:block;cursor:pointer}
|
||||||
|
.dagtip{position:absolute;pointer-events:none;font:11px/1.5 var(--mono);color:var(--bone);background:var(--graphite);border:1px solid var(--line-2);border-radius:8px;padding:8px 10px;max-width:300px;display:none}
|
||||||
|
.kv{color:var(--ash)}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- nav:start -->
|
||||||
|
<a class="skip" href="#main">Skip to content</a>
|
||||||
|
<nav class="nav" aria-label="Main" data-nav-version="5">
|
||||||
|
<div class="container nav-row">
|
||||||
|
<a href="/" class="logo" aria-label="Igneum home">
|
||||||
|
<svg class="brand-mark" viewBox="0 0 1024 1024" aria-hidden="true"><rect width="1024" height="1024" rx="160" 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>
|
||||||
|
<span class="devnet" id="nav-devnet" title="igneum-devnet-3, chain id 4463. Coins have no value. The chain may reset."><span class="dot off" id="foot-dot"></span><span id="foot-state">devnet</span></span>
|
||||||
|
<div class="nav-groups" id="nav-groups" role="list">
|
||||||
|
<div class="nav-group" role="listitem"><button type="button" class="group-btn" id="nav-btn-mine" data-group="mine" aria-expanded="false" aria-controls="nav-panel-mine" aria-haspopup="true">Mine<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="m7 10 5 5 5-5"/></svg></button></div>
|
||||||
|
<div class="nav-group" role="listitem"><button type="button" class="group-btn" id="nav-btn-network" data-group="network" data-active aria-expanded="false" aria-controls="nav-panel-network" aria-haspopup="true">Network<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="m7 10 5 5 5-5"/></svg></button></div>
|
||||||
|
<div class="nav-group" role="listitem"><button type="button" class="group-btn" id="nav-btn-learn" data-group="learn" aria-expanded="false" aria-controls="nav-panel-learn" aria-haspopup="true">Learn<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="m7 10 5 5 5-5"/></svg></button></div>
|
||||||
|
<div class="nav-group" role="listitem"><button type="button" class="group-btn" id="nav-btn-build" data-group="build" aria-expanded="false" aria-controls="nav-panel-build" aria-haspopup="true">Build<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="m7 10 5 5 5-5"/></svg></button></div>
|
||||||
|
</div>
|
||||||
|
<div class="nav-controls">
|
||||||
|
<button class="btn icon-btn" type="button" data-theme-toggle aria-label="Switch to light theme"><svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v2m0 16v2M2 12h2m16 0h2M5 5l1 1m12 12 1 1M5 19l1-1M18 6l1-1"/></svg></button>
|
||||||
|
<a href="/download" class="btn primary small cta" data-nav="download">Download</a>
|
||||||
|
<button class="burger" id="nav-burger" type="button" aria-expanded="false" aria-controls="nav-sheet" aria-label="Open the menu" aria-haspopup="true"><svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path class="bars" d="M4 6h16M4 12h16M4 18h16"/><path class="cross" d="m6 6 12 12M6 18 18 6"/></svg></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="panels" id="nav-panels">
|
||||||
|
<div class="panel-wrap container">
|
||||||
|
<div class="nav-panel" id="nav-panel-mine" data-group="mine" role="region" aria-labelledby="nav-btn-mine" hidden>
|
||||||
|
<div class="panel-list">
|
||||||
|
<a href="/miner" data-nav="miner"><b>Ember, the miner</b><span>One click installs the node, the miner and the prover.</span></a>
|
||||||
|
<a href="/download" data-nav="download"><b>Download</b><span>Windows, macOS, Linux and HiveOS.</span></a>
|
||||||
|
<a href="/app" data-nav="app"><b>The app</b><span>What you see while the card mines.</span></a>
|
||||||
|
<a href="/wallet" data-nav="wallet"><b>The wallet</b><span>Your coins. Final means final.</span></a>
|
||||||
|
<a href="/miners" data-nav="miners"><b>GPU bench table</b><span>Measured rates, card by card.</span></a>
|
||||||
|
<a href="/dev-fee" data-nav="dev-fee"><b>The dev fee</b><span>The optional one, in full view, off with one flag.</span></a>
|
||||||
|
<a href="/metamask" data-nav="metamask"><b>Add to MetaMask</b><span>Igneum as a network in your wallet.</span></a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="nav-panel" id="nav-panel-network" data-group="network" role="region" aria-labelledby="nav-btn-network" hidden>
|
||||||
|
<div class="panel-list">
|
||||||
|
<a href="/live" data-nav="live"><b>Live devnet</b><span>The chain as it grows, one lane per miner.</span></a>
|
||||||
|
<a href="/explorer" data-nav="explorer" aria-current="page"><b>Explorer</b><span>Look a block or an address up.</span></a>
|
||||||
|
<a href="/journey" data-nav="journey"><b>The journey</b><span>Six phases, four gates, the log behind them.</span></a>
|
||||||
|
<a href="/bench" data-nav="bench"><b>Engineering log</b><span>Every measurement, with the commands that made it.</span></a>
|
||||||
|
<a href="/evidence" data-nav="evidence"><b>Evidence</b><span>Every claim and how far it is tested.</span></a>
|
||||||
|
<a href="/light" data-nav="light"><b>Light wallet</b><span>A balance your browser proves, trusting no node.</span></a>
|
||||||
|
<a href="/receipt" data-nav="receipt"><b>Payment receipt</b><span>A receipt any third party re-verifies offline.</span></a>
|
||||||
|
<a href="/oracle" data-nav="oracle"><b>State oracle on Sepolia</b><span>Devnet 3 balances read from another chain.</span></a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="nav-panel" id="nav-panel-learn" data-group="learn" role="region" aria-labelledby="nav-btn-learn" hidden>
|
||||||
|
<div class="panel-list">
|
||||||
|
<a href="/litepaper" data-nav="litepaper"><b>Litepaper</b><span>The design, as published.</span></a>
|
||||||
|
<a href="/income" data-nav="income"><b>Income per tier</b><span>IGN a day per card at three network sizes, and the electricity.</span></a>
|
||||||
|
<a href="/ledger" data-nav="ledger"><b>Ledger</b><span>Every criticism, answered or conceded.</span></a>
|
||||||
|
<a href="/claims" data-nav="claims"><b>What Igneum does not claim</b><span>The limits, stated first.</span></a>
|
||||||
|
<a href="/randomx" data-nav="randomx"><b>Igneum vs RandomX</b><span>What was kept and what was rebuilt for GPUs.</span></a>
|
||||||
|
<a href="/provenance" data-nav="provenance"><b>Built on the shoulders</b><span>Every borrowed part, credited.</span></a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="nav-panel" id="nav-panel-build" data-group="build" role="region" aria-labelledby="nav-btn-build" hidden>
|
||||||
|
<div class="panel-list">
|
||||||
|
<a href="/build" data-nav="build"><b>Build on Igneum</b><span>Chain ids, RPC, a contract in five minutes.</span></a>
|
||||||
|
<a href="/faucet" data-nav="faucet"><b>Devnet faucet</b><span>10 IGN of devnet coin, once a day.</span></a>
|
||||||
|
<a href="/swap" data-nav="swap"><b>Swap</b><span>Test tokens on Devnet 3; every swap is a proven block.</span></a>
|
||||||
|
<a href="/grants" data-nav="grants"><b>Grants</b><span>Tooling, apps, infrastructure, research.</span></a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="nav-sheet" id="nav-sheet" role="dialog" aria-modal="true" aria-label="Menu" hidden>
|
||||||
|
<div class="sheet-scroll">
|
||||||
|
<div class="sheet-group"><div class="sheet-head">Mine</div>
|
||||||
|
<a href="/miner" data-nav="miner"><b>Ember, the miner</b><span>One click installs the node, the miner and the prover.</span></a>
|
||||||
|
<a href="/download" data-nav="download"><b>Download</b><span>Windows, macOS, Linux and HiveOS.</span></a>
|
||||||
|
<a href="/app" data-nav="app"><b>The app</b><span>What you see while the card mines.</span></a>
|
||||||
|
<a href="/wallet" data-nav="wallet"><b>The wallet</b><span>Your coins. Final means final.</span></a>
|
||||||
|
<a href="/miners" data-nav="miners"><b>GPU bench table</b><span>Measured rates, card by card.</span></a>
|
||||||
|
<a href="/dev-fee" data-nav="dev-fee"><b>The dev fee</b><span>The optional one, in full view, off with one flag.</span></a>
|
||||||
|
<a href="/metamask" data-nav="metamask"><b>Add to MetaMask</b><span>Igneum as a network in your wallet.</span></a>
|
||||||
|
</div>
|
||||||
|
<div class="sheet-group"><div class="sheet-head">Network</div>
|
||||||
|
<a href="/live" data-nav="live"><b>Live devnet</b><span>The chain as it grows, one lane per miner.</span></a>
|
||||||
|
<a href="/explorer" data-nav="explorer" aria-current="page"><b>Explorer</b><span>Look a block or an address up.</span></a>
|
||||||
|
<a href="/journey" data-nav="journey"><b>The journey</b><span>Six phases, four gates, the log behind them.</span></a>
|
||||||
|
<a href="/bench" data-nav="bench"><b>Engineering log</b><span>Every measurement, with the commands that made it.</span></a>
|
||||||
|
<a href="/evidence" data-nav="evidence"><b>Evidence</b><span>Every claim and how far it is tested.</span></a>
|
||||||
|
<a href="/light" data-nav="light"><b>Light wallet</b><span>A balance your browser proves, trusting no node.</span></a>
|
||||||
|
<a href="/receipt" data-nav="receipt"><b>Payment receipt</b><span>A receipt any third party re-verifies offline.</span></a>
|
||||||
|
<a href="/oracle" data-nav="oracle"><b>State oracle on Sepolia</b><span>Devnet 3 balances read from another chain.</span></a>
|
||||||
|
</div>
|
||||||
|
<div class="sheet-group"><div class="sheet-head">Learn</div>
|
||||||
|
<a href="/litepaper" data-nav="litepaper"><b>Litepaper</b><span>The design, as published.</span></a>
|
||||||
|
<a href="/income" data-nav="income"><b>Income per tier</b><span>IGN a day per card at three network sizes, and the electricity.</span></a>
|
||||||
|
<a href="/ledger" data-nav="ledger"><b>Ledger</b><span>Every criticism, answered or conceded.</span></a>
|
||||||
|
<a href="/claims" data-nav="claims"><b>What Igneum does not claim</b><span>The limits, stated first.</span></a>
|
||||||
|
<a href="/randomx" data-nav="randomx"><b>Igneum vs RandomX</b><span>What was kept and what was rebuilt for GPUs.</span></a>
|
||||||
|
<a href="/provenance" data-nav="provenance"><b>Built on the shoulders</b><span>Every borrowed part, credited.</span></a>
|
||||||
|
</div>
|
||||||
|
<div class="sheet-group"><div class="sheet-head">Build</div>
|
||||||
|
<a href="/build" data-nav="build"><b>Build on Igneum</b><span>Chain ids, RPC, a contract in five minutes.</span></a>
|
||||||
|
<a href="/faucet" data-nav="faucet"><b>Devnet faucet</b><span>10 IGN of devnet coin, once a day.</span></a>
|
||||||
|
<a href="/swap" data-nav="swap"><b>Swap</b><span>Test tokens on Devnet 3; every swap is a proven block.</span></a>
|
||||||
|
<a href="/grants" data-nav="grants"><b>Grants</b><span>Tooling, apps, infrastructure, research.</span></a>
|
||||||
|
</div>
|
||||||
|
<div class="sheet-foot">
|
||||||
|
<a href="/download" class="btn primary big" data-nav="download">Download</a>
|
||||||
|
<div class="sheet-social"><a href="https://discord.gg/igneum" target="_blank" rel="noopener">Discord</a><a href="https://git.igneum.network/igneum-network/spec" target="_blank" rel="noopener">Source</a><a href="https://www.reddit.com/user/Igneum_network/" target="_blank" rel="noopener">Reddit</a></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
// the slim bar (site-ui-5, 7 Oct 2026): three group buttons open one panel each; the burger opens the sheet on phones.
|
||||||
|
// Keyboard: Enter or Space opens a group, Escape closes it and returns focus to its button, arrows move between the
|
||||||
|
// group buttons, Tab walks the open panel's links, a click outside closes. The theme toggle flips data-theme and stores it.
|
||||||
|
var n=document.querySelector('.nav');if(!n)return;
|
||||||
|
var btns=[].slice.call(n.querySelectorAll('.group-btn')),panels=[].slice.call(n.querySelectorAll('.nav-panel')),wrap=n.querySelector('.panels');
|
||||||
|
var burger=document.getElementById('nav-burger'),sheet=document.getElementById('nav-sheet');
|
||||||
|
function panelOf(g){return panels.filter(function(p){return p.getAttribute('data-group')===g;})[0];}
|
||||||
|
function closeAll(focusBack){var was=null;btns.forEach(function(b){if(b.getAttribute('aria-expanded')==='true')was=b;b.setAttribute('aria-expanded','false');});panels.forEach(function(p){p.hidden=true;});n.classList.remove('panel-open');if(focusBack&&was)was.focus();}
|
||||||
|
function open(g){closeAll(false);var b=btns.filter(function(x){return x.getAttribute('data-group')===g;})[0],p=panelOf(g);if(!b||!p)return;b.setAttribute('aria-expanded','true');p.hidden=false;n.classList.add('panel-open');anchor(b,p);}
|
||||||
|
// the panel sits under its button; a panel that would run past the container's right edge aligns its right edge to the button's right edge instead (never past the viewport)
|
||||||
|
function anchor(b,p){var pw=n.querySelector('.panel-wrap');if(!pw)return;var cr=pw.getBoundingClientRect(),br=b.getBoundingClientRect(),w=p.offsetWidth,left=br.left-cr.left;if(left+w>cr.width)left=(br.right-cr.left)-w;if(left<0)left=0;p.style.setProperty('--panel-left',Math.round(left)+'px');}
|
||||||
|
window.addEventListener('resize',function(){var o=btns.filter(function(x){return x.getAttribute('aria-expanded')==='true';})[0];if(o)anchor(o,panelOf(o.getAttribute('data-group')));}); btns.forEach(function(b,i){
|
||||||
|
b.addEventListener('click',function(){var on=b.getAttribute('aria-expanded')==='true';if(on)closeAll(false);else open(b.getAttribute('data-group'));});
|
||||||
|
b.addEventListener('keydown',function(e){
|
||||||
|
var k=e.key;
|
||||||
|
if(k==='ArrowRight'||k==='ArrowLeft'){e.preventDefault();var j=(i+(k==='ArrowRight'?1:btns.length-1))%btns.length;btns[j].focus();if(n.classList.contains('panel-open'))open(btns[j].getAttribute('data-group'));}
|
||||||
|
else if(k==='ArrowDown'){e.preventDefault();open(b.getAttribute('data-group'));var f=panelOf(b.getAttribute('data-group')).querySelector('a');if(f)f.focus();}
|
||||||
|
else if(k==='Escape'){closeAll(true);}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if(wrap)wrap.addEventListener('keydown',function(e){if(e.key==='Escape'){e.stopPropagation();closeAll(true);}});
|
||||||
|
document.addEventListener('click',function(e){if(!n.classList.contains('panel-open'))return;if(e.target.closest('.group-btn')||e.target.closest('.nav-panel'))return;closeAll(false);});
|
||||||
|
document.addEventListener('focusin',function(e){if(!n.classList.contains('panel-open'))return;if(n.contains(e.target))return;closeAll(false);});
|
||||||
|
document.addEventListener('keydown',function(e){if(e.key==='Escape'&&n.classList.contains('panel-open'))closeAll(true);});
|
||||||
|
// the sheet on phones
|
||||||
|
function setSheet(o){if(!burger||!sheet)return;n.classList.toggle('open',o);sheet.hidden=!o;burger.setAttribute('aria-expanded',o?'true':'false');burger.setAttribute('aria-label',o?'Close the menu':'Open the menu');document.documentElement.classList.toggle('nav-locked',o);if(o){var f=sheet.querySelector('a');if(f)f.focus();}}
|
||||||
|
if(burger&&sheet){
|
||||||
|
burger.addEventListener('click',function(){setSheet(!n.classList.contains('open'));});
|
||||||
|
sheet.addEventListener('click',function(e){if(e.target.closest('a'))setSheet(false);});
|
||||||
|
sheet.addEventListener('keydown',function(e){
|
||||||
|
if(e.key==='Escape'){setSheet(false);burger.focus();return;}
|
||||||
|
if(e.key!=='Tab')return;var f=sheet.querySelectorAll('a,button');if(!f.length)return;var first=f[0],last=f[f.length-1];
|
||||||
|
if(e.shiftKey&&document.activeElement===first){e.preventDefault();burger.focus();}else if(!e.shiftKey&&document.activeElement===last){e.preventDefault();burger.focus();}
|
||||||
|
});
|
||||||
|
burger.addEventListener('keydown',function(e){if(e.key==='Tab'&&!e.shiftKey&&n.classList.contains('open')){e.preventDefault();var f=sheet.querySelector('a');if(f)f.focus();}});
|
||||||
|
var mq=window.matchMedia('(min-width:861px)');if(mq.addEventListener)mq.addEventListener('change',function(){setSheet(false);});
|
||||||
|
}
|
||||||
|
// the theme toggle
|
||||||
|
var t=n.querySelector('[data-theme-toggle]');
|
||||||
|
function paintToggle(){var dark=document.documentElement.getAttribute('data-theme')!=='light';t.setAttribute('aria-label',dark?'Switch to light theme':'Switch to dark theme');t.innerHTML=dark?'<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v2m0 16v2M2 12h2m16 0h2M5 5l1 1m12 12 1 1M5 19l1-1M18 6l1-1"/></svg>':'<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M20.7 13.3A8.5 8.5 0 0 1 10.7 3.3 8.5 8.5 0 1 0 20.7 13.3Z"/></svg>';}
|
||||||
|
if(t){t.addEventListener('click',function(){var v=document.documentElement.getAttribute('data-theme')==='light'?'dark':'light';document.documentElement.setAttribute('data-theme',v);try{localStorage.setItem('igneum-theme',v);}catch(e){}document.dispatchEvent(new CustomEvent('igneum-theme'));});paintToggle();document.addEventListener('igneum-theme',paintToggle);}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<!-- nav:end -->
|
||||||
|
<main id="main" class="wrap">
|
||||||
|
<div class="head">
|
||||||
|
<div class="breadcrumb"><a href="/">Igneum</a><span>/</span><a href="/explorer">Explorer</a><span>/</span><span>Transaction</span></div>
|
||||||
|
<div class="eyebrow"><a href="/explorer">Devnet 3 explorer</a> · transaction</div>
|
||||||
|
<h1 class="hash" id="title">Transaction</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>Fees and proving</h2><div class="eyebrow">the receipt's Igneum section</div></div><dl id="fees"></dl><p class="note">Read from the observer node, the chain the finality certificates follow. Both base fees burn in full. The priority fee splits 80/20 between the including miner and the proving pool. Proving gas (pgas) is what the shard that proves this transaction costs; the proving base fee prices it.</p></div>
|
||||||
|
</div>
|
||||||
|
<div class="card"><div class="viz-head"><h2>Block and proof</h2><div class="eyebrow" id="blk-eyebrow"></div></div><dl id="blk"></dl></div>
|
||||||
|
<div class="card"><div class="viz-head"><h2>Logs</h2><div class="eyebrow" id="log-n"></div></div><div class="tbl" id="logs"></div></div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<!-- footer:start -->
|
||||||
|
<footer class="site-footer">
|
||||||
|
<div class="container">
|
||||||
|
<div class="footer-grid">
|
||||||
|
<div class="footer-brand">
|
||||||
|
<a href="/" class="logo" aria-label="Igneum home"><svg class="brand-mark" viewBox="0 0 1024 1024" aria-hidden="true"><rect width="1024" height="1024" rx="160" 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><span class="stop">.</span></a>
|
||||||
|
<p>Mined by GPUs. Proven by fire.</p>
|
||||||
|
<div class="social" aria-label="Igneum elsewhere">
|
||||||
|
<a href="https://git.igneum.network/igneum-network/spec" target="_blank" rel="noopener" aria-label="Igneum source: the spec, the vectors and the issues"><svg viewBox="0 0 24 24" width="22" height="22" fill="currentColor" aria-hidden="true"><path d="M8.6 17.6 3 12l5.6-5.6 1.4 1.4L5.8 12l4.2 4.2zm6.8 0-1.4-1.4L18.2 12 14 7.8l1.4-1.4L21 12z"></path></svg></a>
|
||||||
|
<a href="https://discord.gg/igneum" target="_blank" rel="noopener" aria-label="The Igneum Discord"><svg viewBox="0 0 24 24" width="22" height="22" fill="currentColor" aria-hidden="true"><path d="M19.6 5.3A17 17 0 0 0 15.4 4l-.2.4a15.6 15.6 0 0 1 3.9 1.9 13.6 13.6 0 0 0-14.2 0A15.6 15.6 0 0 1 8.8 4.4L8.6 4a17 17 0 0 0-4.2 1.3C1.8 9.2 1.1 13 1.4 16.7a17.1 17.1 0 0 0 5.2 2.6l1.1-1.8a10.8 10.8 0 0 1-1.7-.8l.4-.3a12.2 12.2 0 0 0 11.2 0l.4.3-1.7.8 1.1 1.8a17 17 0 0 0 5.2-2.6c.4-4.3-.7-8-3-11.4zM8.7 14.4c-1 0-1.8-.9-1.8-2.1s.8-2.1 1.8-2.1 1.9 1 1.8 2.1c0 1.2-.8 2.1-1.8 2.1zm6.6 0c-1 0-1.8-.9-1.8-2.1s.8-2.1 1.8-2.1 1.9 1 1.8 2.1c0 1.2-.8 2.1-1.8 2.1z"></path></svg></a>
|
||||||
|
<a data-social="reddit" href="https://www.reddit.com/user/Igneum_network/" target="_blank" rel="noopener" aria-label="Igneum on Reddit"><svg viewBox="0 0 24 24" width="22" height="22" fill="currentColor" aria-hidden="true"><path d="M22 12.1a2.2 2.2 0 0 0-3.7-1.6 10.8 10.8 0 0 0-5.8-1.8l1-4.6 3.2.7a1.5 1.5 0 1 0 .2-.9l-3.6-.8a.5.5 0 0 0-.5.4l-1.1 5.2a10.8 10.8 0 0 0-5.9 1.8A2.2 2.2 0 1 0 3.4 14a4.3 4.3 0 0 0 0 .7c0 3.4 3.9 6.1 8.6 6.1s8.6-2.7 8.6-6.1a4.3 4.3 0 0 0 0-.7 2.2 2.2 0 0 0 1.4-1.9zM7 13.6a1.5 1.5 0 1 1 1.5 1.5A1.5 1.5 0 0 1 7 13.6zm8.6 4.1a5.7 5.7 0 0 1-3.6 1.1 5.7 5.7 0 0 1-3.6-1.1.4.4 0 0 1 .6-.6 4.9 4.9 0 0 0 3 .9 4.9 4.9 0 0 0 3-.9.4.4 0 0 1 .6.6zm-.3-2.6a1.5 1.5 0 1 1 1.5-1.5 1.5 1.5 0 0 1-1.5 1.5z"></path></svg></a>
|
||||||
|
</div>
|
||||||
|
<div class="footer-dl" aria-label="Download Ember"><a href="/download#windows"><span class="osmark mini" data-os="windows" title="Windows"><svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true" focusable="false" fill="currentColor"><path d="M3 5.6l7.3-1v7.1H3zM11.4 4.4L21 3v8.7h-9.6zM3 12.3h7.3v7.1L3 18.4zM11.4 12.3H21V21l-9.6-1.4z"/></svg></span>Windows</a><a href="/download#mac"><span class="osmark mini" data-os="mac" title="macOS"><svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true" focusable="false" fill="currentColor"><path d="M16.4 12.6c0-2.5 2-3.6 2.1-3.7-1.2-1.7-3-1.9-3.6-2-1.5-.2-3 .9-3.8.9-.8 0-2-.9-3.3-.8-1.7 0-3.2 1-4.1 2.5-1.8 3-.5 7.6 1.3 10.1.9 1.2 1.9 2.6 3.2 2.5 1.3 0 1.8-.8 3.3-.8 1.6 0 2 .8 3.3.8 1.4 0 2.3-1.2 3.1-2.5 1-1.4 1.4-2.8 1.4-2.9 0 0-2.7-1-2.9-4.1zM13.9 5.3c.7-.8 1.2-2 1-3.2-1 0-2.2.7-2.9 1.5-.6.7-1.2 1.9-1 3 1.1.1 2.2-.5 2.9-1.3z"/></svg></span>macOS</a><a href="/download#linux"><span class="osmark mini" data-os="linux" title="Linux"><svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true" focusable="false" fill="currentColor"><path d="M12 2c-2.4 0-4 1.9-4 4.6 0 1.2.2 2 0 2.8-.6 1.4-2.1 2.9-2.6 4.9-.3 1.1-.1 2 .3 2.6-.6.4-1.3 1-1.1 1.7.3 1 2.1 1.2 3.2 1.8.7.4 1.5.6 2.1.1.6.2 1.3.3 2.1.3s1.5-.1 2.1-.3c.6.5 1.4.3 2.1-.1 1.1-.6 2.9-.8 3.2-1.8.2-.7-.5-1.3-1.1-1.7.4-.6.6-1.5.3-2.6-.5-2-2-3.5-2.6-4.9-.2-.8 0-1.6 0-2.8C16 3.9 14.4 2 12 2zm-1.4 4.2c.5 0 .8.5.8 1.2s-.3 1.2-.8 1.2-.8-.5-.8-1.2.3-1.2.8-1.2zm2.8 0c.5 0 .8.5.8 1.2s-.3 1.2-.8 1.2-.8-.5-.8-1.2.3-1.2.8-1.2zM12 9.3c.9 0 1.9.5 1.9 1s-1 1.2-1.9 1.2-1.9-.7-1.9-1.2 1-1 1.9-1zm0 3.4c2.2 0 3.6 2.6 3.6 4.4 0 1.5-1.6 2.3-3.6 2.3s-3.6-.8-3.6-2.3c0-1.8 1.4-4.4 3.6-4.4z"/></svg></span>Linux</a><a href="/download#hive"><span class="osmark mini" data-os="hive" title="HiveOS"><svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round" stroke-linecap="round"><path d="M12 2.6 20.2 7.3v9.4L12 21.4 3.8 16.7V7.3z"/><path d="M12 7.4 16 9.7v4.6L12 16.6 8 14.3V9.7z"/><path d="M12 7.4V2.6M16 9.7l4.2-2.4M16 14.3l4.2 2.4M12 16.6v4.8M8 14.3l-4.2 2.4M8 9.7 3.8 7.3"/></svg></span>HiveOS</a></div>
|
||||||
|
<a href="https://git.igneum.network/igneum-network/spec" target="_blank" rel="noopener" class="text-link">Specification and test vectors<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M6 18 18 6M6 6h12v12"/></svg></a>
|
||||||
|
</div>
|
||||||
|
<div class="footer-column"><h4>Run</h4><div>
|
||||||
|
<a href="/download">Download Ember</a>
|
||||||
|
<a href="/miner">The miner</a>
|
||||||
|
<a href="/app">The app</a>
|
||||||
|
<a href="/wallet">The wallet</a>
|
||||||
|
<a href="/miners">GPU bench table</a>
|
||||||
|
<a href="/dev-fee">The dev fee</a>
|
||||||
|
<a href="/faucet">Devnet faucet</a>
|
||||||
|
<a href="/metamask">Add Igneum to MetaMask</a>
|
||||||
|
</div></div>
|
||||||
|
<div class="footer-column"><h4>Read</h4><div>
|
||||||
|
<a href="/litepaper">Litepaper</a>
|
||||||
|
<a href="/evidence">Evidence</a>
|
||||||
|
<a href="/bench">Engineering log</a>
|
||||||
|
<a href="/ledger">Ledger: every criticism</a>
|
||||||
|
<a href="/claims">What Igneum does not claim</a>
|
||||||
|
<a href="/randomx">Igneum vs RandomX</a>
|
||||||
|
<a href="/provenance">Built on the shoulders</a>
|
||||||
|
</div></div>
|
||||||
|
<div class="footer-column"><h4>Explore</h4><div>
|
||||||
|
<a href="/live">Live devnet</a>
|
||||||
|
<a href="/explorer">Explorer</a>
|
||||||
|
<a href="/build">Build on Igneum</a>
|
||||||
|
<a href="/swap">Swap</a>
|
||||||
|
<a href="/grants">Grants</a>
|
||||||
|
<a href="/journey">Journey</a>
|
||||||
|
<a href="/download">Downloads: devnet build</a>
|
||||||
|
<a href="https://discord.gg/igneum" target="_blank" rel="noopener">Community Discord</a>
|
||||||
|
<a href="mailto:hello@igneum.network">hello@igneum.network</a>
|
||||||
|
<a href="https://git.igneum.network/igneum-network/spec/issues" rel="noopener">An issue on the spec</a>
|
||||||
|
</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="footer-bottom">
|
||||||
|
<p>Igneum Labs LTD · Unit IH-00-01-01-OF-01, Level 01, Innovation One, Dubai International Financial Centre. Experimental software. Nothing on this site is an offer to sell anything.<br>Report a flaw by email or by an issue on the spec. Nobody from Igneum will ask for your seed. © 2026 Igneum · not an offer to sell anything</p>
|
||||||
|
<div class="theme-group" role="group" aria-label="Theme"><button type="button" data-theme-set="system" aria-pressed="true">System</button><button type="button" data-theme-set="light" aria-pressed="false">Light</button><button type="button" data-theme-set="dark" aria-pressed="false">Dark</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
// theme control: stores light or dark under igneum-theme; system clears it and follows the OS. The head applied the value before paint.
|
||||||
|
var bs=document.querySelectorAll('[data-theme-set]');if(!bs.length)return;
|
||||||
|
var media=window.matchMedia('(prefers-color-scheme: light)');
|
||||||
|
function stored(){try{var t=localStorage.getItem('igneum-theme');return t==='light'||t==='dark'?t:'system';}catch(e){return 'system';}}
|
||||||
|
function apply(){var c=stored();document.documentElement.setAttribute('data-theme',c==='system'?(media.matches?'light':'dark'):c);bs.forEach(function(b){b.setAttribute('aria-pressed',b.getAttribute('data-theme-set')===c?'true':'false');});document.dispatchEvent(new CustomEvent('igneum-theme'));}
|
||||||
|
bs.forEach(function(b){b.addEventListener('click',function(){var v=b.getAttribute('data-theme-set');try{if(v==='system')localStorage.removeItem('igneum-theme');else localStorage.setItem('igneum-theme',v);}catch(e){}apply();});});
|
||||||
|
if(media.addEventListener)media.addEventListener('change',function(){if(stored()==='system')apply();});
|
||||||
|
document.addEventListener('igneum-theme',function(){var c=stored();bs.forEach(function(b){b.setAttribute('aria-pressed',b.getAttribute('data-theme-set')===c?'true':'false');});});
|
||||||
|
bs.forEach(function(b){b.setAttribute('aria-pressed',b.getAttribute('data-theme-set')===stored()?'true':'false');});
|
||||||
|
// the network state word in the status bar: one read of the observer per page view
|
||||||
|
var fs=document.getElementById('foot-state'),fd=document.getElementById('foot-dot');if(fs&&fd){fetch('/api/live?window=30',{cache:'no-store'}).then(function(r){return r.json();}).then(function(d){var on=d&&d.ok&&d.state&&!d.state.stale;fs.textContent=on?'devnet live':'devnet, observer stale';fd.className='dot '+(on?'live':'off');}).catch(function(){fs.textContent='devnet';});}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<!-- footer:end -->
|
||||||
|
<script type="module">
|
||||||
|
import { 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 addr = a => a ? `<a class="hash" href="/address/${esc(a)}">${esc(a)}</a>` : '<span class="kv">none</span>';
|
||||||
|
const dl = pairs => pairs.filter(p => p).map(([k, v]) => `<dt>${esc(k)}</dt><dd>${v}</dd>`).join('');
|
||||||
|
const wei = h => (h === null || h === undefined ? null : BigInt(h).toString());
|
||||||
|
const id = decodeURIComponent(location.pathname.split('/').pop() || '');
|
||||||
|
async function load() {
|
||||||
|
const r = await fetch('/api/explorer?tx=' + encodeURIComponent(id)); const j = await r.json();
|
||||||
|
$('title').textContent = id;
|
||||||
|
if (!j.ok) { $('msg').textContent = j.error || 'Not found.'; return; }
|
||||||
|
const t = j.tx, b = j.block, now = new Date(j.now).getTime(), g = t.igneum || {};
|
||||||
|
document.title = `Devnet 3 transaction ${shortHash(t.hash, 8, 6)}`;
|
||||||
|
$('title').textContent = t.hash;
|
||||||
|
const chips = [t.status === 'success' ? '<span class="chip ok">success</span>' : t.status === 'failed' ? '<span class="chip" style="border-color:var(--excluded);color:var(--excluded)">failed</span>' : '<span class="chip">no receipt</span>'];
|
||||||
|
chips.push(`<span class="chip">chain block ${int(t.number)}</span>`);
|
||||||
|
if (b && b.in_observer) { chips.push(b.lock && b.lock.final ? `<span class="chip ok">final under lock ${int(b.lock.index)}</span>` : '<span class="chip">not under a lock yet</span>'); if (b.shards && b.shards.total) chips.push(`<span class="chip ${b.shards.paid === b.shards.total ? 'ok' : ''}">${b.shards.paid} of ${b.shards.total} shards paid</span>`); chips.push(`<span class="chip">class ${esc(b.class || 'v4')}</span>`); }
|
||||||
|
if (t.contract_address) chips.push('<span class="chip hot">contract creation</span>');
|
||||||
|
$('chips').innerHTML = chips.join('');
|
||||||
|
const fee = t.gas_used !== null && t.effective_gas_price !== null ? (BigInt(t.gas_used) * BigInt(t.effective_gas_price)).toString() : null;
|
||||||
|
$('summary').innerHTML = dl([
|
||||||
|
['Network', 'Devnet 3, chain id ' + esc(j.chain_id ?? 4463)],
|
||||||
|
['Hash', `<span class="hash">${esc(t.hash)}</span>`],
|
||||||
|
['Status', t.status || 'no receipt'],
|
||||||
|
['Block', `<a class="hash" href="/block/${esc(t.block_hash)}">${esc(shortHash(t.block_hash, 12, 8))}</a> <span class="kv">chain block ${int(t.number)}, index ${int(t.index)}</span>`],
|
||||||
|
['Time', t.ts ? `${utc(t.ts)} <span class="kv">(${rel(now - t.ts)})</span>` : 'n/a'],
|
||||||
|
['From', addr(t.from)], ['To', t.to ? addr(t.to) + (t.contracts && t.contracts[t.to] !== undefined ? ' <span class="chip">contract</span>' : '') : (t.contract_address ? `<span class="kv">created</span> ${addr(t.contract_address)}` : '<span class="kv">none</span>')],
|
||||||
|
['Value', `${ignFromWei(t.value_wei, 8)} IGN`],
|
||||||
|
['Fee', fee ? `${ignFromWei(fee, 8)} IGN <span class="kv">${int(t.gas_used)} gas at ${ignFromWei(t.effective_gas_price, 10)} IGN per gas</span>` : 'n/a'],
|
||||||
|
['Gas', `${int(t.gas_used)} used of ${int(t.gas)} limit`],
|
||||||
|
['Nonce', int(t.nonce)], ['Type', `${t.type} <span class="kv">${t.type === 2 ? 'EIP-1559' : t.type === 1 ? 'access list' : 'legacy'}</span>`],
|
||||||
|
['Input', `${int(t.input_bytes)} bytes${t.selector ? `, selector <span class="hash">${esc(t.selector)}</span>` : ''}`],
|
||||||
|
]);
|
||||||
|
$('fees').innerHTML = t.igneum ? dl([
|
||||||
|
['Miner tip', g.minerTip ? `${ignFromWei(wei(g.minerTip), 8)} IGN <span class="kv">to the including miner</span>` : 'n/a'],
|
||||||
|
['Execution base fee burned', g.burnedExecutionBaseFee ? `${ignFromWei(wei(g.burnedExecutionBaseFee), 8)} IGN` : 'n/a'],
|
||||||
|
['Proving fee burned', g.burnedProvingFee ? `${ignFromWei(wei(g.burnedProvingFee), 8)} IGN` : 'n/a'],
|
||||||
|
['Proving gas', g.pgasUsed ? `${int(Number(BigInt(g.pgasUsed)))} pgas${g.provingBaseFeePerGas ? ` at ${ignFromWei(wei(g.provingBaseFeePerGas), 10)} IGN per pgas` : ''}${g.overBudget ? ', over budget' : ''}${g.pgasAborted ? ', aborted' : ''}` : 'n/a'],
|
||||||
|
g.developerShares && g.developerShares.length ? ['App share', g.developerShares.map(s => `${ignFromWei(wei(s.wei), 8)} IGN ${s.payee ? 'to ' + addr(s.payee) : '<span class="kv">unregistered, burned</span>'}`).join('<br>')] : null,
|
||||||
|
['Including block', g.includingBlock ? `<a class="hash" href="/block/${esc(String(g.includingBlock).replace(/^0x/, ''))}">${esc(shortHash(String(g.includingBlock).replace(/^0x/, ''), 12, 8))}</a> <span class="kv">the first block that carried it; executed under chain block ${int(t.number)}</span>` : 'n/a'],
|
||||||
|
['Including miner', g.includingMiner ? addr(g.includingMiner) : 'n/a'],
|
||||||
|
]) : '<div class="empty">No receipt yet.</div>';
|
||||||
|
if (b && b.in_observer) {
|
||||||
|
$('blk-eyebrow').textContent = `chain block ${int(b.number)}`;
|
||||||
|
$('blk').innerHTML = dl([
|
||||||
|
['Block', `<a class="hash" href="/block/${esc(b.hash)}">${esc(b.hash)}</a>`],
|
||||||
|
['Blue score', int(b.blue_score)], ['DAA score', int(b.daa)], ['Class', esc(b.class || 'v4')],
|
||||||
|
['Miner', addr(b.evm_miner)],
|
||||||
|
['Lock state', b.lock ? `${b.lock.final ? 'final' : 'not final'} <span class="kv">${esc(b.lock.reason)}</span>` : 'n/a'],
|
||||||
|
['Proof', b.shards && b.shards.total ? `${b.shards.paid} of ${b.shards.total} shards paid${b.shards.median_lag !== null ? `, median lag ${int(b.shards.median_lag)} DAA` : ''} <span class="kv">${b.shards.proving} proving, ${b.shards.verified} verified, ${b.shards.planned} planned</span>` : '<span class="kv">plan not read yet</span>'],
|
||||||
|
]);
|
||||||
|
} else { $('blk-eyebrow').textContent = 'outside the observer window'; $('blk').innerHTML = dl([['Block', `<a class="hash" href="/block/${esc(t.block_hash)}">${esc(t.block_hash)}</a>`], ['Note', esc(b && b.note ? b.note : 'the observer keeps one day of blocks')]]); }
|
||||||
|
const logs = t.logs || [];
|
||||||
|
$('log-n').textContent = `${logs.length} log${logs.length === 1 ? '' : 's'}`;
|
||||||
|
$('logs').innerHTML = logs.length ? `<table><thead><tr><th class="n">Index</th><th>Address</th><th>Topics</th><th>Data</th></tr></thead><tbody>${logs.map(l => `<tr><td class="n">${int(l.i)}</td><td>${addr(l.address)}</td><td class="hash" style="font-size:12px">${(l.topics || []).map(x => esc(x)).join('<br>')}</td><td class="hash" style="font-size:12px;overflow-wrap:anywhere">${esc(l.data)}</td></tr>`).join('')}</tbody></table>` : '<div class="empty">This transaction emitted no logs.</div>';
|
||||||
|
$('body').hidden = false;
|
||||||
|
}
|
||||||
|
load().catch(e => { $('msg').textContent = 'The API did not answer: ' + e.message; });
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
144
site/vercel.json
144
site/vercel.json
|
|
@ -1,17 +1,139 @@
|
||||||
{
|
{
|
||||||
"cleanUrls": true,
|
"cleanUrls": true,
|
||||||
"trailingSlash": false,
|
"trailingSlash": false,
|
||||||
"redirects": [{"source": "/connect", "destination": "/metamask", "permanent": true}, {"source": "/gpu-benchmarks", "destination": "/miners", "permanent": true}],
|
"redirects": [
|
||||||
"rewrites": [{"source": "/block/:id", "destination": "/block"}, {"source": "/address/:addr", "destination": "/address"}, {"source": "/benchmarks", "destination": "/miners"}],
|
{
|
||||||
|
"source": "/connect",
|
||||||
|
"destination": "/metamask",
|
||||||
|
"permanent": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "/gpu-benchmarks",
|
||||||
|
"destination": "/miners",
|
||||||
|
"permanent": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"rewrites": [
|
||||||
|
{
|
||||||
|
"source": "/",
|
||||||
|
"has": [
|
||||||
|
{
|
||||||
|
"type": "host",
|
||||||
|
"value": "explorer.igneum.network"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"destination": "/explorer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "/tx/:hash",
|
||||||
|
"destination": "/tx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "/block/:id",
|
||||||
|
"destination": "/block"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "/address/:addr",
|
||||||
|
"destination": "/address"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "/benchmarks",
|
||||||
|
"destination": "/miners"
|
||||||
|
}
|
||||||
|
],
|
||||||
"headers": [
|
"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": "/(.*)",
|
||||||
{"source": "/(.*)\\.(png|ico)", "headers": [{"key": "Cache-Control", "value": "public, max-age=86400, stale-while-revalidate=604800"}]},
|
"headers": [
|
||||||
{"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"}]},
|
"key": "X-Content-Type-Options",
|
||||||
{"source": "/api/live", "headers": [{"key": "Cache-Control", "value": "public, max-age=1"}]},
|
"value": "nosniff"
|
||||||
{"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"}]}
|
"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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,8 @@ const GROUPS = {
|
||||||
build: ['/build', '/faucet', '/swap', '/grants'], // the builder programme (8 October 2026): the developer entry page, the Devnet 3 faucet, the swap, the grants
|
build: ['/build', '/faucet', '/swap', '/grants'], // the builder programme (8 October 2026): the developer entry page, the Devnet 3 faucet, the swap, the grants
|
||||||
};
|
};
|
||||||
const ROUTES = Object.values(GROUPS).flat();
|
const ROUTES = Object.values(GROUPS).flat();
|
||||||
// pages served without their own panel entry: the explorer's two rewrites, the 404 and the unlinked scenes lab
|
// pages served without their own panel entry: the explorer's three rewrites (block, address, tx), its proving view, the 404 and the unlinked scenes lab
|
||||||
const UNLISTED = new Set(['block.html', 'address.html', '404.html', 'scenes.html']);
|
const UNLISTED = new Set(['block.html', 'address.html', 'tx.html', 'proving.html', '404.html', 'scenes.html']);
|
||||||
|
|
||||||
export function check(site) {
|
export function check(site) {
|
||||||
const pages = readdirSync(site).filter(f => f.endsWith('.html'));
|
const pages = readdirSync(site).filter(f => f.endsWith('.html'));
|
||||||
|
|
|
||||||
|
|
@ -118,3 +118,7 @@ Three readings the coordinator raised, reproduced read-only and answered:
|
||||||
Under the null (independent residuals, n = 6 epochs) one pair reads r over 0.8 with p about 0.028 (t = 2.67 on 4 degrees of freedom) and over 0.9 with p about 0.007; chance 3-cliques per window are about C(m, 3) p^3: 0.09 at m = 30 ids, about 100 at m = 300. So the clique alone is never the alert; the candidate also needs a design flag on a member (chance per id: `nonce` about 0.002, `late_start` about 1e-6, `spread` unknown until the per-model baseline exists; the devnet's maximum excess is 5.2% against the 10% line) and 6 net windows. At n = 12 (`DETECTOR_WINDOW_EPOCHS=12`) p(r over 0.8) is about 0.001 and chance 3-cliques at m = 300 are about 0.005 per window: set 12 once the testnet has over 100 ids. The first week must add: (1) the excess spread per card model over 12 or more epochs (the devnet has three models over 6); (2) the nonce layout of third-party miners and pools (a stratum extranonce in the high word is honest and non-uniform, so until each software is baselined a `nonce` flag is evidence, not an alert); (3) bands for cards the project does not own, which needs the card model in the coinbase tag; (4) the `machine_group` count, to see what an 8-key card looks like at scale.
|
Under the null (independent residuals, n = 6 epochs) one pair reads r over 0.8 with p about 0.028 (t = 2.67 on 4 degrees of freedom) and over 0.9 with p about 0.007; chance 3-cliques per window are about C(m, 3) p^3: 0.09 at m = 30 ids, about 100 at m = 300. So the clique alone is never the alert; the candidate also needs a design flag on a member (chance per id: `nonce` about 0.002, `late_start` about 1e-6, `spread` unknown until the per-model baseline exists; the devnet's maximum excess is 5.2% against the 10% line) and 6 net windows. At n = 12 (`DETECTOR_WINDOW_EPOCHS=12`) p(r over 0.8) is about 0.001 and chance 3-cliques at m = 300 are about 0.005 per window: set 12 once the testnet has over 100 ids. The first week must add: (1) the excess spread per card model over 12 or more epochs (the devnet has three models over 6); (2) the nonce layout of third-party miners and pools (a stratum extranonce in the high word is honest and non-uniform, so until each software is baselined a `nonce` flag is evidence, not an alert); (3) bands for cards the project does not own, which needs the card model in the coinbase tag; (4) the `machine_group` count, to see what an 8-key card looks like at scale.
|
||||||
|
|
||||||
Consequences per tier: the detector costs a miner nothing (it runs on the observer; one `UPDATE` a minute, two epochs re-read a minute, two regexp scans of the intake every 30 minutes). A home card under 8 keys appears as a `machine_group`, never an alert by itself. A pool's key trips `band_high`, which is informational; a pool that publishes its card mix clears it. The alert's action is the signal of epoch-length.md section 11, whose cost per tier is that document's section 7.
|
Consequences per tier: the detector costs a miner nothing (it runs on the observer; one `UPDATE` a minute, two epochs re-read a minute, two regexp scans of the intake every 30 minutes). A home card under 8 keys appears as a `machine_group`, never an alert by itself. A pool's key trips `band_high`, which is informational; a pool that publishes its card mix clears it. The alert's action is the signal of epoch-length.md section 11, whose cost per tier is that document's section 7.
|
||||||
|
|
||||||
|
## Explorer indexer (8 October 2026)
|
||||||
|
|
||||||
|
`tools/observer/explorer-indexer.mjs` runs beside the observer against the same node's EVM RPC and writes the EVM side the explorer needs: `<prefix>live_txs` (every transaction with its receipt, logs and the Igneum fee split), `<prefix>live_accounts` (balance, nonce, code, sent and received counts, miner flag), `<prefix>explorer_blocks` (number to hash, for the three-deep reorg re-read) and `<prefix>explorer_state` (progress, `igneum_getNodeInfo`). It never writes the observer's tables. Env: `DATABASE_URL`, `LIVE_TABLE_PREFIX` (default `dn3_`), `IGNEUM_EVM_RPC` (default `http://127.0.0.1:26850`), `EXPLORER_NETWORK` (default `igneum-devnet-3`; a node reporting another network is refused), `EXPLORER_RETAIN_HOURS` (168), `EXPLORER_BACKFILL` (0 = from the first block), `EXPLORER_BATCH` (25). `--once` runs one tick. Tests: `node --test tools/observer/explorer-indexer.test.mjs`. On build-1 it is the unit `igneum-explorer-dn3` (docs/plans/explorer.md section 8).
|
||||||
|
|
|
||||||
282
tools/observer/explorer-indexer.mjs
Normal file
282
tools/observer/explorer-indexer.mjs
Normal file
|
|
@ -0,0 +1,282 @@
|
||||||
|
// Igneum explorer indexer: the sibling of tools/observer/observer.mjs for the EVM side (8 October 2026, the explorer lane).
|
||||||
|
// The observer writes every DAG block, the shard plans, the checkpoints and the certificates. This process reads the
|
||||||
|
// execution layer of the same node (eth_getBlockByNumber with the transactions, eth_getBlockReceipts, eth_getBalance,
|
||||||
|
// eth_getTransactionCount, eth_getCode, igneum_getNodeInfo) and writes what /tx/<hash>, /address/<addr> and the
|
||||||
|
// transaction lists need. It never touches the observer's tables; it creates its own three next to them under the same
|
||||||
|
// prefix, so one LIVE_TABLE_PREFIX names one chain on both sides. Node 22 or newer, no dependencies.
|
||||||
|
//
|
||||||
|
// node tools/observer/explorer-indexer.mjs run (env below)
|
||||||
|
// node tools/observer/explorer-indexer.mjs --once one tick, then exit (the smoke run)
|
||||||
|
//
|
||||||
|
// Environment, every value optional:
|
||||||
|
// DATABASE_URL Neon connection string (read from ~/.config/igneum/env when unset; never in the repo)
|
||||||
|
// LIVE_TABLE_PREFIX table prefix, default dn3_ (Devnet 3, the same prefix the observer unit on the build box writes)
|
||||||
|
// IGNEUM_EVM_RPC the node's execution JSON-RPC, default http://127.0.0.1:26850 (the Devnet 3 observer node's)
|
||||||
|
// EXPLORER_NETWORK the network the node must report in igneum_getNodeInfo, default igneum-devnet-3; another name refuses
|
||||||
|
// to index (8 October 2026, 10:09 UTC: a unit pointed at the first devnet's node for 26 s wrote 4,797 of
|
||||||
|
// that chain's blocks into the dn3_ tables; the tables were wiped and re-filled)
|
||||||
|
// EXPLORER_BACKFILL chain blocks to index behind the tip on an empty table, default 0 = from the first block
|
||||||
|
// EXPLORER_RETAIN_HOURS hours of transactions kept, default 168 (7 days); older rows are deleted once an hour
|
||||||
|
// EXPLORER_BATCH chain blocks per round while catching up, default 25
|
||||||
|
// EXPLORER_TICK_MS the idle tick, default 2000
|
||||||
|
//
|
||||||
|
// Tables (created on start if missing; the prefix is written as T below):
|
||||||
|
// T live_txs one row per EVM transaction: hash (0x), block_hash (the DAG block's hash, no 0x, as live_blocks), block_number, tx_index, from_addr, to_addr, value_wei,
|
||||||
|
// nonce, gas, gas_price, gas_used, effective_gas_price, status, contract_address, input_bytes, selector,
|
||||||
|
// logs_count, logs (jsonb), igneum (jsonb: the receipt's igneum section), tx_type, ts_ms, received_at
|
||||||
|
// T live_accounts one row per address seen: balance_wei, nonce, code_bytes, is_contract, sent, received, first_seen_number,
|
||||||
|
// last_seen_number, miner (mined a block in the observer's table), refreshed_at
|
||||||
|
// T explorer_blocks one row per indexed chain block: number, hash, tx_count, gas_used, ts_ms, indexed_at (the reorg check
|
||||||
|
// re-reads the newest three numbers every tick and re-indexes a number whose hash moved)
|
||||||
|
// T explorer_state one row: indexed_number, tip_number, chain_id, node_info (igneum_getNodeInfo: params, digest, genesis),
|
||||||
|
// txs_total, accounts_total, backlog, blocks_per_s, last_error, started_at, updated_at
|
||||||
|
//
|
||||||
|
// Reorgs: Devnet 3's rule is no reorg over depth 3 (release-0.3.22 gates), so three numbers are re-read each tick; a deeper
|
||||||
|
// one shows as a block hash in live_txs that the observer's live_blocks no longer calls a chain block, which the API reports.
|
||||||
|
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
|
||||||
|
const ONCE = process.argv.includes('--once');
|
||||||
|
const T = (process.env.LIVE_TABLE_PREFIX === undefined ? 'dn3_' : process.env.LIVE_TABLE_PREFIX).replace(/[^a-z0-9_]/gi, '');
|
||||||
|
const EVM = process.env.IGNEUM_EVM_RPC || 'http://127.0.0.1:26850';
|
||||||
|
const BACKFILL = Math.max(0, Number(process.env.EXPLORER_BACKFILL) || 0);
|
||||||
|
const NETWORK = process.env.EXPLORER_NETWORK || 'igneum-devnet-3';
|
||||||
|
const RETAIN_H = Math.max(1, Number(process.env.EXPLORER_RETAIN_HOURS) || 168);
|
||||||
|
const BATCH = Math.max(1, Math.min(100, Number(process.env.EXPLORER_BATCH) || 25));
|
||||||
|
const TICK_MS = Math.max(500, Number(process.env.EXPLORER_TICK_MS) || 2000);
|
||||||
|
const ACCOUNTS_PER_TICK = 20;
|
||||||
|
const RPC_CONCURRENCY = 6;
|
||||||
|
|
||||||
|
// ---- pure helpers (tested by explorer-indexer.test.mjs) --------------------------------------------------------------
|
||||||
|
export const hexInt = h => (h === null || h === undefined ? null : Number(BigInt(h)));
|
||||||
|
export const hexBig = h => (h === null || h === undefined ? null : BigInt(h).toString());
|
||||||
|
export const lower = a => (a ? String(a).toLowerCase() : null);
|
||||||
|
|
||||||
|
/** One live_txs row from the block's transaction object and its receipt (both as the node's JSON-RPC returns them). */
|
||||||
|
export function txRow(tx, rc, block) {
|
||||||
|
const input = String(tx.input || '0x');
|
||||||
|
return {
|
||||||
|
hash: lower(tx.hash), block_hash: lower(block.hash).replace(/^0x/, ''), block_number: hexInt(block.number), tx_index: hexInt(tx.transactionIndex) ?? 0,
|
||||||
|
from_addr: lower(tx.from), to_addr: lower(tx.to), value_wei: hexBig(tx.value) || '0', nonce: hexInt(tx.nonce), gas: hexInt(tx.gas),
|
||||||
|
gas_price: hexBig(tx.gasPrice ?? tx.maxFeePerGas) , gas_used: rc ? hexInt(rc.gasUsed) : null, effective_gas_price: rc ? hexBig(rc.effectiveGasPrice) : null,
|
||||||
|
status: rc && rc.status !== undefined && rc.status !== null ? hexInt(rc.status) : null, contract_address: rc ? lower(rc.contractAddress) : null,
|
||||||
|
input_bytes: Math.max(0, (input.length - 2) / 2), selector: input.length >= 10 ? input.slice(0, 10).toLowerCase() : null,
|
||||||
|
logs_count: rc && rc.logs ? rc.logs.length : 0,
|
||||||
|
logs: rc && rc.logs ? rc.logs.map(l => ({ i: hexInt(l.logIndex), address: lower(l.address), topics: l.topics || [], data: l.data || '0x' })) : [],
|
||||||
|
igneum: rc && rc.igneum ? rc.igneum : null, tx_type: hexInt(tx.type) ?? 0, ts_ms: hexInt(block.timestamp) === null ? null : hexInt(block.timestamp) * 1000,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The addresses a block touches, each with why (sender, receiver, contract, miner). */
|
||||||
|
export function touched(rows, block) {
|
||||||
|
const m = new Map();
|
||||||
|
const add = (a, kind) => { if (!a) return; const k = lower(a); if (!m.has(k)) m.set(k, new Set()); m.get(k).add(kind); };
|
||||||
|
for (const r of rows) { add(r.from_addr, 'sender'); add(r.to_addr, 'receiver'); add(r.contract_address, 'contract'); }
|
||||||
|
if (block && block.igneum && Array.isArray(block.igneum.rewards)) for (const r of block.igneum.rewards) add(r.miner, 'miner');
|
||||||
|
if (block && block.miner) add(block.miner, 'miner');
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Neon over HTTP, the same pattern as the observer ---------------------------------------------------------------
|
||||||
|
function envFile() {
|
||||||
|
try { return Object.fromEntries(readFileSync(`${homedir()}/.config/igneum/env`, 'utf8').split('\n').filter(l => /^[A-Z_]+=/.test(l)).map(l => { const i = l.indexOf('='); return [l.slice(0, i), l.slice(i + 1).replace(/^"|"$/g, '')]; })); } catch { return {}; }
|
||||||
|
}
|
||||||
|
function neon(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 }), signal: AbortSignal.timeout(30000) });
|
||||||
|
const j = await r.json();
|
||||||
|
if (!r.ok) throw new Error(j.message || JSON.stringify(j));
|
||||||
|
return j.rows || [];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- JSON-RPC with a small concurrency cap ----------------------------------------------------------------------------
|
||||||
|
let inFlight = 0; const waiters = [];
|
||||||
|
async function rpc(method, params = []) {
|
||||||
|
if (inFlight >= RPC_CONCURRENCY) await new Promise(r => waiters.push(r));
|
||||||
|
inFlight++;
|
||||||
|
try {
|
||||||
|
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(15000) });
|
||||||
|
const j = await r.json();
|
||||||
|
if (j.error) throw new Error(`${method}: ${j.error.message || JSON.stringify(j.error)}`);
|
||||||
|
return j.result;
|
||||||
|
} finally { inFlight--; const w = waiters.shift(); if (w) w(); }
|
||||||
|
}
|
||||||
|
const hex = n => '0x' + Number(n).toString(16);
|
||||||
|
|
||||||
|
// ---- schema ----------------------------------------------------------------------------------------------------------
|
||||||
|
async function schema(sql) {
|
||||||
|
await sql(`CREATE TABLE IF NOT EXISTS ${T}live_txs (
|
||||||
|
hash text PRIMARY KEY, block_hash text NOT NULL, block_number bigint NOT NULL, tx_index integer NOT NULL DEFAULT 0,
|
||||||
|
from_addr text, to_addr text, value_wei text NOT NULL DEFAULT '0', nonce bigint, gas bigint, gas_price text, gas_used bigint,
|
||||||
|
effective_gas_price text, status smallint, contract_address text, input_bytes integer NOT NULL DEFAULT 0, selector text,
|
||||||
|
logs_count integer NOT NULL DEFAULT 0, logs jsonb NOT NULL DEFAULT '[]'::jsonb, igneum jsonb, tx_type smallint NOT NULL DEFAULT 0,
|
||||||
|
ts_ms bigint, received_at timestamptz NOT NULL DEFAULT now())`);
|
||||||
|
await sql(`CREATE INDEX IF NOT EXISTS ${T}live_txs_number ON ${T}live_txs (block_number DESC, tx_index)`);
|
||||||
|
await sql(`CREATE INDEX IF NOT EXISTS ${T}live_txs_from ON ${T}live_txs (from_addr, block_number DESC)`);
|
||||||
|
await sql(`CREATE INDEX IF NOT EXISTS ${T}live_txs_to ON ${T}live_txs (to_addr, block_number DESC)`);
|
||||||
|
await sql(`CREATE INDEX IF NOT EXISTS ${T}live_txs_block ON ${T}live_txs (block_hash)`);
|
||||||
|
await sql(`CREATE INDEX IF NOT EXISTS ${T}live_txs_ts ON ${T}live_txs (ts_ms)`);
|
||||||
|
await sql(`CREATE TABLE IF NOT EXISTS ${T}live_accounts (
|
||||||
|
address text PRIMARY KEY, balance_wei text, nonce bigint, code_bytes integer, is_contract boolean, sent integer NOT NULL DEFAULT 0,
|
||||||
|
received integer NOT NULL DEFAULT 0, first_seen_number bigint, last_seen_number bigint, miner boolean NOT NULL DEFAULT false,
|
||||||
|
refreshed_at timestamptz, updated_at timestamptz NOT NULL DEFAULT now())`);
|
||||||
|
await sql(`CREATE INDEX IF NOT EXISTS ${T}live_accounts_seen ON ${T}live_accounts (last_seen_number DESC)`);
|
||||||
|
await sql(`CREATE TABLE IF NOT EXISTS ${T}explorer_blocks (number bigint PRIMARY KEY, hash text NOT NULL, tx_count integer NOT NULL DEFAULT 0,
|
||||||
|
gas_used bigint, ts_ms bigint, indexed_at timestamptz NOT NULL DEFAULT now())`);
|
||||||
|
await sql(`CREATE TABLE IF NOT EXISTS ${T}explorer_state (id integer PRIMARY KEY, indexed_number bigint, tip_number bigint, chain_id integer,
|
||||||
|
node_info jsonb, txs_total bigint, accounts_total bigint, backlog integer, blocks_per_s double precision, last_error text,
|
||||||
|
rpc text, started_at timestamptz, updated_at timestamptz)`);
|
||||||
|
await sql(`INSERT INTO ${T}explorer_state (id, started_at, updated_at, rpc) VALUES (1, now(), now(), $1) ON CONFLICT (id) DO UPDATE SET started_at = now(), rpc = $1`, [EVM]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the work ----------------------------------------------------------------------------------------------------------
|
||||||
|
const log = (...a) => console.log(new Date().toISOString(), ...a);
|
||||||
|
const accountQueue = new Map(); // address -> {kinds:Set, number}
|
||||||
|
const contractKnown = new Map(); // address -> is_contract (code fetched once)
|
||||||
|
let txsPerMin = 0, blocksPerMin = 0, accountsPerMin = 0, lastError = null;
|
||||||
|
|
||||||
|
async function indexNumbers(sql, numbers) {
|
||||||
|
const blocks = await Promise.all(numbers.map(n => rpc('eth_getBlockByNumber', [hex(n), true])));
|
||||||
|
const receipts = await Promise.all(numbers.map((n, i) => (blocks[i] && blocks[i].transactions && blocks[i].transactions.length ? rpc('eth_getBlockReceipts', [hex(n)]).catch(() => null) : Promise.resolve([]))));
|
||||||
|
const rows = []; const blockRows = [];
|
||||||
|
for (let i = 0; i < numbers.length; i++) {
|
||||||
|
const b = blocks[i]; if (!b) continue;
|
||||||
|
const byHash = new Map((receipts[i] || []).map(r => [lower(r.transactionHash), r]));
|
||||||
|
const txs = (b.transactions || []).map(t => txRow(t, byHash.get(lower(t.hash)) || null, b));
|
||||||
|
rows.push(...txs);
|
||||||
|
blockRows.push({ number: hexInt(b.number), hash: lower(b.hash).replace(/^0x/, ''), tx_count: txs.length, gas_used: hexInt(b.gasUsed), ts_ms: hexInt(b.timestamp) * 1000 });
|
||||||
|
for (const [a, kinds] of touched(txs, b)) { const q = accountQueue.get(a) || { kinds: new Set(), number: 0 }; for (const k of kinds) q.kinds.add(k); q.number = Math.max(q.number, hexInt(b.number)); accountQueue.set(a, q); }
|
||||||
|
}
|
||||||
|
// a number re-indexed after a reorg drops its old rows first
|
||||||
|
await sql(`DELETE FROM ${T}live_txs WHERE block_number = ANY($1::bigint[])`, [numbers]);
|
||||||
|
for (let off = 0; off < rows.length; off += 200) {
|
||||||
|
const chunk = rows.slice(off, off + 200); const vals = []; const params = [];
|
||||||
|
chunk.forEach((r, k) => {
|
||||||
|
const base = k * 22;
|
||||||
|
vals.push(`(${Array.from({ length: 22 }, (_, j) => '$' + (base + j + 1)).join(',')})`);
|
||||||
|
params.push(r.hash, r.block_hash, r.block_number, r.tx_index, r.from_addr, r.to_addr, r.value_wei, r.nonce, r.gas, r.gas_price, r.gas_used, r.effective_gas_price, r.status, r.contract_address, r.input_bytes, r.selector, r.logs_count, JSON.stringify(r.logs), r.igneum ? JSON.stringify(r.igneum) : null, r.tx_type, r.ts_ms, new Date().toISOString());
|
||||||
|
});
|
||||||
|
await sql(`INSERT INTO ${T}live_txs (hash, block_hash, block_number, tx_index, from_addr, to_addr, value_wei, nonce, gas, gas_price, gas_used, effective_gas_price, status, contract_address, input_bytes, selector, logs_count, logs, igneum, tx_type, ts_ms, received_at)
|
||||||
|
VALUES ${vals.join(',')} ON CONFLICT (hash) DO UPDATE SET block_hash = EXCLUDED.block_hash, block_number = EXCLUDED.block_number, tx_index = EXCLUDED.tx_index, gas_used = EXCLUDED.gas_used,
|
||||||
|
effective_gas_price = EXCLUDED.effective_gas_price, status = EXCLUDED.status, contract_address = EXCLUDED.contract_address, logs_count = EXCLUDED.logs_count, logs = EXCLUDED.logs, igneum = EXCLUDED.igneum, ts_ms = EXCLUDED.ts_ms`, params);
|
||||||
|
}
|
||||||
|
if (blockRows.length) {
|
||||||
|
const vals = []; const params = [];
|
||||||
|
blockRows.forEach((b, k) => { vals.push(`($${k * 5 + 1},$${k * 5 + 2},$${k * 5 + 3},$${k * 5 + 4},$${k * 5 + 5})`); params.push(b.number, b.hash, b.tx_count, b.gas_used, b.ts_ms); });
|
||||||
|
await sql(`INSERT INTO ${T}explorer_blocks (number, hash, tx_count, gas_used, ts_ms) VALUES ${vals.join(',')} ON CONFLICT (number) DO UPDATE SET hash = EXCLUDED.hash, tx_count = EXCLUDED.tx_count, gas_used = EXCLUDED.gas_used, ts_ms = EXCLUDED.ts_ms, indexed_at = now()`, params);
|
||||||
|
}
|
||||||
|
// sent and received counts and first/last seen, in one statement per batch
|
||||||
|
const seen = new Map();
|
||||||
|
for (const r of rows) {
|
||||||
|
if (r.from_addr) { const s = seen.get(r.from_addr) || { sent: 0, received: 0, min: r.block_number, max: r.block_number }; s.sent++; s.min = Math.min(s.min, r.block_number); s.max = Math.max(s.max, r.block_number); seen.set(r.from_addr, s); }
|
||||||
|
if (r.to_addr) { const s = seen.get(r.to_addr) || { sent: 0, received: 0, min: r.block_number, max: r.block_number }; s.received++; s.min = Math.min(s.min, r.block_number); s.max = Math.max(s.max, r.block_number); seen.set(r.to_addr, s); }
|
||||||
|
}
|
||||||
|
const ents = [...seen.entries()];
|
||||||
|
for (let off = 0; off < ents.length; off += 300) {
|
||||||
|
const chunk = ents.slice(off, off + 300); const vals = []; const params = [];
|
||||||
|
chunk.forEach(([a, s], k) => { vals.push(`($${k * 5 + 1},$${k * 5 + 2},$${k * 5 + 3},$${k * 5 + 4},$${k * 5 + 5})`); params.push(a, s.sent, s.received, s.min, s.max); });
|
||||||
|
await sql(`INSERT INTO ${T}live_accounts (address, sent, received, first_seen_number, last_seen_number) VALUES ${vals.join(',')}
|
||||||
|
ON CONFLICT (address) DO UPDATE SET sent = ${T}live_accounts.sent + EXCLUDED.sent, received = ${T}live_accounts.received + EXCLUDED.received,
|
||||||
|
first_seen_number = LEAST(${T}live_accounts.first_seen_number, EXCLUDED.first_seen_number), last_seen_number = GREATEST(${T}live_accounts.last_seen_number, EXCLUDED.last_seen_number), updated_at = now()`, params);
|
||||||
|
}
|
||||||
|
txsPerMin += rows.length; blocksPerMin += blockRows.length;
|
||||||
|
return blockRows;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshAccounts(sql, max) {
|
||||||
|
if (!accountQueue.size) return 0;
|
||||||
|
const picks = [...accountQueue.entries()].sort((a, b) => b[1].number - a[1].number).slice(0, max);
|
||||||
|
for (const [a] of picks) accountQueue.delete(a);
|
||||||
|
const reads = await Promise.all(picks.map(async ([a, q]) => {
|
||||||
|
try {
|
||||||
|
const [bal, nonce] = await Promise.all([rpc('eth_getBalance', [a, 'latest']), rpc('eth_getTransactionCount', [a, 'latest'])]);
|
||||||
|
let code = contractKnown.get(a);
|
||||||
|
if (code === undefined || q.kinds.has('contract')) { const c = await rpc('eth_getCode', [a, 'latest']); code = Math.max(0, (String(c || '0x').length - 2) / 2); contractKnown.set(a, code); }
|
||||||
|
return { a, bal: hexBig(bal), nonce: hexInt(nonce), code, miner: q.kinds.has('miner') };
|
||||||
|
} catch (e) { lastError = String(e.message || e).slice(0, 200); accountQueue.set(a, q); return null; }
|
||||||
|
}));
|
||||||
|
const ok = reads.filter(Boolean);
|
||||||
|
if (ok.length) {
|
||||||
|
const vals = []; const params = [];
|
||||||
|
ok.forEach((r, k) => { vals.push(`($${k * 6 + 1},$${k * 6 + 2},$${k * 6 + 3},$${k * 6 + 4},$${k * 6 + 5},$${k * 6 + 6})`); params.push(r.a, r.bal, r.nonce, r.code, r.code > 0, r.miner); });
|
||||||
|
await sql(`INSERT INTO ${T}live_accounts (address, balance_wei, nonce, code_bytes, is_contract, miner, refreshed_at) VALUES ${vals.map(v => v.replace(/\)$/, ', now())')).join(',')}
|
||||||
|
ON CONFLICT (address) DO UPDATE SET balance_wei = EXCLUDED.balance_wei, nonce = EXCLUDED.nonce, code_bytes = EXCLUDED.code_bytes, is_contract = EXCLUDED.is_contract,
|
||||||
|
miner = ${T}live_accounts.miner OR EXCLUDED.miner, refreshed_at = now(), updated_at = now()`, params);
|
||||||
|
}
|
||||||
|
accountsPerMin += ok.length;
|
||||||
|
return ok.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function queueMiners(sql) {
|
||||||
|
// the observer's miners of the last 10 minutes get a balance read every minute, so a miner page is never older than that
|
||||||
|
const rows = await sql(`SELECT DISTINCT evm_miner AS a FROM ${T}live_blocks WHERE received_at > now() - interval '10 minutes' AND evm_miner IS NOT NULL`).catch(() => []);
|
||||||
|
for (const r of rows) { const q = accountQueue.get(lower(r.a)) || { kinds: new Set(), number: 0 }; q.kinds.add('miner'); accountQueue.set(lower(r.a), q); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const env = envFile();
|
||||||
|
const sql = neon(process.env.DATABASE_URL || env.DATABASE_URL);
|
||||||
|
await schema(sql);
|
||||||
|
for (const r of await sql(`SELECT address, code_bytes FROM ${T}live_accounts WHERE code_bytes IS NOT NULL`)) contractKnown.set(r.address, Number(r.code_bytes));
|
||||||
|
const st = (await sql(`SELECT indexed_number FROM ${T}explorer_state WHERE id = 1`))[0] || {};
|
||||||
|
let indexed = st.indexed_number === null || st.indexed_number === undefined ? null : Number(st.indexed_number);
|
||||||
|
const chainId = hexInt(await rpc('eth_chainId'));
|
||||||
|
const info0 = await rpc('igneum_getNodeInfo').catch(() => null);
|
||||||
|
if (!info0 || info0.network !== NETWORK) throw new Error(`the node at ${EVM} reports network ${info0 ? info0.network : 'unknown'}, this indexer writes ${NETWORK} (EXPLORER_NETWORK); refusing to index`);
|
||||||
|
log(`explorer indexer: prefix ${T}, rpc ${EVM} (${info0.network}, digest ${String(info0.digest).slice(0, 8)}), chain id ${chainId}, indexed ${indexed === null ? 'nothing yet' : indexed}, batch ${BATCH}, retain ${RETAIN_H} h`);
|
||||||
|
let lastInfo = 0, lastMiners = 0, lastRetain = 0, lastLog = Date.now(), lastRate = Date.now(), rateBlocks = 0;
|
||||||
|
for (;;) {
|
||||||
|
const t0 = Date.now();
|
||||||
|
try {
|
||||||
|
const tip = hexInt(await rpc('eth_blockNumber'));
|
||||||
|
if (indexed === null) { indexed = BACKFILL > 0 ? Math.max(-1, tip - BACKFILL) : -1; log(`starting at chain block ${indexed + 1}, tip ${tip}`); }
|
||||||
|
// the reorg check: the newest three indexed numbers must still carry the hash we stored
|
||||||
|
if (indexed >= 0) {
|
||||||
|
const lo = Math.max(0, indexed - 2);
|
||||||
|
const stored = await sql(`SELECT number, hash FROM ${T}explorer_blocks WHERE number BETWEEN $1 AND $2`, [lo, indexed]);
|
||||||
|
const live = await Promise.all(stored.map(s => rpc('eth_getBlockByNumber', [hex(Number(s.number)), false]).catch(() => null)));
|
||||||
|
const moved = stored.filter((s, i) => live[i] && lower(live[i].hash).replace(/^0x/, '') !== s.hash).map(s => Number(s.number));
|
||||||
|
if (moved.length) { const from = Math.min(...moved); log(`reorg: chain block ${from} moved, re-indexing from it`); indexed = from - 1; }
|
||||||
|
}
|
||||||
|
const behind = tip - indexed;
|
||||||
|
if (behind > 0) {
|
||||||
|
const n = Math.min(BATCH, behind);
|
||||||
|
const numbers = Array.from({ length: n }, (_, i) => indexed + 1 + i);
|
||||||
|
await indexNumbers(sql, numbers);
|
||||||
|
indexed += n; rateBlocks += n;
|
||||||
|
}
|
||||||
|
if (Date.now() - lastMiners > 60000) { await queueMiners(sql); lastMiners = Date.now(); }
|
||||||
|
// while catching up, only the newest addresses are refreshed; the rest wait for the tip
|
||||||
|
await refreshAccounts(sql, behind > BATCH ? 4 : ACCOUNTS_PER_TICK);
|
||||||
|
let info = null;
|
||||||
|
if (Date.now() - lastInfo > 300000) { info = await rpc('igneum_getNodeInfo').catch(() => null); lastInfo = Date.now(); if (info && info.network !== NETWORK) throw new Error(`the node now reports network ${info.network}, not ${NETWORK}; stopping`); }
|
||||||
|
if (indexed > tip + 100) { log(`the node's tip ${tip} is behind the indexed number ${indexed}: a node that moved back; re-indexing from ${tip - 3}`); indexed = Math.max(-1, tip - 3); }
|
||||||
|
if (Date.now() - lastRetain > 3600000) {
|
||||||
|
const cut = Date.now() - RETAIN_H * 3600000;
|
||||||
|
const d = await sql(`WITH d AS (DELETE FROM ${T}live_txs WHERE ts_ms < $1 RETURNING 1) SELECT count(*)::int AS n FROM d`, [cut]);
|
||||||
|
await sql(`DELETE FROM ${T}explorer_blocks WHERE ts_ms < $1`, [cut]);
|
||||||
|
if (d[0] && d[0].n) log(`retention: ${d[0].n} transactions older than ${RETAIN_H} h removed`);
|
||||||
|
lastRetain = Date.now();
|
||||||
|
}
|
||||||
|
const dt = (Date.now() - lastRate) / 1000; let bps = null; if (dt >= 10) { bps = rateBlocks / dt; rateBlocks = 0; lastRate = Date.now(); }
|
||||||
|
await sql(`UPDATE ${T}explorer_state SET indexed_number = $1, tip_number = $2, chain_id = $3, backlog = $4, blocks_per_s = COALESCE($5, blocks_per_s), last_error = $6, updated_at = now(),
|
||||||
|
node_info = COALESCE($7::jsonb, node_info), txs_total = CASE WHEN $8 THEN (SELECT count(*) FROM ${T}live_txs) ELSE txs_total END,
|
||||||
|
accounts_total = CASE WHEN $8 THEN (SELECT count(*) FROM ${T}live_accounts) ELSE accounts_total END WHERE id = 1`,
|
||||||
|
[indexed, tip, chainId, Math.max(0, tip - indexed), bps, lastError, info ? JSON.stringify(info) : null, Date.now() - lastLog > 60000 || ONCE]);
|
||||||
|
if (Date.now() - lastLog > 60000) { log(`indexed ${indexed} of tip ${tip} (behind ${tip - indexed}); last minute: ${blocksPerMin} blocks, ${txsPerMin} txs, ${accountsPerMin} account reads, queue ${accountQueue.size}${lastError ? `, last error: ${lastError}` : ''}`); txsPerMin = blocksPerMin = accountsPerMin = 0; lastError = null; lastLog = Date.now(); }
|
||||||
|
if (ONCE) { log(`once: indexed ${indexed}, tip ${tip}`); return; }
|
||||||
|
if (tip - indexed <= 0) await new Promise(r => setTimeout(r, Math.max(0, TICK_MS - (Date.now() - t0))));
|
||||||
|
} catch (e) {
|
||||||
|
lastError = String(e.message || e).slice(0, 200);
|
||||||
|
log(`tick failed: ${lastError}`);
|
||||||
|
if (ONCE) { process.exitCode = 1; return; }
|
||||||
|
await new Promise(r => setTimeout(r, 5000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.argv[1] && /explorer-indexer\.mjs$/.test(process.argv[1])) main().catch(e => { console.error(e); process.exit(1); });
|
||||||
38
tools/observer/explorer-indexer.test.mjs
Normal file
38
tools/observer/explorer-indexer.test.mjs
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
// The explorer indexer's pure parts: a transaction row from the node's JSON, the touched-address set, the hex helpers.
|
||||||
|
// node --test tools/observer/explorer-indexer.test.mjs
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { txRow, touched, hexInt, hexBig } from './explorer-indexer.mjs';
|
||||||
|
|
||||||
|
const block = { hash: '0xE61CFFB1D306AD8D3F4F9FEC6D750100517572F54A14A82B40EBFD393531320A', number: '0x654d', timestamp: '0x6acd', miner: '0x8DB0505bbb03dffb58875897eeedc4e790a68d18', igneum: { rewards: [{ miner: '0x78fb14e43d3170dcf9abfdc70f15685c8ea31678', wei: '0x1' }] } };
|
||||||
|
const tx = { hash: '0x4E7C2A9D54208B70A557184CB35E5847CDA4E90FFCA9578C6FC05B9C0DC4EF25', from: '0xE617a7f966fc009934e943fe0453821ff0c8716f', to: '0x7fe2f74b45dd8f0a739c906817f197631fdeff85', value: '0x0', nonce: '0x1a', gas: '0x38936', gasPrice: '0x178411b200', input: '0xa9059cbb000000000000000000000000', transactionIndex: '0x3', type: '0x2' };
|
||||||
|
const rc = { transactionHash: tx.hash, gasUsed: '0x6e5e', effectiveGasPrice: '0x178411b200', status: '0x1', contractAddress: null, logs: [{ logIndex: '0x0', address: '0x7FE2f74b45dd8f0a739c906817f197631fdeff85', topics: ['0xaa'], data: '0x01' }], igneum: { minerTip: '0x148eb7b4f000', pgasUsed: '0x56c' } };
|
||||||
|
|
||||||
|
test('hex helpers: null stays null, big values keep every digit', () => {
|
||||||
|
assert.equal(hexInt(null), null); assert.equal(hexInt('0x654d'), 25933);
|
||||||
|
assert.equal(hexBig('0x18c55612df9b9f9a3600'), '116977006436888600000000'); assert.equal(hexBig(undefined), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('txRow: lower-case addresses and hashes, numbers decoded, the receipt folded in, the selector and the log list', () => {
|
||||||
|
const r = txRow(tx, rc, block);
|
||||||
|
assert.equal(r.hash, tx.hash.toLowerCase()); assert.equal(r.block_hash, block.hash.toLowerCase().slice(2)); assert.equal(r.block_number, 25933); assert.equal(r.tx_index, 3);
|
||||||
|
assert.equal(r.from_addr, '0xe617a7f966fc009934e943fe0453821ff0c8716f'); assert.equal(r.to_addr, '0x7fe2f74b45dd8f0a739c906817f197631fdeff85');
|
||||||
|
assert.equal(r.value_wei, '0'); assert.equal(r.nonce, 26); assert.equal(r.gas, 231734); assert.equal(r.gas_used, 28254); assert.equal(r.status, 1);
|
||||||
|
assert.equal(r.selector, '0xa9059cbb'); assert.equal(r.input_bytes, 16); assert.equal(r.logs_count, 1); assert.equal(r.logs[0].address, '0x7fe2f74b45dd8f0a739c906817f197631fdeff85');
|
||||||
|
assert.equal(r.igneum.minerTip, '0x148eb7b4f000'); assert.equal(r.tx_type, 2); assert.equal(r.ts_ms, 0x6acd * 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('txRow without a receipt: the receipt fields are null, nothing throws', () => {
|
||||||
|
const r = txRow({ ...tx, input: '0x', to: null }, null, block);
|
||||||
|
assert.equal(r.gas_used, null); assert.equal(r.status, null); assert.equal(r.selector, null); assert.equal(r.to_addr, null); assert.deepEqual(r.logs, []); assert.equal(r.igneum, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('touched: senders, receivers, the created contract and the rewarded miners, each lower case with its reasons', () => {
|
||||||
|
const rows = [txRow(tx, { ...rc, contractAddress: '0xABCDEF0000000000000000000000000000000001' }, block)];
|
||||||
|
const m = touched(rows, block);
|
||||||
|
assert.deepEqual([...m.get('0xe617a7f966fc009934e943fe0453821ff0c8716f')], ['sender']);
|
||||||
|
assert.deepEqual([...m.get('0x7fe2f74b45dd8f0a739c906817f197631fdeff85')], ['receiver']);
|
||||||
|
assert.deepEqual([...m.get('0xabcdef0000000000000000000000000000000001')], ['contract']);
|
||||||
|
assert.deepEqual([...m.get('0x78fb14e43d3170dcf9abfdc70f15685c8ea31678')], ['miner']);
|
||||||
|
assert.deepEqual([...m.get('0x8db0505bbb03dffb58875897eeedc4e790a68d18')], ['miner']);
|
||||||
|
});
|
||||||
|
|
@ -18,6 +18,7 @@ LIVE_NETWORK="${LIVE_NETWORK:-igneum-devnet-3}"
|
||||||
INDEX_STRINGS=("At launch the strongest chip in our public model" "git.igneum.network/igneum-network/")
|
INDEX_STRINGS=("At launch the strongest chip in our public model" "git.igneum.network/igneum-network/")
|
||||||
LEGAL_PAGE="${LEGAL_PAGE:-/litepaper}"; LEGAL_STRING="Not legal advice" # the legal line lives on the litepaper in master's tree (22:16 UK: no commit put it on the index)
|
LEGAL_PAGE="${LEGAL_PAGE:-/litepaper}"; LEGAL_STRING="Not legal advice" # the legal line lives on the litepaper in master's tree (22:16 UK: no commit put it on the index)
|
||||||
MINERS_MIN_ROWS="${MINERS_MIN_ROWS:-20}" # the current-class table alone (the datacentre rows sit in their own table since 8 Oct 2026)
|
MINERS_MIN_ROWS="${MINERS_MIN_ROWS:-20}" # the current-class table alone (the datacentre rows sit in their own table since 8 Oct 2026)
|
||||||
|
SERVED_PAGES=(swap "faucet|Devnet 3 faucet" "build|Built to prove every block" "grants|Not legal advice.") # clean URLs every deploy must answer 200 (8 Oct 2026: the DEX lane's /swap, the builder lane's /faucet; /light /receipt /oracle /build /grants join as they land)
|
||||||
SITE="${SITE_URL:-https://igneum.network}"
|
SITE="${SITE_URL:-https://igneum.network}"
|
||||||
# the /miners row count: the rows of the current-class table (table#bench-current, the regrouped bench of 8 Oct 2026; the datacentre
|
# the /miners row count: the rows of the current-class table (table#bench-current, the regrouped bench of 8 Oct 2026; the datacentre
|
||||||
# and earlier tables sit under their own ids); known-failed: an empty table reads 0
|
# and earlier tables sit under their own ids); known-failed: an empty table reads 0
|
||||||
|
|
@ -40,16 +41,30 @@ post_checks() { # one pass: prints the first mismatch as "field: detail" and ret
|
||||||
local miners; miners=$(curl -fsS "${nc[@]}" --max-time 20 "$SITE/miners?x=$RANDOM" 2>/dev/null) || { echo "miners: not reachable"; return 1; }
|
local miners; miners=$(curl -fsS "${nc[@]}" --max-time 20 "$SITE/miners?x=$RANDOM" 2>/dev/null) || { echo "miners: not reachable"; return 1; }
|
||||||
rows=$(miners_rows "$miners")
|
rows=$(miners_rows "$miners")
|
||||||
[ "${rows:-0}" -ge "$MINERS_MIN_ROWS" ] || { echo "miners rows: ${rows:-0} (want at least $MINERS_MIN_ROWS)"; return 1; }
|
[ "${rows:-0}" -ge "$MINERS_MIN_ROWS" ] || { echo "miners rows: ${rows:-0} (want at least $MINERS_MIN_ROWS)"; return 1; }
|
||||||
echo "ok: api/live $n, ${#INDEX_STRINGS[@]} index strings, the legal line on $LEGAL_PAGE, $rows miners rows"
|
# the builder-programme pages (8 October 2026): every clean URL in SERVED_PAGES answers 200; a page a lane lands is added here
|
||||||
|
# on its deploy, so a later deploy that loses one reads RED with the path
|
||||||
|
# each entry is "path" or "path|string": the page answers 200 and, when given, carries the string (the lane's own read-back line)
|
||||||
|
local pg path want body code
|
||||||
|
for pg in "${SERVED_PAGES[@]}"; do
|
||||||
|
path="${pg%%|*}"; want=""; case "$pg" in *"|"*) want="${pg#*|}" ;; esac
|
||||||
|
body=$(curl -s -w '\n%{http_code}' "${nc[@]}" --max-time 20 "$SITE/$path?x=$RANDOM" 2>/dev/null) || body=$'\n000'
|
||||||
|
code="${body##*$'\n'}"; body="${body%$'\n'*}"
|
||||||
|
[ "$code" = 200 ] || { echo "page /$path: HTTP $code (want 200)"; return 1; }
|
||||||
|
[ -z "$want" ] || printf '%s' "$body" | /usr/bin/grep -cF -- "$want" >/dev/null || { echo "page /$path: string missing: \"$want\""; return 1; }
|
||||||
|
done
|
||||||
|
echo "ok: api/live $n, ${#INDEX_STRINGS[@]} index strings, the legal line on $LEGAL_PAGE, $rows miners rows, ${#SERVED_PAGES[@]} pages 200"
|
||||||
}
|
}
|
||||||
if [ "${1:-}" = --self-test-checks ]; then
|
if [ "${1:-}" = --self-test-checks ]; then
|
||||||
# known-failed first: a network constant the live site cannot carry must read RED with the field; then the live read as it stands
|
# known-failed first: a network constant the live site cannot carry must read RED with the field; then the live read as it stands
|
||||||
LIVE_NETWORK="igneum-no-such-network" post_checks >/dev/null 2>&1 && { echo "post-deploy self-test: FAIL: a wrong network passed"; exit 1; }
|
LIVE_NETWORK="igneum-no-such-network" post_checks >/dev/null 2>&1 && { echo "post-deploy self-test: FAIL: a wrong network passed"; exit 1; }
|
||||||
out=$(LIVE_NETWORK="igneum-no-such-network" post_checks 2>&1 || true); case "$out" in "api/live network:"*) ;; *) echo "post-deploy self-test: FAIL: the wrong-network line was '$out'"; exit 1 ;; esac
|
out=$(LIVE_NETWORK="igneum-no-such-network" post_checks 2>&1 || true); case "$out" in "api/live network:"*) ;; *) echo "post-deploy self-test: FAIL: the wrong-network line was '$out'"; exit 1 ;; esac
|
||||||
|
# an array is set inside a subshell, not as a command prefix (a prefix makes it the string "(x)")
|
||||||
|
( SERVED_PAGES=("no-such-page-7f3a"); post_checks >/dev/null 2>&1 ) && { echo "post-deploy self-test: FAIL: a missing page passed"; exit 1; }
|
||||||
|
out=$( SERVED_PAGES=("no-such-page-7f3a"); post_checks 2>&1 || true ); case "$out" in "page /no-such-page-7f3a:"*) ;; *) echo "post-deploy self-test: FAIL: the missing-page line was '$out'"; exit 1 ;; esac
|
||||||
INDEX_STRINGS=("no such string on any page 7f3a") LIVE_NETWORK="$(curl -fsS --max-time 20 "$SITE/api/live" | python3 -c 'import sys,json; print(json.load(sys.stdin)["state"]["network"])')" post_checks >/dev/null 2>&1 && { echo "post-deploy self-test: FAIL: a missing index string passed"; exit 1; }
|
INDEX_STRINGS=("no such string on any page 7f3a") LIVE_NETWORK="$(curl -fsS --max-time 20 "$SITE/api/live" | python3 -c 'import sys,json; print(json.load(sys.stdin)["state"]["network"])')" post_checks >/dev/null 2>&1 && { echo "post-deploy self-test: FAIL: a missing index string passed"; exit 1; }
|
||||||
[ "$(miners_rows '<table id="bench-current"><tr class="hdr"></tr></table><table id="bench-earlier"><tr class="row"></tr></table>')" = 0 ] || { echo "post-deploy self-test: FAIL: an empty current table did not read 0 rows"; exit 1; }
|
[ "$(miners_rows '<table id="bench-current"><tr class="hdr"></tr></table><table id="bench-earlier"><tr class="row"></tr></table>')" = 0 ] || { echo "post-deploy self-test: FAIL: an empty current table did not read 0 rows"; exit 1; }
|
||||||
[ "$(miners_rows '<table id="bench-current"><tr class="row"></tr><tr class="detail"></tr><tr class="row"></tr></table>')" = 2 ] || { echo "post-deploy self-test: FAIL: two rows did not read 2"; exit 1; }
|
[ "$(miners_rows '<table id="bench-current"><tr class="row"></tr><tr class="detail"></tr><tr class="row"></tr></table>')" = 2 ] || { echo "post-deploy self-test: FAIL: two rows did not read 2"; exit 1; }
|
||||||
echo "post-deploy self-test: a wrong network and a missing index string read RED with the field; an empty current table reads 0 rows, two rows read 2"
|
echo "post-deploy self-test: a wrong network, a missing index string and a missing page read RED with the field; an empty current table reads 0 rows, two rows read 2"
|
||||||
echo "live site now: $(post_checks 2>&1 || true)"
|
echo "live site now: $(post_checks 2>&1 || true)"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,10 @@ 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 { }
|
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 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 }));
|
// a rewrite with a host condition (vercel.json `has: [{type: 'host', value}]`, the explorer host's root since 8 October 2026) applies only
|
||||||
|
// when the request's Host matches, as on Vercel; without this the explorer's host rewrite turned the local "/" into /explorer and the
|
||||||
|
// scene parity gate found no scene on the home fold
|
||||||
|
const rewrites = (cfg.rewrites || []).map(r => ({ re: new RegExp('^' + r.source.replace(/:[a-z]+/g, '[^/]+') + '$'), to: r.destination, host: ((r.has || []).find(h => h.type === 'host') || {}).value || null }));
|
||||||
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 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();
|
const handlers = new Map();
|
||||||
async function api(name) {
|
async function api(name) {
|
||||||
|
|
@ -29,7 +32,8 @@ function shim(res) {
|
||||||
}
|
}
|
||||||
createServer(async (req, res) => {
|
createServer(async (req, res) => {
|
||||||
const url = new URL(req.url, 'http://x'); let p = decodeURIComponent(url.pathname);
|
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; }
|
const host = String(req.headers.host || '').replace(/:\d+$/, '');
|
||||||
|
for (const rw of rewrites) { if (rw.host && rw.host !== host) continue; if (rw.re.test(p)) { p = rw.to; break; } }
|
||||||
if (p.startsWith('/api/')) {
|
if (p.startsWith('/api/')) {
|
||||||
const name = p.slice(5).replace(/[^a-z0-9_-]/gi, '');
|
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'); }
|
if (!existsSync(join(site, 'api', name + '.mjs'))) { res.statusCode = 404; return res.end('no such function'); }
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue