mirror of
https://github.com/hanzoai/bootnode.git
synced 2026-09-30 20:46:32 +00:00
feat(api): expose mpc/keys/launch + add contracts & subgraphs
- register previously-dark routers: mpc (threshold ECDSA), keys, launch (explorer/indexer/gateway/exchange/faucet ecosystem deploy) - new /v1/contracts: deploy via signed tx, receipt, code, eth_call - new /v1/subgraphs: The Graph admin (create/deploy/remove/status) - align launch CRD luxd ports to canonical 9630/9631
This commit is contained in:
@@ -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}
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
|
||||
@@ -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)}
|
||||
Reference in New Issue
Block a user