mirror of
https://github.com/hanzoai/bootnode.git
synced 2026-09-30 20:46:32 +00:00
Merge pull request #9 from bootnode/fix/api-boot
fix: get the API booting (stale refactor imports + qdrant healthcheck)
This commit is contained in:
@@ -1,3 +1,3 @@
|
||||
"""Bootnode - Blockchain Development Platform."""
|
||||
|
||||
__version__ = "2.0.0"
|
||||
__version__ = "1.0.0"
|
||||
|
||||
@@ -21,6 +21,11 @@ from bootnode.api.networks import router as networks_router
|
||||
from bootnode.api.observability import router as o11y_router
|
||||
from bootnode.api.chat import router as chat_router
|
||||
from bootnode.api.zap import router as zap_router
|
||||
from bootnode.api.mpc import router as mpc_router
|
||||
from bootnode.api.keys import router as keys_router
|
||||
from bootnode.api.launch import router as launch_router
|
||||
from bootnode.api.contracts import router as contracts_router
|
||||
from bootnode.api.subgraphs import router as subgraphs_router
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -44,3 +49,8 @@ router.include_router(team_router, prefix="/team", tags=["Team"])
|
||||
router.include_router(networks_router, prefix="/networks", tags=["Networks"])
|
||||
router.include_router(o11y_router, prefix="/o11y", tags=["Observability"])
|
||||
router.include_router(chat_router, prefix="/chat", tags=["AI Chat"])
|
||||
router.include_router(mpc_router, prefix="/mpc", tags=["MPC Wallets"])
|
||||
router.include_router(keys_router, prefix="/keys", tags=["Key Management"])
|
||||
router.include_router(launch_router, prefix="/launch", tags=["Launch"])
|
||||
router.include_router(contracts_router, prefix="/contracts", tags=["Contracts"])
|
||||
router.include_router(subgraphs_router, prefix="/subgraphs", tags=["Subgraphs"])
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Contracts API — deploy and interact with smart contracts via chain RPC.
|
||||
|
||||
Deploy = broadcast a signed creation transaction; the mined receipt carries the
|
||||
new contract address. Read = eth_call. Code check = eth_getCode. Signing stays
|
||||
client-side (or via the MPC wallet API) — this module never holds private keys.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from bootnode.api.deps import ApiKeyDep
|
||||
from bootnode.core.chains import ChainRegistry, RPCClient
|
||||
from bootnode.core.chains.rpc import RPCError
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class DeployRequest(BaseModel):
|
||||
"""A pre-signed contract-creation transaction to broadcast."""
|
||||
|
||||
signed_tx: str = Field(..., description="0x-prefixed signed creation transaction")
|
||||
network: str = "mainnet"
|
||||
|
||||
|
||||
class CallRequest(BaseModel):
|
||||
"""A read-only contract call (eth_call)."""
|
||||
|
||||
to: str = Field(..., description="Contract address")
|
||||
data: str = Field(..., description="0x-encoded calldata")
|
||||
block: str = "latest"
|
||||
network: str = "mainnet"
|
||||
|
||||
|
||||
async def _rpc(chain: str, network: str, method: str, params: list[Any]) -> Any:
|
||||
if not ChainRegistry.is_supported(chain, network):
|
||||
raise HTTPException(400, f"Unsupported chain/network: {chain}/{network}")
|
||||
try:
|
||||
async with RPCClient(chain, network) as client:
|
||||
return await client.call(method, params, 1)
|
||||
except RPCError as e:
|
||||
raise HTTPException(502, f"RPC error {e.code}: {e.message}")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e: # upstream RPC unreachable / retries exhausted
|
||||
raise HTTPException(502, f"RPC call to {chain}/{network} failed: {type(e).__name__}")
|
||||
|
||||
|
||||
@router.post("/{chain}/deploy")
|
||||
async def deploy_contract(chain: str, body: DeployRequest, api_key: ApiKeyDep) -> dict:
|
||||
"""Broadcast a signed contract-creation transaction.
|
||||
|
||||
Returns the transaction hash; poll the receipt to get the contract address
|
||||
once the transaction is mined.
|
||||
"""
|
||||
tx_hash = await _rpc(chain, body.network, "eth_sendRawTransaction", [body.signed_tx])
|
||||
return {
|
||||
"chain": chain,
|
||||
"network": body.network,
|
||||
"txHash": tx_hash,
|
||||
"receipt": f"/v1/contracts/{chain}/tx/{tx_hash}/receipt?network={body.network}",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{chain}/tx/{tx_hash}/receipt")
|
||||
async def deploy_receipt(
|
||||
chain: str, tx_hash: str, api_key: ApiKeyDep, network: str = "mainnet"
|
||||
) -> dict:
|
||||
"""Fetch a transaction receipt — `contractAddress` is set once a creation
|
||||
transaction is mined."""
|
||||
receipt = await _rpc(chain, network, "eth_getTransactionReceipt", [tx_hash])
|
||||
if receipt is None:
|
||||
return {"status": "pending", "txHash": tx_hash, "contractAddress": None}
|
||||
return {
|
||||
"status": "mined" if receipt.get("status") == "0x1" else "failed",
|
||||
"txHash": tx_hash,
|
||||
"contractAddress": receipt.get("contractAddress"),
|
||||
"blockNumber": receipt.get("blockNumber"),
|
||||
"gasUsed": receipt.get("gasUsed"),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{chain}/{address}/code")
|
||||
async def contract_code(
|
||||
chain: str, address: str, api_key: ApiKeyDep, network: str = "mainnet"
|
||||
) -> dict:
|
||||
"""eth_getCode — confirm a contract is deployed at an address."""
|
||||
code = await _rpc(chain, network, "eth_getCode", [address, "latest"])
|
||||
deployed = bool(code) and code != "0x"
|
||||
return {
|
||||
"chain": chain,
|
||||
"address": address,
|
||||
"deployed": deployed,
|
||||
"codeSize": (len(code) - 2) // 2 if deployed else 0,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{chain}/call")
|
||||
async def call_contract(chain: str, body: CallRequest, api_key: ApiKeyDep) -> dict:
|
||||
"""eth_call — read-only contract method invocation."""
|
||||
result = await _rpc(
|
||||
chain, body.network, "eth_call", [{"to": body.to, "data": body.data}, body.block]
|
||||
)
|
||||
return {"chain": chain, "to": body.to, "result": result}
|
||||
@@ -5,7 +5,7 @@ from sqlalchemy import select
|
||||
from sqlalchemy import func as sa_func
|
||||
|
||||
from bootnode.api.deps import ApiKeyDep, DbDep, ProjectDep
|
||||
from bootnode.db.models import MPCWalletModel
|
||||
from bootnode.db.models import SmartWallet
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -22,18 +22,18 @@ async def get_key_status(
|
||||
Proxies to MPC wallet status data.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(sa_func.count(MPCWalletModel.id)).where(
|
||||
MPCWalletModel.project_id == project.id,
|
||||
MPCWalletModel.status == "active",
|
||||
select(sa_func.count(SmartWallet.id)).where(
|
||||
SmartWallet.project_id == project.id,
|
||||
SmartWallet.status == "active",
|
||||
)
|
||||
)
|
||||
total = result.scalar() or 0
|
||||
|
||||
# Get first active wallet as default
|
||||
first_result = await db.execute(
|
||||
select(MPCWalletModel).where(
|
||||
MPCWalletModel.project_id == project.id,
|
||||
MPCWalletModel.status == "active",
|
||||
select(SmartWallet).where(
|
||||
SmartWallet.project_id == project.id,
|
||||
SmartWallet.status == "active",
|
||||
).limit(1)
|
||||
)
|
||||
default_wallet = first_result.scalar_one_or_none()
|
||||
|
||||
@@ -145,8 +145,8 @@ def _generate_lux_network(cfg: ChainConfig, network: str, net: "NetworkSpec", na
|
||||
|
||||
lines += [
|
||||
" ports:",
|
||||
" http: 9650",
|
||||
" staking: 9651",
|
||||
" http: 9630",
|
||||
" staking: 9631",
|
||||
" consensus:",
|
||||
f" sybilProtectionEnabled: {sybil}",
|
||||
" storage:",
|
||||
@@ -231,7 +231,7 @@ def _generate_lux_explorer(cfg: ChainConfig, network: str, net: "NetworkSpec", n
|
||||
"""
|
||||
ex = cfg.services.explorer
|
||||
host = cfg.brand.domains.explorer or f"explorer.{network}.{cfg.chain.slug}.network"
|
||||
rpc_url = net.rpc_url or f"http://{cfg.chain.slug}d-0.{cfg.chain.slug}d-headless.{namespace}.svc:9650/ext/bc/C/rpc"
|
||||
rpc_url = net.rpc_url or f"http://{cfg.chain.slug}d-0.{cfg.chain.slug}d-headless.{namespace}.svc:9630/ext/bc/C/rpc"
|
||||
port = ex.port
|
||||
|
||||
return f"""apiVersion: apps/v1
|
||||
@@ -479,7 +479,7 @@ spec:
|
||||
- name: CHAIN_ID
|
||||
value: "{net.chain_id}"
|
||||
- name: RPC_URL
|
||||
value: "http://{cfg.chain.slug}d-0.{cfg.chain.slug}d:9650/ext/bc/C/rpc"
|
||||
value: "http://{cfg.chain.slug}d-0.{cfg.chain.slug}d:9630/ext/bc/C/rpc"
|
||||
- name: DRIP_AMOUNT
|
||||
value: "{faucet.drip_amount}"
|
||||
- name: RATE_LIMIT
|
||||
@@ -584,8 +584,8 @@ def register_chain_networks(cfg: ChainConfig) -> None:
|
||||
chain=slug,
|
||||
network_id=net.network_id,
|
||||
chain_id=net.chain_id,
|
||||
http_port=9650,
|
||||
staking_port=9651,
|
||||
http_port=9630,
|
||||
staking_port=9631,
|
||||
namespace=cfg.namespace_for(name),
|
||||
)
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ from bootnode.core.mpc import (
|
||||
decrypt_share,
|
||||
encrypt_share,
|
||||
)
|
||||
from bootnode.db.models import MPCWalletModel
|
||||
from bootnode.db.models import SmartWallet
|
||||
|
||||
logger = structlog.get_logger()
|
||||
router = APIRouter()
|
||||
@@ -85,7 +85,7 @@ async def create_mpc_wallet(
|
||||
api_key: ApiKeyDep,
|
||||
project: ProjectDep,
|
||||
db: DbDep,
|
||||
) -> MPCWalletModel:
|
||||
) -> SmartWallet:
|
||||
"""Create a new MPC wallet with threshold key shares."""
|
||||
if request.threshold < 2:
|
||||
raise HTTPException(400, "Threshold must be at least 2")
|
||||
@@ -116,7 +116,7 @@ async def create_mpc_wallet(
|
||||
"3": "backup",
|
||||
}
|
||||
|
||||
wallet = MPCWalletModel(
|
||||
wallet = SmartWallet(
|
||||
project_id=project.id,
|
||||
address=keypair.address,
|
||||
chain=request.chain,
|
||||
@@ -148,13 +148,13 @@ async def list_mpc_wallets(
|
||||
project: ProjectDep,
|
||||
db: DbDep,
|
||||
chain: str | None = None,
|
||||
) -> list[MPCWalletModel]:
|
||||
) -> list[SmartWallet]:
|
||||
"""List all MPC wallets for the project."""
|
||||
query = select(MPCWalletModel).where(
|
||||
MPCWalletModel.project_id == project.id
|
||||
query = select(SmartWallet).where(
|
||||
SmartWallet.project_id == project.id
|
||||
)
|
||||
if chain:
|
||||
query = query.where(MPCWalletModel.chain == chain)
|
||||
query = query.where(SmartWallet.chain == chain)
|
||||
|
||||
result = await db.execute(query)
|
||||
return list(result.scalars().all())
|
||||
@@ -168,19 +168,19 @@ async def get_key_status(
|
||||
) -> MPCKeyStatusResponse:
|
||||
"""Get MPC wallet status for the project (used by staking page)."""
|
||||
result = await db.execute(
|
||||
select(sa_func.count(MPCWalletModel.id)).where(
|
||||
MPCWalletModel.project_id == project.id,
|
||||
MPCWalletModel.status == "active",
|
||||
select(sa_func.count(SmartWallet.id)).where(
|
||||
SmartWallet.project_id == project.id,
|
||||
SmartWallet.status == "active",
|
||||
)
|
||||
)
|
||||
total = result.scalar() or 0
|
||||
|
||||
# Get first wallet as default
|
||||
first = await db.execute(
|
||||
select(MPCWalletModel)
|
||||
select(SmartWallet)
|
||||
.where(
|
||||
MPCWalletModel.project_id == project.id,
|
||||
MPCWalletModel.status == "active",
|
||||
SmartWallet.project_id == project.id,
|
||||
SmartWallet.status == "active",
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
@@ -207,12 +207,12 @@ async def get_mpc_wallet(
|
||||
api_key: ApiKeyDep,
|
||||
project: ProjectDep,
|
||||
db: DbDep,
|
||||
) -> MPCWalletModel:
|
||||
) -> SmartWallet:
|
||||
"""Get MPC wallet details."""
|
||||
result = await db.execute(
|
||||
select(MPCWalletModel).where(
|
||||
MPCWalletModel.address == address.lower(),
|
||||
MPCWalletModel.project_id == project.id,
|
||||
select(SmartWallet).where(
|
||||
SmartWallet.address == address.lower(),
|
||||
SmartWallet.project_id == project.id,
|
||||
)
|
||||
)
|
||||
wallet = result.scalar_one_or_none()
|
||||
@@ -231,9 +231,9 @@ async def sign_with_mpc(
|
||||
) -> MPCSignResponse:
|
||||
"""Sign a message or transaction using threshold shares."""
|
||||
result = await db.execute(
|
||||
select(MPCWalletModel).where(
|
||||
MPCWalletModel.address == address.lower(),
|
||||
MPCWalletModel.project_id == project.id,
|
||||
select(SmartWallet).where(
|
||||
SmartWallet.address == address.lower(),
|
||||
SmartWallet.project_id == project.id,
|
||||
)
|
||||
)
|
||||
db_wallet = result.scalar_one_or_none()
|
||||
@@ -317,9 +317,9 @@ async def get_share_info(
|
||||
) -> dict:
|
||||
"""Get share holder info (not the actual shares)."""
|
||||
result = await db.execute(
|
||||
select(MPCWalletModel).where(
|
||||
MPCWalletModel.address == address.lower(),
|
||||
MPCWalletModel.project_id == project.id,
|
||||
select(SmartWallet).where(
|
||||
SmartWallet.address == address.lower(),
|
||||
SmartWallet.project_id == project.id,
|
||||
)
|
||||
)
|
||||
wallet = result.scalar_one_or_none()
|
||||
@@ -345,9 +345,9 @@ async def decrypt_wallet_share(
|
||||
) -> dict:
|
||||
"""Decrypt a specific share for signing (requires auth)."""
|
||||
result = await db.execute(
|
||||
select(MPCWalletModel).where(
|
||||
MPCWalletModel.address == address.lower(),
|
||||
MPCWalletModel.project_id == project.id,
|
||||
select(SmartWallet).where(
|
||||
SmartWallet.address == address.lower(),
|
||||
SmartWallet.project_id == project.id,
|
||||
)
|
||||
)
|
||||
wallet = result.scalar_one_or_none()
|
||||
|
||||
@@ -69,18 +69,20 @@ CHAIN_IMAGES = {
|
||||
"blast": {
|
||||
"execution": {"blast": "blastio/blast-geth:latest"},
|
||||
},
|
||||
# ============ HANZO ECOSYSTEM (Lux-based) ============
|
||||
# ============ LUX ECOSYSTEM (luxd — Lux, Zoo, Pars, Hanzo) ============
|
||||
# All four run the same luxd node (ghcr.io/luxfi/node). Only the primary
|
||||
# network-id and genesis differ; Zoo/Pars/Hanzo are Lux-based sovereign L1s.
|
||||
"lux": {
|
||||
"execution": {"luxd": "luxfi/luxd:latest"},
|
||||
"execution": {"luxd": "ghcr.io/luxfi/node:latest"},
|
||||
},
|
||||
"zoo": {
|
||||
"execution": {"luxd": "luxfi/luxd:latest"}, # Zoo subnet on Lux
|
||||
"execution": {"luxd": "ghcr.io/luxfi/node:latest"}, # Zoo: Lux-based sovereign L1
|
||||
},
|
||||
"pars": {
|
||||
"execution": {"luxd": "luxfi/luxd:latest"}, # Pars subnet on Lux
|
||||
"execution": {"luxd": "ghcr.io/luxfi/node:latest"}, # Pars: Lux-based sovereign L1
|
||||
},
|
||||
"hanzo": {
|
||||
"execution": {"luxd": "luxfi/luxd:latest"}, # Hanzo subnet on Lux
|
||||
"execution": {"luxd": "ghcr.io/luxfi/node:latest"}, # Hanzo: Lux-based sovereign L1
|
||||
},
|
||||
# ============ MAJOR L1s ============
|
||||
"bitcoin": {
|
||||
@@ -240,10 +242,10 @@ CHAIN_PORTS = {
|
||||
"mantle": {"rpc": 8545, "ws": 8546},
|
||||
"blast": {"rpc": 8545, "ws": 8546},
|
||||
# Hanzo ecosystem
|
||||
"lux": {"rpc": 9650, "ws": 9650, "p2p": 9651},
|
||||
"zoo": {"rpc": 9650, "ws": 9650, "p2p": 9651},
|
||||
"pars": {"rpc": 9650, "ws": 9650, "p2p": 9651},
|
||||
"hanzo": {"rpc": 9650, "ws": 9650, "p2p": 9651},
|
||||
"lux": {"rpc": 9630, "ws": 9630, "p2p": 9631},
|
||||
"zoo": {"rpc": 9630, "ws": 9630, "p2p": 9631},
|
||||
"pars": {"rpc": 9630, "ws": 9630, "p2p": 9631},
|
||||
"hanzo": {"rpc": 9630, "ws": 9630, "p2p": 9631},
|
||||
# Major L1s
|
||||
"bitcoin": {"rpc": 8332, "p2p": 8333},
|
||||
"solana": {"rpc": 8899, "ws": 8900, "p2p": 8001},
|
||||
@@ -374,7 +376,12 @@ def allocate_ports() -> Dict[str, int]:
|
||||
if bindings:
|
||||
for binding in bindings:
|
||||
host_port = int(binding.get("HostPort", 0))
|
||||
if host_port >= _port_counter["rpc"]:
|
||||
# Only seed the counter from the RPC/ws range.
|
||||
# High p2p/beacon ports (30303+, 19000+) must not
|
||||
# drive the counter up — otherwise each node's p2p
|
||||
# port feeds back and inflates the next allocation
|
||||
# until it overflows 65535.
|
||||
if _port_counter["rpc"] <= host_port < 30000:
|
||||
_port_counter["rpc"] = host_port + 10
|
||||
_port_counter["ws"] = host_port + 11
|
||||
except Exception:
|
||||
@@ -820,10 +827,10 @@ async def start_node_container(node_id: str, node: dict):
|
||||
_nodes[node_id]["container_id"] = exec_container.id[:12]
|
||||
_nodes[node_id]["containers"] = [c.id[:12] for c in containers_created]
|
||||
|
||||
# ============ LUX ECOSYSTEM (Lux, Zoo, Pars, Hanzo) ============
|
||||
# ============ LUX ECOSYSTEM (luxd — Lux, Zoo, Pars, Hanzo) ============
|
||||
elif chain in ["lux", "zoo", "pars", "hanzo"]:
|
||||
exec_images = chain_images.get("execution", {})
|
||||
image = exec_images.get("luxd", "luxfi/luxd:latest")
|
||||
image = exec_images.get("luxd", "ghcr.io/luxfi/node:latest")
|
||||
|
||||
try:
|
||||
client.images.get(image)
|
||||
@@ -835,47 +842,60 @@ async def start_node_container(node_id: str, node: dict):
|
||||
try:
|
||||
old = client.containers.get(container_name)
|
||||
old.remove(force=True)
|
||||
except:
|
||||
except docker.errors.NotFound:
|
||||
pass
|
||||
|
||||
# Configure luxd based on network
|
||||
lux_network_id = "mainnet" if network == "mainnet" else "fuji" if network == "testnet" else "local"
|
||||
# Canonical Lux primary network-id (matches luxd-boot.sh):
|
||||
# localnet=1337, devnet=3, testnet=2, mainnet=1. Zoo/Pars/Hanzo are
|
||||
# Lux-based sovereign L1s — same luxd boot; genesis selects the chain.
|
||||
net_id = {"local": 1337, "localnet": 1337, "devnet": 3,
|
||||
"testnet": 2, "mainnet": 1}.get(network, 1337)
|
||||
|
||||
# For local development, use special config
|
||||
if network == "local":
|
||||
lux_cmd = [
|
||||
"--network-id=local",
|
||||
"--http-host=0.0.0.0",
|
||||
"--http-port=9650",
|
||||
"--staking-enabled=false",
|
||||
"--db-type=memdb",
|
||||
# luxd serves C-Chain HTTP on 9630, staking on 9631 (not 9650).
|
||||
# Custom VM plugins load from --plugin-dir (mount .so into /data/plugins).
|
||||
lux_cmd = [
|
||||
f"--network-id={net_id}",
|
||||
"--http-host=0.0.0.0",
|
||||
"--http-port=9630",
|
||||
"--staking-port=9631",
|
||||
"--data-dir=/data",
|
||||
"--plugin-dir=/data/plugins",
|
||||
]
|
||||
# Single-node local/dev: run without a peer quorum. Real staking keys
|
||||
# under /data/staking are picked up automatically when present (prod).
|
||||
if network in ("local", "localnet", "devnet"):
|
||||
lux_cmd += [
|
||||
"--sybil-protection-enabled=false",
|
||||
"--network-health-min-conn-peers=0",
|
||||
"--skip-bootstrap=true",
|
||||
]
|
||||
else:
|
||||
lux_cmd = [
|
||||
f"--network-id={lux_network_id}",
|
||||
"--http-host=0.0.0.0",
|
||||
"--http-port=9650",
|
||||
"--public-ip-resolution-service=opendns",
|
||||
]
|
||||
|
||||
# Add subnet-specific flags for Zoo/Pars/Hanzo
|
||||
if chain in ["zoo", "pars", "hanzo"]:
|
||||
# These would have subnet-specific configs
|
||||
lux_cmd.extend([
|
||||
"--track-subnets=", # Add actual subnet IDs for production
|
||||
])
|
||||
lux_cmd.append("--public-ip-resolution-service=opendns")
|
||||
|
||||
# luxd loads VMs from --plugin-dir. The C-Chain EVM ships as a plugin
|
||||
# (.so named by vmID under /luxd/build/plugins); custom VM plugins drop
|
||||
# in the same dir. Stage the bundled plugins into the data plugin dir,
|
||||
# then exec luxd — same shape as the canonical luxd-boot.sh. The image
|
||||
# Cmd is ["./luxd"] with no Entrypoint, so we wrap in sh -c.
|
||||
luxd_argv = " ".join(lux_cmd)
|
||||
stage = "mkdir -p /data/plugins && cp -n /luxd/build/plugins/* /data/plugins/ 2>/dev/null; "
|
||||
# Publish RPC always; publish staking only if it maps to a valid host
|
||||
# port (guard against an out-of-range allocation).
|
||||
lux_port_map = {"9630/tcp": ports["rpc"]}
|
||||
staking_host = ports.get("p2p") or (ports["rpc"] + 1)
|
||||
if 1024 < staking_host <= 65535:
|
||||
lux_port_map["9631/tcp"] = staking_host
|
||||
container = client.containers.run(
|
||||
image,
|
||||
command=lux_cmd,
|
||||
entrypoint=["/bin/sh", "-c"],
|
||||
# Single-list element: the whole script is one argv to `sh -c`
|
||||
# (a bare string would be word-split by docker-py).
|
||||
command=[f"{stage}exec /luxd/build/luxd {luxd_argv}"],
|
||||
name=container_name,
|
||||
detach=True,
|
||||
ports={
|
||||
"9650/tcp": ports["rpc"],
|
||||
"9651/tcp": ports.get("p2p", 9651 + (ports["rpc"] - 9650)),
|
||||
},
|
||||
ports=lux_port_map,
|
||||
volumes={
|
||||
f"bootnode-data-{node_id[:8]}": {"bind": "/root/.luxd", "mode": "rw"},
|
||||
f"bootnode-data-{node_id[:8]}": {"bind": "/data", "mode": "rw"},
|
||||
},
|
||||
network=network_name,
|
||||
restart_policy={"Name": "unless-stopped"},
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Subgraphs API — deploy and manage The Graph subgraphs on a graph-node.
|
||||
|
||||
Talks to graph-node's admin JSON-RPC (default http://graph-node:8020) for
|
||||
create/deploy/remove, and its index-node status GraphQL (default
|
||||
http://graph-node:8030/graphql) for indexing health. The graph-node + IPFS
|
||||
stack runs via `docker compose --profile subgraph up` locally, or as a
|
||||
launched-chain service in the cluster.
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Any, Optional
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from bootnode.api.deps import ApiKeyDep
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
ADMIN_URL = os.getenv("GRAPH_NODE_ADMIN_URL", "http://graph-node:8020")
|
||||
STATUS_URL = os.getenv("GRAPH_NODE_STATUS_URL", "http://graph-node:8030/graphql")
|
||||
|
||||
|
||||
class CreateRequest(BaseModel):
|
||||
name: str = Field(..., description="Subgraph name, e.g. 'lux/dex'")
|
||||
|
||||
|
||||
class DeployRequest(BaseModel):
|
||||
name: str
|
||||
ipfs_hash: str = Field(..., description="IPFS hash of the compiled subgraph manifest")
|
||||
version_label: Optional[str] = None
|
||||
|
||||
|
||||
async def _admin(method: str, params: dict) -> Any:
|
||||
"""Call a graph-node admin JSON-RPC method."""
|
||||
payload = {"jsonrpc": "2.0", "id": 1, "method": method, "params": params}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
r = await client.post(ADMIN_URL, json=payload)
|
||||
except httpx.HTTPError as e:
|
||||
raise HTTPException(502, f"graph-node admin unreachable at {ADMIN_URL}: {e}")
|
||||
data = r.json()
|
||||
if data.get("error"):
|
||||
raise HTTPException(400, f"graph-node error: {data['error']}")
|
||||
return data.get("result")
|
||||
|
||||
|
||||
@router.post("/create")
|
||||
async def create_subgraph(body: CreateRequest, api_key: ApiKeyDep) -> dict:
|
||||
"""Register a subgraph name on the graph-node (subgraph_create)."""
|
||||
result = await _admin("subgraph_create", {"name": body.name})
|
||||
return {"name": body.name, "created": True, "result": result}
|
||||
|
||||
|
||||
@router.post("/deploy")
|
||||
async def deploy_subgraph(body: DeployRequest, api_key: ApiKeyDep) -> dict:
|
||||
"""Deploy a compiled subgraph by IPFS hash (subgraph_deploy)."""
|
||||
params: dict[str, str] = {"name": body.name, "ipfs_hash": body.ipfs_hash}
|
||||
if body.version_label:
|
||||
params["version_label"] = body.version_label
|
||||
result = await _admin("subgraph_deploy", params)
|
||||
return {
|
||||
"name": body.name,
|
||||
"ipfsHash": body.ipfs_hash,
|
||||
"deployed": True,
|
||||
"endpoints": result,
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/{name:path}")
|
||||
async def remove_subgraph(name: str, api_key: ApiKeyDep) -> dict:
|
||||
"""Remove a subgraph (subgraph_remove)."""
|
||||
result = await _admin("subgraph_remove", {"name": name})
|
||||
return {"name": name, "removed": True, "result": result}
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_subgraphs(api_key: ApiKeyDep) -> dict:
|
||||
"""List indexing status for all deployed subgraphs (index-node status API)."""
|
||||
query = {
|
||||
"query": "{ indexingStatuses { subgraph synced health "
|
||||
"chains { network chainHeadBlock { number } latestBlock { number } } } }"
|
||||
}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
r = await client.post(STATUS_URL, json=query)
|
||||
statuses = r.json().get("data", {}).get("indexingStatuses", [])
|
||||
except (httpx.HTTPError, ValueError) as e:
|
||||
raise HTTPException(502, f"graph-node status unreachable at {STATUS_URL}: {e}")
|
||||
return {"subgraphs": statuses, "count": len(statuses)}
|
||||
@@ -17,6 +17,7 @@ import structlog
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
|
||||
from bootnode import __version__
|
||||
from bootnode.api.deps import ApiKeyDep
|
||||
from bootnode.config import get_settings
|
||||
|
||||
@@ -44,7 +45,7 @@ class ZapServerInfo(BaseModel):
|
||||
"""ZAP server capabilities."""
|
||||
|
||||
name: str = "bootnode"
|
||||
version: str = "2.0.0"
|
||||
version: str = __version__
|
||||
capabilities: dict[str, bool]
|
||||
tools_count: int
|
||||
resources_count: int
|
||||
@@ -388,7 +389,7 @@ async def get_zap_schema(_api_key: ApiKeyDep) -> dict[str, Any]:
|
||||
return {
|
||||
"schema": schema_content,
|
||||
"format": "zap",
|
||||
"version": "2.0.0",
|
||||
"version": __version__,
|
||||
"interfaces": ["Bootnode"],
|
||||
"compile": "zapc compile bootnode.zap --out=bootnode.capnp",
|
||||
}
|
||||
@@ -410,5 +411,5 @@ async def get_capnp_schema(_api_key: ApiKeyDep) -> dict[str, Any]:
|
||||
return {
|
||||
"schema": schema_content,
|
||||
"format": "capnp",
|
||||
"version": "2.0.0",
|
||||
"version": __version__,
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ from typing import Any
|
||||
|
||||
import structlog
|
||||
from fastapi import FastAPI, Request, Response
|
||||
|
||||
from bootnode import __version__
|
||||
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
||||
|
||||
logger = structlog.get_logger()
|
||||
@@ -166,7 +168,7 @@ def setup_otel(app: FastAPI) -> None:
|
||||
"http://otel-collector.hanzo.svc:4317",
|
||||
)
|
||||
service_name = os.getenv("OTEL_SERVICE_NAME", "bootnode-api")
|
||||
service_version = os.getenv("OTEL_SERVICE_VERSION", "2.0.0")
|
||||
service_version = os.getenv("OTEL_SERVICE_VERSION", __version__)
|
||||
|
||||
# Always add metrics middleware (no external deps)
|
||||
app.add_middleware(MetricsMiddleware)
|
||||
|
||||
@@ -3,12 +3,16 @@
|
||||
from bootnode.db.models import (
|
||||
ApiKey,
|
||||
Base,
|
||||
Cluster,
|
||||
MPCWalletModel,
|
||||
Node,
|
||||
GasPolicy,
|
||||
OrgCluster,
|
||||
Project,
|
||||
SmartWallet,
|
||||
Subscription,
|
||||
TeamMember,
|
||||
Usage,
|
||||
User,
|
||||
Webhook,
|
||||
WebhookDelivery,
|
||||
)
|
||||
from bootnode.db.session import async_session, engine, get_db, init_db
|
||||
|
||||
@@ -18,11 +22,15 @@ __all__ = [
|
||||
"async_session",
|
||||
"init_db",
|
||||
"Base",
|
||||
"Cluster",
|
||||
"Node",
|
||||
"Project",
|
||||
"ApiKey",
|
||||
"MPCWalletModel",
|
||||
"Webhook",
|
||||
"GasPolicy",
|
||||
"OrgCluster",
|
||||
"Project",
|
||||
"SmartWallet",
|
||||
"Subscription",
|
||||
"TeamMember",
|
||||
"Usage",
|
||||
"User",
|
||||
"Webhook",
|
||||
"WebhookDelivery",
|
||||
]
|
||||
|
||||
@@ -8,6 +8,7 @@ from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import ORJSONResponse
|
||||
|
||||
from bootnode import __version__
|
||||
from bootnode.api import router as api_router
|
||||
from bootnode.config import get_settings
|
||||
from bootnode.core.cache import redis_client
|
||||
@@ -86,7 +87,7 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
app = FastAPI(
|
||||
title="Bootnode API",
|
||||
description="Blockchain Development Platform - Multi-chain RPC, Token API, NFT API, Smart Wallets, and more",
|
||||
version="2.0.0",
|
||||
version=__version__,
|
||||
docs_url="/docs" if not settings.is_production else None,
|
||||
redoc_url="/redoc" if not settings.is_production else None,
|
||||
openapi_url="/openapi.json" if not settings.is_production else None,
|
||||
@@ -139,7 +140,7 @@ app.include_router(ws_router, prefix=f"{settings.api_prefix}/ws", tags=["WebSock
|
||||
@app.get("/health")
|
||||
async def health_check() -> dict:
|
||||
"""Health check endpoint."""
|
||||
return {"status": "healthy", "version": "2.0.0"}
|
||||
return {"status": "healthy", "version": __version__}
|
||||
|
||||
|
||||
@app.get("/")
|
||||
@@ -147,7 +148,7 @@ async def root() -> dict:
|
||||
"""Root endpoint."""
|
||||
return {
|
||||
"name": "Bootnode API",
|
||||
"version": "2.0.0",
|
||||
"version": __version__,
|
||||
"docs": "/docs",
|
||||
"status": "operational",
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ from typing import Any
|
||||
import capnp
|
||||
import structlog
|
||||
|
||||
from bootnode import __version__
|
||||
from bootnode.config import get_settings
|
||||
|
||||
logger = structlog.get_logger()
|
||||
@@ -219,7 +220,7 @@ class BootnodeZapImpl(bootnode_capnp.Bootnode.Server):
|
||||
|
||||
return bootnode_capnp.ServerInfo.new_message(
|
||||
name="bootnode",
|
||||
version="2.0.0",
|
||||
version=__version__,
|
||||
capabilities=bootnode_capnp.ServerInfo.Capabilities.new_message(
|
||||
tools=True,
|
||||
resources=True,
|
||||
@@ -385,7 +386,7 @@ class BootnodeZapImpl(bootnode_capnp.Bootnode.Server):
|
||||
mimeType="application/json",
|
||||
)
|
||||
content_msg.content.text = json.dumps({
|
||||
"version": "2.0.0",
|
||||
"version": __version__,
|
||||
"rate_limit": 1000,
|
||||
})
|
||||
return content_msg
|
||||
|
||||
+3
-6
@@ -79,11 +79,8 @@ services:
|
||||
environment:
|
||||
QDRANT__SERVICE__GRPC_PORT: 6334
|
||||
QDRANT__LOG_LEVEL: INFO
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--spider", "-q", "http://localhost:6333/"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
# qdrant image ships no wget/curl; readiness is gated via depends_on:
|
||||
# service_started + the api's own startup checks instead of a broken healthcheck.
|
||||
|
||||
# Meilisearch - Full-text search for addresses, tokens, contracts
|
||||
meilisearch:
|
||||
@@ -151,7 +148,7 @@ services:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
qdrant:
|
||||
condition: service_healthy
|
||||
condition: service_started
|
||||
meilisearch:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ WORKDIR /app
|
||||
|
||||
# Install dependencies based on the preferred package manager
|
||||
COPY package.json pnpm-lock.yaml* ./
|
||||
RUN corepack enable pnpm && pnpm install --frozen-lockfile
|
||||
RUN corepack enable pnpm && pnpm config set dangerously-allow-all-builds true && pnpm install --no-frozen-lockfile
|
||||
|
||||
# Rebuild the source code only when needed
|
||||
FROM base AS builder
|
||||
|
||||
@@ -10,6 +10,7 @@ export type BrandConfig = {
|
||||
logoWhite: string
|
||||
favicon: string
|
||||
domain: string
|
||||
skillsOrg?: string // GitHub org for `npx skills add <org>/skills`
|
||||
apiUrl: string
|
||||
wsUrl: string
|
||||
statusUrl: string
|
||||
@@ -56,6 +57,7 @@ const brands: Record<string, BrandConfig> = {
|
||||
logoWhite: "/logo/bootnode-logo-white.svg",
|
||||
favicon: "/logo/bootnode-icon.svg",
|
||||
domain: "bootno.de",
|
||||
skillsOrg: "bootnode",
|
||||
apiUrl: "https://api.bootno.de",
|
||||
wsUrl: "wss://ws.bootno.de",
|
||||
statusUrl: "https://status.bootno.de",
|
||||
@@ -90,6 +92,7 @@ const brands: Record<string, BrandConfig> = {
|
||||
logoWhite: "/logo/hanzo-logo-white.svg",
|
||||
favicon: "/logo/hanzo-icon.svg",
|
||||
domain: "web3.hanzo.ai",
|
||||
skillsOrg: "hanzoai",
|
||||
apiUrl: "https://api.cloud.hanzo.ai",
|
||||
wsUrl: "wss://ws.cloud.hanzo.ai",
|
||||
statusUrl: "https://status.hanzo.ai",
|
||||
@@ -131,6 +134,7 @@ const brands: Record<string, BrandConfig> = {
|
||||
logoWhite: "/logo/lux-wordmark-white.svg",
|
||||
favicon: "/logo/lux-icon.svg",
|
||||
domain: "cloud.lux.network",
|
||||
skillsOrg: "luxfi",
|
||||
apiUrl: "https://api.cloud.lux.network",
|
||||
wsUrl: "wss://ws.cloud.lux.network",
|
||||
statusUrl: "https://status.lux.network",
|
||||
@@ -181,6 +185,7 @@ const brands: Record<string, BrandConfig> = {
|
||||
logoWhite: "/logo/pars-logo-white.svg",
|
||||
favicon: "/logo/pars-icon.svg",
|
||||
domain: "cloud.pars.network",
|
||||
skillsOrg: "pars-network",
|
||||
apiUrl: "https://api.cloud.pars.network",
|
||||
wsUrl: "wss://ws.cloud.pars.network",
|
||||
statusUrl: "https://status.pars.network",
|
||||
@@ -223,6 +228,7 @@ const brands: Record<string, BrandConfig> = {
|
||||
logoWhite: "/logo/zoo-logo-white.svg",
|
||||
favicon: "/logo/zoo-icon.svg",
|
||||
domain: "web3.zoo.ngo",
|
||||
skillsOrg: "zooai",
|
||||
apiUrl: "https://api.web3.zoo.ngo",
|
||||
wsUrl: "wss://ws.web3.zoo.ngo",
|
||||
statusUrl: "https://status.zoo.ngo",
|
||||
|
||||
@@ -60,5 +60,12 @@
|
||||
"postcss": "^8.5.3",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"@tailwindcss/oxide",
|
||||
"esbuild",
|
||||
"sharp"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user