Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions .github/workflows/auto_check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,18 @@ on:
- cron: "00 */4 * * *"
push:
branches:
- "master"
- "main"
workflow_dispatch:

permissions:
contents: write
pull-requests: write

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v4
- run: npx @dappnode/dappnodesdk github-action bump-upstream
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Expand Down
197 changes: 174 additions & 23 deletions build/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ import dotenv from "dotenv";
import shelljs from "shelljs";
import cors from "cors";
import appConfig from "./AppConfig";
const { API_PORT = 3000 } = process.env;
const { API_PORT = 3000, ROCKETPOOL_API_URL = "http://127.0.0.1:8280" } = process.env;

const ROCKETPOOL_SETTINGS = "/app/rocketpool/user-settings.yml";
const WEI_PER_ETH = BigInt("1000000000000000000");

dotenv.config();

Expand Down Expand Up @@ -46,41 +49,193 @@ app.get("/api/v1/w3s-status", async (req, res) => {
}
});

app.get("/api/v1/version", (req: Request, res: Response) => {
var version = shelljs.exec(`/usr/local/bin/rocketpoold --version`).stdout;
res.send(version);
app.get("/api/v1/version", async (req: Request, res: Response) => {
res.send(await callRocketpoolApi("/api/version"));
});

app.get("/api/v1/megapool/next-validator-bond", async (req: Request, res: Response) => {
try {
const status = await callRocketpoolApi("/api/megapool/status");
if (status.status !== "success") {
res.send(status);
return;
}

const megapool = status.megapoolDetails ?? {};
const activeValidatorCount = Number(megapool.activeValidatorCount ?? 0);
const nodeBond = BigInt(megapool.nodeBond ?? 0);
const nodeQueuedBond = BigInt(megapool.nodeQueuedBond ?? 0);
const bondedEth = nodeBond + nodeQueuedBond;

const bondRequirement = await callRocketpoolApi(
"/api/node/get-bond-requirement",
{ numValidators: String(activeValidatorCount + 1) }
);
if (bondRequirement.status !== "success") {
res.send(bondRequirement);
return;
}

let nextValidatorBond = BigInt(bondRequirement.bondRequirement ?? 0) - bondedEth;
if (nextValidatorBond < WEI_PER_ETH) {
nextValidatorBond = WEI_PER_ETH;
}
if (nextValidatorBond > BigInt(32) * WEI_PER_ETH) {
nextValidatorBond = BigInt(32) * WEI_PER_ETH;
}

res.send({
status: "success",
error: "",
bondRequirement: nextValidatorBond.toString(),
activeValidatorCount,
nodeBond: nodeBond.toString(),
nodeQueuedBond: nodeQueuedBond.toString(),
megapoolDeployed: megapool.deployed ?? false,
});
} catch (error) {
console.log(error);
res.send({ status: "error", error: String(error) });
}
});

// POST /api/v1/rocketpool-command-custom
app.post("/api/v1/rocketpool-command-custom", (req: Request, res: Response) => {
app.post("/api/v1/rocketpool-command-custom", async (req: Request, res: Response) => {
console.log(req.body.cmd);
// Keep the advanced shell as an explicit escape hatch for now, but use the
// v1.20 CLI (no removed `api` subcommand). Main UI calls use the HTTP API.
var result = shelljs.exec(
`/usr/local/bin/rocketpoold --settings /app/rocketpool/user-settings.yml api ${req.body.cmd}`
`/usr/local/bin/rocketpool --settings ${ROCKETPOOL_SETTINGS} ${req.body.cmd}`
).stdout;
res.send(result);
});

// POST /api/v1/rocketpool-command
app.post("/api/v1/rocketpool-command", (req: Request, res: Response) => {
app.post("/api/v1/rocketpool-command", async (req: Request, res: Response) => {
console.log(req.body.cmd);
res.send(executeCommand(req.body.cmd));
res.send(await executeCommand(req.body.cmd));
});

// function that executes the command using shelljs
function executeCommand(cmd: string) {
var result = shelljs.exec(
`/usr/local/bin/rocketpoold --settings /app/rocketpool/user-settings.yml api ${cmd}`
).stdout;
// conver result to json and check if result response "status":"success"
var resultJson = JSON.parse(result);
async function executeCommand(cmd: string) {
const resultJson = await executeRocketpoolCommand(cmd);
if (resultJson.status == "success") {
if (cmd.startsWith("node deposit")) {
// {"status":"success","error":"","txHash":"0x72162c8ac6b6fd9afe7c5b95166b7dc9c20df10031404fb09ad32db4bd9400c9","minipoolAddress":"0x14866919e7043288676eca918f6fc40d6f4616e0","validatorPubkey":"8eaccddb3ff58d68be44c1302b58d747350ef7e63c6c5fd555d356b8ab41f7cd61bb94f315897558c5d193324eec6ebc","scrubPeriod":3600000000000}
executeCommand(`wait ${resultJson.txHash}`);
importKey(`0x${resultJson.validatorPubkey}`);
await executeCommand(`wait ${resultJson.txHash}`);
const validatorPubkeys = resultJson.validatorPubkeys ?? [];
for (const validatorPubkey of validatorPubkeys) {
await importKey(ensureHexPrefix(validatorPubkey));
}
}
}
return result;
return resultJson;
}

async function executeRocketpoolCommand(cmd: string) {
const parts = splitCommand(cmd);
const [group, action, ...args] = parts;

if (group === "wait") {
return callRocketpoolApi("/api/wait", { txHash: action });
}

if (group === "wallet") {
if (action === "status") return callRocketpoolApi("/api/wallet/status");
if (action === "init") return callRocketpoolApi("/api/wallet/init", {}, "POST");
if (action === "recover") return callRocketpoolApi("/api/wallet/recover", { mnemonic: args.join(" ") }, "POST");
}

if (group === "node") {
if (action === "status") return callRocketpoolApi("/api/node/status");
if (action === "sync") return callRocketpoolApi("/api/node/sync");
if (action === "can-register") return callRocketpoolApi("/api/node/can-register", { timezoneLocation: args[0] });
if (action === "register") return callRocketpoolApi("/api/node/register", { timezoneLocation: args[0] }, "POST");
if (action === "can-set-primary-withdrawal-address") return callRocketpoolApi("/api/node/can-set-primary-withdrawal-address", { address: args[0], confirm: args[1] });
if (action === "set-primary-withdrawal-address") return callRocketpoolApi("/api/node/set-primary-withdrawal-address", { address: args[0], confirm: args[1] }, "POST");
if (action === "can-set-smoothing-pool-status") return callRocketpoolApi("/api/node/can-set-smoothing-pool-status", { status: args[0] });
if (action === "set-smoothing-pool-status") return callRocketpoolApi("/api/node/set-smoothing-pool-status", { status: args[0] }, "POST");
if (action === "stake-rpl-allowance") return callRocketpoolApi("/api/node/stake-rpl-allowance");
if (action === "stake-rpl-approve-rpl") return callRocketpoolApi("/api/node/stake-rpl-approve-rpl", { amountWei: args[0] }, "POST");
if (action === "can-stake-rpl") return callRocketpoolApi("/api/node/can-stake-rpl", { amountWei: args[0] });
if (action === "stake-rpl") return callRocketpoolApi("/api/node/stake-rpl", { amountWei: args[0] }, "POST");
if (action === "can-deposit") return callRocketpoolApi("/api/node/can-deposit", depositParams(args));
if (action === "deposit") return callRocketpoolApi("/api/node/deposit", depositParams(args, true), "POST");
if (action === "rewards") return callRocketpoolApi("/api/node/rewards");
if (action === "get-rewards-info") return callRocketpoolApi("/api/node/get-rewards-info");
if (action === "can-claim-rewards") return callRocketpoolApi("/api/node/can-claim-rewards", { indices: args[0] });
if (action === "claim-rewards") return callRocketpoolApi("/api/node/claim-rewards", { indices: args[0] }, "POST");
if (action === "can-claim-and-stake-rewards") return callRocketpoolApi("/api/node/can-claim-and-stake-rewards", { indices: args[0], stakeAmount: args[1] });
if (action === "claim-and-stake-rewards") return callRocketpoolApi("/api/node/claim-and-stake-rewards", { indices: args[0], stakeAmount: args[1] }, "POST");
}

if (group === "network") {
if (action === "node-fee") return callRocketpoolApi("/api/network/node-fee");
if (action === "rpl-price") return callRocketpoolApi("/api/network/rpl-price");
}

if (group === "minipool" && action === "status") {
return callRocketpoolApi("/api/minipool/status");
}

if (group === "megapool") {
if (action === "status") return callRocketpoolApi("/api/megapool/status");
if (action === "get-new-validator-bond-requirement") return callRocketpoolApi("/api/megapool/get-new-validator-bond-requirement");
}

return { status: "error", error: `Unsupported Rocket Pool command: ${cmd}` };
}

function depositParams(args: string[], includeExecuteParams = false): Record<string, string> {
const params: Record<string, string> = {
amountWei: args[0],
minFee: args[1],
salt: args[2],
expressTickets: args[3],
count: args[6] ?? args[4] ?? "1",
};
if (includeExecuteParams) {
params.useCreditBalance = args[3];
params.expressTickets = args[4];
params.submit = args[5];
params.count = args[6] ?? "1";
}
return params;
}

async function callRocketpoolApi(path: string, params: Record<string, string> = {}, method = "GET") {
const url = new URL(path, ROCKETPOOL_API_URL);
const init: RequestInit = { method };

if (method === "GET") {
for (const [key, value] of Object.entries(params)) {
url.searchParams.set(key, value);
}
} else {
init.body = new URLSearchParams(params).toString();
init.headers = { "Content-Type": "application/x-www-form-urlencoded" };
}

const response = await fetch(url, init);
const text = await response.text();
try {
return parseRocketpoolJson(text);
} catch (error) {
return { status: response.ok ? "success" : "error", error: response.ok ? "" : text, output: text };
}
}

function parseRocketpoolJson(text: string) {
// Rocket Pool returns wei amounts as JSON numbers. Preserve large integers as
// strings so Node/React never round transaction amounts beyond MAX_SAFE_INTEGER.
return JSON.parse(text.replace(/(:\s*)(-?\d{16,})(\s*[,}])/g, '$1"$2"$3'));
}

function splitCommand(cmd: string): string[] {
const matches = cmd.match(/"([^"]*)"|'([^']*)'|\S+/g) ?? [];
return matches.map((part) => part.replace(/^['"]|['"]$/g, ""));
}

function ensureHexPrefix(value: string): string {
return value.startsWith("0x") ? value : `0x${value}`;
}

// POST /api/v1/minipool/import
Expand Down Expand Up @@ -134,10 +289,6 @@ app.listen(API_PORT, () => {
console.log(`⚡️[server]: Server is running at http://localhost:${API_PORT}`);
});

String.prototype.startsWith = function (str) {
return this.indexOf(str) === 0;
};

interface ImportKeyResponseData {
data: ImportKeyResponse[];
}
Expand Down
32 changes: 30 additions & 2 deletions build/rocketpool-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ fi
case $NETWORK in
"mainnet")
echo "Mainnet network"
# Smartnode's internal network identifier (mainnet|testnet); the dappnode
# package keeps using $NETWORK (mainnet/hoodi) for its own identity.
SMARTNODE_NETWORK="mainnet"

# Assign proper value to _DAPPNODE_GLOBAL_EXECUTION_CLIENT_MAINNET.
case $_DAPPNODE_GLOBAL_EXECUTION_CLIENT_MAINNET in
Expand Down Expand Up @@ -84,8 +87,13 @@ case $NETWORK in
esac

;;
"testnet")
"testnet"|"hoodi")
echo "Hoodi network"
# Smartnode only accepts mainnet|testnet|devnet; "testnet" IS Hoodi
# (chainId 560048). Mapping hoodi -> testnet here so the daemon can resolve
# the RocketStorage address; without it every on-chain call fails with
# "The Rocket Pool storage contract was not found".
SMARTNODE_NETWORK="testnet"

# https://github.com/dappnode/DAppNodePackage-SSV-Shifu/blob/775dfbc2190b8c3bc7384a2e4c62d83892071001/build/entrypoint.sh#L3
# Assign proper value to _DAPPNODE_GLOBAL_EXECUTION_CLIENT_HOODI.
Expand Down Expand Up @@ -180,6 +188,7 @@ export BEACON_NODE_CLIENT=$_BEACON_NODE_CLIENT
# BEACON_NODE_API_4000="http://beacon-chain.prysm-hoodi.dappnode:4000"

NETWORK="${NETWORK}" \
SMARTNODE_NETWORK="${SMARTNODE_NETWORK}" \
EXECUTION_NODE_CLIENT="${EXECUTION_NODE_CLIENT}" \
BEACON_NODE_CLIENT="${BEACON_NODE_CLIENT}" \
EXECUTION_LAYER_HTTP="${EXECUTION_LAYER_HTTP}" \
Expand All @@ -197,8 +206,27 @@ if [ -f "/rocketpool/data/wallet" ]; then
echo "${INFO} Wallet already created"
fi
if [ ! -f /rocketpool/data/password ]; then
echo "${INFO} Initializing Rocketpool service before setting password"
/usr/local/bin/rocketpoold --settings /app/rocketpool/user-settings.yml node &
ROCKETPOOL_PID=$!

echo "${INFO} Waiting for Rocketpool HTTP API"
for i in $(seq 1 60); do
if curl -fsS http://127.0.0.1:8280/api/version >/dev/null 2>&1; then
break
fi
if ! kill -0 "$ROCKETPOOL_PID" >/dev/null 2>&1; then
echo "${ERROR} Rocketpool service exited before HTTP API became available"
wait "$ROCKETPOOL_PID"
exit $?
fi
sleep 1
done

echo "${INFO} set-password"
/usr/local/bin/rocketpoold --settings /app/rocketpool/user-settings.yml api wallet set-password "${WALLET_PASSWORD}"
curl -fsS -X POST --data-urlencode "password=${WALLET_PASSWORD}" http://127.0.0.1:8280/api/wallet/set-password || true
wait "$ROCKETPOOL_PID"
exit $?
fi
echo "${INFO} Initializing Rocketpool service"
exec /usr/local/bin/rocketpoold --settings /app/rocketpool/user-settings.yml node
Expand Down
Loading