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:
2026-06-19 00:32:57 +00:00
parent 9090b70ea2
commit 4b23b671a6
4 changed files with 212 additions and 6 deletions
+10
View File
@@ -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"])
+105
View File
@@ -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}
+6 -6
View File
@@ -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),
)
+91
View File
@@ -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)}