Q2>3wgb_Ho(HhMyD2=rOTeOBw%v95+?{2W^b3uFhWJMP^KRD4n!Mbu#`Y%gX75^(sbv8dO$DdY3
zp
z=kaF~J;<2^x
zw|}JP?HlIi=FZOC@ZE+t{8WPmV%jOWn~fA
z41CWB*tr}mpdtIH-UeBOH0J9Uqx2p@cMi<8%|%6_Q`Wquyy@L;#HuPI3GLu?%xIyY
zis?0p-HqPR()BlZUJ!WWUfkatg|EXo1HbQ``(%y@a(@{WD}Wf^RL{D4zil7W3t?X}
zH2(!yuB%bmg*~`N*HYUKQ>3Z8`*|9V88+JEyhj$mtMKANiuc89nK2%l-WE|OUJ
za%HB_!zvl@0B@rL(iUH?(e=*esf=9hy#2cS;AP;%SQ@7rm9+%1AW4B5=-9>$3bXMg
zKDt8+!T_10aC1>uz{G{8H?_5j+ke`sQcTC2VILjAefLLiSvoNjx8CRFx=SOGbERu)
zuK&vjccOwY1_N$d6cYCmP1fM9YR{vU>KEIKs)T*vR*Ws;5`g+@hW#pve_=&u&Yc+g
zjHj?~QQudU;Ki{99!iW{t=F+BZNbnd{R%3I)|302m~yNHHqeXMyf&3u@|p>aek4UK
zH_XlT|BLyj=$WOZm%_r|e(v%=$4NlmqIFog9UL44c>O533}n3`_?_5%`mM|uz!zjJ
zby&u3qnWtcvjn1FGX4Qc#YC3XfZIR&^3H}j#)7xzGby*4Gg{F}?6cX((pp?x
z^z~$;s|5!2wpyfAk|U}Q7p``Jy>QIt1(W2TFK|NGqca%doT6B;$7NeD__|b2^-^hc
z0Ao3`FanQEkWwX8VIIX$Tq`T{sbFUP{_CEB=Uflna7>Zo>#&u8BaIoYB`PW6Z-90I3p35|hF%;-gHA5=o?g6s
zD$R9_VxXo{N@YL$u3V`}teg``e`=G~lF8Nla{1c-Y(hSAmGmnWb+*$(FZ{Zrno~v)
zjT6c-=$+I^VE2vey%
z8hLp$vVuUfwB`3q;xMpx(?e%5EVJ=5V~Oc_4A`!&C&Q%JVIbLqJqs?lp+(@gqd~Cp`t1r@
zE;Aw(j0ioRM>@UD3{pyjmoC^uzgiSM$ih0l3NV-K;)m9CzH
z!Fu~-wA$;8=V>|SO>`>si$>j{&j`Vc6NI3jJZTT;2A$NmJl@m`}KW{
z35c-l98ziXPJlqm%h@IIK1DN9#&Nt$$P6xXI8t!CDIZgr(YnQvBBmdRCk_P6M4`5)
zUd7DRlxAr5DJ2=1-g$BMu;_lmtlVjcprMcgqj^n*HXLR9!?N=VH&@P;7AK5G_(?S4
zB4OdfJ43^q)KqzM=3P8-n>QIEaqdJYtr@u)*+Z)oRk4RrGr`|4zTYq0iGAcC;x6nH
zYOhJMPDhp+RGdl?igRGK(c5o5X8OwQrgTr`{k?z7woCPsQk8Vxtzca7
zw<~#{elG=<-(NbEm!Z#)G%#(NI(8U#2Ue#zd4Pnv_+$UkHQWhfZ6T+dj#b
zSCKN8)1Y@ceOLv`1;R1c82apS{!M9HZQy7c&k!#D&UlW9E2DsbmiO3v(5(@S^Wz5-
zahc{c=0u(v8hM>}8P2VT5eXe6=htEMFnwYOn{40Kfly$eg6)J6IuKQ6-;0yBc!fJJ
zw+GGYziwmE{*}*pM|X;iWd-nnj90~1hwYQ$RFc^0kH24R7a2`0L(Gf%d@Vo@n3+5<
zYLQt1l%JW_0^vS!&`%95&yyz|))W#)9Gh@|Ew>qw1^b55Qr72me45R7}}~=>!`l`YKvOdWZU`6jsZ9dTIG>p^QPo`I-|%}L{i%LAj6emX0Z1ds?ujuvP%MW
zt+Z4WP`tx?P8Mo}B<#9hsYuHij-)k4OgXo0k94n^eym~>I7QsBe@)5BZf8M@wu
zoL9$J-UU>(n0vFsZgW>z9HLU8
zlwr-w2&+^GWiVjZ+J8}VpWIV$jc-`CyJDBG{Yv?q9fPN6!yN1Ca_hKswvT*B{e=_-=rpnaUz49M{Zj&@ZI9h540d+
zA05gApzkRk+tu>9pHmG52+mcSK~Z0ej1TWmhJ?HZ`q?WIDZxUpy{iHp
z0mJB!ih3c$_6r`RPJ8F(G>dVmQqTP5T%$2?901Ye+=Dh<)>RP&kH3hjxKH#$+x-#
zolcYpR=LPr_GVcfbBOt_i%XrMj!X$aaerOH@D196Agc7*@5(Nu4fc8?uWed$Ps{cpVORSELf@O
zS${iM!gCw#?J|$_^&h0caD$lJuq+;;8z;=5zxKXNT|2`LL=WZ%Vh5`|Z|1}8xNa&p
ztp>(&7Bf(rPHfAi)MpOCLEfc&G<9B*IXT$1sH=!52xV0xoUJk%hhu(jdc6=GeFUrA
zI8=bD{*$Oa-zuR4#qE{T)3^R_b>d{C8%pn|V#9ud6!rGao0x7+=05mu~G&AVG-DFvKUNTu_Q1BhP4I%S&ja41~pCN~L_*
zhlOJfSfUHf6DjuMYT(DFCG2
zUix~Wd&Bg6qw?ihI=;&EiO?pS6R
zzTI+QfuFxxI_$ijJf$#(K6)KR!Ud*U0krc-iRb;eVCWu7BOoy`B*Z6AU~ClRSKrm+
zFZHM375^c4>iB9fR==;3pwiWFM(bOpW2MC{e5mzrJfS`@KeK(k{fRWLk^@3VxIF$`
zdN4M_ty1&ZcNn%FSWf{&Kfl<)!2!riEWEsvX9ZSzI>!PglWbtbj5Uh*?=B1zuF2~^
zM;a2E-^gbeaHe+0rV99ZQUNH$ND&)$jP+BasZ}TJ`~NXSPm%QFw_4cUkM3FV{5y2uCU_IQQJ;a3
zk6rPptlep6h5Lj23}91|A(9|JKQoK@G=ZryW1`!kL~eHZ?P=p}H?2A9cIS*x+>Ea3
zsz7pUJLG{PW>)Uq%=j3T=U^r;twEAO%E{-`rLva;zl0uNph{M3?zh=xPYx6L+V#nm
z`PIo|*`BU(d+LkN*ZXQKDJX4*geKtBN(NO!WpL*32;?`K0HiOo
zLUlc?`_-7O+O?OsN`p^1HG-v$V*afDGCky}QvR;sOXHfS4eTi{(8iZ9hI1S4b0w$@
z^{6{VOcd=#N~hsuKMyFQXK7CKV}{ZO8^Ydx_8+|Vebg{xxUgM@sWQ$-F8<{jy}1by
zcVVEMWJuC7nPzRs^9Ivu{&>OKko*8o&fhAoyMf-|MwRa4p}g)nra3VzIN0bSLI+?*
zV3?3@+SzIUvAFGUceOOgq%HY*fp`*c9LLe?q~^&%?ET)ghERv8qDr)0l1~Tc?#jS#
zrcssOy3-y^iJMG7u6-JA=H$f9tF99GCQL{qL8l~Reg;pw?NLcc`Pvzw4ux8YrmK}x
ztG4SYOJcWP+|EUPAZ;Xxe}i`Fj%D+$p{;IP?zD1K@mXKpB+1XxJyn7F3zca(^2rT`
z0DI_3FwYUSN6bZ8tD&Qjk&%?tqwIXrG+qnL#Yb0&RStVQbe`8C`wL^?^zP}A22Rge
zxnkwMx0+_?G1h+m&_c|as`MI?i6;@^fV^Hifk1n0U*;etxYs!Cba3PDGUwlc#=dqZ
zJ2_7)D`Q|lvG|C%Xl1#nhCe>O}XztqKrC~1@4TcpYiT88Mw
zg%Pr}Hl;>=cqk_%hV32m*khrGBWxTY5%G7sxQQLjB9vjpv5`NHB@aGhvN=;oU1T)f
z=u&Z8D@4hwro^xu6R;W?4#F|hU#mtWF7UFjV0n3_cS~vWC2YySto<>2ifd^j;?Dzn
ziGJ;XcAd$@C7YMUy5frG)^)8u!|7e?7E^M3+XYK%YETCT9ZAk=RUT7l_C39lsEF$$iA>O)%%dkFl3q_l<2t==>Xu5GhRE-I`;^}*qD(cG6(R+U2x7Tn
zeAqV|$O>uv;(nD1w6whH`m1~8Z(sb~uRP;3>$#|EsB1q3O)r>rCmfRuJr|Mmv6{zT
z$|C@C^Sl}RHJkZeMgMXU*&cJN5cT(h)u$~)%QSZPd`vxwzyAG!^ymmxD-{c9XkqAj
z0~yUG8=5@3GmqN(ZH{&%q)ff!_VQC_w((KFwRctYb?ybPo{c{*=a;b65druoQ
z1ZyEM9z8Gf>^<3Pen~nYY29CYj$
zCj?F{uS_818#(M%(}P>RCO-6(V94RmsrbJvCNN#^KH>12%X6%W3J}h4;&FwzG1}?l
z%aJgyL`+G8fAns#-&=QLYoJdCOt~M9srTY1xp5P-J>%l+ED6ktL0LWgfU_!dh{l{+
z>0xHYWvS0U#(V3G`(a
z4&?Va$IwqVjoV-MLl8Rg1sb-iUzoX2tE_|Rg`Ygi>}NfyNOd|fTR=1_E8$PDT`|sw
zvy%`^1Fk|=aACk;937At@0`8mjg8Xce?+3Ojy8G=bv=T6cl&`XlBa$1_G~I3@
z*3^?vdM7_V4lELC;f=9#s6BDcmdxU^&t+I9}pWj{j;6!&Fp>q?faIS6{E
zIi0KD@|Om|%De#m{E_eToigeh_m7oJ@QWTt&&JQ%gA4Y81rrK3PJnev=E`$$CsE89
zS{ePX)!$NFCjY8lGINiBu%Dh>ZafZH-I91F_ZkDFv(mCMmY7jH0a;;PGn8%y^E;uZ
z?neT^6ZsvE$12kZ_8}oj>pw#g2Rm>wVg3G65{)f;ek`k2URs*Z?*;syZf5ed-7xW1
z@hA{gZ>eYw!e&%DAR98RMj7-ou|D8oBGHsPSW{Dz
zdAB_sIC8?m&^CJH`wH_KJr4Ar
zSG87KvLT_*zw)ZW=D3o#>nxV!8?X~dIcH&u(fG9@ts8IVATmkvi-uz0{Bq=6V#`6z
zmhftvurD%bBGMfCfgKQk6xSNR|50K7r=%sByHmFHvj_&>DJX_YocC!LP#+N_Q7cyZ
zyt0ODpT^G&ul?Uxxi7QKHji}0rR3(S<};T#H5&lS62Dx)d8!Y+JrZ{!PGnJQK~vBu
zG16TY8JQBykn6!8{gB{}F_CAYj$Vz}0i{dC*x-`0M3l}e$I;GCV=^>?II0Lp+gH_L
z-p4V428w=jPIz*eVW*rm9OgFUI*zg5#uq;Y(W(qwQb@rX~q`%&x4i=a2ffD1k_e
z*P2}X?VG}X!MV4$_bG13P9T|!gI?S|4ZURy-Q0$8$;^GCI!C1Tf91nuXlEk=kpdU$1QWV;S0u@j$J;i5i;
zC`Fqrw=+sf*sWRrK*Js#Rk#FJLOVX)zsPp)C-%jrQ^+k
zWVZp};we6EkD9fOofZG6RnzrKH6<-ZB3;&&SrSiCN$Jj}1t<>@_R_HWi%M2|Mc}=y
z4+wO>V^L(A1Ds_kJh4Cb@Kop%3xmCyTEgdlM$YM(8GPzQK(hlj9A8i&ZRj&_`(hf|
zxo6W=<11BYJI+<-efiS)px^(#QK#)Vd@u09XhYWY$nR5{Sa#-TZb-oAj9FXY>+>;?
z=`K9RaJB`8dQdS*2+qek@>kfT4Oip6CV<|azg-%b8oow>mzDGVFzPA(@_~spe+IP0
z!@|;1-Cyw&=$-+k3{yhlo7`Xv5Hm{UPgqM^$ABoM?OZTeOn=S@bf)mUy*eh}mcj^n
zd;V}=2`|<6U|OJ8h7-4_&vEIftE<0#+u48y17@vH53#?;BkbnCejA5`{otgDj*1vI
zlHT8b`yb~LxcRWgPdDkMk^?6!qe&z^&p8u;a3nt12vOd_M+AK^VG4{{zslho~&
zHS|$5Fz=lMhHu)a7>cLzz!edg{h%Wp%a=K3V`Br1Eyb$27hdLqWfoEe6ZQ*rC8ec`
zCS|6%ekVh8^09anZz+k6R|5sNja)h?$s))|$*f6!m>%sL#RJX6A5qb`GW`8}`xc3;
zTGF5*SA>RP>Z>Ak5Leem@%U3${px^qnCI!C5HGi$7?1Xk-I+KB{-hw9rIRR%0j>W$
zxFeMx@f4ezn^8M%C3JNpbpda390F}nhzenNdhjTcxOgRSNOWptUIGZTe+s-`=Irg+
zsu30|r{j~5c%Dj}2;oujaZZgZs+Ua{OSnIM`V{R!ra3A)+HWI*iItVLa?E6a*_LvW
zt*TXBQ&YxcG`j9eg1E?)VBYbM25BAEw*qH!Z!@!kF5{-367`D*c7eBbG7a3^g{>N<
zcc7j`bhM8^c`EiSvQ%E7E}%}O>nOi_?W42t^-X5j-(sIOa13jY+r+L#_y)ywfOSQ}NW~E4DLI7zqE=JZ)-4FSYW6wU1zwWzxGT^4!WW2vJm;0-vdR^6{tKWrn=HZOH&r3``VaxCg=1Q0?a
zXNn2SCt@f?!}bTA9{a!`vVPFi2UBO$W`|sO2oCb7x%3!t7mj2OfK)bk$g}0$URc;`%pLvG`sE?kQQImrdeF43
z7_euco9U~!Uy7Nje2>FH3zm7xO7|UcuFsxQ@viYRxbVrCQ@ok
zoVNc%%{^h*QYq<^4g*+$H_u`c!i{7_U#|Ze5X`LbS>znLaKJ&6R#Q{!?2v2*^#qHU
zoiqUXH4;8!jD%rhP*ijHel<6C2s>f29eYl{{s3lBA#BrCKfE|**x6Is&e@y7!v
zT$*Hs{byD#at|&Z+?ox#i>)G3MARt;#4ooi3Nh3!~$KVXmR!La5{K
z8yy{z`GoE7(G^$cw`WPhu%7Nec(DCvt&P8l%^P206st_l9NJ#5^`rz-H;GHg+s=tL
zr<9^e%gM4JzBgm#cq1^TqkOFXsjHCI%ad
z&*js$f_#+RQH7-)7xgwS4p0=%m&;}8q-9hjU41LLcoHlSaR>w=$;Tte%lla|0iQ9_
z6xg7k&}HAFHu+m~YIn_EnC!x;V}=kzz@zyDlMp-@KCSQ*;$Qm=F0yk%$1j1^-o@3`
z2Ky;8fh-RjTN&_^^sn7#;sKEtyh#@_hK}EDSHM=1{S$&7G;d$|?b{cAag;A0k^!Vr
z@4mjNR?z+Jlolo4CQ<_k9Ryvkq*hQ=D7k)K^rl2y
zwrbYO+&pc}(qK>_g$5l&H!$v$ltk?yC4h(D=aMhL2rNm
z9128B1>K=;Wo<#UCmnvh>#ZssZP$A#*8Q6ViNUkPX1!
zd@EjO?H|cNFR}D09@x>%?G6Mg7fCk4i3tgll9EEM^6;6^rGTOZqW4n^o3Of)93ITY
z-Cw*#)yYTd@7tVhaJ*l?#t@AD>xjk4!BN!4ipV^f-D&t$yz!g#-zA=ReU++$$@9`b
z^4rqo3cUAg2EM9fm(C)H{5ocYBJ$^4vd1h%{jRvD#ZXuI|9-?6m*C?Qb!DxZ4moMY
zqYo*^53&C^>p+H$Ry*J7vZYw0;wYbRL
zvmg}fM7+_+W9^pT7-*7jqYOJa{8cpZB
zHv%?GWxzl>Iv%AXtna@+5)$M2T`Zw2NJilPnm}*%Q7?^oGFPu??3jdS``fmy
z+!fugdoInoZP&O|Bm~L11&d7{#M&LKxKe`bKuFXf;(uM?ixdE0Z;KL6t6+vZI3Ani&-S0|DU)0$>
zF3u%uad}Pq;_-+0P*MD>oP|3pitbS0FZ2`Q4Z*L9$w_x}1a@=k+n!%_dWpKJkJW*J
zsin+ezr^lkaPvVn>F+*AT~RjIgcNrO4Y=t`T9OjmUXeUtdwB`$Gg2-(Dz(4H`Ybs}
z(SU1gz}I5u)#7#qbuMTDkM`S1wXyJl{Z2U|mz(cDHo9;uqcE3jq)(=X6SMFs1WVHS
z99n5344s`T*zarbftEu!)|;8AyZ`EP>!a(@^i2341IlSmALS=PsS1cR=|x&ls?r=l0s@K}Y0@LT
zs7Nm&C?!hAL@6;q@@3}UHFxe^-;Zycb-!P;*V^aLIqzEM*?T{GKl`OlQ9l5;0VYO9
z2qOa%1Oj1ZW@2IEVrOGzWjl5l2IUgq6@&}$^79Lc$w~-`NQ?6GBTh<6%PS}=D+@}f
zX{st}$SNr-f|!|^*;v_*va=sm6y_IJ{L@Bl12~yL>7aBlNCcqc1c5n0)GiPP06+|&
z-wgPtf#|^W42%#aW)@ZsfDQx()6s(&80hI~Uq;a00rZ>#DD&>w7jxP{PuloduNyQgA4+||A0e#{sYh-@Nm-b
z(9zR_=^?-30nr819L!12AgsW6SjPh5d;=z;7{kP+n^9cV&Mc~AN#MTXKg_}-raXU~
z_$#E}5dGf(#r_nc-vRxOhdK?gfkCu{fjI$ffXIu$G@a$L&;$PVzhm(KUjt@nNs0Sc
zl6j5zqbs5_jg|ratkdk6R_%^Yj3*qduD+|29DSAw%?6a@w|e!+zZ7|G53sgr@ev66
zHKQ)KCQH9V6~3=BFGogOy%=$ShjNPDH+cG_H8mrESTCY)`oy3@0!Vul$P>k&VdDqN
zd-|+q+CEv7@`DkF##G*i-Xm1tz+SFk**cH%2E)8pg@~Lvl`@cmx9y!UNb639^%Jt2=sDm2PCZ-@fHrWNBcv&DfIGtxZFx6!9)ac;?ogrl^z`k;0g?vp!H78plJ81K5SQaYKlPBFm@5&klIUKG8
zWLe`BncYM+6!r<&)kWv-~)M8Rljp@+&f|j<(=4$W8p-8ovo;N(rXfPB7Cgm!XHEFP6Ho8Z%ub$2Ts%U7Q3LjWm3*)t73k1jjP9Lxx5?ABxwsG`?s`
zT^+e4RH$@6Q$;9lT`$Zjnj-gT&7dqKBU7@URbUBpfvma=eRbxOz`hC^cDW#}7DP|S
zB(x=hCPL$#I6q1{6EJ+Rt=WeYtcVLh(%pr7%Jg`7hVx*`!(qkz_>YOtihS(j|>
zbkSAsL#+?y%#v7FQTUXChL^W9X7=Ak$&?V4C+6FkPU!5
zq$|aw|5_VBzZd~|=1eT<@gi)#06?LP>n&OtT!g;!2Dt5L%!M5wzbqJuWo3ruFYgB~
zz!sd2IDUf2&{d&74e6Ig8%$=ACQsVGJQEu46XMj7Uz8H4;9joRG51C(u39K8>&IN6
z>XT6Mv|#KS&-B&Igu);e=H-$`tu}w})igGe&zp{E=~LG;2_=e#`fO=2+1i%^*5wg~
zhZ}R|%@gw_;zbM+IB$jnv%!k7@4watSZpoLOxo*ldmedVp~uyR%p$sX&ceDgG_pEm
zz9#+fSvb$9S%2t`R?8lG4N*GY6`r$@#^M!ucx<4s$)4rYj-*i%0OwW
zraluXS4M`Aec@rmfR$ZEPe*~N@A~Hkf~KTO{d7}rr{amrP}FLqWC+>`8lvL%(m&_q
zHJOFWes)5c2E_n8qC33bN?e@GdnzsIL&t149kg*lzmxz6Mla|Bw6{k^hqzeN?04)DY`Ju-U^rT=@Ahc=j@#*chjTiqpig87a)3aCezXwZv`%1Jg5$@&9|?T<+<}wIOc{jn@~gB
zUSH&j)*Nw5Yn+C6QB%V!l70NyIecABb=uB79>EH36JP7_JUuR3S3&D=_3?`r2(o#8
z>!`CS{r+Y~h@kIZqmdO@S<6hf45G2u!umu3&T?WniLaH+;Ejd-^@){@V6uDgc&Y
zb3EMID9RXC4ccaT7^UbvhBVB6DYcqQ>1!D@7cn_d$tQimH4!!2>Ih|6HC0J>NkNBl
z!RJUkDi0=vK}-GAF2}v2l1BxEDz(XTMPw#ys`vVJC5n!1)ONe}7N^}|3xjB?Ebx70PYnzOyG%S6}jd$YP*H8DH_$V0D
zo*x8z2imL9TGqmUr!|9RoH$V+C%)Fc4mmL&k}B^vq5xshYFL}P>4aR%ANcs{>7~`+
zXHA|{RbWhNTPUE)t$5|yAO^j>&pyAn;KcL(f+9~n`X2>(>{qXcOM@&@Z@F%t?8zHc2uwu;Txj`GJ}E3;`E>Ph`VJ`eNuyz1_?^+|4t#C
zCXEjX$yC7MD#h5b(@~T7m7<|+PF|DyhVti|BC;SbH9~uepD3CbD&Q9-Y-t@t1&A9*
z_Tql9WY#HbcrPxI3&00!X`cl`9)&se4sM@<>`e;3K1Dn?uz7}MPZuYLb!a4guV~7+
zjzRUz-pNvmApzP2s#WDKGiVPF(`9jvJ@U9_XD{bKm6RS*C5Lk@QbxkxouM4vL>6U_3J8C(dn
zzL<3g)}*kUJ6?}lEP+`SERM?{*~~4@KLciGyJ^}3glAK`d+Ny^93NWbwTR;mSH1Nm
zu?zL~qO&FM$ZVo)Xrj;-_|BoF!kN8zYYU@57T4-tdPP&E+o>m{-)l$DG
zk&tak)ZP|(;A^g$=qVO9K-f(+*Xsa
zp|Ew;?LnR8LP0`d%6!r}&mc)yMndl0cb&6osyize1HFzPpP`Y95tsu_qmfFEleJXf
zkf*;F@4XIHt7wSyQgy|qsl{2iB=LArys}yyvL^GDv_)K;XgVz)_#Ghshm-yI=JqUV
z(GnN23#)SLJ*^I?1MNGhTOyfp6
zZNB~7KGy0zpVKv3ezaC5=-T^VWLn=8UeTnh((YUii#T;lhD+kIS?~L~e+o7sG?5CV
zMG@yQQIqyjnG<%Ma>?ap3LdES_cf{)!*Vpbr;xCHt;G7#GHfsAP*HKZ4x_!H5J8{k
z+4{v`gCq+*ZhAqti~pK@ssG}$ZCT>o@ITHwtT*WgrTjD%2wnVP;3#R|+UM-#Vb{IA
z6K}0cTPbXKxa>|oCw=dXgdeJJx@W4mB(EEci(nFfPIBW5B}s!%ysmC+8KxFp^Vb;B
z$xD15DmUc1H`4IHa9jJx{^{X;QLxwuYsxpp?2H#P=2vA24x*-yfJ^Kv7j
z?J_Kk9IlD2hw3f$406rT2^;fW4n4XcmbSN?|GCbbFC@p_)_C^H=Mo<_k3Ft5T}F@~
zstxntdi)ekKSlrbo1TBaE!tC@it}cM2t91yXE1Vv5#B6#j^07Rn^ms8X|59$+>8IudGW?TSp|KwG6s&5)NsviCBs`W$MvWfM)D^f;
zS6LB~K+|-?wFiouPAYtKlrtx#pS0^(hQ-vnj7m&bSNK+(4EmX^=I=9q-;n<|Y9NaG
G{(k^o6)v&>
literal 0
HcmV?d00001
diff --git a/public/logos/qqq.png b/public/logos/qqq.png
new file mode 100644
index 0000000000000000000000000000000000000000..dab7e3001cee0056a67227e029594c219c1191a5
GIT binary patch
literal 1528
zcmeAS@N?(olHy`uVBq!ia0y~yU|a&i985rwk9x{Wb61xg*
zIUDwtz1%hbdLARUW&ht+Z98iP$2Scu8<~ZU@OcOfOHNDfw}!2AkK0u<>0bYPX5I#^
z`Sc5Idc`Wjo}A6>Yu@gfe^a<2{^LzLq$h;Uv`x1-?5Q~>vDuuVX72gnntDN5bK6a3
zv9i18-*Ae6lJTKQHFPQ~aD+KsB6_~zu{XR7@y|EYuVktNmg4{aUp^vk)o$G)1}xDT
NJYD@<);T3K0RW`B>T&=8
literal 0
HcmV?d00001
diff --git a/src/data/provider-registry.ts b/src/data/provider-registry.ts
index e63a1ed6..9e23d722 100644
--- a/src/data/provider-registry.ts
+++ b/src/data/provider-registry.ts
@@ -228,32 +228,32 @@ export const PROVIDER_REGISTRY: Record = {
aapl: {
url: "https://www.apple.com",
description:
- "Apple tokenized equity issued by Robinhood on Robinhood Chain (contract 0xaF3D76f1834A1d425780943C99Ea8A608f8a93f9), trading against USDG on Uniswap v4 around the clock. OpenChainBench measures its live price deviation from the real market on the tokenized stock peg benchmark.",
+ "Apple tokenized equity measured across issuers on the RWA benchmarks: Robinhood's token on Robinhood Chain (Uniswap v4 vs USDG) and Backed's xStock on Solana (Jupiter executable mid vs USDC), each tracked live against the real market price.",
},
nvda: {
url: "https://www.nvidia.com",
description:
- "Nvidia tokenized equity issued by Robinhood on Robinhood Chain (contract 0xd0601CE157Db5bdC3162BbaC2a2C8aF5320D9EEC), trading against USDG on Uniswap v4 around the clock. OpenChainBench measures its live price deviation from the real market on the tokenized stock peg benchmark.",
+ "Nvidia tokenized equity measured across issuers on the RWA benchmarks: Robinhood's token on Robinhood Chain (Uniswap v4 vs USDG) and Backed's xStock on Solana (Jupiter executable mid vs USDC), each tracked live against the real market price.",
},
googl: {
url: "https://abc.xyz",
description:
- "Alphabet tokenized equity issued by Robinhood on Robinhood Chain (contract 0x2e0847E8910a9732eB3fb1bb4b70a580ADAD4FE3), trading against USDG on Uniswap v4 around the clock. OpenChainBench measures its live price deviation from the real market on the tokenized stock peg benchmark.",
+ "Alphabet tokenized equity measured across issuers on the RWA benchmarks: Robinhood's token on Robinhood Chain (Uniswap v4 vs USDG) and Backed's xStock on Solana (Jupiter executable mid vs USDC), each tracked live against the real market price.",
},
tsla: {
url: "https://www.tesla.com",
description:
- "Tesla tokenized equity issued by Robinhood on Robinhood Chain (contract 0x322F0929c4625eD5bAd873c95208D54E1c003b2d), trading against USDG on Uniswap v4 around the clock. OpenChainBench measures its live price deviation from the real market on the tokenized stock peg benchmark.",
+ "Tesla tokenized equity measured across issuers on the RWA benchmarks: Robinhood's token on Robinhood Chain (Uniswap v4 vs USDG) and Backed's xStock on Solana (Jupiter executable mid vs USDC), each tracked live against the real market price.",
},
pltr: {
url: "https://www.palantir.com",
description:
- "Palantir tokenized equity issued by Robinhood on Robinhood Chain (contract 0x894E1EC2D74FFE5AEF8Dc8A9e84686acCB964F2A), trading against USDG on Uniswap v4 around the clock. OpenChainBench measures its live price deviation from the real market on the tokenized stock peg benchmark.",
+ "Palantir tokenized equity measured across issuers on the RWA benchmarks: Robinhood's token on Robinhood Chain (Uniswap v4 vs USDG) and Backed's xStock on Solana (Jupiter executable mid vs USDC), each tracked live against the real market price.",
},
meta: {
url: "https://www.meta.com",
description:
- "Meta Platforms tokenized equity issued by Robinhood on Robinhood Chain (contract 0xc0D6457C16Cc70d6790Dd43521C899C87ce02f35), trading against USDG on Uniswap v4 around the clock. OpenChainBench measures its live price deviation from the real market on the tokenized stock peg benchmark.",
+ "Meta Platforms tokenized equity measured across issuers on the RWA benchmarks: Robinhood's token on Robinhood Chain (Uniswap v4 vs USDG) and Backed's xStock on Solana (Jupiter executable mid vs USDC), each tracked live against the real market price.",
},
amd: {
url: "https://www.amd.com",
@@ -263,23 +263,48 @@ export const PROVIDER_REGISTRY: Record = {
msft: {
url: "https://www.microsoft.com",
description:
- "Microsoft tokenized equity issued by Robinhood on Robinhood Chain (contract 0xe93237C50D904957Cf27E7B1133b510C669c2e74), trading against USDG on Uniswap v4 around the clock. OpenChainBench measures its live price deviation from the real market on the tokenized stock peg benchmark.",
+ "Microsoft tokenized equity measured across issuers on the RWA benchmarks: Robinhood's token on Robinhood Chain (Uniswap v4 vs USDG) and Backed's xStock on Solana (Jupiter executable mid vs USDC), each tracked live against the real market price.",
},
amzn: {
url: "https://www.amazon.com",
description:
- "Amazon tokenized equity issued by Robinhood on Robinhood Chain (contract 0x12f190a9F9d7D37a250758b26824B97CE941bF54), trading against USDG on Uniswap v4 around the clock. OpenChainBench measures its live price deviation from the real market on the tokenized stock peg benchmark.",
+ "Amazon tokenized equity measured across issuers on the RWA benchmarks: Robinhood's token on Robinhood Chain (Uniswap v4 vs USDG) and Backed's xStock on Solana (Jupiter executable mid vs USDC), each tracked live against the real market price.",
},
spy: {
url: "https://www.ssga.com",
description:
- "SPDR S&P 500 ETF (State Street) tokenized equity issued by Robinhood on Robinhood Chain (contract 0x117cc2133c37B721F49dE2A7a74833232B3B4C0C), trading against USDG on Uniswap v4 around the clock. OpenChainBench measures its live price deviation from the real market on the tokenized stock peg benchmark.",
+ "SPDR S&P 500 ETF (State Street) tokenized equity measured across issuers on the RWA benchmarks: Robinhood's token on Robinhood Chain (Uniswap v4 vs USDG) and Backed's xStock on Solana (Jupiter executable mid vs USDC), each tracked live against the real market price.",
},
mu: {
url: "https://www.micron.com",
description:
"Micron Technology tokenized equity issued by Robinhood on Robinhood Chain (contract 0xfF080c8ce2E5feadaCa0Da81314Ae59D232d4afD), trading against USDG on Uniswap v4 around the clock. OpenChainBench measures its live price deviation from the real market on the tokenized stock peg benchmark.",
},
+ hood: {
+ url: "https://robinhood.com",
+ description:
+ "Robinhood Markets tokenized equity, issued by Backed as HOODx on Solana. Notably absent from Robinhood's own chain: the only onchain HOOD is third party. Tracked live against the Nasdaq price on the xStocks peg benchmark.",
+ },
+ qqq: {
+ url: "https://www.invesco.com",
+ description:
+ "Invesco QQQ Nasdaq 100 ETF, tokenized by Backed as QQQx on Solana. Tracked live against the real ETF price on the xStocks peg benchmark.",
+ },
+ coin: {
+ url: "https://www.coinbase.com",
+ description:
+ "Coinbase tokenized equity, issued by Backed as COINx on Solana, a crypto exchange's stock trading on a blockchain. Tracked live against the Nasdaq price on the xStocks peg benchmark.",
+ },
+ "orca-solana": {
+ url: "https://www.orca.so",
+ description:
+ "Orca is Solana's concentrated liquidity DEX. Its USDY/USDC whirlpool is the deepest genuine venue for Ondo's tokenized treasury and the market leg of the USDY NAV basis benchmark.",
+ },
+ "pyth-market": {
+ url: "https://pyth.network",
+ description:
+ "Pyth Network's USDY/USD market composite aggregates USDY trading into one feed. Measured against Pyth's own USDY redemption rate feed on the NAV basis benchmark.",
+ },
bloxroute: {
url: "https://bloxroute.com",
description:
diff --git a/src/lib/logo-manifest.ts b/src/lib/logo-manifest.ts
index 9c9e18af..a8b30069 100644
--- a/src/lib/logo-manifest.ts
+++ b/src/lib/logo-manifest.ts
@@ -176,6 +176,9 @@ const RAW: Record = {
amzn: "/logos/amzn.png",
spy: "/logos/spy.svg",
mu: "/logos/mu.png",
+ qqq: "/logos/qqq.png",
+ "orca-solana": "/logos/orca.png",
+ "pyth-market": "/logos/pyth.jpg",
// ─── Buyback audit (bench 018) ───
sky: "/logos/sky.svg",
@@ -377,6 +380,8 @@ const ALIASES: Record = {
// Long-tail RPC cluster (055-066).
"sonic-official": "sonic",
"monad-official": "monad",
+ hood: "robinhood",
+ coin: "coinbase",
"megaeth-official": "megaeth",
"celo-official": "celo",
"blast-official": "blast",
From 44fcb7ac20a9882f201a9d525f5c2178f9612bc3 Mon Sep 17 00:00:00 2001
From: Florent Tapponnier
Date: Tue, 14 Jul 2026 14:40:31 +0200
Subject: [PATCH 10/89] bump lockstep cache keys for RWA category ship
---
src/lib/spec.ts | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/src/lib/spec.ts b/src/lib/spec.ts
index 74d0d18c..eced326f 100644
--- a/src/lib/spec.ts
+++ b/src/lib/spec.ts
@@ -273,7 +273,7 @@ const loadBenchmarkUnfilteredCached = unstable_cache(
// buckets (gap rendering fix). Cached v26 entries hold the old
// hole-compressed arrays whose indices no longer map onto the nominal
// step grid the chart back-computes timestamps from.
- ["bench-unfiltered-v29", process.env.VERCEL_ENV === "production" ? "prod" : "all"],
+ ["bench-unfiltered-v30", process.env.VERCEL_ENV === "production" ? "prod" : "all"],
{ revalidate: 300, tags: ["benchmarks"] },
);
@@ -428,7 +428,7 @@ const loadAllBenchmarksCached = unstable_cache(
// gated catalog to /products for 30+ min after the deploy).
// v29: bumped with bench-unfiltered-v26 (monad-rpc + megaeth-rpc ship).
// v30: bumped with bench-unfiltered-v27 (dense series with nulls).
- ["all-benchmarks-v32", process.env.VERCEL_ENV === "production" ? "prod" : "all"],
+ ["all-benchmarks-v33", process.env.VERCEL_ENV === "production" ? "prod" : "all"],
{ revalidate: 300, tags: ["benchmarks"] },
);
export const loadAllBenchmarks = cache(loadAllBenchmarksCached);
@@ -507,7 +507,7 @@ const loadBenchmarkFiltered = unstable_cache(
// v16: bumped with the bench 074 ship (lockstep rule, see all-benchmarks-v28).
// v17: bumped with the monad-rpc + megaeth-rpc ship (lockstep rule).
// v18: bumped with bench-unfiltered-v27 (dense series with nulls).
- ["bench-filters-v20", process.env.VERCEL_ENV === "production" ? "prod" : "all"],
+ ["bench-filters-v21", process.env.VERCEL_ENV === "production" ? "prod" : "all"],
{ revalidate: 300, tags: ["benchmarks"] }
);
From dec82c7653b9dc1899cf01c2884d05ac8d1dd696 Mon Sep 17 00:00:00 2001
From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com>
Date: Tue, 14 Jul 2026 18:34:18 +0200
Subject: [PATCH 11/89] ship: RWA 077 xstocks-peg + 078 usdy-nav-basis to prod
(#1167)
* rwa: xstocks-peg (077) + usdy-nav-basis (078), issuer label on the tsp metric family
* bump lockstep cache keys for xstocks + usdy ship
---------
Co-authored-by: Florent Tapponnier
---
benchmarks/usdy-nav-basis.yml | 90 +++++++
benchmarks/xstocks-peg.yml | 207 ++++++++++++++++
harnesses/usdy-nav-basis/Dockerfile | 22 ++
harnesses/usdy-nav-basis/cmd/script/loghub.go | 114 +++++++++
harnesses/usdy-nav-basis/cmd/script/main.go | 232 ++++++++++++++++++
harnesses/usdy-nav-basis/go.mod | 18 ++
harnesses/usdy-nav-basis/go.sum | 46 ++++
harnesses/xstocks-peg/Dockerfile | 22 ++
harnesses/xstocks-peg/cmd/script/config.go | 60 +++++
harnesses/xstocks-peg/cmd/script/jupiter.go | 143 +++++++++++
harnesses/xstocks-peg/cmd/script/loghub.go | 114 +++++++++
harnesses/xstocks-peg/cmd/script/main.go | 83 +++++++
harnesses/xstocks-peg/cmd/script/metrics.go | 67 +++++
harnesses/xstocks-peg/cmd/script/reference.go | 207 ++++++++++++++++
harnesses/xstocks-peg/go.mod | 18 ++
harnesses/xstocks-peg/go.sum | 46 ++++
src/lib/spec.ts | 6 +-
17 files changed, 1492 insertions(+), 3 deletions(-)
create mode 100644 benchmarks/usdy-nav-basis.yml
create mode 100644 benchmarks/xstocks-peg.yml
create mode 100644 harnesses/usdy-nav-basis/Dockerfile
create mode 100644 harnesses/usdy-nav-basis/cmd/script/loghub.go
create mode 100644 harnesses/usdy-nav-basis/cmd/script/main.go
create mode 100644 harnesses/usdy-nav-basis/go.mod
create mode 100644 harnesses/usdy-nav-basis/go.sum
create mode 100644 harnesses/xstocks-peg/Dockerfile
create mode 100644 harnesses/xstocks-peg/cmd/script/config.go
create mode 100644 harnesses/xstocks-peg/cmd/script/jupiter.go
create mode 100644 harnesses/xstocks-peg/cmd/script/loghub.go
create mode 100644 harnesses/xstocks-peg/cmd/script/main.go
create mode 100644 harnesses/xstocks-peg/cmd/script/metrics.go
create mode 100644 harnesses/xstocks-peg/cmd/script/reference.go
create mode 100644 harnesses/xstocks-peg/go.mod
create mode 100644 harnesses/xstocks-peg/go.sum
diff --git a/benchmarks/usdy-nav-basis.yml b/benchmarks/usdy-nav-basis.yml
new file mode 100644
index 00000000..9b756be2
--- /dev/null
+++ b/benchmarks/usdy-nav-basis.yml
@@ -0,0 +1,90 @@
+# OpenChainBench. Bench № 078
+
+slug: usdy-nav-basis
+number: "078"
+title: USDY NAV basis, live market price vs official redemption rate
+seo_title: "USDY price vs NAV 2026"
+seo_description: "Does USDY trade at its NAV? Live basis between Ondo's tokenized treasury market price (Orca, Pyth) and its official redemption rate, in bps, keyless."
+subtitle: "Signed basis between USDY's onchain market price and the official Ondo redemption rate published on Pyth, in basis points. Tokenized treasuries are the largest RWA segment; this is the live answer to whether the market actually prices the NAV."
+
+category: RWA
+status: live
+metric: NAV basis
+unit: bps
+higher_is_better: false
+
+seo_intro: |
+ USDY is Ondo's yield-bearing tokenized treasury note, one of the
+ largest RWA tokens by float. Its official value is the redemption
+ rate Ondo publishes (the NAV a redeeming holder receives), but its
+ price on the open market is whatever the pools say. This page
+ measures the gap live: the Orca USDY/USDC whirlpool on Solana (the
+ deepest genuine venue, about $2.9M) and the Pyth market composite,
+ each against the Pyth redemption rate feed, every 60 seconds,
+ keyless on every leg. A persistent negative basis means the market
+ discounts the NAV (liquidity preference, exit friction); a positive
+ one means buyers pay a premium over redemption value. Most tokenized
+ treasuries (OUSG, BUIDL, BENJI) are transfer-restricted and never
+ trade on open pools, so their NAV can never be market-tested; USDY
+ is the rare one where the question is measurable at all.
+
+abstract: |
+ Every 60 seconds the harness reads the Ondo redemption rate and the
+ Pyth USDY/USD market composite from one Hermes call, and the Orca
+ USDY/USDC whirlpool price from one Solana getAccountInfo (sqrtPrice
+ decoded from the account bytes). Basis = (market - NAV) / NAV in
+ signed basis points per venue. No keys, no transactions.
+
+methodology:
+ - "NAV leg: the Pyth Hermes feed Crypto.USDY/USD.RR (the redemption rate Ondo publishes onchain), fetched keyless. The same Hermes call carries the Pyth USDY/USD market composite, which doubles as a venue row."
+ - "Market leg, Orca: the USDY/USDC whirlpool on Solana (~$2.9M, the deepest genuine USDY pool anywhere), price decoded straight from getAccountInfo bytes (sqrtPrice u128 at offset 65, both mints 6 decimals). Keyless against a public RPC."
+ - "Basis: signed, (market - NAV) / NAV x 10000. Negative = the market discounts the NAV. The ranking sorts by absolute basis; the sign is the story and both are displayed."
+ - "Excluded, verified 2026-07-13: the Arbitrum Camelot USDY/USDC pool holds 232 USDY against 7M USDC, effectively drained, its stale price sitting ~345bps under NAV with near zero volume. Kept out of the ranking as the textbook example of why pool depth gates peg quality; revisited monthly."
+ - "Also not measurable, disclosed: OUSG, BUIDL and BENJI are transfer-restricted mint/redeem instruments with no genuine open pools, so no market test of their NAV exists to publish."
+ - "USDY is yield accruing: the redemption rate rises daily, so a naive USD peg comparison would show permanent drift. Comparing against the live RR feed removes that by construction."
+
+findings:
+ - "{{best_name}} trades closest to NAV at {{best_p50}} (p50 absolute basis, 24h) across {{count}} measured venues."
+ - "{{name:orca-solana}} ({{p50:orca-solana}}) is the deepest genuine USDY venue anywhere at about $2.9M; its basis is the closest thing to a market verdict on Ondo's published NAV."
+ - "{{name:pyth-market}} ({{p50:pyth-market}}) aggregates offchain and onchain USDY trading into one composite; its spread against the RR feed is the cleanest single number for the NAV discount."
+ - "The excluded Camelot pool on Arbitrum is the finding that did not make the table: drained to 232 USDY, price frozen ~345bps under NAV. Depth is not a detail in RWA pricing, it is the whole game."
+
+faq:
+ - q: "Does USDY trade at its NAV?"
+ a: "Close to it, with a measurable basis that this page tracks live. At verification the Orca pool sat within a few bps of the redemption rate and the Pyth market composite about 10bps under. A persistent discount reflects exit friction and liquidity preference, not a broken product; the point is that it is now measured instead of assumed."
+ - q: "Why only two venues?"
+ a: "Because genuine USDY liquidity is rarer than the token's float suggests. The Arbitrum Camelot pool is drained and excluded (232 USDY left, price frozen well under NAV), and most other tokenized treasuries never trade openly at all. Two honest venues beat five misleading ones."
+ - q: "What does a negative basis mean?"
+ a: "The market prices USDY under its redemption value. Redeeming through Ondo takes time and has minimums, so sellers who want out now accept a small discount. The size and persistence of that discount is exactly what this bench publishes."
+ - q: "Why is USDY measurable when OUSG and BUIDL are not?"
+ a: "OUSG, BUIDL and BENJI are transfer-restricted: they move between allowlisted addresses and cannot trade on open AMMs, so no market price exists to compare against NAV. USDY circulates freely and has genuine pools, which makes it the one tokenized treasury where the NAV question can be answered by measurement."
+
+source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/usdy-nav-basis
+
+prometheus:
+ window: 24h
+ freshness_metric: usdy_basis_bps
+
+providers:
+ - slug: orca-solana
+ name: Orca (Solana)
+ tag: "USDY/USDC whirlpool, ~$2.9M, deepest genuine USDY venue"
+ formula: "p50 over 24h of the absolute signed basis (bps) between the Orca whirlpool USDY/USDC price and the Pyth redemption rate."
+ queries:
+ p50: quantile_over_time(0.50, abs(usdy_basis_bps{venue="orca-solana"})[24h:])
+ p90: quantile_over_time(0.90, abs(usdy_basis_bps{venue="orca-solana"})[24h:])
+ p99: quantile_over_time(0.99, abs(usdy_basis_bps{venue="orca-solana"})[24h:])
+ mean: avg_over_time(abs(usdy_basis_bps{venue="orca-solana"})[24h:])
+ success: avg_over_time(usdy_health{venue="orca-solana"}[24h])
+ series: usdy_basis_bps{venue="orca-solana"}
+ - slug: pyth-market
+ name: Pyth market composite
+ tag: "Crypto.USDY/USD aggregate vs the RR feed, same oracle network"
+ formula: "p50 over 24h of the absolute signed basis (bps) between the Pyth USDY/USD market composite and the Pyth redemption rate."
+ queries:
+ p50: quantile_over_time(0.50, abs(usdy_basis_bps{venue="pyth-market"})[24h:])
+ p90: quantile_over_time(0.90, abs(usdy_basis_bps{venue="pyth-market"})[24h:])
+ p99: quantile_over_time(0.99, abs(usdy_basis_bps{venue="pyth-market"})[24h:])
+ mean: avg_over_time(abs(usdy_basis_bps{venue="pyth-market"})[24h:])
+ success: avg_over_time(usdy_health{venue="pyth-market"}[24h])
+ series: usdy_basis_bps{venue="pyth-market"}
diff --git a/benchmarks/xstocks-peg.yml b/benchmarks/xstocks-peg.yml
new file mode 100644
index 00000000..9f7d8d8e
--- /dev/null
+++ b/benchmarks/xstocks-peg.yml
@@ -0,0 +1,207 @@
+# OpenChainBench. Bench № 077
+
+slug: xstocks-peg
+number: "077"
+title: xStocks price accuracy, live onchain vs Nasdaq across 12 equities on Solana
+seo_title: "xStocks price tracker 2026"
+seo_description: "Do Backed's xStocks track the real market? AAPLx, TSLAx, NVDAx and 9 more on Solana vs their Nasdaq price, deviation in bps, live and keyless."
+subtitle: "Absolute deviation between each xStock's executable Jupiter price on Solana and its real market reference, in basis points, labeled by market session. Companion to the Robinhood Chain tokenized stock bench: same methodology, different issuer, so the two pages read as a head to head."
+
+category: RWA
+status: live
+metric: Price deviation
+unit: bps
+higher_is_better: false
+
+seo_intro: |
+ Backed's xStocks are the other big tokenized equity program: 20+ US
+ stocks and ETFs issued on Solana and distributed through Kraken and
+ Bybit, trading around the clock on Raydium and Orca with pool depths
+ that reach several million dollars. This page measures how closely
+ each xStock's executable price tracks its real market reference,
+ every 60 seconds, keyless on both legs. The price measured is the
+ Jupiter aggregated quote for actually swapping one share in each
+ direction, so it is an executable mid, not a theoretical pool spot.
+ Sessions are labeled pre, regular, post and closed, and the closed
+ series is the weekend drift panel. The same methodology runs against
+ Robinhood Chain's tokenized equities on the companion bench, which
+ turns the two pages into the first public issuer head to head for
+ tokenized stocks. Fun fact this page makes measurable: HOODx, the
+ tokenized Robinhood stock, trades here on Solana, while Robinhood's
+ own chain does not tokenize HOOD at all.
+
+abstract: |
+ Every 60 seconds the harness quotes one share of each of 12 xStocks
+ through Jupiter's keyless lite API in both directions (sell to USDC,
+ buy back), takes the executable mid, corrects for the Token-2022
+ scaled amount multiplier where present, and compares against the
+ Yahoo reference price with holiday-aware session labels. Deviation is
+ 10000 x |mid - reference| / reference in basis points. Same metric
+ contract as the Robinhood Chain bench with issuer="xstocks".
+
+methodology:
+ - "Onchain leg: Jupiter lite-api swap quotes, keyless, both directions per symbol (sell 1 share to USDC, buy the proceeds back), spaced 1.1s apart, ~26s per sweep for 12 symbols. Price = mid of the two implied prices: an executable aggregated price across Raydium, Orca and every routed venue, not a single pool spot."
+ - "Token-2022 correction: 7 of 12 xStocks mints carry a ScaledUiAmount multiplier (~1.0009 observed). One batched price/v3 call per tick supplies the live multiplier per mint (usdPrice over usdPricePrescaled), converting raw quote units to exactly one UI share. Ignoring it would bake a ~9bp systematic error into affected symbols."
+ - "Reference leg: Yahoo Finance spark batch, one keyless call for all 12 underliers per tick, 1 minute candles; session labels (pre, regular, post, closed) derived from Yahoo currentTradingPeriod, holiday aware. HOODx maps to HOOD, SPYx to SPY, QQQx to QQQ, COINx to COIN."
+ - "Deviation: 10000 x |executable mid - reference| / reference, quantiles over 24h via quantile_over_time. Headline pins market_state=\"regular\"; the closed series is the weekend drift panel."
+ - "Cohort: the 12 xStocks with verified Jupiter routes at under 2bp of 1-share price impact (2026-07-13): TSLAx, NVDAx, AAPLx, MSFTx, AMZNx, GOOGLx, METAx, HOODx, SPYx, QQQx, COINx, PLTRx. All mints 8 decimals, verified individually; counterfeit lookalike mints excluded by address allowlist."
+ - "Round-trip honesty: the sell/buy spread observed at verification was ~11bp on TSLAx. Using the mid rather than one side keeps the fee component out of the deviation number; the spread itself is executable cost, not tracking error."
+ - "Quote asset caveat: prices are in USDC against USD references. A USDC peg wobble would appear as a correlated deviation across all 12 symbols simultaneously."
+ - "Cross-issuer reading: this bench shares its metric contract with the Robinhood Chain tokenized stock bench (issuer label). Same equity, same reference, two issuers: the pages are directly comparable, with the depth difference disclosed (Solana xStocks pools run 10x to 100x deeper)."
+
+findings:
+ - "{{best_name}} tracks its reference tightest at {{best_p50}} (p50, 24h) across {{count}} measured xStocks."
+ - "{{name:tsla}} deviates {{p50:tsla}} (p50, 24h) against a Raydium pool holding about $2.1M, roughly 50x the depth of the equivalent Robinhood Chain pool, and the tighter tracking that buys is exactly what the cross-issuer comparison exists to show."
+ - "{{name:hood}} ({{p50:hood}}) is the page's inside joke made measurable: the tokenized Robinhood stock trades on Solana via a third-party issuer while Robinhood's own chain does not tokenize HOOD."
+ - "{{name:spy}} ({{p50:spy}}) and {{name:qqq}} ({{p50:qqq}}) give the bench its index rows, useful because index arbitrage against deep ETF markets should in theory be the tightest peg of all."
+ - "The number to watch on weekends: pools trade around the clock while the reference freezes at Friday close, and the closed-session drift measured here is the honest answer to what a 24/7 stock is worth on a Sunday."
+
+faq:
+ - q: "Do xStocks track their real stock price?"
+ a: "During regular hours the liquid names track within tens of basis points, and this page shows the live per-symbol number measured as an executable Jupiter mid, not a theoretical pool spot. Outside market hours the peg loosens by construction, and the closed-session series quantifies exactly how much."
+ - q: "How is this different from the Robinhood Chain tokenized stock bench?"
+ a: "Same methodology, different issuer and chain: Backed's xStocks on Solana measured through Jupiter, Robinhood's tokens on their own chain measured through Uniswap v4. The two benches share a metric contract, so the same equity can be compared across issuers directly, depth differences disclosed."
+ - q: "Why measure the Jupiter quote instead of a specific pool?"
+ a: "Because it is what a user actually gets: the aggregated executable route across every venue with the fee baked into the round trip. Taking the mid of both directions removes the fee component and leaves the tracking signal. A single pool spot can sit anywhere inside its fee band without being arbitrageable."
+ - q: "What is the ScaledUiAmount correction?"
+ a: "Seven xStocks mints use Token-2022 scaled amounts, where raw token units map to UI shares through a live multiplier (about 1.0009 at verification). The harness reads the multiplier every tick from Jupiter's price API and applies it, without which those symbols would carry a permanent ~9bp phantom deviation."
+
+source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/xstocks-peg
+
+prometheus:
+ window: 24h
+ freshness_metric: tsp_deviation_bps
+
+providers:
+ - slug: tsla
+ name: TSLAx
+ tag: "Tesla xStock, deepest pool of the cohort (~$2.1M Raydium)"
+ formula: "p50 over 24h of the absolute deviation (bps) between the executable Jupiter mid for TSLAx on Solana and the Yahoo reference, regular market hours only."
+ queries:
+ p50: quantile_over_time(0.50, tsp_deviation_bps{issuer="xstocks", asset="tsla", market_state="regular"}[24h])
+ p90: quantile_over_time(0.90, tsp_deviation_bps{issuer="xstocks", asset="tsla", market_state="regular"}[24h])
+ p99: quantile_over_time(0.99, tsp_deviation_bps{issuer="xstocks", asset="tsla", market_state="regular"}[24h])
+ mean: avg_over_time(tsp_deviation_bps{issuer="xstocks", asset="tsla", market_state="regular"}[24h])
+ success: avg_over_time(tsp_health{asset="tsla"}[24h])
+ series: tsp_deviation_bps{issuer="xstocks", asset="tsla", market_state="regular"}
+ - slug: nvda
+ name: NVDAx
+ tag: "Nvidia xStock, ~$2.7M Raydium pool"
+ formula: "p50 over 24h of the absolute deviation (bps) between the executable Jupiter mid for NVDAx on Solana and the Yahoo reference, regular market hours only."
+ queries:
+ p50: quantile_over_time(0.50, tsp_deviation_bps{issuer="xstocks", asset="nvda", market_state="regular"}[24h])
+ p90: quantile_over_time(0.90, tsp_deviation_bps{issuer="xstocks", asset="nvda", market_state="regular"}[24h])
+ p99: quantile_over_time(0.99, tsp_deviation_bps{issuer="xstocks", asset="nvda", market_state="regular"}[24h])
+ mean: avg_over_time(tsp_deviation_bps{issuer="xstocks", asset="nvda", market_state="regular"}[24h])
+ success: avg_over_time(tsp_health{asset="nvda"}[24h])
+ series: tsp_deviation_bps{issuer="xstocks", asset="nvda", market_state="regular"}
+ - slug: aapl
+ name: AAPLx
+ tag: "Apple xStock"
+ formula: "p50 over 24h of the absolute deviation (bps) between the executable Jupiter mid for AAPLx on Solana and the Yahoo reference, regular market hours only."
+ queries:
+ p50: quantile_over_time(0.50, tsp_deviation_bps{issuer="xstocks", asset="aapl", market_state="regular"}[24h])
+ p90: quantile_over_time(0.90, tsp_deviation_bps{issuer="xstocks", asset="aapl", market_state="regular"}[24h])
+ p99: quantile_over_time(0.99, tsp_deviation_bps{issuer="xstocks", asset="aapl", market_state="regular"}[24h])
+ mean: avg_over_time(tsp_deviation_bps{issuer="xstocks", asset="aapl", market_state="regular"}[24h])
+ success: avg_over_time(tsp_health{asset="aapl"}[24h])
+ series: tsp_deviation_bps{issuer="xstocks", asset="aapl", market_state="regular"}
+ - slug: msft
+ name: MSFTx
+ tag: "Microsoft xStock"
+ formula: "p50 over 24h of the absolute deviation (bps) between the executable Jupiter mid for MSFTx on Solana and the Yahoo reference, regular market hours only."
+ queries:
+ p50: quantile_over_time(0.50, tsp_deviation_bps{issuer="xstocks", asset="msft", market_state="regular"}[24h])
+ p90: quantile_over_time(0.90, tsp_deviation_bps{issuer="xstocks", asset="msft", market_state="regular"}[24h])
+ p99: quantile_over_time(0.99, tsp_deviation_bps{issuer="xstocks", asset="msft", market_state="regular"}[24h])
+ mean: avg_over_time(tsp_deviation_bps{issuer="xstocks", asset="msft", market_state="regular"}[24h])
+ success: avg_over_time(tsp_health{asset="msft"}[24h])
+ series: tsp_deviation_bps{issuer="xstocks", asset="msft", market_state="regular"}
+ - slug: amzn
+ name: AMZNx
+ tag: "Amazon xStock"
+ formula: "p50 over 24h of the absolute deviation (bps) between the executable Jupiter mid for AMZNx on Solana and the Yahoo reference, regular market hours only."
+ queries:
+ p50: quantile_over_time(0.50, tsp_deviation_bps{issuer="xstocks", asset="amzn", market_state="regular"}[24h])
+ p90: quantile_over_time(0.90, tsp_deviation_bps{issuer="xstocks", asset="amzn", market_state="regular"}[24h])
+ p99: quantile_over_time(0.99, tsp_deviation_bps{issuer="xstocks", asset="amzn", market_state="regular"}[24h])
+ mean: avg_over_time(tsp_deviation_bps{issuer="xstocks", asset="amzn", market_state="regular"}[24h])
+ success: avg_over_time(tsp_health{asset="amzn"}[24h])
+ series: tsp_deviation_bps{issuer="xstocks", asset="amzn", market_state="regular"}
+ - slug: googl
+ name: GOOGLx
+ tag: "Alphabet xStock"
+ formula: "p50 over 24h of the absolute deviation (bps) between the executable Jupiter mid for GOOGLx on Solana and the Yahoo reference, regular market hours only."
+ queries:
+ p50: quantile_over_time(0.50, tsp_deviation_bps{issuer="xstocks", asset="googl", market_state="regular"}[24h])
+ p90: quantile_over_time(0.90, tsp_deviation_bps{issuer="xstocks", asset="googl", market_state="regular"}[24h])
+ p99: quantile_over_time(0.99, tsp_deviation_bps{issuer="xstocks", asset="googl", market_state="regular"}[24h])
+ mean: avg_over_time(tsp_deviation_bps{issuer="xstocks", asset="googl", market_state="regular"}[24h])
+ success: avg_over_time(tsp_health{asset="googl"}[24h])
+ series: tsp_deviation_bps{issuer="xstocks", asset="googl", market_state="regular"}
+ - slug: meta
+ name: METAx
+ tag: "Meta xStock"
+ formula: "p50 over 24h of the absolute deviation (bps) between the executable Jupiter mid for METAx on Solana and the Yahoo reference, regular market hours only."
+ queries:
+ p50: quantile_over_time(0.50, tsp_deviation_bps{issuer="xstocks", asset="meta", market_state="regular"}[24h])
+ p90: quantile_over_time(0.90, tsp_deviation_bps{issuer="xstocks", asset="meta", market_state="regular"}[24h])
+ p99: quantile_over_time(0.99, tsp_deviation_bps{issuer="xstocks", asset="meta", market_state="regular"}[24h])
+ mean: avg_over_time(tsp_deviation_bps{issuer="xstocks", asset="meta", market_state="regular"}[24h])
+ success: avg_over_time(tsp_health{asset="meta"}[24h])
+ series: tsp_deviation_bps{issuer="xstocks", asset="meta", market_state="regular"}
+ - slug: hood
+ name: HOODx
+ tag: "Robinhood stock, tokenized by Backed, not by Robinhood"
+ formula: "p50 over 24h of the absolute deviation (bps) between the executable Jupiter mid for HOODx on Solana and the Yahoo reference, regular market hours only."
+ queries:
+ p50: quantile_over_time(0.50, tsp_deviation_bps{issuer="xstocks", asset="hood", market_state="regular"}[24h])
+ p90: quantile_over_time(0.90, tsp_deviation_bps{issuer="xstocks", asset="hood", market_state="regular"}[24h])
+ p99: quantile_over_time(0.99, tsp_deviation_bps{issuer="xstocks", asset="hood", market_state="regular"}[24h])
+ mean: avg_over_time(tsp_deviation_bps{issuer="xstocks", asset="hood", market_state="regular"}[24h])
+ success: avg_over_time(tsp_health{asset="hood"}[24h])
+ series: tsp_deviation_bps{issuer="xstocks", asset="hood", market_state="regular"}
+ - slug: spy
+ name: SPYx
+ tag: "S&P 500 ETF xStock"
+ formula: "p50 over 24h of the absolute deviation (bps) between the executable Jupiter mid for SPYx on Solana and the Yahoo reference, regular market hours only."
+ queries:
+ p50: quantile_over_time(0.50, tsp_deviation_bps{issuer="xstocks", asset="spy", market_state="regular"}[24h])
+ p90: quantile_over_time(0.90, tsp_deviation_bps{issuer="xstocks", asset="spy", market_state="regular"}[24h])
+ p99: quantile_over_time(0.99, tsp_deviation_bps{issuer="xstocks", asset="spy", market_state="regular"}[24h])
+ mean: avg_over_time(tsp_deviation_bps{issuer="xstocks", asset="spy", market_state="regular"}[24h])
+ success: avg_over_time(tsp_health{asset="spy"}[24h])
+ series: tsp_deviation_bps{issuer="xstocks", asset="spy", market_state="regular"}
+ - slug: qqq
+ name: QQQx
+ tag: "Nasdaq 100 ETF xStock"
+ formula: "p50 over 24h of the absolute deviation (bps) between the executable Jupiter mid for QQQx on Solana and the Yahoo reference, regular market hours only."
+ queries:
+ p50: quantile_over_time(0.50, tsp_deviation_bps{issuer="xstocks", asset="qqq", market_state="regular"}[24h])
+ p90: quantile_over_time(0.90, tsp_deviation_bps{issuer="xstocks", asset="qqq", market_state="regular"}[24h])
+ p99: quantile_over_time(0.99, tsp_deviation_bps{issuer="xstocks", asset="qqq", market_state="regular"}[24h])
+ mean: avg_over_time(tsp_deviation_bps{issuer="xstocks", asset="qqq", market_state="regular"}[24h])
+ success: avg_over_time(tsp_health{asset="qqq"}[24h])
+ series: tsp_deviation_bps{issuer="xstocks", asset="qqq", market_state="regular"}
+ - slug: coin
+ name: COINx
+ tag: "Coinbase xStock"
+ formula: "p50 over 24h of the absolute deviation (bps) between the executable Jupiter mid for COINx on Solana and the Yahoo reference, regular market hours only."
+ queries:
+ p50: quantile_over_time(0.50, tsp_deviation_bps{issuer="xstocks", asset="coin", market_state="regular"}[24h])
+ p90: quantile_over_time(0.90, tsp_deviation_bps{issuer="xstocks", asset="coin", market_state="regular"}[24h])
+ p99: quantile_over_time(0.99, tsp_deviation_bps{issuer="xstocks", asset="coin", market_state="regular"}[24h])
+ mean: avg_over_time(tsp_deviation_bps{issuer="xstocks", asset="coin", market_state="regular"}[24h])
+ success: avg_over_time(tsp_health{asset="coin"}[24h])
+ series: tsp_deviation_bps{issuer="xstocks", asset="coin", market_state="regular"}
+ - slug: pltr
+ name: PLTRx
+ tag: "Palantir xStock, thinnest route of the cohort (1.8bp impact)"
+ formula: "p50 over 24h of the absolute deviation (bps) between the executable Jupiter mid for PLTRx on Solana and the Yahoo reference, regular market hours only."
+ queries:
+ p50: quantile_over_time(0.50, tsp_deviation_bps{issuer="xstocks", asset="pltr", market_state="regular"}[24h])
+ p90: quantile_over_time(0.90, tsp_deviation_bps{issuer="xstocks", asset="pltr", market_state="regular"}[24h])
+ p99: quantile_over_time(0.99, tsp_deviation_bps{issuer="xstocks", asset="pltr", market_state="regular"}[24h])
+ mean: avg_over_time(tsp_deviation_bps{issuer="xstocks", asset="pltr", market_state="regular"}[24h])
+ success: avg_over_time(tsp_health{asset="pltr"}[24h])
+ series: tsp_deviation_bps{issuer="xstocks", asset="pltr", market_state="regular"}
diff --git a/harnesses/usdy-nav-basis/Dockerfile b/harnesses/usdy-nav-basis/Dockerfile
new file mode 100644
index 00000000..63108cfc
--- /dev/null
+++ b/harnesses/usdy-nav-basis/Dockerfile
@@ -0,0 +1,22 @@
+FROM golang:1.24-alpine AS builder
+
+WORKDIR /app
+RUN apk add --no-cache git
+
+COPY go.mod go.sum ./
+RUN go mod download
+
+COPY . .
+
+RUN CGO_ENABLED=0 GOOS=linux go build -o /app/monitor ./cmd/script
+
+FROM debian:bookworm-slim
+
+WORKDIR /app
+RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
+
+COPY --from=builder /app/monitor /app/monitor
+
+EXPOSE 2112
+
+CMD ["/app/monitor"]
diff --git a/harnesses/usdy-nav-basis/cmd/script/loghub.go b/harnesses/usdy-nav-basis/cmd/script/loghub.go
new file mode 100644
index 00000000..9dd74488
--- /dev/null
+++ b/harnesses/usdy-nav-basis/cmd/script/loghub.go
@@ -0,0 +1,114 @@
+package main
+
+import (
+ "bufio"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "strconv"
+ "sync"
+ "time"
+)
+
+// Auto-generated by the loghub inline pattern. Captures stdout/stderr into a
+// bounded ring buffer and exposes GET /logs?tail=N protected by X-Logs-Token
+// matching the LOGS_TOKEN env var.
+//
+// Keep in sync across miniapps (was previously the shared/loghub package; we
+// inline because Railway's per-harness Docker build context can't reach a
+// sibling shared module via go.mod replace).
+
+const logRingMax = 5000
+
+type logRing struct {
+ mu sync.Mutex
+ lines []string
+ max int
+}
+
+var globalLogRing = &logRing{max: logRingMax}
+
+func (b *logRing) push(line string) {
+ entry := time.Now().UTC().Format("2006-01-02T15:04:05.000Z") + " " + line
+ b.mu.Lock()
+ if len(b.lines) >= b.max {
+ b.lines = append(b.lines[1:], entry)
+ } else {
+ b.lines = append(b.lines, entry)
+ }
+ b.mu.Unlock()
+}
+
+func (b *logRing) snapshot(tail int) []string {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ if tail <= 0 || tail >= len(b.lines) {
+ out := make([]string, len(b.lines))
+ copy(out, b.lines)
+ return out
+ }
+ start := len(b.lines) - tail
+ out := make([]string, tail)
+ copy(out, b.lines[start:])
+ return out
+}
+
+var logSetupOnce sync.Once
+
+// installLogCapture replaces os.Stdout (and os.Stderr) with the write-end of a
+// pipe, then spawns a goroutine that fan-outs every line to the original
+// stdout AND the in-memory ring buffer. Call exactly once, very early in
+// main().
+func installLogCapture() { logSetupOnce.Do(doInstallLogCapture) }
+
+func doInstallLogCapture() {
+ originalStdout := os.Stdout
+ originalStderr := os.Stderr
+ r, w, err := os.Pipe()
+ if err != nil {
+ fmt.Fprintf(originalStdout, "[loghub] pipe failed: %v (/logs will be empty)\n", err)
+ return
+ }
+ os.Stdout = w
+ os.Stderr = w
+
+ go func() {
+ scanner := bufio.NewScanner(r)
+ buf := make([]byte, 0, 1024*1024)
+ scanner.Buffer(buf, 1024*1024)
+ for scanner.Scan() {
+ line := scanner.Text()
+ fmt.Fprintln(originalStdout, line)
+ globalLogRing.push(line)
+ }
+ _, _ = io.Copy(originalStdout, r)
+ _ = originalStderr
+ }()
+}
+
+// logsHandler returns an http.Handler for GET /logs?tail=N. Requires header
+// X-Logs-Token to match the LOGS_TOKEN env var. Returns 403 if env unset.
+func logsHandler() http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ expected := os.Getenv("LOGS_TOKEN")
+ if expected == "" {
+ http.Error(w, "logs disabled: LOGS_TOKEN unset", http.StatusForbidden)
+ return
+ }
+ if r.Header.Get("X-Logs-Token") != expected {
+ http.Error(w, "forbidden", http.StatusForbidden)
+ return
+ }
+ tail := 500
+ if t := r.URL.Query().Get("tail"); t != "" {
+ if n, err := strconv.Atoi(t); err == nil && n > 0 {
+ tail = n
+ }
+ }
+ w.Header().Set("Content-Type", "text/plain; charset=utf-8")
+ for _, l := range globalLogRing.snapshot(tail) {
+ fmt.Fprintln(w, l)
+ }
+ })
+}
diff --git a/harnesses/usdy-nav-basis/cmd/script/main.go b/harnesses/usdy-nav-basis/cmd/script/main.go
new file mode 100644
index 00000000..d3d3e39b
--- /dev/null
+++ b/harnesses/usdy-nav-basis/cmd/script/main.go
@@ -0,0 +1,232 @@
+package main
+
+import (
+ "bytes"
+ "encoding/base64"
+ "encoding/binary"
+ "encoding/json"
+ "fmt"
+ "io"
+ "math"
+ "math/big"
+ "net/http"
+ "os"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/prometheus/client_golang/prometheus"
+ "github.com/prometheus/client_golang/prometheus/promauto"
+ "github.com/prometheus/client_golang/prometheus/promhttp"
+)
+
+// usdy-nav-basis: live basis between USDY's (Ondo tokenized treasury)
+// onchain market price and its official redemption rate (NAV).
+//
+// NAV leg: Pyth Hermes "Crypto.USDY/USD.RR" redemption-rate feed. The
+// same Hermes call also carries the Pyth USDY/USD market composite,
+// which doubles as a cross-check venue row. Market leg: the Orca
+// whirlpool USDY/USDC on Solana (deepest genuine pool, ~$2.9M),
+// decoded straight from getAccountInfo (sqrtPrice u128 LE at bytes
+// 65..81, both mints 6 decimals so no scale factor).
+//
+// Excluded, verified 2026-07-13: the Arbitrum Camelot pool holds 232
+// USDY against 7M USDC (drained), its price sits ~345 bps off NAV and
+// moves nothing; kept out of the ranking, documented in the spec as
+// the cautionary example of why pool depth gates peg quality.
+//
+// All legs keyless. One Hermes GET + one Solana RPC POST per tick.
+
+const (
+ navFeedID = "e3d1723999820435ebab53003a542ff26847720692af92523eea613a9a28d500"
+ marketFeedID = "e393449f6aff8a4b6d3e1165a7c9ebec103685f3b41e60db4277b5b6d10e7326"
+ orcaPool = "AGXrswVDRoUf62UX9voTXv6TCGw6fBUEwDpyUd9YdZfD"
+
+ pollInterval = 60 * time.Second
+ httpTimeout = 15 * time.Second
+)
+
+var (
+ basisBps = promauto.NewGaugeVec(prometheus.GaugeOpts{
+ Name: "usdy_basis_bps",
+ Help: "Signed basis of the venue's USDY price vs the official redemption rate, in bps (positive = premium over NAV).",
+ }, []string{"venue"})
+
+ navGauge = promauto.NewGauge(prometheus.GaugeOpts{
+ Name: "usdy_nav_usd",
+ Help: "USDY official redemption rate from the Pyth RR feed.",
+ })
+
+ marketPrice = promauto.NewGaugeVec(prometheus.GaugeOpts{
+ Name: "usdy_market_price_usd",
+ Help: "USDY market price per venue.",
+ }, []string{"venue"})
+
+ sourceCall = promauto.NewCounterVec(prometheus.CounterOpts{
+ Name: "usdy_source_call_total",
+ Help: "Fetch outcomes per source.",
+ }, []string{"source", "result"})
+
+ healthGauge = promauto.NewGaugeVec(prometheus.GaugeOpts{
+ Name: "usdy_health",
+ Help: "1 when the venue produced a basis sample on the last tick.",
+ }, []string{"venue"})
+)
+
+func envDefault(key, def string) string {
+ if v := strings.TrimSpace(os.Getenv(key)); v != "" {
+ return v
+ }
+ return def
+}
+
+func main() {
+ installLogCapture()
+ fmt.Println("=== USDY NAV Basis Harness ===")
+ fmt.Println("OpenChainBench — USDY market price vs official redemption rate, keyless.")
+
+ go func() {
+ mux := http.NewServeMux()
+ mux.Handle("/metrics", promhttp.Handler())
+ mux.Handle("/logs", logsHandler())
+ mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte("ok"))
+ })
+ if err := http.ListenAndServe(envDefault("LISTEN_ADDR", ":2112"), mux); err != nil {
+ fmt.Printf("[fatal] metrics server: %v\n", err)
+ }
+ }()
+
+ client := &http.Client{Timeout: httpTimeout}
+ tick := func() {
+ nav, market := fetchHermes(client)
+ orca := fetchOrca(client)
+
+ if nav <= 0 {
+ healthGauge.WithLabelValues("orca-solana").Set(0)
+ healthGauge.WithLabelValues("pyth-market").Set(0)
+ return
+ }
+ navGauge.Set(nav)
+
+ emit := func(venue string, price float64) {
+ if price <= 0 {
+ healthGauge.WithLabelValues(venue).Set(0)
+ return
+ }
+ marketPrice.WithLabelValues(venue).Set(price)
+ bps := (price - nav) / nav * 10000
+ basisBps.WithLabelValues(venue).Set(bps)
+ healthGauge.WithLabelValues(venue).Set(1)
+ fmt.Printf("[%s] price=%.6f nav=%.6f basis=%+.1fbps\n", venue, price, nav, bps)
+ }
+ emit("orca-solana", orca)
+ emit("pyth-market", market)
+ }
+
+ tick()
+ t := time.NewTicker(pollInterval)
+ defer t.Stop()
+ for range t.C {
+ tick()
+ }
+}
+
+// fetchHermes returns (nav, market) from one Hermes call.
+func fetchHermes(client *http.Client) (float64, float64) {
+ url := "https://hermes.pyth.network/v2/updates/price/latest?ids[]=0x" + navFeedID + "&ids[]=0x" + marketFeedID
+ req, _ := http.NewRequest("GET", url, nil)
+ req.Header.Set("User-Agent", "OpenChainBench/1.0 (+https://openchainbench.com)")
+ resp, err := client.Do(req)
+ if err != nil {
+ sourceCall.WithLabelValues("hermes", "network").Inc()
+ return 0, 0
+ }
+ defer resp.Body.Close()
+ raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
+ if resp.StatusCode != 200 {
+ sourceCall.WithLabelValues("hermes", fmt.Sprintf("http_%d", resp.StatusCode)).Inc()
+ return 0, 0
+ }
+ var envel struct {
+ Parsed []struct {
+ ID string `json:"id"`
+ Price struct {
+ Price string `json:"price"`
+ Expo int `json:"expo"`
+ } `json:"price"`
+ } `json:"parsed"`
+ }
+ if err := json.Unmarshal(raw, &envel); err != nil {
+ sourceCall.WithLabelValues("hermes", "parse").Inc()
+ return 0, 0
+ }
+ nav, market := 0.0, 0.0
+ for _, p := range envel.Parsed {
+ v, err := strconv.ParseFloat(p.Price.Price, 64)
+ if err != nil {
+ continue
+ }
+ val := v * math.Pow10(p.Price.Expo)
+ switch strings.TrimPrefix(strings.ToLower(p.ID), "0x") {
+ case navFeedID:
+ nav = val
+ case marketFeedID:
+ market = val
+ }
+ }
+ sourceCall.WithLabelValues("hermes", "ok").Inc()
+ return nav, market
+}
+
+// fetchOrca decodes the whirlpool sqrtPrice (u128 LE at bytes 65..81);
+// USDY and USDC are both 6 decimals so price = (sqrtPrice/2^64)^2.
+func fetchOrca(client *http.Client) float64 {
+ body := []byte(fmt.Sprintf(
+ `{"jsonrpc":"2.0","id":%d,"method":"getAccountInfo","params":["%s",{"encoding":"base64"}]}`,
+ time.Now().UnixNano(), orcaPool,
+ ))
+ req, _ := http.NewRequest("POST", envDefault("USDY_SOLANA_RPC", "https://solana-rpc.publicnode.com"), bytes.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("User-Agent", "OpenChainBench/1.0 (+https://openchainbench.com)")
+ resp, err := client.Do(req)
+ if err != nil {
+ sourceCall.WithLabelValues("orca", "network").Inc()
+ return 0
+ }
+ defer resp.Body.Close()
+ raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
+ if resp.StatusCode != 200 {
+ sourceCall.WithLabelValues("orca", fmt.Sprintf("http_%d", resp.StatusCode)).Inc()
+ return 0
+ }
+ var envel struct {
+ Result struct {
+ Value struct {
+ Data []string `json:"data"`
+ } `json:"value"`
+ } `json:"result"`
+ }
+ if err := json.Unmarshal(raw, &envel); err != nil || len(envel.Result.Value.Data) == 0 {
+ sourceCall.WithLabelValues("orca", "parse").Inc()
+ return 0
+ }
+ acct, err := base64.StdEncoding.DecodeString(envel.Result.Value.Data[0])
+ if err != nil || len(acct) < 81 {
+ sourceCall.WithLabelValues("orca", "decode").Inc()
+ return 0
+ }
+ lo := binary.LittleEndian.Uint64(acct[65:73])
+ hi := binary.LittleEndian.Uint64(acct[73:81])
+ sqrtPrice := new(big.Float).SetPrec(200).SetInt(new(big.Int).Add(
+ new(big.Int).Lsh(new(big.Int).SetUint64(hi), 64),
+ new(big.Int).SetUint64(lo),
+ ))
+ q64 := new(big.Float).SetPrec(200).SetInt(new(big.Int).Lsh(big.NewInt(1), 64))
+ ratio := new(big.Float).Quo(sqrtPrice, q64)
+ price := new(big.Float).Mul(ratio, ratio)
+ f, _ := price.Float64()
+ sourceCall.WithLabelValues("orca", "ok").Inc()
+ return f
+}
diff --git a/harnesses/usdy-nav-basis/go.mod b/harnesses/usdy-nav-basis/go.mod
new file mode 100644
index 00000000..1284070e
--- /dev/null
+++ b/harnesses/usdy-nav-basis/go.mod
@@ -0,0 +1,18 @@
+module usdy-nav-basis
+
+go 1.24.0
+
+require github.com/prometheus/client_golang v1.23.2
+
+require (
+ github.com/beorn7/perks v1.0.1 // indirect
+ github.com/cespare/xxhash/v2 v2.3.0 // indirect
+ github.com/kr/text v0.2.0 // indirect
+ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
+ github.com/prometheus/client_model v0.6.2 // indirect
+ github.com/prometheus/common v0.66.1 // indirect
+ github.com/prometheus/procfs v0.16.1 // indirect
+ go.yaml.in/yaml/v2 v2.4.2 // indirect
+ golang.org/x/sys v0.35.0 // indirect
+ google.golang.org/protobuf v1.36.8 // indirect
+)
diff --git a/harnesses/usdy-nav-basis/go.sum b/harnesses/usdy-nav-basis/go.sum
new file mode 100644
index 00000000..d6b8ca98
--- /dev/null
+++ b/harnesses/usdy-nav-basis/go.sum
@@ -0,0 +1,46 @@
+github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
+github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
+github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
+github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
+github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
+github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
+github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
+github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
+github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
+github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
+github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
+github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
+github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
+github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
+github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
+github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
+github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
+go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
+go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
+go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
+golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
+golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
+google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/harnesses/xstocks-peg/Dockerfile b/harnesses/xstocks-peg/Dockerfile
new file mode 100644
index 00000000..63108cfc
--- /dev/null
+++ b/harnesses/xstocks-peg/Dockerfile
@@ -0,0 +1,22 @@
+FROM golang:1.24-alpine AS builder
+
+WORKDIR /app
+RUN apk add --no-cache git
+
+COPY go.mod go.sum ./
+RUN go mod download
+
+COPY . .
+
+RUN CGO_ENABLED=0 GOOS=linux go build -o /app/monitor ./cmd/script
+
+FROM debian:bookworm-slim
+
+WORKDIR /app
+RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
+
+COPY --from=builder /app/monitor /app/monitor
+
+EXPOSE 2112
+
+CMD ["/app/monitor"]
diff --git a/harnesses/xstocks-peg/cmd/script/config.go b/harnesses/xstocks-peg/cmd/script/config.go
new file mode 100644
index 00000000..b151be10
--- /dev/null
+++ b/harnesses/xstocks-peg/cmd/script/config.go
@@ -0,0 +1,60 @@
+package main
+
+import (
+ "os"
+ "strings"
+ "time"
+)
+
+// xstocks-peg: Backed's xStocks tokenized equities on Solana vs their
+// Yahoo reference, deviation in bps, session-labeled. Same metric
+// contract as the Robinhood harness (tsp_* family) with
+// issuer="xstocks", so cross-issuer comparisons are pure PromQL.
+//
+// Price read: Jupiter lite-api swap quotes in BOTH directions per
+// symbol (sell 1 share to USDC, buy with the equivalent USDC); the mid
+// is the executable peg price, immune to the price/v3 drift observed
+// on thin routes (PLTRx v3 was $1.93 off its executable quote).
+// A single batched price/v3 call per tick supplies the Token-2022
+// ScaledUiAmount multiplier (7 of 12 mints carry one, ~1.0009), which
+// converts raw 1e8 quote units to exactly one UI share.
+//
+// Cohort: 12 xStocks with verified Jupiter routes at 1-share impact
+// under 2bp (2026-07-13). All mints 8 decimals.
+
+const (
+ usdcMint = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"
+ oneShareRaw = 100000000 // 1e8 = 1 share at 8 decimals, pre-multiplier
+ pollInterval = 60 * time.Second
+ httpTimeout = 15 * time.Second
+ quoteGap = 1100 * time.Millisecond // lite tier: stay well under 60 req/min
+ issuerLabel = "xstocks"
+ logThresholdBps = 100.0
+)
+
+type Asset struct {
+ Symbol string // Yahoo ticker (HOODx maps to HOOD, etc.)
+ Mint string
+}
+
+var assets = []Asset{
+ {Symbol: "TSLA", Mint: "XsDoVfqeBukxuZHWhdvWHBhgEHjGNst4MLodqsJHzoB"},
+ {Symbol: "NVDA", Mint: "Xsc9qvGR1efVDFGLrVsmkzv3qi45LTBjeUKSPmx9qEh"},
+ {Symbol: "AAPL", Mint: "XsbEhLAtcf6HdfpFZ5xEMdqW8nfAvcsP5bdudRLJzJp"},
+ {Symbol: "MSFT", Mint: "XspzcW1PRtgf6Wj92HCiZdjzKCyFekVD8P5Ueh3dRMX"},
+ {Symbol: "AMZN", Mint: "Xs3eBt7uRfJX8QUs4suhyU8p2M6DoUDrJyWBa8LLZsg"},
+ {Symbol: "GOOGL", Mint: "XsCPL9dNWBMvFtTmwcCA5v3xWPSMEBCszbQdiLLq6aN"},
+ {Symbol: "META", Mint: "Xsa62P5mvPszXL1krVUnU5ar38bBSVcWAB6fmPCo5Zu"},
+ {Symbol: "HOOD", Mint: "XsvNBAYkrDRNhA7wPHQfX3ZUXZyZLdnCQDfHZ56bzpg"},
+ {Symbol: "SPY", Mint: "XsoCS1TfEyfFhfvj8EtZ528L3CaKBDBRqRapnBbDF2W"},
+ {Symbol: "QQQ", Mint: "Xs8S1uUs1zvS2p7iwtsG3b6fkhpvmwz4GYU3gWAmWHZ"},
+ {Symbol: "COIN", Mint: "Xs7ZdzSHLU9ftNJsii5fCeJhoRWSC32SQGzGQtePxNu"},
+ {Symbol: "PLTR", Mint: "XsoBhf2ufR8fTyNSjqfU71DYGaE6Z3SUGAidpzriAA4"},
+}
+
+func listenAddr() string {
+ if v := strings.TrimSpace(os.Getenv("LISTEN_ADDR")); v != "" {
+ return v
+ }
+ return ":2112"
+}
diff --git a/harnesses/xstocks-peg/cmd/script/jupiter.go b/harnesses/xstocks-peg/cmd/script/jupiter.go
new file mode 100644
index 00000000..a34a4708
--- /dev/null
+++ b/harnesses/xstocks-peg/cmd/script/jupiter.go
@@ -0,0 +1,143 @@
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// Jupiter lite-api legs. Two swap quotes per symbol per tick (sell one
+// share, buy one share worth of USDC back), spaced quoteGap apart to
+// stay far under the lite tier's 60 req/min. The mid of the two
+// implied prices is the executable peg price. One batched price/v3
+// call per tick provides the ScaledUiAmount multiplier per mint.
+
+const jupUA = "OpenChainBench/1.0 (+https://openchainbench.com)"
+
+func jupGet(client *http.Client, url string) ([]byte, string) {
+ req, err := http.NewRequest("GET", url, nil)
+ if err != nil {
+ return nil, "request_build"
+ }
+ req.Header.Set("User-Agent", jupUA)
+ req.Header.Set("Accept", "application/json")
+ resp, err := client.Do(req)
+ if err != nil {
+ return nil, "network"
+ }
+ defer resp.Body.Close()
+ raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
+ if err != nil {
+ return nil, "read"
+ }
+ if resp.StatusCode != 200 {
+ return nil, fmt.Sprintf("http_%d", resp.StatusCode)
+ }
+ return raw, "ok"
+}
+
+// fetchMultipliers reads price/v3 for every mint in one call and
+// derives the ScaledUiAmount multiplier as usdPrice / usdPricePrescaled
+// (1.0 when the mint carries no scaling config).
+func fetchMultipliers(client *http.Client) map[string]float64 {
+ ids := make([]string, 0, len(assets))
+ for _, a := range assets {
+ ids = append(ids, a.Mint)
+ }
+ raw, status := jupGet(client, "https://lite-api.jup.ag/price/v3?ids="+strings.Join(ids, ","))
+ if raw == nil {
+ tspSourceCall.WithLabelValues("jup_price", status).Inc()
+ return nil
+ }
+ var flat map[string]struct {
+ USDPrice float64 `json:"usdPrice"`
+ USDPricePrescaled float64 `json:"usdPricePrescaled"`
+ }
+ if err := json.Unmarshal(raw, &flat); err != nil {
+ tspSourceCall.WithLabelValues("jup_price", "parse").Inc()
+ return nil
+ }
+ out := make(map[string]float64, len(flat))
+ for mint, v := range flat {
+ m := 1.0
+ if v.USDPricePrescaled > 0 && v.USDPrice > 0 {
+ m = v.USDPrice / v.USDPricePrescaled
+ }
+ out[mint] = m
+ }
+ tspSourceCall.WithLabelValues("jup_price", "ok").Inc()
+ return out
+}
+
+type quoteResp struct {
+ OutAmount string `json:"outAmount"`
+}
+
+func quoteOut(client *http.Client, inMint, outMint string, amount int64) (float64, bool) {
+ url := fmt.Sprintf(
+ "https://lite-api.jup.ag/swap/v1/quote?inputMint=%s&outputMint=%s&amount=%d&slippageBps=100",
+ inMint, outMint, amount,
+ )
+ raw, status := jupGet(client, url)
+ if raw == nil {
+ tspSourceCall.WithLabelValues("jup_quote", status).Inc()
+ return 0, false
+ }
+ var q quoteResp
+ if err := json.Unmarshal(raw, &q); err != nil || q.OutAmount == "" {
+ tspSourceCall.WithLabelValues("jup_quote", "parse").Inc()
+ return 0, false
+ }
+ n, err := strconv.ParseFloat(q.OutAmount, 64)
+ if err != nil || n <= 0 {
+ tspSourceCall.WithLabelValues("jup_quote", "decode").Inc()
+ return 0, false
+ }
+ tspSourceCall.WithLabelValues("jup_quote", "ok").Inc()
+ return n, true
+}
+
+// fetchOnchainPrices returns the executable mid price in USDC per UI
+// share for every symbol. Sequential with quoteGap spacing: ~26s for
+// the 12-symbol cohort, comfortably inside the 60s tick.
+func fetchOnchainPrices(client *http.Client, multipliers map[string]float64) map[string]float64 {
+ prices := make(map[string]float64, len(assets))
+ start := time.Now()
+ for _, a := range assets {
+ mult := 1.0
+ if m, ok := multipliers[a.Mint]; ok && m > 0 {
+ mult = m
+ }
+
+ // Sell leg: 1 raw share -> USDC.
+ sellOut, okSell := quoteOut(client, a.Mint, usdcMint, oneShareRaw)
+ time.Sleep(quoteGap)
+ sellPx := 0.0
+ if okSell {
+ sellPx = sellOut / 1e6 * mult
+ }
+
+ // Buy leg: spend the sell proceeds, see how many shares return.
+ buyPx := 0.0
+ if okSell {
+ gotRaw, okBuy := quoteOut(client, usdcMint, a.Mint, int64(sellOut))
+ if okBuy && gotRaw > 0 {
+ buyPx = (sellOut / 1e6) / (gotRaw / 1e8 / mult)
+ }
+ time.Sleep(quoteGap)
+ }
+
+ switch {
+ case sellPx > 0 && buyPx > 0:
+ prices[strings.ToLower(a.Symbol)] = (sellPx + buyPx) / 2
+ case sellPx > 0:
+ prices[strings.ToLower(a.Symbol)] = sellPx
+ }
+ }
+ tspSourceLatency.WithLabelValues("onchain").Set(float64(time.Since(start).Milliseconds()))
+ return prices
+}
diff --git a/harnesses/xstocks-peg/cmd/script/loghub.go b/harnesses/xstocks-peg/cmd/script/loghub.go
new file mode 100644
index 00000000..9dd74488
--- /dev/null
+++ b/harnesses/xstocks-peg/cmd/script/loghub.go
@@ -0,0 +1,114 @@
+package main
+
+import (
+ "bufio"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "strconv"
+ "sync"
+ "time"
+)
+
+// Auto-generated by the loghub inline pattern. Captures stdout/stderr into a
+// bounded ring buffer and exposes GET /logs?tail=N protected by X-Logs-Token
+// matching the LOGS_TOKEN env var.
+//
+// Keep in sync across miniapps (was previously the shared/loghub package; we
+// inline because Railway's per-harness Docker build context can't reach a
+// sibling shared module via go.mod replace).
+
+const logRingMax = 5000
+
+type logRing struct {
+ mu sync.Mutex
+ lines []string
+ max int
+}
+
+var globalLogRing = &logRing{max: logRingMax}
+
+func (b *logRing) push(line string) {
+ entry := time.Now().UTC().Format("2006-01-02T15:04:05.000Z") + " " + line
+ b.mu.Lock()
+ if len(b.lines) >= b.max {
+ b.lines = append(b.lines[1:], entry)
+ } else {
+ b.lines = append(b.lines, entry)
+ }
+ b.mu.Unlock()
+}
+
+func (b *logRing) snapshot(tail int) []string {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ if tail <= 0 || tail >= len(b.lines) {
+ out := make([]string, len(b.lines))
+ copy(out, b.lines)
+ return out
+ }
+ start := len(b.lines) - tail
+ out := make([]string, tail)
+ copy(out, b.lines[start:])
+ return out
+}
+
+var logSetupOnce sync.Once
+
+// installLogCapture replaces os.Stdout (and os.Stderr) with the write-end of a
+// pipe, then spawns a goroutine that fan-outs every line to the original
+// stdout AND the in-memory ring buffer. Call exactly once, very early in
+// main().
+func installLogCapture() { logSetupOnce.Do(doInstallLogCapture) }
+
+func doInstallLogCapture() {
+ originalStdout := os.Stdout
+ originalStderr := os.Stderr
+ r, w, err := os.Pipe()
+ if err != nil {
+ fmt.Fprintf(originalStdout, "[loghub] pipe failed: %v (/logs will be empty)\n", err)
+ return
+ }
+ os.Stdout = w
+ os.Stderr = w
+
+ go func() {
+ scanner := bufio.NewScanner(r)
+ buf := make([]byte, 0, 1024*1024)
+ scanner.Buffer(buf, 1024*1024)
+ for scanner.Scan() {
+ line := scanner.Text()
+ fmt.Fprintln(originalStdout, line)
+ globalLogRing.push(line)
+ }
+ _, _ = io.Copy(originalStdout, r)
+ _ = originalStderr
+ }()
+}
+
+// logsHandler returns an http.Handler for GET /logs?tail=N. Requires header
+// X-Logs-Token to match the LOGS_TOKEN env var. Returns 403 if env unset.
+func logsHandler() http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ expected := os.Getenv("LOGS_TOKEN")
+ if expected == "" {
+ http.Error(w, "logs disabled: LOGS_TOKEN unset", http.StatusForbidden)
+ return
+ }
+ if r.Header.Get("X-Logs-Token") != expected {
+ http.Error(w, "forbidden", http.StatusForbidden)
+ return
+ }
+ tail := 500
+ if t := r.URL.Query().Get("tail"); t != "" {
+ if n, err := strconv.Atoi(t); err == nil && n > 0 {
+ tail = n
+ }
+ }
+ w.Header().Set("Content-Type", "text/plain; charset=utf-8")
+ for _, l := range globalLogRing.snapshot(tail) {
+ fmt.Fprintln(w, l)
+ }
+ })
+}
diff --git a/harnesses/xstocks-peg/cmd/script/main.go b/harnesses/xstocks-peg/cmd/script/main.go
new file mode 100644
index 00000000..5cc18415
--- /dev/null
+++ b/harnesses/xstocks-peg/cmd/script/main.go
@@ -0,0 +1,83 @@
+package main
+
+import (
+ "fmt"
+ "math"
+ "net/http"
+ "strings"
+ "time"
+)
+
+func main() {
+ installLogCapture()
+ fmt.Println("=== xStocks Peg Harness ===")
+ fmt.Println("OpenChainBench — Backed xStocks on Solana vs Nasdaq reference.")
+ fmt.Printf("Cohort: %d assets | poll: %s | Jupiter lite-api\n", len(assets), pollInterval)
+ for _, a := range assets {
+ fmt.Printf(" - %-6s mint=%s\n", a.Symbol, a.Mint)
+ }
+
+ go func() {
+ if err := startMetricsServer(listenAddr()); err != nil {
+ fmt.Printf("[fatal] metrics server: %v\n", err)
+ }
+ }()
+
+ client := &http.Client{Timeout: httpTimeout}
+ var periods *tradingPeriods
+
+ tick := func() {
+ now := time.Now()
+ if periods == nil || now.Sub(periods.FetchedAt) > 30*time.Minute {
+ if tp := fetchTradingPeriods(client); tp != nil {
+ periods = tp
+ }
+ }
+ state := periods.state(now)
+ for _, s := range []string{"pre", "regular", "post", "closed", "unknown"} {
+ v := 0.0
+ if s == state {
+ v = 1.0
+ }
+ tspMarketState.WithLabelValues(s).Set(v)
+ }
+
+ refs := fetchReferencePrices(client)
+ multipliers := fetchMultipliers(client)
+ onchain := fetchOnchainPrices(client, multipliers)
+
+ for _, a := range assets {
+ sym := strings.ToLower(a.Symbol)
+ ref, hasRef := refs[sym]
+ pool, hasPool := onchain[sym]
+ if hasRef {
+ tspPriceReference.WithLabelValues(sym).Set(ref.Price)
+ if ref.AsOfSec > 0 {
+ tspRefAge.WithLabelValues(sym).Set(float64(now.Unix() - ref.AsOfSec))
+ }
+ }
+ if hasPool {
+ tspPriceOnchain.WithLabelValues(sym, issuerLabel).Set(pool)
+ }
+ if hasRef && hasPool && ref.Price > 0 {
+ dev := math.Abs(pool-ref.Price) / ref.Price * 10000
+ tspDeviationBps.WithLabelValues(sym, state, issuerLabel).Set(dev)
+ tspHealth.WithLabelValues(sym).Set(1)
+ flag := ""
+ if dev > logThresholdBps && state == "regular" {
+ flag = " <-- wide"
+ }
+ fmt.Printf("[%s][%s] pool=%.2f ref=%.2f dev=%.1fbps%s\n", sym, state, pool, ref.Price, dev, flag)
+ } else {
+ tspHealth.WithLabelValues(sym).Set(0)
+ }
+ }
+ }
+
+ tick()
+ t := time.NewTicker(pollInterval)
+ defer t.Stop()
+ for range t.C {
+ tick()
+ }
+}
diff --git a/harnesses/xstocks-peg/cmd/script/metrics.go b/harnesses/xstocks-peg/cmd/script/metrics.go
new file mode 100644
index 00000000..82072a68
--- /dev/null
+++ b/harnesses/xstocks-peg/cmd/script/metrics.go
@@ -0,0 +1,67 @@
+package main
+
+import (
+ "net/http"
+
+ "github.com/prometheus/client_golang/prometheus"
+ "github.com/prometheus/client_golang/prometheus/promauto"
+ "github.com/prometheus/client_golang/prometheus/promhttp"
+)
+
+var (
+ // Headline: absolute deviation of the onchain pool price from the
+ // Yahoo reference, in basis points, labeled with the market session
+ // the sample was taken in. The bench pins its ranking to
+ // market_state="regular"; the closed-state series is the weekend /
+ // overnight drift panel.
+ tspDeviationBps = promauto.NewGaugeVec(prometheus.GaugeOpts{
+ Name: "tsp_deviation_bps",
+ Help: "Absolute onchain vs reference price deviation per tokenized stock, in bps, labeled by market session state.",
+ }, []string{"asset", "market_state", "issuer"})
+
+ tspPriceOnchain = promauto.NewGaugeVec(prometheus.GaugeOpts{
+ Name: "tsp_price_onchain_usdg",
+ Help: "Venue spot price of the tokenized stock, in the venue quote stable.",
+ }, []string{"asset", "issuer"})
+
+ tspPriceReference = promauto.NewGaugeVec(prometheus.GaugeOpts{
+ Name: "tsp_price_reference_usd",
+ Help: "Reference equity price from Yahoo Finance (regularMarketPrice; last close when the market is closed).",
+ }, []string{"asset"})
+
+ tspRefAge = promauto.NewGaugeVec(prometheus.GaugeOpts{
+ Name: "tsp_reference_age_seconds",
+ Help: "Age of the reference price sample (now minus regularMarketTime). Large outside regular hours by design.",
+ }, []string{"asset"})
+
+ tspMarketState = promauto.NewGaugeVec(prometheus.GaugeOpts{
+ Name: "tsp_market_session",
+ Help: "1 for the currently active market session label, 0 otherwise.",
+ }, []string{"market_state"})
+
+ tspSourceLatency = promauto.NewGaugeVec(prometheus.GaugeOpts{
+ Name: "tsp_source_latency_milliseconds",
+ Help: "Round-trip latency of the last fetch per source.",
+ }, []string{"source"})
+
+ tspSourceCall = promauto.NewCounterVec(prometheus.CounterOpts{
+ Name: "tsp_source_call_total",
+ Help: "Fetch outcomes per source (onchain batch, yahoo spark, yahoo chart).",
+ }, []string{"source", "result"})
+
+ tspHealth = promauto.NewGaugeVec(prometheus.GaugeOpts{
+ Name: "tsp_health",
+ Help: "1 when the last tick produced a deviation sample for the asset, 0 otherwise.",
+ }, []string{"asset"})
+)
+
+func startMetricsServer(addr string) error {
+ mux := http.NewServeMux()
+ mux.Handle("/metrics", promhttp.Handler())
+ mux.Handle("/logs", logsHandler())
+ mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte("ok"))
+ })
+ return http.ListenAndServe(addr, mux)
+}
diff --git a/harnesses/xstocks-peg/cmd/script/reference.go b/harnesses/xstocks-peg/cmd/script/reference.go
new file mode 100644
index 00000000..3ebc5c3b
--- /dev/null
+++ b/harnesses/xstocks-peg/cmd/script/reference.go
@@ -0,0 +1,207 @@
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+ "time"
+)
+
+// Reference leg: Yahoo Finance, keyless. One spark batch call fetches
+// the latest price for every symbol; one chart call (AAPL as the
+// bellwether) fetches the day's exact session windows, which Yahoo
+// publishes holiday-aware so the harness never maintains an NYSE
+// calendar. Verified 2026-07-13 from the harness host: clean 200s with
+// a browser User-Agent (datacenter IP), 70-130ms.
+//
+// Market state is derived from currentTradingPeriod epochs:
+// pre / regular / post / closed. Deviation samples carry the state as
+// a label so the bench can pin its headline to regular hours and read
+// the weekend drift from the closed-state series.
+
+const (
+ sparkHost = "https://query1.finance.yahoo.com/v8/finance/spark"
+ chartHost = "https://query1.finance.yahoo.com/v8/finance/chart/AAPL"
+ yahooUA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36"
+)
+
+type refQuote struct {
+ Price float64
+ AsOfSec int64
+}
+
+type tradingPeriods struct {
+ PreStart, PreEnd int64
+ RegularStart, RegularEnd int64
+ PostStart, PostEnd int64
+ FetchedAt time.Time
+}
+
+// fetchReferencePrices returns the freshest Yahoo price per symbol
+// (lowercased) from one spark batch call. When the market is closed
+// the spark meta still carries regularMarketPrice = last close, which
+// is exactly the weekend reference we want.
+func fetchReferencePrices(client *http.Client) map[string]refQuote {
+ syms := make([]string, 0, len(assets))
+ for _, a := range assets {
+ syms = append(syms, a.Symbol)
+ }
+ url := sparkHost + "?symbols=" + strings.Join(syms, ",") + "&range=1d&interval=1m"
+ raw, status := yahooGet(client, url)
+ if raw == nil {
+ tspSourceCall.WithLabelValues("yahoo", status).Inc()
+ // Rotate to query2 once before giving up this tick.
+ raw, status = yahooGet(client, strings.Replace(url, "query1", "query2", 1))
+ if raw == nil {
+ tspSourceCall.WithLabelValues("yahoo", status).Inc()
+ return nil
+ }
+ }
+ tspSourceCall.WithLabelValues("yahoo", "ok").Inc()
+
+ // Spark response: {"spark":{"result":[{"symbol":"AAPL","response":[{"meta":{...}}]}]}}
+ // or the flatter {"AAPL":{...}} shape depending on edge; handle both.
+ prices := make(map[string]refQuote, len(syms))
+ var envel struct {
+ Spark struct {
+ Result []struct {
+ Symbol string `json:"symbol"`
+ Response []struct {
+ Meta struct {
+ RegularMarketPrice float64 `json:"regularMarketPrice"`
+ RegularMarketTime int64 `json:"regularMarketTime"`
+ } `json:"meta"`
+ } `json:"response"`
+ } `json:"result"`
+ } `json:"spark"`
+ }
+ if err := json.Unmarshal(raw, &envel); err == nil {
+ for _, r := range envel.Spark.Result {
+ if len(r.Response) == 0 || r.Response[0].Meta.RegularMarketPrice <= 0 {
+ continue
+ }
+ prices[strings.ToLower(r.Symbol)] = refQuote{
+ Price: r.Response[0].Meta.RegularMarketPrice,
+ AsOfSec: r.Response[0].Meta.RegularMarketTime,
+ }
+ }
+ }
+ if len(prices) == 0 {
+ // Flat spark shape (observed live 2026-07-13):
+ // {"MSFT":{"timestamp":[...],"close":[...],"previousClose":X},...}
+ // Price = last non-null close; previousClose is the fallback when
+ // the close array is empty (market closed all day).
+ var flat map[string]struct {
+ Timestamp []int64 `json:"timestamp"`
+ Close []*float64 `json:"close"`
+ PreviousClose *float64 `json:"previousClose"`
+ }
+ if err := json.Unmarshal(raw, &flat); err == nil {
+ for sym, v := range flat {
+ q := refQuote{}
+ for i := len(v.Close) - 1; i >= 0; i-- {
+ if v.Close[i] != nil && *v.Close[i] > 0 {
+ q.Price = *v.Close[i]
+ if i < len(v.Timestamp) {
+ q.AsOfSec = v.Timestamp[i]
+ }
+ break
+ }
+ }
+ if q.Price == 0 && v.PreviousClose != nil && *v.PreviousClose > 0 {
+ q.Price = *v.PreviousClose
+ }
+ if q.Price > 0 {
+ prices[strings.ToLower(sym)] = q
+ }
+ }
+ }
+ }
+ if len(prices) == 0 {
+ tspSourceCall.WithLabelValues("yahoo", "parse_empty").Inc()
+ head := string(raw)
+ if len(head) > 400 {
+ head = head[:400]
+ }
+ fmt.Printf("[yahoo] spark yielded no symbols; body head: %s\n", head)
+ return nil
+ }
+ return prices
+}
+
+// fetchTradingPeriods reads currentTradingPeriod from one chart call.
+// Refreshed every 30 minutes; between refreshes marketState() reuses
+// the cached windows.
+func fetchTradingPeriods(client *http.Client) *tradingPeriods {
+ raw, status := yahooGet(client, chartHost+"?range=1d&interval=5m")
+ if raw == nil {
+ tspSourceCall.WithLabelValues("yahoo_chart", status).Inc()
+ return nil
+ }
+ var envel struct {
+ Chart struct {
+ Result []struct {
+ Meta struct {
+ CurrentTradingPeriod struct {
+ Pre struct{ Start, End int64 } `json:"pre"`
+ Regular struct{ Start, End int64 } `json:"regular"`
+ Post struct{ Start, End int64 } `json:"post"`
+ } `json:"currentTradingPeriod"`
+ } `json:"meta"`
+ } `json:"result"`
+ } `json:"chart"`
+ }
+ if err := json.Unmarshal(raw, &envel); err != nil || len(envel.Chart.Result) == 0 {
+ tspSourceCall.WithLabelValues("yahoo_chart", "parse").Inc()
+ return nil
+ }
+ m := envel.Chart.Result[0].Meta.CurrentTradingPeriod
+ tspSourceCall.WithLabelValues("yahoo_chart", "ok").Inc()
+ return &tradingPeriods{
+ PreStart: m.Pre.Start, PreEnd: m.Pre.End,
+ RegularStart: m.Regular.Start, RegularEnd: m.Regular.End,
+ PostStart: m.Post.Start, PostEnd: m.Post.End,
+ FetchedAt: time.Now(),
+ }
+}
+
+func (tp *tradingPeriods) state(now time.Time) string {
+ if tp == nil {
+ return "unknown"
+ }
+ u := now.Unix()
+ switch {
+ case u >= tp.RegularStart && u < tp.RegularEnd:
+ return "regular"
+ case u >= tp.PreStart && u < tp.PreEnd:
+ return "pre"
+ case u >= tp.PostStart && u < tp.PostEnd:
+ return "post"
+ default:
+ return "closed"
+ }
+}
+
+func yahooGet(client *http.Client, url string) ([]byte, string) {
+ req, err := http.NewRequest("GET", url, nil)
+ if err != nil {
+ return nil, "request_build"
+ }
+ req.Header.Set("User-Agent", yahooUA)
+ req.Header.Set("Accept", "application/json")
+ resp, err := client.Do(req)
+ if err != nil {
+ return nil, "network"
+ }
+ defer resp.Body.Close()
+ raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<22))
+ if err != nil {
+ return nil, "read"
+ }
+ if resp.StatusCode != 200 {
+ return nil, fmt.Sprintf("http_%d", resp.StatusCode)
+ }
+ return raw, "ok"
+}
diff --git a/harnesses/xstocks-peg/go.mod b/harnesses/xstocks-peg/go.mod
new file mode 100644
index 00000000..815ee407
--- /dev/null
+++ b/harnesses/xstocks-peg/go.mod
@@ -0,0 +1,18 @@
+module xstocks-peg
+
+go 1.24.0
+
+require github.com/prometheus/client_golang v1.23.2
+
+require (
+ github.com/beorn7/perks v1.0.1 // indirect
+ github.com/cespare/xxhash/v2 v2.3.0 // indirect
+ github.com/kr/text v0.2.0 // indirect
+ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
+ github.com/prometheus/client_model v0.6.2 // indirect
+ github.com/prometheus/common v0.66.1 // indirect
+ github.com/prometheus/procfs v0.16.1 // indirect
+ go.yaml.in/yaml/v2 v2.4.2 // indirect
+ golang.org/x/sys v0.35.0 // indirect
+ google.golang.org/protobuf v1.36.8 // indirect
+)
diff --git a/harnesses/xstocks-peg/go.sum b/harnesses/xstocks-peg/go.sum
new file mode 100644
index 00000000..d6b8ca98
--- /dev/null
+++ b/harnesses/xstocks-peg/go.sum
@@ -0,0 +1,46 @@
+github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
+github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
+github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
+github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
+github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
+github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
+github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
+github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
+github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
+github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
+github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
+github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
+github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
+github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
+github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
+github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
+github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
+go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
+go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
+go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
+golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
+golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
+google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/src/lib/spec.ts b/src/lib/spec.ts
index eced326f..0621639a 100644
--- a/src/lib/spec.ts
+++ b/src/lib/spec.ts
@@ -273,7 +273,7 @@ const loadBenchmarkUnfilteredCached = unstable_cache(
// buckets (gap rendering fix). Cached v26 entries hold the old
// hole-compressed arrays whose indices no longer map onto the nominal
// step grid the chart back-computes timestamps from.
- ["bench-unfiltered-v30", process.env.VERCEL_ENV === "production" ? "prod" : "all"],
+ ["bench-unfiltered-v31", process.env.VERCEL_ENV === "production" ? "prod" : "all"],
{ revalidate: 300, tags: ["benchmarks"] },
);
@@ -428,7 +428,7 @@ const loadAllBenchmarksCached = unstable_cache(
// gated catalog to /products for 30+ min after the deploy).
// v29: bumped with bench-unfiltered-v26 (monad-rpc + megaeth-rpc ship).
// v30: bumped with bench-unfiltered-v27 (dense series with nulls).
- ["all-benchmarks-v33", process.env.VERCEL_ENV === "production" ? "prod" : "all"],
+ ["all-benchmarks-v34", process.env.VERCEL_ENV === "production" ? "prod" : "all"],
{ revalidate: 300, tags: ["benchmarks"] },
);
export const loadAllBenchmarks = cache(loadAllBenchmarksCached);
@@ -507,7 +507,7 @@ const loadBenchmarkFiltered = unstable_cache(
// v16: bumped with the bench 074 ship (lockstep rule, see all-benchmarks-v28).
// v17: bumped with the monad-rpc + megaeth-rpc ship (lockstep rule).
// v18: bumped with bench-unfiltered-v27 (dense series with nulls).
- ["bench-filters-v21", process.env.VERCEL_ENV === "production" ? "prod" : "all"],
+ ["bench-filters-v22", process.env.VERCEL_ENV === "production" ? "prod" : "all"],
{ revalidate: 300, tags: ["benchmarks"] }
);
From 8a881c4cfbe24e70ceae7ee3a685bfacb530c84f Mon Sep 17 00:00:00 2001
From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com>
Date: Tue, 14 Jul 2026 23:00:49 +0200
Subject: [PATCH 12/89] usdy: 3-retry on Orca RPC to fix 40% success rate
(#1168) (#1169)
publicnode.com burst-limits the VPS on getAccountInfo, dropping
~60% of samples. Two retries with 500ms backoff bring hit rate to
>95% without switching endpoints. Also add commitment=confirmed
to reduce slot-miss null returns.
Co-authored-by: Florent Tapponnier
---
harnesses/usdy-nav-basis/cmd/script/main.go | 16 +++++++++++++++-
1 file changed, 15 insertions(+), 1 deletion(-)
diff --git a/harnesses/usdy-nav-basis/cmd/script/main.go b/harnesses/usdy-nav-basis/cmd/script/main.go
index d3d3e39b..099fb63a 100644
--- a/harnesses/usdy-nav-basis/cmd/script/main.go
+++ b/harnesses/usdy-nav-basis/cmd/script/main.go
@@ -182,9 +182,23 @@ func fetchHermes(client *http.Client) (float64, float64) {
// fetchOrca decodes the whirlpool sqrtPrice (u128 LE at bytes 65..81);
// USDY and USDC are both 6 decimals so price = (sqrtPrice/2^64)^2.
+// publicnode.com burst-rate-limits us to ~40% getAccountInfo success on
+// the VPS; 2 retries with backoff take that to >95% without changing RPCs.
func fetchOrca(client *http.Client) float64 {
+ for attempt := 0; attempt < 3; attempt++ {
+ if attempt > 0 {
+ time.Sleep(time.Duration(attempt) * 500 * time.Millisecond)
+ }
+ if p := fetchOrcaOnce(client); p > 0 {
+ return p
+ }
+ }
+ return 0
+}
+
+func fetchOrcaOnce(client *http.Client) float64 {
body := []byte(fmt.Sprintf(
- `{"jsonrpc":"2.0","id":%d,"method":"getAccountInfo","params":["%s",{"encoding":"base64"}]}`,
+ `{"jsonrpc":"2.0","id":%d,"method":"getAccountInfo","params":["%s",{"encoding":"base64","commitment":"confirmed"}]}`,
time.Now().UnixNano(), orcaPool,
))
req, _ := http.NewRequest("POST", envDefault("USDY_SOLANA_RPC", "https://solana-rpc.publicnode.com"), bytes.NewReader(body))
From ee105cbafe2c4c8e9bcea04b59177f31d276cdc6 Mon Sep 17 00:00:00 2001
From: Florent Tapponnier
Date: Wed, 15 Jul 2026 01:23:39 +0200
Subject: [PATCH 13/89] hyperliquid frontend page: add ProviderLogo in the hero
next to the name
---
src/app/hyperliquid/[slug]/page.tsx | 10 +++++++---
1 file changed, 7 insertions(+), 3 deletions(-)
diff --git a/src/app/hyperliquid/[slug]/page.tsx b/src/app/hyperliquid/[slug]/page.tsx
index 7f0b419c..2c937d45 100644
--- a/src/app/hyperliquid/[slug]/page.tsx
+++ b/src/app/hyperliquid/[slug]/page.tsx
@@ -10,6 +10,7 @@ import {
} from "@/lib/hl-builder-stats";
import { Breadcrumb } from "@/components/breadcrumb";
import { HlBuilderDashboard } from "@/components/hl-builder-dashboard";
+import { ProviderLogo } from "@/components/provider-logo";
import { pageMetadata } from "@/lib/page-metadata";
import { safeJsonLd } from "@/lib/jsonld";
@@ -145,9 +146,12 @@ export default async function HlFrontendPage({
Hyperliquid frontend
-
- {frontend.name}
-
+
+
+
+ {frontend.name}
+
+
Date: Tue, 14 Jul 2026 15:15:22 +0200
Subject: [PATCH 14/89] Merge pull request #1166 from
ChainBench/feat/predexon-pm-provider
pm-api-latency: add Predexon as 6th provider
---
benchmarks/pm-api-latency.yml | 19 +++++++++++++++++--
1 file changed, 17 insertions(+), 2 deletions(-)
diff --git a/benchmarks/pm-api-latency.yml b/benchmarks/pm-api-latency.yml
index 394efef7..ba19bf0e 100644
--- a/benchmarks/pm-api-latency.yml
+++ b/benchmarks/pm-api-latency.yml
@@ -101,6 +101,9 @@ per_chain_explainer:
- slug: myriad
h2: Myriad API latency
body: "Myriad's API is a single region Heroku deployment in US East with no order book endpoint and no WebSocket. Its cohort worst {{p50:myriad}} p50 (24h, all regions) is mostly geography: from eu-west and Singapore every request crosses an ocean first. When checking whether the Myriad API is down, look at the us-east region figure, since a global latency rise that spares us-east is network weather, not a venue incident. Myriad also runs the tightest keyless request budget in the cohort, covered in the pm-rate-limits bench."
+ - slug: predexon
+ h2: Predexon API latency
+ body: "Predexon is a multi-venue aggregator that unifies Polymarket, Kalshi and Limitless behind one schema. Its API answers at {{p50:predexon}} p50 (24h, all regions), measured on the same market-price endpoint the harness probes on the native venues, so the direct comparison of a unified aggregator against three venue-native APIs is head to head. Predexon aggregates upstream data; a Predexon latency higher than the sum of its three underliers is the cost of unification. Lower is the value of having one contract instead of three."
source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/pm-rate-limits
@@ -128,14 +131,14 @@ metric_panels:
- id: uptime_24h
label: Uptime 24h
description: "Share of probe cycles that succeeded over the last 24 hours, averaged across the three probe regions. 100 percent means every 5 second probe of the venue's API came back healthy."
- metric: 100 * avg(avg_over_time(pmapi_health{source="direct",venue=~"polymarket|kalshi|limitless|manifold|myriad"}[24h]))
+ metric: 100 * avg(avg_over_time(pmapi_health{source="direct",venue=~"polymarket|kalshi|limitless|manifold|myriad"}[24h])) or 100 * avg(avg_over_time(pmapi_health{source="predexon"}[24h]))
label_key: venue
unit: pct
higher_is_better: true
- id: uptime_7d
label: Uptime 7d
description: "Same health gauge averaged over 7 days. Short outages that vanish from the 24h figure stay visible here for a week."
- metric: 100 * avg(avg_over_time(pmapi_health{source="direct",venue=~"polymarket|kalshi|limitless|manifold|myriad"}[7d]))
+ metric: 100 * avg(avg_over_time(pmapi_health{source="direct",venue=~"polymarket|kalshi|limitless|manifold|myriad"}[7d])) or 100 * avg(avg_over_time(pmapi_health{source="predexon"}[7d]))
label_key: venue
unit: pct
higher_is_better: true
@@ -228,3 +231,15 @@ providers:
success: clamp_max(sum(rate(pmapi_requests_total{venue="myriad",source="direct",class="price",conn="warm",outcome="ok"}[24h])) / sum(rate(pmapi_requests_total{venue="myriad",source="direct",class="price",conn="warm",outcome!="probe_invalid"}[24h])), 1)
sample_size: sum(increase(pmapi_requests_total{venue="myriad",source="direct",class="price",conn="warm",outcome!="probe_invalid"}[24h]))
series: 1000 * histogram_quantile(0.50, sum(rate(pmapi_request_duration_seconds_bucket{venue="myriad",source="direct",class="price",conn="warm",cache!="hit"}[1h])) by (le))
+ - slug: predexon
+ name: Predexon
+ tag: Multi-venue aggregator API (Polymarket, Kalshi, Limitless), unified schema
+ formula: "p50 of warm, non CDN cached round trips against the Predexon market-price endpoints for the three covered venues, keyless-equivalent (free-tier key), successful requests only, 24h window."
+ queries:
+ p50: 1000 * histogram_quantile(0.50, sum(rate(pmapi_request_duration_seconds_bucket{source="predexon",class="price",conn="warm",cache!="hit"}[24h])) by (le))
+ p90: 1000 * histogram_quantile(0.90, sum(rate(pmapi_request_duration_seconds_bucket{source="predexon",class="price",conn="warm",cache!="hit"}[24h])) by (le))
+ p99: 1000 * histogram_quantile(0.99, sum(rate(pmapi_request_duration_seconds_bucket{source="predexon",class="price",conn="warm",cache!="hit"}[24h])) by (le))
+ mean: 1000 * sum(rate(pmapi_request_duration_seconds_sum{source="predexon",class="price",conn="warm",cache!="hit"}[24h])) / sum(rate(pmapi_request_duration_seconds_count{source="predexon",class="price",conn="warm",cache!="hit"}[24h]))
+ success: clamp_max(sum(rate(pmapi_requests_total{source="predexon",class="price",conn="warm",outcome="ok"}[24h])) / sum(rate(pmapi_requests_total{source="predexon",class="price",conn="warm",outcome!="probe_invalid"}[24h])), 1)
+ sample_size: sum(increase(pmapi_requests_total{source="predexon",class="price",conn="warm",outcome!="probe_invalid"}[24h]))
+ series: 1000 * histogram_quantile(0.50, sum(rate(pmapi_request_duration_seconds_bucket{source="predexon",class="price",conn="warm",cache!="hit"}[1h])) by (le))
From abf6005718ec71f05d8a126a612fa3540421211f Mon Sep 17 00:00:00 2001
From: Florent Tapponnier
Date: Wed, 15 Jul 2026 01:54:46 +0200
Subject: [PATCH 15/89] bump lockstep cache keys for Predexon ship
---
src/lib/spec.ts | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/src/lib/spec.ts b/src/lib/spec.ts
index 0621639a..8ad03e04 100644
--- a/src/lib/spec.ts
+++ b/src/lib/spec.ts
@@ -273,7 +273,7 @@ const loadBenchmarkUnfilteredCached = unstable_cache(
// buckets (gap rendering fix). Cached v26 entries hold the old
// hole-compressed arrays whose indices no longer map onto the nominal
// step grid the chart back-computes timestamps from.
- ["bench-unfiltered-v31", process.env.VERCEL_ENV === "production" ? "prod" : "all"],
+ ["bench-unfiltered-v32", process.env.VERCEL_ENV === "production" ? "prod" : "all"],
{ revalidate: 300, tags: ["benchmarks"] },
);
@@ -428,7 +428,7 @@ const loadAllBenchmarksCached = unstable_cache(
// gated catalog to /products for 30+ min after the deploy).
// v29: bumped with bench-unfiltered-v26 (monad-rpc + megaeth-rpc ship).
// v30: bumped with bench-unfiltered-v27 (dense series with nulls).
- ["all-benchmarks-v34", process.env.VERCEL_ENV === "production" ? "prod" : "all"],
+ ["all-benchmarks-v35", process.env.VERCEL_ENV === "production" ? "prod" : "all"],
{ revalidate: 300, tags: ["benchmarks"] },
);
export const loadAllBenchmarks = cache(loadAllBenchmarksCached);
@@ -507,7 +507,7 @@ const loadBenchmarkFiltered = unstable_cache(
// v16: bumped with the bench 074 ship (lockstep rule, see all-benchmarks-v28).
// v17: bumped with the monad-rpc + megaeth-rpc ship (lockstep rule).
// v18: bumped with bench-unfiltered-v27 (dense series with nulls).
- ["bench-filters-v22", process.env.VERCEL_ENV === "production" ? "prod" : "all"],
+ ["bench-filters-v23", process.env.VERCEL_ENV === "production" ? "prod" : "all"],
{ revalidate: 300, tags: ["benchmarks"] }
);
From 20780452ac59ca402ee5fceb28659b5f5e2ffb43 Mon Sep 17 00:00:00 2001
From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com>
Date: Wed, 15 Jul 2026 19:14:34 +0200
Subject: [PATCH 16/89] add bench 086 polkadot-rpc: first Substrate chain in
the RPC cluster (#1183)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Cherry-picked from dev (#1179), adapted to main's rpc-capabilities
shape (no bench 083 consensus/hash-quorum machinery on main yet).
Extends rpc-capabilities to Polkadot, the first non-EVM, non-Solana
chain in the cluster. Substrate JSON-RPC via chain_getHeader (returns
hex-encoded block number matching EVM's eth_getBlockByNumber shape),
staleness by relay-block gap (40 blocks, ~4 min at Polkadot's ~6 s
cadence).
Harness changes:
- Chain.Kind gains "polkadot" alongside "solana" and default (EVM).
- New callSubstrateHeader: chain_getHeader POST, parses hex block
number, same latency measurement + result classification as EVM.
- probeOne switches on Kind (was if solana / else). polkadot uses
polkadotStaleBlockGap = 40.
- Archive loop skipped for polkadot alongside solana: no eth_getBalance-
by-depth equivalent on Substrate public endpoints.
Providers at launch (3, live-verified 2026-07-15 with 4 consecutive
chain_getHeader probes each, all returning block 0x1ea223d):
- polkadot-official (Parity, rpc.polkadot.io)
- onfinality (public gateway, Web3 Foundation grantee)
- publicnode (Allnodes)
Excluded by the launch audit:
- 1RPC: filters chain_getHeader as "Not Allowed" on privacy relay
- Dwellir: 503 during audit sweep, revisit
- Ankr / Chainstack: paid tier / key-gated on Substrate
- RadiumBlock / Grove / Thirdweb: referral header / invalid chain / key-gated
Spec (benchmarks/polkadot-rpc.yml, bench № 086) copied from #1179
unchanged: 3-provider cohort, same schema as moonbeam-rpc etc.
Post-merge:
- Railway rpc-capabilities-{us,eu,sgp} auto-rebuild on this main push
and start emitting rpc_latency_milliseconds{chain="polkadot"}
metrics.
- The bench 083 consensus path (dev-only for now) is not wired here;
when dev syncs to main naturally, the polkadot switch case in
probeOne already excludes consensus.observe correctly on that path.
Co-authored-by: Florent Tapponnier
---
benchmarks/polkadot-rpc.yml | 161 ++++++++++++++++++
.../rpc-capabilities/cmd/script/archive.go | 7 +-
.../rpc-capabilities/cmd/script/config.go | 33 +++-
.../rpc-capabilities/cmd/script/probe.go | 85 ++++++++-
4 files changed, 277 insertions(+), 9 deletions(-)
create mode 100644 benchmarks/polkadot-rpc.yml
diff --git a/benchmarks/polkadot-rpc.yml b/benchmarks/polkadot-rpc.yml
new file mode 100644
index 00000000..9e8b0ffe
--- /dev/null
+++ b/benchmarks/polkadot-rpc.yml
@@ -0,0 +1,161 @@
+# OpenChainBench. Bench № 086
+
+slug: polkadot-rpc
+number: "086"
+title: Fastest free Polkadot RPC, live no-key endpoint latency
+seo_title: "Fastest free Polkadot RPC 2026"
+seo_description: "{{best_name}} leads free Polkadot RPC at {{best_p50}} (chain_getHeader p50, 24h). 3 no-key providers measured every 60s from 3 regions."
+subtitle: HTTP round-trip latency for chain_getHeader against every free, no-key public Polkadot relay-chain endpoint, audited every 60 seconds from 3 regions.
+
+category: RPCs
+status: live
+metric: RPC latency
+unit: ms
+higher_is_better: false
+
+seo_intro: |
+ Polkadot is the first non-EVM, non-Solana chain in the RPC latency
+ cluster. Substrate-based relay chain, JSON-RPC 2.0 wire but a
+ different method set: the probe is `chain_getHeader` (returns the
+ latest header with a hex-encoded block number) rather than
+ `eth_getBlockByNumber`. Everything else on this page is the same
+ bench pattern used on Ethereum, Base, Solana and the 20+ other
+ chains: identical POST every 60 seconds from three regions, latency
+ measured client-side at millisecond precision, per-region breakdowns
+ first class. Provider cohort at launch: Parity's official
+ `rpc.polkadot.io`, OnFinality's public gateway, and PublicNode
+ (Allnodes). Dwellir + 1RPC audited alongside and excluded (Dwellir
+ 503 during the launch sweep; 1RPC filters `chain_getHeader` on its
+ privacy relay); both will be revisited when a stable no-key path
+ reappears.
+
+abstract: |
+ Per-chain member of the RPC latency cluster, extended for Substrate.
+ We measure the round-trip latency of a single, identical JSON-RPC
+ call (`chain_getHeader`) against every no-key public Polkadot
+ relay-chain endpoint that sustains continuous probing, 3 providers
+ at launch, every 60 seconds, from us-east, eu-west and Singapore. The harness
+ classifies every response (ok / http_err / jsonrpc_err / stale /
+ timeout) with a relay-chain-scaled staleness gap (40 blocks ≈ 4
+ minutes at Polkadot's ~6 s block time), so the leaderboard rewards
+ sustained, honest availability rather than a fast error message.
+ The cross-chain view lives on the parent `rpc-capabilities`
+ benchmark; this page is the Polkadot-scoped answer with per-region
+ breakdowns as a first-class dimension.
+
+methodology:
+ - "Cadence: every 60 seconds per provider, from each of 3 probe regions (us-east Virginia, eu-west Amsterdam, sgp Singapore). Headline p50/p90/p99 aggregate across all 3 regions via Prometheus `avg(quantile_over_time(...))`; per-region breakdowns are first-class on this page via the region tabs."
+ - "Payload: `{\"jsonrpc\":\"2.0\",\"id\":,\"method\":\"chain_getHeader\",\"params\":[]}`. Plain HTTP POST, identical for every endpoint, no API key in any request. Non-cacheable by design: the latest-header fetch with a rotating id defeats edge caches that would answer a bare head query without touching a node."
+ - "Latency: client-side round-trip delta in milliseconds, exposed as both a gauge and a histogram (buckets 50 ms → 10 s), so percentiles are computed via Prometheus `quantile_over_time` over the last 24 hours."
+ - "Call-result classification: `ok` (HTTP 200 + non-empty result with a parsable hex block number), `http_err`, `jsonrpc_err` (HTTP 200 carrying an error body), `stale` (more than 40 relay blocks behind the cross-provider tip, roughly 4 minutes at Polkadot's ~6 s block time), `timeout`. Latency without reliability is a misleading ranking signal."
+ - "No archive-depth loop on this chain. Polkadot's state model is accessed via `state_getStorage` keyed by a Blake2-hashed storage key rather than the EVM `eth_getBalance(address, height)` pattern, so the archive probe used on the EVM cluster is not applied here."
+ - "No cross-provider hash quorum on this chain. `chain_getHeader` returns the parent-block hash rather than the current-block hash (the current hash is derived by Blake2 over the SCALE-encoded header). Bench 083's height-hash quorum check is therefore skipped for Polkadot; the same reliability signal is still captured via `ok` / `stale` classification against the rolling cross-provider tip."
+ - "This page is part of the per-chain RPC cluster derived from the cross-chain [rpc-capabilities](https://openchainbench.com/benchmarks/rpc-capabilities) benchmark; the identical harness, cadence and exclusion rules apply on every chain."
+ - "Chain scope: every query on this page is pinned to `chain=\"polkadot\"`. Provider coverage at launch: 3 no-key endpoints (Parity official, OnFinality public gateway, PublicNode). Excluded by the launch audit: 1RPC filters `chain_getHeader` as \"Not Allowed\" on its privacy relay; Dwellir returned 503 during the audit sweep; Ankr paid Polkadot tier only; Chainstack key-gated; RadiumBlock requires a referral header; Grove and Thirdweb return invalid-chain or key-gated."
+
+findings:
+ - "{{best_name}} currently leads free Polkadot RPC at {{best_p50}} (`chain_getHeader` p50, 24h) across 3 measured providers."
+ - "{{name:onfinality}} is a Web3 Foundation grantee that serves a large share of Polkadot ecosystem infra; the bench measures its public gateway under the same probe used for the multi-chain gateways, so the leaderboard is directly comparable to what a chain-agnostic integrator sees."
+ - "{{name:publicnode}} extends its universal multi-chain footprint onto Substrate without a keyed path; the reliability column is what to read alongside p50 here since Substrate JSON-RPC has different failure modes than EVM (SCALE decode timeouts, sudden peer drops during finality gaps)."
+
+faq:
+ - q: "What is the fastest free Polkadot RPC right now?"
+ a: "{{best_name}} currently leads at {{best_p50}} (`chain_getHeader` p50 over the last 24h), measured against 3 no-key providers probed every 60 seconds from us-east, eu-west and Singapore. The leaderboard re-sorts continuously against fresh Prometheus samples, so the answer on this page is the answer right now, not a quarterly snapshot. Use the region tabs to see the leader from the origin closest to your deployment."
+ - q: "Which Polkadot RPCs work without an API key?"
+ a: "Three endpoints sustain continuous keyless probing at launch: Parity's official `rpc.polkadot.io`, OnFinality's public gateway and PublicNode (operated by Allnodes). Every listed endpoint was live-verified with four consecutive `chain_getHeader` probes returning a parsable hex block number before inclusion. Excluded by the audit: 1RPC filters `chain_getHeader` as \"Not Allowed\" on its privacy relay, Dwellir returned 503 Service Unavailable during the sweep (will be revisited once stable), Ankr requires a paid Polkadot key, Chainstack is key-gated on Substrate, and RadiumBlock rejects requests without a referral header."
+ - q: "Does the fastest Polkadot RPC change by region?"
+ a: "Frequently, and often more sharply than EVM chains. Polkadot's public infra is heavily weighted toward Web3 Foundation grantees whose primary data centers sit in Europe; a gateway that wins from Amsterdam can lose from Singapore by multiples. The region tabs at the top of the page re-scope every number on the page to a single origin; pick the one closest to where your requests actually originate."
+ - q: "How is Polkadot RPC latency measured here, technically?"
+ a: "One identical Substrate JSON-RPC POST (`chain_getHeader`) every 60 seconds against each provider from each of 3 regions, using the same plain HTTP client the EVM cluster uses. The response is a header object containing a hex-encoded relay-block number and the parent-block hash; the harness parses the number (`strconv.ParseUint(strings.TrimPrefix(hdr.Number, \"0x\"), 16, 64)`, same rule as EVM). Wall-clock round-trip is recorded at millisecond precision; p50/p90/p99 are computed via Prometheus `quantile_over_time` over 24 hours. Responses are classified (`ok` / `http_err` / `jsonrpc_err` / `stale` / `timeout`) so an endpoint stuck on an old head or returning errors behind HTTP 200 is never ranked as fastest."
+ - q: "Why is the probe `chain_getHeader` and not `system_health`?"
+ a: "Two reasons. First, `chain_getHeader` returns the head block number, which lets the harness plug into the cluster's cross-provider tip machinery for staleness classification without adding a chain-specific code path. Second, `system_health` returns a small object about peer count and sync state that any polite CDN can synthesize without touching the underlying node, defeating the point of a latency probe on the RPC path. `chain_getHeader` forces the endpoint to actually read the head from the runtime, which is what integrators care about."
+ - q: "Why is there no archive-depth or hash-quorum column for Polkadot?"
+ a: "Polkadot's state model accesses historical state via `state_getStorage` keyed by a Blake2-hashed storage key, which has no chain-agnostic depth analog for the EVM `eth_getBalance(address, height)` probe the cluster uses on other chains. Similarly, `chain_getHeader` returns the parent-block hash (not the current-block hash: the current hash is derived by Blake2 over the SCALE-encoded header), so the cross-provider height-hash quorum check from bench 083 cannot be run against Polkadot without adding a Substrate-native encoder to the harness. Both columns are omitted honestly rather than filled with a proxy that would misrepresent the chain."
+
+source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/rpc-capabilities
+
+prometheus:
+ window: 24h
+ freshness_metric: rpc_latency_milliseconds
+
+# Per-cell (region) ranking matrix for scoped badge claims. Chain is
+# fixed for the whole bench, so cells key on region alone.
+rank_matrix_query: avg by (provider, region) (ocb:rpc_latency_milliseconds:p50_24h{chain="polkadot"})
+
+# Region is the only dimension: chain is baked into every query.
+dimensions:
+ region:
+ - { value: all, label: All regions }
+ - { value: us-east, label: US-East }
+ - { value: eu-west, label: EU-West }
+ - { value: sgp, label: Singapore }
+
+providers:
+ - slug: polkadot-official
+ name: Parity
+ tag: Parity's official Polkadot relay endpoint
+ formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `chain_getHeader` POST sent every 60s from 3 regions (us-east + eu-west + sgp) to Parity's official Polkadot relay endpoint."
+ queries:
+ p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="polkadot-official", chain="polkadot"})
+ p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="polkadot-official", chain="polkadot"})
+ p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="polkadot-official", chain="polkadot"})
+ mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="polkadot-official", chain="polkadot"})
+ success: sum(ocb:rpc_call:ok_rate_24h{provider="polkadot-official", chain="polkadot"}) / sum(ocb:rpc_call:rate_24h{provider="polkadot-official", chain="polkadot"})
+ sample_size: sum(ocb:rpc_call:increase_24h{provider="polkadot-official", chain="polkadot"})
+ series: avg(avg_over_time(rpc_latency_milliseconds{provider="polkadot-official", chain="polkadot"}[1h]))
+ regions:
+ - region: us-east
+ p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="polkadot-official", chain="polkadot", region="us-east"})
+ series: avg_over_time(rpc_latency_milliseconds{provider="polkadot-official", chain="polkadot", region="us-east"}[1h])
+ - region: eu-west
+ p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="polkadot-official", chain="polkadot", region="eu-west"})
+ series: avg_over_time(rpc_latency_milliseconds{provider="polkadot-official", chain="polkadot", region="eu-west"}[1h])
+ - region: ap-southeast
+ p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="polkadot-official", chain="polkadot", region="sgp"})
+ series: avg_over_time(rpc_latency_milliseconds{provider="polkadot-official", chain="polkadot", region="sgp"}[1h])
+
+ - slug: onfinality
+ name: OnFinality
+ tag: Substrate-native public gateway, Web3 Foundation grantee
+ formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `chain_getHeader` POST sent every 60s from 3 regions (us-east + eu-west + sgp) to OnFinality's public Polkadot endpoint."
+ queries:
+ p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="onfinality", chain="polkadot"})
+ p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="onfinality", chain="polkadot"})
+ p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="onfinality", chain="polkadot"})
+ mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="onfinality", chain="polkadot"})
+ success: sum(ocb:rpc_call:ok_rate_24h{provider="onfinality", chain="polkadot"}) / sum(ocb:rpc_call:rate_24h{provider="onfinality", chain="polkadot"})
+ sample_size: sum(ocb:rpc_call:increase_24h{provider="onfinality", chain="polkadot"})
+ series: avg(avg_over_time(rpc_latency_milliseconds{provider="onfinality", chain="polkadot"}[1h]))
+ regions:
+ - region: us-east
+ p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="onfinality", chain="polkadot", region="us-east"})
+ series: avg_over_time(rpc_latency_milliseconds{provider="onfinality", chain="polkadot", region="us-east"}[1h])
+ - region: eu-west
+ p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="onfinality", chain="polkadot", region="eu-west"})
+ series: avg_over_time(rpc_latency_milliseconds{provider="onfinality", chain="polkadot", region="eu-west"}[1h])
+ - region: ap-southeast
+ p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="onfinality", chain="polkadot", region="sgp"})
+ series: avg_over_time(rpc_latency_milliseconds{provider="onfinality", chain="polkadot", region="sgp"}[1h])
+
+ - slug: publicnode
+ name: PublicNode
+ tag: Allnodes-operated, 70+ chains, includes Substrate
+ formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `chain_getHeader` POST sent every 60s from 3 regions (us-east + eu-west + sgp) to PublicNode's no-key Polkadot endpoint."
+ queries:
+ p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="polkadot"})
+ p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="publicnode", chain="polkadot"})
+ p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="publicnode", chain="polkadot"})
+ mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="publicnode", chain="polkadot"})
+ success: sum(ocb:rpc_call:ok_rate_24h{provider="publicnode", chain="polkadot"}) / sum(ocb:rpc_call:rate_24h{provider="publicnode", chain="polkadot"})
+ sample_size: sum(ocb:rpc_call:increase_24h{provider="publicnode", chain="polkadot"})
+ series: avg(avg_over_time(rpc_latency_milliseconds{provider="publicnode", chain="polkadot"}[1h]))
+ regions:
+ - region: us-east
+ p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="polkadot", region="us-east"})
+ series: avg_over_time(rpc_latency_milliseconds{provider="publicnode", chain="polkadot", region="us-east"}[1h])
+ - region: eu-west
+ p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="polkadot", region="eu-west"})
+ series: avg_over_time(rpc_latency_milliseconds{provider="publicnode", chain="polkadot", region="eu-west"}[1h])
+ - region: ap-southeast
+ p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="polkadot", region="sgp"})
+ series: avg_over_time(rpc_latency_milliseconds{provider="publicnode", chain="polkadot", region="sgp"}[1h])
diff --git a/harnesses/rpc-capabilities/cmd/script/archive.go b/harnesses/rpc-capabilities/cmd/script/archive.go
index 8362f56b..d7889264 100644
--- a/harnesses/rpc-capabilities/cmd/script/archive.go
+++ b/harnesses/rpc-capabilities/cmd/script/archive.go
@@ -43,9 +43,10 @@ var depthBuckets = []uint64{300, 7200, 216000, 1296000, 5000000}
func StartArchiveLoop(ctx context.Context) {
for _, c := range chains() {
c := c
- if c.Kind == "solana" {
- // eth_getBalance at historical heights has no Solana
- // equivalent on public endpoints; skip the archive loop.
+ if c.Kind == "solana" || c.Kind == "polkadot" {
+ // eth_getBalance at historical heights has no Solana or
+ // Substrate equivalent on public endpoints; skip the
+ // archive loop.
continue
}
for _, p := range c.Providers {
diff --git a/harnesses/rpc-capabilities/cmd/script/config.go b/harnesses/rpc-capabilities/cmd/script/config.go
index c759b0b5..740d3537 100644
--- a/harnesses/rpc-capabilities/cmd/script/config.go
+++ b/harnesses/rpc-capabilities/cmd/script/config.go
@@ -29,9 +29,14 @@ type Chain struct {
Slug string
Name string
Providers []Provider
- // Kind selects the probe path: "" (EVM, eth_getBlockByNumber) or
- // "solana" (getSlot at processed commitment, slot-based staleness,
- // no archive-depth loop).
+ // Kind selects the probe path:
+ // "" or "evm": eth_getBlockByNumber("latest", false), block-based
+ // staleness, archive-depth loop.
+ // "solana": getSlot at processed commitment, slot-based staleness,
+ // no archive-depth loop.
+ // "polkadot": chain_getHeader against Substrate JSON-RPC,
+ // block-based staleness (Polkadot relay produces one block every
+ // ~6 s, override via polkadotStaleBlockGap), no archive-depth loop.
Kind string
}
@@ -59,6 +64,28 @@ type Chain struct {
// (3 providers), Zora / Abstract / HyperEVM (≤2 keyless providers).
func chains() []Chain {
return []Chain{
+ // ─── Polkadot relay chain — first non-EVM, non-Solana chain
+ // added to the cohort. Substrate JSON-RPC via chain_getHeader
+ // (returns hex block number, staleness by relay-block gap).
+ // Providers verified keyless with 4 consecutive probes on
+ // 2026-07-15: rpc.polkadot.io (Parity official), OnFinality
+ // public gateway, and PublicNode (Allnodes). Excluded by that
+ // sweep: 1RPC (chain_getHeader filtered as "Not Allowed" on
+ // their privacy relay), Dwellir (503 Service Unavailable
+ // during audit, retry when their gateway stabilises), Ankr
+ // (paid Polkadot tier only), Chainstack (key-gated),
+ // RadiumBlock (empty response without referral header), Grove/
+ // Thirdweb (invalid-chain or key-gated).
+ {
+ Slug: "polkadot",
+ Name: "Polkadot",
+ Kind: "polkadot",
+ Providers: []Provider{
+ {Slug: "polkadot-official", Name: "Parity", URL: envDefault("RPC_URL_POLKADOT_OFFICIAL", "https://rpc.polkadot.io")},
+ {Slug: "onfinality", Name: "OnFinality", URL: envDefault("RPC_URL_POLKADOT_ONFINALITY", "https://polkadot.api.onfinality.io/public")},
+ {Slug: "publicnode", Name: "PublicNode", URL: envDefault("RPC_URL_POLKADOT_PUBLICNODE", "https://polkadot-rpc.publicnode.com")},
+ },
+ },
// ─── Solana mainnet — added 2026-07-12, all 5 endpoints keyless
// and live-verified (getSlot + getLatestBlockhash + getVersion,
// mutually consistent advancing slots). Excluded by that sweep:
diff --git a/harnesses/rpc-capabilities/cmd/script/probe.go b/harnesses/rpc-capabilities/cmd/script/probe.go
index d19f1925..96859460 100644
--- a/harnesses/rpc-capabilities/cmd/script/probe.go
+++ b/harnesses/rpc-capabilities/cmd/script/probe.go
@@ -30,6 +30,11 @@ const (
// ~2 minutes behind the cross-provider tip - the same order of
// tolerance the EVM gap gives a 12s-block chain.
solanaStaleSlotGap uint64 = 300
+ // polkadotStaleBlockGap: Polkadot relay produces one block every
+ // ~6 s, so 40 blocks ≈ 4 min. Same order of tolerance the EVM
+ // gap gives a 12s-block chain, scaled for the faster relay
+ // cadence.
+ polkadotStaleBlockGap uint64 = 40
)
// chainTips tracks the highest block seen for each chain across all
@@ -183,9 +188,12 @@ func probeOne(ctx context.Context, c Chain, p Provider) {
var result string
var latency float64
var err error
- if c.Kind == "solana" {
+ switch c.Kind {
+ case "solana":
block, result, latency, err = callLatestSlot(probeCtx, p.URL)
- } else {
+ case "polkadot":
+ block, result, latency, err = callSubstrateHeader(probeCtx, p.URL)
+ default:
block, result, latency, err = callLatestBlock(probeCtx, p.URL)
}
@@ -193,8 +201,11 @@ func probeOne(ctx context.Context, c Chain, p Provider) {
tips.update(c.Slug, block)
tip := tips.get(c.Slug)
gap := staleBlockGap
- if c.Kind == "solana" {
+ switch c.Kind {
+ case "solana":
gap = solanaStaleSlotGap
+ case "polkadot":
+ gap = polkadotStaleBlockGap
}
if tip > 0 && block+gap < tip {
result = "stale"
@@ -296,3 +307,71 @@ func callLatestSlot(ctx context.Context, url string) (slot uint64, result string
}
return n, "ok", latencyMs, nil
}
+
+// substrateHeader is the shape of the `chain_getHeader` result on
+// Polkadot and every Substrate-based relay chain. The block number is
+// hex-encoded (`0x` prefix) matching the EVM header convention, so the
+// parse path reuses the same strconv rule.
+type substrateHeader struct {
+ Number string `json:"number"`
+}
+
+// callSubstrateHeader is the Polkadot probe path: chain_getHeader with a
+// rotating request id (same anti-cache rule as the EVM header fetch).
+// Returns the relay-chain block height so the caller can plug into the
+// same tips machinery the EVM path uses; staleness classification in
+// probeOne uses polkadotStaleBlockGap.
+func callSubstrateHeader(ctx context.Context, url string) (block uint64, result string, latencyMs float64, err error) {
+ body := []byte(fmt.Sprintf(
+ `{"jsonrpc":"2.0","method":"chain_getHeader","params":[],"id":%d}`,
+ time.Now().UnixNano(),
+ ))
+ req, _ := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("User-Agent", "OpenChainBench/1.0 (+https://openchainbench.com)")
+ client := &http.Client{Timeout: probeTimeout}
+
+ start := time.Now()
+ resp, err := client.Do(req)
+ latencyMs = float64(time.Since(start).Milliseconds())
+
+ if err != nil {
+ if ctx.Err() != nil || strings.Contains(err.Error(), "deadline exceeded") || strings.Contains(err.Error(), "Timeout") {
+ return 0, "timeout", latencyMs, err
+ }
+ return 0, "http_err", latencyMs, err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != 200 {
+ _, _ = io.Copy(io.Discard, resp.Body)
+ return 0, "http_err", latencyMs, fmt.Errorf("status %d", resp.StatusCode)
+ }
+
+ raw, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return 0, "http_err", latencyMs, err
+ }
+ var r rpcBlockEnvelope
+ if err := json.Unmarshal(raw, &r); err != nil {
+ return 0, "http_err", latencyMs, err
+ }
+ if r.Error != nil {
+ return 0, "jsonrpc_err", latencyMs, fmt.Errorf("rpc -%d: %s", r.Error.Code, r.Error.Message)
+ }
+ if len(r.Result) == 0 || string(r.Result) == "null" {
+ return 0, "jsonrpc_err", latencyMs, fmt.Errorf("empty result")
+ }
+ var hdr substrateHeader
+ if err := json.Unmarshal(r.Result, &hdr); err != nil {
+ return 0, "jsonrpc_err", latencyMs, err
+ }
+ if hdr.Number == "" {
+ return 0, "jsonrpc_err", latencyMs, fmt.Errorf("substrate header missing number")
+ }
+ n, err := strconv.ParseUint(strings.TrimPrefix(hdr.Number, "0x"), 16, 64)
+ if err != nil {
+ return 0, "jsonrpc_err", latencyMs, err
+ }
+ return n, "ok", latencyMs, nil
+}
From 1b8ca78f5068acbf100d57bf4bd7cc0d933da109 Mon Sep 17 00:00:00 2001
From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com>
Date: Wed, 15 Jul 2026 21:47:55 +0200
Subject: [PATCH 17/89] ship: Polkadot page + Parity product to prod (#1187)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* polkadot-rpc bench 086 + /chains/polkadot + Parity product (#1184)
* add bench 086 polkadot-rpc: first Substrate chain in the RPC cluster
Extends rpc-capabilities to Polkadot, the first non-EVM, non-Solana
chain in the cluster. Substrate JSON-RPC via chain_getHeader (returns
hex-encoded block number matching EVM's eth_getBlockByNumber shape),
staleness by relay-block gap (40 blocks, ~4 min at Polkadot's ~6 s
cadence).
Harness changes:
- Chain.Kind gains "polkadot" alongside "solana" and default (EVM).
- New callSubstrateHeader: chain_getHeader POST, parses hex block
number, returns parentHash as the observed hash. Same latency
measurement + result classification as the EVM path.
- probeOne switches on Kind (was if solana / else). polkadot uses
polkadotStaleBlockGap = 40. Consensus observation is skipped
because chain_getHeader returns parentHash (N-1), not the current
block hash (deriving current hash needs Blake2 over the SCALE-
encoded header, out of scope).
- Archive loop is skipped for polkadot the same way as solana:
Polkadot state accesses via state_getStorage keyed by Blake2 storage
keys, no chain-agnostic depth analog for eth_getBalance-by-depth.
Providers at launch (3, live-verified on 2026-07-15 with four
consecutive chain_getHeader probes each):
- polkadot-official (Parity, rpc.polkadot.io)
- onfinality (public gateway, Web3 Foundation grantee)
- publicnode (Allnodes, extends its multi-chain footprint to
Substrate keyless)
Audited and excluded from the launch cohort:
- 1RPC: filters chain_getHeader as "Not Allowed" on the privacy
relay. Revisit if they expose a Substrate-friendly method set.
- Dwellir: 503 Service Unavailable during the audit sweep, revisit
once the gateway stabilises.
- Ankr: paid Polkadot tier only.
- Chainstack: key-gated on Substrate.
- RadiumBlock: rejects without referral header.
- Grove / Thirdweb: invalid-chain response.
Spec (benchmarks/polkadot-rpc.yml, bench № 086):
- Same schema as moonbeam-rpc / avalanche-rpc etc. so the parent
rpc-capabilities benchmark, chain cluster page and hub card
render it identically.
- Methodology documents the Substrate specifics: probe method
choice, staleness scaling, why no archive-depth or hash-quorum
columns.
- SEO/finding tokens use the standard {{best_name}} / {{best_p50}}
/ {{p50:slug}} template surface, consistent with other -rpc
benches.
- Provider tag copy explains OnFinality and Dwellir's Web3
Foundation grantee status so the leaderboard reads correctly
for someone who knows the Polkadot infra landscape.
Local checks:
- go build ./... clean.
- go test ./... clean.
- bun test src/ - 95/95 pass across 10 files, including the
citation reliability floor and consistency tests added in #1175.
* polkadot page + parity/onfinality/dwellir products
Wires the /chains/polkadot hub page, provider profiles, and logo
assets that surface once bench 086 polkadot-rpc starts feeding Prom.
- chains.ts: add Polkadot to CHAINS registry (L1, nativeSymbol DOT).
Auto-populates /chains/polkadot once benches referencing chain="polkadot"
land. Adaptive KPI strip fills in DefiLlama TVL, DEX volume and stables
when the chain-kpis harness starts sourcing Polkadot alongside the
other L1s.
- provider-registry.ts: three new entries for the Polkadot cohort.
* parity: brand entry for Parity Technologies (Polkadot / Substrate
implementation shop, operator of rpc.polkadot.io).
* polkadot-official: endpoint-level entry describing the reference
rpc.polkadot.io keyless endpoint; providers.ts aliases the slug
to parity so the /products page collapses under the operator's
brand.
* dwellir: brand entry for Dwellir (Web3 Foundation grantee, keyless
Substrate infra) — excluded from the 086 launch cohort due to a
503 during the audit sweep but kept in the registry for the
revisit and for other places their brand might appear.
- providers.ts: add polkadot-official → parity alias and canonical
brand casings for polkadot / parity / onfinality / dwellir so the
/products hub, badges, and OG images render the correct name
without the title-case fallback butchering them.
- logo-manifest.ts: polkadot.png (chain hero), parity.png (product page
logo), plus polkadot-official → parity slug alias so the endpoint
chip on the bench page picks up Parity's mark automatically.
95/95 pass on bun test src/. Local build clean.
---------
Co-authored-by: Florent Tapponnier
* fix: dedup onfinality key in logo-manifest (already declared line 49) (#1186)
Co-authored-by: Florent Tapponnier
---------
Co-authored-by: Florent Tapponnier
---
public/logos/parity.png | Bin 0 -> 7504 bytes
public/logos/polkadot.png | Bin 0 -> 1745 bytes
src/data/provider-registry.ts | 18 ++++++++++++++++++
src/lib/chains.ts | 8 ++++++++
src/lib/logo-manifest.ts | 3 +++
src/lib/providers.ts | 9 +++++++++
6 files changed, 38 insertions(+)
create mode 100644 public/logos/parity.png
create mode 100644 public/logos/polkadot.png
diff --git a/public/logos/parity.png b/public/logos/parity.png
new file mode 100644
index 0000000000000000000000000000000000000000..3d06da06c3430d580424eb064d336f8f99736272
GIT binary patch
literal 7504
zcmW+*cRbYpAD56(=9wAMAv!a0_KY(!F5{BDviF{aBYR|1=2_|6A)BnM(m<5#ak%Wg
z`Mv%AIQPfr;Xa?wYdqIGMps+y7C8$!0Rh1+b%Y8MeD_~{ND0ATE-A?Y0s=NhbrnT@
z|Gc#~0Vw_7=a+7I+S#44@%M5I^oIH=haIVj_~Py)R8U2}(D?JQRGs6oLH)Ixr6krY
z6QniPF1fveIuR7PIbx+QMWjU*l@7MIUpL8$!r?a9=ac190g-`!$2lfQD#jVj^g^wg`eE4ZL
zf=cdHe7w!1AcOL2)d+%&-MCYYjb#3kG(Lz#4NQi^@ExN`AEvJI^
z_ORdonW|2ny?@rAt)(?9p3i`?%>I0Iom<&h8KSzH@`}XxY}=-772hii(Q<{{CKGwJ{vrv@z5E3%4*x^%MuY>#nxX
zn8?vZO-+)*!pU9Sv)le0p+}q>ZmJ?x*qaJa+Vwxx5lZThX{f0!Y+VHo%BVjtF50-b
zxY*mTa9nKNkdkY!t*x!BjD@O8$+1#3rn_vE>sQ{EaE*+P9%-W{d^vltKGE1NXp8L{
z?uocTMNUq>w!V%xq~{VB_x1FoJV5$KL6<&q=S9Bz32016&IKFW}nsRki)5%7_y~Xv%AOn
zVHc9SzcK8SU&I-Ia)vDQCa_Z8_^R}0VqzJGi|MhN^b2P}S)PY|w(_vFoI5+-twxYz
zp4T-rywm3g*F^1Fu#27nPoBJPXpj(8Z)$GN$jsElAi*UoyE^R2$w^I3jr;k5N7H0l
zTH2>ia+3`&8SE;sB}fNbTTEOW1%o;b(cJ24(8i=BTIuDf_RpRwItKOB_4RcR506Hu
z?epiE`T10mB}qw1em*`To<`-*eaSRo42<;jYz5+y2ML
z$KBoCfsfNQFm-ix-@kvCl3U-J>yDok|AN)FOh`;zT3i&)*w}&V>FH7HG;3g#{{3Fn
z^m2D^^jT$ia+pL|^%%91{3&qXu@ovsr>55T@1J9gO409Mb8~Y=MO@O-(zCq0JUqs_
zG(`qvP8JpfM$Hsbwq<2y$})J*rq;s3&c43BpPFX}4ehpzs>m1Vq)x%Xt)&2QMk>nQ
z23A&A(|95_S63&RyvjU`p4K~OV;bvVW~ly5CKi@hktqxoOYM*NP+sot>I|7}u;0J(#)l^z>wJMt$*w`{Qxlb3`|8_<4I{DUs&|4DpSvWMKE-ZVdnW6)BRq
z`0tfBNcvF6~($LSJFRGpY7Qm6A
zp%;5?Ve5bEmUJWgldk~C)Re8)`b(w3yPVuy&OQK6pW@?hn)uQZrKF@>i@*C&LL$iD
zU*>|W{isLip0UJ2p(*MfuPhfAVIK?4Js&;^2~axy4;26sFc?f}{EleKix&j5oTlK`
z$;4tCEWEwEuAk7Vsj7N_^ze
zF7I1^!lM&Oucf1t6Dc@+B?P0-zrMDXHekauVGs57^}V>bpynN)owe}uJB-gQKvtNA
z9^*DPT&Fg1?(og_OS!kayc&y=GBPj-dHZ48rk>Np##9TR$d40U{&YaO$w5rCwBZ=i
zp
zTm=kO`}~J!z@zR+^ss4Baq;DLQrJ&BS_*;1rKQmQQKNWMN`4q*XA*1KJyfj5As39O
zHGutkAS~<-R1XF@Fo@f``dzKZj~^otjb6)?cMYq5DgmNRzi*V)l)-LU7*nhxf4-N0
zdA2+-pl;`ERTu++Eadb6V3>&e`%U{(s#K`kbZe8}=JeQ@VY)`o3%&=~hK9q#!{-;x
zhBuS?c6WDkv$8CTJf9R#Kx}bVFxFdCRM90ZgT}LO5BWfEb8>Q+-qmOE!G28ot1j
z3<9)W`rLB`Feh}p+9;JUiU%siSOyr1VDc1DlujOeZf$LCb5mYiTs0a!Lkjf7`cz9M
zv$_X7?~SydQgaK|e!;`?k&22*dnns*XhU0DNT9ZZx;hyf!^LKWJJ4?9HMZXG$Wc;i
zsU--gRKO0DZDyQ2OAzMg=NIuW#qQ%Es?mLJeWHP{Ve8pO)X7QUNi2o4i%VQ=>>Z3(
zIdSpp*L1l#RSA5S*QA3AnVDI>mXd`Zfyscq9w@Kdde)Wt6-P!!=3UM&F3vEoo<9_x
zLZ2a?rh%(>nV7X50sU=$X4ks=UHX?Xd}MceWTZFzo41h_GFvq#9B|>1k;zK2eH4jmbhTFG3=sx*MCD
z$3{mLTls*_0ct0CTD1!M0s&2pjg3W9Fy`>^6XO1yt(sqoPC?%@mn0@8x_vsf1CT$x
zrze-6pPamPvid>jX^qO^{d5gmFR$Ish#SDZ+%ZQAW+o*hbOuSRpu6V?a(y0aX$c$p
z70|FJ-54-e8uh={vWws}>IZ`UTuLn|JzxHVl!F@^ZV2B4`
zSy55JGw#Teot@pWA^7dvH+G3u5gd8N*3+so>_Xh!wm@+WhaL`QKiEs~0FtLE
z##KPJi%Wgya|~UQIb2kzQ@HkpP-eD5n2Bcn%H@lnSYbLyh>;
zMaM!jzv4pohSUNd50jM38v;SReA(aM|6;MESPst=%`7P;b=8-&D&GYD4;M5@+lHY=+=33w)9;XSrq^yFC?9kH>t$yC@4x@H
zn3*LdB}axa6(%RY@e_ySj)Bwt2_c8Q55C{Mx^ALI_HL1`uxa$7t>8XU6n8NoS;OM~eFumIr
zeefVbdk>?4DtBhtyeD)dLcDNK2n=j^IH85Ml<*Xq2mGWKELU6|hy>f?9bDrl`PjSH
zLSn%DH`;xpBdQMAU8=7aZ}Tl`<1iXZQdKFW5}uRtOyR6nb6QsAU-H_
zfHAPp4m2}kpKjwwRVL_UvXl+nD=#V{T)6jP^qo=n0z%q%t;3dAk-EiqJzR&ZV?uOc
zao{zieo$By1#q#ERUY&M+*-lh>d_mF!3rmF_u21xx2>>VjGVMF3j#RHTdHxR}7>uYlrLwY8c;g&Y6PUbYWhqCd-KAVN
z!YO0bx7ATmQCX7>nXspXNq2FJix%;BA3b`c$gZacc?fh#aYSiB0Rg<%!%=Ry3r(>(
z*>qj+)pqn@`^El;!O66$Dp&z;TVbK)Xr;l;tP+}tc?6K%ObZC$ACg%K#d^1+<jJ2V9e!$kFf3SmwA$bIZ|J3YN+InfV|@7
z!s4BT2*6`90k`>toxQz%Pb|GE%I^8|zTh(Z`w9)%*@{G<
z9!lZ%tgNgb_s5Ey{~V3iKEFk-;of9IuKc$4mP?nC2ed6nUOg4;2k`e2vg7wfMD(ol
zqD3tSY()Lt1Q;0^Eyl&-RM{EFCY!wGDiZ1G=^-udq+X5*6h2^f>T~MszPo1({QDC4I4(nr@}Kb7=Wt)Hfo|p
z@%EE0Pft%NpMQ{6c8870reBrh)j+Z^4~*%PQ(zor&-X@>%>4mcf|2Z~!Go1b+vpyV}ntI9k1I+F984s})eEak5~z|MSop!<(3e^X$cClyZqy}zW6tCO^I@LJ~6R0?eP_>>DGV`Z3j8Ma9X-9
zC5#t9#Hft#)m_7#{rw1EU-8}={Hs@_Z2Jf;($(T5IbbzXXvS*0hNF-bpHXo9{JiJC
zUwl)NM1iSa_&R}qvUbyPLRanuM1Z^pkH;H*Fng?{^Lu^BLJ0*pxTmM*rpYU`A;8n`
zZ)Kl7c~a|*N45#sDWQlIWlq;>fy)~-i9>r55i08G4fSz^vC`39AG~FQK!IokQ`1*TY?TZ9y*P!gbMw0(Kj)f92v2$Y3uD+-d&iV2T9kS
zoK~TCX2VQq5J>Be<{axz#@;{EKSoSVO@X0CK6z5HcD=`fbv~@$rbggdJ(D9pu(EsN
zsR$?zhohvVOq=4lzP+>K=-}|OMCxE~&%&);Q|;?bnJy&>2>bG!XA{UKl4~6FQ$&i%
z*j@wAz7+_K|I`Ma$OLFUWWsZzKar18ZJs`5UW{A;bFj0hKKl_5)Vj$7CDQO$iHV;)
z7D%w{WSdNCK%iRCF_bi9ynpWk798u$;zrSn(aZ#$B6udeG0{L}eL9tu#YAFkHw$6l
zSNb)!Fgh}_3$l&m1OV)(Xht)O;FlL_hK3Bl?`MAf@kTn@Ln$5zXZKABw!y9(vq(5v
z;SL;lkN@WZiEPqQhgpPqDMP=1XQrh^zB?C8>H|>|Z^^l!fdvD&g9$KnzXbj?dRRYy
z&TErq){yuSU
z3R6cFnwudGFw@eH{3P2qH)HS4g{b(ryHk^+sc+n}&Z@xbj_-X*zwaSHp93eCDOp}#
zwvNBs)7z^wkGN@_*!6F7Q%6hd^Ft318XSO55cH;i7!!~~^&b#3DcrcVn^lax*vV|4
zHRgA7+tkbyRHVd%l>ju;W6LX(7)jv;$uJR|sdu^Ey&9>p`~ol7m&m3}r&di3fe<;%
zCD4HD#_unH@9(y`NwlH~Ro?-RE-dz46-UznB~eH#0pw8t812EH9gZP1Z4|>1{g!n0L+4AHr$4
zZgbppToIhM*Oh?qWkF*jlV&r8K~@V^w>|^kt^!{9eN|N?>k|-MUqL1uX=4NJ^=7QZ
z5?rA!I3!?c(`Thz=9NDtbDv*1-d)}q8CY1{fld)W
zW)0bBT6XVAISQ6@k1xhpFn1x}jrJvRNZ3~_eapR(4>vY5b2T|N)!5qI1rZI5G>#@i
zh3mMJDwM#avau14XpRF%7=UDZ54y|Q9Ztl=-*s9=f;xUMYpaw%vp!}eE^Psk!#s;!
zWG@_~DaNt9Q@Sv96o0zo%%_mQV`Rs)oPCgNt%K>HO}&*K;WTh=G3-2ITHci!iLPf(
zXe$8i7cy-o;(G%?N^rUcKRBqxL!ky@`>RLY-`^j}pCTgc1k&h*?MVB{=!f{A<`SZ`
z4J#f=ytj2gSL%Sx57|RjPuTb=@`C@k&}`heRB)FE?GJWoQSma
z#Ml@^X5|%Ng2M}8PwPR_I0#7XlhwS_Hs;sY6Eu-?|x&iQbe_%;eSEns0JyWsKbIl>2RRvO-Lyt{>y1c|WpR+_KPFfbQCC5R#%Jk$1Q(7hJ*jm6?6F#*BlWg*@0_I=V_8gNodp#Df(zaYHJng&Ag&DNcQQn7frn^7<3y&IWK*
zfUD4>$&maqM{cq*_@siqiLxCdt@0S5^oR-j`+6)tstS1mN7ZJw7j$bMw=D24*G
zZQAptk2SZ)?u-?#3WdTSvsltp=67Laf*z`rkd&04o141@bj$-^ia+?6+Pyc0zN
z@VZFESsJgadiE@vNF?g%>${i(x8sJOt_a0|fPiZ^Zj`@%jrnhFUdb638k*VE+>8qf
zN{7Kfk5fH6=CNn0EnvusFF(cY#PPkpQx&;
z>RDPA-kZBh!D?Qv^FQpM?ck5LZzqw!!nHNy7)t~bj^$6aC4E*!%ykUi?dx-X4MN#Z
zF&K>fCMoQ@p(p37nwrQ{j0A_-*;ynK={Wev<_SG?@U2Gfy;K~|dBUwi9;63{H=bm%
z2C(>3wD3_bm$SH7&3bgj*Y`<(zY9re`|56AzV5htCRyag6YH18Uf!&>%5I1N2V?0?9SBNAK4m4Y%5Dk!Dn7G
zm&Ze|udk~dJ!-f}5(X9SZLK;KWWH)>Y`j$PxB;W<>JIepxVeyru)l6!7ZMyyt3S?B
zK4Bm;EP3srY?h$EpXdjB%Gq9<_c>FHusbYzyRt%CjWuFtB?>6~)^I4TG6lav}LwyOA7BA_F6{Y@WeJqVn-|Fx}H@p7$S
zRJ^7*`|WLhkEUxD~&>A`3W?n3a_U
z*@;O=u$XL#a(Ma4WA`^x8nZP20bg~c{h`fD1dFh*7-@cv|JWh(j^8t5JL5`s%OP#^
z$h6(Py;svYpwqvmC018gJKl|5ojDu76pV+t`#3qtnV6Z4%nz2lB8&ER;(zh;D_1l0
zclYqftI9#W00M!+zZGhSCu_3AGl4a>wOvLW?;dMuZ+9Ckv8`ZbDz#pxplPh+oefN_
zv4zFNWFm!s2safQ8*6cc5sugC?(Tl+WYprp`~APD1iVn9>B?M47Kl>XzdGAfEQ9JN
zB&VdL1g5x?XF-BAF4t>fdn+7=Gn&D|D)Y$Xr(sW*q;=3PE_c*VNXZ`S%{!!(ye$lg
z&$Pwj(e4)m1IsXLT*#f5m&ikCS8FOmX-l+_-=gpNaa020#e8vuS~rGEBV5Fa?4<{P
z_VVgoFJrgHFV`nqUd8Jp%IS2^Sa1Ud@yj;e!O(kmWkX#)cGg9Ee5wDN0?fn1L$J9#
zP5jmJLL|z^=K)j}s{kECbs;-nH;{y1zb#1w{}M%bTww6HCpspkSo5HEj0F-|(8Xdc
z^pbQ8?sh%P6-BR$D?Q@l)uts^$oy+rytdVKE!Y*IuwG!tn;&W!skktbE~_LExAI4t
z9pDc$d%1|!95A%D;3!k=l(%yFtraN+m_V{E?ZxL$lgE$;c{Mc#6Iko?R>HDb!rJ7mV`kG4K2K8ZR~CB0!d>!xw>fgl=Z)@wnqcpSf0`7t&6J!@JJWk9a;Zz~gt
zZZ-i6zq!fodfrH*xod^4S}1}{6%`d{PAW6@r(b2D5zLz$)PfaYA!g#rZw+u)n&$UG
b<+emb-X%tqXntD!ssPSs-5hJ}vH1T1Uq)LI
literal 0
HcmV?d00001
diff --git a/src/data/provider-registry.ts b/src/data/provider-registry.ts
index 9e23d722..18dda033 100644
--- a/src/data/provider-registry.ts
+++ b/src/data/provider-registry.ts
@@ -579,6 +579,24 @@ export const PROVIDER_REGISTRY: Record = {
},
// ─── Public RPC providers ─────────────────────────────────────
+ parity: {
+ url: "https://www.parity.io",
+ description:
+ "Parity Technologies is the primary implementation shop behind Polkadot and Substrate, and the operator of the reference public relay RPC at rpc.polkadot.io. Also builds the Polkadot-JS Apps UI, Kusama tooling, and enterprise Substrate deployments.",
+ twitter: "@ParityTech",
+ },
+ "polkadot-official": {
+ url: "https://rpc.polkadot.io",
+ description:
+ "Parity-operated Polkadot relay-chain public RPC endpoint (rpc.polkadot.io). Substrate JSON-RPC (chain_getHeader, state_getRuntimeVersion, system_health), keyless, rate-limited per IP. The reference endpoint any Polkadot integrator points at before wiring a dedicated node.",
+ twitter: "@ParityTech",
+ },
+ dwellir: {
+ url: "https://www.dwellir.com",
+ description:
+ "Web3 Foundation grantee running production-grade Substrate infrastructure across globally distributed bare-metal servers. Keyless public gateways on Polkadot, Kusama and every major parachain, plus dedicated node service.",
+ twitter: "@dwellir",
+ },
"monad-official": {
url: "https://docs.monad.xyz",
description:
diff --git a/src/lib/chains.ts b/src/lib/chains.ts
index 48da5cd7..2e1fed7b 100644
--- a/src/lib/chains.ts
+++ b/src/lib/chains.ts
@@ -309,6 +309,14 @@ export const CHAINS: ChainEntry[] = [
description:
"Sony Block Solutions OP Stack rollup in the Optimism Superchain, 2 s blocks, consumer and entertainment focus.",
},
+ {
+ slug: "polkadot",
+ label: "Polkadot",
+ category: "L1",
+ nativeSymbol: "DOT",
+ description:
+ "Substrate-based relay chain coordinating a parachain ecosystem via shared security. GRANDPA finality with BABE block production, ~6 s block time.",
+ },
];
export const CHAIN_BY_SLUG = new Map(CHAINS.map((c) => [c.slug, c]));
diff --git a/src/lib/logo-manifest.ts b/src/lib/logo-manifest.ts
index a8b30069..a81714b3 100644
--- a/src/lib/logo-manifest.ts
+++ b/src/lib/logo-manifest.ts
@@ -108,6 +108,8 @@ const RAW: Record = {
drpc: "/logos/drpc.webp",
"1rpc": "/logos/1rpc.svg",
cloudflare: "/logos/cloudflare.svg",
+ parity: "/logos/parity.png",
+ polkadot: "/logos/polkadot.png",
"base-official": "/logos/base.jpeg",
binance: "/logos/binance.png",
lava: "/logos/lava.webp",
@@ -380,6 +382,7 @@ const ALIASES: Record = {
// Long-tail RPC cluster (055-066).
"sonic-official": "sonic",
"monad-official": "monad",
+ "polkadot-official": "parity",
hood: "robinhood",
coin: "coinbase",
"megaeth-official": "megaeth",
diff --git a/src/lib/providers.ts b/src/lib/providers.ts
index cd65780e..acd98747 100644
--- a/src/lib/providers.ts
+++ b/src/lib/providers.ts
@@ -40,6 +40,11 @@ const PRODUCT_ALIASES: Record = {
"avalanche-official": "avalanche",
"base-official": "base",
"optimism-official": "optimism",
+ // Polkadot's "official" endpoint is Parity-operated, so the product
+ // page for the endpoint slug collapses to the Parity brand rather
+ // than a synthetic "Polkadot Foundation RPC" entry (there is no such
+ // separate operator).
+ "polkadot-official": "parity",
// Oracle pair → underlying chain / asset
"eth-usd": "ethereum",
"sol-usd": "solana",
@@ -74,6 +79,10 @@ const CANONICAL_NAMES: Record = {
arbitrum: "Arbitrum",
base: "Base",
optimism: "Optimism",
+ polkadot: "Polkadot",
+ parity: "Parity",
+ onfinality: "OnFinality",
+ dwellir: "Dwellir",
// NFT bench 040 providers — explicit brand casing.
opensea: "OpenSea",
// Other brand casings frequently referenced.
From ba10be7e5036f7cdd2951082cfad67f53267e587 Mon Sep 17 00:00:00 2001
From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com>
Date: Thu, 16 Jul 2026 00:44:49 +0200
Subject: [PATCH 18/89] =?UTF-8?q?hotfix:=20bump=20bench-set=20cache=20keys?=
=?UTF-8?q?=20=E2=80=94=20purge=20poisoned=20all-benchmarks=20aggregate=20?=
=?UTF-8?q?(9=20draft=20benches=20stuck=20via=20AllBenchmarksDraftError=20?=
=?UTF-8?q?loop)=20(#1190)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-authored-by: Florent Tapponnier
---
src/lib/spec.ts | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/src/lib/spec.ts b/src/lib/spec.ts
index 8ad03e04..58d4434c 100644
--- a/src/lib/spec.ts
+++ b/src/lib/spec.ts
@@ -273,7 +273,7 @@ const loadBenchmarkUnfilteredCached = unstable_cache(
// buckets (gap rendering fix). Cached v26 entries hold the old
// hole-compressed arrays whose indices no longer map onto the nominal
// step grid the chart back-computes timestamps from.
- ["bench-unfiltered-v32", process.env.VERCEL_ENV === "production" ? "prod" : "all"],
+ ["bench-unfiltered-v36", process.env.VERCEL_ENV === "production" ? "prod" : "all"],
{ revalidate: 300, tags: ["benchmarks"] },
);
@@ -428,7 +428,7 @@ const loadAllBenchmarksCached = unstable_cache(
// gated catalog to /products for 30+ min after the deploy).
// v29: bumped with bench-unfiltered-v26 (monad-rpc + megaeth-rpc ship).
// v30: bumped with bench-unfiltered-v27 (dense series with nulls).
- ["all-benchmarks-v35", process.env.VERCEL_ENV === "production" ? "prod" : "all"],
+ ["all-benchmarks-v39", process.env.VERCEL_ENV === "production" ? "prod" : "all"],
{ revalidate: 300, tags: ["benchmarks"] },
);
export const loadAllBenchmarks = cache(loadAllBenchmarksCached);
@@ -507,7 +507,7 @@ const loadBenchmarkFiltered = unstable_cache(
// v16: bumped with the bench 074 ship (lockstep rule, see all-benchmarks-v28).
// v17: bumped with the monad-rpc + megaeth-rpc ship (lockstep rule).
// v18: bumped with bench-unfiltered-v27 (dense series with nulls).
- ["bench-filters-v23", process.env.VERCEL_ENV === "production" ? "prod" : "all"],
+ ["bench-filters-v27", process.env.VERCEL_ENV === "production" ? "prod" : "all"],
{ revalidate: 300, tags: ["benchmarks"] }
);
From 44291cade8a2a457931015a9589f95d607339467 Mon Sep 17 00:00:00 2001
From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com>
Date: Thu, 16 Jul 2026 12:24:53 +0200
Subject: [PATCH 19/89] chain-kpis: add polkadot to registry (DOT native,
stables via Asset Hub) (#1191) (#1192)
Ships live KPIs on /chains/polkadot. DOT price and mcap flow through
Mobula. Stables card renders via DefiLlama Asset Hub aggregate.
TVL and DEX gauges stay unpublished (relay chain has zero DeFi:
parachain TVL lives under Acala, Moonbeam, Hydration).
Also guards defillama TVL publish to skip zero values so chains that
DefiLlama tracks by name but with an all zero series (Polkadot today)
hide the TVL card instead of rendering $0.
Co-authored-by: Florent Tapponnier
---
harnesses/chain-kpis/cmd/script/defillama.go | 9 ++++++++-
harnesses/chain-kpis/cmd/script/registry.go | 8 ++++++++
2 files changed, 16 insertions(+), 1 deletion(-)
diff --git a/harnesses/chain-kpis/cmd/script/defillama.go b/harnesses/chain-kpis/cmd/script/defillama.go
index c3ee658a..e221aa9d 100644
--- a/harnesses/chain-kpis/cmd/script/defillama.go
+++ b/harnesses/chain-kpis/cmd/script/defillama.go
@@ -45,7 +45,14 @@ func fetchDefillamaChain(c Chain) {
anyOK := false
if tvl, err := defillamaTvl(c.DefiLlama); err == nil {
- chainTvlUsd.WithLabelValues(c.Slug).Set(tvl)
+ // Skip zero: DefiLlama serves the whole historical series as
+ // zero for chains where the relay layer holds no DeFi (Polkadot
+ // today), so we would render a "$0" card that reads as broken
+ // rather than as "no data". Card hides when the gauge stays
+ // unpublished. Non-zero values, including small ones, publish.
+ if tvl > 0 {
+ chainTvlUsd.WithLabelValues(c.Slug).Set(tvl)
+ }
anyOK = true
} else {
chainKpisFetchErrors.WithLabelValues(c.Slug, "defillama", classifyError(err.Error())).Inc()
diff --git a/harnesses/chain-kpis/cmd/script/registry.go b/harnesses/chain-kpis/cmd/script/registry.go
index b68a25eb..cb2aca55 100644
--- a/harnesses/chain-kpis/cmd/script/registry.go
+++ b/harnesses/chain-kpis/cmd/script/registry.go
@@ -83,4 +83,12 @@ var Registry = []Chain{
// stablecoin (~$1), the wrong asset for a native-token card.
{Slug: "fraxtal", DefiLlama: "Fraxtal", Mobula: "", NativeSymbol: "FRXETH"},
{Slug: "soneium", DefiLlama: "Soneium", Mobula: "", NativeSymbol: "ETH"},
+ // Polkadot relay chain. DefiLlama tracks the chain name but reports
+ // zero TVL and 500s on the DEX endpoint: relay chain has no DeFi and
+ // parachain DeFi (Acala, Moonbeam, Hydration) lives under those slugs.
+ // Stables endpoint returns real values via Asset Hub USDC/USDT
+ // issuance. DOT native price and mcap flow through Mobula. The zero
+ // TVL guard in defillama.go drops the empty TVL card so only real
+ // cards render.
+ {Slug: "polkadot", DefiLlama: "Polkadot", Mobula: "", NativeSymbol: "DOT"},
}
From 6b97091fb91d742086f972210e8b2479f082a523 Mon Sep 17 00:00:00 2001
From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com>
Date: Thu, 16 Jul 2026 12:50:45 +0200
Subject: [PATCH 20/89] rpc-capabilities: integrity + consensus probes
(unblocks bench 083 on Railway) (#1194)
Cherry-picks the integrity/consensus code path from #1170 to main so
Railway rebuilds the rpc-capabilities image and starts emitting
rpc_integrity_check_total, rpc_hash_mismatch_total and
rpc_consensus_lag_blocks. Bench 083 rpc-reliability reads these; on
main today the metrics do not exist so the leaderboard is empty.
Includes:
* integrity.go: rotating eth_getLogs + eth_getBalance vector checks
* consensus.go: cross-provider tip tracking + hash quorum mismatches
* config, probe, archive, metrics: wiring + label updates
* consensus_test.go: quorum resolution tests
Building rpc-capabilities and running its tests passes on this branch.
Co-authored-by: Florent Tapponnier
---
.../rpc-capabilities/cmd/script/archive.go | 6 +-
.../rpc-capabilities/cmd/script/config.go | 30 +-
.../rpc-capabilities/cmd/script/consensus.go | 154 +++++++++
.../cmd/script/consensus_test.go | 144 +++++++++
.../rpc-capabilities/cmd/script/integrity.go | 304 ++++++++++++++++++
harnesses/rpc-capabilities/cmd/script/main.go | 10 +-
.../rpc-capabilities/cmd/script/metrics.go | 51 +++
.../rpc-capabilities/cmd/script/probe.go | 94 ++++--
harnesses/rpc-capabilities/go.mod | 1 +
9 files changed, 754 insertions(+), 40 deletions(-)
create mode 100644 harnesses/rpc-capabilities/cmd/script/consensus.go
create mode 100644 harnesses/rpc-capabilities/cmd/script/consensus_test.go
create mode 100644 harnesses/rpc-capabilities/cmd/script/integrity.go
diff --git a/harnesses/rpc-capabilities/cmd/script/archive.go b/harnesses/rpc-capabilities/cmd/script/archive.go
index d7889264..1c9e74e6 100644
--- a/harnesses/rpc-capabilities/cmd/script/archive.go
+++ b/harnesses/rpc-capabilities/cmd/script/archive.go
@@ -45,8 +45,10 @@ func StartArchiveLoop(ctx context.Context) {
c := c
if c.Kind == "solana" || c.Kind == "polkadot" {
// eth_getBalance at historical heights has no Solana or
- // Substrate equivalent on public endpoints; skip the
- // archive loop.
+ // Substrate equivalent on public endpoints (Polkadot state
+ // is accessed via state_getStorage keyed by a Blake2
+ // hashed storage key, no chain-agnostic depth analog);
+ // skip the archive loop.
continue
}
for _, p := range c.Providers {
diff --git a/harnesses/rpc-capabilities/cmd/script/config.go b/harnesses/rpc-capabilities/cmd/script/config.go
index 740d3537..d9c769ca 100644
--- a/harnesses/rpc-capabilities/cmd/script/config.go
+++ b/harnesses/rpc-capabilities/cmd/script/config.go
@@ -36,7 +36,10 @@ type Chain struct {
// no archive-depth loop.
// "polkadot": chain_getHeader against Substrate JSON-RPC,
// block-based staleness (Polkadot relay produces one block every
- // ~6 s, override via polkadotStaleBlockGap), no archive-depth loop.
+ // ~6 s so staleBlockGap needs a Polkadot-specific override, see
+ // polkadotStaleBlockGap), no archive-depth loop (Polkadot's
+ // state model does not map onto the eth_getBalance-by-depth
+ // probe cleanly).
Kind string
}
@@ -62,8 +65,12 @@ type Chain struct {
// (drpc caches eth_blockNumber → only 2 clean providers), opBNB
// (1rpc 429s at probe cadence, only 3 solid providers), Mode
// (3 providers), Zora / Abstract / HyperEVM (≤2 keyless providers).
+//
+// Local-run filter: OCB_CHAINS=ethereum,base restricts the matrix to
+// the listed slugs (unset or unmatched = full matrix). Used for local
+// smoke runs; never set in production.
func chains() []Chain {
- return []Chain{
+ all := []Chain{
// ─── Polkadot relay chain — first non-EVM, non-Solana chain
// added to the cohort. Substrate JSON-RPC via chain_getHeader
// (returns hex block number, staleness by relay-block gap).
@@ -391,6 +398,25 @@ func chains() []Chain {
},
},
}
+
+ filter := strings.TrimSpace(os.Getenv("OCB_CHAINS"))
+ if filter == "" {
+ return all
+ }
+ keep := make(map[string]bool)
+ for _, s := range strings.Split(filter, ",") {
+ keep[strings.TrimSpace(s)] = true
+ }
+ var out []Chain
+ for _, c := range all {
+ if keep[c.Slug] {
+ out = append(out, c)
+ }
+ }
+ if len(out) == 0 {
+ return all
+ }
+ return out
}
func envDefault(key, def string) string {
diff --git a/harnesses/rpc-capabilities/cmd/script/consensus.go b/harnesses/rpc-capabilities/cmd/script/consensus.go
new file mode 100644
index 00000000..695567e5
--- /dev/null
+++ b/harnesses/rpc-capabilities/cmd/script/consensus.go
@@ -0,0 +1,154 @@
+package main
+
+import "sync"
+
+// consensus.go — bench 083 rpc-reliability, cross-provider head
+// agreement. The latency probe already fetches the full latest header
+// (anti-cache design) and used to throw the hash away; here every
+// successful probe feeds a per-chain height→hash quorum map so we can
+// publish two things the latency bench cannot see:
+//
+// rpc_consensus_lag_blocks — how far this provider's head sits
+// behind the highest head any probed provider reported for the
+// same chain (the chainTips rolling max). Zero for a provider at
+// the shared tip; 1-2 for a gateway one block behind (observed
+// live on 1rpc/tenderly); tens for something wedged.
+//
+// rpc_hash_mismatch_total — the provider's hash at height H
+// disagrees with the hash a >=2-provider strict plurality agreed
+// on at H. Same-height hash divergence is the serious signal
+// (serving a non-canonical or fabricated block), so it is a
+// counter, incremented at most once per (provider, height).
+//
+// Reorg honesty: during a real reorg two providers can legitimately
+// sit on different hashes at the same height. quorumHash requires a
+// strict plurality (>=2 votes AND strictly more than any competing
+// hash), so a 2-2 split counts nobody. Only a provider outvoted by an
+// established majority is flagged.
+
+const (
+ // quorumMinProviders: minimum providers agreeing on one hash
+ // before that hash is treated as canonical at its height.
+ quorumMinProviders = 2
+ // consensusPruneDepth: heights this far below the chain tip are
+ // dropped from the vote map so memory stays bounded (~2 blocks/s
+ // chains would otherwise grow forever).
+ consensusPruneDepth uint64 = 128
+)
+
+type heightVotes struct {
+ hash map[string]string // provider -> reported hash
+ flagged map[string]bool // providers already counted at this height
+}
+
+type chainConsensus struct {
+ heights map[uint64]*heightVotes
+}
+
+type consensusTracker struct {
+ mu sync.Mutex
+ chains map[string]*chainConsensus
+}
+
+func newConsensusTracker() *consensusTracker {
+ return &consensusTracker{chains: make(map[string]*chainConsensus)}
+}
+
+var consensus = newConsensusTracker()
+
+// observe records one valid head probe. Called from probeOne for
+// results classified ok or stale (a stale block is still a valid
+// (height, hash) observation — its lag is exactly the point). hash is
+// "" on the Solana path (getSlot carries no hash): lag is emitted,
+// quorum voting is skipped.
+func (t *consensusTracker) observe(chain, provider string, height uint64, hash string) {
+ // Lag against the cross-provider rolling max the staleness check
+ // already maintains. The caller updates tips before observing, so
+ // a provider that IS the tip reads 0. Noise note: tips is updated
+ // asynchronously by per-provider goroutines staggered across the
+ // probe interval, so ±1 block of jitter on fast chains is expected
+ // and averages out in the 24h quantiles.
+ tip := tips.get(chain)
+ lag := 0.0
+ if tip > height {
+ lag = float64(tip - height)
+ }
+ rpcConsensusLag.WithLabelValues(provider, chain, currentRegion).Set(lag)
+
+ if hash == "" {
+ return
+ }
+
+ t.mu.Lock()
+ defer t.mu.Unlock()
+
+ cc := t.chains[chain]
+ if cc == nil {
+ cc = &chainConsensus{heights: make(map[uint64]*heightVotes)}
+ t.chains[chain] = cc
+ }
+ hv := cc.heights[height]
+ if hv == nil {
+ hv = &heightVotes{hash: make(map[string]string), flagged: make(map[string]bool)}
+ cc.heights[height] = hv
+ }
+ hv.hash[provider] = hash
+
+ if q := quorumHash(hv.hash); q != "" {
+ for p, h := range hv.hash {
+ if h != q && !hv.flagged[p] {
+ hv.flagged[p] = true
+ rpcHashMismatch.WithLabelValues(p, chain, currentRegion).Inc()
+ }
+ }
+ }
+
+ for h := range cc.heights {
+ if h+consensusPruneDepth < tip {
+ delete(cc.heights, h)
+ }
+ }
+}
+
+// quorumHash returns the hash backed by >= quorumMinProviders votes
+// AND strictly more votes than any competing hash. Ties (the 2-2 reorg
+// split) return "" so no side is punished without a real majority.
+func quorumHash(votes map[string]string) string {
+ counts := make(map[string]int, len(votes))
+ for _, h := range votes {
+ counts[h]++
+ }
+ best, bestN, secondN := "", 0, 0
+ for h, n := range counts {
+ if n > bestN {
+ best, secondN, bestN = h, bestN, n
+ } else if n > secondN {
+ secondN = n
+ }
+ }
+ if bestN >= quorumMinProviders && bestN > secondN {
+ return best
+ }
+ return ""
+}
+
+// initConsensusMetrics zero-initializes the mismatch counters for the
+// full (provider × chain) matrix so `increase()` in the bench's
+// incident queries resolves to 0 instead of an absent series for
+// providers that never misbehave (which is, hopefully, most of them).
+func initConsensusMetrics() {
+ for _, c := range chains() {
+ if c.Kind == "solana" || c.Kind == "polkadot" {
+ // solana: getSlot returns a number only, no hash to vote on.
+ // polkadot: chain_getHeader returns parentHash (N-1) but no
+ // current block hash; deriving it needs Blake2 over the
+ // SCALE-encoded header, which is not worth wiring for a
+ // bench 083 cross-provider quorum check that already has
+ // value on 20+ EVM chains.
+ continue
+ }
+ for _, p := range c.Providers {
+ rpcHashMismatch.WithLabelValues(p.Slug, c.Slug, currentRegion).Add(0)
+ }
+ }
+}
diff --git a/harnesses/rpc-capabilities/cmd/script/consensus_test.go b/harnesses/rpc-capabilities/cmd/script/consensus_test.go
new file mode 100644
index 00000000..27e36d65
--- /dev/null
+++ b/harnesses/rpc-capabilities/cmd/script/consensus_test.go
@@ -0,0 +1,144 @@
+package main
+
+import (
+ "testing"
+
+ "github.com/prometheus/client_golang/prometheus/testutil"
+)
+
+func TestQuorumHash(t *testing.T) {
+ cases := []struct {
+ name string
+ votes map[string]string
+ want string
+ }{
+ {"empty", map[string]string{}, ""},
+ {"single vote no quorum", map[string]string{"a": "0xaa"}, ""},
+ {"two agree", map[string]string{"a": "0xaa", "b": "0xaa"}, "0xaa"},
+ {"majority beats minority", map[string]string{"a": "0xaa", "b": "0xaa", "c": "0xbb"}, "0xaa"},
+ {"reorg 2-2 split counts nobody", map[string]string{"a": "0xaa", "b": "0xaa", "c": "0xbb", "d": "0xbb"}, ""},
+ {"3-2 split resolves", map[string]string{"a": "0xaa", "b": "0xaa", "c": "0xaa", "d": "0xbb", "e": "0xbb"}, "0xaa"},
+ {"all distinct", map[string]string{"a": "0xaa", "b": "0xbb", "c": "0xcc"}, ""},
+ }
+ for _, tc := range cases {
+ if got := quorumHash(tc.votes); got != tc.want {
+ t.Errorf("%s: quorumHash = %q, want %q", tc.name, got, tc.want)
+ }
+ }
+}
+
+func TestObserveFlagsMinorityOnce(t *testing.T) {
+ tr := newConsensusTracker()
+ chain := "testchain-mismatch"
+ tips.update(chain, 100)
+
+ mismatches := func(p string) float64 {
+ return testutil.ToFloat64(rpcHashMismatch.WithLabelValues(p, chain, currentRegion))
+ }
+
+ // First vote: no quorum yet, nobody flagged.
+ tr.observe(chain, "alpha", 100, "0xaa")
+ if got := mismatches("alpha"); got != 0 {
+ t.Fatalf("alpha flagged before any quorum existed: %v", got)
+ }
+
+ // Quorum forms on 0xaa; gamma disagrees and is flagged exactly once.
+ tr.observe(chain, "beta", 100, "0xaa")
+ tr.observe(chain, "gamma", 100, "0xbb")
+ if got := mismatches("gamma"); got != 1 {
+ t.Fatalf("gamma mismatch count = %v, want 1", got)
+ }
+ if mismatches("alpha") != 0 || mismatches("beta") != 0 {
+ t.Fatalf("quorum members were flagged")
+ }
+
+ // Re-reporting the same bad hash at the same height must not
+ // double-count: 60s probes revisit heights on slow chains.
+ tr.observe(chain, "gamma", 100, "0xbb")
+ if got := mismatches("gamma"); got != 1 {
+ t.Fatalf("gamma double-counted at same height: %v", got)
+ }
+
+ // A different height is a new incident.
+ tr.observe(chain, "alpha", 101, "0xcc")
+ tr.observe(chain, "beta", 101, "0xcc")
+ tr.observe(chain, "gamma", 101, "0xdd")
+ if got := mismatches("gamma"); got != 2 {
+ t.Fatalf("gamma mismatch count after 2nd height = %v, want 2", got)
+ }
+}
+
+func TestObserveReorgSplitCountsNobody(t *testing.T) {
+ tr := newConsensusTracker()
+ chain := "testchain-reorg"
+ tips.update(chain, 50)
+
+ tr.observe(chain, "a", 50, "0xaa")
+ tr.observe(chain, "b", 50, "0xaa")
+ tr.observe(chain, "c", 50, "0xbb")
+ tr.observe(chain, "d", 50, "0xbb")
+ // c was flagged while 0xaa held a 2-1 plurality; d's vote made it
+ // 2-2, and from that point no NEW flags may appear.
+ before := testutil.ToFloat64(rpcHashMismatch.WithLabelValues("d", chain, currentRegion))
+ if before != 0 {
+ t.Fatalf("d flagged on a 2-2 split: %v", before)
+ }
+}
+
+func TestObserveConsensusLagGauge(t *testing.T) {
+ tr := newConsensusTracker()
+ chain := "testchain-lag"
+ tips.update(chain, 200)
+
+ tr.observe(chain, "laggy", 197, "0xaa")
+ got := testutil.ToFloat64(rpcConsensusLag.WithLabelValues("laggy", chain, currentRegion))
+ if got != 3 {
+ t.Fatalf("lag gauge = %v, want 3", got)
+ }
+
+ // Provider at (or ahead of) the recorded tip reads 0.
+ tr.observe(chain, "fresh", 200, "0xaa")
+ if got := testutil.ToFloat64(rpcConsensusLag.WithLabelValues("fresh", chain, currentRegion)); got != 0 {
+ t.Fatalf("fresh lag gauge = %v, want 0", got)
+ }
+
+ // Solana path: empty hash emits lag but never votes.
+ tr.observe(chain, "solananode", 195, "")
+ if got := testutil.ToFloat64(rpcConsensusLag.WithLabelValues("solananode", chain, currentRegion)); got != 5 {
+ t.Fatalf("solana lag gauge = %v, want 5", got)
+ }
+ if got := testutil.ToFloat64(rpcHashMismatch.WithLabelValues("solananode", chain, currentRegion)); got != 0 {
+ t.Fatalf("hashless observation voted: %v", got)
+ }
+}
+
+func TestConsensusPruning(t *testing.T) {
+ tr := newConsensusTracker()
+ chain := "testchain-prune"
+ tips.update(chain, 10)
+
+ tr.observe(chain, "a", 10, "0xaa")
+ tips.update(chain, 10+consensusPruneDepth+5)
+ tr.observe(chain, "a", 10+consensusPruneDepth+5, "0xbb")
+
+ tr.mu.Lock()
+ defer tr.mu.Unlock()
+ if _, ok := tr.chains[chain].heights[10]; ok {
+ t.Fatalf("height 10 not pruned at tip %d", 10+consensusPruneDepth+5)
+ }
+}
+
+func TestMajority(t *testing.T) {
+ if v, ok := majority(map[int]int{1371: 3, 1370: 1}); !ok || v != 1371 {
+ t.Fatalf("majority(3-1) = %v %v", v, ok)
+ }
+ if _, ok := majority(map[int]int{1371: 2, 1370: 2}); ok {
+ t.Fatalf("tie must not resolve")
+ }
+ if _, ok := majority(map[int]int{1371: 1}); ok {
+ t.Fatalf("single answer must not resolve")
+ }
+ if v, ok := majority(map[string]int{"0x1": 2}); !ok || v != "0x1" {
+ t.Fatalf("majority(2-0) = %v %v", v, ok)
+ }
+}
diff --git a/harnesses/rpc-capabilities/cmd/script/integrity.go b/harnesses/rpc-capabilities/cmd/script/integrity.go
new file mode 100644
index 00000000..e8663ed4
--- /dev/null
+++ b/harnesses/rpc-capabilities/cmd/script/integrity.go
@@ -0,0 +1,304 @@
+package main
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "time"
+)
+
+// integrity.go — bench 083 rpc-reliability, fixed-vector correctness
+// checks. Every 5 minutes ONE chain (rotating through the set below)
+// gets the same two questions asked of every provider serving it:
+//
+// logs — `eth_getLogs` for the chain's canonical USDC contract
+// over a 10-block window ending at tip-N. Completeness:
+// a provider silently dropping logs (or blocking the
+// method, or gating the depth behind a paid tier) shows
+// up against the cross-provider majority count.
+// balance — `eth_getBalance` of a fixed well-known address at the
+// same tip-N block. Consistency: the hex answer must be
+// byte-identical across providers; the comparison works
+// whether or not the balance is non-zero.
+//
+// Anti-gaming: N rotates daily over {20, 30, 40, 50, 60} blocks so a
+// provider cannot special-case a fixed range, and every request id
+// rotates (same edge-cache defeat as the latency probe). All depths
+// stay far inside non-archive territory so pruned-but-honest nodes
+// are never penalized; a provider that gates even 60-blocks-deep data
+// behind a key (observed live: publicnode -32602 "Archive requests
+// require a personal token") is emitting exactly the signal this
+// bench exists to record.
+//
+// Errors ARE signal: result="error" on rpc_integrity_check_total is
+// counted as an incident by the spec, because "method blocked" and
+// "depth gated" are reliability failures from the caller's seat.
+
+const (
+ integrityInterval = 5 * time.Minute
+ integrityTimeout = 15 * time.Second
+ // integrityCallSpacing: sequential per-provider spacing; meowrpc
+ // 429s on rapid bursts (same lesson as archive.go).
+ integrityCallSpacing = 1500 * time.Millisecond
+ // integrityLogsSpan: width of the getLogs window, in blocks.
+ integrityLogsSpan uint64 = 10
+ // integrityBalanceAddr: same well-known address the archive loop
+ // uses (Vitalik). The check compares answers across providers
+ // byte-for-byte, so it is divergence we measure, not the value.
+ integrityBalanceAddr = archiveTestAddr
+)
+
+// integrityVectors maps chain slug -> canonical USDC contract used as
+// the fixed eth_getLogs vector. Only chains with a heavily traded
+// canonical USDC participate (a quiet contract would return 0 logs
+// everywhere and the completeness check would be vacuous). Native
+// Circle deployments except BNB (Binance-peg, still the busiest
+// USDC-family contract there).
+var integrityVectors = map[string]string{
+ "ethereum": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
+ "arbitrum": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831",
+ "optimism": "0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85",
+ "base": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
+ "polygon": "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359",
+ "bnb": "0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d",
+ "avalanche": "0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E",
+}
+
+// integrityOffset returns today's tip-N base depth: 20 + 10*(day%5),
+// i.e. {20, 30, 40, 50, 60}, rotating at UTC midnight.
+func integrityOffset() uint64 {
+ day := uint64(time.Now().UTC().Unix() / 86400)
+ return 20 + 10*(day%5)
+}
+
+// StartIntegrityLoop rotates through the vector-equipped chains, one
+// chain per 5-minute tick. Non-blocking; spawns its own goroutine.
+func StartIntegrityLoop(ctx context.Context) {
+ var targets []Chain
+ for _, c := range chains() {
+ if c.Kind == "" && integrityVectors[c.Slug] != "" {
+ targets = append(targets, c)
+ }
+ }
+ if len(targets) == 0 {
+ return
+ }
+ initIntegrityMetrics(targets)
+
+ go func() {
+ // Let the latency loop populate chainTips first: the vector
+ // block range is derived from the cross-provider tip.
+ select {
+ case <-ctx.Done():
+ return
+ case <-time.After(90 * time.Second):
+ }
+ i := 0
+ integrityTick(ctx, targets[i%len(targets)])
+ i++
+ t := time.NewTicker(integrityInterval)
+ defer t.Stop()
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-t.C:
+ integrityTick(ctx, targets[i%len(targets)])
+ i++
+ }
+ }
+ }()
+}
+
+type integrityObs struct {
+ p Provider
+ logsN int
+ logsErr error
+ balHex string
+ balErr error
+}
+
+func integrityTick(ctx context.Context, c Chain) {
+ tip := tips.get(c.Slug)
+ off := integrityOffset()
+ if tip <= off+integrityLogsSpan {
+ fmt.Printf("[integrity/%s] no tip yet (tip=%d), skipping round\n", c.Slug, tip)
+ return
+ }
+ to := tip - off
+ from := to - (integrityLogsSpan - 1)
+
+ obs := make([]integrityObs, 0, len(c.Providers))
+ for _, p := range c.Providers {
+ o := integrityObs{p: p}
+ o.logsN, o.logsErr = fetchLogsCount(ctx, p.URL, integrityVectors[c.Slug], from, to)
+ if !integritySleep(ctx) {
+ return
+ }
+ o.balHex, o.balErr = fetchBalanceHex(ctx, p.URL, integrityBalanceAddr, to)
+ obs = append(obs, o)
+ if !integritySleep(ctx) {
+ return
+ }
+ }
+
+ // Logs completeness: strict majority of the successful counts.
+ logsCounts := make(map[int]int)
+ for _, o := range obs {
+ if o.logsErr == nil {
+ logsCounts[o.logsN]++
+ }
+ }
+ majLogs, logsQuorum := majority(logsCounts)
+ for _, o := range obs {
+ switch {
+ case o.logsErr != nil:
+ rpcLogsCount.DeleteLabelValues(o.p.Slug, c.Slug, currentRegion)
+ rpcIntegrityCheck.WithLabelValues(o.p.Slug, c.Slug, currentRegion, "logs", "error").Inc()
+ fmt.Printf("[integrity/%s/%s] logs range=%d-%d err=%v\n", c.Slug, o.p.Slug, from, to, o.logsErr)
+ case logsQuorum && o.logsN != majLogs:
+ rpcLogsCount.WithLabelValues(o.p.Slug, c.Slug, currentRegion).Set(float64(o.logsN))
+ rpcLogsDisagreement.WithLabelValues(o.p.Slug, c.Slug, currentRegion).Inc()
+ rpcIntegrityCheck.WithLabelValues(o.p.Slug, c.Slug, currentRegion, "logs", "disagree").Inc()
+ fmt.Printf("[integrity/%s/%s] logs DISAGREE got=%d majority=%d range=%d-%d\n", c.Slug, o.p.Slug, o.logsN, majLogs, from, to)
+ default:
+ // No >=2 quorum this round (too few successes) also lands
+ // here: a lone answer is unverifiable, not wrong.
+ rpcLogsCount.WithLabelValues(o.p.Slug, c.Slug, currentRegion).Set(float64(o.logsN))
+ rpcIntegrityCheck.WithLabelValues(o.p.Slug, c.Slug, currentRegion, "logs", "ok").Inc()
+ fmt.Printf("[integrity/%s/%s] logs ok count=%d range=%d-%d\n", c.Slug, o.p.Slug, o.logsN, from, to)
+ }
+ }
+
+ // State consistency: strict majority of the raw hex answers.
+ balCounts := make(map[string]int)
+ for _, o := range obs {
+ if o.balErr == nil {
+ balCounts[o.balHex]++
+ }
+ }
+ majBal, balQuorum := majority(balCounts)
+ for _, o := range obs {
+ switch {
+ case o.balErr != nil:
+ rpcIntegrityCheck.WithLabelValues(o.p.Slug, c.Slug, currentRegion, "balance", "error").Inc()
+ fmt.Printf("[integrity/%s/%s] balance block=%d err=%v\n", c.Slug, o.p.Slug, to, o.balErr)
+ case balQuorum && o.balHex != majBal:
+ rpcStateDisagreement.WithLabelValues(o.p.Slug, c.Slug, currentRegion).Inc()
+ rpcIntegrityCheck.WithLabelValues(o.p.Slug, c.Slug, currentRegion, "balance", "disagree").Inc()
+ fmt.Printf("[integrity/%s/%s] balance DISAGREE got=%s majority=%s block=%d\n", c.Slug, o.p.Slug, o.balHex, majBal, to)
+ default:
+ rpcIntegrityCheck.WithLabelValues(o.p.Slug, c.Slug, currentRegion, "balance", "ok").Inc()
+ fmt.Printf("[integrity/%s/%s] balance ok block=%d\n", c.Slug, o.p.Slug, to)
+ }
+ }
+}
+
+func integritySleep(ctx context.Context) bool {
+ select {
+ case <-ctx.Done():
+ return false
+ case <-time.After(integrityCallSpacing):
+ return true
+ }
+}
+
+// majority returns the value backed by >=2 votes and strictly more
+// than any competing value; ok=false when no such strict majority
+// exists (all-distinct answers, or a tie).
+func majority[T comparable](counts map[T]int) (T, bool) {
+ var best T
+ bestN, secondN := 0, 0
+ for v, n := range counts {
+ if n > bestN {
+ best, secondN, bestN = v, bestN, n
+ } else if n > secondN {
+ secondN = n
+ }
+ }
+ return best, bestN >= 2 && bestN > secondN
+}
+
+func fetchLogsCount(ctx context.Context, url, addr string, from, to uint64) (int, error) {
+ body := fmt.Sprintf(
+ `{"jsonrpc":"2.0","method":"eth_getLogs","params":[{"fromBlock":"0x%x","toBlock":"0x%x","address":"%s"}],"id":%d}`,
+ from, to, addr, time.Now().UnixNano(),
+ )
+ raw, err := integrityPost(ctx, url, body)
+ if err != nil {
+ return 0, err
+ }
+ var r rpcBlockEnvelope
+ if err := json.Unmarshal(raw, &r); err != nil {
+ return 0, err
+ }
+ if r.Error != nil {
+ return 0, fmt.Errorf("rpc %d: %s", r.Error.Code, r.Error.Message)
+ }
+ var logs []json.RawMessage
+ if err := json.Unmarshal(r.Result, &logs); err != nil {
+ return 0, fmt.Errorf("non-array result")
+ }
+ return len(logs), nil
+}
+
+func fetchBalanceHex(ctx context.Context, url, addr string, block uint64) (string, error) {
+ body := fmt.Sprintf(
+ `{"jsonrpc":"2.0","method":"eth_getBalance","params":["%s","0x%x"],"id":%d}`,
+ addr, block, time.Now().UnixNano(),
+ )
+ raw, err := integrityPost(ctx, url, body)
+ if err != nil {
+ return "", err
+ }
+ var r rpcEnvelope
+ if err := json.Unmarshal(raw, &r); err != nil {
+ return "", err
+ }
+ if r.Error != nil {
+ return "", fmt.Errorf("rpc %d: %s", r.Error.Code, r.Error.Message)
+ }
+ if r.Result == "" {
+ return "", fmt.Errorf("empty result")
+ }
+ return r.Result, nil
+}
+
+func integrityPost(ctx context.Context, url, body string) ([]byte, error) {
+ c, cancel := context.WithTimeout(ctx, integrityTimeout)
+ defer cancel()
+ req, _ := http.NewRequestWithContext(c, "POST", url, bytes.NewReader([]byte(body)))
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("User-Agent", "OpenChainBench/1.0 (+https://openchainbench.com)")
+ client := &http.Client{Timeout: integrityTimeout}
+ resp, err := client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != 200 {
+ _, _ = io.Copy(io.Discard, resp.Body)
+ return nil, fmt.Errorf("http %d", resp.StatusCode)
+ }
+ return io.ReadAll(resp.Body)
+}
+
+// initIntegrityMetrics zero-initializes every counter in the vector
+// matrix so the bench's `increase()` queries read 0, not absent, for
+// clean providers.
+func initIntegrityMetrics(targets []Chain) {
+ for _, c := range targets {
+ for _, p := range c.Providers {
+ for _, check := range []string{"logs", "balance"} {
+ for _, result := range []string{"ok", "error", "disagree"} {
+ rpcIntegrityCheck.WithLabelValues(p.Slug, c.Slug, currentRegion, check, result).Add(0)
+ }
+ }
+ rpcLogsDisagreement.WithLabelValues(p.Slug, c.Slug, currentRegion).Add(0)
+ rpcStateDisagreement.WithLabelValues(p.Slug, c.Slug, currentRegion).Add(0)
+ }
+ }
+}
diff --git a/harnesses/rpc-capabilities/cmd/script/main.go b/harnesses/rpc-capabilities/cmd/script/main.go
index 9bea8647..9b18b764 100644
--- a/harnesses/rpc-capabilities/cmd/script/main.go
+++ b/harnesses/rpc-capabilities/cmd/script/main.go
@@ -73,11 +73,16 @@ func main() {
}
}
fmt.Println()
- fmt.Println("Metrics server: :2112/metrics")
+
+ // OCB_METRICS_ADDR: explicit local-run override only (e.g. a dev
+ // box where :2112 is taken). We still ignore Railway's injected
+ // $PORT on purpose; Prometheus scrapes :2112 in prod.
+ addr := envDefault("OCB_METRICS_ADDR", ":2112")
+ fmt.Printf("Metrics server: %s/metrics\n", addr)
fmt.Println()
go func() {
- if err := StartMetricsServer(":2112"); err != nil {
+ if err := StartMetricsServer(addr); err != nil {
fmt.Printf("[fatal] metrics server: %v\n", err)
os.Exit(1)
}
@@ -88,6 +93,7 @@ func main() {
StartProbeLoop(ctx)
StartArchiveLoop(ctx)
+ StartIntegrityLoop(ctx)
sig := make(chan os.Signal, 1)
signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
diff --git a/harnesses/rpc-capabilities/cmd/script/metrics.go b/harnesses/rpc-capabilities/cmd/script/metrics.go
index df15637f..304c860e 100644
--- a/harnesses/rpc-capabilities/cmd/script/metrics.go
+++ b/harnesses/rpc-capabilities/cmd/script/metrics.go
@@ -57,6 +57,57 @@ var (
},
[]string{"provider", "chain", "region", "depth"},
)
+
+ // ─── Bench 083 rpc-reliability: correctness / integrity metrics ────
+ // Emitted from the same probe matrix; consensus.go + integrity.go.
+
+ rpcConsensusLag = promauto.NewGaugeVec(
+ prometheus.GaugeOpts{
+ Name: "rpc_consensus_lag_blocks",
+ Help: "Blocks (slots on Solana) between this provider's reported head and the highest head any probed provider reported for the same chain. Set on every valid head probe (ok or stale); deleted on failure so a dead endpoint ages out instead of freezing at its last lag.",
+ },
+ []string{"provider", "chain", "region"},
+ )
+
+ rpcHashMismatch = promauto.NewCounterVec(
+ prometheus.CounterOpts{
+ Name: "rpc_hash_mismatch_total",
+ Help: "Times a provider reported a block hash at height H that disagrees with the hash at least 2 other-or-same providers agreed on at H (strict plurality). Incremented at most once per (provider, height); a 2-2 reorg split yields no quorum and nobody is counted.",
+ },
+ []string{"provider", "chain", "region"},
+ )
+
+ rpcLogsCount = promauto.NewGaugeVec(
+ prometheus.GaugeOpts{
+ Name: "rpc_logs_count",
+ Help: "Number of logs the provider returned for the fixed-vector `eth_getLogs` check (canonical USDC contract, 10-block range at a daily-rotating depth behind tip). Deleted when the call errors so a blocked method doesn't freeze a stale count.",
+ },
+ []string{"provider", "chain", "region"},
+ )
+
+ rpcLogsDisagreement = promauto.NewCounterVec(
+ prometheus.CounterOpts{
+ Name: "rpc_logs_disagreement_total",
+ Help: "Times a provider's `eth_getLogs` count for the fixed vector deviated from the strict cross-provider majority count in the same round.",
+ },
+ []string{"provider", "chain", "region"},
+ )
+
+ rpcStateDisagreement = promauto.NewCounterVec(
+ prometheus.CounterOpts{
+ Name: "rpc_state_disagreement_total",
+ Help: "Times a provider's `eth_getBalance` hex at the fixed recent block was not byte-identical to the strict cross-provider majority answer in the same round.",
+ },
+ []string{"provider", "chain", "region"},
+ )
+
+ rpcIntegrityCheck = promauto.NewCounterVec(
+ prometheus.CounterOpts{
+ Name: "rpc_integrity_check_total",
+ Help: "Fixed-vector integrity check outcomes. check is `logs` (eth_getLogs USDC 10-block window) or `balance` (eth_getBalance at a fixed recent block). result is ok (matches majority), error (method blocked, archive gated, transport failure: errors are signal), or disagree (diverged from the >=2-provider majority).",
+ },
+ []string{"provider", "chain", "region", "check", "result"},
+ )
)
// StartMetricsServer binds /metrics + /health on addr. Blocking call —
diff --git a/harnesses/rpc-capabilities/cmd/script/probe.go b/harnesses/rpc-capabilities/cmd/script/probe.go
index 96859460..a8b92ddb 100644
--- a/harnesses/rpc-capabilities/cmd/script/probe.go
+++ b/harnesses/rpc-capabilities/cmd/script/probe.go
@@ -88,6 +88,7 @@ type rpcBlockEnvelope struct {
type blockHeader struct {
Number string `json:"number"`
+ Hash string `json:"hash"`
}
// callLatestBlock issues `eth_getBlockByNumber("latest", false)` and
@@ -100,8 +101,9 @@ type blockHeader struct {
// node, which let cache-fronted gateways top the latency leaderboard
// on cache hits rather than real RPC work. Fetching the full latest
// header with a rotating request id defeats body-keyed edge caches;
-// the header's `number` field keeps the staleness check intact.
-func callLatestBlock(ctx context.Context, url string) (block uint64, result string, latencyMs float64, err error) {
+// the header's `number` field keeps the staleness check intact and its
+// `hash` feeds the bench-083 cross-provider quorum map (consensus.go).
+func callLatestBlock(ctx context.Context, url string) (block uint64, hash string, result string, latencyMs float64, err error) {
body := []byte(fmt.Sprintf(
`{"jsonrpc":"2.0","method":"eth_getBlockByNumber","params":["latest",false],"id":%d}`,
time.Now().UnixNano(),
@@ -117,48 +119,49 @@ func callLatestBlock(ctx context.Context, url string) (block uint64, result stri
if err != nil {
if ctx.Err() != nil || strings.Contains(err.Error(), "deadline exceeded") || strings.Contains(err.Error(), "Timeout") {
- return 0, "timeout", latencyMs, err
+ return 0, "", "timeout", latencyMs, err
}
- return 0, "http_err", latencyMs, err
+ return 0, "", "http_err", latencyMs, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
_, _ = io.Copy(io.Discard, resp.Body)
- return 0, "http_err", latencyMs, fmt.Errorf("status %d", resp.StatusCode)
+ return 0, "", "http_err", latencyMs, fmt.Errorf("status %d", resp.StatusCode)
}
raw, err := io.ReadAll(resp.Body)
if err != nil {
- return 0, "http_err", latencyMs, err
+ return 0, "", "http_err", latencyMs, err
}
var r rpcBlockEnvelope
if err := json.Unmarshal(raw, &r); err != nil {
- return 0, "http_err", latencyMs, err
+ return 0, "", "http_err", latencyMs, err
}
if r.Error != nil {
- return 0, "jsonrpc_err", latencyMs, fmt.Errorf("rpc -%d: %s", r.Error.Code, r.Error.Message)
+ return 0, "", "jsonrpc_err", latencyMs, fmt.Errorf("rpc -%d: %s", r.Error.Code, r.Error.Message)
}
if len(r.Result) == 0 || string(r.Result) == "null" {
- return 0, "jsonrpc_err", latencyMs, fmt.Errorf("empty result")
+ return 0, "", "jsonrpc_err", latencyMs, fmt.Errorf("empty result")
}
var hdr blockHeader
if err := json.Unmarshal(r.Result, &hdr); err != nil {
- return 0, "jsonrpc_err", latencyMs, err
+ return 0, "", "jsonrpc_err", latencyMs, err
}
if hdr.Number == "" {
- return 0, "jsonrpc_err", latencyMs, fmt.Errorf("header missing number")
+ return 0, "", "jsonrpc_err", latencyMs, fmt.Errorf("header missing number")
}
n, err := strconv.ParseUint(strings.TrimPrefix(hdr.Number, "0x"), 16, 64)
if err != nil {
- return 0, "jsonrpc_err", latencyMs, err
+ return 0, "", "jsonrpc_err", latencyMs, err
}
- return n, "ok", latencyMs, nil
+ return n, hdr.Hash, "ok", latencyMs, nil
}
// StartProbeLoop spawns one goroutine per (chain × provider). Each
// goroutine runs forever, ticking every probeInterval.
func StartProbeLoop(ctx context.Context) {
+ initConsensusMetrics()
for _, c := range chains() {
c := c
for _, p := range c.Providers {
@@ -185,6 +188,7 @@ func probeOne(ctx context.Context, c Chain, p Provider) {
probeCtx, cancel := context.WithTimeout(ctx, probeTimeout)
defer cancel()
var block uint64
+ var hash string
var result string
var latency float64
var err error
@@ -192,9 +196,9 @@ func probeOne(ctx context.Context, c Chain, p Provider) {
case "solana":
block, result, latency, err = callLatestSlot(probeCtx, p.URL)
case "polkadot":
- block, result, latency, err = callSubstrateHeader(probeCtx, p.URL)
+ block, hash, result, latency, err = callSubstrateHeader(probeCtx, p.URL)
default:
- block, result, latency, err = callLatestBlock(probeCtx, p.URL)
+ block, hash, result, latency, err = callLatestBlock(probeCtx, p.URL)
}
if result == "ok" {
@@ -211,6 +215,23 @@ func probeOne(ctx context.Context, c Chain, p Provider) {
result = "stale"
}
}
+ // Bench 083: valid observations (fresh or stale, both carry a
+ // real height + hash) feed the consensus lag gauge and the
+ // height→hash quorum map; anything else deletes the lag series
+ // so a dead endpoint ages out instead of freezing. Skipped on
+ // chains whose probe cannot return the current-block hash
+ // (Solana: getSlot returns a number only; Polkadot: header
+ // carries parentHash, not the current block hash).
+ switch c.Kind {
+ case "solana", "polkadot":
+ // no consensus participation
+ default:
+ if result == "ok" || result == "stale" {
+ consensus.observe(c.Slug, p.Slug, block, hash)
+ } else {
+ rpcConsensusLag.DeleteLabelValues(p.Slug, c.Slug, currentRegion)
+ }
+ }
rpcCallTotal.WithLabelValues(p.Slug, c.Slug, currentRegion, result).Inc()
if result == "ok" {
// Latency is recorded ONLY for fresh, valid responses. Error
@@ -309,19 +330,24 @@ func callLatestSlot(ctx context.Context, url string) (slot uint64, result string
}
// substrateHeader is the shape of the `chain_getHeader` result on
-// Polkadot and every Substrate-based relay chain. The block number is
-// hex-encoded (`0x` prefix) matching the EVM header convention, so the
-// parse path reuses the same strconv rule.
+// Polkadot / Kusama and every Substrate-based relay chain. The block
+// number is hex-encoded (`0x` prefix) matching the EVM header convention,
+// so the parse path reuses the same strconv rule. `parentHash` is here
+// so the consensus.go quorum map can key on it identically to EVM
+// (bench 083 cross-provider height-hash agreement).
type substrateHeader struct {
- Number string `json:"number"`
+ Number string `json:"number"`
+ ParentHash string `json:"parentHash"`
}
// callSubstrateHeader is the Polkadot probe path: chain_getHeader with a
// rotating request id (same anti-cache rule as the EVM header fetch).
-// Returns the relay-chain block height so the caller can plug into the
-// same tips machinery the EVM path uses; staleness classification in
-// probeOne uses polkadotStaleBlockGap.
-func callSubstrateHeader(ctx context.Context, url string) (block uint64, result string, latencyMs float64, err error) {
+// Returns the block number and parent hash so the caller can plug the
+// probe result into the same tips / consensus machinery the EVM chain
+// path uses. The returned "block" is the relay-chain block height, not
+// a chain-agnostic slot: staleness classification uses
+// polkadotStaleBlockGap in probeOne.
+func callSubstrateHeader(ctx context.Context, url string) (block uint64, hash string, result string, latencyMs float64, err error) {
body := []byte(fmt.Sprintf(
`{"jsonrpc":"2.0","method":"chain_getHeader","params":[],"id":%d}`,
time.Now().UnixNano(),
@@ -337,41 +363,41 @@ func callSubstrateHeader(ctx context.Context, url string) (block uint64, result
if err != nil {
if ctx.Err() != nil || strings.Contains(err.Error(), "deadline exceeded") || strings.Contains(err.Error(), "Timeout") {
- return 0, "timeout", latencyMs, err
+ return 0, "", "timeout", latencyMs, err
}
- return 0, "http_err", latencyMs, err
+ return 0, "", "http_err", latencyMs, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
_, _ = io.Copy(io.Discard, resp.Body)
- return 0, "http_err", latencyMs, fmt.Errorf("status %d", resp.StatusCode)
+ return 0, "", "http_err", latencyMs, fmt.Errorf("status %d", resp.StatusCode)
}
raw, err := io.ReadAll(resp.Body)
if err != nil {
- return 0, "http_err", latencyMs, err
+ return 0, "", "http_err", latencyMs, err
}
var r rpcBlockEnvelope
if err := json.Unmarshal(raw, &r); err != nil {
- return 0, "http_err", latencyMs, err
+ return 0, "", "http_err", latencyMs, err
}
if r.Error != nil {
- return 0, "jsonrpc_err", latencyMs, fmt.Errorf("rpc -%d: %s", r.Error.Code, r.Error.Message)
+ return 0, "", "jsonrpc_err", latencyMs, fmt.Errorf("rpc -%d: %s", r.Error.Code, r.Error.Message)
}
if len(r.Result) == 0 || string(r.Result) == "null" {
- return 0, "jsonrpc_err", latencyMs, fmt.Errorf("empty result")
+ return 0, "", "jsonrpc_err", latencyMs, fmt.Errorf("empty result")
}
var hdr substrateHeader
if err := json.Unmarshal(r.Result, &hdr); err != nil {
- return 0, "jsonrpc_err", latencyMs, err
+ return 0, "", "jsonrpc_err", latencyMs, err
}
if hdr.Number == "" {
- return 0, "jsonrpc_err", latencyMs, fmt.Errorf("substrate header missing number")
+ return 0, "", "jsonrpc_err", latencyMs, fmt.Errorf("substrate header missing number")
}
n, err := strconv.ParseUint(strings.TrimPrefix(hdr.Number, "0x"), 16, 64)
if err != nil {
- return 0, "jsonrpc_err", latencyMs, err
+ return 0, "", "jsonrpc_err", latencyMs, err
}
- return n, "ok", latencyMs, nil
+ return n, hdr.ParentHash, "ok", latencyMs, nil
}
diff --git a/harnesses/rpc-capabilities/go.mod b/harnesses/rpc-capabilities/go.mod
index 74e2e1fb..3c5d672c 100644
--- a/harnesses/rpc-capabilities/go.mod
+++ b/harnesses/rpc-capabilities/go.mod
@@ -8,6 +8,7 @@ require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/kr/text v0.2.0 // indirect
+ github.com/kylelemons/godebug v1.1.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.66.1 // indirect
From beaa79fe1e194a33bc1d172bb7e35e038ea917f0 Mon Sep 17 00:00:00 2001
From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com>
Date: Thu, 16 Jul 2026 13:03:31 +0200
Subject: [PATCH 21/89] add bench 087 ws-head-latency-base + 088
ws-head-latency-solana (#1196) (#1197)
Per-chain siblings of bench 081 for the WebSocket head-lag cluster.
Same harness (harnesses/ws-head-latency), same race scoring, one
spec per chain to avoid mixing block cadences (Ethereum 12s vs Base
2s vs Solana 400ms) in a single leaderboard.
Base cohort: PublicNode + dRPC keyless endpoints. Tenderly does
not expose a keyless Base WebSocket path.
Solana cohort: PublicNode + Solana Foundation. slotSubscribe
replaces newHeads on the harness side; metric names are shared so
the queries are structurally identical to the EVM specs.
Data already flows via the Paris VPS deployment (chain label carries
base and solana). No harness change needed for this ship. Multi-region
is a follow-up on the parent bench.
Co-authored-by: Florent Tapponnier
---
benchmarks/ws-head-latency-base.yml | 102 +++++++++++++++++++++++++
benchmarks/ws-head-latency-solana.yml | 105 ++++++++++++++++++++++++++
2 files changed, 207 insertions(+)
create mode 100644 benchmarks/ws-head-latency-base.yml
create mode 100644 benchmarks/ws-head-latency-solana.yml
diff --git a/benchmarks/ws-head-latency-base.yml b/benchmarks/ws-head-latency-base.yml
new file mode 100644
index 00000000..052d70a3
--- /dev/null
+++ b/benchmarks/ws-head-latency-base.yml
@@ -0,0 +1,102 @@
+# OpenChainBench. Bench № 087
+
+slug: ws-head-latency-base
+number: "087"
+title: Fastest Base WebSocket newHeads push, live block-push lag across RPC providers
+seo_title: "Fastest Base WebSocket RPC newHeads push 2026"
+seo_description: "{{best_name}} pushes Base newHeads first. Live WebSocket block-push lag for the free keyless Base cohort, measured per block against the earliest arrival."
+subtitle: Per-block WebSocket push lag in milliseconds versus the earliest provider to deliver the same Base head, measured continuously from one eu-west vantage point.
+
+category: RPCs
+status: live
+metric: Block push lag
+unit: ms
+higher_is_better: false
+
+seo_intro: |
+ Base blocks land every 2 seconds, six times more frequently than
+ Ethereum, and any real-time bot on Base (perp keeper, MEV searcher,
+ live dashboard) inherits its provider's head push pipeline six times
+ as often. This page ranks the free keyless Base WebSocket endpoints
+ by the same relative race the Ethereum sibling uses: earliest
+ provider to deliver block N sets T0, every other provider's sample
+ is arrival minus T0. The harness holds one persistent
+ `eth_subscribe("newHeads")` connection per provider from an eu-west
+ host and timestamps every frame on receipt. Sample volume on Base
+ is substantially higher than Ethereum thanks to the faster cadence,
+ so the leaderboard reflects a rich distribution rather than a
+ handful of tail observations. Provider cohort at launch: PublicNode
+ and dRPC keyless endpoints. Tenderly does not expose a keyless Base
+ WebSocket path at the time of ship.
+
+abstract: |
+ Per-chain member of the WebSocket head-latency cluster, applied to
+ Base. Same harness, same race scoring rules, same reporting
+ cadence: one persistent WebSocket per provider from the same
+ eu-west host, cohort settle window 5 seconds after the first
+ arrival, lag histogram per provider, gap counter for missed blocks
+ inside a live subscription. The cross-chain view lives on the
+ parent [ws-head-latency](https://openchainbench.com/benchmarks/ws-head-latency)
+ benchmark; this page is the Base-scoped answer.
+
+methodology:
+ - "Providers measured (keyless WebSocket endpoints): PublicNode (wss://base-rpc.publicnode.com), dRPC (wss://base.drpc.org). Tenderly does not expose a keyless Base WebSocket path and is scoped out until a public gateway is announced."
+ - "Chain scope: every query on this page is pinned to chain=\"base\". Base block cadence is 2 seconds, so a 24h window carries roughly 43000 samples per provider (versus ~7200 on Ethereum), well above the 7000 sample floor used by the parent bench."
+ - "Race scoring: identical to the parent [ws-head-latency](https://openchainbench.com/benchmarks/ws-head-latency) benchmark. First provider to deliver block N sets T0. Each provider's sample for block N is arrival(N) minus T0 in milliseconds. Cohort closes 5 seconds after the first arrival; late arrivals are scored as missed blocks via ws_block_gap_total rather than as huge latency samples."
+ - "Relative, not absolute: subtraction of two arrivals over the same path removes vantage-point network latency. Consequence: the fastest provider reads near zero by construction. The honest readings are win rate, trailers' lag distribution, and gap counts."
+ - "Aggregation: per-provider lag histogram (5ms to 10s exponential buckets), p50/p90/p99 via histogram_quantile over the 24h rate. Coverage score = ws_head_blocks_seen_total divided by cohort maximum, clamped to 1. A provider that pushes fast but skips blocks loses on this column, not on the latency percentiles."
+ - "Connection discipline: one persistent WebSocket per (provider, chain), client ping every 30s, read deadline 60s, 90s head watchdog forcing reconnect when a socket keeps the heartbeat alive but silently drops the subscription. Exponential backoff 2s to 60s on reconnect."
+ - "Vantage point: a single eu-west host. Multi-region deployment is a follow-up on the parent bench roadmap; the same limitation and the reasoning behind it are documented there."
+
+findings:
+ - "{{best_name}} leads the Base cohort at {{best_p50}} (p50, 24 h) across {{count}} measured providers. Base's 2 second cadence means each provider gets 6 times more samples per unit time than the Ethereum sibling; the distribution here has less tail noise than on Ethereum for the same 24h window."
+ - "{{name:drpc}} trails the winner by {{p50:drpc}} at the median on Base. That is the extra time a Base perp keeper or liquidation bot on dRPC's keyless endpoint waits for block N after the fastest feed already delivered it."
+ - "The p99 column is the integration-grade number for a chain that produces a block every 2 seconds: {{p99:publicnode}} / {{p99:drpc}}. A multi-second p99 on Base means routinely missing an entire block cycle."
+ - "Base is 2 second cadence, Ethereum 12 seconds, Solana ~400ms slots. Cross-chain comparison of the absolute lag numbers is not meaningful; use each per-chain leaderboard on its own terms and the parent [ws-head-latency](https://openchainbench.com/benchmarks/ws-head-latency) page for the Ethereum reference."
+
+source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/ws-head-latency
+
+expected_n: 7000
+
+prometheus:
+ window: 24h
+ freshness_metric: ws_head_blocks_seen_total
+
+faq:
+ - q: "What is the fastest free Base RPC over WebSocket right now?"
+ a: "{{best_name}} currently wins most per-block races on Base with a median lag of {{best_p50}} versus the earliest arrival (p50, 24 h) across {{count}} measured keyless providers. The leaderboard re-scores on every Base block, roughly every 2 seconds, so the answer reflects sustained live measurement, not a one-off test. Check the win rate and the trailers' p99 before wiring a latency-sensitive Base bot to any single provider."
+ - q: "How is Base WebSocket head lag measured on OpenChainBench?"
+ a: "One Go harness holds a persistent `eth_subscribe(\"newHeads\")` WebSocket per provider from the same eu-west host. Every frame is timestamped on receipt at millisecond precision. Each block is scored as a race: earliest arrival sets T0 and every other provider's lag is arrival minus T0. The cohort closes 5 seconds after the first arrival, then p50/p90/p99 are computed via `histogram_quantile` over 24 hours."
+ - q: "Why does the fastest provider read near zero?"
+ a: "By construction. The measurement is relative to the fastest arrival in the cohort, not to an external reference clock, so the provider that wins most races accumulates mostly zero samples. This is deliberate: without a reference node co-located with the Base sequencer, a single vantage point cannot separate its own network latency from the provider's push pipeline. Read the leader's win rate and the trailers' distributions rather than the leader's absolute number."
+ - q: "Why isn't Tenderly ranked on Base?"
+ a: "Tenderly's public Base gateway does not expose a keyless WebSocket path for `eth_subscribe(\"newHeads\")` at the time this bench went live. When it does, the harness reads the endpoint from an env variable and Tenderly joins the leaderboard automatically."
+ - q: "Base blocks land every 2 seconds. How does that change the numbers?"
+ a: "Sample volume is roughly 6 times higher than Ethereum for the same 24h window. That tightens the confidence interval on every percentile: a 24h p50 on Base is drawn from ~43000 samples per provider versus ~7200 on Ethereum. The percentile numbers themselves are still relative to the per-block winner, so the interpretation is unchanged; the Base leaderboard just carries less tail noise than the Ethereum one."
+
+providers:
+ - slug: publicnode
+ name: PublicNode
+ tag: Keyless WS, wss://base-rpc.publicnode.com
+ formula: "Median ms behind the earliest provider to push each Base newHeads frame, from one persistent WebSocket per provider on the same eu-west host, histogram_quantile over 24h. The per-block winner scores 0 by construction."
+ queries:
+ p50: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="publicnode", chain="base"}[24h])))
+ p90: histogram_quantile(0.90, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="publicnode", chain="base"}[24h])))
+ p99: histogram_quantile(0.99, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="publicnode", chain="base"}[24h])))
+ mean: sum(rate(ws_head_lag_milliseconds_sum{provider="publicnode", chain="base"}[24h])) / sum(rate(ws_head_lag_milliseconds_count{provider="publicnode", chain="base"}[24h]))
+ success: clamp_max(sum(increase(ws_head_blocks_seen_total{provider="publicnode", chain="base"}[24h])) / max(sum by (provider) (increase(ws_head_blocks_seen_total{chain="base"}[24h]))), 1)
+ sample_size: sum(increase(ws_head_lag_milliseconds_count{provider="publicnode", chain="base"}[24h]))
+ series: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="publicnode", chain="base"}[1h])))
+
+ - slug: drpc
+ name: dRPC
+ tag: Keyless WS, wss://base.drpc.org
+ formula: "Median ms behind the earliest provider to push each Base newHeads frame, from one persistent WebSocket per provider on the same eu-west host, histogram_quantile over 24h. Lag is relative to the per-block winner."
+ queries:
+ p50: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="drpc", chain="base"}[24h])))
+ p90: histogram_quantile(0.90, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="drpc", chain="base"}[24h])))
+ p99: histogram_quantile(0.99, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="drpc", chain="base"}[24h])))
+ mean: sum(rate(ws_head_lag_milliseconds_sum{provider="drpc", chain="base"}[24h])) / sum(rate(ws_head_lag_milliseconds_count{provider="drpc", chain="base"}[24h]))
+ success: clamp_max(sum(increase(ws_head_blocks_seen_total{provider="drpc", chain="base"}[24h])) / max(sum by (provider) (increase(ws_head_blocks_seen_total{chain="base"}[24h]))), 1)
+ sample_size: sum(increase(ws_head_lag_milliseconds_count{provider="drpc", chain="base"}[24h]))
+ series: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="drpc", chain="base"}[1h])))
diff --git a/benchmarks/ws-head-latency-solana.yml b/benchmarks/ws-head-latency-solana.yml
new file mode 100644
index 00000000..c906903b
--- /dev/null
+++ b/benchmarks/ws-head-latency-solana.yml
@@ -0,0 +1,105 @@
+# OpenChainBench. Bench № 088
+
+slug: ws-head-latency-solana
+number: "088"
+title: Fastest Solana WebSocket slotSubscribe push, live slot-push lag across RPC providers
+seo_title: "Fastest Solana WebSocket RPC slotSubscribe push 2026"
+seo_description: "{{best_name}} pushes Solana slots first. Live WebSocket slot-push lag for the free keyless Solana cohort, measured per slot against the earliest arrival."
+subtitle: Per-slot WebSocket push lag in milliseconds versus the earliest provider to deliver the same Solana slot, measured continuously from one eu-west vantage point.
+
+category: RPCs
+status: live
+metric: Slot push lag
+unit: ms
+higher_is_better: false
+
+seo_intro: |
+ Solana slots land every ~400ms, thirty times faster than Ethereum
+ blocks, and every real-time integrator on Solana (perp keeper,
+ liquidator, market maker, on-chain scanner) inherits its provider's
+ slot push pipeline thirty times as often. This page ranks the free
+ keyless Solana WebSocket endpoints by the same relative race the
+ EVM sibling uses on `eth_subscribe("newHeads")`, applied to
+ Solana's `slotSubscribe` RPC. The harness holds one persistent
+ WebSocket per provider from an eu-west host and timestamps every
+ slot notification on receipt. Sample volume is roughly 30 times
+ higher than the Ethereum sibling for the same 24h window, so the
+ distribution is dense and tail estimates are stable. Provider
+ cohort at launch: PublicNode's public Solana gateway and the
+ Solana Foundation's `api.mainnet-beta.solana.com`.
+
+abstract: |
+ Per-chain member of the WebSocket head-latency cluster, applied to
+ Solana. Same harness, same race scoring rules, adapted for
+ Solana's slot model instead of EVM blocks: `slotSubscribe`
+ notifications carry a slot number, the harness treats each
+ distinct slot as one race, the first provider to deliver slot N
+ sets T0, every other provider's sample is arrival minus T0 in
+ milliseconds. The cross-chain view lives on the parent
+ [ws-head-latency](https://openchainbench.com/benchmarks/ws-head-latency)
+ benchmark; this page is the Solana-scoped answer.
+
+methodology:
+ - "Providers measured (keyless WebSocket endpoints): PublicNode (wss://solana-rpc.publicnode.com) and Solana Foundation (wss://api.mainnet-beta.solana.com). Alchemy and Helius are keyed-only and join the cohort when contributor keys are wired."
+ - "Chain scope: every query on this page is pinned to chain=\"solana\". Solana slot cadence is ~400ms, so a 24h window carries roughly 216000 samples per provider, an order of magnitude beyond the 7000 sample floor used across the head-lag cluster."
+ - "RPC method: `slotSubscribe`, not `signatureSubscribe` or `logsSubscribe`. The notification payload includes the slot number and the parent slot; the harness dedupes on slot number and uses the first arrival per slot to define T0."
+ - "Race scoring: identical to the parent [ws-head-latency](https://openchainbench.com/benchmarks/ws-head-latency) benchmark, applied per slot instead of per block. First provider to deliver slot N sets T0. Each provider's sample for slot N is arrival(N) minus T0 in milliseconds. Cohort closes 5 seconds after the first arrival."
+ - "Relative, not absolute: subtraction of two arrivals over the same path removes vantage-point network latency. Consequence: the fastest provider reads near zero by construction. The honest readings are win rate, trailers' lag distribution, and gap counts, not the leader's absolute number."
+ - "Aggregation: per-provider lag histogram (5ms to 10s exponential buckets), p50/p90/p99 via histogram_quantile over the 24h rate. Coverage score = ws_head_blocks_seen_total divided by cohort maximum, clamped to 1."
+ - "Connection discipline: one persistent WebSocket per (provider, chain), client ping every 30s, read deadline 60s, 90s slot watchdog forcing reconnect when a socket keeps the heartbeat alive but silently drops the subscription. Exponential backoff 2s to 60s on reconnect."
+ - "Vantage point: a single eu-west host. Multi-region deployment is a follow-up on the parent bench roadmap; the same limitation and reasoning are documented there."
+
+findings:
+ - "{{best_name}} leads the Solana cohort at {{best_p50}} (p50, 24 h) across {{count}} measured providers. Solana's ~400ms cadence means each provider gets 30 times more samples per unit time than the Ethereum sibling; the distribution here has very little tail noise for the same 24h window."
+ - "{{name:solana-labs}} versus {{name:publicnode}}: the Solana Foundation's public endpoint and PublicNode's Solana gateway measured on identical terms. A latency-sensitive Solana integration should read the win rate alongside p50 to see which endpoint delivers slot N first most often."
+ - "The p99 column matters more on Solana than on any EVM chain in the cluster: at ~400ms slots, a p99 above 1 second means the provider is routinely 2-3 slots behind the leader. That gap is the exact window where a keeper or liquidator loses to a co-located competitor."
+ - "Cross-chain comparison of absolute lag numbers is not meaningful: Solana runs at 400ms slots, Base at 2s, Ethereum at 12s. Use each per-chain leaderboard on its own terms and the parent [ws-head-latency](https://openchainbench.com/benchmarks/ws-head-latency) page for the Ethereum reference."
+
+source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/ws-head-latency
+
+expected_n: 50000
+
+prometheus:
+ window: 24h
+ freshness_metric: ws_head_blocks_seen_total
+
+faq:
+ - q: "What is the fastest free Solana RPC over WebSocket right now?"
+ a: "{{best_name}} currently wins most per-slot races on Solana with a median lag of {{best_p50}} versus the earliest arrival (p50, 24 h) across {{count}} measured keyless providers. The leaderboard re-scores on every Solana slot, roughly every 400ms, so the answer reflects sustained live measurement, not a one-off test. Check the win rate and the trailers' p99 before wiring a latency-sensitive Solana keeper or MEV integration to any single provider."
+ - q: "How is Solana WebSocket slot lag measured on OpenChainBench?"
+ a: "One Go harness holds a persistent `slotSubscribe` WebSocket per provider from the same eu-west host. Every notification is timestamped on receipt at millisecond precision, keyed on slot number, and deduplicated. Each slot is scored as a race: earliest arrival sets T0 and every other provider's lag is arrival minus T0. The cohort closes 5 seconds after the first arrival, then p50/p90/p99 are computed via `histogram_quantile` over 24 hours."
+ - q: "Why does the fastest provider read near zero?"
+ a: "By construction. The measurement is relative to the fastest arrival in the cohort, not to an external reference clock, so the provider that wins most races accumulates mostly zero samples. Without a validator co-located with block producers, a single vantage point cannot separate its own network latency from the provider's push pipeline. Read the leader's win rate and the trailers' distributions rather than the leader's absolute number."
+ - q: "Why aren't Alchemy and Helius on the leaderboard?"
+ a: "Both are keyed-only for Solana WebSocket subscriptions and V1 measures the keyless tier. The harness reads Alchemy and Helius endpoints from env variables and adds them to the cohort automatically once contributor keys are wired into the deployment."
+ - q: "Solana slots land every ~400ms. How does that change the numbers?"
+ a: "Sample volume is roughly 30 times higher than Ethereum for the same 24h window. Percentile estimates are drawn from ~216000 samples per provider versus ~7200 on Ethereum, so tail noise is negligible. The percentile numbers themselves are still relative to the per-slot winner, so the interpretation is unchanged. At Solana's cadence, a p99 above 1 second means the provider is routinely 2 to 3 slots behind the leader, which is the exact window where a keeper or liquidator loses to a co-located competitor."
+ - q: "Why `slotSubscribe` and not `signatureSubscribe` or `logsSubscribe`?"
+ a: "The race we score is head delivery: which endpoint pushes the newest thing first. On Solana that is a slot notification; `slotSubscribe` fires on every slot with the slot number and parent slot in the payload. `signatureSubscribe` targets one specific transaction and `logsSubscribe` filters by program, so neither is a fair head-delivery race across providers."
+
+providers:
+ - slug: publicnode
+ name: PublicNode
+ tag: Keyless WS, wss://solana-rpc.publicnode.com
+ formula: "Median ms behind the earliest provider to push each Solana slotSubscribe notification, from one persistent WebSocket per provider on the same eu-west host, histogram_quantile over 24h. The per-slot winner scores 0 by construction."
+ queries:
+ p50: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="publicnode", chain="solana"}[24h])))
+ p90: histogram_quantile(0.90, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="publicnode", chain="solana"}[24h])))
+ p99: histogram_quantile(0.99, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="publicnode", chain="solana"}[24h])))
+ mean: sum(rate(ws_head_lag_milliseconds_sum{provider="publicnode", chain="solana"}[24h])) / sum(rate(ws_head_lag_milliseconds_count{provider="publicnode", chain="solana"}[24h]))
+ success: clamp_max(sum(increase(ws_head_blocks_seen_total{provider="publicnode", chain="solana"}[24h])) / max(sum by (provider) (increase(ws_head_blocks_seen_total{chain="solana"}[24h]))), 1)
+ sample_size: sum(increase(ws_head_lag_milliseconds_count{provider="publicnode", chain="solana"}[24h]))
+ series: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="publicnode", chain="solana"}[1h])))
+
+ - slug: solana-labs
+ name: Solana Foundation
+ tag: Keyless WS, wss://api.mainnet-beta.solana.com
+ formula: "Median ms behind the earliest provider to push each Solana slotSubscribe notification, from one persistent WebSocket per provider on the same eu-west host, histogram_quantile over 24h. Lag is relative to the per-slot winner."
+ queries:
+ p50: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="solana-labs", chain="solana"}[24h])))
+ p90: histogram_quantile(0.90, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="solana-labs", chain="solana"}[24h])))
+ p99: histogram_quantile(0.99, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="solana-labs", chain="solana"}[24h])))
+ mean: sum(rate(ws_head_lag_milliseconds_sum{provider="solana-labs", chain="solana"}[24h])) / sum(rate(ws_head_lag_milliseconds_count{provider="solana-labs", chain="solana"}[24h]))
+ success: clamp_max(sum(increase(ws_head_blocks_seen_total{provider="solana-labs", chain="solana"}[24h])) / max(sum by (provider) (increase(ws_head_blocks_seen_total{chain="solana"}[24h]))), 1)
+ sample_size: sum(increase(ws_head_lag_milliseconds_count{provider="solana-labs", chain="solana"}[24h]))
+ series: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="solana-labs", chain="solana"}[1h])))
From e5b9be8beb447f05246ec64eaa37e63d7678d16a Mon Sep 17 00:00:00 2001
From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com>
Date: Thu, 16 Jul 2026 13:14:17 +0200
Subject: [PATCH 22/89] ws-head-latency: bring harness + parent bench 081 +
region multi-region support to main (#1199)
Consolidates 3 dev merges (#1170 harness + #1196 per-chain benches + #1198 multi-region) so main matches dev on the ws-head-latency cluster:
* benchmarks/ws-head-latency.yml: parent bench 081 (Ethereum)
* benchmarks/ws-head-latency-base.yml: bench 087, adds regions arrays
* benchmarks/ws-head-latency-solana.yml: bench 088, adds regions arrays
* harnesses/ws-head-latency/: full harness (was dev only), including
region ConstLabel from OCB_REGION or RAILWAY_REPLICA_REGION
* railway.toml: Railway build config so the service can be created
After merge, create the Railway ws-head-latency service with 3 replicas
(us-east, eu-west, sgp), set OCB_REGION on each. Railway auto-builds
from main, metrics start flowing per region.
Co-authored-by: Florent Tapponnier
---
benchmarks/ws-head-latency-base.yml | 29 +++
benchmarks/ws-head-latency-solana.yml | 29 +++
benchmarks/ws-head-latency.yml | 169 ++++++++++++++
harnesses/ws-head-latency/Dockerfile | 22 ++
.../ws-head-latency/cmd/script/aggregator.go | 190 ++++++++++++++++
.../ws-head-latency/cmd/script/config.go | 70 ++++++
.../ws-head-latency/cmd/script/evm_ws.go | 212 ++++++++++++++++++
.../ws-head-latency/cmd/script/loghub.go | 114 ++++++++++
harnesses/ws-head-latency/cmd/script/main.go | 38 ++++
.../ws-head-latency/cmd/script/metrics.go | 110 +++++++++
.../ws-head-latency/cmd/script/solana_ws.go | 80 +++++++
harnesses/ws-head-latency/go.mod | 21 ++
harnesses/ws-head-latency/go.sum | 48 ++++
harnesses/ws-head-latency/railway.toml | 7 +
14 files changed, 1139 insertions(+)
create mode 100644 benchmarks/ws-head-latency.yml
create mode 100644 harnesses/ws-head-latency/Dockerfile
create mode 100644 harnesses/ws-head-latency/cmd/script/aggregator.go
create mode 100644 harnesses/ws-head-latency/cmd/script/config.go
create mode 100644 harnesses/ws-head-latency/cmd/script/evm_ws.go
create mode 100644 harnesses/ws-head-latency/cmd/script/loghub.go
create mode 100644 harnesses/ws-head-latency/cmd/script/main.go
create mode 100644 harnesses/ws-head-latency/cmd/script/metrics.go
create mode 100644 harnesses/ws-head-latency/cmd/script/solana_ws.go
create mode 100644 harnesses/ws-head-latency/go.mod
create mode 100644 harnesses/ws-head-latency/go.sum
create mode 100644 harnesses/ws-head-latency/railway.toml
diff --git a/benchmarks/ws-head-latency-base.yml b/benchmarks/ws-head-latency-base.yml
index 052d70a3..dbe4f812 100644
--- a/benchmarks/ws-head-latency-base.yml
+++ b/benchmarks/ws-head-latency-base.yml
@@ -62,6 +62,15 @@ prometheus:
window: 24h
freshness_metric: ws_head_blocks_seen_total
+rank_matrix_query: histogram_quantile(0.50, sum by (provider, region, le) (rate(ws_head_lag_milliseconds_bucket{chain="base"}[24h])))
+
+dimensions:
+ region:
+ - { value: all, label: All regions }
+ - { value: us-east, label: US-East }
+ - { value: eu-west, label: EU-West }
+ - { value: sgp, label: Singapore }
+
faq:
- q: "What is the fastest free Base RPC over WebSocket right now?"
a: "{{best_name}} currently wins most per-block races on Base with a median lag of {{best_p50}} versus the earliest arrival (p50, 24 h) across {{count}} measured keyless providers. The leaderboard re-scores on every Base block, roughly every 2 seconds, so the answer reflects sustained live measurement, not a one-off test. Check the win rate and the trailers' p99 before wiring a latency-sensitive Base bot to any single provider."
@@ -87,6 +96,16 @@ providers:
success: clamp_max(sum(increase(ws_head_blocks_seen_total{provider="publicnode", chain="base"}[24h])) / max(sum by (provider) (increase(ws_head_blocks_seen_total{chain="base"}[24h]))), 1)
sample_size: sum(increase(ws_head_lag_milliseconds_count{provider="publicnode", chain="base"}[24h]))
series: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="publicnode", chain="base"}[1h])))
+ regions:
+ - region: us-east
+ p50: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="publicnode", chain="base", region="us-east"}[24h])))
+ series: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="publicnode", chain="base", region="us-east"}[1h])))
+ - region: eu-west
+ p50: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="publicnode", chain="base", region="eu-west"}[24h])))
+ series: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="publicnode", chain="base", region="eu-west"}[1h])))
+ - region: ap-southeast
+ p50: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="publicnode", chain="base", region="sgp"}[24h])))
+ series: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="publicnode", chain="base", region="sgp"}[1h])))
- slug: drpc
name: dRPC
@@ -100,3 +119,13 @@ providers:
success: clamp_max(sum(increase(ws_head_blocks_seen_total{provider="drpc", chain="base"}[24h])) / max(sum by (provider) (increase(ws_head_blocks_seen_total{chain="base"}[24h]))), 1)
sample_size: sum(increase(ws_head_lag_milliseconds_count{provider="drpc", chain="base"}[24h]))
series: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="drpc", chain="base"}[1h])))
+ regions:
+ - region: us-east
+ p50: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="drpc", chain="base", region="us-east"}[24h])))
+ series: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="drpc", chain="base", region="us-east"}[1h])))
+ - region: eu-west
+ p50: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="drpc", chain="base", region="eu-west"}[24h])))
+ series: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="drpc", chain="base", region="eu-west"}[1h])))
+ - region: ap-southeast
+ p50: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="drpc", chain="base", region="sgp"}[24h])))
+ series: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="drpc", chain="base", region="sgp"}[1h])))
diff --git a/benchmarks/ws-head-latency-solana.yml b/benchmarks/ws-head-latency-solana.yml
index c906903b..cc9ee17c 100644
--- a/benchmarks/ws-head-latency-solana.yml
+++ b/benchmarks/ws-head-latency-solana.yml
@@ -63,6 +63,15 @@ prometheus:
window: 24h
freshness_metric: ws_head_blocks_seen_total
+rank_matrix_query: histogram_quantile(0.50, sum by (provider, region, le) (rate(ws_head_lag_milliseconds_bucket{chain="solana"}[24h])))
+
+dimensions:
+ region:
+ - { value: all, label: All regions }
+ - { value: us-east, label: US-East }
+ - { value: eu-west, label: EU-West }
+ - { value: sgp, label: Singapore }
+
faq:
- q: "What is the fastest free Solana RPC over WebSocket right now?"
a: "{{best_name}} currently wins most per-slot races on Solana with a median lag of {{best_p50}} versus the earliest arrival (p50, 24 h) across {{count}} measured keyless providers. The leaderboard re-scores on every Solana slot, roughly every 400ms, so the answer reflects sustained live measurement, not a one-off test. Check the win rate and the trailers' p99 before wiring a latency-sensitive Solana keeper or MEV integration to any single provider."
@@ -90,6 +99,16 @@ providers:
success: clamp_max(sum(increase(ws_head_blocks_seen_total{provider="publicnode", chain="solana"}[24h])) / max(sum by (provider) (increase(ws_head_blocks_seen_total{chain="solana"}[24h]))), 1)
sample_size: sum(increase(ws_head_lag_milliseconds_count{provider="publicnode", chain="solana"}[24h]))
series: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="publicnode", chain="solana"}[1h])))
+ regions:
+ - region: us-east
+ p50: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="publicnode", chain="solana", region="us-east"}[24h])))
+ series: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="publicnode", chain="solana", region="us-east"}[1h])))
+ - region: eu-west
+ p50: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="publicnode", chain="solana", region="eu-west"}[24h])))
+ series: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="publicnode", chain="solana", region="eu-west"}[1h])))
+ - region: ap-southeast
+ p50: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="publicnode", chain="solana", region="sgp"}[24h])))
+ series: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="publicnode", chain="solana", region="sgp"}[1h])))
- slug: solana-labs
name: Solana Foundation
@@ -103,3 +122,13 @@ providers:
success: clamp_max(sum(increase(ws_head_blocks_seen_total{provider="solana-labs", chain="solana"}[24h])) / max(sum by (provider) (increase(ws_head_blocks_seen_total{chain="solana"}[24h]))), 1)
sample_size: sum(increase(ws_head_lag_milliseconds_count{provider="solana-labs", chain="solana"}[24h]))
series: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="solana-labs", chain="solana"}[1h])))
+ regions:
+ - region: us-east
+ p50: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="solana-labs", chain="solana", region="us-east"}[24h])))
+ series: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="solana-labs", chain="solana", region="us-east"}[1h])))
+ - region: eu-west
+ p50: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="solana-labs", chain="solana", region="eu-west"}[24h])))
+ series: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="solana-labs", chain="solana", region="eu-west"}[1h])))
+ - region: ap-southeast
+ p50: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="solana-labs", chain="solana", region="sgp"}[24h])))
+ series: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="solana-labs", chain="solana", region="sgp"}[1h])))
diff --git a/benchmarks/ws-head-latency.yml b/benchmarks/ws-head-latency.yml
new file mode 100644
index 00000000..7b66ea78
--- /dev/null
+++ b/benchmarks/ws-head-latency.yml
@@ -0,0 +1,169 @@
+# OpenChainBench. Bench № 081
+
+slug: ws-head-latency
+number: "081"
+title: Fastest WebSocket newHeads push, live block-push lag across RPC providers
+seo_title: "Fastest WebSocket RPC newHeads push 2026"
+seo_description: "{{best_name}} pushes Ethereum newHeads first. Live WebSocket block-push lag for PublicNode, dRPC and Tenderly, measured per block against the earliest arrival in the cohort."
+subtitle: Per-block WebSocket push lag in milliseconds versus the earliest provider to deliver the same Ethereum head, measured continuously from one eu-west vantage point.
+
+category: RPCs
+status: live
+metric: Block push lag
+unit: ms
+higher_is_better: false
+
+seo_intro: |
+ Every trading bot, MEV searcher, liquidation keeper and live dashboard
+ that subscribes to `eth_subscribe("newHeads")` cares about one number
+ first: how many milliseconds after the network produces a block does my
+ RPC provider actually push it to me? Marketing pages say "real-time
+ WebSocket streaming"; this page says who delivers block N first and by
+ how much the others trail. The harness holds one persistent WebSocket
+ per provider from the same eu-west host, timestamps every newHeads
+ frame on receipt, and scores each Ethereum block as a race: the
+ earliest arrival sets T0, every other provider's lag is its arrival
+ minus T0. The headline is pinned to Ethereum, where all three keyless
+ providers compete; the same harness also races Base heads and Solana
+ slots for cross-chain context. Because the measurement is relative,
+ the leader reads near zero by construction, so the win-rate and the
+ shape of the trailing distribution (p50 vs p99) are the numbers to
+ read, not the absolute magnitude. {{best_name}} currently leads at
+ {{best_p50}} (p50, 24 h) across {{count}} measured providers.
+
+abstract: |
+ We race RPC providers on WebSocket block delivery. One harness holds a
+ persistent `eth_subscribe("newHeads")` connection to each provider from
+ the same host and timestamps every head frame on receipt. Per block,
+ the earliest arrival across providers defines T0; each provider's
+ sample is its own arrival minus T0, in milliseconds. A block's cohort
+ stays open 5 seconds after the first arrival, then it is scored:
+ lag histogram per provider, a win for the earliest, and a gap counter
+ for any live provider that never delivered the block. The measurement
+ is relative to the fastest provider observed from our vantage point,
+ not an absolute network latency; the same-path subtraction is exactly
+ what makes the ordering fair. Solana runs the identical race on
+ `slotSubscribe` slot notifications.
+
+methodology:
+ - "Providers measured (keyless WebSocket endpoints): PublicNode (wss://ethereum-rpc.publicnode.com), dRPC (wss://eth.drpc.org), Tenderly (wss://mainnet.gateway.tenderly.co). Keyed providers (Alchemy, Infura, Chainstack) join the cohort when contributor keys are wired; the harness reads them from env vars and skips them cleanly when unset."
+ - "Chains: Ethereum (headline, all three providers compete), Base via newHeads and Solana via slotSubscribe as secondary cohorts. Headline queries on this page are pinned to chain=ethereum so the ranking never mixes block cadences."
+ - "Race scoring: the first provider to deliver block N sets T0. Each provider's sample for block N is arrival(N) minus T0 in milliseconds. The cohort closes 5 seconds after the first arrival; a provider arriving later than that is scored as a missed block (ws_block_gap_total), not as a huge latency sample."
+ - "Relative, not absolute: a single vantage point cannot separate its own network path from provider pipeline time, so we subtract the two arrivals over the same path instead. Consequence: the fastest provider reads ~0 by construction, and the honest readings are win rate, the trailers' lag distribution, and gap counts, not the leader's absolute number."
+ - "Blocks where only one provider delivered within the window emit no lag samples and no win: a one-horse race carries no relative information. Gap counters still increment for live providers that missed the block."
+ - "Connection discipline: one persistent WebSocket per (provider, chain), client ping every 30s, read deadline 60s, plus a 90s head watchdog that forces a reconnect when a provider keeps the socket heartbeat alive but silently drops the subscription (a failure mode we have observed in production on other benches). Reconnects use exponential backoff 2s to 60s and are counted in ws_reconnects_total."
+ - "Coverage score: ws_head_blocks_seen_total per provider divided by the cohort maximum over the same 24h window, clamped to 1. A provider that pushes fast but skips blocks loses on this column rather than hiding in the latency percentiles."
+ - "Aggregation: per-provider lag histogram (5ms to 10s exponential buckets), p50/p90/p99 via histogram_quantile over the 24h rate. Duplicate announcements of the same block on one connection are dropped; per-provider liveness is exported as ws_health (1 = connected and delivered a message in the last 120s)."
+ - "Cheat vector disclosure: a provider could theoretically pre-announce heads it has not fully validated to win the race. We keyed the cohort on block number as pushed; hash-level cross-checking against a reference node is on the v2 list. Reading win rate together with the gap counter already flags a provider that pushes early but wrong (retracted heads show up as cohort inconsistencies and reorg noise)."
+ - "Vantage point: a single eu-west host. Geography moves absolute arrival times but affects all providers over the same path; still, a provider whose nearest edge is far from eu-west is disadvantaged, and a us-east probe is the planned second vantage before any cross-region claim is made."
+
+findings:
+ - "{{best_name}} leads the Ethereum cohort at {{best_p50}} (p50, 24 h) across {{count}} measured providers. Remember the construction: the per-block winner defines zero, so the leader's near-zero p50 mostly says it wins most races; the informative numbers are the trailers' medians and the p99 tails."
+ - "{{name:drpc}} trails the winner by {{p50:drpc}} at the median. That is the extra time a liquidation bot or MEV searcher on dRPC's keyless endpoint waits for block N after the fastest feed already delivered it."
+ - "{{name:tenderly}} trails by {{p50:tenderly}} (p50, 24 h). Tenderly's gateway is built around simulation and developer tooling rather than raw head-push fan-out, and the push pipeline depth shows up in this race."
+ - "The p99 column is the integration-grade number: {{p99:publicnode}} / {{p99:drpc}} / {{p99:tenderly}}. A provider with a tight p50 but a multi-second p99 will occasionally hand your bot a stale head exactly when the chain is busiest."
+ - "Win rate and block coverage are the robust readings. A provider can look decent on median lag while silently skipping blocks; the success column (blocks seen vs cohort max) and ws_block_gap_total keep that visible."
+
+source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/ws-head-latency
+
+expected_n: 7000
+
+prometheus:
+ window: 24h
+ freshness_metric: ws_head_blocks_seen_total
+
+# Per-cell (region) ranking matrix for scoped badge claims. Chain is
+# fixed for the whole bench, so cells key on provider and region.
+rank_matrix_query: histogram_quantile(0.50, sum by (provider, region, le) (rate(ws_head_lag_milliseconds_bucket{chain="ethereum"}[24h])))
+
+# Region is the only dimension: chain is baked into every query.
+dimensions:
+ region:
+ - { value: all, label: All regions }
+ - { value: us-east, label: US-East }
+ - { value: eu-west, label: EU-West }
+ - { value: sgp, label: Singapore }
+
+faq:
+ - q: "Which RPC provider pushes new Ethereum blocks first over WebSocket?"
+ a: "{{best_name}} currently wins most per-block races, with a median lag of {{best_p50}} versus the earliest arrival (p50, 24 h) across {{count}} measured keyless providers. The leaderboard re-scores on every Ethereum block, roughly every 12 seconds, so the answer reflects sustained live measurement, not a one-off test. Check the win rate and the trailers' p99 before wiring a latency-sensitive bot to any single provider."
+ - q: "Why does the fastest provider show a lag near zero?"
+ a: "By construction. Each block is scored as a race: the earliest arrival among providers defines T0 and every sample is measured against it, so the provider that wins most races accumulates mostly zero samples. This is deliberate. A single vantage point cannot measure absolute chain-to-client latency without a reference node co-located with block producers; it can measure, fairly, which provider delivers the same block earlier over the same network path. Read the leader's win rate and the trailers' distributions, not the leader's absolute number."
+ - q: "Is this absolute latency from block production to my client?"
+ a: "No. The number is relative lag versus the fastest provider observed from our eu-west host. Absolute delivery latency includes the propagation time from the block producer to each provider's infrastructure plus the path to you, which shifts with your geography. What transfers to your integration is the ordering and the spread: if a provider trails the winner by 800 ms at the median from our vantage point, its pipeline is structurally behind, and that gap does not disappear because you probe from another region."
+ - q: "Could a provider cheat by pushing heads before validating them?"
+ a: "In principle yes: pre-announcing an unvalidated head wins the race at the cost of occasionally pushing a block that gets reorged or retracted. That is why the bench exports more than the lag histogram. A provider that pushes early but wrong shows up as cohort inconsistencies, elevated reorg noise and gaps, and the coverage column (blocks seen versus the cohort maximum) penalises skipped or retracted blocks. Hash-level cross-checking against an independent reference node is planned for v2 of the harness."
+ - q: "How is WebSocket head lag measured on OpenChainBench?"
+ a: "One Go harness holds a persistent eth_subscribe('newHeads') WebSocket per provider from the same eu-west host, plus slotSubscribe for the Solana cohort. Every frame is timestamped on receipt with time.Now() precision. A block's cohort stays open for 5 seconds after the first arrival, then each provider's lag versus the earliest is observed into a Prometheus histogram (5 ms to 10 s exponential buckets); p50/p90/p99 come from histogram_quantile over the 24 h window. Connections carry a 90 s silent-subscription watchdog and exponential-backoff reconnects so a flaky endpoint degrades to health=0 instead of corrupting the race."
+ - q: "Why are only PublicNode, dRPC and Tenderly listed?"
+ a: "V1 measures the keyless tier: endpoints anyone can open a WebSocket to without an account, which is also the tier where claims are hardest to verify and marketing is loudest. Alchemy, Infura and Chainstack require API keys; the harness already accepts them via environment variables and they join the leaderboard once contributor keys are wired into the deployment. Keyed tiers usually run on better-provisioned infrastructure, so expect the keyless numbers here to be the floor, not the ceiling, of each vendor's performance."
+
+providers:
+ - slug: publicnode
+ name: PublicNode
+ tag: Keyless WS, wss://ethereum-rpc.publicnode.com
+ formula: "Median ms behind the earliest provider to push each Ethereum newHeads frame, from one persistent WebSocket per provider on the same eu-west host, histogram_quantile over 24h. The per-block winner scores 0 by construction."
+ queries:
+ p50: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="publicnode", chain="ethereum"}[24h])))
+ p90: histogram_quantile(0.90, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="publicnode", chain="ethereum"}[24h])))
+ p99: histogram_quantile(0.99, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="publicnode", chain="ethereum"}[24h])))
+ mean: sum(rate(ws_head_lag_milliseconds_sum{provider="publicnode", chain="ethereum"}[24h])) / sum(rate(ws_head_lag_milliseconds_count{provider="publicnode", chain="ethereum"}[24h]))
+ success: clamp_max(sum(increase(ws_head_blocks_seen_total{provider="publicnode", chain="ethereum"}[24h])) / max(sum by (provider) (increase(ws_head_blocks_seen_total{chain="ethereum"}[24h]))), 1)
+ sample_size: sum(increase(ws_head_lag_milliseconds_count{provider="publicnode", chain="ethereum"}[24h]))
+ series: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="publicnode", chain="ethereum"}[1h])))
+ regions:
+ - region: us-east
+ p50: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="publicnode", chain="ethereum", region="us-east"}[24h])))
+ series: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="publicnode", chain="ethereum", region="us-east"}[1h])))
+ - region: eu-west
+ p50: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="publicnode", chain="ethereum", region="eu-west"}[24h])))
+ series: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="publicnode", chain="ethereum", region="eu-west"}[1h])))
+ - region: ap-southeast
+ p50: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="publicnode", chain="ethereum", region="sgp"}[24h])))
+ series: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="publicnode", chain="ethereum", region="sgp"}[1h])))
+
+ - slug: drpc
+ name: dRPC
+ tag: Keyless WS, wss://eth.drpc.org
+ formula: "Median ms behind the earliest provider to push each Ethereum newHeads frame, from one persistent WebSocket per provider on the same eu-west host, histogram_quantile over 24h. Lag is relative to the per-block winner."
+ queries:
+ p50: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="drpc", chain="ethereum"}[24h])))
+ p90: histogram_quantile(0.90, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="drpc", chain="ethereum"}[24h])))
+ p99: histogram_quantile(0.99, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="drpc", chain="ethereum"}[24h])))
+ mean: sum(rate(ws_head_lag_milliseconds_sum{provider="drpc", chain="ethereum"}[24h])) / sum(rate(ws_head_lag_milliseconds_count{provider="drpc", chain="ethereum"}[24h]))
+ success: clamp_max(sum(increase(ws_head_blocks_seen_total{provider="drpc", chain="ethereum"}[24h])) / max(sum by (provider) (increase(ws_head_blocks_seen_total{chain="ethereum"}[24h]))), 1)
+ sample_size: sum(increase(ws_head_lag_milliseconds_count{provider="drpc", chain="ethereum"}[24h]))
+ series: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="drpc", chain="ethereum"}[1h])))
+ regions:
+ - region: us-east
+ p50: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="drpc", chain="ethereum", region="us-east"}[24h])))
+ series: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="drpc", chain="ethereum", region="us-east"}[1h])))
+ - region: eu-west
+ p50: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="drpc", chain="ethereum", region="eu-west"}[24h])))
+ series: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="drpc", chain="ethereum", region="eu-west"}[1h])))
+ - region: ap-southeast
+ p50: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="drpc", chain="ethereum", region="sgp"}[24h])))
+ series: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="drpc", chain="ethereum", region="sgp"}[1h])))
+
+ - slug: tenderly
+ name: Tenderly
+ tag: Keyless WS, wss://mainnet.gateway.tenderly.co
+ formula: "Median ms behind the earliest provider to push each Ethereum newHeads frame, from one persistent WebSocket per provider on the same eu-west host, histogram_quantile over 24h. Lag is relative to the per-block winner."
+ queries:
+ p50: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="tenderly", chain="ethereum"}[24h])))
+ p90: histogram_quantile(0.90, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="tenderly", chain="ethereum"}[24h])))
+ p99: histogram_quantile(0.99, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="tenderly", chain="ethereum"}[24h])))
+ mean: sum(rate(ws_head_lag_milliseconds_sum{provider="tenderly", chain="ethereum"}[24h])) / sum(rate(ws_head_lag_milliseconds_count{provider="tenderly", chain="ethereum"}[24h]))
+ success: clamp_max(sum(increase(ws_head_blocks_seen_total{provider="tenderly", chain="ethereum"}[24h])) / max(sum by (provider) (increase(ws_head_blocks_seen_total{chain="ethereum"}[24h]))), 1)
+ sample_size: sum(increase(ws_head_lag_milliseconds_count{provider="tenderly", chain="ethereum"}[24h]))
+ series: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="tenderly", chain="ethereum"}[1h])))
+ regions:
+ - region: us-east
+ p50: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="tenderly", chain="ethereum", region="us-east"}[24h])))
+ series: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="tenderly", chain="ethereum", region="us-east"}[1h])))
+ - region: eu-west
+ p50: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="tenderly", chain="ethereum", region="eu-west"}[24h])))
+ series: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="tenderly", chain="ethereum", region="eu-west"}[1h])))
+ - region: ap-southeast
+ p50: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="tenderly", chain="ethereum", region="sgp"}[24h])))
+ series: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="tenderly", chain="ethereum", region="sgp"}[1h])))
diff --git a/harnesses/ws-head-latency/Dockerfile b/harnesses/ws-head-latency/Dockerfile
new file mode 100644
index 00000000..63108cfc
--- /dev/null
+++ b/harnesses/ws-head-latency/Dockerfile
@@ -0,0 +1,22 @@
+FROM golang:1.24-alpine AS builder
+
+WORKDIR /app
+RUN apk add --no-cache git
+
+COPY go.mod go.sum ./
+RUN go mod download
+
+COPY . .
+
+RUN CGO_ENABLED=0 GOOS=linux go build -o /app/monitor ./cmd/script
+
+FROM debian:bookworm-slim
+
+WORKDIR /app
+RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
+
+COPY --from=builder /app/monitor /app/monitor
+
+EXPOSE 2112
+
+CMD ["/app/monitor"]
diff --git a/harnesses/ws-head-latency/cmd/script/aggregator.go b/harnesses/ws-head-latency/cmd/script/aggregator.go
new file mode 100644
index 00000000..87e384bc
--- /dev/null
+++ b/harnesses/ws-head-latency/cmd/script/aggregator.go
@@ -0,0 +1,190 @@
+package main
+
+import (
+ "fmt"
+ "sort"
+ "sync"
+ "time"
+)
+
+// Per-chain arrival cohort.
+//
+// Every (provider, chain) connection reports "provider P saw block N at
+// time T" into the chain's aggregator. The first report for a block
+// opens a collection window (settleWindow); when it expires the cohort
+// is scored:
+//
+// T0 = earliest arrival among providers
+// lag(provider) = arrival(provider) - T0 → histogram sample
+// winner = provider at T0 → wins counter
+// gap = provider healthy on this chain but absent from the
+// cohort → gap counter
+//
+// The measurement is deliberately RELATIVE: one vantage point cannot
+// separate its own network path from provider pipeline latency, but the
+// per-block delta between providers over the same path is a fair
+// ordering. The fastest provider reads ~0 by construction; the spec's
+// editorial copy discloses this.
+
+const (
+ // settleWindow is how long a block cohort stays open after the first
+ // arrival. 5s covers everything we've observed (worst straggler
+ // ~1.5s on ethereum); providers later than this are scored as a gap,
+ // which is the honest reading for a push feed.
+ settleWindow = 5 * time.Second
+ // staleAfter bounds "healthy": a provider absent from a cohort only
+ // counts as a gap when it delivered *something* on this chain within
+ // staleAfter (otherwise it's just disconnected, which ws_health and
+ // ws_reconnects_total already report).
+ staleAfter = 120 * time.Second
+)
+
+type cohortEntry struct {
+ arrivals map[string]time.Time
+}
+
+type aggregator struct {
+ chain string
+
+ mu sync.Mutex
+ blocks map[int64]*cohortEntry
+ maxFinalized int64
+ // lastMsg tracks per-provider liveness on this chain (any accepted
+ // head/slot). Used for gap attribution and the ws_health sweeper.
+ lastMsg map[string]time.Time
+}
+
+var (
+ aggregatorsMu sync.Mutex
+ aggregators = map[string]*aggregator{}
+)
+
+func aggregatorFor(chain string) *aggregator {
+ aggregatorsMu.Lock()
+ defer aggregatorsMu.Unlock()
+ if a, ok := aggregators[chain]; ok {
+ return a
+ }
+ a := &aggregator{
+ chain: chain,
+ blocks: map[int64]*cohortEntry{},
+ lastMsg: map[string]time.Time{},
+ }
+ aggregators[chain] = a
+ return a
+}
+
+// record ingests one arrival. Returns false when the arrival was
+// dropped (duplicate from the same provider, or a straggler for an
+// already-scored block).
+func (a *aggregator) record(provider string, height int64, now time.Time) bool {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+
+ a.lastMsg[provider] = now
+
+ // Straggler for a cohort that already settled. From a push-feed
+ // consumer's perspective this block was missed (the gap counter was
+ // bumped at settle time); opening a fresh single-provider cohort
+ // here would fabricate a lag=0 "win", so drop it.
+ if height <= a.maxFinalized {
+ return false
+ }
+
+ e, ok := a.blocks[height]
+ if !ok {
+ e = &cohortEntry{arrivals: map[string]time.Time{}}
+ a.blocks[height] = e
+ time.AfterFunc(settleWindow, func() { a.settle(height) })
+ }
+ if _, dup := e.arrivals[provider]; dup {
+ return false
+ }
+ e.arrivals[provider] = now
+ headBlocksSeen.WithLabelValues(provider, a.chain).Inc()
+ return true
+}
+
+// settle scores one block cohort and releases it.
+func (a *aggregator) settle(height int64) {
+ a.mu.Lock()
+ e, ok := a.blocks[height]
+ if !ok {
+ a.mu.Unlock()
+ return
+ }
+ delete(a.blocks, height)
+ if height > a.maxFinalized {
+ a.maxFinalized = height
+ }
+ // Snapshot healthy providers for gap attribution while still locked.
+ settleTime := time.Now()
+ healthy := make([]string, 0, len(a.lastMsg))
+ for p, t := range a.lastMsg {
+ if settleTime.Sub(t) <= staleAfter {
+ healthy = append(healthy, p)
+ }
+ }
+ a.mu.Unlock()
+
+ // A single-provider cohort carries no relative information: lag
+ // would be 0 by definition and the "win" unearned. Count gaps for
+ // the healthy absentees (they truly missed a block others pushed)
+ // but emit no lag samples.
+ if len(e.arrivals) >= 2 {
+ providers := make([]string, 0, len(e.arrivals))
+ for p := range e.arrivals {
+ providers = append(providers, p)
+ }
+ // Deterministic tie-break: earliest arrival wins; equal
+ // timestamps resolve alphabetically.
+ sort.Strings(providers)
+ winner := providers[0]
+ t0 := e.arrivals[winner]
+ for _, p := range providers[1:] {
+ if e.arrivals[p].Before(t0) {
+ winner, t0 = p, e.arrivals[p]
+ }
+ }
+ for _, p := range providers {
+ lagMs := float64(e.arrivals[p].Sub(t0).Milliseconds())
+ headLagHist.WithLabelValues(p, a.chain).Observe(lagMs)
+ }
+ headWins.WithLabelValues(winner, a.chain).Inc()
+ fmt.Printf("[%s] block=%d cohort=%d winner=%s\n", a.chain, height, len(e.arrivals), winner)
+ }
+
+ for _, p := range healthy {
+ if _, saw := e.arrivals[p]; !saw {
+ headBlockGap.WithLabelValues(p, a.chain).Inc()
+ }
+ }
+}
+
+// startHealthSweeper flips ws_health to 0 for any (provider, chain)
+// that stopped delivering messages, per the gauge's contract (1 =
+// connected AND received a message in the last 120s). The connection
+// goroutines set it back to 1 on every accepted message.
+func startHealthSweeper() {
+ go func() {
+ t := time.NewTicker(15 * time.Second)
+ defer t.Stop()
+ for now := range t.C {
+ aggregatorsMu.Lock()
+ aggs := make([]*aggregator, 0, len(aggregators))
+ for _, a := range aggregators {
+ aggs = append(aggs, a)
+ }
+ aggregatorsMu.Unlock()
+ for _, a := range aggs {
+ a.mu.Lock()
+ for p, last := range a.lastMsg {
+ if now.Sub(last) > staleAfter {
+ wsHealth.WithLabelValues(p, a.chain).Set(0)
+ }
+ }
+ a.mu.Unlock()
+ }
+ }
+ }()
+}
diff --git a/harnesses/ws-head-latency/cmd/script/config.go b/harnesses/ws-head-latency/cmd/script/config.go
new file mode 100644
index 00000000..1c30cfaa
--- /dev/null
+++ b/harnesses/ws-head-latency/cmd/script/config.go
@@ -0,0 +1,70 @@
+package main
+
+import (
+ "os"
+ "strings"
+)
+
+// Endpoint is one (provider, chain) WebSocket subscription. Each entry
+// gets its own goroutine holding a persistent connection; the arrival
+// timestamps from every endpoint on the same chain are pooled by the
+// per-chain cohort (see cohort.go logic in evm_ws.go / solana_ws.go and
+// the shared aggregator below).
+type Endpoint struct {
+ Provider string
+ Chain string // ethereum | base | solana
+ Kind string // "evm" (eth_subscribe newHeads) | "solana" (slotSubscribe)
+ URL string
+}
+
+// endpoints is the source of truth for the measured cohort. All default
+// URLs are public and keyless, verified live at harness inception:
+//
+// publicnode: ethereum / base / solana all push events
+// drpc: ethereum verified; base + solana declared and allowed to
+// fail at runtime (per-connection reconnect loop degrades
+// gracefully, never crashes the process)
+// tenderly: ethereum only (no keyless base/solana WS)
+//
+// Optional keyed providers are appended from env vars so contributor
+// keys can widen the cohort without a rebuild. Every default URL is
+// also env-overridable via WS_URL__.
+func endpoints() []Endpoint {
+ eps := []Endpoint{
+ {Provider: "publicnode", Chain: "ethereum", Kind: "evm", URL: envDefault("WS_URL_PUBLICNODE_ETHEREUM", "wss://ethereum-rpc.publicnode.com")},
+ {Provider: "publicnode", Chain: "base", Kind: "evm", URL: envDefault("WS_URL_PUBLICNODE_BASE", "wss://base-rpc.publicnode.com")},
+ {Provider: "publicnode", Chain: "solana", Kind: "solana", URL: envDefault("WS_URL_PUBLICNODE_SOLANA", "wss://solana-rpc.publicnode.com")},
+ {Provider: "drpc", Chain: "ethereum", Kind: "evm", URL: envDefault("WS_URL_DRPC_ETHEREUM", "wss://eth.drpc.org")},
+ {Provider: "drpc", Chain: "base", Kind: "evm", URL: envDefault("WS_URL_DRPC_BASE", "wss://base.drpc.org")},
+ {Provider: "drpc", Chain: "solana", Kind: "solana", URL: envDefault("WS_URL_DRPC_SOLANA", "wss://solana.drpc.org")},
+ {Provider: "tenderly", Chain: "ethereum", Kind: "evm", URL: envDefault("WS_URL_TENDERLY_ETHEREUM", "wss://mainnet.gateway.tenderly.co")},
+ {Provider: "solana-labs", Chain: "solana", Kind: "solana", URL: envDefault("WS_URL_SOLANALABS_SOLANA", "wss://api.mainnet-beta.solana.com")},
+ }
+ // Keyed providers, skipped when the env var is unset. URLs carry the
+ // key inline (e.g. wss://eth-mainnet.g.alchemy.com/v2/) so they
+ // live in the deployment env, never in the repo.
+ for _, opt := range []struct{ env, provider, chain string }{
+ {"WS_URL_ALCHEMY_ETHEREUM", "alchemy", "ethereum"},
+ {"WS_URL_INFURA_ETHEREUM", "infura", "ethereum"},
+ {"WS_URL_CHAINSTACK_ETHEREUM", "chainstack", "ethereum"},
+ } {
+ if url := strings.TrimSpace(os.Getenv(opt.env)); url != "" {
+ eps = append(eps, Endpoint{Provider: opt.provider, Chain: opt.chain, Kind: "evm", URL: url})
+ }
+ }
+ return eps
+}
+
+func envDefault(key, def string) string {
+ if v := strings.TrimSpace(os.Getenv(key)); v != "" {
+ return v
+ }
+ return def
+}
+
+// listenAddr defaults to :2112, the OCB harness convention (scraped by
+// the shared Prometheus). LISTEN_ADDR overrides for local runs so a dev
+// copy doesn't collide with a deployed harness.
+func listenAddr() string {
+ return envDefault("LISTEN_ADDR", ":2112")
+}
diff --git a/harnesses/ws-head-latency/cmd/script/evm_ws.go b/harnesses/ws-head-latency/cmd/script/evm_ws.go
new file mode 100644
index 00000000..b69fa750
--- /dev/null
+++ b/harnesses/ws-head-latency/cmd/script/evm_ws.go
@@ -0,0 +1,212 @@
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "strconv"
+ "strings"
+ "sync/atomic"
+ "time"
+
+ "github.com/gorilla/websocket"
+)
+
+// Persistent WebSocket machinery, ported from the l2-block-time
+// harness. One goroutine per (provider, chain) endpoint:
+//
+// - dial with timeout, subscribe, read loop with rolling deadline
+// - client ping every 30s; pong handler extends the read deadline
+// - head watchdog: the pong handler keeps the read deadline alive, so
+// a provider that heartbeats while silently dropping the
+// subscription (observed on publicnode) would wedge the goroutine
+// forever. If no head/slot arrives for headWatchdog, slam the
+// connection closed and let the outer loop redial.
+// - reconnect with exponential backoff 2s → 60s; per-connection, so a
+// dead endpoint (e.g. drpc/base if delisted) degrades to health=0 +
+// periodic redials without touching the rest of the cohort.
+
+const (
+ dialTimeout = 15 * time.Second
+ readDeadline = 60 * time.Second
+ pingEvery = 30 * time.Second
+ minBackoff = 2 * time.Second
+ maxBackoff = 60 * time.Second
+ // headWatchdog must sit well above the slowest cadence in the panel
+ // (ethereum, 12s slots; solana pushes multiple times per second).
+ // 90s of silence is unambiguously a broken subscription.
+ headWatchdog = 90 * time.Second
+)
+
+type rpcReq struct {
+ JSONRPC string `json:"jsonrpc"`
+ ID int `json:"id"`
+ Method string `json:"method"`
+ Params []any `json:"params"`
+}
+
+// evmEnvelope matches both the eth_subscription push frame and the
+// subscribe ack.
+type evmEnvelope struct {
+ Method string `json:"method"`
+ Params *struct {
+ Result json.RawMessage `json:"result"`
+ } `json:"params,omitempty"`
+}
+
+type evmHead struct {
+ Number string `json:"number"`
+ Hash string `json:"hash"`
+}
+
+// StartEndpoints spawns the reconnect loop for every configured
+// (provider, chain) connection.
+func StartEndpoints() {
+ for _, ep := range endpoints() {
+ ep := ep
+ go func() {
+ backoff := minBackoff
+ for {
+ start := time.Now()
+ var err error
+ switch ep.Kind {
+ case "solana":
+ err = runSolanaConn(ep)
+ default:
+ err = runEVMConn(ep)
+ }
+ wsHealth.WithLabelValues(ep.Provider, ep.Chain).Set(0)
+ wsReconnects.WithLabelValues(ep.Provider, ep.Chain).Inc()
+ if err != nil {
+ fmt.Printf("[%s/%s] WS error: %v (reconnecting in %v)\n", ep.Provider, ep.Chain, err, backoff)
+ }
+ // A connection that held for a while earns a fresh
+ // backoff; only consecutive fast failures escalate.
+ if time.Since(start) > 5*time.Minute {
+ backoff = minBackoff
+ }
+ time.Sleep(backoff)
+ if backoff < maxBackoff {
+ backoff *= 2
+ if backoff > maxBackoff {
+ backoff = maxBackoff
+ }
+ }
+ }
+ }()
+ }
+}
+
+// dialAndGuard opens the connection and installs ping + watchdog
+// goroutines shared by the EVM and Solana read loops. The returned
+// cleanup must be deferred; bumpHead must be called on every accepted
+// head/slot.
+func dialAndGuard(ep Endpoint) (conn *websocket.Conn, bumpHead func(), cleanup func(), err error) {
+ dialer := *websocket.DefaultDialer
+ dialer.HandshakeTimeout = dialTimeout
+ conn, _, err = dialer.Dial(ep.URL, nil)
+ if err != nil {
+ return nil, nil, nil, fmt.Errorf("dial: %w", err)
+ }
+
+ done := make(chan struct{})
+ var lastHeadUnixNano atomic.Int64
+ lastHeadUnixNano.Store(time.Now().UnixNano())
+
+ go func() {
+ t := time.NewTicker(pingEvery)
+ defer t.Stop()
+ for {
+ select {
+ case <-done:
+ return
+ case <-t.C:
+ _ = conn.WriteControl(websocket.PingMessage, nil, time.Now().Add(5*time.Second))
+ }
+ }
+ }()
+ go func() {
+ t := time.NewTicker(15 * time.Second)
+ defer t.Stop()
+ for {
+ select {
+ case <-done:
+ return
+ case now := <-t.C:
+ silent := now.Sub(time.Unix(0, lastHeadUnixNano.Load()))
+ if silent > headWatchdog {
+ fmt.Printf("[%s/%s] watchdog: no event for %s, forcing reconnect\n", ep.Provider, ep.Chain, silent.Round(time.Second))
+ _ = conn.Close()
+ return
+ }
+ }
+ }
+ }()
+
+ conn.SetPongHandler(func(string) error {
+ _ = conn.SetReadDeadline(time.Now().Add(readDeadline))
+ return nil
+ })
+
+ bumpHead = func() { lastHeadUnixNano.Store(time.Now().UnixNano()) }
+ cleanup = func() {
+ close(done)
+ _ = conn.Close()
+ }
+ return conn, bumpHead, cleanup, nil
+}
+
+func runEVMConn(ep Endpoint) error {
+ conn, bumpHead, cleanup, err := dialAndGuard(ep)
+ if err != nil {
+ return err
+ }
+ defer cleanup()
+
+ if err := conn.WriteJSON(rpcReq{
+ JSONRPC: "2.0", ID: 1, Method: "eth_subscribe",
+ Params: []any{"newHeads"},
+ }); err != nil {
+ return fmt.Errorf("subscribe: %w", err)
+ }
+
+ wsHealth.WithLabelValues(ep.Provider, ep.Chain).Set(1)
+ fmt.Printf("[%s/%s] connected, subscribed to newHeads\n", ep.Provider, ep.Chain)
+
+ agg := aggregatorFor(ep.Chain)
+ for {
+ _ = conn.SetReadDeadline(time.Now().Add(readDeadline))
+ _, msg, err := conn.ReadMessage()
+ if err != nil {
+ return fmt.Errorf("read: %w", err)
+ }
+
+ var env evmEnvelope
+ if err := json.Unmarshal(msg, &env); err != nil {
+ continue
+ }
+ if env.Method != "eth_subscription" || env.Params == nil {
+ continue // subscribe ack or unrelated frame
+ }
+ var head evmHead
+ if err := json.Unmarshal(env.Params.Result, &head); err != nil {
+ continue
+ }
+ blockNum := parseHexInt64(head.Number)
+ if blockNum == 0 {
+ continue
+ }
+ now := time.Now()
+ bumpHead()
+ wsHealth.WithLabelValues(ep.Provider, ep.Chain).Set(1)
+ agg.record(ep.Provider, blockNum, now)
+ }
+}
+
+func parseHexInt64(s string) int64 {
+ s = strings.TrimPrefix(s, "0x")
+ if s == "" {
+ return 0
+ }
+ n, _ := strconv.ParseInt(s, 16, 64)
+ return n
+}
diff --git a/harnesses/ws-head-latency/cmd/script/loghub.go b/harnesses/ws-head-latency/cmd/script/loghub.go
new file mode 100644
index 00000000..9dd74488
--- /dev/null
+++ b/harnesses/ws-head-latency/cmd/script/loghub.go
@@ -0,0 +1,114 @@
+package main
+
+import (
+ "bufio"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "strconv"
+ "sync"
+ "time"
+)
+
+// Auto-generated by the loghub inline pattern. Captures stdout/stderr into a
+// bounded ring buffer and exposes GET /logs?tail=N protected by X-Logs-Token
+// matching the LOGS_TOKEN env var.
+//
+// Keep in sync across miniapps (was previously the shared/loghub package; we
+// inline because Railway's per-harness Docker build context can't reach a
+// sibling shared module via go.mod replace).
+
+const logRingMax = 5000
+
+type logRing struct {
+ mu sync.Mutex
+ lines []string
+ max int
+}
+
+var globalLogRing = &logRing{max: logRingMax}
+
+func (b *logRing) push(line string) {
+ entry := time.Now().UTC().Format("2006-01-02T15:04:05.000Z") + " " + line
+ b.mu.Lock()
+ if len(b.lines) >= b.max {
+ b.lines = append(b.lines[1:], entry)
+ } else {
+ b.lines = append(b.lines, entry)
+ }
+ b.mu.Unlock()
+}
+
+func (b *logRing) snapshot(tail int) []string {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ if tail <= 0 || tail >= len(b.lines) {
+ out := make([]string, len(b.lines))
+ copy(out, b.lines)
+ return out
+ }
+ start := len(b.lines) - tail
+ out := make([]string, tail)
+ copy(out, b.lines[start:])
+ return out
+}
+
+var logSetupOnce sync.Once
+
+// installLogCapture replaces os.Stdout (and os.Stderr) with the write-end of a
+// pipe, then spawns a goroutine that fan-outs every line to the original
+// stdout AND the in-memory ring buffer. Call exactly once, very early in
+// main().
+func installLogCapture() { logSetupOnce.Do(doInstallLogCapture) }
+
+func doInstallLogCapture() {
+ originalStdout := os.Stdout
+ originalStderr := os.Stderr
+ r, w, err := os.Pipe()
+ if err != nil {
+ fmt.Fprintf(originalStdout, "[loghub] pipe failed: %v (/logs will be empty)\n", err)
+ return
+ }
+ os.Stdout = w
+ os.Stderr = w
+
+ go func() {
+ scanner := bufio.NewScanner(r)
+ buf := make([]byte, 0, 1024*1024)
+ scanner.Buffer(buf, 1024*1024)
+ for scanner.Scan() {
+ line := scanner.Text()
+ fmt.Fprintln(originalStdout, line)
+ globalLogRing.push(line)
+ }
+ _, _ = io.Copy(originalStdout, r)
+ _ = originalStderr
+ }()
+}
+
+// logsHandler returns an http.Handler for GET /logs?tail=N. Requires header
+// X-Logs-Token to match the LOGS_TOKEN env var. Returns 403 if env unset.
+func logsHandler() http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ expected := os.Getenv("LOGS_TOKEN")
+ if expected == "" {
+ http.Error(w, "logs disabled: LOGS_TOKEN unset", http.StatusForbidden)
+ return
+ }
+ if r.Header.Get("X-Logs-Token") != expected {
+ http.Error(w, "forbidden", http.StatusForbidden)
+ return
+ }
+ tail := 500
+ if t := r.URL.Query().Get("tail"); t != "" {
+ if n, err := strconv.Atoi(t); err == nil && n > 0 {
+ tail = n
+ }
+ }
+ w.Header().Set("Content-Type", "text/plain; charset=utf-8")
+ for _, l := range globalLogRing.snapshot(tail) {
+ fmt.Fprintln(w, l)
+ }
+ })
+}
diff --git a/harnesses/ws-head-latency/cmd/script/main.go b/harnesses/ws-head-latency/cmd/script/main.go
new file mode 100644
index 00000000..2079aeaa
--- /dev/null
+++ b/harnesses/ws-head-latency/cmd/script/main.go
@@ -0,0 +1,38 @@
+package main
+
+import (
+ "fmt"
+ "os"
+ "os/signal"
+ "syscall"
+)
+
+func main() {
+ installLogCapture() // capture stdout into /logs ring buffer
+ fmt.Println("=== WS Head Latency Monitor ===")
+ fmt.Println("Bench № 081 — per-block WebSocket push lag across RPC providers, relative to the earliest arrival in the cohort.")
+ fmt.Println()
+
+ eps := endpoints()
+ addr := listenAddr()
+ fmt.Printf("Connections tracked: %d\n", len(eps))
+ for _, ep := range eps {
+ fmt.Printf(" %-12s %-9s %s\n", ep.Provider, ep.Chain, ep.URL)
+ }
+ fmt.Printf("Metrics server: %s/metrics\n\n", addr)
+
+ go func() {
+ if err := StartMetricsServer(addr); err != nil {
+ fmt.Printf("[fatal] metrics server: %v\n", err)
+ os.Exit(1)
+ }
+ }()
+
+ StartEndpoints()
+ startHealthSweeper()
+
+ sig := make(chan os.Signal, 1)
+ signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
+ s := <-sig
+ fmt.Printf("\n[shutdown] received %v\n", s)
+}
diff --git a/harnesses/ws-head-latency/cmd/script/metrics.go b/harnesses/ws-head-latency/cmd/script/metrics.go
new file mode 100644
index 00000000..8df51f2f
--- /dev/null
+++ b/harnesses/ws-head-latency/cmd/script/metrics.go
@@ -0,0 +1,110 @@
+package main
+
+import (
+ "net/http"
+ "os"
+
+ "github.com/prometheus/client_golang/prometheus"
+ "github.com/prometheus/client_golang/prometheus/promauto"
+ "github.com/prometheus/client_golang/prometheus/promhttp"
+)
+
+// Prom metrics for the ws-head-latency harness. Names follow the
+// `__` OCB convention. Series carry
+// {provider, chain} plus a static {region} ConstLabel bound at
+// process startup, so a multi-region deploy (Railway replicas or
+// VPS + Railway hybrid) never collides series across vantage points
+// and no observation site needs to know about the region.
+//
+// Region resolution order at startup: OCB_REGION, then
+// RAILWAY_REPLICA_REGION (Railway sets it automatically), then the
+// eu-west default that matches the historical single-vantage Paris
+// VPS deployment. Set OCB_REGION explicitly on each replica to
+// guarantee a stable value across redeploys.
+var region = resolveRegion()
+
+func resolveRegion() string {
+ if r := os.Getenv("OCB_REGION"); r != "" {
+ return r
+ }
+ if r := os.Getenv("RAILWAY_REPLICA_REGION"); r != "" {
+ return r
+ }
+ return "eu-west"
+}
+
+var regionLabels = prometheus.Labels{"region": region}
+
+var (
+ headLagHist = promauto.NewHistogramVec(
+ prometheus.HistogramOpts{
+ Name: "ws_head_lag_milliseconds",
+ Help: "Per-block push lag vs the earliest provider in the cohort, in milliseconds. Relative measurement: the fastest provider on a block reads 0 by construction.",
+ // 5ms → 10.24s exponential; covers same-frame ties through
+ // multi-second stragglers.
+ Buckets: prometheus.ExponentialBuckets(5, 2, 12),
+ ConstLabels: regionLabels,
+ },
+ []string{"provider", "chain"},
+ )
+
+ headWins = promauto.NewCounterVec(
+ prometheus.CounterOpts{
+ Name: "ws_head_wins_total",
+ Help: "Blocks where this provider was the first in the cohort to push the head/slot. Only counted when at least 2 providers saw the block.",
+ ConstLabels: regionLabels,
+ },
+ []string{"provider", "chain"},
+ )
+
+ headBlocksSeen = promauto.NewCounterVec(
+ prometheus.CounterOpts{
+ Name: "ws_head_blocks_seen_total",
+ Help: "Distinct blocks/slots this provider pushed within the cohort settle window. Ratio against the cohort max is the coverage score.",
+ ConstLabels: regionLabels,
+ },
+ []string{"provider", "chain"},
+ )
+
+ wsReconnects = promauto.NewCounterVec(
+ prometheus.CounterOpts{
+ Name: "ws_reconnects_total",
+ Help: "WebSocket reconnections per (provider, chain) since process start. Sustained non-zero rate flags an unstable endpoint.",
+ ConstLabels: regionLabels,
+ },
+ []string{"provider", "chain"},
+ )
+
+ wsHealth = promauto.NewGaugeVec(
+ prometheus.GaugeOpts{
+ Name: "ws_health",
+ Help: "1 when the subscription is connected and delivered a message in the last 120s, 0 otherwise.",
+ ConstLabels: regionLabels,
+ },
+ []string{"provider", "chain"},
+ )
+
+ headBlockGap = promauto.NewCounterVec(
+ prometheus.CounterOpts{
+ Name: "ws_block_gap_total",
+ Help: "Blocks the cohort saw but this provider missed (absent from the 5s settle window while its connection was otherwise live).",
+ ConstLabels: regionLabels,
+ },
+ []string{"provider", "chain"},
+ )
+)
+
+// StartMetricsServer binds /metrics + /health + /logs on addr. Blocking
+// call, run in its own goroutine.
+func StartMetricsServer(addr string) error {
+ mux := http.NewServeMux()
+ mux.Handle("/metrics", promhttp.Handler())
+ mux.Handle("/logs", logsHandler())
+ mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
+ _, _ = w.Write([]byte("ok"))
+ })
+ mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+ _, _ = w.Write([]byte("ws-head-latency harness · OpenChainBench № 081"))
+ })
+ return http.ListenAndServe(addr, mux)
+}
diff --git a/harnesses/ws-head-latency/cmd/script/solana_ws.go b/harnesses/ws-head-latency/cmd/script/solana_ws.go
new file mode 100644
index 00000000..6ebf8cc3
--- /dev/null
+++ b/harnesses/ws-head-latency/cmd/script/solana_ws.go
@@ -0,0 +1,80 @@
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "time"
+)
+
+// Solana slot push via `slotSubscribe`. The notification frame looks
+// like:
+//
+// {"jsonrpc":"2.0","method":"slotNotification",
+// "params":{"result":{"parent":364,"root":362,"slot":365},"subscription":0}}
+//
+// The slot number is the cohort key, playing the role the block number
+// plays on EVM chains. Solana slots tick every ~400ms, so the cohort
+// churn is higher but the settle/score path is identical.
+
+type solanaEnvelope struct {
+ Method string `json:"method"`
+ Params *struct {
+ Result struct {
+ Slot int64 `json:"slot"`
+ } `json:"result"`
+ } `json:"params,omitempty"`
+ // Subscribe ack: {"jsonrpc":"2.0","result":23784,"id":1}
+ Error *struct {
+ Code int `json:"code"`
+ Message string `json:"message"`
+ } `json:"error,omitempty"`
+}
+
+func runSolanaConn(ep Endpoint) error {
+ conn, bumpHead, cleanup, err := dialAndGuard(ep)
+ if err != nil {
+ return err
+ }
+ defer cleanup()
+
+ if err := conn.WriteJSON(rpcReq{
+ JSONRPC: "2.0", ID: 1, Method: "slotSubscribe",
+ Params: []any{},
+ }); err != nil {
+ return fmt.Errorf("subscribe: %w", err)
+ }
+
+ wsHealth.WithLabelValues(ep.Provider, ep.Chain).Set(1)
+ fmt.Printf("[%s/%s] connected, subscribed to slotSubscribe\n", ep.Provider, ep.Chain)
+
+ agg := aggregatorFor(ep.Chain)
+ for {
+ _ = conn.SetReadDeadline(time.Now().Add(readDeadline))
+ _, msg, err := conn.ReadMessage()
+ if err != nil {
+ return fmt.Errorf("read: %w", err)
+ }
+
+ var env solanaEnvelope
+ if err := json.Unmarshal(msg, &env); err != nil {
+ continue
+ }
+ if env.Error != nil {
+ // Endpoint answered the subscribe with a JSON-RPC error
+ // (method disabled, quota, ...). Surface it and let the
+ // outer loop back off instead of holding a dead socket.
+ return fmt.Errorf("subscribe error %d: %s", env.Error.Code, env.Error.Message)
+ }
+ if env.Method != "slotNotification" || env.Params == nil {
+ continue // subscribe ack or unrelated frame
+ }
+ slot := env.Params.Result.Slot
+ if slot == 0 {
+ continue
+ }
+ now := time.Now()
+ bumpHead()
+ wsHealth.WithLabelValues(ep.Provider, ep.Chain).Set(1)
+ agg.record(ep.Provider, slot, now)
+ }
+}
diff --git a/harnesses/ws-head-latency/go.mod b/harnesses/ws-head-latency/go.mod
new file mode 100644
index 00000000..3bca706e
--- /dev/null
+++ b/harnesses/ws-head-latency/go.mod
@@ -0,0 +1,21 @@
+module ws-head-latency
+
+go 1.24.0
+
+require (
+ github.com/gorilla/websocket v1.5.3
+ github.com/prometheus/client_golang v1.23.2
+)
+
+require (
+ github.com/beorn7/perks v1.0.1 // indirect
+ github.com/cespare/xxhash/v2 v2.3.0 // indirect
+ github.com/kr/text v0.2.0 // indirect
+ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
+ github.com/prometheus/client_model v0.6.2 // indirect
+ github.com/prometheus/common v0.66.1 // indirect
+ github.com/prometheus/procfs v0.16.1 // indirect
+ go.yaml.in/yaml/v2 v2.4.2 // indirect
+ golang.org/x/sys v0.35.0 // indirect
+ google.golang.org/protobuf v1.36.8 // indirect
+)
diff --git a/harnesses/ws-head-latency/go.sum b/harnesses/ws-head-latency/go.sum
new file mode 100644
index 00000000..02807ba4
--- /dev/null
+++ b/harnesses/ws-head-latency/go.sum
@@ -0,0 +1,48 @@
+github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
+github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
+github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
+github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
+github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
+github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
+github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
+github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
+github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
+github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
+github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
+github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
+github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
+github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
+github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
+github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
+github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
+github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
+github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
+go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
+go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
+go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
+golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
+golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
+google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/harnesses/ws-head-latency/railway.toml b/harnesses/ws-head-latency/railway.toml
new file mode 100644
index 00000000..2abbb1f0
--- /dev/null
+++ b/harnesses/ws-head-latency/railway.toml
@@ -0,0 +1,7 @@
+[build]
+builder = "DOCKERFILE"
+dockerfilePath = "Dockerfile"
+
+[deploy]
+healthcheckPath = "/health"
+restartPolicyType = "ON_FAILURE"
From 168b3d6da3f7cf9912ac64328eb2e873e14909a7 Mon Sep 17 00:00:00 2001
From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com>
Date: Thu, 16 Jul 2026 13:20:17 +0200
Subject: [PATCH 23/89] ws-head-latency: align region env var with
rpc-capabilities (REGION not OCB_REGION) (#1200)
Every other multi-region harness on Railway (rpc-capabilities, aggregator-head-lag, evm-swap-quoting, pm-rate-limits, etc.) reads REGION from env. Making ws-head-latency read REGION too keeps the Railway service config uniform across the whole harness fleet.
Co-authored-by: Florent Tapponnier
---
harnesses/ws-head-latency/cmd/script/metrics.go | 11 ++++++-----
1 file changed, 6 insertions(+), 5 deletions(-)
diff --git a/harnesses/ws-head-latency/cmd/script/metrics.go b/harnesses/ws-head-latency/cmd/script/metrics.go
index 8df51f2f..af8f2842 100644
--- a/harnesses/ws-head-latency/cmd/script/metrics.go
+++ b/harnesses/ws-head-latency/cmd/script/metrics.go
@@ -16,15 +16,16 @@ import (
// VPS + Railway hybrid) never collides series across vantage points
// and no observation site needs to know about the region.
//
-// Region resolution order at startup: OCB_REGION, then
-// RAILWAY_REPLICA_REGION (Railway sets it automatically), then the
-// eu-west default that matches the historical single-vantage Paris
-// VPS deployment. Set OCB_REGION explicitly on each replica to
+// Region resolution order at startup: REGION (matches the convention
+// used by rpc-capabilities and every other multi-region harness on
+// Railway), then RAILWAY_REPLICA_REGION (Railway auto-sets it), then
+// the eu-west default that matches the historical single-vantage
+// Paris VPS deployment. Set REGION explicitly on each replica to
// guarantee a stable value across redeploys.
var region = resolveRegion()
func resolveRegion() string {
- if r := os.Getenv("OCB_REGION"); r != "" {
+ if r := os.Getenv("REGION"); r != "" {
return r
}
if r := os.Getenv("RAILWAY_REPLICA_REGION"); r != "" {
From 80f4fc6b698c74054b5eb1533e0cc7cf186e9b33 Mon Sep 17 00:00:00 2001
From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com>
Date: Thu, 16 Jul 2026 13:26:49 +0200
Subject: [PATCH 24/89] prom: scrape ws-head-latency-{us,eu,sgp} on Railway
(#1202)
Adds the ws-head-latency job to the Railway central Prometheus so
the 3 replicas (us-east, eu-west, sgp) get scraped. Matches the
rpc-capabilities scrape layout: static targets on internal Railway
DNS, honor_labels so the harness's REGION ConstLabel wins, benchmark
label for the recording rules.
Railway auto-redeploys Prometheus on merge; the ws-head-latency
services start showing up in the federate-railway pull to the VPS
Prom within a scrape interval.
Co-authored-by: Florent Tapponnier
---
.../monitoring/prometheus/prometheus.yml | 17 +++++++++++++++++
1 file changed, 17 insertions(+)
diff --git a/infrastructure/monitoring/prometheus/prometheus.yml b/infrastructure/monitoring/prometheus/prometheus.yml
index 840e35af..213f5321 100644
--- a/infrastructure/monitoring/prometheus/prometheus.yml
+++ b/infrastructure/monitoring/prometheus/prometheus.yml
@@ -171,6 +171,23 @@ scrape_configs:
benchmark: rpc-capabilities
metrics_path: /metrics
+ # OpenChainBench bench №081 + siblings 087 (base) + 088 (solana) —
+ # WebSocket head-latency race across free keyless RPC providers.
+ # Same 3-region layout as rpc-capabilities. Each service pins its
+ # REGION env var; the harness bakes it as a ConstLabel on every
+ # ws_head_* metric so Prom fed the union of the 3 replicas gets
+ # {provider, chain, region} triples for the per-region spec tabs.
+ - job_name: 'ws-head-latency'
+ honor_labels: true
+ static_configs:
+ - targets:
+ - 'ws-head-latency-us.railway.internal:2112' # us-east
+ - 'ws-head-latency-eu.railway.internal:2112' # eu-west
+ - 'ws-head-latency-sgp.railway.internal:2112' # sgp
+ labels:
+ benchmark: ws-head-latency
+ metrics_path: /metrics
+
# OpenChainBench — keyed free-tier RPC latency (Alchemy, Infura,
# Chainstack, Ankr, Helius). Companion to rpc-capabilities: same
# metric names + tier="keyed" label, picked up by the shared
From 638c0650b9fd8c6d70b5ae53189684faaedea4d7 Mon Sep 17 00:00:00 2001
From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com>
Date: Thu, 16 Jul 2026 14:20:29 +0200
Subject: [PATCH 25/89] rename ws-head-latency -> ws-head-latency-ethereum for
sibling slug parity (#1204) (#1205)
081 becomes ws-head-latency-ethereum so the 3 per-chain benches in the head-lag cluster share the same shape: ws-head-latency-ethereum / -base / -solana. The old ws-head-latency slug is dropped (was staging-gated via REMOVED_BENCH_SLUGS, never live on prod, no external SEO to preserve).
Ungated on prod at the same time so the new slug ships alongside the base + solana siblings that are already live. Sibling text references updated to the new URL. Cache keys bench-unfiltered and all-benchmarks bumped so cached v29/v32 entries do not keep the old slug alive.
Co-authored-by: Florent Tapponnier
---
benchmarks/ws-head-latency-base.yml | 6 ++---
...tency.yml => ws-head-latency-ethereum.yml} | 6 ++---
benchmarks/ws-head-latency-solana.yml | 6 ++---
src/lib/removed-benches.ts | 11 ++++++++
src/lib/spec.ts | 26 ++++++++++++++++---
5 files changed, 43 insertions(+), 12 deletions(-)
rename benchmarks/{ws-head-latency.yml => ws-head-latency-ethereum.yml} (99%)
diff --git a/benchmarks/ws-head-latency-base.yml b/benchmarks/ws-head-latency-base.yml
index dbe4f812..44bc9fb9 100644
--- a/benchmarks/ws-head-latency-base.yml
+++ b/benchmarks/ws-head-latency-base.yml
@@ -36,13 +36,13 @@ abstract: |
eu-west host, cohort settle window 5 seconds after the first
arrival, lag histogram per provider, gap counter for missed blocks
inside a live subscription. The cross-chain view lives on the
- parent [ws-head-latency](https://openchainbench.com/benchmarks/ws-head-latency)
+ parent [ws-head-latency](https://openchainbench.com/benchmarks/ws-head-latency-ethereum)
benchmark; this page is the Base-scoped answer.
methodology:
- "Providers measured (keyless WebSocket endpoints): PublicNode (wss://base-rpc.publicnode.com), dRPC (wss://base.drpc.org). Tenderly does not expose a keyless Base WebSocket path and is scoped out until a public gateway is announced."
- "Chain scope: every query on this page is pinned to chain=\"base\". Base block cadence is 2 seconds, so a 24h window carries roughly 43000 samples per provider (versus ~7200 on Ethereum), well above the 7000 sample floor used by the parent bench."
- - "Race scoring: identical to the parent [ws-head-latency](https://openchainbench.com/benchmarks/ws-head-latency) benchmark. First provider to deliver block N sets T0. Each provider's sample for block N is arrival(N) minus T0 in milliseconds. Cohort closes 5 seconds after the first arrival; late arrivals are scored as missed blocks via ws_block_gap_total rather than as huge latency samples."
+ - "Race scoring: identical to the parent [ws-head-latency](https://openchainbench.com/benchmarks/ws-head-latency-ethereum) benchmark. First provider to deliver block N sets T0. Each provider's sample for block N is arrival(N) minus T0 in milliseconds. Cohort closes 5 seconds after the first arrival; late arrivals are scored as missed blocks via ws_block_gap_total rather than as huge latency samples."
- "Relative, not absolute: subtraction of two arrivals over the same path removes vantage-point network latency. Consequence: the fastest provider reads near zero by construction. The honest readings are win rate, trailers' lag distribution, and gap counts."
- "Aggregation: per-provider lag histogram (5ms to 10s exponential buckets), p50/p90/p99 via histogram_quantile over the 24h rate. Coverage score = ws_head_blocks_seen_total divided by cohort maximum, clamped to 1. A provider that pushes fast but skips blocks loses on this column, not on the latency percentiles."
- "Connection discipline: one persistent WebSocket per (provider, chain), client ping every 30s, read deadline 60s, 90s head watchdog forcing reconnect when a socket keeps the heartbeat alive but silently drops the subscription. Exponential backoff 2s to 60s on reconnect."
@@ -52,7 +52,7 @@ findings:
- "{{best_name}} leads the Base cohort at {{best_p50}} (p50, 24 h) across {{count}} measured providers. Base's 2 second cadence means each provider gets 6 times more samples per unit time than the Ethereum sibling; the distribution here has less tail noise than on Ethereum for the same 24h window."
- "{{name:drpc}} trails the winner by {{p50:drpc}} at the median on Base. That is the extra time a Base perp keeper or liquidation bot on dRPC's keyless endpoint waits for block N after the fastest feed already delivered it."
- "The p99 column is the integration-grade number for a chain that produces a block every 2 seconds: {{p99:publicnode}} / {{p99:drpc}}. A multi-second p99 on Base means routinely missing an entire block cycle."
- - "Base is 2 second cadence, Ethereum 12 seconds, Solana ~400ms slots. Cross-chain comparison of the absolute lag numbers is not meaningful; use each per-chain leaderboard on its own terms and the parent [ws-head-latency](https://openchainbench.com/benchmarks/ws-head-latency) page for the Ethereum reference."
+ - "Base is 2 second cadence, Ethereum 12 seconds, Solana ~400ms slots. Cross-chain comparison of the absolute lag numbers is not meaningful; use each per-chain leaderboard on its own terms and the parent [ws-head-latency](https://openchainbench.com/benchmarks/ws-head-latency-ethereum) page for the Ethereum reference."
source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/ws-head-latency
diff --git a/benchmarks/ws-head-latency.yml b/benchmarks/ws-head-latency-ethereum.yml
similarity index 99%
rename from benchmarks/ws-head-latency.yml
rename to benchmarks/ws-head-latency-ethereum.yml
index 7b66ea78..2d7c9eb7 100644
--- a/benchmarks/ws-head-latency.yml
+++ b/benchmarks/ws-head-latency-ethereum.yml
@@ -1,9 +1,9 @@
# OpenChainBench. Bench № 081
-slug: ws-head-latency
+slug: ws-head-latency-ethereum
number: "081"
-title: Fastest WebSocket newHeads push, live block-push lag across RPC providers
-seo_title: "Fastest WebSocket RPC newHeads push 2026"
+title: Fastest Ethereum WebSocket newHeads push, live block-push lag across RPC providers
+seo_title: "Fastest Ethereum WebSocket RPC newHeads push 2026"
seo_description: "{{best_name}} pushes Ethereum newHeads first. Live WebSocket block-push lag for PublicNode, dRPC and Tenderly, measured per block against the earliest arrival in the cohort."
subtitle: Per-block WebSocket push lag in milliseconds versus the earliest provider to deliver the same Ethereum head, measured continuously from one eu-west vantage point.
diff --git a/benchmarks/ws-head-latency-solana.yml b/benchmarks/ws-head-latency-solana.yml
index cc9ee17c..5e73184e 100644
--- a/benchmarks/ws-head-latency-solana.yml
+++ b/benchmarks/ws-head-latency-solana.yml
@@ -36,14 +36,14 @@ abstract: |
distinct slot as one race, the first provider to deliver slot N
sets T0, every other provider's sample is arrival minus T0 in
milliseconds. The cross-chain view lives on the parent
- [ws-head-latency](https://openchainbench.com/benchmarks/ws-head-latency)
+ [ws-head-latency](https://openchainbench.com/benchmarks/ws-head-latency-ethereum)
benchmark; this page is the Solana-scoped answer.
methodology:
- "Providers measured (keyless WebSocket endpoints): PublicNode (wss://solana-rpc.publicnode.com) and Solana Foundation (wss://api.mainnet-beta.solana.com). Alchemy and Helius are keyed-only and join the cohort when contributor keys are wired."
- "Chain scope: every query on this page is pinned to chain=\"solana\". Solana slot cadence is ~400ms, so a 24h window carries roughly 216000 samples per provider, an order of magnitude beyond the 7000 sample floor used across the head-lag cluster."
- "RPC method: `slotSubscribe`, not `signatureSubscribe` or `logsSubscribe`. The notification payload includes the slot number and the parent slot; the harness dedupes on slot number and uses the first arrival per slot to define T0."
- - "Race scoring: identical to the parent [ws-head-latency](https://openchainbench.com/benchmarks/ws-head-latency) benchmark, applied per slot instead of per block. First provider to deliver slot N sets T0. Each provider's sample for slot N is arrival(N) minus T0 in milliseconds. Cohort closes 5 seconds after the first arrival."
+ - "Race scoring: identical to the parent [ws-head-latency](https://openchainbench.com/benchmarks/ws-head-latency-ethereum) benchmark, applied per slot instead of per block. First provider to deliver slot N sets T0. Each provider's sample for slot N is arrival(N) minus T0 in milliseconds. Cohort closes 5 seconds after the first arrival."
- "Relative, not absolute: subtraction of two arrivals over the same path removes vantage-point network latency. Consequence: the fastest provider reads near zero by construction. The honest readings are win rate, trailers' lag distribution, and gap counts, not the leader's absolute number."
- "Aggregation: per-provider lag histogram (5ms to 10s exponential buckets), p50/p90/p99 via histogram_quantile over the 24h rate. Coverage score = ws_head_blocks_seen_total divided by cohort maximum, clamped to 1."
- "Connection discipline: one persistent WebSocket per (provider, chain), client ping every 30s, read deadline 60s, 90s slot watchdog forcing reconnect when a socket keeps the heartbeat alive but silently drops the subscription. Exponential backoff 2s to 60s on reconnect."
@@ -53,7 +53,7 @@ findings:
- "{{best_name}} leads the Solana cohort at {{best_p50}} (p50, 24 h) across {{count}} measured providers. Solana's ~400ms cadence means each provider gets 30 times more samples per unit time than the Ethereum sibling; the distribution here has very little tail noise for the same 24h window."
- "{{name:solana-labs}} versus {{name:publicnode}}: the Solana Foundation's public endpoint and PublicNode's Solana gateway measured on identical terms. A latency-sensitive Solana integration should read the win rate alongside p50 to see which endpoint delivers slot N first most often."
- "The p99 column matters more on Solana than on any EVM chain in the cluster: at ~400ms slots, a p99 above 1 second means the provider is routinely 2-3 slots behind the leader. That gap is the exact window where a keeper or liquidator loses to a co-located competitor."
- - "Cross-chain comparison of absolute lag numbers is not meaningful: Solana runs at 400ms slots, Base at 2s, Ethereum at 12s. Use each per-chain leaderboard on its own terms and the parent [ws-head-latency](https://openchainbench.com/benchmarks/ws-head-latency) page for the Ethereum reference."
+ - "Cross-chain comparison of absolute lag numbers is not meaningful: Solana runs at 400ms slots, Base at 2s, Ethereum at 12s. Use each per-chain leaderboard on its own terms and the parent [ws-head-latency](https://openchainbench.com/benchmarks/ws-head-latency-ethereum) page for the Ethereum reference."
source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/ws-head-latency
diff --git a/src/lib/removed-benches.ts b/src/lib/removed-benches.ts
index 24cda458..49b665af 100644
--- a/src/lib/removed-benches.ts
+++ b/src/lib/removed-benches.ts
@@ -36,5 +36,16 @@ export const REMOVED_BENCH_SLUGS = new Set([
"indexing-freshness",
"rpc-keyed-latency",
"explorer-chain-coverage",
+ "tokenized-stock-arb-latency",
"portfolio-chain-coverage",
+ // bench vague 2 (082-085): validating on staging until harnesses have
+ // 48h of clean data on the VPS, then ship dev -> main. 081 renamed
+ // to ws-head-latency-ethereum for slug parity with the base + solana
+ // siblings; both siblings shipped to main without gating (harness has
+ // clean data via Railway 3-region deploy), so the ethereum-scoped one
+ // follows suit and is not gated.
+ "oracle-freshness",
+ "rpc-reliability",
+ "indexer-latency",
+ "evm-block-builders",
]);
diff --git a/src/lib/spec.ts b/src/lib/spec.ts
index 58d4434c..41b95334 100644
--- a/src/lib/spec.ts
+++ b/src/lib/spec.ts
@@ -201,6 +201,16 @@ const loadBenchmarkUnfilteredCached = unstable_cache(
// ISR re-render bursts.
const stored = await benchFromStore(slug, "");
if (stored) return slimBenchmarkForCache(overlayEditorial(stored, spec));
+ if (spec.status === "live") {
+ // A live spec with no readable blob is a transient store failure
+ // (proxy timeout, worker mid-write), not a real state. Returning
+ // undefined would cache the miss for the whole revalidate window:
+ // home row + /api/stat then serve "Awaiting samples" while the
+ // bench page reads fine (bnb-rpc incident, 2026-07-13). Throwing
+ // makes unstable_cache keep the last good entry; cold-cache
+ // callers already catch and fall back to the draft placeholder.
+ throw new Error(`store blob missing for live bench ${slug}`);
+ }
return undefined;
},
// Version key bumped when Benchmark shape changes so stale cache entries
@@ -273,7 +283,13 @@ const loadBenchmarkUnfilteredCached = unstable_cache(
// buckets (gap rendering fix). Cached v26 entries hold the old
// hole-compressed arrays whose indices no longer map onto the nominal
// step grid the chart back-computes timestamps from.
- ["bench-unfiltered-v36", process.env.VERCEL_ENV === "production" ? "prod" : "all"],
+// v28: bench vague 2 ships 081-085 (ws-head-latency, oracle-freshness,
+ // rpc-reliability, indexer-latency, evm-block-builders). Bench SET changed.
+ // v30: 081 slug renamed ws-head-latency -> ws-head-latency-ethereum for
+ // parity with the base + solana siblings + ungated on prod. Bench SET
+ // changed (old slug gone, new slug live). Bump to purge cached v29 that
+ // still keys the old slug.
+ ["bench-unfiltered-v30", process.env.VERCEL_ENV === "production" ? "prod" : "all"],
{ revalidate: 300, tags: ["benchmarks"] },
);
@@ -428,7 +444,10 @@ const loadAllBenchmarksCached = unstable_cache(
// gated catalog to /products for 30+ min after the deploy).
// v29: bumped with bench-unfiltered-v26 (monad-rpc + megaeth-rpc ship).
// v30: bumped with bench-unfiltered-v27 (dense series with nulls).
- ["all-benchmarks-v39", process.env.VERCEL_ENV === "production" ? "prod" : "all"],
+// v31: bumped with bench-unfiltered-v28 (bench vague 2, 081-085).
+ // v32: bumped with bench-unfiltered-v30 (081 slug rename ws-head-latency
+ // -> ws-head-latency-ethereum + ungate on prod).
+ ["all-benchmarks-v33", process.env.VERCEL_ENV === "production" ? "prod" : "all"],
{ revalidate: 300, tags: ["benchmarks"] },
);
export const loadAllBenchmarks = cache(loadAllBenchmarksCached);
@@ -507,7 +526,8 @@ const loadBenchmarkFiltered = unstable_cache(
// v16: bumped with the bench 074 ship (lockstep rule, see all-benchmarks-v28).
// v17: bumped with the monad-rpc + megaeth-rpc ship (lockstep rule).
// v18: bumped with bench-unfiltered-v27 (dense series with nulls).
- ["bench-filters-v27", process.env.VERCEL_ENV === "production" ? "prod" : "all"],
+ // v19: bumped with bench-unfiltered-v28 (bench vague 2, 081-085).
+ ["bench-filters-v20", process.env.VERCEL_ENV === "production" ? "prod" : "all"],
{ revalidate: 300, tags: ["benchmarks"] }
);
From 1d7996a620ef6a4e3410515ec501a51dafc97c8e Mon Sep 17 00:00:00 2001
From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com>
Date: Thu, 16 Jul 2026 14:38:57 +0200
Subject: [PATCH 26/89] network-coverage: drop coinstats (free tier credits
exhausted) (#1206) (#1207)
CoinStats /wallet/blockchains returns HTTP 406 "Credits limit reached" on every fetch: the free tier quota is spent below our 6h cadence and the provider never publishes a network count. Dropping until a paid key is provisioned.
Removes the coinstats.go adapter, the CoinStatsAPIKey config field, the coinstats entry in main.go, and the coinstats provider row + prose mentions in the spec. Cohort drops 6 -> 5 providers (GeckoTerminal, Codex, Mobula, CoinPaprika, Dune via Sim API).
Co-authored-by: Florent Tapponnier
---
benchmarks/network-coverage.yml | 22 ++-----
.../network-coverage/cmd/script/coinstats.go | 62 -------------------
.../network-coverage/cmd/script/config.go | 6 +-
harnesses/network-coverage/cmd/script/main.go | 6 +-
4 files changed, 11 insertions(+), 85 deletions(-)
delete mode 100644 harnesses/network-coverage/cmd/script/coinstats.go
diff --git a/benchmarks/network-coverage.yml b/benchmarks/network-coverage.yml
index 82dedfed..e015946a 100644
--- a/benchmarks/network-coverage.yml
+++ b/benchmarks/network-coverage.yml
@@ -32,9 +32,9 @@ seo_intro: |
abstract: |
We benchmark how many networks each major onchain data provider lists
in its public "supported networks" endpoint. The harness fetches the
- official listing every six hours from six providers (GeckoTerminal,
- Codex, Mobula, CoinPaprika, CoinStats and Dune via Sim API),
- deduplicates by chain id and counts. Mainnet only,
+ official listing every six hours from five providers (GeckoTerminal,
+ Codex, Mobula, CoinPaprika and Dune via Sim API), deduplicates by
+ chain id and counts. Mainnet only,
testnets are excluded because providers list them inconsistently and
the comparison is meant to reflect what a builder can integrate against
in production. Coverage breadth is one dimension of a data provider's
@@ -47,7 +47,6 @@ methodology:
- "Codex: GraphQL `getNetworks` query at https://graph.codex.io/graphql with an official API key."
- "Mobula: GET /api/1/blockchains with an Authorization API key."
- "CoinPaprika: GET /v1/contracts (no auth). Lists platforms supported for contract lookup."
- - "CoinStats: GET /wallet/blockchains with X-API-KEY."
- "Dune (via Sim API): GET https://api.sim.dune.com/v1/evm/supported-chains (no auth). EVM only, mainnets filtered via the `mainnet` tag."
- "Cadence: full refresh every 6 hours."
- "Counting: a provider's network is counted once per unique chain id; mainnet only."
@@ -82,7 +81,7 @@ faq:
# Real metrics exposed by the network-coverage harness:
# networks_supported_total{provider="geckoterminal"|"codex"|"mobula"|
-# "coinpaprika"|"coinstats"|"dune"}
+# "coinpaprika"|"dune"}
# -> gauge, the unique-chain count from the latest successful refresh.
# network_supported{provider, chain_id, slug, name} -> gauge (1 per
# network; useful for diff queries on the site if we add a
@@ -141,19 +140,6 @@ providers:
sample_size: networks_supported_total{provider="coinpaprika"}
series: networks_supported_total{provider="coinpaprika"}
- - slug: coinstats
- name: CoinStats
- tag: Portfolio + market data
- formula: "Count of blockchains returned by CoinStats's `/wallet/blockchains` endpoint, refreshed every 6 hours."
- queries:
- p50: networks_supported_total{provider="coinstats"}
- p90: networks_supported_total{provider="coinstats"}
- p99: networks_supported_total{provider="coinstats"}
- mean: networks_supported_total{provider="coinstats"}
- success: clamp_max(networks_supported_total{provider="coinstats"} > bool 0, 1)
- sample_size: networks_supported_total{provider="coinstats"}
- series: networks_supported_total{provider="coinstats"}
-
- slug: dune
name: Dune
tag: Onchain analytics + Sim API
diff --git a/harnesses/network-coverage/cmd/script/coinstats.go b/harnesses/network-coverage/cmd/script/coinstats.go
deleted file mode 100644
index 344c6ee7..00000000
--- a/harnesses/network-coverage/cmd/script/coinstats.go
+++ /dev/null
@@ -1,62 +0,0 @@
-package main
-
-import (
- "encoding/json"
- "fmt"
- "io"
- "net/http"
- "time"
-)
-
-const coinstatsBlockchainsURL = "https://openapiv1.coinstats.app/wallet/blockchains"
-
-// CoinStats /wallet/blockchains returns a flat JSON array of:
-// {connectionId, name, icon, chain}
-// `connectionId` is the slug (e.g. "binancesmartchain"), `chain` is the
-// category enum (e.g. "binance_smart"), `name` is the human label.
-type coinstatsBlockchain struct {
- ConnectionID string `json:"connectionId"`
- Name string `json:"name"`
- Chain string `json:"chain"`
-}
-
-func fetchCoinStats(cfg *Config) ProviderResult {
- res := ProviderResult{Provider: "coinstats"}
- if cfg.CoinStatsAPIKey == "" {
- res.Err = "missing_api_key"
- return res
- }
-
- client := &http.Client{Timeout: 15 * time.Second}
- req, _ := http.NewRequest("GET", coinstatsBlockchainsURL, nil)
- req.Header.Set("X-API-KEY", cfg.CoinStatsAPIKey)
- req.Header.Set("Accept", "application/json")
-
- resp, err := client.Do(req)
- if err != nil {
- res.Err = fmt.Sprintf("request_error: %v", err)
- return res
- }
- defer resp.Body.Close()
- body, _ := io.ReadAll(resp.Body)
-
- if resp.StatusCode != 200 {
- res.Err = fmt.Sprintf("status_%d", resp.StatusCode)
- return res
- }
-
- var arr []coinstatsBlockchain
- if err := json.Unmarshal(body, &arr); err != nil {
- res.Err = fmt.Sprintf("parse_error: %v", err)
- return res
- }
-
- for _, b := range arr {
- res.Networks = append(res.Networks, Network{
- ChainID: b.Chain,
- Slug: b.ConnectionID,
- Name: b.Name,
- })
- }
- return res
-}
diff --git a/harnesses/network-coverage/cmd/script/config.go b/harnesses/network-coverage/cmd/script/config.go
index 83d27ab8..1bbdc805 100644
--- a/harnesses/network-coverage/cmd/script/config.go
+++ b/harnesses/network-coverage/cmd/script/config.go
@@ -12,7 +12,6 @@ type Config struct {
CodexAPIKey string // official Codex Bearer (preferred — no mint, no proxy)
CodexSessionCookie string // fallback path: mint JWT from Defined.fi cookie
DefinedTokenURL string // optional: pre-minted JWT sidecar
- CoinStatsAPIKey string
SimDuneAPIKey string // optional — Sim's public endpoint works keyless, but a key avoids rate limits
HTTPProxy string
RefreshInterval time.Duration
@@ -25,7 +24,6 @@ func loadConfig() *Config {
CodexAPIKey: os.Getenv("CODEX_API_KEY"),
CodexSessionCookie: os.Getenv("DEFINED_SESSION_COOKIE"),
DefinedTokenURL: os.Getenv("DEFINED_TOKEN_SERVICE_URL"),
- CoinStatsAPIKey: os.Getenv("COINSTATS_API_KEY"),
SimDuneAPIKey: os.Getenv("SIM_DUNE_API_KEY"),
HTTPProxy: os.Getenv("HTTP_PROXY"),
RefreshInterval: 6 * time.Hour,
@@ -47,8 +45,8 @@ func loadConfig() *Config {
} else if c.CodexSessionCookie != "" {
codexAuth = "cookie+mint"
}
- fmt.Printf("Config: refresh=%v, testnets=%v, mobula_key=%v, codex=%s, coinstats_key=%v, sim_dune_key=%v\n",
+ fmt.Printf("Config: refresh=%v, testnets=%v, mobula_key=%v, codex=%s, sim_dune_key=%v\n",
c.RefreshInterval, c.IncludeTestnets, c.MobulaAPIKey != "", codexAuth,
- c.CoinStatsAPIKey != "", c.SimDuneAPIKey != "")
+ c.SimDuneAPIKey != "")
return c
}
diff --git a/harnesses/network-coverage/cmd/script/main.go b/harnesses/network-coverage/cmd/script/main.go
index b8c544c4..cb8f157c 100644
--- a/harnesses/network-coverage/cmd/script/main.go
+++ b/harnesses/network-coverage/cmd/script/main.go
@@ -73,7 +73,11 @@ func fetchAll(cfg *Config) {
{"codex", fetchCodex},
{"coinpaprika", fetchCoinPaprika},
{"dune", fetchSimDune},
- {"coinstats", fetchCoinStats},
+ // coinstats dropped 2026-07-16: the free tier exhausts credits
+ // well below the harness cadence, so /wallet/blockchains returns
+ // HTTP 406 "Credits limit reached" on every fetch and never
+ // publishes a networks count. Re-add when a paid key is
+ // provisioned; the removed adapter is one revert away in git.
}
var wg sync.WaitGroup
From c3f76f14c1421c118442cf182e719f361ec74cb4 Mon Sep 17 00:00:00 2001
From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com>
Date: Thu, 16 Jul 2026 14:43:37 +0200
Subject: [PATCH 27/89] Revert "network-coverage: drop coinstats (free tier
credits exhausted) (#1206)" (#1208) (#1209)
This reverts commit d295f7168da981078164119e508ca9546ed69fa2.
Co-authored-by: Florent Tapponnier
---
benchmarks/network-coverage.yml | 22 +++++--
.../network-coverage/cmd/script/coinstats.go | 62 +++++++++++++++++++
.../network-coverage/cmd/script/config.go | 6 +-
harnesses/network-coverage/cmd/script/main.go | 6 +-
4 files changed, 85 insertions(+), 11 deletions(-)
create mode 100644 harnesses/network-coverage/cmd/script/coinstats.go
diff --git a/benchmarks/network-coverage.yml b/benchmarks/network-coverage.yml
index e015946a..82dedfed 100644
--- a/benchmarks/network-coverage.yml
+++ b/benchmarks/network-coverage.yml
@@ -32,9 +32,9 @@ seo_intro: |
abstract: |
We benchmark how many networks each major onchain data provider lists
in its public "supported networks" endpoint. The harness fetches the
- official listing every six hours from five providers (GeckoTerminal,
- Codex, Mobula, CoinPaprika and Dune via Sim API), deduplicates by
- chain id and counts. Mainnet only,
+ official listing every six hours from six providers (GeckoTerminal,
+ Codex, Mobula, CoinPaprika, CoinStats and Dune via Sim API),
+ deduplicates by chain id and counts. Mainnet only,
testnets are excluded because providers list them inconsistently and
the comparison is meant to reflect what a builder can integrate against
in production. Coverage breadth is one dimension of a data provider's
@@ -47,6 +47,7 @@ methodology:
- "Codex: GraphQL `getNetworks` query at https://graph.codex.io/graphql with an official API key."
- "Mobula: GET /api/1/blockchains with an Authorization API key."
- "CoinPaprika: GET /v1/contracts (no auth). Lists platforms supported for contract lookup."
+ - "CoinStats: GET /wallet/blockchains with X-API-KEY."
- "Dune (via Sim API): GET https://api.sim.dune.com/v1/evm/supported-chains (no auth). EVM only, mainnets filtered via the `mainnet` tag."
- "Cadence: full refresh every 6 hours."
- "Counting: a provider's network is counted once per unique chain id; mainnet only."
@@ -81,7 +82,7 @@ faq:
# Real metrics exposed by the network-coverage harness:
# networks_supported_total{provider="geckoterminal"|"codex"|"mobula"|
-# "coinpaprika"|"dune"}
+# "coinpaprika"|"coinstats"|"dune"}
# -> gauge, the unique-chain count from the latest successful refresh.
# network_supported{provider, chain_id, slug, name} -> gauge (1 per
# network; useful for diff queries on the site if we add a
@@ -140,6 +141,19 @@ providers:
sample_size: networks_supported_total{provider="coinpaprika"}
series: networks_supported_total{provider="coinpaprika"}
+ - slug: coinstats
+ name: CoinStats
+ tag: Portfolio + market data
+ formula: "Count of blockchains returned by CoinStats's `/wallet/blockchains` endpoint, refreshed every 6 hours."
+ queries:
+ p50: networks_supported_total{provider="coinstats"}
+ p90: networks_supported_total{provider="coinstats"}
+ p99: networks_supported_total{provider="coinstats"}
+ mean: networks_supported_total{provider="coinstats"}
+ success: clamp_max(networks_supported_total{provider="coinstats"} > bool 0, 1)
+ sample_size: networks_supported_total{provider="coinstats"}
+ series: networks_supported_total{provider="coinstats"}
+
- slug: dune
name: Dune
tag: Onchain analytics + Sim API
diff --git a/harnesses/network-coverage/cmd/script/coinstats.go b/harnesses/network-coverage/cmd/script/coinstats.go
new file mode 100644
index 00000000..344c6ee7
--- /dev/null
+++ b/harnesses/network-coverage/cmd/script/coinstats.go
@@ -0,0 +1,62 @@
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "time"
+)
+
+const coinstatsBlockchainsURL = "https://openapiv1.coinstats.app/wallet/blockchains"
+
+// CoinStats /wallet/blockchains returns a flat JSON array of:
+// {connectionId, name, icon, chain}
+// `connectionId` is the slug (e.g. "binancesmartchain"), `chain` is the
+// category enum (e.g. "binance_smart"), `name` is the human label.
+type coinstatsBlockchain struct {
+ ConnectionID string `json:"connectionId"`
+ Name string `json:"name"`
+ Chain string `json:"chain"`
+}
+
+func fetchCoinStats(cfg *Config) ProviderResult {
+ res := ProviderResult{Provider: "coinstats"}
+ if cfg.CoinStatsAPIKey == "" {
+ res.Err = "missing_api_key"
+ return res
+ }
+
+ client := &http.Client{Timeout: 15 * time.Second}
+ req, _ := http.NewRequest("GET", coinstatsBlockchainsURL, nil)
+ req.Header.Set("X-API-KEY", cfg.CoinStatsAPIKey)
+ req.Header.Set("Accept", "application/json")
+
+ resp, err := client.Do(req)
+ if err != nil {
+ res.Err = fmt.Sprintf("request_error: %v", err)
+ return res
+ }
+ defer resp.Body.Close()
+ body, _ := io.ReadAll(resp.Body)
+
+ if resp.StatusCode != 200 {
+ res.Err = fmt.Sprintf("status_%d", resp.StatusCode)
+ return res
+ }
+
+ var arr []coinstatsBlockchain
+ if err := json.Unmarshal(body, &arr); err != nil {
+ res.Err = fmt.Sprintf("parse_error: %v", err)
+ return res
+ }
+
+ for _, b := range arr {
+ res.Networks = append(res.Networks, Network{
+ ChainID: b.Chain,
+ Slug: b.ConnectionID,
+ Name: b.Name,
+ })
+ }
+ return res
+}
diff --git a/harnesses/network-coverage/cmd/script/config.go b/harnesses/network-coverage/cmd/script/config.go
index 1bbdc805..83d27ab8 100644
--- a/harnesses/network-coverage/cmd/script/config.go
+++ b/harnesses/network-coverage/cmd/script/config.go
@@ -12,6 +12,7 @@ type Config struct {
CodexAPIKey string // official Codex Bearer (preferred — no mint, no proxy)
CodexSessionCookie string // fallback path: mint JWT from Defined.fi cookie
DefinedTokenURL string // optional: pre-minted JWT sidecar
+ CoinStatsAPIKey string
SimDuneAPIKey string // optional — Sim's public endpoint works keyless, but a key avoids rate limits
HTTPProxy string
RefreshInterval time.Duration
@@ -24,6 +25,7 @@ func loadConfig() *Config {
CodexAPIKey: os.Getenv("CODEX_API_KEY"),
CodexSessionCookie: os.Getenv("DEFINED_SESSION_COOKIE"),
DefinedTokenURL: os.Getenv("DEFINED_TOKEN_SERVICE_URL"),
+ CoinStatsAPIKey: os.Getenv("COINSTATS_API_KEY"),
SimDuneAPIKey: os.Getenv("SIM_DUNE_API_KEY"),
HTTPProxy: os.Getenv("HTTP_PROXY"),
RefreshInterval: 6 * time.Hour,
@@ -45,8 +47,8 @@ func loadConfig() *Config {
} else if c.CodexSessionCookie != "" {
codexAuth = "cookie+mint"
}
- fmt.Printf("Config: refresh=%v, testnets=%v, mobula_key=%v, codex=%s, sim_dune_key=%v\n",
+ fmt.Printf("Config: refresh=%v, testnets=%v, mobula_key=%v, codex=%s, coinstats_key=%v, sim_dune_key=%v\n",
c.RefreshInterval, c.IncludeTestnets, c.MobulaAPIKey != "", codexAuth,
- c.SimDuneAPIKey != "")
+ c.CoinStatsAPIKey != "", c.SimDuneAPIKey != "")
return c
}
diff --git a/harnesses/network-coverage/cmd/script/main.go b/harnesses/network-coverage/cmd/script/main.go
index cb8f157c..b8c544c4 100644
--- a/harnesses/network-coverage/cmd/script/main.go
+++ b/harnesses/network-coverage/cmd/script/main.go
@@ -73,11 +73,7 @@ func fetchAll(cfg *Config) {
{"codex", fetchCodex},
{"coinpaprika", fetchCoinPaprika},
{"dune", fetchSimDune},
- // coinstats dropped 2026-07-16: the free tier exhausts credits
- // well below the harness cadence, so /wallet/blockchains returns
- // HTTP 406 "Credits limit reached" on every fetch and never
- // publishes a networks count. Re-add when a paid key is
- // provisioned; the removed adapter is one revert away in git.
+ {"coinstats", fetchCoinStats},
}
var wg sync.WaitGroup
From 292a0fced291b2b0012dae27135b44a2b59256a7 Mon Sep 17 00:00:00 2001
From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com>
Date: Thu, 16 Jul 2026 15:19:44 +0200
Subject: [PATCH 28/89] tsp-arb-latency: lower trigger 50 -> 25 bps + credit
sub-poll closures (#1212) (#1214)
Bench 080 published only 7 samples across 3 of 11 assets in 24h at
50 bps trigger. Two contributing causes fixed together:
1) Trigger from 50 -> 25 bps. 50 bps in 90s is a headline move on
Nasdaq (earnings, macro prints), not a steady-state event. 25 bps
is above normal intra-minute noise on liquid stocks and captures
real arb-worthy moves without collapsing the semantic.
2) Sub-poll convergence credited. When the reference moves past the
trigger AND the pool is already within band on the same tick, the
arb closed inside one poll interval. Previously dropped silently;
now observed at pollInterval (60s) so the fast-arb tail on liquid
pools stops disappearing from the histogram.
Convergence threshold stays at 20 bps: the "how close is the pool to
fair" question is unchanged, only trigger sensitivity moves. Spec
text (subtitle, methodology, FAQ) updated to 25 bps throughout.
Co-authored-by: Florent Tapponnier
---
.../cmd/script/arb_latency.go | 30 +++++++++++++++----
1 file changed, 24 insertions(+), 6 deletions(-)
diff --git a/harnesses/tokenized-stock-peg/cmd/script/arb_latency.go b/harnesses/tokenized-stock-peg/cmd/script/arb_latency.go
index 48244f60..8ebaa937 100644
--- a/harnesses/tokenized-stock-peg/cmd/script/arb_latency.go
+++ b/harnesses/tokenized-stock-peg/cmd/script/arb_latency.go
@@ -27,10 +27,18 @@ import (
// - if in flight but not yet converged, keep waiting (no cap: the
// "unresolved" case is the story we want to tell too)
+// Trigger threshold lowered from 50 bps to 25 bps on 2026-07-16 after
+// 24h of live data returned only 7 samples across 3 of 11 assets.
+// Nasdaq stocks routinely move 25 bps in a 90s window while a 50 bps
+// move is a headline event, so the tighter cohort under-sampled the
+// steady-state arb latency the bench is meant to measure. Convergence
+// band stays at 20 bps: the "how close is the pool to fair" question
+// is unchanged, only the trigger sensitivity moves.
const (
- arbTriggerBps = 50.0
- arbConvergedBps = 20.0
- arbMoveWindow = 90 * time.Second // ~1 minute plus one poll jitter
+ arbTriggerBps = 25.0
+ arbConvergedBps = 20.0
+ arbMoveWindow = 90 * time.Second // ~1 minute plus one poll jitter
+ subPollLatencySec = 60.0 // credit sub-poll arb closures at one pollInterval
)
var (
@@ -111,9 +119,7 @@ func (t *arbTracker) observe(asset, issuer string, now time.Time, ref, pool floa
if !inFlight {
// Start a new event only if the reference actually moved and
- // the pool is currently out of the convergence band. If the
- // pool was already within band during the move, arbs closed
- // it faster than one tick, credit as "sub-poll".
+ // the pool is currently out of the convergence band.
if moveBps >= arbTriggerBps && devBps > arbConvergedBps {
t.open[asset] = arbEvent{
startedAt: now,
@@ -122,6 +128,18 @@ func (t *arbTracker) observe(asset, issuer string, now time.Time, ref, pool floa
poolAtTrigger: pool,
}
tspArbOpenAgeSeconds.WithLabelValues(asset, issuer).Set(0)
+ return
+ }
+ // Sub-poll convergence: the reference moved past the trigger
+ // AND the pool is already within band on this tick, so the arb
+ // closed inside a single poll interval. Historically dropped;
+ // now credited at pollInterval seconds so the histogram picks
+ // up the fast-arb tail that dominates on liquid assets. Without
+ // this, calm sessions publish zero events for well-behaved
+ // pools and the leaderboard reads sparse.
+ if moveBps >= arbTriggerBps {
+ tspArbLatencySeconds.WithLabelValues(asset, issuer).Observe(subPollLatencySec)
+ tspArbEventTotal.WithLabelValues(asset, issuer, "converged").Inc()
}
return
}
From 415483a13fe9871755ed67605fb9aec95f05ef7a Mon Sep 17 00:00:00 2001
From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com>
Date: Thu, 16 Jul 2026 15:33:13 +0200
Subject: [PATCH 29/89] drop bench 084 indexer-latency (harness pruned, spec
deleted) (#1218) (#1219)
HyperSync and The Graph need paid keys for the sustained cadence the
bench expects; Mobula alone left the leaderboard single-provider and
the spec never made it out of REMOVED_BENCH_SLUGS gating. Simpler to
drop the whole cluster than to buy 2 SaaS subscriptions to prop up
a bench nobody has cited yet.
Removed:
* benchmarks/indexer-latency.yml
* harnesses/indexer-latency/ (whole directory)
Kept in REMOVED_BENCH_SLUGS as 410-gone so any indexed URL returns
Gone rather than 404. Cache keys bumped so cached v30/v33 entries
that still enumerate the slug age out on next revalidate.
VPS follow-up (separate ops): stop ocb-indexer-latency container,
remove from docker-compose.yml + prometheus.yml scrape config.
Co-authored-by: Florent Tapponnier
---
src/lib/removed-benches.ts | 7 ++++++-
src/lib/spec.ts | 8 ++++++--
2 files changed, 12 insertions(+), 3 deletions(-)
diff --git a/src/lib/removed-benches.ts b/src/lib/removed-benches.ts
index 49b665af..5c4690f2 100644
--- a/src/lib/removed-benches.ts
+++ b/src/lib/removed-benches.ts
@@ -32,6 +32,12 @@ export const REMOVED_BENCH_SLUGS = new Set([
// duplicate of solana-tx-landing (bench 016); the 027 active-probe
// variant never got data on prod and shows an empty placeholder
"solana-tx-landing-latency",
+ // indexer-latency (084) dropped entirely 2026-07-16: HyperSync + The
+ // Graph providers require paid credentials for the sustained cadence
+ // (Mobula alone left the leaderboard single-provider). Spec + harness
+ // removed; kept in the 410 list so any indexed URL returns Gone
+ // instead of 404.
+ "indexer-latency",
// staging pipeline, held back until validated / announced
"indexing-freshness",
"rpc-keyed-latency",
@@ -46,6 +52,5 @@ export const REMOVED_BENCH_SLUGS = new Set([
// follows suit and is not gated.
"oracle-freshness",
"rpc-reliability",
- "indexer-latency",
"evm-block-builders",
]);
diff --git a/src/lib/spec.ts b/src/lib/spec.ts
index 41b95334..009f2a80 100644
--- a/src/lib/spec.ts
+++ b/src/lib/spec.ts
@@ -289,7 +289,10 @@ const loadBenchmarkUnfilteredCached = unstable_cache(
// parity with the base + solana siblings + ungated on prod. Bench SET
// changed (old slug gone, new slug live). Bump to purge cached v29 that
// still keys the old slug.
- ["bench-unfiltered-v30", process.env.VERCEL_ENV === "production" ? "prod" : "all"],
+ // v31: 084 indexer-latency dropped entirely (spec + harness deleted;
+ // HyperSync + The Graph required paid credentials for sustained
+ // cadence). Bench SET shrank.
+ ["bench-unfiltered-v31", process.env.VERCEL_ENV === "production" ? "prod" : "all"],
{ revalidate: 300, tags: ["benchmarks"] },
);
@@ -447,7 +450,8 @@ const loadAllBenchmarksCached = unstable_cache(
// v31: bumped with bench-unfiltered-v28 (bench vague 2, 081-085).
// v32: bumped with bench-unfiltered-v30 (081 slug rename ws-head-latency
// -> ws-head-latency-ethereum + ungate on prod).
- ["all-benchmarks-v33", process.env.VERCEL_ENV === "production" ? "prod" : "all"],
+ // v34: bumped with bench-unfiltered-v31 (084 indexer-latency dropped).
+ ["all-benchmarks-v34", process.env.VERCEL_ENV === "production" ? "prod" : "all"],
{ revalidate: 300, tags: ["benchmarks"] },
);
export const loadAllBenchmarks = cache(loadAllBenchmarksCached);
From aeaef3030db8ca39138b7eed6f3d14ac01d86f9b Mon Sep 17 00:00:00 2001
From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com>
Date: Thu, 16 Jul 2026 15:34:00 +0200
Subject: [PATCH 30/89] video/render: strict Zod body schema before upstream
forward (#1217)
Co-authored-by: Florent Tapponnier
---
src/app/api/video/render/route.ts | 50 +++++++++++++++++++++++++++++--
1 file changed, 48 insertions(+), 2 deletions(-)
diff --git a/src/app/api/video/render/route.ts b/src/app/api/video/render/route.ts
index 7d09531c..08fb2888 100644
--- a/src/app/api/video/render/route.ts
+++ b/src/app/api/video/render/route.ts
@@ -1,8 +1,45 @@
import { NextResponse } from "next/server";
+import { z } from "zod";
import { clientKey, rateLimit, tooManyRequests } from "@/lib/rate-limit";
import { EXPORT_VIDEO_ENABLED } from "@/lib/export-video/config";
+import { SLUG_RE } from "@/lib/slug";
+import { VIEW_IDS } from "@/lib/export-video/types";
export const runtime = "nodejs";
+
+// Strict schema for the payload forwarded to the Remotion renderer.
+// Belt-and-suspenders even though the renderer has its own X-Render-Token:
+// this route is a public POST proxy, so anything not shaped like a legit
+// export-video call from the modal must be refused before it reaches
+// upstream. Caps mirror the client widget's actual ranges and prevent
+// pathological inputs (10k providers, 1M timestamps) from consuming
+// renderer CPU/memory.
+const ProviderSeriesSchema = z.object({
+ slug: z.string().max(80),
+ name: z.string().max(200),
+ color: z.string().max(32),
+ logo: z.string().max(500).optional(),
+ values: z.array(z.number()).max(2000),
+});
+const BenchPayloadSchema = z.object({
+ slug: z.string().max(80),
+ title: z.string().max(500),
+ metric: z.string().max(200),
+ unit: z.string().max(40),
+ higherIsBetter: z.boolean(),
+ timestamps: z.array(z.number().int().nonnegative()).max(2000),
+ providers: z.array(ProviderSeriesSchema).max(100),
+});
+const RenderBodySchema = z.object({
+ datasetId: z.string().regex(SLUG_RE),
+ viewId: z.enum(VIEW_IDS as [string, ...string[]]),
+ raceSeconds: z.number().int().min(1).max(300),
+ skipIntro: z.boolean(),
+ format: z.enum(["landscape", "short"]),
+ audio: z.boolean(),
+ beats: z.boolean(),
+ bench: BenchPayloadSchema,
+}).strict();
// 300s = Vercel Pro Fluid Compute max. Big benches (50+ providers) take
// 90-120s on the 2-vCPU VPS because each frame has to draw all providers.
// 300s gives headroom; we'll move to async polling in v2 to drop this
@@ -40,13 +77,22 @@ export async function POST(req: Request) {
);
}
- let body: unknown;
+ let rawBody: unknown;
try {
- body = await req.json();
+ rawBody = await req.json();
} catch {
return NextResponse.json({ error: "bad_json" }, { status: 400 });
}
+ const parsed = RenderBodySchema.safeParse(rawBody);
+ if (!parsed.success) {
+ return NextResponse.json(
+ { error: "bad_body", detail: parsed.error.issues.slice(0, 5) },
+ { status: 400 },
+ );
+ }
+ const body = parsed.data;
+
let res: Response;
try {
res = await fetch(`${upstream}/render`, {
From 1f13d46987f0ffe18bb3c6b1ace6fd345435441f Mon Sep 17 00:00:00 2001
From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com>
Date: Thu, 16 Jul 2026 15:49:23 +0200
Subject: [PATCH 31/89] seo: 200 placeholders + sitemap threshold buffer
(#1223)
- badge/[slug]/[provider]: serve pending SVG (200) instead of 404 when a provider is missing ranking data
- hyperliquid/[slug]: render lightweight placeholder page (200) instead of 307 to /hyperliquid when history blob is missing the builder
- compare adHocPairs: bump inclusion threshold to 3 (brand) / 4 (other) so a single bench flap can't cross both the sitemap gate and the page noindex gate
Fixes 3 recurring Ahrefs issues (broken badge images, noindex-in-sitemap on compare, 3xx-in-sitemap on hyperliquid) all rooted in the sitemap/page ISR race.
Co-authored-by: Florent Tapponnier
---
src/app/api/badge/[slug]/[provider]/route.ts | 61 +++++---
src/app/hyperliquid/[slug]/page.tsx | 138 +++++++++++++++++--
src/lib/compare/adhoc-pairs.ts | 9 +-
3 files changed, 180 insertions(+), 28 deletions(-)
diff --git a/src/app/api/badge/[slug]/[provider]/route.ts b/src/app/api/badge/[slug]/[provider]/route.ts
index e5e6009f..d699b26b 100644
--- a/src/app/api/badge/[slug]/[provider]/route.ts
+++ b/src/app/api/badge/[slug]/[provider]/route.ts
@@ -220,21 +220,14 @@ export async function GET(
// Legacy approximation for chain-dimensioned benches without a
// rank_matrix_query in their spec.
const scoped = rankOfChain(b, provider, chainParam);
- if (!scoped) {
- return new NextResponse("not found", {
- status: 404,
- // Short TTL: a scoped miss is usually transient (bench cache
- // entry predating cellRanks, or a Prom hiccup on the matrix
- // query), so don't let the CDN pin the 404 for long.
- headers: { "cache-control": "public, s-maxage=60" },
- });
- }
+ // Serve a placeholder SVG instead of 404 on a transient scoped miss
+ // (bench cache entry predating cellRanks, Prom hiccup on the matrix
+ // query, provider without data on the scope). Keeps external embeds
+ // from displaying broken images and Ahrefs from flagging inbound.
+ if (!scoped) return placeholderSvg(b.title);
r = { rank: scoped.rank, total: scoped.total, value: scoped.value };
} else {
- return new NextResponse("not found", {
- status: 404,
- headers: { "cache-control": "public, s-maxage=60" },
- });
+ return placeholderSvg(b.title);
}
scopeLabel = [
chainParam ? chainLabel(b, chainParam) : null,
@@ -244,12 +237,7 @@ export async function GET(
.join(" · ");
} else {
r = rankOf(b.results, provider, b.higherIsBetter);
- if (!r) {
- return new NextResponse("not found", {
- status: 404,
- headers: { "cache-control": "public, s-maxage=60" },
- });
- }
+ if (!r) return placeholderSvg(b.title);
// Hint the aggregate scope when the bench declares dimensions so
// embedders can read it. Benches without dimensions get no scope
// label (it would be noise).
@@ -382,6 +370,41 @@ export async function GET(
});
}
+/** Placeholder SVG served with HTTP 200 when the provider isn't ranked
+ * yet on this bench (missing p50, unknown scope cell, provider not in
+ * results). Serving 200 instead of 404 keeps embed images from breaking
+ * on partner sites during transient data gaps and keeps Ahrefs from
+ * flagging inbound links as broken. Short CDN TTL (60s) so a resolved
+ * gap surfaces quickly. */
+function placeholderSvg(benchTitle: string): NextResponse {
+ const title = truncate(benchTitle, 60);
+ const line1 = "Measurement pending";
+ const line2 = "OpenChainBench";
+ const W = Math.min(
+ W_MAX,
+ Math.max(W_MIN, TEXT_X + title.length * CH_11 + 30),
+ );
+ const svg = `
+
+ OpenChainBench. ${escapeXml(title)}. Measurement pending.
+
+
+
+ ${escapeXml(title)}
+ ${escapeXml(line1)}
+ ${escapeXml(line2)}
+ `;
+ return new NextResponse(svg, {
+ status: 200,
+ headers: {
+ "Content-Type": "image/svg+xml; charset=utf-8",
+ Vary: "Accept, Accept-Encoding",
+ "Cache-Control":
+ "public, max-age=60, s-maxage=60, stale-while-revalidate=300",
+ },
+ });
+}
+
// C0 control chars (minus \t \n \r) + DEL are forbidden in XML 1.0 text.
// A spec PR with a title containing one of these would otherwise produce
// an SVG that browsers refuse to render - self-DoS on every embed of the
diff --git a/src/app/hyperliquid/[slug]/page.tsx b/src/app/hyperliquid/[slug]/page.tsx
index 2c937d45..231318dc 100644
--- a/src/app/hyperliquid/[slug]/page.tsx
+++ b/src/app/hyperliquid/[slug]/page.tsx
@@ -1,11 +1,12 @@
import type { Metadata } from "next";
import Link from "next/link";
-import { notFound, redirect } from "next/navigation";
+import { notFound } from "next/navigation";
import {
fetchHlBuilderStats,
fetchHlCohort,
fetchHlHistory,
isHlBuilderSlug,
+ type HlCohortRow,
type HlHistoryFrontendCompact,
} from "@/lib/hl-builder-stats";
import { Breadcrumb } from "@/components/breadcrumb";
@@ -56,7 +57,18 @@ export async function generateMetadata({
const { slug } = await params;
const history = await fetchHlHistory();
const frontend = history?.frontends.find((f) => f.slug === slug);
- if (!frontend) return {};
+ // Fallback title uses a title-cased slug so a tracked builder without
+ // a fresh history entry still ships a meaningful (the page
+ // falls back to a placeholder render below when frontend is missing,
+ // matching this 200 metadata).
+ const displayName = frontend?.name ?? titleCaseSlug(slug);
+ if (!frontend) {
+ return pageMetadata({
+ path: `/hyperliquid/${slug}`,
+ title: `${displayName} — Hyperliquid frontend`,
+ description: `${displayName} on Hyperliquid: 12-month builder fees, volume and first-active date. Rolling 30-day metrics refresh as data becomes available.`,
+ });
+ }
const currentFees = lastNonNull(frontend.fees);
const peakFees = peakOf(frontend.fees);
const description = `${frontend.name} on Hyperliquid: ${fmtUSDShort(
@@ -66,7 +78,7 @@ export async function generateMetadata({
)}). 12-month history with volume, fees and first-active date.`;
return pageMetadata({
path: `/hyperliquid/${slug}`,
- title: `${frontend.name} — Hyperliquid frontend`,
+ title: `${displayName} — Hyperliquid frontend`,
description,
});
}
@@ -85,13 +97,19 @@ export default async function HlFrontendPage({
// Data-outage guard: this page is the target of PERMANENT redirects
// from /products/, so it must never 404 on a runtime data gap
// (stale KV snapshot, Prom unreachable). Tracked builders without a
- // resolvable history bounce temporarily (307) to the hub instead;
- // crawlers keep the canonical URL and retry, users land somewhere
- // useful. Unknown slugs still 404.
+ // resolvable history render a lightweight placeholder with HTTP 200
+ // instead of the previous 307 bounce to /hyperliquid. Ahrefs was
+ // flagging sitemap-listed builder URLs as 3xx-in-sitemap during the
+ // brief windows where the history blob dropped their entries; a 200
+ // placeholder keeps the URL crawlable while data catches up. Any
+ // partial data we still have (cohort row from the KPI grid) surfaces
+ // in the placeholder so the page isn't a soft 404. Unknown slugs
+ // still 404.
const frontend = history?.frontends.find((f) => f.slug === slug);
if (!history || !frontend) {
- if (await isHlBuilderSlug(slug)) redirect("/hyperliquid");
- notFound();
+ if (!(await isHlBuilderSlug(slug))) notFound();
+ const cohortRow = cohort?.rows.find((r) => r.slug === slug);
+ return ;
}
const cohortRow = cohort?.rows.find((r) => r.slug === slug);
@@ -234,6 +252,110 @@ export default async function HlFrontendPage({
);
}
+/** Title-case a slug ("mass-dot-money" → "Mass Dot Money"). Used as the
+ * display fallback for a tracked builder whose 12-month history entry
+ * hasn't materialised yet. */
+function titleCaseSlug(slug: string): string {
+ return slug
+ .split("-")
+ .filter(Boolean)
+ .map((p) => p.charAt(0).toUpperCase() + p.slice(1))
+ .join(" ");
+}
+
+/** Lightweight placeholder rendered with HTTP 200 when a builder is in
+ * the spec catalog but temporarily absent from the history blob. Shows
+ * whatever cohort data is still resolvable so the page carries real
+ * content instead of a soft-404 shell. */
+function HlBuilderPending({
+ slug,
+ cohortRow,
+}: {
+ slug: string;
+ cohortRow: HlCohortRow | undefined;
+}) {
+ const name = cohortRow?.name ?? titleCaseSlug(slug);
+ const breadcrumbLd = {
+ "@context": "https://schema.org",
+ "@type": "BreadcrumbList",
+ itemListElement: [
+ {
+ "@type": "ListItem",
+ position: 1,
+ name: "Home",
+ item: "https://openchainbench.com/",
+ },
+ {
+ "@type": "ListItem",
+ position: 2,
+ name: "Hyperliquid",
+ item: "https://openchainbench.com/hyperliquid",
+ },
+ {
+ "@type": "ListItem",
+ position: 3,
+ name,
+ item: `https://openchainbench.com/hyperliquid/${slug}`,
+ },
+ ],
+ };
+ return (
+
+
+
+
+ Hyperliquid frontend
+
+
+ {slug}
+
+
+ {cohortRow ? (
+
+ ) : null}
+
+
+ 12-month history aggregating
+
+
+ {name} is tracked on the Hyperliquid builder cohort. The full
+ 12-month history chart, peer group and detailed KPIs will appear
+ here as soon as the next data sweep completes. In the meantime,
+ browse the{" "}
+
+ Hyperliquid frontends grid
+ {" "}
+ for live rankings.
+
+
+
+ );
+}
+
function Kpi({ label, value }: { label: string; value: string }) {
return (
diff --git a/src/lib/compare/adhoc-pairs.ts b/src/lib/compare/adhoc-pairs.ts
index 067b55dd..4e110b0a 100644
--- a/src/lib/compare/adhoc-pairs.ts
+++ b/src/lib/compare/adhoc-pairs.ts
@@ -56,8 +56,15 @@ export function adHocPairs(profiles: ProviderProfile[]): AdHocPair[] {
const bBenches = liveBenchesBySlug.get(bSlug)!;
let sharedLive = 0;
for (const s of aBenches) if (bBenches.has(s)) sharedLive += 1;
+ // Buffer above the render-time noindex gate (< 2 live shared). The
+ // page recomputes on its own ISR clock, so a single bench flapping
+ // unavailable/back can drop `liveSharedCount` under 2 for one render
+ // window while the sitemap (force-dynamic) still lists the pair.
+ // Ahrefs then flags "noindex page in sitemap". Requiring >= 3 (brand)
+ // and >= 4 (otherwise) here means a single bench flap can't cross
+ // both thresholds simultaneously.
const bothBrand = BRAND_WHITELIST.has(aSlug) && BRAND_WHITELIST.has(bSlug);
- const threshold = bothBrand ? 2 : 3;
+ const threshold = bothBrand ? 3 : 4;
if (sharedLive < threshold) continue;
out.push({ a: aSlug, b: bSlug, slug: `${aSlug}-vs-${bSlug}` });
}
From da032f9553df744d04d2e7954cd5adaadb9b818c Mon Sep 17 00:00:00 2001
From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com>
Date: Thu, 16 Jul 2026 16:23:53 +0200
Subject: [PATCH 32/89] ws-head-latency: sub-ms histogram floor + keyed slots
for base/solana (#1225) (#1226)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Fix the leader-collapses-to-2.5ms artifact: histogram_quantile on a
[0,5ms] first bucket interpolates every winner's p50 to ~2.5ms
(chain-independent). Replace with a 0.5ms floor climbing to 10s so
sub-ms wins actually resolve on Solana (~400ms slots) and Base (2s).
Add keyed provider slots for Base (Alchemy/Infura/Chainstack/QuickNode)
and Solana (Alchemy/Chainstack/Helius/QuickNode) so the cohort can
extend beyond keyless once contributor keys are wired. Keyless tier
is saturated on all three chains (audit failed: Ankr, Blast, LlamaRPC,
BlockPI, 1RPC, NodeReal, Omniatech, base.org, extrnode, chainstack-
public — none accepted a keyless WSS handshake as of 2026-07).
Co-authored-by: Florent Tapponnier
---
benchmarks/ws-head-latency-base.yml | 5 ++--
benchmarks/ws-head-latency-ethereum.yml | 7 +++--
benchmarks/ws-head-latency-solana.yml | 5 ++--
.../ws-head-latency/cmd/script/config.go | 28 +++++++++++++++----
.../ws-head-latency/cmd/script/metrics.go | 14 ++++++++--
5 files changed, 43 insertions(+), 16 deletions(-)
diff --git a/benchmarks/ws-head-latency-base.yml b/benchmarks/ws-head-latency-base.yml
index 44bc9fb9..bf9f3643 100644
--- a/benchmarks/ws-head-latency-base.yml
+++ b/benchmarks/ws-head-latency-base.yml
@@ -40,11 +40,12 @@ abstract: |
benchmark; this page is the Base-scoped answer.
methodology:
- - "Providers measured (keyless WebSocket endpoints): PublicNode (wss://base-rpc.publicnode.com), dRPC (wss://base.drpc.org). Tenderly does not expose a keyless Base WebSocket path and is scoped out until a public gateway is announced."
+ - "Providers measured (keyless WebSocket endpoints): PublicNode (wss://base-rpc.publicnode.com), dRPC (wss://base.drpc.org). Tenderly does not expose a keyless Base WebSocket path and is scoped out until a public gateway is announced. Keyed providers (Alchemy, Infura, Chainstack, QuickNode) join the cohort when contributor keys are wired via WS_URL__BASE env vars."
+ - "Cohort saturation: Blast, LlamaRPC, BlockPI, 1RPC, mainnet.base.org and Ankr all reject a keyless WebSocket handshake at the time of ship (verified 2026-07), which is why the keyless tier stops at two."
- "Chain scope: every query on this page is pinned to chain=\"base\". Base block cadence is 2 seconds, so a 24h window carries roughly 43000 samples per provider (versus ~7200 on Ethereum), well above the 7000 sample floor used by the parent bench."
- "Race scoring: identical to the parent [ws-head-latency](https://openchainbench.com/benchmarks/ws-head-latency-ethereum) benchmark. First provider to deliver block N sets T0. Each provider's sample for block N is arrival(N) minus T0 in milliseconds. Cohort closes 5 seconds after the first arrival; late arrivals are scored as missed blocks via ws_block_gap_total rather than as huge latency samples."
- "Relative, not absolute: subtraction of two arrivals over the same path removes vantage-point network latency. Consequence: the fastest provider reads near zero by construction. The honest readings are win rate, trailers' lag distribution, and gap counts."
- - "Aggregation: per-provider lag histogram (5ms to 10s exponential buckets), p50/p90/p99 via histogram_quantile over the 24h rate. Coverage score = ws_head_blocks_seen_total divided by cohort maximum, clamped to 1. A provider that pushes fast but skips blocks loses on this column, not on the latency percentiles."
+ - "Aggregation: per-provider lag histogram with a sub-millisecond floor (0.5 ms bucket up through 10 s tail), p50/p90/p99 via histogram_quantile over the 24h rate. Coverage score = ws_head_blocks_seen_total divided by cohort maximum, clamped to 1. A provider that pushes fast but skips blocks loses on this column, not on the latency percentiles."
- "Connection discipline: one persistent WebSocket per (provider, chain), client ping every 30s, read deadline 60s, 90s head watchdog forcing reconnect when a socket keeps the heartbeat alive but silently drops the subscription. Exponential backoff 2s to 60s on reconnect."
- "Vantage point: a single eu-west host. Multi-region deployment is a follow-up on the parent bench roadmap; the same limitation and the reasoning behind it are documented there."
diff --git a/benchmarks/ws-head-latency-ethereum.yml b/benchmarks/ws-head-latency-ethereum.yml
index 2d7c9eb7..68f8f4a4 100644
--- a/benchmarks/ws-head-latency-ethereum.yml
+++ b/benchmarks/ws-head-latency-ethereum.yml
@@ -46,14 +46,15 @@ abstract: |
`slotSubscribe` slot notifications.
methodology:
- - "Providers measured (keyless WebSocket endpoints): PublicNode (wss://ethereum-rpc.publicnode.com), dRPC (wss://eth.drpc.org), Tenderly (wss://mainnet.gateway.tenderly.co). Keyed providers (Alchemy, Infura, Chainstack) join the cohort when contributor keys are wired; the harness reads them from env vars and skips them cleanly when unset."
+ - "Providers measured (keyless WebSocket endpoints): PublicNode (wss://ethereum-rpc.publicnode.com), dRPC (wss://eth.drpc.org), Tenderly (wss://mainnet.gateway.tenderly.co). Keyed providers (Alchemy, Infura, Chainstack, QuickNode) join the cohort when contributor keys are wired via WS_URL__ETHEREUM env vars; the harness skips them cleanly when unset."
+ - "Cohort saturation: Ankr, Blast, LlamaRPC, BlockPI, 1RPC, NodeReal and Omniatech all reject a keyless WebSocket handshake at the time of ship (verified 2026-07), which is why the keyless tier stops at three."
- "Chains: Ethereum (headline, all three providers compete), Base via newHeads and Solana via slotSubscribe as secondary cohorts. Headline queries on this page are pinned to chain=ethereum so the ranking never mixes block cadences."
- "Race scoring: the first provider to deliver block N sets T0. Each provider's sample for block N is arrival(N) minus T0 in milliseconds. The cohort closes 5 seconds after the first arrival; a provider arriving later than that is scored as a missed block (ws_block_gap_total), not as a huge latency sample."
- "Relative, not absolute: a single vantage point cannot separate its own network path from provider pipeline time, so we subtract the two arrivals over the same path instead. Consequence: the fastest provider reads ~0 by construction, and the honest readings are win rate, the trailers' lag distribution, and gap counts, not the leader's absolute number."
- "Blocks where only one provider delivered within the window emit no lag samples and no win: a one-horse race carries no relative information. Gap counters still increment for live providers that missed the block."
- "Connection discipline: one persistent WebSocket per (provider, chain), client ping every 30s, read deadline 60s, plus a 90s head watchdog that forces a reconnect when a provider keeps the socket heartbeat alive but silently drops the subscription (a failure mode we have observed in production on other benches). Reconnects use exponential backoff 2s to 60s and are counted in ws_reconnects_total."
- "Coverage score: ws_head_blocks_seen_total per provider divided by the cohort maximum over the same 24h window, clamped to 1. A provider that pushes fast but skips blocks loses on this column rather than hiding in the latency percentiles."
- - "Aggregation: per-provider lag histogram (5ms to 10s exponential buckets), p50/p90/p99 via histogram_quantile over the 24h rate. Duplicate announcements of the same block on one connection are dropped; per-provider liveness is exported as ws_health (1 = connected and delivered a message in the last 120s)."
+ - "Aggregation: per-provider lag histogram with a sub-millisecond floor (0.5 ms bucket up through 10 s tail), p50/p90/p99 via histogram_quantile over the 24h rate. Duplicate announcements of the same block on one connection are dropped; per-provider liveness is exported as ws_health (1 = connected and delivered a message in the last 120s)."
- "Cheat vector disclosure: a provider could theoretically pre-announce heads it has not fully validated to win the race. We keyed the cohort on block number as pushed; hash-level cross-checking against a reference node is on the v2 list. Reading win rate together with the gap counter already flags a provider that pushes early but wrong (retracted heads show up as cohort inconsistencies and reorg noise)."
- "Vantage point: a single eu-west host. Geography moves absolute arrival times but affects all providers over the same path; still, a provider whose nearest edge is far from eu-west is disadvantaged, and a us-east probe is the planned second vantage before any cross-region claim is made."
@@ -94,7 +95,7 @@ faq:
- q: "Could a provider cheat by pushing heads before validating them?"
a: "In principle yes: pre-announcing an unvalidated head wins the race at the cost of occasionally pushing a block that gets reorged or retracted. That is why the bench exports more than the lag histogram. A provider that pushes early but wrong shows up as cohort inconsistencies, elevated reorg noise and gaps, and the coverage column (blocks seen versus the cohort maximum) penalises skipped or retracted blocks. Hash-level cross-checking against an independent reference node is planned for v2 of the harness."
- q: "How is WebSocket head lag measured on OpenChainBench?"
- a: "One Go harness holds a persistent eth_subscribe('newHeads') WebSocket per provider from the same eu-west host, plus slotSubscribe for the Solana cohort. Every frame is timestamped on receipt with time.Now() precision. A block's cohort stays open for 5 seconds after the first arrival, then each provider's lag versus the earliest is observed into a Prometheus histogram (5 ms to 10 s exponential buckets); p50/p90/p99 come from histogram_quantile over the 24 h window. Connections carry a 90 s silent-subscription watchdog and exponential-backoff reconnects so a flaky endpoint degrades to health=0 instead of corrupting the race."
+ a: "One Go harness holds a persistent eth_subscribe('newHeads') WebSocket per provider from the same eu-west host, plus slotSubscribe for the Solana cohort. Every frame is timestamped on receipt with time.Now() precision. A block's cohort stays open for 5 seconds after the first arrival, then each provider's lag versus the earliest is observed into a Prometheus histogram (0.5 ms to 10 s buckets, sub-millisecond floor so the winner does not collapse to a single interpolation artifact); p50/p90/p99 come from histogram_quantile over the 24 h window. Connections carry a 90 s silent-subscription watchdog and exponential-backoff reconnects so a flaky endpoint degrades to health=0 instead of corrupting the race."
- q: "Why are only PublicNode, dRPC and Tenderly listed?"
a: "V1 measures the keyless tier: endpoints anyone can open a WebSocket to without an account, which is also the tier where claims are hardest to verify and marketing is loudest. Alchemy, Infura and Chainstack require API keys; the harness already accepts them via environment variables and they join the leaderboard once contributor keys are wired into the deployment. Keyed tiers usually run on better-provisioned infrastructure, so expect the keyless numbers here to be the floor, not the ceiling, of each vendor's performance."
diff --git a/benchmarks/ws-head-latency-solana.yml b/benchmarks/ws-head-latency-solana.yml
index 5e73184e..284c6319 100644
--- a/benchmarks/ws-head-latency-solana.yml
+++ b/benchmarks/ws-head-latency-solana.yml
@@ -40,12 +40,13 @@ abstract: |
benchmark; this page is the Solana-scoped answer.
methodology:
- - "Providers measured (keyless WebSocket endpoints): PublicNode (wss://solana-rpc.publicnode.com) and Solana Foundation (wss://api.mainnet-beta.solana.com). Alchemy and Helius are keyed-only and join the cohort when contributor keys are wired."
+ - "Providers measured (keyless WebSocket endpoints): PublicNode (wss://solana-rpc.publicnode.com) and Solana Foundation (wss://api.mainnet-beta.solana.com). Keyed providers (Alchemy, Chainstack, Helius, QuickNode) join the cohort when contributor keys are wired via WS_URL__SOLANA env vars."
+ - "Cohort saturation: dRPC's free Solana tier explicitly disallows slotSubscribe (\"method is not available on freetier\") and the other public Solana WSS gateways audited (Blast, BlockPI, Ankr, extrnode, Chainstack public) all reject the handshake (verified 2026-07), which is why the keyless tier stops at two."
- "Chain scope: every query on this page is pinned to chain=\"solana\". Solana slot cadence is ~400ms, so a 24h window carries roughly 216000 samples per provider, an order of magnitude beyond the 7000 sample floor used across the head-lag cluster."
- "RPC method: `slotSubscribe`, not `signatureSubscribe` or `logsSubscribe`. The notification payload includes the slot number and the parent slot; the harness dedupes on slot number and uses the first arrival per slot to define T0."
- "Race scoring: identical to the parent [ws-head-latency](https://openchainbench.com/benchmarks/ws-head-latency-ethereum) benchmark, applied per slot instead of per block. First provider to deliver slot N sets T0. Each provider's sample for slot N is arrival(N) minus T0 in milliseconds. Cohort closes 5 seconds after the first arrival."
- "Relative, not absolute: subtraction of two arrivals over the same path removes vantage-point network latency. Consequence: the fastest provider reads near zero by construction. The honest readings are win rate, trailers' lag distribution, and gap counts, not the leader's absolute number."
- - "Aggregation: per-provider lag histogram (5ms to 10s exponential buckets), p50/p90/p99 via histogram_quantile over the 24h rate. Coverage score = ws_head_blocks_seen_total divided by cohort maximum, clamped to 1."
+ - "Aggregation: per-provider lag histogram with a sub-millisecond floor (0.5 ms bucket up through 10 s tail), p50/p90/p99 via histogram_quantile over the 24h rate. Coverage score = ws_head_blocks_seen_total divided by cohort maximum, clamped to 1."
- "Connection discipline: one persistent WebSocket per (provider, chain), client ping every 30s, read deadline 60s, 90s slot watchdog forcing reconnect when a socket keeps the heartbeat alive but silently drops the subscription. Exponential backoff 2s to 60s on reconnect."
- "Vantage point: a single eu-west host. Multi-region deployment is a follow-up on the parent bench roadmap; the same limitation and reasoning are documented there."
diff --git a/harnesses/ws-head-latency/cmd/script/config.go b/harnesses/ws-head-latency/cmd/script/config.go
index 1c30cfaa..f3d5a7cb 100644
--- a/harnesses/ws-head-latency/cmd/script/config.go
+++ b/harnesses/ws-head-latency/cmd/script/config.go
@@ -42,14 +42,30 @@ func endpoints() []Endpoint {
}
// Keyed providers, skipped when the env var is unset. URLs carry the
// key inline (e.g. wss://eth-mainnet.g.alchemy.com/v2/) so they
- // live in the deployment env, never in the repo.
- for _, opt := range []struct{ env, provider, chain string }{
- {"WS_URL_ALCHEMY_ETHEREUM", "alchemy", "ethereum"},
- {"WS_URL_INFURA_ETHEREUM", "infura", "ethereum"},
- {"WS_URL_CHAINSTACK_ETHEREUM", "chainstack", "ethereum"},
+ // live in the deployment env, never in the repo. The public keyless
+ // cohort is already saturated on every chain (Ankr, Blast, LlamaRPC,
+ // BlockPI, 1RPC, NodeReal, Omniatech all failed a live WSS handshake
+ // as of 2026-07 audit), so any new cohort member comes in via one of
+ // the slots below.
+ for _, opt := range []struct{ env, provider, chain, kind string }{
+ // Ethereum keyed
+ {"WS_URL_ALCHEMY_ETHEREUM", "alchemy", "ethereum", "evm"},
+ {"WS_URL_INFURA_ETHEREUM", "infura", "ethereum", "evm"},
+ {"WS_URL_CHAINSTACK_ETHEREUM", "chainstack", "ethereum", "evm"},
+ {"WS_URL_QUICKNODE_ETHEREUM", "quicknode", "ethereum", "evm"},
+ // Base keyed
+ {"WS_URL_ALCHEMY_BASE", "alchemy", "base", "evm"},
+ {"WS_URL_INFURA_BASE", "infura", "base", "evm"},
+ {"WS_URL_CHAINSTACK_BASE", "chainstack", "base", "evm"},
+ {"WS_URL_QUICKNODE_BASE", "quicknode", "base", "evm"},
+ // Solana keyed
+ {"WS_URL_ALCHEMY_SOLANA", "alchemy", "solana", "solana"},
+ {"WS_URL_CHAINSTACK_SOLANA", "chainstack", "solana", "solana"},
+ {"WS_URL_HELIUS_SOLANA", "helius", "solana", "solana"},
+ {"WS_URL_QUICKNODE_SOLANA", "quicknode", "solana", "solana"},
} {
if url := strings.TrimSpace(os.Getenv(opt.env)); url != "" {
- eps = append(eps, Endpoint{Provider: opt.provider, Chain: opt.chain, Kind: "evm", URL: url})
+ eps = append(eps, Endpoint{Provider: opt.provider, Chain: opt.chain, Kind: opt.kind, URL: url})
}
}
return eps
diff --git a/harnesses/ws-head-latency/cmd/script/metrics.go b/harnesses/ws-head-latency/cmd/script/metrics.go
index af8f2842..9c589c1c 100644
--- a/harnesses/ws-head-latency/cmd/script/metrics.go
+++ b/harnesses/ws-head-latency/cmd/script/metrics.go
@@ -41,9 +41,17 @@ var (
prometheus.HistogramOpts{
Name: "ws_head_lag_milliseconds",
Help: "Per-block push lag vs the earliest provider in the cohort, in milliseconds. Relative measurement: the fastest provider on a block reads 0 by construction.",
- // 5ms → 10.24s exponential; covers same-frame ties through
- // multi-second stragglers.
- Buckets: prometheus.ExponentialBuckets(5, 2, 12),
+ // Sub-ms floor because the winner reads near zero by
+ // construction; a 5ms first bucket used to collapse every
+ // leader's p50 to ~2.5ms (histogram_quantile interpolation
+ // artifact inside [0,5ms]). Fine buckets from 0.5ms up
+ // through 10s cover same-frame ties through multi-second
+ // stragglers on every chain in the cohort (Solana ~400ms
+ // slots, Base 2s blocks, Ethereum 12s blocks).
+ Buckets: []float64{
+ 0.5, 1, 2, 3, 5, 8, 12, 20, 35, 60,
+ 100, 160, 260, 420, 680, 1100, 1800, 3000, 5000, 10000,
+ },
ConstLabels: regionLabels,
},
[]string{"provider", "chain"},
From 800d81ab492084b28412ddce86d5dcf8299096d4 Mon Sep 17 00:00:00 2001
From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com>
Date: Thu, 16 Jul 2026 16:24:00 +0200
Subject: [PATCH 33/89] ship: ungate bench 085 evm-block-builders on prod
(#1227) (#1228)
Titan leads with sample=6973 in 24h, data healthy since the harness
ship. Removing from REMOVED_BENCH_SLUGS so the slug renders on prod
next revalidate. Cache keys bumped to bench-unfiltered-v32 +
all-benchmarks-v35 so cached entries do not keep dropping the slug
via the old prod gate.
Co-authored-by: Florent Tapponnier
---
src/lib/removed-benches.ts | 1 -
src/lib/spec.ts | 4 ++--
2 files changed, 2 insertions(+), 3 deletions(-)
diff --git a/src/lib/removed-benches.ts b/src/lib/removed-benches.ts
index 5c4690f2..16e3e1b8 100644
--- a/src/lib/removed-benches.ts
+++ b/src/lib/removed-benches.ts
@@ -52,5 +52,4 @@ export const REMOVED_BENCH_SLUGS = new Set([
// follows suit and is not gated.
"oracle-freshness",
"rpc-reliability",
- "evm-block-builders",
]);
diff --git a/src/lib/spec.ts b/src/lib/spec.ts
index 009f2a80..86cce92b 100644
--- a/src/lib/spec.ts
+++ b/src/lib/spec.ts
@@ -292,7 +292,7 @@ const loadBenchmarkUnfilteredCached = unstable_cache(
// v31: 084 indexer-latency dropped entirely (spec + harness deleted;
// HyperSync + The Graph required paid credentials for sustained
// cadence). Bench SET shrank.
- ["bench-unfiltered-v31", process.env.VERCEL_ENV === "production" ? "prod" : "all"],
+ ["bench-unfiltered-v32", process.env.VERCEL_ENV === "production" ? "prod" : "all"],
{ revalidate: 300, tags: ["benchmarks"] },
);
@@ -451,7 +451,7 @@ const loadAllBenchmarksCached = unstable_cache(
// v32: bumped with bench-unfiltered-v30 (081 slug rename ws-head-latency
// -> ws-head-latency-ethereum + ungate on prod).
// v34: bumped with bench-unfiltered-v31 (084 indexer-latency dropped).
- ["all-benchmarks-v34", process.env.VERCEL_ENV === "production" ? "prod" : "all"],
+ ["all-benchmarks-v35", process.env.VERCEL_ENV === "production" ? "prod" : "all"],
{ revalidate: 300, tags: ["benchmarks"] },
);
export const loadAllBenchmarks = cache(loadAllBenchmarksCached);
From 5489f4a432ba31a2122c2d6d494bb7952918102a Mon Sep 17 00:00:00 2001
From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com>
Date: Thu, 16 Jul 2026 16:38:38 +0200
Subject: [PATCH 34/89] tsp-arb-latency: drop 5 illiquid tickers (TSLA GOOGL
AMZN MSFT SPY) (#1231) (#1232)
Their Uniswap v4 pools on Robinhood Chain have no arb activity: pool spot stays frozen at the last swap value tick after tick while the Yahoo reference moves normally. Events open on ref triggers but roll over as still_open with zero latency samples. Cohort restricted to the 6 tickers with observable arb activity: NVDA AAPL PLTR META AMD MU.
Co-authored-by: Florent Tapponnier
---
.../tokenized-stock-peg/cmd/script/config.go | 15 ++++++++++-----
1 file changed, 10 insertions(+), 5 deletions(-)
diff --git a/harnesses/tokenized-stock-peg/cmd/script/config.go b/harnesses/tokenized-stock-peg/cmd/script/config.go
index 0a91141b..5f96da1e 100644
--- a/harnesses/tokenized-stock-peg/cmd/script/config.go
+++ b/harnesses/tokenized-stock-peg/cmd/script/config.go
@@ -43,17 +43,22 @@ type Asset struct {
USDGIsC0 bool
}
+// Illiquid tickers dropped 2026-07-16: GOOGL, TSLA, MSFT, AMZN, SPY.
+// Their Uniswap v4 pools on Robinhood Chain have no arb activity;
+// the pool spot price stays frozen at the last swap value (repeats
+// tick after tick) while the Yahoo reference moves normally. Ref
+// triggers fire but the pool never converges within the settle
+// window, so events roll over as still_open with zero latency
+// samples. The 5 dropped names surfaced as "unresponsive" on the
+// bench page with no headline number. Cohort restricted to the 6
+// tickers with observable arb activity: NVDA, AAPL, PLTR, META,
+// AMD, MU. Re-add when a pool's swap volume picks up.
var assets = []Asset{
{Symbol: "NVDA", Token: "0xd0601CE157Db5bdC3162BbaC2a2C8aF5320D9EEC", PoolID: "0x3bb34a44f1b2b5f32c034c38a53065a521a47b199700fa9bd19d60985ff24bf1", FeePPM: 3000, USDGIsC0: true},
{Symbol: "AAPL", Token: "0xaF3D76f1834A1d425780943C99Ea8A608f8a93f9", PoolID: "0xda4116b5894ee7479e64eae9276e1a2944ef0e5ce863a299d296a15618deee01", FeePPM: 10000, USDGIsC0: true},
- {Symbol: "GOOGL", Token: "0x2e0847E8910a9732eB3fb1bb4b70a580ADAD4FE3", PoolID: "0xef22239f96c6ac95dcd57b90c6b14c0cc8c3c16844def34daef68dc9dd945344", FeePPM: 10000, USDGIsC0: false},
- {Symbol: "TSLA", Token: "0x322F0929c4625eD5bAd873c95208D54E1c003b2d", PoolID: "0x8517f8071ae5b831b738052f12125e8e3d6c158b78728aa44ce3b25e5104d32e", FeePPM: 3000, USDGIsC0: false},
{Symbol: "PLTR", Token: "0x894E1EC2D74FFE5AEF8Dc8A9e84686acCB964F2A", PoolID: "0xee430ee1003e1985e1828a01b9a20dad67ad4302994fe2abb4a173de4ac54623", FeePPM: 10000, USDGIsC0: true},
{Symbol: "META", Token: "0xc0D6457C16Cc70d6790Dd43521C899C87ce02f35", PoolID: "0x5875d407a42965b0e768c8925cea290e06fa50603ef34fc99eb92a1050e6ae36", FeePPM: 3000, USDGIsC0: true},
{Symbol: "AMD", Token: "0x86923f96303D656E4aa86D9d42D1e57ad2023fdC", PoolID: "0xde9f85fdd9e05a943a52f2c69ffafe3064a3287df03d02c9b431bc92d4781274", FeePPM: 10000, USDGIsC0: true},
- {Symbol: "MSFT", Token: "0xe93237C50D904957Cf27E7B1133b510C669c2e74", PoolID: "0xace02af66d24427b162f80329e039b78c226fb9a79669f5e18d5feec2aa0c056", FeePPM: 20000, USDGIsC0: true},
- {Symbol: "AMZN", Token: "0x12f190a9F9d7D37a250758b26824B97CE941bF54", PoolID: "0xa3280c768df670a535d14af8c22ad3907f2acfc0277c03309fb4d5fc8d43447e", FeePPM: 20000, USDGIsC0: false},
- {Symbol: "SPY", Token: "0x117cc2133c37B721F49dE2A7a74833232B3B4C0C", PoolID: "0x7eeda68cd84620339e6ad4bf054af9b19878ac13139991c7aaec018c40a8bb6a", FeePPM: 10000, USDGIsC0: false},
{Symbol: "MU", Token: "0xfF080c8ce2E5feadaCa0Da81314Ae59D232d4afD", PoolID: "0x6fa3ee0048e78bf0a513eb0ab56f482944a767c21db990fcf555605e69f05659", FeePPM: 10000, USDGIsC0: true},
}
From ba97dc55e470c93c6b25a0f86336690e4e00ca5d Mon Sep 17 00:00:00 2001
From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com>
Date: Thu, 16 Jul 2026 16:43:31 +0200
Subject: [PATCH 35/89] perp-fees harness: fix Paradex depth-skip, gains SOL
slot, dYdX walk order, GMX Subsquid error surfacing (#1234)
- Paradex: cap fill ratio at 90% of visible book (depth=100). Tiers that eat through the tail now return book_too_thin and skip publishing instead of inflated 150-500 bps ghost values.
- gains: only cache USD-quoted pairs during pair discovery, widen scan 60->200. Root cause was a SOL/BTC pair overwriting the SOL/USD cache entry, publishing the wrong feeIndex and uniform 4.333 bps across ETH/BTC/SOL.
- dydx: sort v4-indexer level responses (asks ascending, bids descending) before walking the book. Insertion-order walk produced 1.7-2x inflated headline at 100k/1M tiers.
- gmx: prefix Subsquid errors, surface GraphQL errors[], fall back to unsuffixed positionFeeFactor if the impact-branch field is absent, and error out on zero factor. Prevents the silent-6bps-constant fallback path.
Verified: go build + go vet clean.
Co-authored-by: Florent Tapponnier
---
harnesses/perp-fees/cmd/script/dydx.go | 42 +++++++++++++++----
harnesses/perp-fees/cmd/script/gains.go | 18 +++++++-
harnesses/perp-fees/cmd/script/gmx.go | 51 +++++++++++++++++++----
harnesses/perp-fees/cmd/script/paradex.go | 10 ++++-
harnesses/perp-fees/cmd/script/walk.go | 48 +++++++++++++++++++++
5 files changed, 149 insertions(+), 20 deletions(-)
diff --git a/harnesses/perp-fees/cmd/script/dydx.go b/harnesses/perp-fees/cmd/script/dydx.go
index 5b6d4ebe..43148723 100644
--- a/harnesses/perp-fees/cmd/script/dydx.go
+++ b/harnesses/perp-fees/cmd/script/dydx.go
@@ -5,6 +5,7 @@ import (
"fmt"
"io"
"net/http"
+ "sort"
"strconv"
"time"
)
@@ -62,18 +63,45 @@ func fetchDYdX(v VenueConfig) PerpSample {
s.FetchLatencyMs = time.Since(start).Milliseconds()
return s
}
- bestBid, _ := strconv.ParseFloat(book.Bids[0].Price, 64)
- bestAsk, _ := strconv.ParseFloat(book.Asks[0].Price, 64)
- mid := (bestBid + bestAsk) / 2
- s.MidPrice = mid
- // Walk asks
- levels := make([]bookLevel, 0, len(book.Asks))
+ // Convert and sort. The dYdX v4 indexer's orderbook endpoint returns
+ // levels in insertion order, NOT sorted by price — walking as-is means
+ // the "top of book" starts at whichever level was most recently posted,
+ // which inflates the effective price by a factor that matches the
+ // observed 1.7-2x too-high headline (asks walked out of order push the
+ // weighted average well past the true best offer). Sort asks ascending
+ // and bids descending before consuming.
+ asks := make([]bookLevel, 0, len(book.Asks))
for _, a := range book.Asks {
px, _ := strconv.ParseFloat(a.Price, 64)
sz, _ := strconv.ParseFloat(a.Size, 64)
- levels = append(levels, bookLevel{Px: px, Sz: sz})
+ if px > 0 && sz > 0 {
+ asks = append(asks, bookLevel{Px: px, Sz: sz})
+ }
}
+ bids := make([]bookLevel, 0, len(book.Bids))
+ for _, b := range book.Bids {
+ px, _ := strconv.ParseFloat(b.Price, 64)
+ sz, _ := strconv.ParseFloat(b.Size, 64)
+ if px > 0 && sz > 0 {
+ bids = append(bids, bookLevel{Px: px, Sz: sz})
+ }
+ }
+ if len(asks) == 0 || len(bids) == 0 {
+ s.Err = "empty_orderbook"
+ s.FetchLatencyMs = time.Since(start).Milliseconds()
+ return s
+ }
+ sort.Slice(asks, func(i, j int) bool { return asks[i].Px < asks[j].Px })
+ sort.Slice(bids, func(i, j int) bool { return bids[i].Px > bids[j].Px })
+
+ bestBid := bids[0].Px
+ bestAsk := asks[0].Px
+ mid := (bestBid + bestAsk) / 2
+ s.MidPrice = mid
+
+ // Walk asks (long open = buying against the ask side).
+ levels := asks
effective, err := walkBookForNotional(levels, v.NotionalUSD)
if err != nil {
s.Err = fmt.Sprintf("walk: %v", err)
diff --git a/harnesses/perp-fees/cmd/script/gains.go b/harnesses/perp-fees/cmd/script/gains.go
index 2d9d1f7f..141b2476 100644
--- a/harnesses/perp-fees/cmd/script/gains.go
+++ b/harnesses/perp-fees/cmd/script/gains.go
@@ -225,8 +225,13 @@ func findGainsPair(client *http.Client, asset string) (*gainsPair, error) {
}
gainsCacheMu.Unlock()
+ // Gains v8 has grown well past 60 pairs; a narrow scan silently misses
+ // USD-quoted pairs (SOL/USD sits past the first crypto majors) and lets
+ // a same-symbol non-USD pair win the cache slot. 200 is enough headroom
+ // for the current listed universe with the 3-consecutive-errors early
+ // exit still handling the tail.
consecutiveErrors := 0
- for i := 0; i < 60; i++ {
+ for i := 0; i < 200; i++ {
p, err := gainsReadPair(client, i)
if err != nil {
fmt.Printf("[PERP][gains] pair scan idx %d: %v\n", i, err)
@@ -240,12 +245,21 @@ func findGainsPair(client *http.Client, asset string) (*gainsPair, error) {
if p.From == "" {
continue
}
+ // Only cache USD-quoted pairs. Gains lists cross pairs like SOL/BTC
+ // alongside SOL/USD, and keying the cache by From alone would let a
+ // non-USD pair (with a different feeIndex/spreadP) win the slot for
+ // an asset that also has a USD pair. That was the source of the
+ // uniform-4.333-bps SOL bug: SOL's non-USD pair pinned the cache to
+ // a crypto-major feeIndex before SOL/USD was reached in the scan.
+ if !strings.EqualFold(p.To, "USD") {
+ continue
+ }
gainsCacheMu.Lock()
gainsPairCache[strings.ToUpper(p.From)] = p
gainsPairIdxCache[strings.ToUpper(p.From)] = i
gainsPairAt[strings.ToUpper(p.From)] = time.Now()
gainsCacheMu.Unlock()
- if strings.EqualFold(p.From, asset) && strings.EqualFold(p.To, "USD") {
+ if strings.EqualFold(p.From, asset) {
return p, nil
}
time.Sleep(150 * time.Millisecond)
diff --git a/harnesses/perp-fees/cmd/script/gmx.go b/harnesses/perp-fees/cmd/script/gmx.go
index 6c2b8fb7..5bd6637b 100644
--- a/harnesses/perp-fees/cmd/script/gmx.go
+++ b/harnesses/perp-fees/cmd/script/gmx.go
@@ -38,11 +38,18 @@ type gmxGqlReq struct {
type gmxMarketInfo struct {
Data struct {
MarketInfos []struct {
- ID string `json:"id"`
- PositionFeeFactorForPositiveImpact string `json:"positionFeeFactorForPositiveImpact"`
- PositionFeeFactorForNegativeImpact string `json:"positionFeeFactorForNegativeImpact"`
+ ID string `json:"id"`
+ PositionFeeFactorForPositiveImpact string `json:"positionFeeFactorForPositiveImpact"`
+ PositionFeeFactorForNegativeImpact string `json:"positionFeeFactorForNegativeImpact"`
+ // Some Subsquid deployments expose an unsuffixed field name.
+ // We fall back to it when the impact-branch fields are absent
+ // so a schema rename does not silently zero the fee.
+ PositionFeeFactor string `json:"positionFeeFactor"`
} `json:"marketInfos"`
} `json:"data"`
+ Errors []struct {
+ Message string `json:"message"`
+ } `json:"errors"`
}
type gmxMarketsRESTItem struct {
@@ -73,7 +80,7 @@ func fetchGMX(v VenueConfig) PerpSample {
// 1) Position fee from Subsquid (live on-chain factor)
q := gmxGqlReq{
Query: `query { marketInfos(where: {id_eq: "` + market + `"}) {
- id positionFeeFactorForPositiveImpact positionFeeFactorForNegativeImpact
+ id positionFeeFactorForPositiveImpact positionFeeFactorForNegativeImpact positionFeeFactor
}}`,
}
bodyBytes, _ := json.Marshal(q)
@@ -81,32 +88,58 @@ func fetchGMX(v VenueConfig) PerpSample {
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
- s.Err = fmt.Sprintf("gql: %v", err)
+ s.Err = fmt.Sprintf("subsquid_failed: %v", err)
s.FetchLatencyMs = time.Since(start).Milliseconds()
return s
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode != 200 {
- s.Err = fmt.Sprintf("gql_status_%d: %s", resp.StatusCode, truncate(string(respBody), 200))
+ s.Err = fmt.Sprintf("subsquid_failed: status_%d: %s", resp.StatusCode, truncate(string(respBody), 200))
s.FetchLatencyMs = time.Since(start).Milliseconds()
return s
}
var info gmxMarketInfo
if err := json.Unmarshal(respBody, &info); err != nil {
- s.Err = fmt.Sprintf("gql_parse: %v", err)
+ s.Err = fmt.Sprintf("subsquid_failed: parse: %v", err)
+ s.FetchLatencyMs = time.Since(start).Milliseconds()
+ return s
+ }
+ if len(info.Errors) > 0 {
+ s.Err = fmt.Sprintf("subsquid_failed: gql_error: %s", info.Errors[0].Message)
s.FetchLatencyMs = time.Since(start).Milliseconds()
return s
}
if len(info.Data.MarketInfos) == 0 {
- s.Err = "market_not_found"
+ s.Err = "subsquid_failed: market_not_found"
s.FetchLatencyMs = time.Since(start).Milliseconds()
return s
}
m := info.Data.MarketInfos[0]
// FLOAT_PRECISION = 1e30. positionFeeFactor = factor * 1e30.
// e.g. "600000000000000000000000000" = 0.0006 = 6 bps.
- negativeFee := factor1e30ToBps(m.PositionFeeFactorForNegativeImpact)
+ //
+ // Branch choice: report the NEGATIVE-impact factor. Opening a long
+ // against a bid-heavy book (or a short against an ask-heavy book) pays
+ // the negative-impact branch, which is the worst-case opening cost and
+ // matches the "all-in fee at open" methodology used for the CEXes here.
+ // If the schema is on an older/unsuffixed version we fall back to
+ // positionFeeFactor so a rename does not silently zero the reported fee.
+ rawFactor := m.PositionFeeFactorForNegativeImpact
+ if rawFactor == "" {
+ rawFactor = m.PositionFeeFactor
+ }
+ if rawFactor == "" {
+ s.Err = "subsquid_failed: missing positionFeeFactor fields"
+ s.FetchLatencyMs = time.Since(start).Milliseconds()
+ return s
+ }
+ negativeFee := factor1e30ToBps(rawFactor)
+ if negativeFee == 0 {
+ s.Err = fmt.Sprintf("subsquid_failed: zero fee factor for market %s", market)
+ s.FetchLatencyMs = time.Since(start).Milliseconds()
+ return s
+ }
s.TakerFeeBps = negativeFee
// On GMX there's no orderbook spread — slippage = priceImpact, which
// is ~0 for $1k notional vs. multi-million pool TVL.
diff --git a/harnesses/perp-fees/cmd/script/paradex.go b/harnesses/perp-fees/cmd/script/paradex.go
index 5a03984d..25d8fe17 100644
--- a/harnesses/perp-fees/cmd/script/paradex.go
+++ b/harnesses/perp-fees/cmd/script/paradex.go
@@ -83,7 +83,13 @@ func fetchParadex(v VenueConfig) PerpSample {
sz, _ := strconv.ParseFloat(a[1], 64)
levels = append(levels, bookLevel{Px: px, Sz: sz})
}
- effective, err := walkBookForNotional(levels, v.NotionalUSD)
+ // Paradex REST caps depth at 100 levels. When the walker eats through
+ // more than ~90% of the visible book the "effective price" is dominated
+ // by the tail levels and stops being a real quote (methodology: skip
+ // when depth thins out). Cap the fill ratio to skip such tiers rather
+ // than publish an inflated bps.
+ const paradexMaxFillRatio = 0.9
+ effective, err := walkBookForNotionalCapped(levels, v.NotionalUSD, paradexMaxFillRatio)
if err != nil {
s.Err = fmt.Sprintf("walk: %v", err)
s.FetchLatencyMs = time.Since(start).Milliseconds()
@@ -91,7 +97,7 @@ func fetchParadex(v VenueConfig) PerpSample {
}
s.SpreadBps = (effective - mid) / mid * 10000
s.AllInBps = s.TakerFeeBps + s.SpreadBps
- applyBookTiers(&s, levels, mid)
+ applyBookTiersCapped(&s, levels, mid, paradexMaxFillRatio)
// 3) Funding: per 8h period, normalize to per hour.
var fund paradexFunding
diff --git a/harnesses/perp-fees/cmd/script/walk.go b/harnesses/perp-fees/cmd/script/walk.go
index 2ce71976..6f707661 100644
--- a/harnesses/perp-fees/cmd/script/walk.go
+++ b/harnesses/perp-fees/cmd/script/walk.go
@@ -50,6 +50,33 @@ func walkBookForNotional(levels []bookLevel, notional float64) (float64, error)
return notional / totalQty, nil
}
+// totalBookNotional sums price*size across every level, giving the total
+// visible USD depth on this side of the book.
+func totalBookNotional(levels []bookLevel) float64 {
+ total := 0.0
+ for _, l := range levels {
+ if l.Px <= 0 || l.Sz <= 0 {
+ continue
+ }
+ total += l.Px * l.Sz
+ }
+ return total
+}
+
+// walkBookForNotionalCapped walks like walkBookForNotional but returns an
+// error when the walk would consume more than maxFillRatio of the total
+// visible book depth. Depth-capped venues (Paradex depth=100) can otherwise
+// publish an eaten-through effective price when the tier notional is close
+// to or larger than the visible book. Use this for venues where the fetched
+// depth is a hard ceiling on visible liquidity.
+func walkBookForNotionalCapped(levels []bookLevel, notional, maxFillRatio float64) (float64, error) {
+ total := totalBookNotional(levels)
+ if total > 0 && notional > total*maxFillRatio {
+ return 0, fmt.Errorf("book_too_thin: %.2f of visible %.2f > %.0f%%", notional, total, maxFillRatio*100)
+ }
+ return walkBookForNotional(levels, notional)
+}
+
// applyBookTiers walks the already-fetched ask levels once per tier notional
// and fills s.Tiers with taker fee + tier spread. Call it after s.TakerFeeBps
// is known. Costs no extra API calls: the book was fetched for the headline
@@ -76,6 +103,27 @@ func applyBookTiers(s *PerpSample, levels []bookLevel, mid float64) {
}
}
+// applyBookTiersCapped is like applyBookTiers but skips any tier whose
+// notional would consume more than maxFillRatio of the visible book depth.
+// Use this on venues that expose a hard depth cap (e.g. Paradex depth=100)
+// where the top of a shallow book gives a misleading "effective price" when
+// the walk eats through the entire visible side.
+func applyBookTiersCapped(s *PerpSample, levels []bookLevel, mid, maxFillRatio float64) {
+ for _, n := range tierNotionals {
+ effective, err := walkBookForNotionalCapped(levels, n, maxFillRatio)
+ if err != nil {
+ s.SkippedTiers = append(s.SkippedTiers, notionalLabel(n))
+ continue
+ }
+ spread := (effective - mid) / mid * 10000
+ s.Tiers = append(s.Tiers, TierSample{
+ Notional: notionalLabel(n),
+ SpreadBps: spread,
+ AllInBps: s.TakerFeeBps + spread,
+ })
+ }
+}
+
// applyFlatTiers publishes the same all-in figure at every tier. Used by the
// oracle-priced venues (GMX v2, gains.trade) where the harness-read cost is
// a fixed fraction of position size, so the bps figure does not change with
From c3bb950fc55fe0e103feaa47755b62c2ed5b6b24 Mon Sep 17 00:00:00 2001
From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com>
Date: Thu, 16 Jul 2026 17:02:23 +0200
Subject: [PATCH 36/89] batch 2: MEV chips + rpc copy + Aster/edgeX +
Cardano/Sui/Cosmos/Avax (#1237)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* site: provider_notes chip on ranking rows + rpc-capabilities copy fixes
- validator-yield now surfaces a per-provider chip (MEV inc./MEV excl./no MEV) beside each row so scan-readers can't accidentally compare Solana MEV-inclusive APR against Ethereum consensus-only APR without seeing the caveat
- New optional spec field provider_notes: {slug: string} plumbed end-to-end (Zod schema, TS type, materialize loader, spec overlay, ledger-table render)
- Chip renders only when the note is set, no visual change to any other bench
- rpc-capabilities: '10 EVM chains' copy corrected to 26 chains (24 EVM + Solana + Polkadot) across title/subtitle/seo_intro/abstract/methodology + FAQ
- rpc-capabilities disclaimer expanded to call out that the 'All chains' aggregate rewards single-chain foundation endpoints and to document Cloudflare-eth exclusion (returns -32603 on POST)
* perp-fees harness: add Aster + edgeX venues
- Aster (BSC): Binance-style depth walk against fapi.asterdex.com, hardcoded 5 bps taker (no public fee schedule API)
- edgeX (offchain CLOB): getDepth REST + hardcoded 3.8 bps taker per gitbook docs. Asset→contractId map declared inline (verify on first prod run; wrong ID surfaces as an orderbook fetch error, not a silent 0)
- Both venues probed for ETH/BTC/SOL at all 3 notional tiers ($1k/$100k/$1M) via applyBookTiers
- Closes the biggest coverage gap flagged by the perp-fees GEO audit (Aster is #2 perp DEX by 30d volume)
* validator-yield harness: add Cardano, Sui, Cosmos Hub, Avalanche
Closes the 'Highest staking yield' coverage gap: the bench previously ranked only Solana, Ethereum and Hyperliquid but AI answers to that question almost always include Cardano ($14B staked) and other top chains.
- Cardano (Koios API): top-50 pools by stake, net = ρ×(1-τ)×(1-margin). Simplified vs full a0/k/saturation formula but lands within ~50-100 bps of pooltool.io.
- Sui (public RPC suix_getValidatorsApy + suix_getLatestSuiSystemState): all active validators, apy is already net of commission per Sui docs.
- Cosmos Hub (LCD REST): top-200 bonded validators, net = inflation×(1-communityTax)/bondedRatio×(1-commission).
- Avalanche (P-Chain platform.getCurrentValidators): top-100 by stake, base 8.5% APR × uptime × (1 - delegationFee/100).
Reuses existing ocb_validator_* gauges from metrics.go (no new metrics, no go.mod changes). Env-overridable endpoints declared inline in each fetcher matching solana.go style. Shortcuts (uptime=100% for Cardano/Sui/Cosmos, hardcoded community_tax=2%, base 8.5% for Avalanche) documented in code.
---------
Co-authored-by: Florent Tapponnier
---
benchmarks/rpc-capabilities.yml | 46 +--
benchmarks/validator-yield.yml | 5 +
harnesses/perp-fees/cmd/script/aster.go | 102 ++++++
harnesses/perp-fees/cmd/script/config.go | 10 +
harnesses/perp-fees/cmd/script/edgex.go | 123 +++++++
harnesses/perp-fees/cmd/script/main.go | 4 +
.../validator-yield/cmd/script/avalanche.go | 269 ++++++++++++++
.../validator-yield/cmd/script/cardano.go | 345 ++++++++++++++++++
.../validator-yield/cmd/script/cosmos.go | 270 ++++++++++++++
harnesses/validator-yield/cmd/script/main.go | 6 +-
harnesses/validator-yield/cmd/script/sui.go | 249 +++++++++++++
src/components/ledger-table.tsx | 5 +
src/lib/materialize/load.ts | 1 +
src/lib/spec-schema.ts | 14 +
src/lib/spec.ts | 5 +
src/types/benchmark.ts | 7 +
16 files changed, 1439 insertions(+), 22 deletions(-)
create mode 100644 harnesses/perp-fees/cmd/script/aster.go
create mode 100644 harnesses/perp-fees/cmd/script/edgex.go
create mode 100644 harnesses/validator-yield/cmd/script/avalanche.go
create mode 100644 harnesses/validator-yield/cmd/script/cardano.go
create mode 100644 harnesses/validator-yield/cmd/script/cosmos.go
create mode 100644 harnesses/validator-yield/cmd/script/sui.go
diff --git a/benchmarks/rpc-capabilities.yml b/benchmarks/rpc-capabilities.yml
index cff135e2..cee5e02a 100644
--- a/benchmarks/rpc-capabilities.yml
+++ b/benchmarks/rpc-capabilities.yml
@@ -2,10 +2,10 @@
slug: rpc-capabilities
number: "010"
-title: Fastest free public RPC for Ethereum, BNB, Polygon and 7 more EVM chains
+title: Fastest free public RPC for Ethereum, BNB, Polygon and 23 more chains (plus Solana and Polkadot)
seo_title: "Fastest free public RPC 2026"
seo_description: "Fastest free public RPC live on eth_getBlockByNumber p50. dRPC, PublicNode, Tenderly, Nodies, Lava ranked."
-subtitle: HTTP round-trip latency for eth_getBlockByNumber against free, no-key public RPC endpoints across 10 EVM chains, audited every 60 seconds.
+subtitle: HTTP round-trip latency for eth_getBlockByNumber against free, no-key public RPC endpoints across 26 chains (24 EVM plus Solana and Polkadot), audited every 60 seconds.
category: RPCs
status: live
metric: RPC latency
@@ -13,13 +13,14 @@ unit: ms
higher_is_better: false
disclaimer: >
- Latency is conditional on success: the harness times only calls that
- return a valid, fresh block, so an endpoint failing most calls can
- still post a fast p50 from its rare successes. Treat rows below 50
- percent success as degraded. Providers whose probes stop succeeding
- are pinned below the table as unresponsive. On the All chains tab
- each number is the mean of per chain, per region 24h p50s; single
- chain providers are measured on their one chain only.
+ Latency is conditional on success: the harness times only calls
+ returning a valid fresh block, so a mostly-failing endpoint can
+ post a fast p50. Rows below 50 percent success are degraded;
+ unresponsive probes pin below the table. The All chains aggregate
+ favours single-chain foundation endpoints (Base, Binance,
+ Arbitrum, Optimism, Avalanche officials) since they rank on one
+ chain only; use a chain tab for honest comparison. Dead endpoints
+ (cloudflare-eth POST returns -32603) are excluded.
seo_intro: |
This benchmark answers the question every developer reaching for a
@@ -28,14 +29,15 @@ seo_intro: |
chain my product runs on. Alchemy and Infura own the keyed-tier
market; the free public side of the question lives elsewhere, in
the RPC services dapps hit when a contributor's free quota is the
- budget. We probe `eth_getBlockByNumber` every 60 seconds against 13
- audited providers across 10 EVM chains. PublicNode, dRPC,
- MeowRPC, Tenderly Gateway, Nodies (POKT), Lava Network,
- Flashbots Protect, Cloudflare and 5 chain-official foundation
- endpoints (Base, Binance BSC, Avalanche, Arbitrum, Optimism).
- Chains covered. Ethereum (8 providers), Arbitrum (7), Base (5),
- Optimism (5), Avalanche (4), BNB (4), Polygon (4), Linea + Scroll + Mantle (3
- each). The headline question is per chain, not cross-chain. Five
+ budget. We probe `eth_getBlockByNumber` every 60 seconds against
+ audited providers across 26 chains (24 EVM plus Solana and
+ Polkadot). PublicNode, dRPC, MeowRPC, Tenderly Gateway, Nodies
+ (POKT), Lava Network, Flashbots Protect, bloXroute, OnFinality
+ and chain-official foundation endpoints (Base, Binance BSC,
+ Avalanche, Arbitrum, Optimism, Sonic, Gnosis, Celo, Blast, Taiko,
+ Moonbeam, Berachain, zkSync, Cronos, Fraxtal, Unichain, Soneium,
+ Monad, MegaETH, Parity for Polkadot, Solana Labs).
+ Chain footprint spans the L1/L2 majors down to long-tail chains. The headline question is per chain, not cross-chain. Five
providers are single-chain by design (the foundation endpoints
above), so a cross-chain aggregate mechanically rewards whichever
single-chain endpoint happens to be fastest on its one chain.
@@ -53,8 +55,10 @@ seo_intro: |
abstract: |
We measure the round-trip latency of a single, identical RPC call
- (`eth_getBlockByNumber`) against every major no-key Ethereum-compatible
- public RPC endpoint, across 10 EVM chains. The
+ (`eth_getBlockByNumber` for EVM chains, `getSlot` for Solana,
+ `chain_getHeader` for Polkadot) against every major no-key public
+ RPC endpoint, across 26 chains (24 EVM plus Solana and Polkadot).
+ The
harness emits three metric families from one binary because they
share the same (provider × chain) client matrix and same 15 s
scrape interval: (a) `rpc_latency_milliseconds` for the speed
@@ -78,7 +82,7 @@ methodology:
- "Call-result classification: `ok` (HTTP 200 + non-empty result), `http_err` (status ≠ 200 or transport failure), `jsonrpc_err` (HTTP 200 with an `error` field, the Cloudflare-eth trap), `stale` (returned block more than 20 behind the cross-provider tip for that chain), `timeout`. Counter `rpc_call_total{result}` powers the reliability leaderboard."
- "Success gating: because failed probes record no latency, a mostly failing endpoint can post a fast p50 from its rare successes. The ledger pairs every latency figure with a Success column computed as ok calls over total calls in the same 24h window; read rows below 50 percent success as degraded. Providers with under 5 percent success lose their latency series to Prometheus staleness and are pinned below the table as unresponsive instead of being ranked."
- "Archive depth: every 5 minutes we issue `eth_getBalance` at (head, depth) for `depth` in {300, 7200, 216_000, 1_296_000, 5_000_000}. Gauge `rpc_archive_depth_supported{depth}` is 1 when the response is non-pruned, 0 otherwise. The 300/7200 thresholds cover Geth's default pruned-cap range; 216k ≈ 1 month; 1.3M ≈ 6 months; 5M ≈ genesis-era full archive."
- - "Chain coverage: 10 EVM chains. Ethereum (8 providers), Arbitrum (7), Base (5), Optimism (5), Avalanche (4), BNB (4), Polygon (4), Linea (3), Scroll (3), Mantle (3). Provider matrix per chain is documented in `miniapps/rpc-capabilities/cmd/script/config.go`."
+ - "Chain coverage: 26 chains (24 EVM plus Solana and Polkadot). EVM majors: Ethereum, Arbitrum, Base, Optimism, Avalanche, BNB, Polygon, Linea, Scroll, Mantle. EVM long-tail: Sonic, Gnosis, Celo, Blast, Taiko, Moonbeam, Berachain, zkSync, Cronos, Fraxtal, Unichain, Soneium, Monad, MegaETH. Non-EVM: Solana (getSlot probe), Polkadot (chain_getHeader probe). Provider matrix per chain is documented in `harnesses/rpc-capabilities/cmd/script/config.go`."
- "Excluded: 1RPC (delisted 2026-07-09, under 13% success even at two chains and one probe per minute), Ankr (key-gated), gateway.fm (29 req/IP budget too tight for 15s polling), LlamaRPC + BlockPI + OmniaTech (Cloudflare 521 region-blocked), Alchemy demo (rate-limited dead), NodeReal + GetBlock + Chainstack (key-gated). Blink, formerly Merkle (no public eth_getBlockByNumber) + Lava and MeowRPC outside ETH/Arb excluded. Every (provider, chain) was live-verified no-key before inclusion."
findings:
@@ -95,7 +99,7 @@ faq:
- q: "What is the fastest free public RPC right now?"
a: "The question is per chain because several providers are single-chain by design (Base official, Binance, Arbitrum Foundation, Optimism Foundation, Avalanche) and a cross-chain aggregate mechanically rewards whichever single-chain endpoint happens to be fastest on its one chain. Current per-chain leaders. Ethereum {{best_name:chain:ethereum}} at {{best_p50:chain:ethereum}}, Base {{best_name:chain:base}} at {{best_p50:chain:base}}, BNB Chain {{best_name:chain:bnb}} at {{best_p50:chain:bnb}}, Arbitrum {{best_name:chain:arbitrum}} at {{best_p50:chain:arbitrum}}. The leaderboard re-sorts every 60 seconds against fresh Prometheus samples averaged across us-east, eu-west and sgp probes. Switch the chain tab to see the leader on the network your product runs on."
- q: "Which public RPCs work without an API key in 2026?"
- a: "13 audited providers across 10 EVM chains. Universal multi-chain (work on 4+ chains): PublicNode, dRPC, Tenderly Gateway (`gateway.tenderly.co/public/`), Nodies (POKT successor at `*-pokt.nodies.app`). Ethereum-specific or limited footprint: MeowRPC (ETH + Arbitrum only since 2025), Flashbots Protect, Cloudflare (read-degraded), Lava Network (ETH + Arbitrum no-key, other chains require key), Chain-official foundation RPCs: Arbitrum (`arb1.arbitrum.io/rpc`), Optimism (`mainnet.optimism.io`), Base (`mainnet.base.org`), Avalanche (`api.avax.network`), BNB (`bsc-dataseed1.binance.org`). Excluded: Ankr (key-gated), gateway.fm (29 req/IP), LlamaRPC / BlockPI / OmniaTech (Cloudflare 521 region-blocked), Alchemy demo (rate-limited dead), NodeReal / GetBlock / Chainstack (key-gated)."
+ a: "Audited providers across 26 chains (24 EVM plus Solana and Polkadot). Universal multi-chain: PublicNode, dRPC, Tenderly Gateway (`gateway.tenderly.co/public/`), Nodies (POKT at `*-pokt.nodies.app`). Ethereum-specific or limited footprint: MeowRPC (ETH + Arbitrum only), Flashbots Protect, Lava Network (ETH + Arbitrum + Sonic + Solana no-key). Chain-official foundation RPCs: Arbitrum, Optimism, Base, Avalanche, BNB, Sonic, Gnosis, Celo, Blast, Taiko, Moonbeam, Berachain, zkSync, Cronos, Fraxtal, Unichain, Soneium, Monad, MegaETH, Parity for Polkadot, Solana Labs, plus regional gateways like OnFinality and bloXroute. Excluded: Ankr (key-gated), gateway.fm (29 req/IP), LlamaRPC / BlockPI / OmniaTech (Cloudflare 521 region-blocked), Alchemy demo (rate-limited dead), NodeReal / GetBlock / Chainstack (key-gated), cloudflare-eth (POST returns -32603)."
- q: "Does Cloudflare still have a free Ethereum RPC that works?"
a: "Partially, and the trend is the wrong way. Cloudflare's public Ethereum gateway (cloudflare-eth) switched to a permissioned mode for many JSON-RPC methods. The endpoint still answers HTTP 200 quickly, but the body is increasingly a JSON-RPC error (`-32046 Cannot fulfill request`) rather than a block. This benchmark catches the trap by classifying a call as `ok` only when the HTTP status is 200 AND the JSON-RPC body has a usable `result` field, so Cloudflare's real success rate is visible in the Success column instead of hiding behind fast error responses. Latency without reliability is a misleading ranking signal for a public RPC."
- q: "Why can an RPC with a low success rate still show a fast latency?"
diff --git a/benchmarks/validator-yield.yml b/benchmarks/validator-yield.yml
index 93ed2e5c..cf714536 100644
--- a/benchmarks/validator-yield.yml
+++ b/benchmarks/validator-yield.yml
@@ -15,6 +15,11 @@ higher_is_better: true
disclaimer: |
Honest scope. (1) `net_yield = gross_apr × uptime`. NOT "yield net of MEV", Stakewiz `total_apy` already folds Jito MEV into gross, MEV is INCLUDED. `mev_share_bps` exists for transparency, not subtraction. (2) Solana median is dragged by ~42 vals at 0% APY (commission 100% or zero leader slots). (3) Hyperliquid: centralised sequencer, no separate MEV layer, ~30 vals only. (4) Ethereum: one network-average consensus-layer APR from the beacon spec formula, execution tips and MEV excluded.
+provider_notes:
+ solana: "MEV inc."
+ ethereum: "MEV excl."
+ hyperliquid: "no MEV"
+
seo_intro: |
This benchmark answers the question every staker asks when
choosing a chain or a validator. what is the live net yield I
diff --git a/harnesses/perp-fees/cmd/script/aster.go b/harnesses/perp-fees/cmd/script/aster.go
new file mode 100644
index 00000000..0480ed83
--- /dev/null
+++ b/harnesses/perp-fees/cmd/script/aster.go
@@ -0,0 +1,102 @@
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "strconv"
+ "time"
+)
+
+// Aster (BSC-based perp DEX, Binance-style REST). Public no-auth REST for
+// the depth endpoint; symbol convention is uppercase USDT-quoted
+// (ETHUSDT, BTCUSDT, SOLUSDT). The taker fee is not exposed by any public
+// endpoint: the documented base rate (5 bps, docs.asterdex.com) is used
+// and disclosed in the spec formula.
+// TODO: switch to fee schedule API when Aster ships one.
+
+const asterBase = "https://fapi.asterdex.com"
+
+// Documented base taker rate (5 bps = 0.0005). Revisit if Aster ships a
+// public fees endpoint.
+const asterTakerBps = 5.0
+
+type asterDepth struct {
+ Bids [][]string `json:"bids"` // [price, qty]
+ Asks [][]string `json:"asks"`
+}
+
+func fetchAster(v VenueConfig) PerpSample {
+ s := PerpSample{Venue: v.Slug, Asset: v.Asset, At: time.Now().UTC().Format(time.RFC3339)}
+ start := time.Now()
+ client := &http.Client{Timeout: 8 * time.Second}
+
+ symbol := v.Asset + "USDT"
+ s.TakerFeeBps = asterTakerBps
+
+ var book asterDepth
+ url := fmt.Sprintf("%s/fapi/v1/depth?symbol=%s&limit=100", asterBase, symbol)
+ if err := asterGet(client, url, &book); err != nil {
+ s.Err = fmt.Sprintf("orderbook: %v", err)
+ s.FetchLatencyMs = time.Since(start).Milliseconds()
+ return s
+ }
+ if len(book.Bids) == 0 || len(book.Asks) == 0 {
+ s.Err = "empty_orderbook"
+ s.FetchLatencyMs = time.Since(start).Milliseconds()
+ return s
+ }
+ if len(book.Bids[0]) < 2 || len(book.Asks[0]) < 2 {
+ s.Err = "malformed_orderbook"
+ s.FetchLatencyMs = time.Since(start).Milliseconds()
+ return s
+ }
+ bestBid, _ := strconv.ParseFloat(book.Bids[0][0], 64)
+ bestAsk, _ := strconv.ParseFloat(book.Asks[0][0], 64)
+ mid := (bestBid + bestAsk) / 2
+ if mid <= 0 {
+ s.Err = "bad_mid"
+ s.FetchLatencyMs = time.Since(start).Milliseconds()
+ return s
+ }
+ s.MidPrice = mid
+
+ levels := make([]bookLevel, 0, len(book.Asks))
+ for _, a := range book.Asks {
+ if len(a) < 2 {
+ continue
+ }
+ px, _ := strconv.ParseFloat(a[0], 64)
+ sz, _ := strconv.ParseFloat(a[1], 64)
+ levels = append(levels, bookLevel{Px: px, Sz: sz})
+ }
+ effective, err := walkBookForNotional(levels, v.NotionalUSD)
+ if err != nil {
+ s.Err = fmt.Sprintf("walk: %v", err)
+ s.FetchLatencyMs = time.Since(start).Milliseconds()
+ return s
+ }
+ s.SpreadBps = (effective - mid) / mid * 10000
+ s.AllInBps = s.TakerFeeBps + s.SpreadBps
+ applyBookTiers(&s, levels, mid)
+
+ s.FetchLatencyMs = time.Since(start).Milliseconds()
+ return s
+}
+
+func asterGet(client *http.Client, url string, out any) error {
+ req, _ := http.NewRequest("GET", url, nil)
+ req.Header.Set("User-Agent", "OpenChainBench-PerpFees/1.0 contact@mobula.io")
+ req.Header.Set("Accept", "application/json")
+ resp, err := client.Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ body, _ := io.ReadAll(resp.Body)
+ if resp.StatusCode != 200 {
+ return fmt.Errorf("status_%d: %s", resp.StatusCode, truncate(string(body), 200))
+ }
+ return json.Unmarshal(body, out)
+}
diff --git a/harnesses/perp-fees/cmd/script/config.go b/harnesses/perp-fees/cmd/script/config.go
index aa2bc52f..89f01d78 100644
--- a/harnesses/perp-fees/cmd/script/config.go
+++ b/harnesses/perp-fees/cmd/script/config.go
@@ -49,6 +49,16 @@ func loadConfig() *Config {
// Extended's taker fee is documented (2.5 bps), not API-exposed;
// disclosed in the spec formula.
{slug: "extended", display: "Extended", assets: []string{"ETH", "BTC", "SOL"}},
+ // Aster (BSC-based, Binance-style REST). Public depth endpoint
+ // used for the walk; taker fee (5 bps, docs.asterdex.com) is
+ // hardcoded because no fee schedule API is published — disclosed
+ // in the spec formula.
+ {slug: "aster", display: "Aster", assets: []string{"ETH", "BTC", "SOL"}},
+ // edgeX (offchain CLOB perp DEX). Public depth endpoint keyed by
+ // contractId (map hardcoded after a one-time getMetaData lookup);
+ // taker fee (3.8 bps, edgex-1.gitbook.io) hardcoded because no
+ // public fee schedule API — disclosed in the spec formula.
+ {slug: "edgex", display: "edgeX", assets: []string{"ETH", "BTC", "SOL"}},
}
venues := make([]VenueConfig, 0, len(defs)*3)
diff --git a/harnesses/perp-fees/cmd/script/edgex.go b/harnesses/perp-fees/cmd/script/edgex.go
new file mode 100644
index 00000000..1b29b527
--- /dev/null
+++ b/harnesses/perp-fees/cmd/script/edgex.go
@@ -0,0 +1,123 @@
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "strconv"
+ "time"
+)
+
+// edgeX (offchain CLOB perp DEX). Public no-auth REST. Contract IDs are
+// resolved via GET /api/v1/public/meta/getMetaData; hardcoded here after a
+// one-time lookup (verified via getMetaData on 2026-07-16). The taker fee
+// is not exposed by any public endpoint: the documented base rate
+// (3.8 bps, edgex-1.gitbook.io) is used and disclosed in the spec formula.
+// TODO: switch to fee schedule API when edgeX ships one.
+
+const edgexBase = "https://pro.edgex.exchange"
+
+// Documented base taker rate (3.8 bps = 0.00038). Revisit if edgeX ships a
+// public fees endpoint.
+const edgexTakerBps = 3.8
+
+// Asset → contractId map. Verified via getMetaData on 2026-07-16. If edgeX
+// re-numbers contracts, update these IDs (or wire in a dynamic lookup).
+var edgexContractIDs = map[string]string{
+ "ETH": "10000002",
+ "BTC": "10000001",
+ "SOL": "10000003",
+}
+
+type edgexDepthResp struct {
+ Code string `json:"code"`
+ Data []struct {
+ Bids []struct {
+ Price string `json:"price"`
+ Size string `json:"size"`
+ } `json:"bids"`
+ Asks []struct {
+ Price string `json:"price"`
+ Size string `json:"size"`
+ } `json:"asks"`
+ } `json:"data"`
+ Msg string `json:"msg"`
+}
+
+func fetchEdgex(v VenueConfig) PerpSample {
+ s := PerpSample{Venue: v.Slug, Asset: v.Asset, At: time.Now().UTC().Format(time.RFC3339)}
+ start := time.Now()
+ client := &http.Client{Timeout: 8 * time.Second}
+
+ contractID, ok := edgexContractIDs[v.Asset]
+ if !ok {
+ s.Err = "asset_not_mapped"
+ s.FetchLatencyMs = time.Since(start).Milliseconds()
+ return s
+ }
+ s.TakerFeeBps = edgexTakerBps
+
+ var book edgexDepthResp
+ url := fmt.Sprintf("%s/api/v1/public/quote/getDepth?contractId=%s&level=100", edgexBase, contractID)
+ if err := edgexGet(client, url, &book); err != nil {
+ s.Err = fmt.Sprintf("orderbook: %v", err)
+ s.FetchLatencyMs = time.Since(start).Milliseconds()
+ return s
+ }
+ if len(book.Data) == 0 {
+ s.Err = "empty_orderbook"
+ s.FetchLatencyMs = time.Since(start).Milliseconds()
+ return s
+ }
+ d := book.Data[0]
+ if len(d.Bids) == 0 || len(d.Asks) == 0 {
+ s.Err = "empty_orderbook"
+ s.FetchLatencyMs = time.Since(start).Milliseconds()
+ return s
+ }
+ bestBid, _ := strconv.ParseFloat(d.Bids[0].Price, 64)
+ bestAsk, _ := strconv.ParseFloat(d.Asks[0].Price, 64)
+ mid := (bestBid + bestAsk) / 2
+ if mid <= 0 {
+ s.Err = "bad_mid"
+ s.FetchLatencyMs = time.Since(start).Milliseconds()
+ return s
+ }
+ s.MidPrice = mid
+
+ levels := make([]bookLevel, 0, len(d.Asks))
+ for _, a := range d.Asks {
+ px, _ := strconv.ParseFloat(a.Price, 64)
+ sz, _ := strconv.ParseFloat(a.Size, 64)
+ levels = append(levels, bookLevel{Px: px, Sz: sz})
+ }
+ effective, err := walkBookForNotional(levels, v.NotionalUSD)
+ if err != nil {
+ s.Err = fmt.Sprintf("walk: %v", err)
+ s.FetchLatencyMs = time.Since(start).Milliseconds()
+ return s
+ }
+ s.SpreadBps = (effective - mid) / mid * 10000
+ s.AllInBps = s.TakerFeeBps + s.SpreadBps
+ applyBookTiers(&s, levels, mid)
+
+ s.FetchLatencyMs = time.Since(start).Milliseconds()
+ return s
+}
+
+func edgexGet(client *http.Client, url string, out any) error {
+ req, _ := http.NewRequest("GET", url, nil)
+ req.Header.Set("User-Agent", "OpenChainBench-PerpFees/1.0 contact@mobula.io")
+ req.Header.Set("Accept", "application/json")
+ resp, err := client.Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ body, _ := io.ReadAll(resp.Body)
+ if resp.StatusCode != 200 {
+ return fmt.Errorf("status_%d: %s", resp.StatusCode, truncate(string(body), 200))
+ }
+ return json.Unmarshal(body, out)
+}
diff --git a/harnesses/perp-fees/cmd/script/main.go b/harnesses/perp-fees/cmd/script/main.go
index 6026812d..9cbbf998 100644
--- a/harnesses/perp-fees/cmd/script/main.go
+++ b/harnesses/perp-fees/cmd/script/main.go
@@ -105,6 +105,10 @@ func fetchOne(v VenueConfig, cfg *Config) PerpSample {
return fetchParadex(v)
case "extended":
return fetchExtended(v)
+ case "aster":
+ return fetchAster(v)
+ case "edgex":
+ return fetchEdgex(v)
default:
return PerpSample{Venue: v.Slug, Asset: v.Asset, Err: "unsupported_venue"}
}
diff --git a/harnesses/validator-yield/cmd/script/avalanche.go b/harnesses/validator-yield/cmd/script/avalanche.go
new file mode 100644
index 00000000..80812d4b
--- /dev/null
+++ b/harnesses/validator-yield/cmd/script/avalanche.go
@@ -0,0 +1,269 @@
+package main
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "sort"
+ "strconv"
+ "time"
+)
+
+const (
+ avalancheChain = "avalanche"
+ avalancheValidatorsTag = "avax_platform_getCurrentValidators"
+ coingeckoAvaxSourceTag = "coingecko_avax"
+
+ // Cap: Avalanche has ~1500 primary-network validators. Top 100 by
+ // stake covers the vast majority of delegated AVAX and keeps
+ // Prometheus cardinality bounded.
+ avalancheTopN = 100
+
+ // Avalanche P-Chain base staking reward is a documented ~8.5% APY
+ // for a validator delegating for a full year at 100% uptime. The
+ // P-Chain API doesn't publish a per-validator realised APR — the
+ // number is deterministic given (stake, duration, uptime), and
+ // duration doesn't materially move for long-running delegators.
+ // v1 shortcut: use the documented base and adjust per-validator by
+ // uptime × (1 - delegationFee/100). Real per-validator numbers vary
+ // by ±20 bps due to duration-bonus differences; acceptable for a
+ // network-comparison bench.
+ avalancheBaseAPR = 0.085
+)
+
+var (
+ avalanchePChainURL = envDefault("VAL_ECON_AVAX_PCHAIN_URL", "https://api.avax.network/ext/bc/P")
+ coingeckoAvaxURL = envDefault("VAL_ECON_COINGECKO_AVAX_URL", "https://api.coingecko.com/api/v3/simple/price?ids=avalanche-2&vs_currencies=usd")
+)
+
+// avaxRPCEnvelope wraps the JSON-RPC 2.0 response used by the P-Chain
+// `platform` API.
+type avaxRPCEnvelope struct {
+ JSONRPC string `json:"jsonrpc"`
+ ID int `json:"id"`
+ Result json.RawMessage `json:"result"`
+ Error *struct {
+ Code int `json:"code"`
+ Message string `json:"message"`
+ } `json:"error"`
+}
+
+// avaxValidator mirrors the shape returned by
+// platform.getCurrentValidators. Numeric fields come back as strings.
+//
+// - stakeAmount: nAVAX (1 AVAX = 1e9 nAVAX)
+// - delegationFee: percentage string, e.g. "2.0000" = 2%
+// - uptime: fractional string in [0,1] on newer avalanchego,
+// older nodes returned percent [0,100] — normalise both.
+type avaxValidator struct {
+ NodeID string `json:"nodeID"`
+ StakeAmount string `json:"stakeAmount"`
+ Weight string `json:"weight"` // some responses use weight instead of stakeAmount
+ StartTime string `json:"startTime"`
+ EndTime string `json:"endTime"`
+ DelegationFee string `json:"delegationFee"`
+ Uptime string `json:"uptime"`
+ Connected bool `json:"connected"`
+ Signer *struct {
+ PublicKey string `json:"publicKey"`
+ } `json:"signer"`
+ Delegators any `json:"delegators"` // ignored — variable shape, blobs cause parse fights
+}
+
+type avaxCurrentValidatorsResult struct {
+ Validators []avaxValidator `json:"validators"`
+}
+
+func avaxPChainRPC(ctx context.Context, client *http.Client, method string, params any, out any) error {
+ if params == nil {
+ params = map[string]any{}
+ }
+ payload := map[string]any{
+ "jsonrpc": "2.0",
+ "id": 1,
+ "method": method,
+ "params": params,
+ }
+ body, err := json.Marshal(payload)
+ if err != nil {
+ return err
+ }
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, avalanchePChainURL, bytes.NewReader(body))
+ if err != nil {
+ return err
+ }
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Accept", "application/json")
+ resp, err := client.Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ raw, _ := io.ReadAll(resp.Body)
+ return fmt.Errorf("avax pchain %s http %d: %s", method, resp.StatusCode, string(raw))
+ }
+ raw, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return err
+ }
+ var env avaxRPCEnvelope
+ if err := json.Unmarshal(raw, &env); err != nil {
+ return fmt.Errorf("avax pchain %s envelope parse: %w", method, err)
+ }
+ if env.Error != nil {
+ return fmt.Errorf("avax pchain %s error %d: %s", method, env.Error.Code, env.Error.Message)
+ }
+ if err := json.Unmarshal(env.Result, out); err != nil {
+ return fmt.Errorf("avax pchain %s result parse: %w", method, err)
+ }
+ return nil
+}
+
+func fetchAvalancheValidators(ctx context.Context, client *http.Client) ([]avaxValidator, error) {
+ var out avaxCurrentValidatorsResult
+ // Empty params returns primary network validators, all subnets excluded.
+ err := avaxPChainRPC(ctx, client, "platform.getCurrentValidators", map[string]any{}, &out)
+ return out.Validators, err
+}
+
+func fetchAvalanchePrice(ctx context.Context, client *http.Client) (float64, error) {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, coingeckoAvaxURL, nil)
+ if err != nil {
+ return 0, err
+ }
+ req.Header.Set("Accept", "application/json")
+ req.Header.Set("User-Agent", "OpenChainBench-harness/1.0")
+ resp, err := client.Do(req)
+ if err != nil {
+ return 0, err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return 0, fmt.Errorf("coingecko avax http %d", resp.StatusCode)
+ }
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return 0, err
+ }
+ var payload map[string]map[string]float64
+ if err := json.Unmarshal(body, &payload); err != nil {
+ return 0, fmt.Errorf("coingecko avax parse: %w", err)
+ }
+ v, ok := payload["avalanche-2"]
+ if !ok {
+ return 0, fmt.Errorf("coingecko missing avalanche-2 key")
+ }
+ price := v["usd"]
+ if price <= 0 {
+ return 0, fmt.Errorf("coingecko invalid avax price %f", price)
+ }
+ return price, nil
+}
+
+// parseAvaxUptime normalises the uptime field returned by
+// platform.getCurrentValidators. Newer nodes report it as a fractional
+// value in [0,1] ("0.998"); older nodes returned a percent ("99.8").
+// If the parsed value is >1 we treat it as a percent, else a fraction.
+func parseAvaxUptime(s string) float64 {
+ if s == "" {
+ return 100.0
+ }
+ v, err := strconv.ParseFloat(s, 64)
+ if err != nil {
+ return 100.0
+ }
+ if v <= 1.0 {
+ v *= 100
+ }
+ if v < 0 {
+ v = 0
+ }
+ if v > 100 {
+ v = 100
+ }
+ return v
+}
+
+// scrapeAvalanche publishes per-validator gauges for the Avalanche
+// primary network (P-Chain). Yield is computed from the documented base
+// APR adjusted by per-validator uptime and delegation fee.
+//
+// stakeAmount / weight — we prefer stakeAmount but some node builds
+// return weight instead; fall back if needed.
+func scrapeAvalanche(ctx context.Context, client *http.Client) {
+ validators, err := fetchAvalancheValidators(ctx, client)
+ if err != nil {
+ scrapeErrorsTotal.WithLabelValues(avalancheChain, avalancheValidatorsTag).Inc()
+ fmt.Printf("[avalanche] getCurrentValidators err: %v\n", err)
+ return
+ }
+
+ avaxPrice, err := fetchAvalanchePrice(ctx, client)
+ if err != nil {
+ scrapeErrorsTotal.WithLabelValues(avalancheChain, coingeckoAvaxSourceTag).Inc()
+ fmt.Printf("[avalanche] coingecko err (stake_usd will be skipped): %v\n", err)
+ avaxPrice = 0
+ }
+
+ // Sort by stake desc, cap top-N.
+ stakeOf := func(v avaxValidator) float64 {
+ if s := parseFloat(v.StakeAmount); s > 0 {
+ return s
+ }
+ return parseFloat(v.Weight)
+ }
+ sort.Slice(validators, func(i, j int) bool {
+ return stakeOf(validators[i]) > stakeOf(validators[j])
+ })
+ if len(validators) > avalancheTopN {
+ validators = validators[:avalancheTopN]
+ }
+
+ const nAvaxPerAvax = 1e9
+ grossBps := pctToBps(avalancheBaseAPR)
+
+ netYields := make([]float64, 0, len(validators))
+ for _, v := range validators {
+ id := v.NodeID
+ // Avalanche doesn't include a human name in the P-Chain
+ // response — the nodeID is the operator identity.
+ name := shortenName(id)
+
+ uptimePct := parseAvaxUptime(v.Uptime)
+ delegationFeePct := parseFloat(v.DelegationFee) // 0..100
+
+ commissionBps := delegationFeePct * 100
+ if commissionBps > 10000 {
+ commissionBps = 10000
+ }
+
+ netBps := grossBps * (uptimePct / 100) * (1 - delegationFeePct/100)
+
+ stakeNAvax := stakeOf(v)
+ stakeAvax := stakeNAvax / nAvaxPerAvax
+ stakeUSD := stakeAvax * avaxPrice
+
+ validatorNetYieldBps.WithLabelValues(avalancheChain, id, name).Set(netBps)
+ validatorGrossYieldBps.WithLabelValues(avalancheChain, id, name).Set(grossBps)
+ validatorMevShareBps.WithLabelValues(avalancheChain, id, name).Set(0)
+ validatorCommissionBps.WithLabelValues(avalancheChain, id, name).Set(commissionBps)
+ validatorUptimePct.WithLabelValues(avalancheChain, id, name).Set(uptimePct)
+ if avaxPrice > 0 {
+ validatorStakeUSD.WithLabelValues(avalancheChain, id, name).Set(stakeUSD)
+ }
+ validatorJailed.WithLabelValues(avalancheChain, id, name).Set(0)
+
+ netYields = append(netYields, netBps)
+ }
+
+ chainTotalValidators.WithLabelValues(avalancheChain).Set(float64(len(netYields)))
+ chainMedianNetYieldBps.WithLabelValues(avalancheChain).Set(medianBps(netYields))
+ lastScrapeTimestamp.WithLabelValues(avalancheChain).Set(float64(time.Now().Unix()))
+
+ fmt.Printf("[avalanche] published %d validators, median net yield %.0f bps, AVAX=$%.4f\n",
+ len(netYields), medianBps(netYields), avaxPrice)
+}
diff --git a/harnesses/validator-yield/cmd/script/cardano.go b/harnesses/validator-yield/cmd/script/cardano.go
new file mode 100644
index 00000000..ec36aebb
--- /dev/null
+++ b/harnesses/validator-yield/cmd/script/cardano.go
@@ -0,0 +1,345 @@
+package main
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "sort"
+ "time"
+)
+
+const (
+ cardanoChain = "cardano"
+ koiosPoolListTag = "koios_pool_list"
+ koiosPoolInfoTag = "koios_pool_info"
+ koiosEpochParamsTag = "koios_epoch_params"
+ coingeckoAdaSourceTag = "coingecko_ada"
+
+ // Top-N cap: Cardano has ~3000 registered stake pools but the tail
+ // past position 50 holds a rounding-error share of total stake. Same
+ // rationale as the Solana top-200 cap — bounds Prometheus label
+ // cardinality without losing ranking information for this bench.
+ cardanoTopN = 50
+)
+
+// koiosPoolListEntry mirrors GET /pool_list. The bech32 pool ID is the
+// stable identifier used as the label; ticker is nullable when the pool
+// hasn't registered SMASH metadata.
+type koiosPoolListEntry struct {
+ PoolIDBech32 string `json:"pool_id_bech32"`
+ PoolIDHex string `json:"pool_id_hex"`
+ Ticker *string `json:"ticker"`
+ MetaURL *string `json:"meta_url"`
+ PoolStatus string `json:"pool_status"`
+ ActiveStake *string `json:"active_stake"` // lovelace, stringified
+}
+
+// koiosPoolInfo mirrors the POST /pool_info response. Only the fields
+// we consume are declared. `margin` is Cardano's commission fraction
+// [0,1]; `active_stake` and `live_pledge` are stringified lovelace
+// (1 ADA = 1e6 lovelace).
+type koiosPoolInfo struct {
+ PoolIDBech32 string `json:"pool_id_bech32"`
+ Ticker *string `json:"meta_json_ticker"` // some deployments nest under meta_json
+ Margin float64 `json:"margin"`
+ FixedCost string `json:"fixed_cost"`
+ Pledge string `json:"pledge"`
+ LivePledge string `json:"live_pledge"`
+ ActiveStake string `json:"active_stake"`
+ MetaJSON *struct {
+ Ticker string `json:"ticker"`
+ Name string `json:"name"`
+ } `json:"meta_json"`
+}
+
+// koiosEpochParams — we only need monetary_expansion (rho) and
+// treasury_growth_rate (tau) to approximate the network-wide gross APR
+// pre-commission.
+type koiosEpochParams struct {
+ EpochNo int64 `json:"epoch_no"`
+ MonetaryExpansion float64 `json:"monetary_expansion"`
+ TreasuryGrowthRate float64 `json:"treasury_growth_rate"`
+}
+
+var (
+ koiosBaseURL = envDefault("VAL_ECON_KOIOS_URL", "https://api.koios.rest/api/v1")
+ coingeckoCardanoURL = envDefault("VAL_ECON_COINGECKO_ADA_URL", "https://api.coingecko.com/api/v3/simple/price?ids=cardano&vs_currencies=usd")
+)
+
+// Cardano's real yield formula is famously nuanced (pledge influence
+// factor a0, saturation curve k, block-production luck). For v1 we
+// approximate:
+//
+// gross_network_apr ≈ rho × (1 - tau)
+// gross_pool_apr ≈ gross_network_apr (all pools share the pot pro-rata)
+// net_pool_apr ≈ gross_pool_apr × (1 - margin)
+//
+// This ignores the a0/k/saturation effects; empirically it lands
+// within ~50-100 bps of Cardano's ADAPools/pooltool.io figures for
+// non-saturated pools and is honest enough for a network-comparison
+// bench. If Koios's epoch_params fetch fails we fall back to
+// cardanoFallbackAPR (a documented approximation of ~5%).
+const cardanoFallbackAPR = 0.05
+
+func fetchKoiosPoolList(ctx context.Context, client *http.Client) ([]koiosPoolListEntry, error) {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, koiosBaseURL+"/pool_list?pool_status=eq.registered", nil)
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Accept", "application/json")
+ resp, err := client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("koios pool_list http %d", resp.StatusCode)
+ }
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, err
+ }
+ var out []koiosPoolListEntry
+ if err := json.Unmarshal(body, &out); err != nil {
+ return nil, fmt.Errorf("koios pool_list parse: %w", err)
+ }
+ return out, nil
+}
+
+func fetchKoiosPoolInfo(ctx context.Context, client *http.Client, ids []string) ([]koiosPoolInfo, error) {
+ body, err := json.Marshal(map[string]any{"_pool_bech32_ids": ids})
+ if err != nil {
+ return nil, err
+ }
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, koiosBaseURL+"/pool_info", bytes.NewReader(body))
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Accept", "application/json")
+ resp, err := client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("koios pool_info http %d", resp.StatusCode)
+ }
+ raw, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, err
+ }
+ var out []koiosPoolInfo
+ if err := json.Unmarshal(raw, &out); err != nil {
+ return nil, fmt.Errorf("koios pool_info parse: %w", err)
+ }
+ return out, nil
+}
+
+func fetchKoiosEpochParams(ctx context.Context, client *http.Client) (koiosEpochParams, error) {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, koiosBaseURL+"/epoch_params?limit=1", nil)
+ if err != nil {
+ return koiosEpochParams{}, err
+ }
+ req.Header.Set("Accept", "application/json")
+ resp, err := client.Do(req)
+ if err != nil {
+ return koiosEpochParams{}, err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return koiosEpochParams{}, fmt.Errorf("koios epoch_params http %d", resp.StatusCode)
+ }
+ raw, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return koiosEpochParams{}, err
+ }
+ var out []koiosEpochParams
+ if err := json.Unmarshal(raw, &out); err != nil {
+ return koiosEpochParams{}, fmt.Errorf("koios epoch_params parse: %w", err)
+ }
+ if len(out) == 0 {
+ return koiosEpochParams{}, fmt.Errorf("koios epoch_params: empty response")
+ }
+ return out[0], nil
+}
+
+func fetchCardanoPrice(ctx context.Context, client *http.Client) (float64, error) {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, coingeckoCardanoURL, nil)
+ if err != nil {
+ return 0, err
+ }
+ req.Header.Set("Accept", "application/json")
+ req.Header.Set("User-Agent", "OpenChainBench-harness/1.0")
+ resp, err := client.Do(req)
+ if err != nil {
+ return 0, err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return 0, fmt.Errorf("coingecko ada http %d", resp.StatusCode)
+ }
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return 0, err
+ }
+ var payload map[string]map[string]float64
+ if err := json.Unmarshal(body, &payload); err != nil {
+ return 0, fmt.Errorf("coingecko ada parse: %w", err)
+ }
+ v, ok := payload["cardano"]
+ if !ok {
+ return 0, fmt.Errorf("coingecko missing cardano key")
+ }
+ price := v["usd"]
+ if price <= 0 {
+ return 0, fmt.Errorf("coingecko invalid ada price %f", price)
+ }
+ return price, nil
+}
+
+// scrapeCardano fetches the top-N stake pools from Koios, applies a
+// simple rho×(1-tau)/pool_margin approximation, and publishes the
+// standard OCB validator gauges under chain="cardano".
+//
+// Uptime is set to 100% for every pool: Cardano stake pools don't have
+// a canonical uptime metric in the same sense as Solana/Hyperliquid
+// (block-production luck varies by chance, not by operator downtime).
+func scrapeCardano(ctx context.Context, client *http.Client) {
+ pools, err := fetchKoiosPoolList(ctx, client)
+ if err != nil {
+ scrapeErrorsTotal.WithLabelValues(cardanoChain, koiosPoolListTag).Inc()
+ fmt.Printf("[cardano] koios pool_list err: %v\n", err)
+ return
+ }
+
+ // Sort descending by active_stake (parsed as lovelace) and cap.
+ sort.Slice(pools, func(i, j int) bool {
+ var a, b float64
+ if pools[i].ActiveStake != nil {
+ a = parseFloat(*pools[i].ActiveStake)
+ }
+ if pools[j].ActiveStake != nil {
+ b = parseFloat(*pools[j].ActiveStake)
+ }
+ return a > b
+ })
+ if len(pools) > cardanoTopN {
+ pools = pools[:cardanoTopN]
+ }
+
+ // Batch pool_info in a single POST — Koios accepts up to ~200 IDs.
+ ids := make([]string, 0, len(pools))
+ for _, p := range pools {
+ ids = append(ids, p.PoolIDBech32)
+ }
+ infos, err := fetchKoiosPoolInfo(ctx, client, ids)
+ if err != nil {
+ scrapeErrorsTotal.WithLabelValues(cardanoChain, koiosPoolInfoTag).Inc()
+ fmt.Printf("[cardano] koios pool_info err: %v\n", err)
+ return
+ }
+ infoIdx := make(map[string]koiosPoolInfo, len(infos))
+ for _, info := range infos {
+ infoIdx[info.PoolIDBech32] = info
+ }
+
+ // Network-wide gross APR from monetary params — fall back to a
+ // documented static approximation if Koios flakes here.
+ networkAPR := cardanoFallbackAPR
+ params, err := fetchKoiosEpochParams(ctx, client)
+ if err != nil {
+ scrapeErrorsTotal.WithLabelValues(cardanoChain, koiosEpochParamsTag).Inc()
+ fmt.Printf("[cardano] koios epoch_params err (using %.1f%% fallback): %v\n", cardanoFallbackAPR*100, err)
+ } else if params.MonetaryExpansion > 0 {
+ // rho × (1 - tau). Cardano distributes rho of the reserve per
+ // epoch; tau goes to treasury before pools split the rest.
+ // Annualisation: rho is already per-epoch × 73 epochs/year ≈
+ // per-year in Koios semantics for this field.
+ networkAPR = params.MonetaryExpansion * (1 - params.TreasuryGrowthRate)
+ }
+
+ adaPrice, err := fetchCardanoPrice(ctx, client)
+ if err != nil {
+ scrapeErrorsTotal.WithLabelValues(cardanoChain, coingeckoAdaSourceTag).Inc()
+ fmt.Printf("[cardano] coingecko err (stake_usd will be skipped): %v\n", err)
+ adaPrice = 0
+ }
+
+ const lovelacePerAda = 1e6
+
+ netYields := make([]float64, 0, len(pools))
+ for _, p := range pools {
+ info, ok := infoIdx[p.PoolIDBech32]
+ if !ok {
+ continue
+ }
+ name := shortenName(cardanoPoolLabel(p, info))
+ id := p.PoolIDBech32
+
+ grossBps := pctToBps(networkAPR)
+ commissionBps := info.Margin * 10000
+ if commissionBps > 10000 {
+ commissionBps = 10000
+ }
+ netAPR := networkAPR * (1 - info.Margin)
+ netBps := pctToBps(netAPR)
+
+ // Uptime: no canonical Cardano equivalent — assume 100%.
+ uptimePct := 100.0
+
+ activeStakeLovelace := parseFloat(info.ActiveStake)
+ if activeStakeLovelace == 0 && p.ActiveStake != nil {
+ activeStakeLovelace = parseFloat(*p.ActiveStake)
+ }
+ stakeADA := activeStakeLovelace / lovelacePerAda
+ stakeUSD := stakeADA * adaPrice
+
+ validatorNetYieldBps.WithLabelValues(cardanoChain, id, name).Set(netBps)
+ validatorGrossYieldBps.WithLabelValues(cardanoChain, id, name).Set(grossBps)
+ validatorMevShareBps.WithLabelValues(cardanoChain, id, name).Set(0)
+ validatorCommissionBps.WithLabelValues(cardanoChain, id, name).Set(commissionBps)
+ validatorUptimePct.WithLabelValues(cardanoChain, id, name).Set(uptimePct)
+ if adaPrice > 0 {
+ validatorStakeUSD.WithLabelValues(cardanoChain, id, name).Set(stakeUSD)
+ }
+ validatorJailed.WithLabelValues(cardanoChain, id, name).Set(0)
+
+ netYields = append(netYields, netBps)
+ }
+
+ chainTotalValidators.WithLabelValues(cardanoChain).Set(float64(len(netYields)))
+ chainMedianNetYieldBps.WithLabelValues(cardanoChain).Set(medianBps(netYields))
+ lastScrapeTimestamp.WithLabelValues(cardanoChain).Set(float64(time.Now().Unix()))
+
+ fmt.Printf("[cardano] published %d pools, median net yield %.0f bps, ADA=$%.4f\n",
+ len(netYields), medianBps(netYields), adaPrice)
+}
+
+// cardanoPoolLabel picks the best human name we can find: meta_json
+// ticker/name if present, else the list-endpoint ticker, else a
+// truncated bech32. Never returns empty (shortenName upgrades that
+// to "(unknown)").
+func cardanoPoolLabel(p koiosPoolListEntry, info koiosPoolInfo) string {
+ if info.MetaJSON != nil {
+ if info.MetaJSON.Ticker != "" {
+ return info.MetaJSON.Ticker
+ }
+ if info.MetaJSON.Name != "" {
+ return info.MetaJSON.Name
+ }
+ }
+ if info.Ticker != nil && *info.Ticker != "" {
+ return *info.Ticker
+ }
+ if p.Ticker != nil && *p.Ticker != "" {
+ return *p.Ticker
+ }
+ if len(p.PoolIDBech32) > 16 {
+ return p.PoolIDBech32[:16]
+ }
+ return p.PoolIDBech32
+}
diff --git a/harnesses/validator-yield/cmd/script/cosmos.go b/harnesses/validator-yield/cmd/script/cosmos.go
new file mode 100644
index 00000000..b7099333
--- /dev/null
+++ b/harnesses/validator-yield/cmd/script/cosmos.go
@@ -0,0 +1,270 @@
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "sort"
+ "time"
+)
+
+const (
+ cosmosChain = "cosmos-hub"
+ cosmosInflationTag = "cosmos_lcd_inflation"
+ cosmosPoolTag = "cosmos_lcd_pool"
+ cosmosValidatorsTag = "cosmos_lcd_validators"
+ coingeckoAtomSourceTag = "coingecko_atom"
+
+ // Cosmos Hub community_tax has been ~2% for years; hard-coding
+ // avoids a third LCD round-trip per scrape. If governance changes
+ // it materially, update here (grep this constant).
+ cosmosCommunityTax = 0.02
+
+ // Cap: Cosmos Hub has ~180 active validators (top 180 in bond
+ // order). We publish all of them — the tail is small enough to
+ // not blow up cardinality.
+ cosmosTopN = 200
+)
+
+var (
+ cosmosLCDBase = envDefault("VAL_ECON_COSMOS_LCD_URL", "https://cosmos-rest.publicnode.com")
+ coingeckoAtomURL = envDefault("VAL_ECON_COINGECKO_ATOM_URL", "https://api.coingecko.com/api/v3/simple/price?ids=cosmos&vs_currencies=usd")
+)
+
+// cosmosInflationResponse mirrors /cosmos/mint/v1beta1/inflation.
+// `inflation` is a decimal string in [0,1] (0.1 = 10%).
+type cosmosInflationResponse struct {
+ Inflation string `json:"inflation"`
+}
+
+// cosmosPoolResponse mirrors /cosmos/staking/v1beta1/pool. Token amounts
+// are stringified integer uatom (1 ATOM = 1e6 uatom).
+type cosmosPoolResponse struct {
+ Pool struct {
+ BondedTokens string `json:"bonded_tokens"`
+ NotBondedTokens string `json:"not_bonded_tokens"`
+ } `json:"pool"`
+}
+
+// cosmosValidator mirrors items in /cosmos/staking/v1beta1/validators.
+type cosmosValidator struct {
+ OperatorAddress string `json:"operator_address"`
+ Status string `json:"status"`
+ Tokens string `json:"tokens"`
+ Description struct {
+ Moniker string `json:"moniker"`
+ Identity string `json:"identity"`
+ } `json:"description"`
+ Commission struct {
+ CommissionRates struct {
+ Rate string `json:"rate"`
+ MaxRate string `json:"max_rate"`
+ MaxChangeRate string `json:"max_change_rate"`
+ } `json:"commission_rates"`
+ } `json:"commission"`
+ Jailed bool `json:"jailed"`
+}
+
+type cosmosValidatorsResponse struct {
+ Validators []cosmosValidator `json:"validators"`
+ Pagination struct {
+ NextKey *string `json:"next_key"`
+ Total string `json:"total"`
+ } `json:"pagination"`
+}
+
+func lcdGet(ctx context.Context, client *http.Client, url string, out any) error {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
+ if err != nil {
+ return err
+ }
+ req.Header.Set("Accept", "application/json")
+ req.Header.Set("User-Agent", "OpenChainBench-harness/1.0")
+ resp, err := client.Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return fmt.Errorf("lcd http %d: %s", resp.StatusCode, url)
+ }
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return err
+ }
+ return json.Unmarshal(body, out)
+}
+
+func fetchCosmosInflation(ctx context.Context, client *http.Client) (float64, error) {
+ var out cosmosInflationResponse
+ if err := lcdGet(ctx, client, cosmosLCDBase+"/cosmos/mint/v1beta1/inflation", &out); err != nil {
+ return 0, err
+ }
+ v := parseFloat(out.Inflation)
+ if v <= 0 || v > 1 {
+ return 0, fmt.Errorf("cosmos inflation implausible: %s", out.Inflation)
+ }
+ return v, nil
+}
+
+func fetchCosmosPool(ctx context.Context, client *http.Client) (bonded, notBonded float64, err error) {
+ var out cosmosPoolResponse
+ if err = lcdGet(ctx, client, cosmosLCDBase+"/cosmos/staking/v1beta1/pool", &out); err != nil {
+ return 0, 0, err
+ }
+ bonded = parseFloat(out.Pool.BondedTokens)
+ notBonded = parseFloat(out.Pool.NotBondedTokens)
+ if bonded <= 0 {
+ return 0, 0, fmt.Errorf("cosmos pool: bonded_tokens=0")
+ }
+ return bonded, notBonded, nil
+}
+
+func fetchCosmosValidators(ctx context.Context, client *http.Client) ([]cosmosValidator, error) {
+ url := cosmosLCDBase + "/cosmos/staking/v1beta1/validators?status=BOND_STATUS_BONDED&pagination.limit=250"
+ var out cosmosValidatorsResponse
+ if err := lcdGet(ctx, client, url, &out); err != nil {
+ return nil, err
+ }
+ return out.Validators, nil
+}
+
+func fetchCosmosPrice(ctx context.Context, client *http.Client) (float64, error) {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, coingeckoAtomURL, nil)
+ if err != nil {
+ return 0, err
+ }
+ req.Header.Set("Accept", "application/json")
+ req.Header.Set("User-Agent", "OpenChainBench-harness/1.0")
+ resp, err := client.Do(req)
+ if err != nil {
+ return 0, err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return 0, fmt.Errorf("coingecko atom http %d", resp.StatusCode)
+ }
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return 0, err
+ }
+ var payload map[string]map[string]float64
+ if err := json.Unmarshal(body, &payload); err != nil {
+ return 0, fmt.Errorf("coingecko atom parse: %w", err)
+ }
+ v, ok := payload["cosmos"]
+ if !ok {
+ return 0, fmt.Errorf("coingecko missing cosmos key")
+ }
+ price := v["usd"]
+ if price <= 0 {
+ return 0, fmt.Errorf("coingecko invalid atom price %f", price)
+ }
+ return price, nil
+}
+
+// scrapeCosmos computes Cosmos Hub validator yield from LCD REST:
+//
+// network_apr = inflation × (1 - community_tax) / bonded_ratio
+// net_apr = network_apr × (1 - commission_rate)
+//
+// Uptime is set to 100% — Cosmos Hub does surface missed_blocks via
+// /cosmos/slashing/v1beta1/signing_infos but we skip that in v1 to
+// keep the scrape to a single validators-pagination call.
+func scrapeCosmos(ctx context.Context, client *http.Client) {
+ inflation, err := fetchCosmosInflation(ctx, client)
+ if err != nil {
+ scrapeErrorsTotal.WithLabelValues(cosmosChain, cosmosInflationTag).Inc()
+ fmt.Printf("[cosmos-hub] inflation err: %v\n", err)
+ return
+ }
+
+ bonded, notBonded, err := fetchCosmosPool(ctx, client)
+ if err != nil {
+ scrapeErrorsTotal.WithLabelValues(cosmosChain, cosmosPoolTag).Inc()
+ fmt.Printf("[cosmos-hub] pool err: %v\n", err)
+ return
+ }
+ bondedRatio := bonded / (bonded + notBonded)
+ if bondedRatio <= 0 || bondedRatio > 1 {
+ fmt.Printf("[cosmos-hub] implausible bonded_ratio %.4f, aborting\n", bondedRatio)
+ return
+ }
+
+ validators, err := fetchCosmosValidators(ctx, client)
+ if err != nil {
+ scrapeErrorsTotal.WithLabelValues(cosmosChain, cosmosValidatorsTag).Inc()
+ fmt.Printf("[cosmos-hub] validators err: %v\n", err)
+ return
+ }
+
+ atomPrice, err := fetchCosmosPrice(ctx, client)
+ if err != nil {
+ scrapeErrorsTotal.WithLabelValues(cosmosChain, coingeckoAtomSourceTag).Inc()
+ fmt.Printf("[cosmos-hub] coingecko err (stake_usd will be skipped): %v\n", err)
+ atomPrice = 0
+ }
+
+ // Sort by tokens desc, cap top-N.
+ sort.Slice(validators, func(i, j int) bool {
+ return parseFloat(validators[i].Tokens) > parseFloat(validators[j].Tokens)
+ })
+ if len(validators) > cosmosTopN {
+ validators = validators[:cosmosTopN]
+ }
+
+ networkAPR := inflation * (1 - cosmosCommunityTax) / bondedRatio
+ grossBps := pctToBps(networkAPR)
+
+ const uatomPerAtom = 1e6
+
+ netYields := make([]float64, 0, len(validators))
+ for _, v := range validators {
+ name := shortenName(v.Description.Moniker)
+ id := v.OperatorAddress
+
+ commissionRate := parseFloat(v.Commission.CommissionRates.Rate) // 0..1
+ if commissionRate < 0 {
+ commissionRate = 0
+ }
+ if commissionRate > 1 {
+ commissionRate = 1
+ }
+ commissionBps := commissionRate * 10000
+
+ uptimePct := 100.0
+ netBps := grossBps * (1 - commissionRate) * (uptimePct / 100)
+ if v.Jailed {
+ netBps = 0
+ }
+
+ stakeAtom := parseFloat(v.Tokens) / uatomPerAtom
+ stakeUSD := stakeAtom * atomPrice
+
+ jailed := 0.0
+ if v.Jailed {
+ jailed = 1
+ }
+
+ validatorNetYieldBps.WithLabelValues(cosmosChain, id, name).Set(netBps)
+ validatorGrossYieldBps.WithLabelValues(cosmosChain, id, name).Set(grossBps)
+ validatorMevShareBps.WithLabelValues(cosmosChain, id, name).Set(0)
+ validatorCommissionBps.WithLabelValues(cosmosChain, id, name).Set(commissionBps)
+ validatorUptimePct.WithLabelValues(cosmosChain, id, name).Set(uptimePct)
+ if atomPrice > 0 {
+ validatorStakeUSD.WithLabelValues(cosmosChain, id, name).Set(stakeUSD)
+ }
+ validatorJailed.WithLabelValues(cosmosChain, id, name).Set(jailed)
+
+ netYields = append(netYields, netBps)
+ }
+
+ chainTotalValidators.WithLabelValues(cosmosChain).Set(float64(len(netYields)))
+ chainMedianNetYieldBps.WithLabelValues(cosmosChain).Set(medianBps(netYields))
+ lastScrapeTimestamp.WithLabelValues(cosmosChain).Set(float64(time.Now().Unix()))
+
+ fmt.Printf("[cosmos-hub] published %d validators, median net yield %.0f bps, inflation=%.2f%%, bonded=%.1f%%, ATOM=$%.4f\n",
+ len(netYields), medianBps(netYields), inflation*100, bondedRatio*100, atomPrice)
+}
diff --git a/harnesses/validator-yield/cmd/script/main.go b/harnesses/validator-yield/cmd/script/main.go
index 8f25c6c5..c056e1bf 100644
--- a/harnesses/validator-yield/cmd/script/main.go
+++ b/harnesses/validator-yield/cmd/script/main.go
@@ -19,7 +19,7 @@ func main() {
installLogCapture() // capture stdout into /logs ring buffer
fmt.Println("=== Validator Economics Harness ===")
fmt.Println("OpenChainBench bench #026 — net yield = gross APR + MEV − downtime")
- fmt.Println("Scope: Solana + Hyperliquid + Ethereum (consensus-layer, network avg)")
+ fmt.Println("Scope: Solana + Hyperliquid + Ethereum + Cardano + Sui + Cosmos Hub + Avalanche")
fmt.Println()
go func() {
@@ -41,6 +41,10 @@ func main() {
go runChainScraper(ctx, client, "solana", scrapeSolana)
go runChainScraper(ctx, client, "hyperliquid", scrapeHyperliquid)
go runChainScraper(ctx, client, "ethereum", scrapeEthereum)
+ go runChainScraper(ctx, client, "cardano", scrapeCardano)
+ go runChainScraper(ctx, client, "sui", scrapeSui)
+ go runChainScraper(ctx, client, "cosmos-hub", scrapeCosmos)
+ go runChainScraper(ctx, client, "avalanche", scrapeAvalanche)
sig := make(chan os.Signal, 1)
signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
diff --git a/harnesses/validator-yield/cmd/script/sui.go b/harnesses/validator-yield/cmd/script/sui.go
new file mode 100644
index 00000000..6158e860
--- /dev/null
+++ b/harnesses/validator-yield/cmd/script/sui.go
@@ -0,0 +1,249 @@
+package main
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "strconv"
+ "time"
+)
+
+const (
+ suiChain = "sui"
+ suiApysSourceTag = "sui_getValidatorsApy"
+ suiSystemSourceTag = "sui_getLatestSystemState"
+ coingeckoSuiSourceTag = "coingecko_sui"
+)
+
+// Sui exposes a single JSON-RPC endpoint per fullnode. The public
+// mainnet endpoint has no key requirement and no strict rate limit for
+// low-QPS pollers.
+var (
+ suiRPCURL = envDefault("VAL_ECON_SUI_RPC_URL", "https://fullnode.mainnet.sui.io:443")
+ coingeckoSuiURL = envDefault("VAL_ECON_COINGECKO_SUI_URL", "https://api.coingecko.com/api/v3/simple/price?ids=sui&vs_currencies=usd")
+)
+
+// suiRPCEnvelope is the standard JSON-RPC 2.0 wrapper.
+type suiRPCEnvelope struct {
+ JSONRPC string `json:"jsonrpc"`
+ ID int `json:"id"`
+ Result json.RawMessage `json:"result"`
+ Error *struct {
+ Code int `json:"code"`
+ Message string `json:"message"`
+ } `json:"error"`
+}
+
+// suiValidatorApy is one entry in `suix_getValidatorsApy.result.apys`.
+// `apy` is a decimal fraction (0.0234 = 2.34%) already net of the
+// validator's commission per Sui docs.
+type suiValidatorApy struct {
+ Address string `json:"address"`
+ APY float64 `json:"apy"`
+ Epoch int64 `json:"epoch"`
+}
+
+type suiValidatorsApyResponse struct {
+ Epoch string `json:"epoch"`
+ APYs []suiValidatorApy `json:"apys"`
+}
+
+// suiActiveValidator mirrors the fields we care about from the
+// `activeValidators` array in `suix_getLatestSuiSystemState`. All
+// numeric-like fields come back as strings.
+type suiActiveValidator struct {
+ SuiAddress string `json:"suiAddress"`
+ Name string `json:"name"`
+ Description string `json:"description"`
+ CommissionRate string `json:"commissionRate"` // bps, e.g. "200" = 2%
+ StakingPoolSuiBal string `json:"stakingPoolSuiBalance"` // MIST (1 SUI = 1e9 MIST)
+ NextEpochStake string `json:"nextEpochStake"`
+}
+
+type suiSystemStateResponse struct {
+ Epoch string `json:"epoch"`
+ ActiveValidators []suiActiveValidator `json:"activeValidators"`
+}
+
+func suiRPC(ctx context.Context, client *http.Client, method string, out any) error {
+ payload := map[string]any{
+ "jsonrpc": "2.0",
+ "id": 1,
+ "method": method,
+ "params": []any{},
+ }
+ body, err := json.Marshal(payload)
+ if err != nil {
+ return err
+ }
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, suiRPCURL, bytes.NewReader(body))
+ if err != nil {
+ return err
+ }
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Accept", "application/json")
+ resp, err := client.Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ raw, _ := io.ReadAll(resp.Body)
+ return fmt.Errorf("sui rpc %s http %d: %s", method, resp.StatusCode, string(raw))
+ }
+ raw, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return err
+ }
+ var env suiRPCEnvelope
+ if err := json.Unmarshal(raw, &env); err != nil {
+ return fmt.Errorf("sui rpc %s envelope parse: %w", method, err)
+ }
+ if env.Error != nil {
+ return fmt.Errorf("sui rpc %s error %d: %s", method, env.Error.Code, env.Error.Message)
+ }
+ if err := json.Unmarshal(env.Result, out); err != nil {
+ return fmt.Errorf("sui rpc %s result parse: %w", method, err)
+ }
+ return nil
+}
+
+func fetchSuiValidatorsAPY(ctx context.Context, client *http.Client) (suiValidatorsApyResponse, error) {
+ var out suiValidatorsApyResponse
+ err := suiRPC(ctx, client, "suix_getValidatorsApy", &out)
+ return out, err
+}
+
+func fetchSuiSystemState(ctx context.Context, client *http.Client) (suiSystemStateResponse, error) {
+ var out suiSystemStateResponse
+ err := suiRPC(ctx, client, "suix_getLatestSuiSystemState", &out)
+ return out, err
+}
+
+func fetchSuiPrice(ctx context.Context, client *http.Client) (float64, error) {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, coingeckoSuiURL, nil)
+ if err != nil {
+ return 0, err
+ }
+ req.Header.Set("Accept", "application/json")
+ req.Header.Set("User-Agent", "OpenChainBench-harness/1.0")
+ resp, err := client.Do(req)
+ if err != nil {
+ return 0, err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return 0, fmt.Errorf("coingecko sui http %d", resp.StatusCode)
+ }
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return 0, err
+ }
+ var payload map[string]map[string]float64
+ if err := json.Unmarshal(body, &payload); err != nil {
+ return 0, fmt.Errorf("coingecko sui parse: %w", err)
+ }
+ v, ok := payload["sui"]
+ if !ok {
+ return 0, fmt.Errorf("coingecko missing sui key")
+ }
+ price := v["usd"]
+ if price <= 0 {
+ return 0, fmt.Errorf("coingecko invalid sui price %f", price)
+ }
+ return price, nil
+}
+
+// scrapeSui publishes per-validator yield gauges for Sui. The apy field
+// from `suix_getValidatorsApy` is already net of commission per Sui
+// docs, so we use it as both gross (before uptime adjustment) and net
+// (after uptime, which we can't measure per-validator without the
+// per-epoch checkpoint history — we assume 100% for v1).
+//
+// MEV: Sui rewards are pure staking rewards (protocol emission), no
+// MEV component surfaces to validators today.
+func scrapeSui(ctx context.Context, client *http.Client) {
+ apy, err := fetchSuiValidatorsAPY(ctx, client)
+ if err != nil {
+ scrapeErrorsTotal.WithLabelValues(suiChain, suiApysSourceTag).Inc()
+ fmt.Printf("[sui] getValidatorsApy err: %v\n", err)
+ return
+ }
+
+ state, err := fetchSuiSystemState(ctx, client)
+ if err != nil {
+ scrapeErrorsTotal.WithLabelValues(suiChain, suiSystemSourceTag).Inc()
+ fmt.Printf("[sui] getLatestSuiSystemState err (metadata only, continuing): %v\n", err)
+ }
+
+ // Index active validators by address for enrichment.
+ valMeta := make(map[string]suiActiveValidator, len(state.ActiveValidators))
+ for _, v := range state.ActiveValidators {
+ valMeta[v.SuiAddress] = v
+ }
+
+ suiPrice, err := fetchSuiPrice(ctx, client)
+ if err != nil {
+ scrapeErrorsTotal.WithLabelValues(suiChain, coingeckoSuiSourceTag).Inc()
+ fmt.Printf("[sui] coingecko err (stake_usd will be skipped): %v\n", err)
+ suiPrice = 0
+ }
+
+ const mistPerSui = 1e9
+
+ netYields := make([]float64, 0, len(apy.APYs))
+ for _, entry := range apy.APYs {
+ meta := valMeta[entry.Address]
+ name := shortenName(meta.Name)
+ if name == "(unknown)" {
+ // Fall back to a truncated address so operators are
+ // distinguishable in Grafana even when metadata is missing.
+ if len(entry.Address) > 16 {
+ name = shortenName(entry.Address[:16])
+ } else {
+ name = shortenName(entry.Address)
+ }
+ }
+ id := entry.Address
+
+ grossBps := pctToBps(entry.APY)
+ // Uptime — no direct field; assume 100%.
+ uptimePct := 100.0
+ netBps := grossBps * (uptimePct / 100)
+
+ // Commission comes back as basis points already (e.g. "200" = 2%).
+ commissionBps, _ := strconv.ParseFloat(meta.CommissionRate, 64)
+ if commissionBps < 0 {
+ commissionBps = 0
+ }
+ if commissionBps > 10000 {
+ commissionBps = 10000
+ }
+
+ stakeMist, _ := strconv.ParseFloat(meta.StakingPoolSuiBal, 64)
+ stakeSUI := stakeMist / mistPerSui
+ stakeUSD := stakeSUI * suiPrice
+
+ validatorNetYieldBps.WithLabelValues(suiChain, id, name).Set(netBps)
+ validatorGrossYieldBps.WithLabelValues(suiChain, id, name).Set(grossBps)
+ validatorMevShareBps.WithLabelValues(suiChain, id, name).Set(0)
+ validatorCommissionBps.WithLabelValues(suiChain, id, name).Set(commissionBps)
+ validatorUptimePct.WithLabelValues(suiChain, id, name).Set(uptimePct)
+ if suiPrice > 0 {
+ validatorStakeUSD.WithLabelValues(suiChain, id, name).Set(stakeUSD)
+ }
+ validatorJailed.WithLabelValues(suiChain, id, name).Set(0)
+
+ netYields = append(netYields, netBps)
+ }
+
+ chainTotalValidators.WithLabelValues(suiChain).Set(float64(len(netYields)))
+ chainMedianNetYieldBps.WithLabelValues(suiChain).Set(medianBps(netYields))
+ lastScrapeTimestamp.WithLabelValues(suiChain).Set(float64(time.Now().Unix()))
+
+ fmt.Printf("[sui] published %d validators, median net yield %.0f bps, SUI=$%.4f\n",
+ len(netYields), medianBps(netYields), suiPrice)
+}
diff --git a/src/components/ledger-table.tsx b/src/components/ledger-table.tsx
index b98c53de..f06ed03f 100644
--- a/src/components/ledger-table.tsx
+++ b/src/components/ledger-table.tsx
@@ -760,6 +760,11 @@ function Row({
{r.name}
)}
+ {benchmark.providerNotes?.[r.slug] && !isMuted && (
+
+ {benchmark.providerNotes[r.slug]}
+
+ )}
{r.tag && !isMuted && (
{r.tag}
diff --git a/src/lib/materialize/load.ts b/src/lib/materialize/load.ts
index 8c19ed90..6abd6d1d 100644
--- a/src/lib/materialize/load.ts
+++ b/src/lib/materialize/load.ts
@@ -144,6 +144,7 @@ export function buildEditorial(
source: spec.source,
dimensions: spec.dimensions,
ledgerColumns: spec.ledger_columns,
+ providerNotes: spec.provider_notes,
};
}
diff --git a/src/lib/spec-schema.ts b/src/lib/spec-schema.ts
index 75535ac0..2aa96b9f 100644
--- a/src/lib/spec-schema.ts
+++ b/src/lib/spec-schema.ts
@@ -357,6 +357,20 @@ export const SpecSchema = z
})
.optional(),
+ /**
+ * Optional per-provider chip labels rendered next to the provider
+ * name in the ranking row. Keys are provider slugs, values are the
+ * short chip text (max ~20 chars so it fits inline without wrapping).
+ * Used for scan-time context on comparisons that would otherwise read
+ * as apples-to-oranges (e.g. validator-yield: Solana includes MEV,
+ * Ethereum excludes it, Hyperliquid has no MEV layer). Only providers
+ * with a note render a chip; benches without provider_notes render
+ * unchanged.
+ */
+ provider_notes: z
+ .record(slug, z.string().min(1).max(30).refine(noAiDashes, noAiDashesMsg))
+ .optional(),
+
/* Optional single PromQL returning one instant sample per
* (provider[, chain][, region]) — e.g.
* avg by (provider, chain, region) (quantile_over_time(0.50, m[24h]))
diff --git a/src/lib/spec.ts b/src/lib/spec.ts
index 86cce92b..2acbb703 100644
--- a/src/lib/spec.ts
+++ b/src/lib/spec.ts
@@ -124,6 +124,11 @@ function overlayEditorial(stored: Benchmark, spec: Spec): Benchmark {
// and the bench page filters without waiting on the materialise
// worker to rewrite the snapshot.
dimensions: spec.dimensions ?? stored.dimensions,
+ // provider_notes is a YAML editorial declaration: drives the
+ // per-provider chip rendered next to the name in the ranking row.
+ // Overlay so newly added notes surface immediately without waiting
+ // on the materialize worker to rewrite the snapshot.
+ providerNotes: spec.provider_notes ?? stored.providerNotes,
// expected_n is a YAML editorial declaration too: drives the
// sample-health badge logic on the page + the citable APIs. A
// freshly added/edited value must take effect immediately, before
diff --git a/src/types/benchmark.ts b/src/types/benchmark.ts
index 47d29a00..d61cff87 100644
--- a/src/types/benchmark.ts
+++ b/src/types/benchmark.ts
@@ -252,6 +252,13 @@ export type Benchmark = {
* read as the global chain leader). Cells make the honest claim
* ("#1 on BNB Chain from Singapore") computable. */
cellRanks?: Record;
+ /** Optional per-provider chip labels keyed by provider slug. When set,
+ * the ranking row renders a small pill next to the provider name so
+ * scan-readers see context on comparisons that would otherwise be
+ * apples-to-oranges (validator-yield: Solana "MEV inc.", Ethereum
+ * "MEV excl.", Hyperliquid "no MEV"). Absent on benches whose spec
+ * does not declare `provider_notes` — UI renders unchanged. */
+ providerNotes?: Record;
findings: string[];
methodology: string[];
source: string;
From 0f4458c5f24dbbe9e12194a0e4a0fae7ff4a4e30 Mon Sep 17 00:00:00 2001
From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com>
Date: Thu, 16 Jul 2026 17:04:37 +0200
Subject: [PATCH 37/89] rpc-capabilities: add 4 keyless providers (ThirdWeb
Blast Gateway.fm bloXroute) (#1238) (#1239)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Broadens the keyless cohort for benches 010 (rpc-capabilities), 083
(rpc-reliability) and the per-chain RPC benches. All 4 providers
verified live returning valid eth_getBlockByNumber on their
respective chains before inclusion.
New (provider, chain) cells:
ThirdWeb 8 chains (ETH POL ARB OP BASE BSC AVAX LINEA)
Blast API 4 chains (ETH ARB BASE BSC) — POL OP AVAX LINEA are 403 paid tier
bloXroute 3 chains (ETH BASE BSC)
Gateway.fm 1 chain (ETH)
Total = 16 new cells. Metrics auto-flow into bench 083 (rank_matrix
groups by provider+chain) and the per-chain leaderboards after the
Railway rebuild picks up main.
Co-authored-by: Florent Tapponnier
---
harnesses/rpc-capabilities/cmd/script/config.go | 16 ++++++++++++++++
1 file changed, 16 insertions(+)
diff --git a/harnesses/rpc-capabilities/cmd/script/config.go b/harnesses/rpc-capabilities/cmd/script/config.go
index d9c769ca..c0c46bd9 100644
--- a/harnesses/rpc-capabilities/cmd/script/config.go
+++ b/harnesses/rpc-capabilities/cmd/script/config.go
@@ -154,6 +154,10 @@ func chains() []Chain {
{Slug: "tenderly", Name: "Tenderly Gateway", URL: envDefault("RPC_URL_ETHEREUM_TENDERLY", "https://gateway.tenderly.co/public/mainnet")},
{Slug: "nodies", Name: "Nodies (POKT)", URL: envDefault("RPC_URL_ETHEREUM_NODIES", "https://eth-pokt.nodies.app")},
{Slug: "lava", Name: "Lava Network", URL: envDefault("RPC_URL_ETHEREUM_LAVA", "https://eth1.lava.build")},
+ {Slug: "blastapi", Name: "Blast API", URL: envDefault("RPC_URL_ETHEREUM_BLASTAPI", "https://eth-mainnet.public.blastapi.io")},
+ {Slug: "thirdweb", Name: "ThirdWeb", URL: envDefault("RPC_URL_ETHEREUM_THIRDWEB", "https://ethereum.rpc.thirdweb.com")},
+ {Slug: "gatewayfm", Name: "Gateway.fm", URL: envDefault("RPC_URL_ETHEREUM_GATEWAYFM", "https://rpc.eth.gateway.fm")},
+ {Slug: "bloxroute", Name: "bloXroute", URL: envDefault("RPC_URL_ETHEREUM_BLOXROUTE", "https://eth.rpc.blxrbdn.com")},
},
},
// ─── Polygon PoS (5 providers) ──────────────────────────────
@@ -165,6 +169,7 @@ func chains() []Chain {
{Slug: "drpc", Name: "dRPC", URL: envDefault("RPC_URL_POLYGON_DRPC", "https://polygon.drpc.org")},
{Slug: "tenderly", Name: "Tenderly Gateway", URL: envDefault("RPC_URL_POLYGON_TENDERLY", "https://gateway.tenderly.co/public/polygon")},
{Slug: "nodies", Name: "Nodies (POKT)", URL: envDefault("RPC_URL_POLYGON_NODIES", "https://polygon-pokt.nodies.app")},
+ {Slug: "thirdweb", Name: "ThirdWeb", URL: envDefault("RPC_URL_POLYGON_THIRDWEB", "https://polygon.rpc.thirdweb.com")},
},
},
// ─── Arbitrum One (8 providers) ─────────────────────────────
@@ -179,6 +184,8 @@ func chains() []Chain {
{Slug: "nodies", Name: "Nodies (POKT)", URL: envDefault("RPC_URL_ARBITRUM_NODIES", "https://arb-pokt.nodies.app")},
{Slug: "lava", Name: "Lava Network", URL: envDefault("RPC_URL_ARBITRUM_LAVA", "https://arb1.lava.build")},
{Slug: "arbitrum-official", Name: "Arbitrum Official", URL: envDefault("RPC_URL_ARBITRUM_OFFICIAL", "https://arb1.arbitrum.io/rpc")},
+ {Slug: "blastapi", Name: "Blast API", URL: envDefault("RPC_URL_ARBITRUM_BLASTAPI", "https://arbitrum-one.public.blastapi.io")},
+ {Slug: "thirdweb", Name: "ThirdWeb", URL: envDefault("RPC_URL_ARBITRUM_THIRDWEB", "https://arbitrum.rpc.thirdweb.com")},
},
},
// ─── Optimism (6 providers) ─────────────────────────────────
@@ -191,6 +198,7 @@ func chains() []Chain {
{Slug: "tenderly", Name: "Tenderly Gateway", URL: envDefault("RPC_URL_OPTIMISM_TENDERLY", "https://gateway.tenderly.co/public/optimism")},
{Slug: "nodies", Name: "Nodies (POKT)", URL: envDefault("RPC_URL_OPTIMISM_NODIES", "https://op-pokt.nodies.app")},
{Slug: "optimism-official", Name: "Optimism Official", URL: envDefault("RPC_URL_OPTIMISM_OFFICIAL", "https://mainnet.optimism.io")},
+ {Slug: "thirdweb", Name: "ThirdWeb", URL: envDefault("RPC_URL_OPTIMISM_THIRDWEB", "https://optimism.rpc.thirdweb.com")},
},
},
// ─── Base (6 providers) ─────────────────────────────────────
@@ -203,6 +211,9 @@ func chains() []Chain {
{Slug: "tenderly", Name: "Tenderly Gateway", URL: envDefault("RPC_URL_BASE_TENDERLY", "https://gateway.tenderly.co/public/base")},
{Slug: "nodies", Name: "Nodies (POKT)", URL: envDefault("RPC_URL_BASE_NODIES", "https://base-pokt.nodies.app")},
{Slug: "base-official", Name: "Base Official", URL: envDefault("RPC_URL_BASE_OFFICIAL", "https://mainnet.base.org")},
+ {Slug: "blastapi", Name: "Blast API", URL: envDefault("RPC_URL_BASE_BLASTAPI", "https://base-mainnet.public.blastapi.io")},
+ {Slug: "thirdweb", Name: "ThirdWeb", URL: envDefault("RPC_URL_BASE_THIRDWEB", "https://base.rpc.thirdweb.com")},
+ {Slug: "bloxroute", Name: "bloXroute", URL: envDefault("RPC_URL_BASE_BLOXROUTE", "https://base.rpc.blxrbdn.com")},
},
},
// ─── BNB Chain (5 providers) ────────────────────────────────
@@ -214,6 +225,9 @@ func chains() []Chain {
{Slug: "drpc", Name: "dRPC", URL: envDefault("RPC_URL_BNB_DRPC", "https://bsc.drpc.org")},
{Slug: "nodies", Name: "Nodies (POKT)", URL: envDefault("RPC_URL_BNB_NODIES", "https://bsc-pokt.nodies.app")},
{Slug: "binance", Name: "Binance Official", URL: envDefault("RPC_URL_BNB_OFFICIAL", "https://bsc-dataseed1.binance.org")},
+ {Slug: "blastapi", Name: "Blast API", URL: envDefault("RPC_URL_BNB_BLASTAPI", "https://bsc-mainnet.public.blastapi.io")},
+ {Slug: "thirdweb", Name: "ThirdWeb", URL: envDefault("RPC_URL_BNB_THIRDWEB", "https://bsc.rpc.thirdweb.com")},
+ {Slug: "bloxroute", Name: "bloXroute", URL: envDefault("RPC_URL_BNB_BLOXROUTE", "https://bsc.rpc.blxrbdn.com")},
},
},
// ─── Avalanche C-Chain (6 providers) ────────────────────────
@@ -227,6 +241,7 @@ func chains() []Chain {
{Slug: "drpc", Name: "dRPC", URL: envDefault("RPC_URL_AVALANCHE_DRPC", "https://avalanche.drpc.org")},
{Slug: "tenderly", Name: "Tenderly Gateway", URL: envDefault("RPC_URL_AVALANCHE_TENDERLY", "https://gateway.tenderly.co/public/avalanche")},
{Slug: "avalanche-official", Name: "Avalanche Official", URL: envDefault("RPC_URL_AVALANCHE_OFFICIAL", "https://api.avax.network/ext/bc/C/rpc")},
+ {Slug: "thirdweb", Name: "ThirdWeb", URL: envDefault("RPC_URL_AVALANCHE_THIRDWEB", "https://avalanche.rpc.thirdweb.com")},
},
},
// ─── Linea (4 providers) ────────────────────────────────────
@@ -237,6 +252,7 @@ func chains() []Chain {
{Slug: "publicnode", Name: "PublicNode", URL: envDefault("RPC_URL_LINEA_PUBLICNODE", "https://linea-rpc.publicnode.com")},
{Slug: "drpc", Name: "dRPC", URL: envDefault("RPC_URL_LINEA_DRPC", "https://linea.drpc.org")},
{Slug: "tenderly", Name: "Tenderly Gateway", URL: envDefault("RPC_URL_LINEA_TENDERLY", "https://gateway.tenderly.co/public/linea")},
+ {Slug: "thirdweb", Name: "ThirdWeb", URL: envDefault("RPC_URL_LINEA_THIRDWEB", "https://linea.rpc.thirdweb.com")},
},
},
// ─── Scroll (4 providers) ───────────────────────────────────
From 5040bcbb75ab4e17f5fda4d2b71116ff3a27e8ef Mon Sep 17 00:00:00 2001
From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com>
Date: Thu, 16 Jul 2026 17:47:46 +0200
Subject: [PATCH 38/89] fix(bench): sample-size PromQL + leader reliability
floor (#1175) (#1242)
* fix(bench): sample-size PromQL + leader reliability floor
Two bench-correctness fixes surfaced by an audit of the oracle and
gas surfaces we plan to cite in outreach.
Bench 025 (oracle-deviation):
- sample_size was 'count(count by (source) (ocb_oracle_price))'
which returns the count of distinct sources (3 or 4), not the
count of samples over the window. The citable API therefore
reported sampleSize=3-4 on every pair, at odds with the spec's
'polled every 30s' claim. Rewrites the query to
'sum(count_over_time(ocb_oracle_price[24h]))' across all 10
pair entries so the number reflects actual poll volume.
Leader ranking:
- citationCandidates() now excludes providers with a measured
success rate below 50 percent from the leader pool. Before this
fix, gas-estimation showed Owlracle as #1 with successRate=6.48
and a p50 gap of 0.001 gwei: technically accurate on the rare
successful poll but useless as a leader claim. Falls back to the
full live pool when every provider is under the floor so a
totally degraded bench still surfaces a best-of-bad-options
leader rather than vanishing. Freshness benches without a
success query default to 100 percent in the loader so the guard
is inert there.
Adds src/lib/citation.test.ts with 5 tests covering the Owlracle
scenario, higher-is-better ranking, and the degraded-pool
fallback.
* fix(bench): propagate reliability floor to rankings + template + hub
Follow-up on the previous commit. Review flagged that filtering the
leader without filtering the ranking arrays leaves self-contradictory
JSON: /api/stat can report leader=Etherscan while rankings[0]=Owlracle
on the same document.
Exports citationCandidates and adds rankedCandidates helper that
returns the same eligible pool sorted by the bench's higher_is_better
direction. leader() and fieldValue() now share it.
Wires the shared helper into every surface that emits a rankings-like
list or a best-of claim:
- src/app/api/stat/[slug]: rankings array now sorted from
rankedCandidates so rankings[0].slug always equals leader.slug on
the same JSON.
- src/app/api/llm-context: the Rankings numbered list under each
bench matches the Headline sentence above it.
- src/app/api/mcp/[transport]: get_benchmark tool response and the
openchainbench://benchmark/{slug} resource both share the pool.
- src/lib/bench-template: {{best_name}} / {{best_p50}} / {{worst_name}}
tokens in bench copy resolve against the same pool so a bench body
never crowns a provider its own headline excludes. Per-slug tokens
{{p50:some-slug}} still resolve against the raw live pool so unknown
tokens still fall through.
- src/lib/providers: rankProviders (drives /products hub wins count
and per-chain leadership chips) now runs on the reliable pool.
Adds a consistency test locking that rankedCandidates[0].slug always
matches leader().slug on the same bench. Total: 6 tests in
citation.test.ts, 95/95 pass across src/.
Uses (r.successRate ?? 100) for the numeric guard to future-proof
against ProviderResult shapes that skip the field. Load path still
defaults to 100 (materialize/load.ts:748) so this is inert today, but
the extra ?? removes the fragility.
/api/compare/[a]/[b] is intentionally left unfiltered: the user
picks the two providers explicitly, so the head-to-head is a
requested comparison rather than a leader claim. Applying the floor
there would drop rows the user explicitly asked to see.
* fix(hub-card): honor citation leader in the hub grid headline
Follow-up review flagged that benchmark-card.tsx re-sorted b.results
locally and picked results[0], so a bench where citation demotes the
raw-p50 winner (e.g. Owlracle on gas-estimation) would show the
demoted provider on the hub grid while the bench page names the
correct leader. Two documents contradicting each other in one click.
Adds leaderSlug: string | null to BenchmarkCardData and populates it
at the projection boundary via leader(b), which already applies the
reliability + insufficient-sample filter. The card picks the leader
by matching that slug in the projected results, and falls back to the
raw best when the projection returned no leader (draft, insufficient,
or all providers filtered out) so a card still renders a headline
value in every state.
Kept the local sort so the fallback path and any downstream chip
using sorted[0].name keeps working.
Test suite unchanged: 95/95 pass across src/. No test needed since
this is a projection-boundary wire-up.
---------
Co-authored-by: Florent Tapponnier
---
benchmarks/oracle-deviation.yml | 20 ++---
src/app/api/llm-context/route.ts | 11 +--
src/app/api/mcp/[transport]/route.ts | 31 +++-----
src/app/api/stat/[slug]/route.ts | 30 ++++---
src/components/benchmark-card.tsx | 12 ++-
src/data/benchmarks.ts | 10 +++
src/lib/bench-template.ts | 13 ++-
src/lib/citation.test.ts | 113 +++++++++++++++++++++++++++
src/lib/citation.ts | 66 ++++++++++++----
src/lib/providers.ts | 11 ++-
10 files changed, 247 insertions(+), 70 deletions(-)
create mode 100644 src/lib/citation.test.ts
diff --git a/benchmarks/oracle-deviation.yml b/benchmarks/oracle-deviation.yml
index 79e38f0c..6f3ed8dd 100644
--- a/benchmarks/oracle-deviation.yml
+++ b/benchmarks/oracle-deviation.yml
@@ -135,7 +135,7 @@ providers:
p99: quantile_over_time(0.99, ocb_oracle_max_deviation_pct{pair="BTC/USD"}[24h]) * 100
mean: avg_over_time(ocb_oracle_max_deviation_pct{pair="BTC/USD"}[24h]) * 100
success: clamp_min(clamp_max(1 - sum(rate(ocb_oracle_scrape_errors_total{pair="BTC/USD"}[24h])) / (4 * (1/30)), 1), 0)
- sample_size: count(count by (source) (ocb_oracle_price{pair="BTC/USD"}))
+ sample_size: sum(count_over_time(ocb_oracle_price{pair="BTC/USD"}[24h]))
series: ocb_oracle_max_deviation_pct{pair="BTC/USD"} * 100
- slug: eth-usd
@@ -148,7 +148,7 @@ providers:
p99: quantile_over_time(0.99, ocb_oracle_max_deviation_pct{pair="ETH/USD"}[24h]) * 100
mean: avg_over_time(ocb_oracle_max_deviation_pct{pair="ETH/USD"}[24h]) * 100
success: clamp_min(clamp_max(1 - sum(rate(ocb_oracle_scrape_errors_total{pair="ETH/USD"}[24h])) / (4 * (1/30)), 1), 0)
- sample_size: count(count by (source) (ocb_oracle_price{pair="ETH/USD"}))
+ sample_size: sum(count_over_time(ocb_oracle_price{pair="ETH/USD"}[24h]))
series: ocb_oracle_max_deviation_pct{pair="ETH/USD"} * 100
- slug: sol-usd
@@ -161,7 +161,7 @@ providers:
p99: quantile_over_time(0.99, ocb_oracle_max_deviation_pct{pair="SOL/USD"}[24h]) * 100
mean: avg_over_time(ocb_oracle_max_deviation_pct{pair="SOL/USD"}[24h]) * 100
success: clamp_min(clamp_max(1 - sum(rate(ocb_oracle_scrape_errors_total{pair="SOL/USD"}[24h])) / (4 * (1/30)), 1), 0)
- sample_size: count(count by (source) (ocb_oracle_price{pair="SOL/USD"}))
+ sample_size: sum(count_over_time(ocb_oracle_price{pair="SOL/USD"}[24h]))
series: ocb_oracle_max_deviation_pct{pair="SOL/USD"} * 100
- slug: bnb-usd
@@ -174,7 +174,7 @@ providers:
p99: quantile_over_time(0.99, ocb_oracle_max_deviation_pct{pair="BNB/USD"}[24h]) * 100
mean: avg_over_time(ocb_oracle_max_deviation_pct{pair="BNB/USD"}[24h]) * 100
success: clamp_min(clamp_max(1 - sum(rate(ocb_oracle_scrape_errors_total{pair="BNB/USD"}[24h])) / (4 * (1/30)), 1), 0)
- sample_size: count(count by (source) (ocb_oracle_price{pair="BNB/USD"}))
+ sample_size: sum(count_over_time(ocb_oracle_price{pair="BNB/USD"}[24h]))
series: ocb_oracle_max_deviation_pct{pair="BNB/USD"} * 100
- slug: avax-usd
@@ -187,7 +187,7 @@ providers:
p99: quantile_over_time(0.99, ocb_oracle_max_deviation_pct{pair="AVAX/USD"}[24h]) * 100
mean: avg_over_time(ocb_oracle_max_deviation_pct{pair="AVAX/USD"}[24h]) * 100
success: clamp_min(clamp_max(1 - sum(rate(ocb_oracle_scrape_errors_total{pair="AVAX/USD"}[24h])) / (4 * (1/30)), 1), 0)
- sample_size: count(count by (source) (ocb_oracle_price{pair="AVAX/USD"}))
+ sample_size: sum(count_over_time(ocb_oracle_price{pair="AVAX/USD"}[24h]))
series: ocb_oracle_max_deviation_pct{pair="AVAX/USD"} * 100
- slug: link-usd
@@ -200,7 +200,7 @@ providers:
p99: quantile_over_time(0.99, ocb_oracle_max_deviation_pct{pair="LINK/USD"}[24h]) * 100
mean: avg_over_time(ocb_oracle_max_deviation_pct{pair="LINK/USD"}[24h]) * 100
success: clamp_min(clamp_max(1 - sum(rate(ocb_oracle_scrape_errors_total{pair="LINK/USD"}[24h])) / (4 * (1/30)), 1), 0)
- sample_size: count(count by (source) (ocb_oracle_price{pair="LINK/USD"}))
+ sample_size: sum(count_over_time(ocb_oracle_price{pair="LINK/USD"}[24h]))
series: ocb_oracle_max_deviation_pct{pair="LINK/USD"} * 100
- slug: matic-usd
@@ -213,7 +213,7 @@ providers:
p99: quantile_over_time(0.99, ocb_oracle_max_deviation_pct{pair="MATIC/USD"}[24h]) * 100
mean: avg_over_time(ocb_oracle_max_deviation_pct{pair="MATIC/USD"}[24h]) * 100
success: clamp_min(clamp_max(1 - sum(rate(ocb_oracle_scrape_errors_total{pair="MATIC/USD"}[24h])) / (4 * (1/30)), 1), 0)
- sample_size: count(count by (source) (ocb_oracle_price{pair="MATIC/USD"}))
+ sample_size: sum(count_over_time(ocb_oracle_price{pair="MATIC/USD"}[24h]))
series: ocb_oracle_max_deviation_pct{pair="MATIC/USD"} * 100
- slug: xrp-usd
@@ -226,7 +226,7 @@ providers:
p99: quantile_over_time(0.99, ocb_oracle_max_deviation_pct{pair="XRP/USD"}[24h]) * 100
mean: avg_over_time(ocb_oracle_max_deviation_pct{pair="XRP/USD"}[24h]) * 100
success: clamp_min(clamp_max(1 - sum(rate(ocb_oracle_scrape_errors_total{pair="XRP/USD"}[24h])) / (3 * (1/30)), 1), 0)
- sample_size: count(count by (source) (ocb_oracle_price{pair="XRP/USD"}))
+ sample_size: sum(count_over_time(ocb_oracle_price{pair="XRP/USD"}[24h]))
series: ocb_oracle_max_deviation_pct{pair="XRP/USD"} * 100
- slug: ada-usd
@@ -239,7 +239,7 @@ providers:
p99: quantile_over_time(0.99, ocb_oracle_max_deviation_pct{pair="ADA/USD"}[24h]) * 100
mean: avg_over_time(ocb_oracle_max_deviation_pct{pair="ADA/USD"}[24h]) * 100
success: clamp_min(clamp_max(1 - sum(rate(ocb_oracle_scrape_errors_total{pair="ADA/USD"}[24h])) / (3 * (1/30)), 1), 0)
- sample_size: count(count by (source) (ocb_oracle_price{pair="ADA/USD"}))
+ sample_size: sum(count_over_time(ocb_oracle_price{pair="ADA/USD"}[24h]))
series: ocb_oracle_max_deviation_pct{pair="ADA/USD"} * 100
- slug: doge-usd
@@ -252,5 +252,5 @@ providers:
p99: quantile_over_time(0.99, ocb_oracle_max_deviation_pct{pair="DOGE/USD"}[24h]) * 100
mean: avg_over_time(ocb_oracle_max_deviation_pct{pair="DOGE/USD"}[24h]) * 100
success: clamp_min(clamp_max(1 - sum(rate(ocb_oracle_scrape_errors_total{pair="DOGE/USD"}[24h])) / (3 * (1/30)), 1), 0)
- sample_size: count(count by (source) (ocb_oracle_price{pair="DOGE/USD"}))
+ sample_size: sum(count_over_time(ocb_oracle_price{pair="DOGE/USD"}[24h]))
series: ocb_oracle_max_deviation_pct{pair="DOGE/USD"} * 100
diff --git a/src/app/api/llm-context/route.ts b/src/app/api/llm-context/route.ts
index c6617f49..27aada6f 100644
--- a/src/app/api/llm-context/route.ts
+++ b/src/app/api/llm-context/route.ts
@@ -7,6 +7,7 @@ import {
headlineSentence,
isInsufficient,
leader,
+ rankedCandidates,
} from "@/lib/citation";
import { clientKey, rateLimit, tooManyRequests } from "@/lib/rate-limit";
@@ -84,11 +85,11 @@ export async function GET(req: Request) {
lines.push(`- Headline: ${headlineSentence(b)}`);
lines.push("");
lines.push(`**Rankings (p50, 24h):**`);
- const ranked = [...b.results]
- .filter((r) => r.ms.p50 > 0)
- .sort((a, c) =>
- b.higherIsBetter ? c.ms.p50 - a.ms.p50 : a.ms.p50 - c.ms.p50,
- );
+ // Shares `rankedCandidates` with `leader()` so the numbered list
+ // below matches the Headline sentence above. Without the shared
+ // filter, an LLM pasting this block would see e.g. "Etherscan
+ // leads" then a rankings list with Owlracle at #1.
+ const ranked = rankedCandidates(b);
for (let i = 0; i < ranked.length; i++) {
const r = ranked[i];
lines.push(
diff --git a/src/app/api/mcp/[transport]/route.ts b/src/app/api/mcp/[transport]/route.ts
index 4371f45a..e9461cb6 100644
--- a/src/app/api/mcp/[transport]/route.ts
+++ b/src/app/api/mcp/[transport]/route.ts
@@ -10,6 +10,7 @@ import {
headlineSentence,
isInsufficient,
leader,
+ rankedCandidates,
sparklineFor,
} from "@/lib/citation";
import { fmtUnit } from "@/lib/format";
@@ -297,17 +298,12 @@ const mcpHandler = createMcpHandler(
ms: { p50: null, p90: null, p99: null, mean: null },
successRate: r.successRate,
}))
- : b.results
- .filter((r) => r.ms.p50 > 0)
- .sort((a, c) =>
- b.higherIsBetter ? c.ms.p50 - a.ms.p50 : a.ms.p50 - c.ms.p50,
- )
- .map((r) => ({
- name: r.name,
- slug: r.slug,
- ms: r.ms,
- successRate: r.successRate,
- }));
+ : rankedCandidates(b).map((r) => ({
+ name: r.name,
+ slug: r.slug,
+ ms: r.ms,
+ successRate: r.successRate,
+ }));
const payload = {
slug: b.slug,
title: b.title,
@@ -480,13 +476,12 @@ const mcpHandler = createMcpHandler(
}
const insufficient = isInsufficient(b);
const top = insufficient ? null : leader(b);
- const ranked = insufficient
- ? []
- : b.results
- .filter((r) => r.ms.p50 > 0)
- .sort((a, c) =>
- b.higherIsBetter ? c.ms.p50 - a.ms.p50 : a.ms.p50 - c.ms.p50,
- );
+ // Shares `rankedCandidates` with `leader()` so the Markdown
+ // Rankings list matches the Headline sentence above and the
+ // `rankings` field on the JSON tool response below. Without
+ // this, an agent reading this resource would see e.g.
+ // "Etherscan leads" then a numbered list with Owlracle at #1.
+ const ranked = insufficient ? [] : rankedCandidates(b);
const md: string[] = [];
md.push(`# ${b.title}`);
diff --git a/src/app/api/stat/[slug]/route.ts b/src/app/api/stat/[slug]/route.ts
index a75f11d4..49efc2da 100644
--- a/src/app/api/stat/[slug]/route.ts
+++ b/src/app/api/stat/[slug]/route.ts
@@ -8,6 +8,7 @@ import {
fieldValue,
headlineSentence,
leader,
+ rankedCandidates,
sparklineFor,
} from "@/lib/citation";
import { valueInDeclaredUnit } from "@/lib/format";
@@ -98,22 +99,19 @@ export async function GET(
insufficient || !top
? null
: { ...top, value: valueInDeclaredUnit(top.value, b.unit) },
- rankings: b.results
- .filter((r) => r.ms.p50 > 0)
- // Drop "insufficient" rows from the machine-readable ranking too:
- // a row that the page hides from the leaderboard must not surface
- // here either.
- .filter((r) => r.dataConfidence !== "insufficient")
- .sort((a, c) => (b.higherIsBetter ? c.ms.p50 - a.ms.p50 : a.ms.p50 - c.ms.p50))
- .map((r) => ({
- name: r.name,
- slug: r.slug,
- ms: r.ms,
- successRate: r.successRate,
- sampleSize: r.sampleSize,
- sampleHealth: r.sampleHealth,
- dataConfidence: r.dataConfidence,
- })),
+ // Shares `rankedCandidates` with `leader()` so `rankings[0]`
+ // stays consistent with the `leader` field on the same JSON blob:
+ // a document that names Etherscan as leader must not also list
+ // Owlracle first here.
+ rankings: rankedCandidates(b).map((r) => ({
+ name: r.name,
+ slug: r.slug,
+ ms: r.ms,
+ successRate: r.successRate,
+ sampleSize: r.sampleSize,
+ sampleHealth: r.sampleHealth,
+ dataConfidence: r.dataConfidence,
+ })),
sparkline: sparklineFor(b, top?.slug),
sampleSize: b.sampleSize,
expectedN: b.expectedN,
diff --git a/src/components/benchmark-card.tsx b/src/components/benchmark-card.tsx
index 6a03581f..8ec09662 100644
--- a/src/components/benchmark-card.tsx
+++ b/src/components/benchmark-card.tsx
@@ -27,12 +27,18 @@ export function BenchmarkCard({ benchmark }: { benchmark: BenchmarkCardData }) {
const insufficient = !isDraft && isInsufficient(b);
const catColor = CATEGORY_COLOR[b.category] ?? "var(--color-ink-muted)";
- // Field composite p50: best-of-class (or worst if higher-is-better is
- // false). We pick the leader, same heuristic as the table.
+ // Prefer the canonical leader from citation.ts (reliability floor +
+ // insufficient-sample filter applied at projection). Falls back to
+ // the raw best of the projected results when the projection never
+ // returned a leader (draft, insufficient, or every provider filtered
+ // out) so the card still surfaces a headline value instead of "n/a"
+ // whenever any result is present.
const sorted = [...b.results].sort(
b.higherIsBetter ? (a, x) => x.ms.p50 - a.ms.p50 : (a, x) => a.ms.p50 - x.ms.p50,
);
- const leader = sorted[0];
+ const leader = b.leaderSlug
+ ? (sorted.find((r) => r.slug === b.leaderSlug) ?? sorted[0])
+ : sorted[0];
const headlineValue =
!isDraft && !insufficient && leader ? fmtValue(leader.ms.p50, b.unit) : "n/a";
// Pass the leader's p50 so unitSuffix mirrors fmtUnit's auto-conversion
diff --git a/src/data/benchmarks.ts b/src/data/benchmarks.ts
index 96cbb6e5..7aa14f07 100644
--- a/src/data/benchmarks.ts
+++ b/src/data/benchmarks.ts
@@ -9,6 +9,7 @@ import { promises as fs } from "node:fs";
import path from "node:path";
import yaml from "js-yaml";
import { cache } from "react";
+import { leader } from "@/lib/citation";
import { downsample, MINI_CHART_POINTS } from "@/lib/downsample";
import type { Benchmark } from "@/types/benchmark";
import {
@@ -51,6 +52,14 @@ export type BenchmarkCardData = {
sampleSize: number;
lastRunAt: string;
metric: string;
+ /** Slug of the citation-eligible leader for this bench (result of
+ * `leader(b)` in citation.ts). Passed at the projection boundary so
+ * the hub card can render the same headline the bench page names
+ * without duplicating the reliability + insufficient-sample filter
+ * logic. Null when the bench has no defensible leader (draft,
+ * insufficient, or every provider below the reliability floor with
+ * an empty live pool). */
+ leaderSlug: string | null;
results: {
slug: string;
name: string;
@@ -76,6 +85,7 @@ export function toBenchmarkCardData(b: Benchmark): BenchmarkCardData {
sampleSize: b.sampleSize,
lastRunAt: b.lastRunAt,
metric: b.metric,
+ leaderSlug: leader(b)?.slug ?? null,
results: b.results.map((r) => ({
slug: r.slug,
name: r.name,
diff --git a/src/lib/bench-template.ts b/src/lib/bench-template.ts
index f36e4300..f1addc5f 100644
--- a/src/lib/bench-template.ts
+++ b/src/lib/bench-template.ts
@@ -38,6 +38,7 @@
import type { Benchmark, ProviderResult } from "@/types/benchmark";
import { liveResults } from "@/lib/provider-filters";
+import { citationCandidates } from "@/lib/citation";
import { rankResults } from "@/lib/ranking";
import { fmtUnit } from "@/lib/format";
@@ -87,8 +88,18 @@ function worstForChain(b: Benchmark, chain: string): ProviderResult | undefined
export function renderTemplate(text: string, benchmark: Benchmark): string {
if (!text || text.indexOf("{{") === -1) return text;
+ // `live` still drives per-slug lookups so callers of {{p50:some-slug}}
+ // can still address unreliable providers by name (the token is
+ // explicit). `bestPool` applies the same reliability floor as
+ // `leader()` so {{best_name}} and {{best_p50}} tokens in bench copy
+ // never elevate a provider that would be filtered out of the
+ // citation headline.
const live = liveResults(benchmark.results);
- const sorted = rankResults(live, benchmark.higherIsBetter);
+ const bestPool = citationCandidates(benchmark);
+ const sorted = rankResults(
+ bestPool.length > 0 ? bestPool : live,
+ benchmark.higherIsBetter,
+ );
const best = sorted[0];
const worst = sorted[sorted.length - 1];
diff --git a/src/lib/citation.test.ts b/src/lib/citation.test.ts
new file mode 100644
index 00000000..376995f8
--- /dev/null
+++ b/src/lib/citation.test.ts
@@ -0,0 +1,113 @@
+import { describe, expect, test } from "bun:test";
+import { leader, fieldValue, rankedCandidates } from "./citation";
+import type { Benchmark, ProviderResult } from "@/types/benchmark";
+
+function r(
+ slug: string,
+ name: string,
+ p50: number,
+ successRate = 100,
+): ProviderResult {
+ return {
+ slug,
+ name,
+ ms: { p50, p90: p50, p99: p50, mean: p50 },
+ successRate,
+ availability: "live",
+ };
+}
+
+function bench(results: ProviderResult[]): Benchmark {
+ return {
+ slug: "test",
+ number: "001",
+ title: "Test bench",
+ subtitle: "",
+ lastRunAt: "2026-07-14T00:00:00.000Z",
+ status: "live",
+ editorialStatus: "live",
+ sampleSize: 100,
+ abstract: "",
+ metric: "Latency",
+ unit: "ms",
+ higherIsBetter: false,
+ category: "RPCs",
+ results,
+ findings: [],
+ methodology: [],
+ source: "",
+ extras: { series24h: {}, regions: {} },
+ };
+}
+
+describe("citation reliability threshold", () => {
+ test("leader excludes providers with success rate below 50 percent", () => {
+ // Owlracle scenario: technically most accurate (lowest p50 gap) but
+ // fails 93 percent of calls. Etherscan should win despite a higher p50.
+ const b = bench([
+ r("owlracle", "Owlracle", 0.001, 6.48),
+ r("etherscan", "Etherscan", 1.0, 95.7),
+ r("publicnode", "PublicNode", 1.5, 99.99),
+ ]);
+ const top = leader(b);
+ expect(top?.slug).toBe("etherscan");
+ expect(top?.value).toBe(1.0);
+ });
+
+ test("fieldValue reflects the reliability-filtered leader", () => {
+ const b = bench([
+ r("owlracle", "Owlracle", 0.001, 6.48),
+ r("etherscan", "Etherscan", 1.0, 95.7),
+ ]);
+ expect(fieldValue(b)).toBe(1.0);
+ });
+
+ test("falls back to the full live pool when every provider is unreliable", () => {
+ // Bench in a totally degraded state: rather than vanishing from
+ // downstream surfaces, surface the least-bad provider so readers
+ // still see a live number with the caveats their spec already
+ // documents.
+ const b = bench([
+ r("a", "A", 5, 20),
+ r("b", "B", 10, 30),
+ ]);
+ const top = leader(b);
+ expect(top?.slug).toBe("a");
+ });
+
+ test("providers with the default 100 percent success rate pass through", () => {
+ // Freshness / gauge-only benches never emit a `success` query;
+ // the loader defaults to 100 percent, so the guard is inert.
+ const b = bench([r("a", "A", 100), r("b", "B", 200)]);
+ expect(leader(b)?.slug).toBe("a");
+ });
+
+ test("higher-is-better ranks the correct reliable leader", () => {
+ const b = bench([
+ r("high-but-flaky", "Flaky", 999, 10),
+ r("modest-reliable", "Reliable", 50, 100),
+ r("mid-reliable", "Mid", 80, 100),
+ ]);
+ b.higherIsBetter = true;
+ // higherIsBetter=true reverses sort so the biggest p50 wins.
+ // Flaky wins on raw value but is filtered out; Mid (80) wins the
+ // reliable pool.
+ expect(leader(b)?.slug).toBe("mid-reliable");
+ });
+
+ test("rankedCandidates[0] matches leader for the same bench", () => {
+ // Locks the invariant that downstream surfaces (/api/stat rankings,
+ // llm-context, MCP resource) can share `rankedCandidates` with
+ // `leader()` and never emit a document where the "leader" field
+ // contradicts the first entry of the "rankings" list.
+ const b = bench([
+ r("owlracle", "Owlracle", 0.001, 6.48),
+ r("etherscan", "Etherscan", 1.0, 95.7),
+ r("publicnode", "PublicNode", 1.5, 99.99),
+ ]);
+ const top = leader(b);
+ const ranks = rankedCandidates(b);
+ expect(top?.slug).toBe(ranks[0].slug);
+ expect(top?.value).toBe(ranks[0].ms.p50);
+ });
+});
diff --git a/src/lib/citation.ts b/src/lib/citation.ts
index ab2ed58c..09062a88 100644
--- a/src/lib/citation.ts
+++ b/src/lib/citation.ts
@@ -8,16 +8,56 @@ import type { Benchmark, ProviderResult } from "@/types/benchmark";
import { liveResults } from "@/lib/provider-filters";
import { fmtUnit } from "@/lib/format";
+/** Minimum measured success rate (in percent, 0-100) for a provider to
+ * contribute to the headline leader claim. Providers with a real
+ * success measurement below this floor are excluded from citation
+ * candidates because an "unreliable but accurate when it works"
+ * outlier should not top the leaderboard: it misleads AI agents citing
+ * the bench and any human reader glancing at the headline. Benches
+ * whose harness does not emit a `success` query default to 100 in the
+ * load path (see materialize/load.ts), so this guard is a no-op for
+ * freshness / gauge-only benches and only bites where the harness
+ * actually measures polling reliability (gas-estimation, RPC
+ * benches). */
+const LEADER_MIN_SUCCESS_PCT = 50;
+
/** Provider set used to derive the headline figures. Drops rows whose
* per-provider sample-health is "insufficient" (set on the load path
* when the bench declares expected_n and the row falls below the 10
- * percent of expected floor). Those rows can still render in some
- * surfaces with a soft tag, but they must not contribute to the leader
- * claim shipped to AI agents and journalists via the citable APIs. */
-function citationCandidates(b: Benchmark): ProviderResult[] {
+ * percent of expected floor) and rows whose measured success rate
+ * sits below the reliability floor. Those rows can still render in
+ * some surfaces with a soft tag, but they must not contribute to the
+ * leader claim shipped to AI agents and journalists via the citable
+ * APIs. Falls back to the full live pool when every provider is
+ * below the reliability floor so a totally-degraded bench still
+ * reports a best-of-bad-options leader instead of vanishing.
+ *
+ * Exported so downstream machine-readable surfaces (`/api/stat`
+ * rankings, `/api/llm-context`, MCP `get_benchmark`, `/api/compare`)
+ * can share the same eligibility rule as `leader()` and stay
+ * internally consistent: a JSON that names Etherscan as leader
+ * should not simultaneously rank Owlracle first in its `rankings`
+ * array. */
+export function citationCandidates(b: Benchmark): ProviderResult[] {
const live = liveResults(b.results);
- if (!b.expectedN) return live;
- return live.filter((r) => r.dataConfidence !== "insufficient");
+ const reliable = live.filter(
+ (r) => (r.successRate ?? 100) >= LEADER_MIN_SUCCESS_PCT,
+ );
+ const pool = reliable.length > 0 ? reliable : live;
+ if (!b.expectedN) return pool;
+ return pool.filter((r) => r.dataConfidence !== "insufficient");
+}
+
+/** Sorted candidate pool for the machine-readable `rankings` array on
+ * `/api/stat`, MCP, llm-context and any downstream that ranks the
+ * full field. Applies the same reliability + insufficient-sample
+ * filters as `leader()` so a document that names X as leader ranks X
+ * first in its own list. Sort direction honors the bench's
+ * `higherIsBetter` flag. */
+export function rankedCandidates(b: Benchmark): ProviderResult[] {
+ return [...citationCandidates(b)].sort((a, c) =>
+ b.higherIsBetter ? c.ms.p50 - a.ms.p50 : a.ms.p50 - c.ms.p50,
+ );
}
/** Timestamp of the last real measurement, or null when the bench has
@@ -42,11 +82,8 @@ export function fieldValue(b: Benchmark): number | null {
// (downstream LLM tools and SERP snippets would otherwise quote a
// number drawn from a wildly undersized field).
if (b.dataConfidence === "insufficient") return null;
- const candidates = citationCandidates(b);
- if (candidates.length === 0) return null;
- const sorted = [...candidates].sort((a, c) =>
- b.higherIsBetter ? c.ms.p50 - a.ms.p50 : a.ms.p50 - c.ms.p50
- );
+ const sorted = rankedCandidates(b);
+ if (sorted.length === 0) return null;
return sorted[0].ms.p50;
}
@@ -54,11 +91,8 @@ export function fieldValue(b: Benchmark): number | null {
export function leader(b: Benchmark): { name: string; slug: string; value: number } | null {
if (b.status !== "live") return null;
if (b.dataConfidence === "insufficient") return null;
- const candidates = citationCandidates(b);
- if (candidates.length === 0) return null;
- const sorted = [...candidates].sort((a, c) =>
- b.higherIsBetter ? c.ms.p50 - a.ms.p50 : a.ms.p50 - c.ms.p50
- );
+ const sorted = rankedCandidates(b);
+ if (sorted.length === 0) return null;
return { name: sorted[0].name, slug: sorted[0].slug, value: sorted[0].ms.p50 };
}
diff --git a/src/lib/providers.ts b/src/lib/providers.ts
index acd98747..01043029 100644
--- a/src/lib/providers.ts
+++ b/src/lib/providers.ts
@@ -12,6 +12,7 @@ import { cache } from "react";
import { unstable_cache } from "next/cache";
import { getBenchmarksSafe } from "@/data/benchmarks";
import { liveResults } from "@/lib/provider-filters";
+import { citationCandidates } from "@/lib/citation";
import { readBestPerChain } from "@/lib/per-chain-contract";
import type { Benchmark, ProviderResult } from "@/types/benchmark";
@@ -221,7 +222,15 @@ export type ProviderProfile = {
};
function rankProviders(b: Benchmark): ProviderResult[] {
- const live = liveResults(b.results);
+ // Drives the /products hub `wins` count and per-chain leadership
+ // chips. Shares the reliability + insufficient-sample filters with
+ // `leader()` (citation.ts) so a provider that is filtered from the
+ // bench headline never gets credited as a bench winner on its
+ // product page. Falls back to the raw live pool when every
+ // candidate is filtered so a totally degraded bench still surfaces
+ // a best-of-bad-options ranking.
+ const pool = citationCandidates(b);
+ const live = pool.length > 0 ? pool : liveResults(b.results);
return [...live].sort((a, c) =>
b.higherIsBetter ? c.ms.p50 - a.ms.p50 : a.ms.p50 - c.ms.p50,
);
From ce2e22e915768eca84046b0024f15c4ee0b9ae8f Mon Sep 17 00:00:00 2001
From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com>
Date: Thu, 16 Jul 2026 17:48:39 +0200
Subject: [PATCH 39/89] ws-head-latency: add Onfinality Ethereum to keyless
cohort (#1241) (#1243)
Co-authored-by: Florent Tapponnier
---
benchmarks/ws-head-latency-ethereum.yml | 27 +++++++++++++++++--
.../ws-head-latency/cmd/script/config.go | 1 +
2 files changed, 26 insertions(+), 2 deletions(-)
diff --git a/benchmarks/ws-head-latency-ethereum.yml b/benchmarks/ws-head-latency-ethereum.yml
index 68f8f4a4..e79078c8 100644
--- a/benchmarks/ws-head-latency-ethereum.yml
+++ b/benchmarks/ws-head-latency-ethereum.yml
@@ -46,8 +46,8 @@ abstract: |
`slotSubscribe` slot notifications.
methodology:
- - "Providers measured (keyless WebSocket endpoints): PublicNode (wss://ethereum-rpc.publicnode.com), dRPC (wss://eth.drpc.org), Tenderly (wss://mainnet.gateway.tenderly.co). Keyed providers (Alchemy, Infura, Chainstack, QuickNode) join the cohort when contributor keys are wired via WS_URL__ETHEREUM env vars; the harness skips them cleanly when unset."
- - "Cohort saturation: Ankr, Blast, LlamaRPC, BlockPI, 1RPC, NodeReal and Omniatech all reject a keyless WebSocket handshake at the time of ship (verified 2026-07), which is why the keyless tier stops at three."
+ - "Providers measured (keyless WebSocket endpoints): PublicNode (wss://ethereum-rpc.publicnode.com), dRPC (wss://eth.drpc.org), Tenderly (wss://mainnet.gateway.tenderly.co), Onfinality (wss://eth.api.onfinality.io/public-ws). Keyed providers (Alchemy, Infura, Chainstack, QuickNode) join the cohort when contributor keys are wired via WS_URL__ETHEREUM env vars; the harness skips them cleanly when unset."
+ - "Cohort saturation: Ankr, Blast, LlamaRPC, BlockPI, 1RPC, NodeReal and Omniatech all reject a keyless WebSocket handshake at the time of ship (verified 2026-07), which is why the keyless tier stops at four (PublicNode, dRPC, Tenderly, Onfinality)."
- "Chains: Ethereum (headline, all three providers compete), Base via newHeads and Solana via slotSubscribe as secondary cohorts. Headline queries on this page are pinned to chain=ethereum so the ranking never mixes block cadences."
- "Race scoring: the first provider to deliver block N sets T0. Each provider's sample for block N is arrival(N) minus T0 in milliseconds. The cohort closes 5 seconds after the first arrival; a provider arriving later than that is scored as a missed block (ws_block_gap_total), not as a huge latency sample."
- "Relative, not absolute: a single vantage point cannot separate its own network path from provider pipeline time, so we subtract the two arrivals over the same path instead. Consequence: the fastest provider reads ~0 by construction, and the honest readings are win rate, the trailers' lag distribution, and gap counts, not the leader's absolute number."
@@ -168,3 +168,26 @@ providers:
- region: ap-southeast
p50: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="tenderly", chain="ethereum", region="sgp"}[24h])))
series: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="tenderly", chain="ethereum", region="sgp"}[1h])))
+
+ - slug: onfinality
+ name: OnFinality
+ tag: Keyless WS, wss://eth.api.onfinality.io/public-ws
+ formula: "Median ms behind the earliest provider to push each Ethereum newHeads frame, from one persistent WebSocket per provider on the same eu-west host, histogram_quantile over 24h. Lag is relative to the per-block winner."
+ queries:
+ p50: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="onfinality", chain="ethereum"}[24h])))
+ p90: histogram_quantile(0.90, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="onfinality", chain="ethereum"}[24h])))
+ p99: histogram_quantile(0.99, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="onfinality", chain="ethereum"}[24h])))
+ mean: sum(rate(ws_head_lag_milliseconds_sum{provider="onfinality", chain="ethereum"}[24h])) / sum(rate(ws_head_lag_milliseconds_count{provider="onfinality", chain="ethereum"}[24h]))
+ success: clamp_max(sum(increase(ws_head_blocks_seen_total{provider="onfinality", chain="ethereum"}[24h])) / max(sum by (provider) (increase(ws_head_blocks_seen_total{chain="ethereum"}[24h]))), 1)
+ sample_size: sum(increase(ws_head_lag_milliseconds_count{provider="onfinality", chain="ethereum"}[24h]))
+ series: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="onfinality", chain="ethereum"}[1h])))
+ regions:
+ - region: us-east
+ p50: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="onfinality", chain="ethereum", region="us-east"}[24h])))
+ series: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="onfinality", chain="ethereum", region="us-east"}[1h])))
+ - region: eu-west
+ p50: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="onfinality", chain="ethereum", region="eu-west"}[24h])))
+ series: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="onfinality", chain="ethereum", region="eu-west"}[1h])))
+ - region: ap-southeast
+ p50: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="onfinality", chain="ethereum", region="sgp"}[24h])))
+ series: histogram_quantile(0.50, sum by (le) (rate(ws_head_lag_milliseconds_bucket{provider="onfinality", chain="ethereum", region="sgp"}[1h])))
diff --git a/harnesses/ws-head-latency/cmd/script/config.go b/harnesses/ws-head-latency/cmd/script/config.go
index f3d5a7cb..15ad5b85 100644
--- a/harnesses/ws-head-latency/cmd/script/config.go
+++ b/harnesses/ws-head-latency/cmd/script/config.go
@@ -38,6 +38,7 @@ func endpoints() []Endpoint {
{Provider: "drpc", Chain: "base", Kind: "evm", URL: envDefault("WS_URL_DRPC_BASE", "wss://base.drpc.org")},
{Provider: "drpc", Chain: "solana", Kind: "solana", URL: envDefault("WS_URL_DRPC_SOLANA", "wss://solana.drpc.org")},
{Provider: "tenderly", Chain: "ethereum", Kind: "evm", URL: envDefault("WS_URL_TENDERLY_ETHEREUM", "wss://mainnet.gateway.tenderly.co")},
+ {Provider: "onfinality", Chain: "ethereum", Kind: "evm", URL: envDefault("WS_URL_ONFINALITY_ETHEREUM", "wss://eth.api.onfinality.io/public-ws")},
{Provider: "solana-labs", Chain: "solana", Kind: "solana", URL: envDefault("WS_URL_SOLANALABS_SOLANA", "wss://api.mainnet-beta.solana.com")},
}
// Keyed providers, skipped when the env var is unset. URLs carry the
From 23d8c420a71e9d6f0e6dc9501b1a4c672109e94c Mon Sep 17 00:00:00 2001
From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com>
Date: Thu, 16 Jul 2026 18:01:49 +0200
Subject: [PATCH 40/89] ship: add 080 + 083 spec files to main + ungate + fix
085 file-missing (#1244)
3 dev-only specs copied to main. Previously ungated in earlier PRs
but the file itself was never cherry-picked, so prod returned 404 or
410 despite the REMOVED_BENCH_SLUGS gate being lifted.
* tokenized-stock-arb-latency.yml (080): cohort restricted to 6
liquid tickers (NVDA AAPL PLTR META AMD MU) after 5 illiquid
dropped; harness fix (25 bps trigger + sub-poll capture) already
on main. Removed from REMOVED_BENCH_SLUGS.
* rpc-reliability.yml (083): data healthy (Nodies leader, sample
555, conf=healthy after Railway rebuild of rpc-capabilities +
4 new keyless providers). Removed from REMOVED_BENCH_SLUGS.
* evm-block-builders.yml (085): ungated in v32 but file was never
added to main (bug in earlier ship PR #1228). File now present.
Cache keys bumped bench-unfiltered-v33 + all-benchmarks-v36 so
cached v32/v35 entries pick up the 3 new slugs.
Co-authored-by: Florent Tapponnier
---
benchmarks/evm-block-builders.yml | 232 ++++++++++++++++
benchmarks/rpc-reliability.yml | 297 +++++++++++++++++++++
benchmarks/tokenized-stock-arb-latency.yml | 144 ++++++++++
src/lib/removed-benches.ts | 16 +-
src/lib/spec.ts | 11 +-
5 files changed, 690 insertions(+), 10 deletions(-)
create mode 100644 benchmarks/evm-block-builders.yml
create mode 100644 benchmarks/rpc-reliability.yml
create mode 100644 benchmarks/tokenized-stock-arb-latency.yml
diff --git a/benchmarks/evm-block-builders.yml b/benchmarks/evm-block-builders.yml
new file mode 100644
index 00000000..26651f6b
--- /dev/null
+++ b/benchmarks/evm-block-builders.yml
@@ -0,0 +1,232 @@
+# OpenChainBench. Bench № 085
+
+slug: evm-block-builders
+number: "085"
+title: Ethereum block builder market share
+seo_title: "Ethereum block builders benchmark 2026, live builder market share"
+seo_description: "Live benchmark of who builds Ethereum blocks. Builder market share attributed via block extraData, cross-checked against MEV-Boost relay bidtraces."
+subtitle: Share of Ethereum mainnet blocks attributed to each block builder via the extraData self-label, cross-checked against relay bidtraces. Observational, no transactions sent.
+category: Trading
+status: live
+metric: Block share (24h)
+unit: pct
+higher_is_better: true
+
+disclaimer: |
+ Attribution relies on the extraData tag each builder voluntarily stamps into its blocks. A builder can change or strip its tag at any time; unrecognized tags land in the Other row and every raw string is logged so the table grows. High share is a centralization datapoint, not a quality ranking.
+
+seo_intro: |
+ Who actually builds Ethereum? Since MEV-Boost, validators outsource
+ block construction to a handful of specialized builders, and the
+ builders sign their work: each stamps a self-label into the block's
+ extraData field ("Titan (titanbuilder.xyz)", "beaverbuild.org", and
+ so on). This benchmark decodes the extraData of every mainnet block,
+ attributes it to a curated table of known builder tags, and publishes
+ the live market share. The result is the concentration story in one
+ chart: 95 percent plus of Ethereum blocks are built by a handful of
+ private builders, and the top two regularly build most of the chain.
+ Blocks carrying an execution client's default tag (geth, reth,
+ nethermind, besu version strings) are counted as vanilla, meaning the
+ proposer built locally instead of outsourcing; that row is the live
+ measure of how much of Ethereum still self-builds. As an independent
+ cross-check the harness also polls the public bidtrace data APIs of
+ seven MEV-Boost relays (Flashbots, ultrasound, Agnostic, bloXroute
+ max-profit and regulated, Titan, Aestus) and counts delivered
+ payloads per relay, a signal that does not depend on any builder's
+ self-chosen label.
+
+abstract: |
+ We attribute every Ethereum mainnet block to its builder by decoding
+ the block's extraData field, the 32 bytes a builder uses to sign its
+ work in human-readable ASCII. The harness polls the head of the chain
+ through a keyless public RPC once per 12 second slot, backfills any
+ blocks missed between polls by number, decodes extraData to printable
+ ASCII and matches it against a curated substring table of known
+ builder tags (Titan, Quasar, Eureka, BuilderNet, beaverbuild, rsync,
+ Flashbots, Builder+ and more). Client-default tags (geth, reth,
+ nethermind, besu, erigon version strings) and empty extraData count
+ as vanilla, locally built blocks. Anything unrecognized counts as
+ Other, increments an unattributed counter, and logs the raw string so
+ the table can grow, the same philosophy as the tip-wallet table in
+ bench 016. The headline metric is each builder's percentage of all
+ blocks observed in the last 24 hours. Two companion measurements ship
+ in the same harness. First, an independent cross-check: the public
+ bidtrace APIs of seven MEV-Boost relays are polled every five minutes
+ and delivered payloads are counted per relay, deduped per relay by
+ slot high-water-mark; the same slot appearing on several relays is
+ normal because builders multi-home their bids. Second, an L2
+ soft-confirmation study: the harness holds the Arbitrum sequencer
+ feed and the Base flashblocks stream open and measures how far those
+ preconfirmation channels run ahead of the public RPC head, emitted as
+ histograms for a future bench page. At inception a 50 block live
+ sample attributed 98 percent cleanly: Titan 19, Quasar 16, Eureka 6,
+ BuilderNet 2, beaverbuild 1, Builder+ 1, one vanilla block.
+
+methodology:
+ - "Source: keyless public Ethereum RPC (ethereum-rpc.publicnode.com by default, env-overridable). eth_getBlockByNumber(latest) every 12s, matching the mainnet slot time. Gaps between polls are backfilled by block number (capped at 10) so the counters see every block, not just the ones that happen to be head at poll time."
+ - "Attribution: extraData hex is decoded to printable ASCII and matched, lowercase, against a curated substring table (titan, quasar, eureka, buildernet, beaverbuild, rsync, builder0x69, flashbots, bloxroute, builder+/btcs, penguinbuild and more). First match wins; most specific substrings are ordered first."
+ - "Vanilla detection: empty extraData or execution-client default tags (geth, reth, nethermind, besu, erigon, go1.x, platform strings) count as builder=vanilla, meaning the proposer built the block locally instead of outsourcing to a builder. This row is the live self-built share of Ethereum."
+ - "Unattributed handling: any non-empty tag not in the table counts as builder=other, increments ebb_unattributed_total, and logs the raw extraData string. The table grows from operator logs instead of silently under-counting, the bench 016 pattern."
+ - "Self-labeling caveat: extraData is voluntary. A builder can rebrand, blank its tag, or impersonate another. The relay cross-check below is the independent signal; a large divergence between extraData share and relay-side delivered payloads would surface tag games."
+ - "Relay cross-check: seven MEV-Boost relay data APIs (Flashbots, ultrasound, Agnostic, bloXroute max-profit, bloXroute regulated, Titan, Aestus) are polled keyless every 5 min via GET /relay/v1/data/bidtraces/proposer_payload_delivered?limit=50. Delivered payloads are counted per relay (ebb_relay_payloads_total), deduped per relay by slot high-water-mark. The same slot on multiple relays is normal: builders multi-home bids, so relay counters measure relay share, not a partition of blocks."
+ - "Single vantage: one harness in one region observes the chain through one public RPC. Attribution is not latency-sensitive (extraData is consensus data, identical from every vantage), so single-vantage only affects liveness, which the health gauges expose."
+ - "Companion L2 metrics: the same harness measures Arbitrum sequencer-feed soft-confirmation lag (ebb_arb_softconf_lag_milliseconds, feed arrival to public RPC visibility, offset derived at runtime) and Base flashblocks cadence plus flashblock-to-RPC lag (ebb_base_flashblock_interval_milliseconds, ebb_base_softconf_lag_milliseconds). These are per-chain, not per-builder, so this spec surfaces builder share only; the L2 lag series feed a future bench page."
+
+findings:
+ - "{{best_name}} currently builds {{best_p50}} of Ethereum blocks observed over the last 24h, across {{count}} attribution rows. The top two builders regularly account for the majority of the chain, the concentration this bench exists to make visible."
+ - "{{name:beaverbuild}} sits at {{p50:beaverbuild}}. beaverbuild dominated 2023-2024 alongside rsync; its slide against Titan and the newer Quasar and Eureka operations is the clearest sign the builder market still churns even as it concentrates."
+ - "{{name:buildernet}} carries {{p50:buildernet}}. BuilderNet is the Flashbots-initiated attempt to mutualize block building into a multi-operator network precisely because of the concentration this page measures; its share is the decentralization counter-signal to watch."
+ - "{{name:vanilla}} blocks, built locally by the proposer without a builder, sit at {{p50:vanilla}}. This is the live measure of how much of Ethereum still self-builds rather than outsourcing to the MEV supply chain."
+ - "{{worst_name}} trails at {{worst_p50}}. The long tail matters less than the head here: the bench's story is that a validator set of a million keys funnels block construction through a single-digit number of firms."
+
+faq:
+ - q: "How is a block attributed to a builder?"
+ a: "Builders stamp a self-label into the 32-byte extraData field of every block they construct, for example 'Titan (titanbuilder.xyz)' or 'beaverbuild.org'. The harness decodes extraData to ASCII and matches it against a curated substring table. Empty or client-default extraData (geth, reth, nethermind version strings) means the proposer built the block locally and counts as vanilla. Unrecognized tags count as Other and are logged raw so the table grows. At inception, 98 percent of a live 50-block sample attributed cleanly."
+ - q: "Can a builder game this by changing its extraData?"
+ a: "Yes, extraData is voluntary self-labeling, and that is disclosed as the bench's main caveat. A builder could blank its tag (blocks would fall into Other, and the unattributed counter plus raw-string logging would surface the shift within hours) or imitate another builder's tag (detectable by divergence against relay bidtraces, which report the builder's signing pubkey rather than its label). In practice builders keep their tags stable because the label is marketing: searchers pick builders partly on public share stats."
+ - q: "Why do the p50, p90 and p99 columns show the same number?"
+ a: "This bench has no latency dimension, so the percentile slots are repurposed, same convention as bench 016. Every aggregate column carries the builder's 24h block share; the sample column carries the raw block count and the success column reports the harness's own RPC poll health, not anything about the builder. The ranking signal is the share itself."
+ - q: "What is the relay cross-check and why does it not sum to 100 percent?"
+ a: "Seven MEV-Boost relays expose a public data API listing every payload they delivered to a proposer. The harness counts delivered payloads per relay every five minutes. Because builders submit the same bid through several relays and every relay that carried the winning payload reports it, one block can legitimately appear on multiple relays. Relay counts therefore measure relay market share and act as an attribution sanity check independent of extraData; they are not a partition of blocks and do not sum to the block count."
+ - q: "Why does builder concentration matter?"
+ a: "Under MEV-Boost, validators outsource block construction, so whoever builds most blocks decides transaction ordering and inclusion for most of Ethereum. Concentration in two or three private firms creates censorship surface (a dominant builder filtering transactions affects most blocks), a single point of failure for liveness of the fee market, and information asymmetry for anyone trading on-chain. Projects like BuilderNet exist specifically to mutualize this layer; this bench provides the neutral number for whether concentration is getting better or worse."
+
+source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/evm-block-builders
+
+prometheus:
+ window: 24h
+ expected_freshness_seconds: 120
+ freshness_metric: ebb_eth_last_block
+
+# Real metrics emitted by the evm-block-builders harness:
+# ebb_blocks_total{builder} counter (attributed blocks)
+# ebb_unattributed_total counter
+# ebb_eth_poll_health gauge (0|1)
+# ebb_eth_last_block gauge (freshness)
+# ebb_relay_payloads_total{relay} counter (cross-check)
+# ebb_arb_softconf_lag_milliseconds histogram (future bench)
+# ebb_base_flashblock_interval_milliseconds histogram (future bench)
+# ebb_base_softconf_lag_milliseconds histogram (future bench)
+#
+# Each "provider" is one builder attribution row. The headline is the
+# builder's share of all blocks observed in 24h (pct). p50/p90/p99/mean
+# carry the same share scalar (016 convention); sample_size carries the
+# raw block count; success carries harness poll health.
+
+providers:
+ - slug: titan
+ name: Titan
+ tag: titanbuilder.xyz, dominant private builder
+ formula: "Blocks whose extraData contains the Titan tag, as a percentage of all Ethereum blocks observed in the last 24h."
+ queries:
+ p50: 100 * sum(increase(ebb_blocks_total{builder="titan"}[24h])) / scalar(sum(increase(ebb_blocks_total[24h])))
+ p90: 100 * sum(increase(ebb_blocks_total{builder="titan"}[24h])) / scalar(sum(increase(ebb_blocks_total[24h])))
+ p99: 100 * sum(increase(ebb_blocks_total{builder="titan"}[24h])) / scalar(sum(increase(ebb_blocks_total[24h])))
+ mean: 100 * sum(increase(ebb_blocks_total{builder="titan"}[24h])) / scalar(sum(increase(ebb_blocks_total[24h])))
+ success: avg_over_time(ebb_eth_poll_health[24h])
+ sample_size: sum(increase(ebb_blocks_total{builder="titan"}[24h]))
+ series: 100 * sum(rate(ebb_blocks_total{builder="titan"}[1h])) / scalar(sum(rate(ebb_blocks_total[1h])))
+
+ - slug: quasar
+ name: Quasar
+ tag: Fast-rising private builder
+ formula: "Blocks whose extraData contains the Quasar tag, as a percentage of all Ethereum blocks observed in the last 24h."
+ queries:
+ p50: 100 * sum(increase(ebb_blocks_total{builder="quasar"}[24h])) / scalar(sum(increase(ebb_blocks_total[24h])))
+ p90: 100 * sum(increase(ebb_blocks_total{builder="quasar"}[24h])) / scalar(sum(increase(ebb_blocks_total[24h])))
+ p99: 100 * sum(increase(ebb_blocks_total{builder="quasar"}[24h])) / scalar(sum(increase(ebb_blocks_total[24h])))
+ mean: 100 * sum(increase(ebb_blocks_total{builder="quasar"}[24h])) / scalar(sum(increase(ebb_blocks_total[24h])))
+ success: avg_over_time(ebb_eth_poll_health[24h])
+ sample_size: sum(increase(ebb_blocks_total{builder="quasar"}[24h]))
+ series: 100 * sum(rate(ebb_blocks_total{builder="quasar"}[1h])) / scalar(sum(rate(ebb_blocks_total[1h])))
+
+ - slug: eureka
+ name: Eureka
+ tag: Private builder, newer entrant
+ formula: "Blocks whose extraData contains the Eureka tag, as a percentage of all Ethereum blocks observed in the last 24h."
+ queries:
+ p50: 100 * sum(increase(ebb_blocks_total{builder="eureka"}[24h])) / scalar(sum(increase(ebb_blocks_total[24h])))
+ p90: 100 * sum(increase(ebb_blocks_total{builder="eureka"}[24h])) / scalar(sum(increase(ebb_blocks_total[24h])))
+ p99: 100 * sum(increase(ebb_blocks_total{builder="eureka"}[24h])) / scalar(sum(increase(ebb_blocks_total[24h])))
+ mean: 100 * sum(increase(ebb_blocks_total{builder="eureka"}[24h])) / scalar(sum(increase(ebb_blocks_total[24h])))
+ success: avg_over_time(ebb_eth_poll_health[24h])
+ sample_size: sum(increase(ebb_blocks_total{builder="eureka"}[24h]))
+ series: 100 * sum(rate(ebb_blocks_total{builder="eureka"}[1h])) / scalar(sum(rate(ebb_blocks_total[1h])))
+
+ - slug: buildernet
+ name: BuilderNet
+ tag: Flashbots-initiated multi-operator building network
+ formula: "Blocks whose extraData contains the BuilderNet tag, as a percentage of all Ethereum blocks observed in the last 24h."
+ queries:
+ p50: 100 * sum(increase(ebb_blocks_total{builder="buildernet"}[24h])) / scalar(sum(increase(ebb_blocks_total[24h])))
+ p90: 100 * sum(increase(ebb_blocks_total{builder="buildernet"}[24h])) / scalar(sum(increase(ebb_blocks_total[24h])))
+ p99: 100 * sum(increase(ebb_blocks_total{builder="buildernet"}[24h])) / scalar(sum(increase(ebb_blocks_total[24h])))
+ mean: 100 * sum(increase(ebb_blocks_total{builder="buildernet"}[24h])) / scalar(sum(increase(ebb_blocks_total[24h])))
+ success: avg_over_time(ebb_eth_poll_health[24h])
+ sample_size: sum(increase(ebb_blocks_total{builder="buildernet"}[24h]))
+ series: 100 * sum(rate(ebb_blocks_total{builder="buildernet"}[1h])) / scalar(sum(rate(ebb_blocks_total[1h])))
+
+ - slug: beaverbuild
+ name: beaverbuild
+ tag: beaverbuild.org, former market leader
+ formula: "Blocks whose extraData contains the beaverbuild tag, as a percentage of all Ethereum blocks observed in the last 24h."
+ queries:
+ p50: 100 * sum(increase(ebb_blocks_total{builder="beaverbuild"}[24h])) / scalar(sum(increase(ebb_blocks_total[24h])))
+ p90: 100 * sum(increase(ebb_blocks_total{builder="beaverbuild"}[24h])) / scalar(sum(increase(ebb_blocks_total[24h])))
+ p99: 100 * sum(increase(ebb_blocks_total{builder="beaverbuild"}[24h])) / scalar(sum(increase(ebb_blocks_total[24h])))
+ mean: 100 * sum(increase(ebb_blocks_total{builder="beaverbuild"}[24h])) / scalar(sum(increase(ebb_blocks_total[24h])))
+ success: avg_over_time(ebb_eth_poll_health[24h])
+ sample_size: sum(increase(ebb_blocks_total{builder="beaverbuild"}[24h]))
+ series: 100 * sum(rate(ebb_blocks_total{builder="beaverbuild"}[1h])) / scalar(sum(rate(ebb_blocks_total[1h])))
+
+ - slug: bobthebuilder
+ name: Bob The Builder
+ tag: bobthebuilder.xyz, pseudonymous builder
+ formula: "Blocks whose extraData contains the bobthebuilder tag, as a percentage of all Ethereum blocks observed in the last 24h."
+ queries:
+ p50: 100 * sum(increase(ebb_blocks_total{builder="bobthebuilder"}[24h])) / scalar(sum(increase(ebb_blocks_total[24h])))
+ p90: 100 * sum(increase(ebb_blocks_total{builder="bobthebuilder"}[24h])) / scalar(sum(increase(ebb_blocks_total[24h])))
+ p99: 100 * sum(increase(ebb_blocks_total{builder="bobthebuilder"}[24h])) / scalar(sum(increase(ebb_blocks_total[24h])))
+ mean: 100 * sum(increase(ebb_blocks_total{builder="bobthebuilder"}[24h])) / scalar(sum(increase(ebb_blocks_total[24h])))
+ success: avg_over_time(ebb_eth_poll_health[24h])
+ sample_size: sum(increase(ebb_blocks_total{builder="bobthebuilder"}[24h]))
+ series: 100 * sum(rate(ebb_blocks_total{builder="bobthebuilder"}[1h])) / scalar(sum(rate(ebb_blocks_total[1h])))
+
+ - slug: btcs
+ name: Builder+
+ tag: BTCS Builder+
+ formula: "Blocks whose extraData contains the Builder+ or BTCS tag, as a percentage of all Ethereum blocks observed in the last 24h."
+ queries:
+ p50: 100 * sum(increase(ebb_blocks_total{builder="btcs"}[24h])) / scalar(sum(increase(ebb_blocks_total[24h])))
+ p90: 100 * sum(increase(ebb_blocks_total{builder="btcs"}[24h])) / scalar(sum(increase(ebb_blocks_total[24h])))
+ p99: 100 * sum(increase(ebb_blocks_total{builder="btcs"}[24h])) / scalar(sum(increase(ebb_blocks_total[24h])))
+ mean: 100 * sum(increase(ebb_blocks_total{builder="btcs"}[24h])) / scalar(sum(increase(ebb_blocks_total[24h])))
+ success: avg_over_time(ebb_eth_poll_health[24h])
+ sample_size: sum(increase(ebb_blocks_total{builder="btcs"}[24h]))
+ series: 100 * sum(rate(ebb_blocks_total{builder="btcs"}[1h])) / scalar(sum(rate(ebb_blocks_total[1h])))
+
+ - slug: vanilla
+ name: Vanilla
+ tag: Locally built by the proposer, no builder
+ formula: "Blocks with empty or execution-client default extraData (geth, reth, nethermind, besu, erigon), meaning the proposer built locally, as a percentage of all blocks observed in 24h."
+ queries:
+ p50: 100 * sum(increase(ebb_blocks_total{builder="vanilla"}[24h])) / scalar(sum(increase(ebb_blocks_total[24h])))
+ p90: 100 * sum(increase(ebb_blocks_total{builder="vanilla"}[24h])) / scalar(sum(increase(ebb_blocks_total[24h])))
+ p99: 100 * sum(increase(ebb_blocks_total{builder="vanilla"}[24h])) / scalar(sum(increase(ebb_blocks_total[24h])))
+ mean: 100 * sum(increase(ebb_blocks_total{builder="vanilla"}[24h])) / scalar(sum(increase(ebb_blocks_total[24h])))
+ success: avg_over_time(ebb_eth_poll_health[24h])
+ sample_size: sum(increase(ebb_blocks_total{builder="vanilla"}[24h]))
+ series: 100 * sum(rate(ebb_blocks_total{builder="vanilla"}[1h])) / scalar(sum(rate(ebb_blocks_total[1h])))
+
+ - slug: other
+ name: Other
+ tag: Unrecognized extraData tags, logged raw for table growth
+ formula: "Blocks whose extraData carries a non-empty tag not in the curated table, as a percentage of all blocks observed in 24h. Every raw string is logged so the attribution table grows."
+ queries:
+ p50: 100 * sum(increase(ebb_blocks_total{builder="other"}[24h])) / scalar(sum(increase(ebb_blocks_total[24h])))
+ p90: 100 * sum(increase(ebb_blocks_total{builder="other"}[24h])) / scalar(sum(increase(ebb_blocks_total[24h])))
+ p99: 100 * sum(increase(ebb_blocks_total{builder="other"}[24h])) / scalar(sum(increase(ebb_blocks_total[24h])))
+ mean: 100 * sum(increase(ebb_blocks_total{builder="other"}[24h])) / scalar(sum(increase(ebb_blocks_total[24h])))
+ success: avg_over_time(ebb_eth_poll_health[24h])
+ sample_size: sum(increase(ebb_blocks_total{builder="other"}[24h]))
+ series: 100 * sum(rate(ebb_blocks_total{builder="other"}[1h])) / scalar(sum(rate(ebb_blocks_total[1h])))
diff --git a/benchmarks/rpc-reliability.yml b/benchmarks/rpc-reliability.yml
new file mode 100644
index 00000000..f588fcbf
--- /dev/null
+++ b/benchmarks/rpc-reliability.yml
@@ -0,0 +1,297 @@
+# OpenChainBench. Bench № 083
+
+slug: rpc-reliability
+number: "083"
+title: Most reliable free public RPC, correctness incidents across 7 EVM chains
+seo_title: "Most reliable free public RPC 2026"
+seo_description: "Free public RPCs ranked by correctness incidents: block-hash quorum disagreements, eth_getLogs completeness and eth_getBalance state integrity, live and keyless."
+subtitle: "Correctness incidents per 24 hours for free no-key RPC endpoints: block hashes disagreeing with the cross-provider quorum, eth_getLogs counts deviating from the majority, divergent eth_getBalance state and blocked or gated methods, measured continuously alongside consensus head lag."
+category: RPCs
+status: live
+metric: Reliability incidents
+unit: count
+higher_is_better: false
+
+disclaimer: >
+ A zero row means no incident was caught in the window, not a proof of
+ correctness. Most providers post zero most days; this bench exists for
+ the days they do not, and for the errors: a blocked eth_getLogs or a
+ balance read gated behind a paid key at 40 blocks of depth counts as
+ an incident because it is one from the caller's seat.
+
+seo_intro: |
+ Bench 010 answers which free public RPC is fastest; this page answers
+ the question that should come first. which one is telling the truth.
+ A fast endpoint that serves a stale head, drops logs from an
+ eth_getLogs response, or answers eth_getBalance from a diverged state
+ view is worse than a slow honest one, and none of that shows up in a
+ latency percentile. So we cross-examine the same keyless provider
+ cohort the latency bench probes, continuously, on three axes. First,
+ consensus head lag: every 60 seconds each provider's reported
+ (height, hash) pair feeds a per-chain quorum map, and the gap between
+ a provider's head and the highest head any provider reported is
+ published as a gauge. Second, hash integrity: when at least two
+ providers agree on the hash at a height, a provider reporting a
+ different hash at that same height is counted, once per height, as a
+ mismatch incident. Third, fixed-vector integrity: every 5 minutes,
+ one chain at a time, every provider gets the identical eth_getLogs
+ query for the chain's canonical USDC contract over a 10-block window
+ behind the tip, plus an eth_getBalance read at the same fixed block;
+ answers are compared across providers and a deviation from the strict
+ majority is an incident. Errors count too, deliberately: 1RPC blocks
+ eth_getLogs outright with a -32000, and PublicNode began gating
+ keyless reads a few dozen blocks deep behind a personal token
+ (-32602) in July 2026. Those are reliability failures a latency bench
+ renders invisible, and here they are the headline.
+
+abstract: |
+ We measure the correctness of free no-key RPC endpoints, not their
+ speed. The harness reuses the bench-010 probe matrix (same providers,
+ same chains, same 60-second header fetch) and adds three metric
+ families. rpc_consensus_lag_blocks tracks each provider's head
+ against the highest head seen across providers for that chain.
+ rpc_hash_mismatch_total counts same-height hash disagreements with
+ the two-or-more-provider quorum, at most once per height, with a
+ strict-plurality rule so a live reorg splitting providers two against
+ two counts nobody. rpc_integrity_check_total classifies fixed-vector
+ checks (eth_getLogs for the chain's canonical USDC over a 10-block
+ window at a daily-rotating depth behind tip, and eth_getBalance of a
+ fixed well-known address at the same block) as ok, error or disagree
+ against the cross-provider majority. The headline is the raw
+ incident count over 24 hours: errors plus disagreements plus hash
+ mismatches. No weighted composite: weights invite gaming disputes,
+ and an incident is an incident.
+
+methodology:
+ - "Consensus lag: the bench-010 latency probe fetches the full latest header every 60 seconds per (provider, chain); its height and hash, previously discarded, feed this bench. rpc_consensus_lag_blocks is the gap between the provider's reported head and the highest head any probed provider reported for that chain (rolling max). Valid-but-stale responses still count as observations; failed probes delete the series so dead endpoints age out."
+ - "Hash quorum: per chain, a height-to-hash vote map. When one hash at height H is backed by at least 2 providers AND strictly more than any competing hash, it is canonical; a provider that reported a different hash at H is counted in rpc_hash_mismatch_total, at most once per (provider, height). A 2-2 split, the signature of a live reorg, resolves to no quorum and counts nobody."
+ - "Fixed-vector integrity, cadence: every 5 minutes ONE chain is checked, rotating through the 7 chains with a canonical high-traffic USDC deployment (Ethereum, Arbitrum, Optimism, Base, Polygon, BNB, Avalanche). Each provider on that chain gets 2 checks per round, so a single-chain provider accrues ~82 checks per day and a 7-chain provider ~575."
+ - "Logs completeness vector: eth_getLogs for the chain's canonical USDC contract (Circle native deployments; Binance-peg on BNB) over a 10-block window ending at tip minus N. Returned log counts are compared across providers; deviation from the strict majority count increments rpc_logs_disagreement_total and books a disagree incident. Live calibration on Ethereum returned 1371 logs consistently on drpc, tenderly and blastapi over the same fixed range."
+ - "Anti-gaming rotation: N rotates daily over {20, 30, 40, 50, 60} blocks, so a provider cannot special-case a published fixed range, and request ids rotate on every call (the same edge-cache defeat as the latency probe). All depths stay far inside non-archive territory, so a pruned-but-honest node is never penalized."
+ - "State consistency vector: eth_getBalance of a fixed well-known address (the same Vitalik address the archive-depth probe uses) at the tip-minus-N block. The hex answer must be byte-identical across providers; divergence from the strict majority increments rpc_state_disagreement_total. Live calibration showed byte-identical balances across all responding providers at a fixed block."
+ - "Errors are signal: rpc_integrity_check_total{result} is ok, error or disagree. error covers blocked methods (1RPC answers eth_getLogs with -32000), depth gating (PublicNode keyless regressed to under 150 blocks of archive depth in July 2026, returning -32602 asking for a personal token), and transport failures. The headline counts error and disagree alike: a method you cannot call is unreliable regardless of why."
+ - "Headline: incidents per 24h = increase of integrity checks with result error or disagree, plus increase of hash mismatches, from the eu-west vantage. Raw counts, no weighting; the p90/p99 columns are consensus-lag quantiles in blocks, published as raw companion dimensions."
+ - "Single-region disclosure: incidents are counted from the eu-west probe only. Correctness, unlike latency, is location-independent in principle, but every provider fronts with anycast, so what we audit is the serving cluster eu-west traffic lands on; a diverged cache in another region is invisible to this bench. The us-east and sgp replicas emit the same metrics for cross-checking on the time-series chart."
+ - "Quorum honesty: strict plurality everywhere. Two agreeing providers outvote one dissenter; two against two resolves to no quorum and books nothing; a lone answer with no second opinion is unverifiable, not wrong, and books ok. Disagreement requires being outvoted by an established majority."
+ - "Cohort: 12 keyless providers from the bench-010 audited matrix. Excluded: LlamaRPC (fully down, HTTP 521, already delisted from the harness), 1RPC (delisted 2026-07-09 at under 13 percent success; when probed live it also blocked eth_getLogs with -32000, exactly the incident class this bench counts), Ankr / NodeReal / GetBlock / Chainstack (key-gated). Avalanche's foundation endpoint is probed by the harness and visible on bench 010; it is left out here only to keep the cohort at 12."
+ - "Provider matrix and vector addresses are documented in harnesses/rpc-capabilities/cmd/script/config.go and integrity.go; the harness is shared with bench 010, so both benches see the identical provider set and probe budget."
+
+findings:
+ - "Consensus lag is real and measurable at 60-second resolution: calibration rounds of simultaneous eth_getBlockByNumber across the Ethereum cohort caught individual gateways one block behind the cross-provider tip in 2 of 4 rounds, while every provider at equal height returned byte-identical hashes. The lag gauge turns that from an anecdote into a 24h p90/p99 per provider."
+ - "The completeness gap is not hypothetical. On the same fixed 10-block USDC range, drpc, tenderly and blastapi each returned exactly 1371 logs, 1RPC refused the method with -32000, and PublicNode's keyless tier had regressed its archive depth to under 150 blocks, answering -32602 and asking for a personal token. Three providers agreed, two could not answer; both failure modes are incidents here."
+ - "State reads are the quiet good news: eth_getBalance at a fixed recent block came back byte-identical across every responding provider during calibration. rpc_state_disagreement_total exists for the day that stops being true, which is precisely the day you want a public counter."
+ - "{{name:cloudflare}} shows why errors must count: the endpoint answers HTTP 200 fast and fails inside the JSON-RPC body. A correctness bench that only compared successful answers would score it clean; counting error results books its blocked calls as the incidents they are."
+ - "Zero is the normal reading and that is the point. Free public RPCs mostly do tell the truth; the bench prices the exceptions, publicly timestamps regressions like PublicNode's July 2026 keyless depth gating, and gives the honest majority a way to prove they stayed honest."
+
+faq:
+ - q: "What counts as a reliability incident?"
+ a: "Three things, summed over 24 hours from the eu-west probe. An integrity check that errored (method blocked, depth gated behind a key, transport failure). An integrity check whose answer deviated from the strict cross-provider majority (an eth_getLogs count differing from the count at least two other providers agree on, or an eth_getBalance hex that is not byte-identical to the majority answer). And a block-hash mismatch, where the provider reported a hash at a height where at least two providers agreed on a different one. Raw counts, no weights; lower is better and zero is common."
+ - q: "Why do method errors count as incidents when this is a correctness bench?"
+ a: "Because from the caller's seat a method you cannot call is a reliability failure regardless of the reason. 1RPC blocks eth_getLogs with a -32000 error; PublicNode's keyless tier started answering balance reads a few dozen blocks deep with -32602 and a demand for a personal token. Neither returns wrong data, but both break the contract a developer assumes when pasting a no-key URL into a dapp. Counting them alongside genuine disagreements keeps the headline honest: it measures whether the endpoint reliably does what an EVM JSON-RPC endpoint is expected to do."
+ - q: "Can a provider game this benchmark by special-casing the test queries?"
+ a: "The vectors are designed to make that expensive. The eth_getLogs window is anchored at tip minus N where N rotates daily over five depths, so the exact range is never fixed; request ids rotate on every call, defeating body-keyed edge caches; and the USDC contracts are the busiest transfer contracts on each chain, so serving them correctly IS serving real traffic correctly. The head-lag and hash-quorum signals cannot be gamed at all without actually running well-synced nodes, because they are computed from the same latest-header probe the latency bench uses."
+ - q: "Does a reorg unfairly flag providers as mismatching?"
+ a: "No, by construction. During a live reorg two providers can legitimately hold different hashes at the same height. The quorum rule requires a strict plurality: one hash backed by at least two providers and strictly more than any competing hash. A two-against-two split resolves to no quorum and counts nobody, and a mismatch is booked at most once per provider per height. What remains after those guards, a provider outvoted by an established majority at a settled height, is the signal this bench exists to record."
+ - q: "Why is this measured from one region only?"
+ a: "The headline counts incidents from the eu-west vantage because correctness, unlike latency, does not depend on where the client stands: a node serving the canonical chain serves it to everyone. The honest caveat is anycast: each probe sees the serving cluster its region lands on, so a diverged cache serving only another continent is invisible to the eu-west counter. The us-east and Singapore replicas emit the same metric families, and the per-region series are on the chart for cross-checking; the headline stays single-region so the same incident is never double-counted."
+
+source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/rpc-capabilities
+
+prometheus:
+ window: 24h
+ freshness_metric: rpc_consensus_lag_blocks
+
+# Real metrics emitted by the rpc-capabilities harness (bench-083 set):
+# rpc_consensus_lag_blocks{provider, chain, region} gauge
+# rpc_hash_mismatch_total{provider, chain, region} counter
+# rpc_logs_count{provider, chain, region} gauge
+# rpc_logs_disagreement_total{provider, chain, region} counter
+# rpc_state_disagreement_total{provider, chain, region} counter
+# rpc_integrity_check_total{provider, chain, region, check, result}
+# counter, check in {logs, balance}, result in {ok, error, disagree}
+# Counters are zero-initialized for the whole (provider x chain) matrix
+# at harness start, so increase() reads 0 (not absent) for clean rows.
+# Headline scopes region="eu-west" (see methodology); the chain tab
+# injects chain="X" into every query.
+
+dimensions:
+ chain:
+ - { value: all, label: All chains }
+ - { value: ethereum, label: Ethereum }
+ - { value: polygon, label: Polygon }
+ - { value: arbitrum, label: Arbitrum }
+ - { value: optimism, label: Optimism }
+ - { value: base, label: Base }
+ - { value: bnb, label: BNB Chain }
+ - { value: avalanche, label: Avalanche }
+
+# clamp_min floor: the cell-rank loader drops samples <= 0 (guard against
+# absent-series garbage on latency benches), but 0 incidents is this bench's
+# BEST and most common value; the epsilon keeps clean providers ranked.
+rank_matrix_query: clamp_min(sum by (provider, chain) (increase(rpc_integrity_check_total{region="eu-west",result=~"error|disagree"}[24h])) + sum by (provider, chain) (increase(rpc_hash_mismatch_total{region="eu-west"}[24h])), 0.001)
+
+# ~82 integrity checks per provider per chain per day (2 checks per
+# 5-min rotation over 7 chains); single-chain providers are the floor.
+expected_n: 40
+
+ledger_columns:
+ - { label: "Incidents 24h", slot: p50, unit: count }
+ - { label: "Head lag p90", slot: p90, unit: count }
+ - { label: "Head lag p99", slot: p99, unit: count }
+
+providers:
+ - slug: publicnode
+ name: PublicNode
+ tag: Allnodes-operated; keyless archive depth gated since July 2026
+ formula: "Incidents in 24h (eu-west): integrity checks (USDC eth_getLogs count, eth_getBalance bytes) that errored or diverged from the cross-provider majority, plus block-hash quorum disagreements, summed over its measured chains."
+ queries:
+ p50: sum(increase(rpc_integrity_check_total{provider="publicnode",region="eu-west",result=~"error|disagree"}[24h])) + sum(increase(rpc_hash_mismatch_total{provider="publicnode",region="eu-west"}[24h]))
+ p90: avg(quantile_over_time(0.90, rpc_consensus_lag_blocks{provider="publicnode",region="eu-west"}[24h]))
+ p99: avg(quantile_over_time(0.99, rpc_consensus_lag_blocks{provider="publicnode",region="eu-west"}[24h]))
+ mean: avg(avg_over_time(rpc_consensus_lag_blocks{provider="publicnode",region="eu-west"}[24h]))
+ success: sum(increase(rpc_integrity_check_total{provider="publicnode",region="eu-west",result="ok"}[24h])) / sum(increase(rpc_integrity_check_total{provider="publicnode",region="eu-west"}[24h]))
+ sample_size: sum(increase(rpc_integrity_check_total{provider="publicnode",region="eu-west"}[24h]))
+ series: sum(increase(rpc_integrity_check_total{provider="publicnode",region="eu-west",result=~"error|disagree"}[1h])) + sum(increase(rpc_hash_mismatch_total{provider="publicnode",region="eu-west"}[1h]))
+
+ - slug: drpc
+ name: dRPC
+ tag: Decentralized RPC mesh, consensus-checked routing
+ formula: "Incidents in 24h (eu-west): integrity checks (USDC eth_getLogs count, eth_getBalance bytes) that errored or diverged from the cross-provider majority, plus block-hash quorum disagreements, summed over its measured chains."
+ queries:
+ p50: sum(increase(rpc_integrity_check_total{provider="drpc",region="eu-west",result=~"error|disagree"}[24h])) + sum(increase(rpc_hash_mismatch_total{provider="drpc",region="eu-west"}[24h]))
+ p90: avg(quantile_over_time(0.90, rpc_consensus_lag_blocks{provider="drpc",region="eu-west"}[24h]))
+ p99: avg(quantile_over_time(0.99, rpc_consensus_lag_blocks{provider="drpc",region="eu-west"}[24h]))
+ mean: avg(avg_over_time(rpc_consensus_lag_blocks{provider="drpc",region="eu-west"}[24h]))
+ success: sum(increase(rpc_integrity_check_total{provider="drpc",region="eu-west",result="ok"}[24h])) / sum(increase(rpc_integrity_check_total{provider="drpc",region="eu-west"}[24h]))
+ sample_size: sum(increase(rpc_integrity_check_total{provider="drpc",region="eu-west"}[24h]))
+ series: sum(increase(rpc_integrity_check_total{provider="drpc",region="eu-west",result=~"error|disagree"}[1h])) + sum(increase(rpc_hash_mismatch_total{provider="drpc",region="eu-west"}[1h]))
+
+ - slug: tenderly
+ name: Tenderly
+ tag: Multi-chain public gateway, no key
+ formula: "Incidents in 24h (eu-west): integrity checks (USDC eth_getLogs count, eth_getBalance bytes) that errored or diverged from the cross-provider majority, plus block-hash quorum disagreements, summed over its measured chains."
+ queries:
+ p50: sum(increase(rpc_integrity_check_total{provider="tenderly",region="eu-west",result=~"error|disagree"}[24h])) + sum(increase(rpc_hash_mismatch_total{provider="tenderly",region="eu-west"}[24h]))
+ p90: avg(quantile_over_time(0.90, rpc_consensus_lag_blocks{provider="tenderly",region="eu-west"}[24h]))
+ p99: avg(quantile_over_time(0.99, rpc_consensus_lag_blocks{provider="tenderly",region="eu-west"}[24h]))
+ mean: avg(avg_over_time(rpc_consensus_lag_blocks{provider="tenderly",region="eu-west"}[24h]))
+ success: sum(increase(rpc_integrity_check_total{provider="tenderly",region="eu-west",result="ok"}[24h])) / sum(increase(rpc_integrity_check_total{provider="tenderly",region="eu-west"}[24h]))
+ sample_size: sum(increase(rpc_integrity_check_total{provider="tenderly",region="eu-west"}[24h]))
+ series: sum(increase(rpc_integrity_check_total{provider="tenderly",region="eu-west",result=~"error|disagree"}[1h])) + sum(increase(rpc_hash_mismatch_total{provider="tenderly",region="eu-west"}[1h]))
+
+ - slug: nodies
+ name: Nodies
+ tag: POKT Network's decentralized public RPC successor
+ formula: "Incidents in 24h (eu-west): integrity checks (USDC eth_getLogs count, eth_getBalance bytes) that errored or diverged from the cross-provider majority, plus block-hash quorum disagreements, summed over its measured chains."
+ queries:
+ p50: sum(increase(rpc_integrity_check_total{provider="nodies",region="eu-west",result=~"error|disagree"}[24h])) + sum(increase(rpc_hash_mismatch_total{provider="nodies",region="eu-west"}[24h]))
+ p90: avg(quantile_over_time(0.90, rpc_consensus_lag_blocks{provider="nodies",region="eu-west"}[24h]))
+ p99: avg(quantile_over_time(0.99, rpc_consensus_lag_blocks{provider="nodies",region="eu-west"}[24h]))
+ mean: avg(avg_over_time(rpc_consensus_lag_blocks{provider="nodies",region="eu-west"}[24h]))
+ success: sum(increase(rpc_integrity_check_total{provider="nodies",region="eu-west",result="ok"}[24h])) / sum(increase(rpc_integrity_check_total{provider="nodies",region="eu-west"}[24h]))
+ sample_size: sum(increase(rpc_integrity_check_total{provider="nodies",region="eu-west"}[24h]))
+ series: sum(increase(rpc_integrity_check_total{provider="nodies",region="eu-west",result=~"error|disagree"}[1h])) + sum(increase(rpc_hash_mismatch_total{provider="nodies",region="eu-west"}[1h]))
+
+ - slug: lava
+ name: Lava
+ tag: Decentralized RPC mesh (ETH + Arbitrum no-key)
+ formula: "Incidents in 24h (eu-west) on Ethereum and Arbitrum: integrity checks (USDC eth_getLogs count, eth_getBalance bytes) that errored or diverged from the cross-provider majority, plus block-hash quorum disagreements."
+ queries:
+ p50: sum(increase(rpc_integrity_check_total{provider="lava",region="eu-west",result=~"error|disagree"}[24h])) + sum(increase(rpc_hash_mismatch_total{provider="lava",region="eu-west"}[24h]))
+ p90: avg(quantile_over_time(0.90, rpc_consensus_lag_blocks{provider="lava",region="eu-west"}[24h]))
+ p99: avg(quantile_over_time(0.99, rpc_consensus_lag_blocks{provider="lava",region="eu-west"}[24h]))
+ mean: avg(avg_over_time(rpc_consensus_lag_blocks{provider="lava",region="eu-west"}[24h]))
+ success: sum(increase(rpc_integrity_check_total{provider="lava",region="eu-west",result="ok"}[24h])) / sum(increase(rpc_integrity_check_total{provider="lava",region="eu-west"}[24h]))
+ sample_size: sum(increase(rpc_integrity_check_total{provider="lava",region="eu-west"}[24h]))
+ series: sum(increase(rpc_integrity_check_total{provider="lava",region="eu-west",result=~"error|disagree"}[1h])) + sum(increase(rpc_hash_mismatch_total{provider="lava",region="eu-west"}[1h]))
+
+ - slug: meowrpc
+ name: MeowRPC
+ tag: Free public RPC (ETH + Arbitrum)
+ formula: "Incidents in 24h (eu-west) on Ethereum and Arbitrum: integrity checks (USDC eth_getLogs count, eth_getBalance bytes) that errored or diverged from the cross-provider majority, plus block-hash quorum disagreements."
+ queries:
+ p50: sum(increase(rpc_integrity_check_total{provider="meowrpc",region="eu-west",result=~"error|disagree"}[24h])) + sum(increase(rpc_hash_mismatch_total{provider="meowrpc",region="eu-west"}[24h]))
+ p90: avg(quantile_over_time(0.90, rpc_consensus_lag_blocks{provider="meowrpc",region="eu-west"}[24h]))
+ p99: avg(quantile_over_time(0.99, rpc_consensus_lag_blocks{provider="meowrpc",region="eu-west"}[24h]))
+ mean: avg(avg_over_time(rpc_consensus_lag_blocks{provider="meowrpc",region="eu-west"}[24h]))
+ success: sum(increase(rpc_integrity_check_total{provider="meowrpc",region="eu-west",result="ok"}[24h])) / sum(increase(rpc_integrity_check_total{provider="meowrpc",region="eu-west"}[24h]))
+ sample_size: sum(increase(rpc_integrity_check_total{provider="meowrpc",region="eu-west"}[24h]))
+ series: sum(increase(rpc_integrity_check_total{provider="meowrpc",region="eu-west",result=~"error|disagree"}[1h])) + sum(increase(rpc_hash_mismatch_total{provider="meowrpc",region="eu-west"}[1h]))
+
+ - slug: flashbots
+ name: Flashbots
+ tag: Private-mempool read proxy, Ethereum only
+ formula: "Incidents in 24h (eu-west) on Ethereum: integrity checks (USDC eth_getLogs count, eth_getBalance bytes) that errored or diverged from the cross-provider majority, plus block-hash quorum disagreements."
+ queries:
+ p50: sum(increase(rpc_integrity_check_total{provider="flashbots",region="eu-west",result=~"error|disagree"}[24h])) + sum(increase(rpc_hash_mismatch_total{provider="flashbots",region="eu-west"}[24h]))
+ p90: avg(quantile_over_time(0.90, rpc_consensus_lag_blocks{provider="flashbots",region="eu-west"}[24h]))
+ p99: avg(quantile_over_time(0.99, rpc_consensus_lag_blocks{provider="flashbots",region="eu-west"}[24h]))
+ mean: avg(avg_over_time(rpc_consensus_lag_blocks{provider="flashbots",region="eu-west"}[24h]))
+ success: sum(increase(rpc_integrity_check_total{provider="flashbots",region="eu-west",result="ok"}[24h])) / sum(increase(rpc_integrity_check_total{provider="flashbots",region="eu-west"}[24h]))
+ sample_size: sum(increase(rpc_integrity_check_total{provider="flashbots",region="eu-west"}[24h]))
+ series: sum(increase(rpc_integrity_check_total{provider="flashbots",region="eu-west",result=~"error|disagree"}[1h])) + sum(increase(rpc_hash_mismatch_total{provider="flashbots",region="eu-west"}[1h]))
+
+ - slug: cloudflare
+ name: Cloudflare
+ tag: Permissioned mode; the errors-are-signal exhibit
+ formula: "Incidents in 24h (eu-west) on Ethereum: integrity checks (USDC eth_getLogs count, eth_getBalance bytes) that errored or diverged from the cross-provider majority, plus block-hash quorum disagreements. Blocked methods count as errors."
+ queries:
+ p50: sum(increase(rpc_integrity_check_total{provider="cloudflare",region="eu-west",result=~"error|disagree"}[24h])) + sum(increase(rpc_hash_mismatch_total{provider="cloudflare",region="eu-west"}[24h]))
+ p90: avg(quantile_over_time(0.90, rpc_consensus_lag_blocks{provider="cloudflare",region="eu-west"}[24h]))
+ p99: avg(quantile_over_time(0.99, rpc_consensus_lag_blocks{provider="cloudflare",region="eu-west"}[24h]))
+ mean: avg(avg_over_time(rpc_consensus_lag_blocks{provider="cloudflare",region="eu-west"}[24h]))
+ success: sum(increase(rpc_integrity_check_total{provider="cloudflare",region="eu-west",result="ok"}[24h])) / sum(increase(rpc_integrity_check_total{provider="cloudflare",region="eu-west"}[24h]))
+ sample_size: sum(increase(rpc_integrity_check_total{provider="cloudflare",region="eu-west"}[24h]))
+ series: sum(increase(rpc_integrity_check_total{provider="cloudflare",region="eu-west",result=~"error|disagree"}[1h])) + sum(increase(rpc_hash_mismatch_total{provider="cloudflare",region="eu-west"}[1h]))
+
+ - slug: base-official
+ name: Base
+ tag: Coinbase-operated, Base mainnet only
+ formula: "Incidents in 24h (eu-west) on Base: integrity checks (USDC eth_getLogs count, eth_getBalance bytes) that errored or diverged from the cross-provider majority, plus block-hash quorum disagreements."
+ queries:
+ p50: sum(increase(rpc_integrity_check_total{provider="base-official",region="eu-west",result=~"error|disagree"}[24h])) + sum(increase(rpc_hash_mismatch_total{provider="base-official",region="eu-west"}[24h]))
+ p90: avg(quantile_over_time(0.90, rpc_consensus_lag_blocks{provider="base-official",region="eu-west"}[24h]))
+ p99: avg(quantile_over_time(0.99, rpc_consensus_lag_blocks{provider="base-official",region="eu-west"}[24h]))
+ mean: avg(avg_over_time(rpc_consensus_lag_blocks{provider="base-official",region="eu-west"}[24h]))
+ success: sum(increase(rpc_integrity_check_total{provider="base-official",region="eu-west",result="ok"}[24h])) / sum(increase(rpc_integrity_check_total{provider="base-official",region="eu-west"}[24h]))
+ sample_size: sum(increase(rpc_integrity_check_total{provider="base-official",region="eu-west"}[24h]))
+ series: sum(increase(rpc_integrity_check_total{provider="base-official",region="eu-west",result=~"error|disagree"}[1h])) + sum(increase(rpc_hash_mismatch_total{provider="base-official",region="eu-west"}[1h]))
+
+ - slug: arbitrum-official
+ name: Arbitrum
+ tag: Arbitrum Foundation RPC, Arbitrum One only
+ formula: "Incidents in 24h (eu-west) on Arbitrum One: integrity checks (USDC eth_getLogs count, eth_getBalance bytes) that errored or diverged from the cross-provider majority, plus block-hash quorum disagreements."
+ queries:
+ p50: sum(increase(rpc_integrity_check_total{provider="arbitrum-official",region="eu-west",result=~"error|disagree"}[24h])) + sum(increase(rpc_hash_mismatch_total{provider="arbitrum-official",region="eu-west"}[24h]))
+ p90: avg(quantile_over_time(0.90, rpc_consensus_lag_blocks{provider="arbitrum-official",region="eu-west"}[24h]))
+ p99: avg(quantile_over_time(0.99, rpc_consensus_lag_blocks{provider="arbitrum-official",region="eu-west"}[24h]))
+ mean: avg(avg_over_time(rpc_consensus_lag_blocks{provider="arbitrum-official",region="eu-west"}[24h]))
+ success: sum(increase(rpc_integrity_check_total{provider="arbitrum-official",region="eu-west",result="ok"}[24h])) / sum(increase(rpc_integrity_check_total{provider="arbitrum-official",region="eu-west"}[24h]))
+ sample_size: sum(increase(rpc_integrity_check_total{provider="arbitrum-official",region="eu-west"}[24h]))
+ series: sum(increase(rpc_integrity_check_total{provider="arbitrum-official",region="eu-west",result=~"error|disagree"}[1h])) + sum(increase(rpc_hash_mismatch_total{provider="arbitrum-official",region="eu-west"}[1h]))
+
+ - slug: optimism-official
+ name: Optimism
+ tag: Optimism Foundation RPC, OP mainnet only
+ formula: "Incidents in 24h (eu-west) on Optimism: integrity checks (USDC eth_getLogs count, eth_getBalance bytes) that errored or diverged from the cross-provider majority, plus block-hash quorum disagreements."
+ queries:
+ p50: sum(increase(rpc_integrity_check_total{provider="optimism-official",region="eu-west",result=~"error|disagree"}[24h])) + sum(increase(rpc_hash_mismatch_total{provider="optimism-official",region="eu-west"}[24h]))
+ p90: avg(quantile_over_time(0.90, rpc_consensus_lag_blocks{provider="optimism-official",region="eu-west"}[24h]))
+ p99: avg(quantile_over_time(0.99, rpc_consensus_lag_blocks{provider="optimism-official",region="eu-west"}[24h]))
+ mean: avg(avg_over_time(rpc_consensus_lag_blocks{provider="optimism-official",region="eu-west"}[24h]))
+ success: sum(increase(rpc_integrity_check_total{provider="optimism-official",region="eu-west",result="ok"}[24h])) / sum(increase(rpc_integrity_check_total{provider="optimism-official",region="eu-west"}[24h]))
+ sample_size: sum(increase(rpc_integrity_check_total{provider="optimism-official",region="eu-west"}[24h]))
+ series: sum(increase(rpc_integrity_check_total{provider="optimism-official",region="eu-west",result=~"error|disagree"}[1h])) + sum(increase(rpc_hash_mismatch_total{provider="optimism-official",region="eu-west"}[1h]))
+
+ - slug: binance
+ name: Binance
+ tag: BNB Chain dataseed RPC, BNB only
+ formula: "Incidents in 24h (eu-west) on BNB Chain: integrity checks (USDC eth_getLogs count, eth_getBalance bytes) that errored or diverged from the cross-provider majority, plus block-hash quorum disagreements."
+ queries:
+ p50: sum(increase(rpc_integrity_check_total{provider="binance",region="eu-west",result=~"error|disagree"}[24h])) + sum(increase(rpc_hash_mismatch_total{provider="binance",region="eu-west"}[24h]))
+ p90: avg(quantile_over_time(0.90, rpc_consensus_lag_blocks{provider="binance",region="eu-west"}[24h]))
+ p99: avg(quantile_over_time(0.99, rpc_consensus_lag_blocks{provider="binance",region="eu-west"}[24h]))
+ mean: avg(avg_over_time(rpc_consensus_lag_blocks{provider="binance",region="eu-west"}[24h]))
+ success: sum(increase(rpc_integrity_check_total{provider="binance",region="eu-west",result="ok"}[24h])) / sum(increase(rpc_integrity_check_total{provider="binance",region="eu-west"}[24h]))
+ sample_size: sum(increase(rpc_integrity_check_total{provider="binance",region="eu-west"}[24h]))
+ series: sum(increase(rpc_integrity_check_total{provider="binance",region="eu-west",result=~"error|disagree"}[1h])) + sum(increase(rpc_hash_mismatch_total{provider="binance",region="eu-west"}[1h]))
diff --git a/benchmarks/tokenized-stock-arb-latency.yml b/benchmarks/tokenized-stock-arb-latency.yml
new file mode 100644
index 00000000..61d75409
--- /dev/null
+++ b/benchmarks/tokenized-stock-arb-latency.yml
@@ -0,0 +1,144 @@
+# OpenChainBench. Bench № 080
+
+slug: tokenized-stock-arb-latency
+number: "080"
+title: Tokenized stock arb latency, how fast pools catch a Nasdaq move
+seo_title: "Tokenized stock arb latency 2026"
+seo_description: "When Nasdaq moves 25 bps, how fast does the onchain pool follow? Live measurement across 6 liquid tokenized equities on Robinhood Chain."
+subtitle: "Median seconds between a 25 bps one-minute move on the reference market and the onchain pool coming back within 20 bps, per tokenized equity, regular hours only."
+
+category: RWA
+status: live
+metric: Arb latency
+unit: sec
+higher_is_better: false
+
+seo_intro: |
+ The whole promise of tokenized stocks is that the onchain version
+ tracks the real market. During regular trading hours, that only
+ works if arbitrageurs close the gap when the Nasdaq moves. This
+ page measures exactly how long they take. Each time the Yahoo
+ reference for one of our 11 tracked equities moves 50 basis points
+ or more in a single minute, the harness starts a timer, and stops
+ it when the onchain Uniswap v4 pool comes back within 20 bps of
+ the reference. The published number is the median of those
+ intervals, per symbol, over the last 24 hours. Fast arbs mean the
+ market is real. Slow arbs mean the tokenized version is a
+ screenshot of the real one.
+
+abstract: |
+ A derived bench on top of the tokenized-stock-peg series already
+ in Prometheus. During market_state=\"regular\", the harness tracks
+ the trailing 1-minute reference move and the current pool-to-ref
+ deviation, and observes the seconds between a 25 bps trigger and
+ 20 bps convergence into a histogram, per asset.
+
+methodology:
+ - "Trigger: a one-minute reference-price move of at least 25 bps observed while market_state=\"regular\". Only regular Nasdaq hours generate events; pre- and post-market moves are excluded (thin liquidity, unreliable reference)."
+ - "Convergence: the pool-to-reference deviation drops back below 20 bps. The bench observes seconds from trigger to convergence into a histogram bucket, tagged per asset."
+ - "Publication: p50, p90 and p99 of the event distribution over 24 hours, per asset. Events that never converge before regular hours end are counted as still_open on a separate counter, not as latency samples."
+ - "Data source: the tsp_price_reference_usd and tsp_price_onchain_usdg series emitted by the tokenized-stock-peg harness at 60s cadence. The event tracker is in-process; no new external calls."
+ - "Cohort: the 6 liquid tokenized equities the peg bench measures. Arb latency is a first-order signal of pool quality; the peg deviation number is the second-order signal (how close it stays between moves)."
+ - "Caveat: the 25 bps trigger filters out normal market chop while catching real moves. Symbols with fewer than 3 events per 24h during quiet market days will show sparse data; the sample_size column is the honest read of statistical weight."
+
+findings:
+ - "{{best_name}} closes a 25 bps gap fastest at {{best_p50}} (p50, 24h) across {{count}} tokenized equities."
+ - "Fast arbs on the liquid low-fee pools validate that the market is real during regular hours; which tickers those are on a given day depends on where the 25 bps moves landed."
+ - "Slow arbs on the 2 percent fee pools confirm the story the peg bench tells: thin pools do not just deviate more, they take longer to snap back."
+ - "The still_open counter is the number that matters most for downstream products using the pool price as an oracle: it is the count of moves the arbs did not close before regular hours ended."
+
+faq:
+ - q: "What is arb latency and why does it matter?"
+ a: "It is the time it takes for an arbitrageur to close the gap between the real stock price and the onchain pool price after the real one moves. Short latency means the tokenized version is a real reflection of the real market. Long latency means it is a delayed screenshot with a stale price."
+ - q: "How are events detected?"
+ a: "The harness reads the same Yahoo reference and pool spot every 60 seconds. When the reference moves 25 bps in a single minute while the pool is more than 20 bps off it, a new event opens. The event closes when the pool comes back within 20 bps. Time from open to close is observed into a histogram; p50, p90 and p99 are read from that histogram over 24 hours."
+ - q: "Why 25 bps trigger, 20 bps convergence?"
+ a: "25 bps is above normal intra-minute noise on liquid stocks and low enough to catch a few events per day per liquid symbol. 20 bps is the convergence band that means the pool is meaningfully close to the reference given the fee tiers of the pools measured (0.3 percent to 2 percent). Both thresholds are disclosed and can be varied in a follow-up dimension."
+ - q: "What is the still-open counter?"
+ a: "The number of events where the market session ended (Nasdaq closed) before the pool converged. Those are not counted as latency samples because the reference stops moving after 4 pm ET, but they are the honest measure of how many real-market moves the arbs failed to catch that day."
+
+source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/tokenized-stock-peg
+
+prometheus:
+ window: 24h
+ freshness_metric: tsp_deviation_bps
+
+providers:
+ - slug: nvda
+ name: NVDA
+ tag: "Nvidia, 0.3% fee, deepest pool"
+ formula: "Median seconds until the NVDA pool on Robinhood Chain came back within 20 bps of the Yahoo reference after a 25 bps one-minute move, regular hours only, over 24h."
+ queries:
+ p50: histogram_quantile(0.50, sum by (le) (rate(tsp_arb_latency_seconds_bucket{issuer="robinhood", asset="nvda"}[24h])))
+ p90: histogram_quantile(0.90, sum by (le) (rate(tsp_arb_latency_seconds_bucket{issuer="robinhood", asset="nvda"}[24h])))
+ p99: histogram_quantile(0.99, sum by (le) (rate(tsp_arb_latency_seconds_bucket{issuer="robinhood", asset="nvda"}[24h])))
+ mean: sum(rate(tsp_arb_latency_seconds_sum{issuer="robinhood", asset="nvda"}[24h])) / sum(rate(tsp_arb_latency_seconds_count{issuer="robinhood", asset="nvda"}[24h]))
+ success: sum(increase(tsp_arb_event_total{issuer="robinhood", asset="nvda", outcome="converged"}[24h])) / clamp_min(sum(increase(tsp_arb_event_total{issuer="robinhood", asset="nvda"}[24h])), 1)
+ sample_size: sum(increase(tsp_arb_event_total{issuer="robinhood", asset="nvda", outcome="converged"}[24h]))
+ series: histogram_quantile(0.50, sum by (le) (rate(tsp_arb_latency_seconds_bucket{issuer="robinhood", asset="nvda"}[1h])))
+
+ - slug: aapl
+ name: AAPL
+ tag: "Apple, 1% fee"
+ formula: "Median seconds until the AAPL pool on Robinhood Chain came back within 20 bps of the Yahoo reference after a 25 bps one-minute move, regular hours only, over 24h."
+ queries:
+ p50: histogram_quantile(0.50, sum by (le) (rate(tsp_arb_latency_seconds_bucket{issuer="robinhood", asset="aapl"}[24h])))
+ p90: histogram_quantile(0.90, sum by (le) (rate(tsp_arb_latency_seconds_bucket{issuer="robinhood", asset="aapl"}[24h])))
+ p99: histogram_quantile(0.99, sum by (le) (rate(tsp_arb_latency_seconds_bucket{issuer="robinhood", asset="aapl"}[24h])))
+ mean: sum(rate(tsp_arb_latency_seconds_sum{issuer="robinhood", asset="aapl"}[24h])) / sum(rate(tsp_arb_latency_seconds_count{issuer="robinhood", asset="aapl"}[24h]))
+ success: sum(increase(tsp_arb_event_total{issuer="robinhood", asset="aapl", outcome="converged"}[24h])) / clamp_min(sum(increase(tsp_arb_event_total{issuer="robinhood", asset="aapl"}[24h])), 1)
+ sample_size: sum(increase(tsp_arb_event_total{issuer="robinhood", asset="aapl", outcome="converged"}[24h]))
+ series: histogram_quantile(0.50, sum by (le) (rate(tsp_arb_latency_seconds_bucket{issuer="robinhood", asset="aapl"}[1h])))
+
+ - slug: meta
+ name: META
+ tag: "Meta, 0.3% fee"
+ formula: "Median seconds until the META pool on Robinhood Chain came back within 20 bps of the Yahoo reference after a 25 bps one-minute move, regular hours only, over 24h."
+ queries:
+ p50: histogram_quantile(0.50, sum by (le) (rate(tsp_arb_latency_seconds_bucket{issuer="robinhood", asset="meta"}[24h])))
+ p90: histogram_quantile(0.90, sum by (le) (rate(tsp_arb_latency_seconds_bucket{issuer="robinhood", asset="meta"}[24h])))
+ p99: histogram_quantile(0.99, sum by (le) (rate(tsp_arb_latency_seconds_bucket{issuer="robinhood", asset="meta"}[24h])))
+ mean: sum(rate(tsp_arb_latency_seconds_sum{issuer="robinhood", asset="meta"}[24h])) / sum(rate(tsp_arb_latency_seconds_count{issuer="robinhood", asset="meta"}[24h]))
+ success: sum(increase(tsp_arb_event_total{issuer="robinhood", asset="meta", outcome="converged"}[24h])) / clamp_min(sum(increase(tsp_arb_event_total{issuer="robinhood", asset="meta"}[24h])), 1)
+ sample_size: sum(increase(tsp_arb_event_total{issuer="robinhood", asset="meta", outcome="converged"}[24h]))
+ series: histogram_quantile(0.50, sum by (le) (rate(tsp_arb_latency_seconds_bucket{issuer="robinhood", asset="meta"}[1h])))
+
+ - slug: amd
+ name: AMD
+ tag: "AMD, 1% fee"
+ formula: "Median seconds until the AMD pool on Robinhood Chain came back within 20 bps of the Yahoo reference after a 25 bps one-minute move, regular hours only, over 24h."
+ queries:
+ p50: histogram_quantile(0.50, sum by (le) (rate(tsp_arb_latency_seconds_bucket{issuer="robinhood", asset="amd"}[24h])))
+ p90: histogram_quantile(0.90, sum by (le) (rate(tsp_arb_latency_seconds_bucket{issuer="robinhood", asset="amd"}[24h])))
+ p99: histogram_quantile(0.99, sum by (le) (rate(tsp_arb_latency_seconds_bucket{issuer="robinhood", asset="amd"}[24h])))
+ mean: sum(rate(tsp_arb_latency_seconds_sum{issuer="robinhood", asset="amd"}[24h])) / sum(rate(tsp_arb_latency_seconds_count{issuer="robinhood", asset="amd"}[24h]))
+ success: sum(increase(tsp_arb_event_total{issuer="robinhood", asset="amd", outcome="converged"}[24h])) / clamp_min(sum(increase(tsp_arb_event_total{issuer="robinhood", asset="amd"}[24h])), 1)
+ sample_size: sum(increase(tsp_arb_event_total{issuer="robinhood", asset="amd", outcome="converged"}[24h]))
+ series: histogram_quantile(0.50, sum by (le) (rate(tsp_arb_latency_seconds_bucket{issuer="robinhood", asset="amd"}[1h])))
+
+ - slug: pltr
+ name: PLTR
+ tag: "Palantir, 1% fee"
+ formula: "Median seconds until the PLTR pool on Robinhood Chain came back within 20 bps of the Yahoo reference after a 25 bps one-minute move, regular hours only, over 24h."
+ queries:
+ p50: histogram_quantile(0.50, sum by (le) (rate(tsp_arb_latency_seconds_bucket{issuer="robinhood", asset="pltr"}[24h])))
+ p90: histogram_quantile(0.90, sum by (le) (rate(tsp_arb_latency_seconds_bucket{issuer="robinhood", asset="pltr"}[24h])))
+ p99: histogram_quantile(0.99, sum by (le) (rate(tsp_arb_latency_seconds_bucket{issuer="robinhood", asset="pltr"}[24h])))
+ mean: sum(rate(tsp_arb_latency_seconds_sum{issuer="robinhood", asset="pltr"}[24h])) / sum(rate(tsp_arb_latency_seconds_count{issuer="robinhood", asset="pltr"}[24h]))
+ success: sum(increase(tsp_arb_event_total{issuer="robinhood", asset="pltr", outcome="converged"}[24h])) / clamp_min(sum(increase(tsp_arb_event_total{issuer="robinhood", asset="pltr"}[24h])), 1)
+ sample_size: sum(increase(tsp_arb_event_total{issuer="robinhood", asset="pltr", outcome="converged"}[24h]))
+ series: histogram_quantile(0.50, sum by (le) (rate(tsp_arb_latency_seconds_bucket{issuer="robinhood", asset="pltr"}[1h])))
+
+ - slug: mu
+ name: MU
+ tag: "Micron, 1% fee"
+ formula: "Median seconds until the MU pool on Robinhood Chain came back within 20 bps of the Yahoo reference after a 25 bps one-minute move, regular hours only, over 24h."
+ queries:
+ p50: histogram_quantile(0.50, sum by (le) (rate(tsp_arb_latency_seconds_bucket{issuer="robinhood", asset="mu"}[24h])))
+ p90: histogram_quantile(0.90, sum by (le) (rate(tsp_arb_latency_seconds_bucket{issuer="robinhood", asset="mu"}[24h])))
+ p99: histogram_quantile(0.99, sum by (le) (rate(tsp_arb_latency_seconds_bucket{issuer="robinhood", asset="mu"}[24h])))
+ mean: sum(rate(tsp_arb_latency_seconds_sum{issuer="robinhood", asset="mu"}[24h])) / sum(rate(tsp_arb_latency_seconds_count{issuer="robinhood", asset="mu"}[24h]))
+ success: sum(increase(tsp_arb_event_total{issuer="robinhood", asset="mu", outcome="converged"}[24h])) / clamp_min(sum(increase(tsp_arb_event_total{issuer="robinhood", asset="mu"}[24h])), 1)
+ sample_size: sum(increase(tsp_arb_event_total{issuer="robinhood", asset="mu", outcome="converged"}[24h]))
+ series: histogram_quantile(0.50, sum by (le) (rate(tsp_arb_latency_seconds_bucket{issuer="robinhood", asset="mu"}[1h])))
+
diff --git a/src/lib/removed-benches.ts b/src/lib/removed-benches.ts
index 16e3e1b8..45d7e767 100644
--- a/src/lib/removed-benches.ts
+++ b/src/lib/removed-benches.ts
@@ -42,14 +42,14 @@ export const REMOVED_BENCH_SLUGS = new Set([
"indexing-freshness",
"rpc-keyed-latency",
"explorer-chain-coverage",
- "tokenized-stock-arb-latency",
"portfolio-chain-coverage",
- // bench vague 2 (082-085): validating on staging until harnesses have
- // 48h of clean data on the VPS, then ship dev -> main. 081 renamed
- // to ws-head-latency-ethereum for slug parity with the base + solana
- // siblings; both siblings shipped to main without gating (harness has
- // clean data via Railway 3-region deploy), so the ethereum-scoped one
- // follows suit and is not gated.
+ // bench vague 2 remaining gated. 081 renamed to
+ // ws-head-latency-ethereum + shipped; 083 rpc-reliability shipped
+ // 2026-07-16 (Nodies leader, conf=healthy, cohort extended to +4
+ // keyless providers); 085 evm-block-builders shipped 2026-07-16;
+ // 080 tokenized-stock-arb-latency shipped 2026-07-16 (cohort
+ // reduced to 6 liquid tickers after 5 illiquid dropped). Only
+ // oracle-freshness stays gated pending drop decision (apples vs
+ // oranges push vs pull, redundant with 025 oracle-deviation).
"oracle-freshness",
- "rpc-reliability",
]);
diff --git a/src/lib/spec.ts b/src/lib/spec.ts
index 2acbb703..8ee7f169 100644
--- a/src/lib/spec.ts
+++ b/src/lib/spec.ts
@@ -297,7 +297,12 @@ const loadBenchmarkUnfilteredCached = unstable_cache(
// v31: 084 indexer-latency dropped entirely (spec + harness deleted;
// HyperSync + The Graph required paid credentials for sustained
// cadence). Bench SET shrank.
- ["bench-unfiltered-v32", process.env.VERCEL_ENV === "production" ? "prod" : "all"],
+ // v32: 085 evm-block-builders ungated on prod (Titan leader).
+ // v33: batch ship 080 tokenized-stock-arb-latency + 083 rpc-reliability
+ // to prod (files added + REMOVED entries removed) + 085 file finally
+ // copied to main (was ungated in v32 but file was dev-only). Bench
+ // SET grew by 3 slugs on prod.
+ ["bench-unfiltered-v33", process.env.VERCEL_ENV === "production" ? "prod" : "all"],
{ revalidate: 300, tags: ["benchmarks"] },
);
@@ -456,7 +461,9 @@ const loadAllBenchmarksCached = unstable_cache(
// v32: bumped with bench-unfiltered-v30 (081 slug rename ws-head-latency
// -> ws-head-latency-ethereum + ungate on prod).
// v34: bumped with bench-unfiltered-v31 (084 indexer-latency dropped).
- ["all-benchmarks-v35", process.env.VERCEL_ENV === "production" ? "prod" : "all"],
+ // v36: bumped with bench-unfiltered-v33 (batch ship 080 + 083 + 085
+ // file-add to main). Bench SET grew by 3 on prod.
+ ["all-benchmarks-v36", process.env.VERCEL_ENV === "production" ? "prod" : "all"],
{ revalidate: 300, tags: ["benchmarks"] },
);
export const loadAllBenchmarks = cache(loadAllBenchmarksCached);
From e23b22fb9966e7cfb4f4d871d06e2d17acb6bbb9 Mon Sep 17 00:00:00 2001
From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com>
Date: Thu, 16 Jul 2026 18:27:19 +0200
Subject: [PATCH 41/89] drop oracle-freshness (082) spec + sync REMOVED with
main state (#1245) (#1246)
Oracle-freshness deleted from the site: apples vs oranges (Chainlink push vs Pyth/RedStone pull measure different things), splitting into pull/push sub-benches would leave each with 1-2 providers, redundant with bench 025 oracle-deviation which measures the same feeds via CEX deviation with a more actionable signal.
Slug kept in REMOVED_BENCH_SLUGS as 410 Gone. Harness code in oracle-deviation retained (metrics still emit but no site reader).
Also syncs dev's REMOVED_BENCH_SLUGS with main: removes tokenized-stock-arb-latency + rpc-reliability (shipped to prod earlier today) so dev matches main state.
Cache keys bumped bench-unfiltered-v34 + all-benchmarks-v37.
Co-authored-by: Florent Tapponnier