Thanks to everyone who joined this week’s Crosslink Workshop. If you couldn’t make it, you can watch the recording here:
Hi all,
Following the finality discussion in the last workshop, we built a small web app for
finalizers and delegators on the feature net: https://crosslink.staker.space/
What it does
It answers three questions from one independent node’s view of the chain:
- Is it running — PoW tip, block rate, finalized height, and how far finality is
behind the tip. - Can finality be stopped — finality needs ⅔ of bonded stake to sign, so anyone holding more than ⅓ (one operator, or a few together) can withhold and stall it. The page shows how many operators it would currently take to reach that third.
- Where should stake go — every finalizer in one table, plus one rotating suggestion from outside the largest.
Why we made it
Finality is only as predictable as the stake behind it. Some operators will always have more capital, and nobody can or should stop them from bonding to their own finalizer. What we can do is make it visible when the stalling third gets small, and give delegators a practical alternative to “go with the biggest one.”
How finalizers are scored
One measurement for now: whether a finalizer’s signature appears in each block’s commit
certificate, over the last 10,000 finalized blocks the same window for everyone, read from the chain, shrunk toward the network mean so a short record can’t flatter or punish it. The suggestion pool requires B− or better, a recent vote (liveness), and that the finalizer isn’t one of the largest. The pick is random on every load; nobody pays for placement.
Limits, so nobody reads more into it than is there
- One node’s view, forward from the day it started watching. Not an index, not a complete
record. - Votes can only be read at the tip, so while the node is catching up the rates are partial
and the page says so. - Individual bonds are never listed.
- 10,000 blocks is a short window. We’d like to widen it and add more signals to the grade
over time.
This is a first version. There’s a plain-words view at /public if the main page is too dense.
We aim to keep maintaining it throughout the featurenets and evolve it towards mainnet and eventually even make it open.
We are still learning about crosslink and its codebase every day, data may be faulty or how we use the data. Feedback very welcome here by DM, or on Signal.
Hey everyone, I’ll probably try and do a proper write up about the functionality of this tool that I built to help with analytics on Crosslink, but I built an indexer that really targets exposing some of the staking and finalizer data, that are really outside of the reach of the RPC commands.
This indexer runs alongside your existing crosslink node and generates a SQLite db for you to query against.
| Domain | Where the node keeps it | Reachable by RPC? |
|---|---|---|
| Finalizer behaviour | the pos.chain file on disk | tip only — no history |
| Mining / PoW blocks | block store, via getblock | yes |
| Staking actions | inside raw transaction bodies | no — not exposed at all |
I’ll be doing some other looks and slices of the testnet to see other interesting datapoints, as well as modeling some of the behaviors from how the rewards splits between PoS/PoW can potentially lead to. Looking into how does this drive behaviors from miners and/or users that aren’t running their own node and how they can make educated assumptions as to whom will they choose to stake to.
This is the Github Repo for it: GitHub - JustShieldMe/crosslink_indexer: Builds a queryable SQLite database of a Crosslink node's history, for analytics on mining, staking actions, and finalizer behavior. · GitHub
There is a section in the docs in the repo for how the schema for the db is setup, how to use the cli of the indexer, and some example queries. In the CLI you can run the stats command and it gives you a current state of the chain like below:
crosslink-indexer --db ~/crosslink-indexer/crosslink.sqlite stats
== BFT / finalizer ==
bft blocks : 101605
height range : 0 .. 101604
signatures : 1396427
roster rows : 3974550
distinct signers : 33
roster size : 1 .. 48
mean signed power: 79.9% (BFT needs > 66.7%)
== PoW / mining ==
blocks : 521341
height range : 0 .. 521340
distinct miners : 73
== Staking ==
actions : 8890
CreateNewDelegationBond 8593 2191625.1925 ctaz
RetargetDelegationBond 281 0.0000 ctaz
BeginDelegationUnbonding 10 0.0000 ctaz
WithdrawDelegationBond 6 824.1882 ctaz
NOTE: 30 actions outside the staking window that consensus does not exempt
height 352270 (offset 70) f2cff876f7fe54223213c2db06b0a4416e065cc21ada67099e294a14b7857ebd
height 352570 (offset 70) eee37331b82e63f04ee8de8441a04f2e5b0f6fe3d89c05f4b45f2401406ccd81
height 354671 (offset 71) 2748dc9cdd2d2d5748ffd1c1c6a1b635adcda13db9af62e68cc3d10110ee01ca
height 364277 (offset 77) d901fe57ce29517d2c90f7333eea48d9d0f882010de7407134b57fef40ff0fff
height 365170 (offset 70) c280c903faa65d605732fa1cc5fd4d211a37c845a28f0ec1409c0ac951338d46
Please, play around with it, make issues/PRs if it’s something that will help you out, but a couple of things that I’ve already played around with are:
Finality Stalls
Looking at how many rounds it took to get enough voting power signed to continue finality. In the plot below you can see in the earlier days there were more issues as some of the bugs and issues were being resolved, but in the last ~50,000 BFT blocks things have been much more tame and finality maintained without additional rounds of signers. A (maybe obvious) and interesting fact is that times when there were 10+ rounds needed to reach quorums it was usually tied to some of the larger weighted finalizers needing to restart and make themselves available.
The associated SQL queries to do this from the indexer db is:
Finding the stalls
Everything needing 10+ rounds, joined to the PoW block it finalized for a calendar date.
SELECT b.bft_height, b.cert_round, b.roster_size,
ROUND(b.roster_power/1e8,2) AS roster_ctaz, b.signer_count,
ROUND(100.0*b.signer_power/b.roster_power,2) AS pct,
datetime(p.time,'unixepoch') AS approx_time
FROM bft_block b
LEFT JOIN pow_block p ON p.hash = b.candidate_hash
WHERE b.cert_round >= 10
ORDER BY b.bft_height;
Ruling out roster churn
Run once per flagged height, against the height before and after. Both returned zero rows for all 5.
SELECT 'joined' AS chg, hex(pub_key) FROM roster_entry
WHERE bft_height = :h
AND pub_key NOT IN (SELECT pub_key FROM roster_entry WHERE bft_height = :h - 1)
UNION ALL
SELECT 'left', hex(pub_key) FROM roster_entry
WHERE bft_height = :h - 1
AND pub_key NOT IN (SELECT pub_key FROM roster_entry WHERE bft_height = :h);
Staking Flows
Right now through RPC commands, you can only generate the current tip through get_tfl_fat_pointer_to_bft_chain_tip, now you can query the flows through time as in the plot below. A couple of interesting facts is that only 10 times has anyone used the BeginDelegationUnbonding command to start the unstaking from a finalizer, and then only 6 times has stake actually been withdrawn using WithdrawDelegationBond. Probably another place that should have a few more tests done in this phase of the testnet.
SQL code to get the results:
Daily cumulative stake bonded
Calendar-filled the same way as the mining chart, so the line stays continuous through days with no new bond.
WITH RECURSIVE
bounds AS (
SELECT date(MIN(p.time),'unixepoch') d0, date(MAX(p.time),'unixepoch') d1
FROM staking_action s JOIN pow_block p ON p.height = s.height
),
calendar(day) AS (
SELECT d0 FROM bounds
UNION ALL
SELECT date(day, '+1 day') FROM calendar, bounds WHERE day < d1
),
created AS (
SELECT date(p.time,'unixepoch') AS day, SUM(s.amount_zats) AS z
FROM staking_action s JOIN pow_block p ON p.height = s.height
WHERE s.kind_name = 'CreateNewDelegationBond'
GROUP BY day
)
SELECT c.day,
SUM(COALESCE(cr.z, 0)) OVER (ORDER BY c.day) / 1e8 AS cumulative_ctaz
FROM calendar c LEFT JOIN created cr ON cr.day = c.day
ORDER BY c.day;
Mining Behavior/Concentration
The last thing wanted to share since it is relatively proportional to the incentives for the testnet is the mining concentration from the top 10 miners since genesis of the testnet. So far a total of 2,579,050 ctaz has been mined and the top 10 miners claim about ~81% of that amount. Interesting things to point out on the plots is some miners who have either pointed GPUs/ASICs to the testnet in mid-August, based on the rate of change of mining rewards for a couple of the top miners (notably the gold, teal, and green colored lines that prior to August had almost no mining rewards associated with those addresses.
SQL Code to get the results:
WITH RECURSIVE
top_miners AS (
SELECT miner_address, SUM(subsidy_zats) AS total_zats
FROM pow_block
WHERE miner_address IS NOT NULL AND height > 0
GROUP BY miner_address
ORDER BY total_zats DESC
LIMIT 10
),
bounds AS (
SELECT date(MIN(time),'unixepoch') AS d0, date(MAX(time),'unixepoch') AS d1
FROM pow_block WHERE height > 0
),
calendar(day) AS (
SELECT d0 FROM bounds
UNION ALL
SELECT date(day, '+1 day') FROM calendar, bounds WHERE day < d1
),
daily AS (
SELECT date(time,'unixepoch') AS day, miner_address, SUM(subsidy_zats) AS day_zats
FROM pow_block
WHERE height > 0 AND miner_address IN (SELECT miner_address FROM top_miners)
GROUP BY day, miner_address
),
grid AS (
SELECT c.day, m.miner_address
FROM calendar c CROSS JOIN top_miners m
),
filled AS (
SELECT g.day, g.miner_address, COALESCE(d.day_zats, 0) AS day_zats
FROM grid g LEFT JOIN daily d ON d.day = g.day AND d.miner_address = g.miner_address
)
SELECT day, miner_address,
ROUND(SUM(day_zats) OVER (PARTITION BY miner_address ORDER BY day) / 1e8, 4)
AS cumulative_ctaz
FROM filled
ORDER BY miner_address, day;
Outstanding graphs! I love the rewards over time ![]()
Hello everyone,
Just wanted to share this work on Crosslink with Nozywallet feeback is a must here.
Operator notes from wiring Nozy (CLI / desktop / companion API / extension / mobile) as a Protocol Guardian against a live Season 1 monolith.
Same box, JSON-RPC 127.0.0.1:18232. Happy to follow up with more logs if needed.
Box snapshot (2026-09-07 UTC)
20:29:41Z getblockcount 529538
is_tfl_activated true
get_tfl_final_block_height 529534 (TFL lag 4)
wallet_staking_positions 102 active / 2 withdrawable
Staking Day OPEN (offset 38 / cycle 150, ~32 blocks left)
20:34:49Z getblockcount 529581
withdrawable 1 (ee41… gone)
20:36+ getblockcount 529624
get_tfl_final_block_height 529620 (TFL lag 4)
get_tfl_recency_status 39 finalizers, my_height 104763
get_wallet_sync_status error -32601 Method not found
Live WithdrawDelegationBond (the lifecycle most wallets never hit) ![]()
Smallest finished unbond, positions pk as-is:
pk ee415235b77c10d1b2b9e6f0f0bba1d38542a3f8ee41d414d5b27aeaf2122fdf
create_height 413593
create_txid eaa8f2fb2d1a6d7985c1d92e3151056f86ae3023d1ccce11ff891215dad50da9
initial_val 200000000 (2.00000000 cTAZ) bonded principal
latest_val 202456965 (2.02456965 cTAZ)
earned proxy 2456965 (0.02456965 cTAZ) latest − initial
wallet_staking_action WithdrawDelegationBond submitted ~20:29–20:31 UTC while the window was open (PoW ~529542). After settle:
-
withdrawable count 2 → 1
-
ee41…no longer inwallet_staking_positions.withdrawable -
remaining withdrawable left in place:
916ff0a0eaa51ecda39405d579cba04d907e10df395ec2d175212bec00c63288(~7.08460060 cTAZ)
Retry of the same pk at height 529561 (already gone):
RPC error -25
Faucet request for "WithdrawDelegationBond { bond_key: Pub{2fdf} }" failed: failed to build staking action
(Pub{2fdf} is the last two bytes of that pk. The “Faucet request” wrapper on a withdraw is itself a footgun in the error string.)
getbondinfo on the withdrawn key after the action (byte-reversed form — see XL-UP-2):
{"amount": 202456965, "status": 2, "last_action_height": 529556}
Status 2 / last_action_height 529556 is the on-chain stamp that the withdraw landed. Remaining withdrawable still status: 1, last_action_height: 413760 (create).
XL-UP-1 — Filter Finalizer crashes the monolith GUI. Typing in that filter/search can hang or kill the desktop node UI. Workaround: do not use the control; browse roster from Nozy or get_tfl_roster_*. Ask: confirm known issue, or disable the control until it is stable.
XL-UP-2 — Bond pk vs byte-reversed getbondinfo. Write path wants the pk from wallet_staking_positions as-is (the withdraw above used that hex unchanged). getbondinfo wants it byte-reversed. Same remaining withdrawable, same node, two minutes ago:
positions pk (as-is)
916ff0a0eaa51ecda39405d579cba04d907e10df395ec2d175212bec00c63288
getbondinfo → null
byte-reversed
8832c600ec2b2175d1c25e39df107e904da0cb79d50594a3cd1ea5eaa0f06f91
getbondinfo → {"amount": 708460060, "status": 1, "last_action_height": 413760}
Same split on the withdrawn ee41… key: as-is → null; reversed → {amount, status, last_action_height}. Ask: document the endian rule next to both RPCs, accept both forms in getbondinfo, or return a dedicated bond_key that matches the write path.
XL-UP-3 — No first-class earned/rewards field. Season ZEC is pro rata on earned cTAZ, not faucet/bonded principal. That 2.02 cTAZ bond’s JSON has initial_val / latest_val only — no earned / rewards / earn_blocks. Companions invent:
earned ≈ max(0, latest_val − initial_val)
= 202456965 − 200000000
= 2456965 zat = 0.02456965 cTAZ
Bonded principal on that note was 2.00 cTAZ; earned was 0.0246 cTAZ. Easy to paste the wrong number into a payout form. Ask: publish that formula as canonical, or add earned_zat / rewards_zat.
XL-UP-4 — Headless spendable balance. Guardians need unstaked cTAZ in the node wallet without opening the monolith GUI. This Season 1 build still does not expose it:
get_wallet_sync_status → {"code": -32601, "message": "Method not found"}
crosslink_monolith PR #51 is the right shape (user_shielded_spendable_zats, user_unshielded_zats, staked_zats, withdrawable_zats, sync_height, tip_height). Ask: merge to s1_dev / tagged builds so companions can show “available to stake” without a GUI.
Bonus (already mitigated on our side): concurrent wallet_staking_action → "Another stake in progress" if GUI and a companion write at once. We serialize writes. A retry-after hint on the RPC would help. The -25 “Faucet request for WithdrawDelegationBond” string above is the same family of opaque errors.
Nozy does not mine, claim faucet, or clone the indexer.
1. Withdrew the leftover finished unbond (916ff0a0…, ~7.08 cTAZ)
pk 916ff0a0eaa51ecda39405d579cba04d907e10df395ec2d175212bec00c63288
window OPEN at height 530711 (offset 11)
txid 1126eae522a09c9c41446a9469fe6227658adbbb41c1f8d368b391d1969d7898
amount 708460060 zat
getbondinfo: {"amount": 708460060, "status": 2, "last_action_height": 530722}
tx finality: Finalized
withdrawable 1 → 0
2. Began unbonding the smallest active bond only (58d15cfc…, ~1.14 cTAZ)
pk 58d15cfcd8b15eb04cf2131eccb718c667213383699ce2d1cf88d8bffaf3ddc2
create_height 421231
create_txid 06312a244130973bceea15e6684a2a84a68b8ddef44f2d579912c0ccbd44feb0
initial_val 100000000 (1.00000000 cTAZ principal)
latest_val 114333514 (1.14333514 cTAZ)
earned proxy 0.14333514 cTAZ (latest − initial)
window OPEN at height 530747 (offset 47)
txid ac22ad3ee10c7382fc4958206daecad3ab387c9d412060702544baedf53d5134
getbondinfo: {"amount": 114333514, "status": 1, "last_action_height": 530762}
tx finality: Finalized
active 103 → 102
listed withdrawable by height 530767
Window closed before a same-cycle withdraw (tip 530788). Waited for the next Staking Day (~530850).
3. Withdrew that same 58d15cfc… bond
window OPEN at height 530876 (offset 26)
txid 84d63d6a256efadd9e3ac345c06205f1aec3a5becec559a84f4b6f83e08a067e
amount 114333514 zat
getbondinfo: {"amount": 114333514, "status": 2, "last_action_height": 530883}
tx finality: Finalized
withdrawable 1 → 0
Full cycle on one pk: unbond 530762 → withdrawable 530767 → withdraw 530883. Writes used the positions pk as-is. Nozy companion only — node wallet signed.


