Add bridge lane health API and config-ready lane UI for Tier A Week 3.
Probe LINK balances on CCIP bridge contracts, expose proof-transfer metadata on bridge status, and render funded/unfunded lane health on /bridge with extended smoke coverage. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
255
backend/api/track1/bridge_lanes.go
Normal file
255
backend/api/track1/bridge_lanes.go
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
package track1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
_ "embed"
|
||||||
|
"encoding/json"
|
||||||
|
"math/big"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed bridge_lanes_default.json
|
||||||
|
var defaultBridgeLanesJSON []byte
|
||||||
|
|
||||||
|
type bridgeLaneDefinition struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
ChainName string `json:"chain_name"`
|
||||||
|
ChainID int64 `json:"chain_id"`
|
||||||
|
ConfigReady bool `json:"config_ready"`
|
||||||
|
RPCEnvs []string `json:"rpc_envs"`
|
||||||
|
RPCDefault string `json:"rpc_default"`
|
||||||
|
LinkToken string `json:"link_token"`
|
||||||
|
WETH9Bridge string `json:"weth9_bridge"`
|
||||||
|
WETH10Bridge string `json:"weth10_bridge"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type bridgeLanesConfig struct {
|
||||||
|
Updated string `json:"updated"`
|
||||||
|
MinLinkWei string `json:"min_link_wei"`
|
||||||
|
Lanes []bridgeLaneDefinition `json:"lanes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadBridgeLanesConfig() bridgeLanesConfig {
|
||||||
|
path := strings.TrimSpace(os.Getenv("MISSION_CONTROL_BRIDGE_LANES_JSON"))
|
||||||
|
if path != "" {
|
||||||
|
if b, err := os.ReadFile(path); err == nil && len(b) > 0 {
|
||||||
|
var cfg bridgeLanesConfig
|
||||||
|
if json.Unmarshal(b, &cfg) == nil && len(cfg.Lanes) > 0 {
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var cfg bridgeLanesConfig
|
||||||
|
_ = json.Unmarshal(defaultBridgeLanesJSON, &cfg)
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveLaneRPC(def bridgeLaneDefinition) string {
|
||||||
|
for _, key := range def.RPCEnvs {
|
||||||
|
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, row := range ParseExtraRPCProbes() {
|
||||||
|
chainKey := row[2]
|
||||||
|
if chainKey == strconv.FormatInt(def.ChainID, 10) {
|
||||||
|
return row[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(def.RPCDefault)
|
||||||
|
}
|
||||||
|
|
||||||
|
func erc20BalanceOf(ctx context.Context, rpcURL, tokenAddress, holderAddress string) (string, error) {
|
||||||
|
tokenAddress = strings.ToLower(strings.TrimSpace(tokenAddress))
|
||||||
|
holderAddress = strings.ToLower(strings.TrimPrefix(strings.TrimSpace(holderAddress), "0x"))
|
||||||
|
if len(holderAddress) != 40 {
|
||||||
|
return "0", nil
|
||||||
|
}
|
||||||
|
data := "0x70a08231" + strings.Repeat("0", 24) + holderAddress
|
||||||
|
raw, _, err := postJSONRPC(ctx, bridgeLaneHTTPClient(), rpcURL, "eth_call", []interface{}{
|
||||||
|
map[string]interface{}{
|
||||||
|
"to": tokenAddress,
|
||||||
|
"data": data,
|
||||||
|
},
|
||||||
|
"latest",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
var hex string
|
||||||
|
if err := json.Unmarshal(raw, &hex); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
hex = strings.TrimSpace(hex)
|
||||||
|
if hex == "" || hex == "0x" {
|
||||||
|
return "0", nil
|
||||||
|
}
|
||||||
|
value := new(big.Int)
|
||||||
|
if _, ok := value.SetString(strings.TrimPrefix(hex, "0x"), 16); !ok {
|
||||||
|
return "0", nil
|
||||||
|
}
|
||||||
|
return value.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func bridgeLaneHTTPClient() *http.Client {
|
||||||
|
return &http.Client{Timeout: 6 * time.Second}
|
||||||
|
}
|
||||||
|
|
||||||
|
func bridgeFundingStatus(linkBalanceWei, minLinkWei string) string {
|
||||||
|
balance, okBalance := new(big.Int).SetString(strings.TrimSpace(linkBalanceWei), 10)
|
||||||
|
minimum, okMin := new(big.Int).SetString(strings.TrimSpace(minLinkWei), 10)
|
||||||
|
if !okBalance || !okMin {
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
if balance.Cmp(minimum) >= 0 {
|
||||||
|
return "funded"
|
||||||
|
}
|
||||||
|
if balance.Sign() > 0 {
|
||||||
|
return "degraded"
|
||||||
|
}
|
||||||
|
return "unfunded"
|
||||||
|
}
|
||||||
|
|
||||||
|
func proofStatusForLane(key string, proofs map[string]interface{}) string {
|
||||||
|
if proofs == nil {
|
||||||
|
return "proof-pending"
|
||||||
|
}
|
||||||
|
laneProofs, ok := proofs[key].([]interface{})
|
||||||
|
if !ok || len(laneProofs) == 0 {
|
||||||
|
return "proof-pending"
|
||||||
|
}
|
||||||
|
for _, item := range laneProofs {
|
||||||
|
row, ok := item.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if tx, ok := row["tx_hash"].(string); ok && strings.TrimSpace(tx) != "" {
|
||||||
|
return "proof-recorded"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "proof-pending"
|
||||||
|
}
|
||||||
|
|
||||||
|
func readProofTransfersJSON() map[string]interface{} {
|
||||||
|
path := strings.TrimSpace(os.Getenv("MISSION_CONTROL_PROOF_TRANSFERS_JSON"))
|
||||||
|
if path == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil || len(b) == 0 {
|
||||||
|
return map[string]interface{}{"error": "unreadable or empty", "path": path}
|
||||||
|
}
|
||||||
|
if len(b) > 512*1024 {
|
||||||
|
return map[string]interface{}{"error": "file too large", "path": path}
|
||||||
|
}
|
||||||
|
var payload map[string]interface{}
|
||||||
|
if err := json.Unmarshal(b, &payload); err != nil {
|
||||||
|
return map[string]interface{}{"error": err.Error(), "path": path}
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
func probeBridgeContract(ctx context.Context, rpcURL, linkToken, bridgeAddress, minLinkWei string) map[string]interface{} {
|
||||||
|
result := map[string]interface{}{
|
||||||
|
"bridge": strings.TrimSpace(bridgeAddress),
|
||||||
|
}
|
||||||
|
if rpcURL == "" {
|
||||||
|
result["status"] = "unknown"
|
||||||
|
result["error"] = "rpc unavailable"
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
if linkToken == "" || bridgeAddress == "" {
|
||||||
|
result["status"] = "unknown"
|
||||||
|
result["error"] = "missing link token or bridge address"
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
balance, err := erc20BalanceOf(ctx, rpcURL, linkToken, bridgeAddress)
|
||||||
|
if err != nil {
|
||||||
|
result["status"] = "unknown"
|
||||||
|
result["error"] = err.Error()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
result["link_balance_wei"] = balance
|
||||||
|
result["status"] = bridgeFundingStatus(balance, minLinkWei)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func aggregateLaneStatus(weth9Status, weth10Status, proofStatus string) string {
|
||||||
|
statuses := []string{weth9Status, weth10Status}
|
||||||
|
hasUnfunded := false
|
||||||
|
hasDegraded := false
|
||||||
|
hasUnknown := false
|
||||||
|
for _, status := range statuses {
|
||||||
|
switch status {
|
||||||
|
case "unfunded":
|
||||||
|
hasUnfunded = true
|
||||||
|
case "degraded":
|
||||||
|
hasDegraded = true
|
||||||
|
case "unknown":
|
||||||
|
hasUnknown = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hasUnfunded {
|
||||||
|
return "unfunded"
|
||||||
|
}
|
||||||
|
if hasDegraded {
|
||||||
|
return "degraded"
|
||||||
|
}
|
||||||
|
if hasUnknown {
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
if proofStatus == "proof-pending" {
|
||||||
|
return "proof-pending"
|
||||||
|
}
|
||||||
|
return "funded"
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildBridgeLaneHealth(ctx context.Context) (map[string]interface{}, map[string]interface{}) {
|
||||||
|
cfg := loadBridgeLanesConfig()
|
||||||
|
minLinkWei := strings.TrimSpace(cfg.MinLinkWei)
|
||||||
|
if minLinkWei == "" {
|
||||||
|
minLinkWei = "1000000000000000000"
|
||||||
|
}
|
||||||
|
|
||||||
|
proofPayload := readProofTransfersJSON()
|
||||||
|
proofByLane := map[string]interface{}{}
|
||||||
|
if proofPayload != nil {
|
||||||
|
if lanes, ok := proofPayload["lanes"].(map[string]interface{}); ok {
|
||||||
|
proofByLane = lanes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lanes := make([]map[string]interface{}, 0, len(cfg.Lanes))
|
||||||
|
for _, def := range cfg.Lanes {
|
||||||
|
rpcURL := resolveLaneRPC(def)
|
||||||
|
weth9 := probeBridgeContract(ctx, rpcURL, def.LinkToken, def.WETH9Bridge, minLinkWei)
|
||||||
|
weth10 := probeBridgeContract(ctx, rpcURL, def.LinkToken, def.WETH10Bridge, minLinkWei)
|
||||||
|
weth9Status, _ := weth9["status"].(string)
|
||||||
|
weth10Status, _ := weth10["status"].(string)
|
||||||
|
proofStatus := proofStatusForLane(def.Key, proofByLane)
|
||||||
|
|
||||||
|
lanes = append(lanes, map[string]interface{}{
|
||||||
|
"key": def.Key,
|
||||||
|
"chain_name": def.ChainName,
|
||||||
|
"chain_id": def.ChainID,
|
||||||
|
"config_ready": def.ConfigReady,
|
||||||
|
"link_token": def.LinkToken,
|
||||||
|
"status": aggregateLaneStatus(weth9Status, weth10Status, proofStatus),
|
||||||
|
"proof_status": proofStatus,
|
||||||
|
"weth9": weth9,
|
||||||
|
"weth10": weth10,
|
||||||
|
"rpc_endpoint": redactRPCOrigin(rpcURL),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
laneHealth := map[string]interface{}{
|
||||||
|
"updated_at": time.Now().UTC().Format(time.RFC3339),
|
||||||
|
"min_link_wei": minLinkWei,
|
||||||
|
"lanes": lanes,
|
||||||
|
}
|
||||||
|
return laneHealth, proofPayload
|
||||||
|
}
|
||||||
61
backend/api/track1/bridge_lanes_default.json
Normal file
61
backend/api/track1/bridge_lanes_default.json
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
{
|
||||||
|
"updated": "2026-05-23",
|
||||||
|
"min_link_wei": "1000000000000000000",
|
||||||
|
"lanes": [
|
||||||
|
{
|
||||||
|
"key": "chain138",
|
||||||
|
"chain_name": "Defi Oracle Meta Mainnet (138)",
|
||||||
|
"chain_id": 138,
|
||||||
|
"config_ready": true,
|
||||||
|
"rpc_envs": ["RPC_URL", "RPC_URL_138"],
|
||||||
|
"rpc_default": "http://192.168.11.211:8545",
|
||||||
|
"link_token": "0xb7721dd53a8c629d9f1ba31a5819afe250002b03",
|
||||||
|
"weth9_bridge": "0xcacfd227A040002e49e2e01626363071324f820a",
|
||||||
|
"weth10_bridge": "0xe0E93247376aa097dB308B92e6Ba36bA015535D0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "gnosis",
|
||||||
|
"chain_name": "Gnosis (100)",
|
||||||
|
"chain_id": 100,
|
||||||
|
"config_ready": true,
|
||||||
|
"rpc_envs": ["GNOSIS_RPC", "GNOSIS_MAINNET_RPC", "GNOSIS_RPC_URL"],
|
||||||
|
"rpc_default": "https://rpc.gnosischain.com",
|
||||||
|
"link_token": "0xE2e73A1c69ecF83F464EFCE6A5be353a37cA09b2",
|
||||||
|
"weth9_bridge": "0xc8656F24488cb90c452058da92d1a25BA464eaAE",
|
||||||
|
"weth10_bridge": "0xa846aeAD3071df1b6439d5D813156aCE7C2c1DA1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "cronos",
|
||||||
|
"chain_name": "Cronos (25)",
|
||||||
|
"chain_id": 25,
|
||||||
|
"config_ready": true,
|
||||||
|
"rpc_envs": ["CRONOS_RPC", "CRONOS_RPC_URL", "CRONOS_MAINNET_RPC"],
|
||||||
|
"rpc_default": "https://evm.cronos.org",
|
||||||
|
"link_token": "0x8c80A01F461f297Df7F9DA3A4f740D7297C8Ac85",
|
||||||
|
"weth9_bridge": "0x3Cc23d086fCcbAe1e5f3FE2bA4A263E1D27d8Cab",
|
||||||
|
"weth10_bridge": "0x105F8A15b819948a89153505762444Ee9f324684"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "celo",
|
||||||
|
"chain_name": "Celo (42220)",
|
||||||
|
"chain_id": 42220,
|
||||||
|
"config_ready": true,
|
||||||
|
"rpc_envs": ["CELO_RPC", "CELO_MAINNET_RPC"],
|
||||||
|
"rpc_default": "https://forno.celo.org",
|
||||||
|
"link_token": "0xd07294e6E917e07dfDcee882dd1e2565085C2ae0",
|
||||||
|
"weth9_bridge": "0xAb57BF30F1354CA0590af22D8974c7f24DB2DbD7",
|
||||||
|
"weth10_bridge": "0xa780ef19A041745d353c9432f2a7f5A241335ffE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "wemix",
|
||||||
|
"chain_name": "Wemix (1111)",
|
||||||
|
"chain_id": 1111,
|
||||||
|
"config_ready": true,
|
||||||
|
"rpc_envs": ["WEMIX_RPC", "WEMIX_MAINNET_RPC"],
|
||||||
|
"rpc_default": "https://api.wemix.com",
|
||||||
|
"link_token": "0x80f1FcdC96B55e459BF52b998aBBE2c364935d69",
|
||||||
|
"weth9_bridge": "0xD3AD6831aacB5386B8A25BB8D8176a6C8a026f04",
|
||||||
|
"weth10_bridge": "0xa4B9DD039565AeD9641D45b57061f99d9cA6Df08"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
37
backend/api/track1/bridge_lanes_test.go
Normal file
37
backend/api/track1/bridge_lanes_test.go
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
package track1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBridgeFundingStatus(t *testing.T) {
|
||||||
|
require.Equal(t, "funded", bridgeFundingStatus("2000000000000000000", "1000000000000000000"))
|
||||||
|
require.Equal(t, "degraded", bridgeFundingStatus("500000000000000000", "1000000000000000000"))
|
||||||
|
require.Equal(t, "unfunded", bridgeFundingStatus("0", "1000000000000000000"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAggregateLaneStatus(t *testing.T) {
|
||||||
|
require.Equal(t, "unfunded", aggregateLaneStatus("unfunded", "funded", "proof-recorded"))
|
||||||
|
require.Equal(t, "degraded", aggregateLaneStatus("degraded", "funded", "proof-recorded"))
|
||||||
|
require.Equal(t, "proof-pending", aggregateLaneStatus("funded", "funded", "proof-pending"))
|
||||||
|
require.Equal(t, "funded", aggregateLaneStatus("funded", "funded", "proof-recorded"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProofStatusForLane(t *testing.T) {
|
||||||
|
proofs := map[string]interface{}{
|
||||||
|
"gnosis": []interface{}{
|
||||||
|
map[string]interface{}{"tx_hash": "0xabc"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
require.Equal(t, "proof-recorded", proofStatusForLane("gnosis", proofs))
|
||||||
|
require.Equal(t, "proof-pending", proofStatusForLane("cronos", proofs))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadBridgeLanesConfigDefault(t *testing.T) {
|
||||||
|
t.Setenv("MISSION_CONTROL_BRIDGE_LANES_JSON", "")
|
||||||
|
cfg := loadBridgeLanesConfig()
|
||||||
|
require.NotEmpty(t, cfg.Lanes)
|
||||||
|
require.NotEmpty(t, cfg.MinLinkWei)
|
||||||
|
}
|
||||||
@@ -30,6 +30,14 @@ func TestResolveBridgeDeliveryModeMixedWhenTransactionVisibilityStale(t *testing
|
|||||||
require.Equal(t, "bridge_monitoring_and_homepage", got.Scope)
|
require.Equal(t, "bridge_monitoring_and_homepage", got.Scope)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestResolveBridgeDeliveryModeMixedWhenQuietChain(t *testing.T) {
|
||||||
|
diagnostics := &freshness.Diagnostics{
|
||||||
|
ActivityState: "quiet_chain",
|
||||||
|
}
|
||||||
|
got := resolveBridgeDeliveryMode(false, diagnostics, freshness.CompletenessComplete)
|
||||||
|
require.Equal(t, "live", got.Kind)
|
||||||
|
}
|
||||||
|
|
||||||
func TestIsStaleTransactionVisibility(t *testing.T) {
|
func TestIsStaleTransactionVisibility(t *testing.T) {
|
||||||
require.True(t, isStaleTransactionVisibility(&freshness.Diagnostics{ActivityState: "fresh_head_stale_transaction_visibility"}))
|
require.True(t, isStaleTransactionVisibility(&freshness.Diagnostics{ActivityState: "fresh_head_stale_transaction_visibility"}))
|
||||||
require.False(t, isStaleTransactionVisibility(&freshness.Diagnostics{ActivityState: "healthy"}))
|
require.False(t, isStaleTransactionVisibility(&freshness.Diagnostics{ActivityState: "healthy"}))
|
||||||
|
|||||||
@@ -198,6 +198,12 @@ func (s *Server) BuildBridgeStatusData(ctx context.Context) map[string]interface
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if laneHealth, proofTransfers := BuildBridgeLaneHealth(ctx); laneHealth != nil {
|
||||||
|
data["bridge_lanes"] = laneHealth
|
||||||
|
if proofTransfers != nil {
|
||||||
|
data["proof_transfers"] = proofTransfers
|
||||||
|
}
|
||||||
|
}
|
||||||
if mode, ok := data["mode"].(map[string]interface{}); ok {
|
if mode, ok := data["mode"].(map[string]interface{}); ok {
|
||||||
if relays, ok := data["ccip_relays"].(map[string]interface{}); ok && len(relays) > 0 {
|
if relays, ok := data["ccip_relays"].(map[string]interface{}); ok && len(relays) > 0 {
|
||||||
var diagnostics *freshness.Diagnostics
|
var diagnostics *freshness.Diagnostics
|
||||||
|
|||||||
61
config/explorer-bridge-lanes.v1.json
Normal file
61
config/explorer-bridge-lanes.v1.json
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
{
|
||||||
|
"updated": "2026-05-23",
|
||||||
|
"min_link_wei": "1000000000000000000",
|
||||||
|
"lanes": [
|
||||||
|
{
|
||||||
|
"key": "chain138",
|
||||||
|
"chain_name": "Defi Oracle Meta Mainnet (138)",
|
||||||
|
"chain_id": 138,
|
||||||
|
"config_ready": true,
|
||||||
|
"rpc_envs": ["RPC_URL", "RPC_URL_138"],
|
||||||
|
"rpc_default": "http://192.168.11.211:8545",
|
||||||
|
"link_token": "0xb7721dd53a8c629d9f1ba31a5819afe250002b03",
|
||||||
|
"weth9_bridge": "0xcacfd227A040002e49e2e01626363071324f820a",
|
||||||
|
"weth10_bridge": "0xe0E93247376aa097dB308B92e6Ba36bA015535D0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "gnosis",
|
||||||
|
"chain_name": "Gnosis (100)",
|
||||||
|
"chain_id": 100,
|
||||||
|
"config_ready": true,
|
||||||
|
"rpc_envs": ["GNOSIS_RPC", "GNOSIS_MAINNET_RPC", "GNOSIS_RPC_URL"],
|
||||||
|
"rpc_default": "https://rpc.gnosischain.com",
|
||||||
|
"link_token": "0xE2e73A1c69ecF83F464EFCE6A5be353a37cA09b2",
|
||||||
|
"weth9_bridge": "0xc8656F24488cb90c452058da92d1a25BA464eaAE",
|
||||||
|
"weth10_bridge": "0xa846aeAD3071df1b6439d5D813156aCE7C2c1DA1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "cronos",
|
||||||
|
"chain_name": "Cronos (25)",
|
||||||
|
"chain_id": 25,
|
||||||
|
"config_ready": true,
|
||||||
|
"rpc_envs": ["CRONOS_RPC", "CRONOS_RPC_URL", "CRONOS_MAINNET_RPC"],
|
||||||
|
"rpc_default": "https://evm.cronos.org",
|
||||||
|
"link_token": "0x8c80A01F461f297Df7F9DA3A4f740D7297C8Ac85",
|
||||||
|
"weth9_bridge": "0x3Cc23d086fCcbAe1e5f3FE2bA4A263E1D27d8Cab",
|
||||||
|
"weth10_bridge": "0x105F8A15b819948a89153505762444Ee9f324684"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "celo",
|
||||||
|
"chain_name": "Celo (42220)",
|
||||||
|
"chain_id": 42220,
|
||||||
|
"config_ready": true,
|
||||||
|
"rpc_envs": ["CELO_RPC", "CELO_MAINNET_RPC"],
|
||||||
|
"rpc_default": "https://forno.celo.org",
|
||||||
|
"link_token": "0xd07294e6E917e07dfDcee882dd1e2565085C2ae0",
|
||||||
|
"weth9_bridge": "0xAb57BF30F1354CA0590af22D8974c7f24DB2DbD7",
|
||||||
|
"weth10_bridge": "0xa780ef19A041745d353c9432f2a7f5A241335ffE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "wemix",
|
||||||
|
"chain_name": "Wemix (1111)",
|
||||||
|
"chain_id": 1111,
|
||||||
|
"config_ready": true,
|
||||||
|
"rpc_envs": ["WEMIX_RPC", "WEMIX_MAINNET_RPC"],
|
||||||
|
"rpc_default": "https://api.wemix.com",
|
||||||
|
"link_token": "0x80f1FcdC96B55e459BF52b998aBBE2c364935d69",
|
||||||
|
"weth9_bridge": "0xD3AD6831aacB5386B8A25BB8D8176a6C8a026f04",
|
||||||
|
"weth10_bridge": "0xa4B9DD039565AeD9641D45b57061f99d9cA6Df08"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
107
frontend/src/components/explorer/BridgeLaneHealthPanel.tsx
Normal file
107
frontend/src/components/explorer/BridgeLaneHealthPanel.tsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { Card } from '@/libs/frontend-ui-primitives'
|
||||||
|
import EntityBadge from '@/components/common/EntityBadge'
|
||||||
|
import type { MissionControlBridgeLane, MissionControlBridgeLaneHealth } from '@/services/api/missionControl'
|
||||||
|
|
||||||
|
function laneTone(status?: string | null): 'success' | 'warning' | 'info' | 'neutral' {
|
||||||
|
switch (String(status || '').toLowerCase()) {
|
||||||
|
case 'funded':
|
||||||
|
case 'proof-recorded':
|
||||||
|
return 'success'
|
||||||
|
case 'degraded':
|
||||||
|
case 'proof-pending':
|
||||||
|
return 'warning'
|
||||||
|
case 'unfunded':
|
||||||
|
return 'warning'
|
||||||
|
default:
|
||||||
|
return 'neutral'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLinkBalance(wei?: string | null): string {
|
||||||
|
if (!wei) return 'Unknown'
|
||||||
|
try {
|
||||||
|
const value = BigInt(wei)
|
||||||
|
const whole = value / 10n ** 18n
|
||||||
|
const fractional = (value % 10n ** 18n).toString().padStart(18, '0').slice(0, 4).replace(/0+$/, '')
|
||||||
|
return fractional ? `${whole}.${fractional} LINK` : `${whole} LINK`
|
||||||
|
} catch {
|
||||||
|
return wei
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BridgeLaneHealthPanel({
|
||||||
|
laneHealth,
|
||||||
|
className = '',
|
||||||
|
}: {
|
||||||
|
laneHealth?: MissionControlBridgeLaneHealth | null
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
const lanes = laneHealth?.lanes || []
|
||||||
|
if (lanes.length === 0) return null
|
||||||
|
|
||||||
|
const normalizedClassName = className ? ` ${className}` : ''
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card title="Config-ready lane health" className={`mb-8${normalizedClassName}`}>
|
||||||
|
<p className="mb-4 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
LINK balances are read from each remote CCIP bridge contract. Proof-transfer status comes from operator-recorded CCIP message hashes when available.
|
||||||
|
</p>
|
||||||
|
<p className="mb-4 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Operator runbook: fund LINK with{' '}
|
||||||
|
<code className="rounded bg-gray-100 px-1.5 py-0.5 text-xs dark:bg-gray-900">fund-ccip-bridges-with-link.sh</code>
|
||||||
|
{' '}· lane probe{' '}
|
||||||
|
<code className="rounded bg-gray-100 px-1.5 py-0.5 text-xs dark:bg-gray-900">probe-bridge-lane-link-balances.sh</code>
|
||||||
|
{' '}· routing reference{' '}
|
||||||
|
<Link href="/docs/public-api-access" className="text-primary-600 hover:underline">
|
||||||
|
public API access
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-200 text-left text-xs uppercase tracking-wide text-gray-500 dark:border-gray-700 dark:text-gray-400">
|
||||||
|
<th className="py-2 pr-4">Lane</th>
|
||||||
|
<th className="py-2 pr-4">Overall</th>
|
||||||
|
<th className="py-2 pr-4">Proof</th>
|
||||||
|
<th className="py-2 pr-4">WETH9 bridge</th>
|
||||||
|
<th className="py-2 pr-4">WETH10 bridge</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{lanes.map((lane: MissionControlBridgeLane) => (
|
||||||
|
<tr key={lane.key} className="border-b border-gray-100 align-top last:border-0 dark:border-gray-800">
|
||||||
|
<td className="py-3 pr-4">
|
||||||
|
<div className="font-medium text-gray-900 dark:text-white">{lane.chain_name || lane.key}</div>
|
||||||
|
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">chain {lane.chain_id ?? '?'}</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 pr-4">
|
||||||
|
<EntityBadge label={lane.status || 'unknown'} tone={laneTone(lane.status)} />
|
||||||
|
</td>
|
||||||
|
<td className="py-3 pr-4">
|
||||||
|
<EntityBadge label={lane.proof_status || 'proof-pending'} tone={laneTone(lane.proof_status)} />
|
||||||
|
</td>
|
||||||
|
<td className="py-3 pr-4">
|
||||||
|
<div className="font-mono text-xs text-gray-700 dark:text-gray-300">{lane.weth9?.bridge || '—'}</div>
|
||||||
|
<div className="mt-1 flex flex-wrap items-center gap-2">
|
||||||
|
<EntityBadge label={lane.weth9?.status || 'unknown'} tone={laneTone(lane.weth9?.status)} className="px-2 py-0.5 text-[11px]" />
|
||||||
|
<span className="text-xs text-gray-600 dark:text-gray-400">{formatLinkBalance(lane.weth9?.link_balance_wei)}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 pr-4">
|
||||||
|
<div className="font-mono text-xs text-gray-700 dark:text-gray-300">{lane.weth10?.bridge || '—'}</div>
|
||||||
|
<div className="mt-1 flex flex-wrap items-center gap-2">
|
||||||
|
<EntityBadge label={lane.weth10?.status || 'unknown'} tone={laneTone(lane.weth10?.status)} className="px-2 py-0.5 text-[11px]" />
|
||||||
|
<span className="text-xs text-gray-600 dark:text-gray-400">{formatLinkBalance(lane.weth10?.link_balance_wei)}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ import { resolveEffectiveFreshness } from '@/utils/explorerFreshness'
|
|||||||
import { bridgeRoutesApi, normalizeBridgeRouteEntries, type BridgeRoutesResponse } from '@/services/api/bridgeRoutes'
|
import { bridgeRoutesApi, normalizeBridgeRouteEntries, type BridgeRoutesResponse } from '@/services/api/bridgeRoutes'
|
||||||
import { createVisibilityAwarePoller } from '@/utils/visibilityRefresh'
|
import { createVisibilityAwarePoller } from '@/utils/visibilityRefresh'
|
||||||
import { HOME_DASHBOARD_REFRESH_MS } from '@/utils/featuredTokens'
|
import { HOME_DASHBOARD_REFRESH_MS } from '@/utils/featuredTokens'
|
||||||
|
import BridgeLaneHealthPanel from '@/components/explorer/BridgeLaneHealthPanel'
|
||||||
import OperationsSurfaceNav from './OperationsSurfaceNav'
|
import OperationsSurfaceNav from './OperationsSurfaceNav'
|
||||||
import OperationsActionGrid from './OperationsActionGrid'
|
import OperationsActionGrid from './OperationsActionGrid'
|
||||||
|
|
||||||
@@ -460,6 +461,8 @@ export default function BridgeMonitoringPage({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<BridgeLaneHealthPanel laneHealth={bridgeStatus?.data?.bridge_lanes} />
|
||||||
|
|
||||||
{routeEntries.length > 0 ? (
|
{routeEntries.length > 0 ? (
|
||||||
<Card title="CCIP route catalog" className="mb-8">
|
<Card title="CCIP route catalog" className="mb-8">
|
||||||
<p className="mb-4 text-sm text-gray-600 dark:text-gray-400">
|
<p className="mb-4 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ const docsCards = [
|
|||||||
href: '/docs/posture-glossary',
|
href: '/docs/posture-glossary',
|
||||||
description: 'First-read definitions for x402, ISO-20022, forward-canonical, cW public-network, and related explorer badges.',
|
description: 'First-read definitions for x402, ISO-20022, forward-canonical, cW public-network, and related explorer badges.',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Config compatibility keys',
|
||||||
|
href: '/docs/posture-glossary#transportactive-config-compatibility',
|
||||||
|
description: 'Methodology for public /config compatibility keys (transportActive, forward-canonical) and planned v2 alias mapping.',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: 'Public API access',
|
title: 'Public API access',
|
||||||
href: '/docs/public-api-access',
|
href: '/docs/public-api-access',
|
||||||
|
|||||||
@@ -36,7 +36,8 @@ export default function PostureGlossaryDocsPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{postureGlossaryTerms.map((term) => (
|
{postureGlossaryTerms.map((term) => (
|
||||||
<Card key={term.id} title={term.title}>
|
<div key={term.id} id={term.id === 'transport-active' ? 'transportactive-config-compatibility' : undefined}>
|
||||||
|
<Card title={term.title}>
|
||||||
<div className="space-y-3 text-sm leading-6 text-gray-600 dark:text-gray-400">
|
<div className="space-y-3 text-sm leading-6 text-gray-600 dark:text-gray-400">
|
||||||
<p>{term.summary}</p>
|
<p>{term.summary}</p>
|
||||||
<div className="rounded-xl border border-sky-200 bg-sky-50/70 p-4 text-sky-950 dark:border-sky-900/50 dark:bg-sky-950/20 dark:text-sky-100">
|
<div className="rounded-xl border border-sky-200 bg-sky-50/70 p-4 text-sky-950 dark:border-sky-900/50 dark:bg-sky-950/20 dark:text-sky-100">
|
||||||
@@ -52,6 +53,7 @@ export default function PostureGlossaryDocsPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<Card title="Related references">
|
<Card title="Related references">
|
||||||
|
|||||||
@@ -365,7 +365,9 @@ export default function TokenDetailPage() {
|
|||||||
<PostureBadge label="Non-canonical indexed token" tone="warning" />
|
<PostureBadge label="Non-canonical indexed token" tone="warning" />
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-3 text-sm leading-6 text-amber-950 dark:text-amber-100">
|
<p className="mt-3 text-sm leading-6 text-amber-950 dark:text-amber-100">
|
||||||
This contract is indexed by Blockscout but is not in the curated Chain 138 token registry. Prefer canonical addresses from the token index for trading, liquidity, and bridge routing.
|
This contract is indexed by Blockscout but is not in the curated Chain 138 token registry. Prefer canonical addresses from the{' '}
|
||||||
|
<Link href="/tokens" className="font-medium text-primary-600 hover:underline">token index</Link>
|
||||||
|
{' '}and the posture glossary for trading, liquidity, and bridge routing.
|
||||||
</p>
|
</p>
|
||||||
</Card>
|
</Card>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -95,6 +95,32 @@ export interface MissionControlSubsystemStatus {
|
|||||||
completeness?: string | null
|
completeness?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MissionControlBridgeLaneContract {
|
||||||
|
bridge?: string
|
||||||
|
link_balance_wei?: string
|
||||||
|
status?: string
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MissionControlBridgeLane {
|
||||||
|
key: string
|
||||||
|
chain_name?: string
|
||||||
|
chain_id?: number
|
||||||
|
config_ready?: boolean
|
||||||
|
link_token?: string
|
||||||
|
status?: string
|
||||||
|
proof_status?: string
|
||||||
|
weth9?: MissionControlBridgeLaneContract
|
||||||
|
weth10?: MissionControlBridgeLaneContract
|
||||||
|
rpc_endpoint?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MissionControlBridgeLaneHealth {
|
||||||
|
updated_at?: string
|
||||||
|
min_link_wei?: string
|
||||||
|
lanes?: MissionControlBridgeLane[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface MissionControlBridgeStatusResponse {
|
export interface MissionControlBridgeStatusResponse {
|
||||||
data?: {
|
data?: {
|
||||||
status?: string
|
status?: string
|
||||||
@@ -112,6 +138,8 @@ export interface MissionControlBridgeStatusResponse {
|
|||||||
chains?: Record<string, MissionControlChainStatus>
|
chains?: Record<string, MissionControlChainStatus>
|
||||||
ccip_relay?: MissionControlRelayPayload
|
ccip_relay?: MissionControlRelayPayload
|
||||||
ccip_relays?: Record<string, MissionControlRelayPayload>
|
ccip_relays?: Record<string, MissionControlRelayPayload>
|
||||||
|
bridge_lanes?: MissionControlBridgeLaneHealth
|
||||||
|
proof_transfers?: Record<string, unknown>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,10 @@ echo "✅ Docs bundle prepared"
|
|||||||
echo "=== Step 3: Upload artifacts ==="
|
echo "=== Step 3: Upload artifacts ==="
|
||||||
scp -o ConnectTimeout=10 -o StrictHostKeyChecking=no "$TMP_DIR/explorer-config-api" root@"$PROXMOX_HOST":/tmp/explorer-config-api
|
scp -o ConnectTimeout=10 -o StrictHostKeyChecking=no "$TMP_DIR/explorer-config-api" root@"$PROXMOX_HOST":/tmp/explorer-config-api
|
||||||
scp -o ConnectTimeout=10 -o StrictHostKeyChecking=no "$TMP_DIR/explorer-ai-docs.tar.gz" root@"$PROXMOX_HOST":/tmp/explorer-ai-docs.tar.gz
|
scp -o ConnectTimeout=10 -o StrictHostKeyChecking=no "$TMP_DIR/explorer-ai-docs.tar.gz" root@"$PROXMOX_HOST":/tmp/explorer-ai-docs.tar.gz
|
||||||
|
PROOF_JSON="$REPO_ROOT/reports/status/bridge-lane-proof-transfers-latest.json"
|
||||||
|
if [ -f "$PROOF_JSON" ]; then
|
||||||
|
scp -o ConnectTimeout=10 -o StrictHostKeyChecking=no "$PROOF_JSON" root@"$PROXMOX_HOST":/tmp/bridge-lane-proof-transfers.json
|
||||||
|
fi
|
||||||
echo "✅ Artifacts uploaded"
|
echo "✅ Artifacts uploaded"
|
||||||
|
|
||||||
echo "=== Step 4: Install backend, refresh docs, and ensure env ==="
|
echo "=== Step 4: Install backend, refresh docs, and ensure env ==="
|
||||||
@@ -91,8 +95,11 @@ if [ -z "$DB_URL" ]; then
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
pct exec "$VMID" -- bash -lc 'mkdir -p /opt/explorer-ai-docs /etc/systemd/system/explorer-config-api.service.d'
|
pct exec "$VMID" -- bash -lc 'mkdir -p /opt/explorer-ai-docs /etc/systemd/system/explorer-config-api.service.d /opt/explorer-bridge-status'
|
||||||
pct push "$VMID" /tmp/explorer-ai-docs.tar.gz /tmp/explorer-ai-docs.tar.gz --perms 0644
|
pct push "$VMID" /tmp/explorer-ai-docs.tar.gz /tmp/explorer-ai-docs.tar.gz --perms 0644
|
||||||
|
if [ -f /tmp/bridge-lane-proof-transfers.json ]; then
|
||||||
|
pct push "$VMID" /tmp/bridge-lane-proof-transfers.json /opt/explorer-bridge-status/proof-transfers.json --perms 0644
|
||||||
|
fi
|
||||||
pct push "$VMID" /tmp/explorer-config-api /usr/local/bin/explorer-config-api.new --perms 0755
|
pct push "$VMID" /tmp/explorer-config-api /usr/local/bin/explorer-config-api.new --perms 0755
|
||||||
|
|
||||||
pct exec "$VMID" -- env \
|
pct exec "$VMID" -- env \
|
||||||
@@ -181,6 +188,16 @@ else
|
|||||||
rm -f /etc/systemd/system/explorer-config-api.service.d/walletconnect.conf
|
rm -f /etc/systemd/system/explorer-config-api.service.d/walletconnect.conf
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
cat > /etc/systemd/system/explorer-config-api.service.d/bridge-lanes.conf <<'BRIDGEEOF'
|
||||||
|
[Service]
|
||||||
|
Environment=RPC_URL=http://192.168.11.211:8545
|
||||||
|
Environment="MISSION_CONTROL_EXTRA_RPCS=gnosis|https://rpc.gnosischain.com|100
|
||||||
|
cronos|https://evm.cronos.org|25
|
||||||
|
celo|https://forno.celo.org|42220
|
||||||
|
wemix|https://api.wemix.com|1111"
|
||||||
|
Environment=MISSION_CONTROL_PROOF_TRANSFERS_JSON=/opt/explorer-bridge-status/proof-transfers.json
|
||||||
|
BRIDGEEOF
|
||||||
|
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
systemctl restart explorer-config-api
|
systemctl restart explorer-config-api
|
||||||
sleep 2
|
sleep 2
|
||||||
|
|||||||
@@ -45,12 +45,22 @@ test.describe('Explorer sprint smoke', () => {
|
|||||||
await expect(page.getByText(/CCIP route catalog/i).first()).toBeVisible({ timeout: 15000 })
|
await expect(page.getByText(/CCIP route catalog/i).first()).toBeVisible({ timeout: 15000 })
|
||||||
await expect(page.getByText(/Wemix/i).first()).toBeVisible({ timeout: 15000 })
|
await expect(page.getByText(/Wemix/i).first()).toBeVisible({ timeout: 15000 })
|
||||||
await expect(page.getByText(/Bridge Freshness Context/i).first()).toBeVisible({ timeout: 10000 })
|
await expect(page.getByText(/Bridge Freshness Context/i).first()).toBeVisible({ timeout: 10000 })
|
||||||
|
await expect(page.getByText(/Config-ready lane health/i).first()).toBeVisible({ timeout: 10000 })
|
||||||
|
await expect(page.getByText(/unfunded|funded/i).first()).toBeVisible({ timeout: 10000 })
|
||||||
})
|
})
|
||||||
|
|
||||||
test('posture glossary doc page loads', async ({ page }) => {
|
test('posture glossary doc page loads', async ({ page }) => {
|
||||||
await page.goto(`${EXPLORER_URL}/docs/posture-glossary`, { waitUntil: 'domcontentloaded', timeout: 30000 })
|
await page.goto(`${EXPLORER_URL}/docs/posture-glossary`, { waitUntil: 'domcontentloaded', timeout: 30000 })
|
||||||
await expect(page.getByRole('heading', { name: /Posture glossary/i })).toBeVisible({ timeout: 15000 })
|
await expect(page.getByRole('heading', { name: /Posture glossary/i })).toBeVisible({ timeout: 15000 })
|
||||||
await expect(page.getByText(/x402 readiness/i).first()).toBeVisible({ timeout: 10000 })
|
await expect(page.getByText(/x402 readiness/i).first()).toBeVisible({ timeout: 10000 })
|
||||||
|
await expect(page.getByText(/transportActive/i).first()).toBeVisible({ timeout: 10000 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('posture glossary drawer opens from docs badges', async ({ page }) => {
|
||||||
|
await page.goto(`${EXPLORER_URL}/docs/posture-glossary`, { waitUntil: 'domcontentloaded', timeout: 30000 })
|
||||||
|
await page.getByRole('button', { name: /^GRU$/i }).click()
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 })
|
||||||
|
await expect(page.getByText(/Methodology/i).first()).toBeVisible({ timeout: 10000 })
|
||||||
})
|
})
|
||||||
|
|
||||||
test('public API access doc page loads', async ({ page }) => {
|
test('public API access doc page loads', async ({ page }) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user