diff --git a/blueprints/reef-bootnode/docker-compose.yml b/blueprints/reef-bootnode/docker-compose.yml new file mode 100644 index 000000000..85c9b2718 --- /dev/null +++ b/blueprints/reef-bootnode/docker-compose.yml @@ -0,0 +1,51 @@ +version: "3.8" + +services: + reef-bootnode: + image: anukulpandey/reef-chain-node:latest + restart: unless-stopped + working_dir: /workspace + + volumes: + - ./output:/output + + expose: + - "${PORT}" + - "${P2P_PORT}" + + environment: + V1_ADDR: ${V1_ADDR} + V1_SEED: ${V1_SEED} + + V2_ADDR: ${V2_ADDR} + V2_SEED: ${V2_SEED} + + V3_ADDR: ${V3_ADDR} + V3_SEED: ${V3_SEED} + + PORT: ${PORT} + P2P_PORT: ${P2P_PORT} + + entrypoint: /bin/sh + command: -c " + set -e && + + echo '📦 Installing dependencies...' && + apt-get update && + apt-get install -y python3 wget ca-certificates && + + echo '📥 Downloading bootstrap scripts...' && + mkdir -p /workspace/bootstrap/scripts && + wget -O /workspace/bootstrap/scripts/run.sh \ + https://raw.githubusercontent.com/anukulpandey/dokploy-reef-chain-scripts/52c3e30d06bba10936aea2db9740eafd76b3ac40/bootstrap/scripts/run.sh && + wget -O /workspace/bootstrap/scripts/update-spec.py \ + https://raw.githubusercontent.com/anukulpandey/dokploy-reef-chain-scripts/52c3e30d06bba10936aea2db9740eafd76b3ac40/bootstrap/scripts/update-spec.py && + wget -O /workspace/bootstrap/scripts/update-spec.sh \ + https://raw.githubusercontent.com/anukulpandey/dokploy-reef-chain-scripts/52c3e30d06bba10936aea2db9740eafd76b3ac40/bootstrap/scripts/update-spec.sh && + + echo '🔧 Making scripts executable...' && + chmod +x /workspace/bootstrap/scripts/run.sh && + chmod +x /workspace/bootstrap/scripts/update-spec.sh && + + echo '🚀 Running spec generator + bootnode:' && + exec /workspace/bootstrap/scripts/run.sh" diff --git a/blueprints/reef-bootnode/meta.json b/blueprints/reef-bootnode/meta.json new file mode 100644 index 000000000..0684d4fa1 --- /dev/null +++ b/blueprints/reef-bootnode/meta.json @@ -0,0 +1,18 @@ +{ + "id": "reef-bootnode", + "name": "Reef Chain - Bootnode Validator", + "version": "1", + "description": "Runs bootnode for a custom Reef Chain cluster", + "links": { + "github": "https://github.com/reef-chain/chain-upgrade", + "website": "https://hub.docker.com/repository/docker/anukulpandey/reef-chain-node", + "docs": "https://docs.reef.io/" + }, + "logo": "reef.svg", + "tags": [ + "reef-chain", + "validator", + "self-hosted", + "evm" + ] +} diff --git a/blueprints/reef-bootnode/reef.svg b/blueprints/reef-bootnode/reef.svg new file mode 100644 index 000000000..598d7930a --- /dev/null +++ b/blueprints/reef-bootnode/reef.svg @@ -0,0 +1,68 @@ + + + + + + + + + + + diff --git a/blueprints/reef-bootnode/template.toml b/blueprints/reef-bootnode/template.toml new file mode 100644 index 000000000..9907faa74 --- /dev/null +++ b/blueprints/reef-bootnode/template.toml @@ -0,0 +1,37 @@ +[variables] +V1_ADDR = "" +V1_SEED = "" + +V2_ADDR = "" +V2_SEED = "" + +V3_ADDR = "" +V3_SEED = "" + +PORT="8000" +P2P_PORT="30335" + +[[config.domains]] +serviceName = "reef-bootnode" +port = 8000 +host = "${domain}" +path = "/" + +[[config.domains]] +serviceName = "reef-bootnode" +port = 8000 +host = "spec.${domain}" +path = "/" + +[config.env] +V1_ADDR = "" +V1_SEED = "" + +V2_ADDR = "" +V2_SEED = "" + +V3_ADDR = "" +V3_SEED = "" + +PORT="8000" +P2P_PORT="30335" diff --git a/blueprints/reef-dev-cluster/docker-compose.yml b/blueprints/reef-dev-cluster/docker-compose.yml new file mode 100644 index 000000000..49b726af8 --- /dev/null +++ b/blueprints/reef-dev-cluster/docker-compose.yml @@ -0,0 +1,129 @@ +services: + reef-dev-cluster: + image: anukulpandey/reef-chain-node:latest + restart: unless-stopped + working_dir: /workspace + + environment: + v1sec: "${v1sec:-}" + v2sec: "${v2sec:-}" + v3sec: "${v3sec:-}" + faucetsec: "${faucetsec:-}" + DEFAULT_AMOUNT: "${DEFAULT_AMOUNT:-2000}" + MAX_AMOUNT: "${MAX_AMOUNT:-2000}" + + volumes: + - reef-dev-cluster-state:/workspace/state + - reef-dev-cluster-download:/workspace/download + + expose: + - "30335" + - "30333" + - "30334" + - "30336" + - "8001" + - "9944" + - "9945" + - "8080" + + healthcheck: + test: + - CMD-SHELL + - bash -lc 'exec 3<>/dev/tcp/127.0.0.1/9944' + interval: 10s + timeout: 5s + retries: 30 + start_period: 300s + + entrypoint: /bin/sh + + command: + - -c + - | + set -e + + export DEBIAN_FRONTEND=noninteractive + + echo '📦 Installing required tools...' + + apt-get update + + apt-get install -y \ + wget \ + curl \ + python3 \ + ca-certificates \ + gnupg \ + nginx + + mkdir -p \ + /etc/apt/keyrings \ + /workspace \ + /workspace/state \ + /workspace/download + + echo '🔑 Configuring NodeSource repository...' + + curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \ + | gpg --batch --yes --dearmor -o /etc/apt/keyrings/nodesource.gpg + + echo 'deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_20.x nodistro main' \ + > /etc/apt/sources.list.d/nodesource.list + + apt-get update + + apt-get install -y nodejs + + echo '📥 Downloading cluster bootstrap...' + + wget -O /workspace/run-cluster.sh \ + https://raw.githubusercontent.com/anukulpandey/dokploy-templates/d8a82420dad013a3eb7e5d8afc0b9f2700a0045a/blueprints/reef-dev-cluster/run-cluster.sh + + echo '🔧 Making script executable...' + + chmod +x /workspace/run-cluster.sh + + echo '🚀 Starting self-contained network...' + + exec env TEMPLATE_ASSET_REF=d8a82420dad013a3eb7e5d8afc0b9f2700a0045a \ + /workspace/run-cluster.sh + + reef-dev-cluster-eth-rpc: + image: anukulpandey/reef-chain-eth-rpc:latest + restart: unless-stopped + working_dir: /workspace + + depends_on: + reef-dev-cluster: + condition: service_healthy + + expose: + - "8545" + + environment: + NODE_RPC_URL: ws://reef-dev-cluster:9945 + PORT: "8545" + + healthcheck: + test: + - CMD-SHELL + - bash -lc 'exec 3<>/dev/tcp/127.0.0.1/8545' + interval: 10s + timeout: 5s + retries: 12 + start_period: 20s + + entrypoint: /bin/sh + + command: + - -c + - | + exec eth-rpc \ + --node-rpc-url "$$NODE_RPC_URL" \ + --rpc-port "$$PORT" \ + --rpc-external \ + --rpc-cors all + +volumes: + reef-dev-cluster-state: + reef-dev-cluster-download: diff --git a/blueprints/reef-dev-cluster/faucet/package.json b/blueprints/reef-dev-cluster/faucet/package.json new file mode 100644 index 000000000..fd45ff3d7 --- /dev/null +++ b/blueprints/reef-dev-cluster/faucet/package.json @@ -0,0 +1,13 @@ +{ + "name": "reef-dev-cluster-faucet", + "version": "1.0.0", + "private": true, + "description": "HTTP faucet for a self-contained Reef dev cluster", + "main": "server.js", + "scripts": { + "start": "node server.js" + }, + "dependencies": { + "@polkadot/api": "^16.4.8" + } +} diff --git a/blueprints/reef-dev-cluster/faucet/server.js b/blueprints/reef-dev-cluster/faucet/server.js new file mode 100644 index 000000000..15d1ce32b --- /dev/null +++ b/blueprints/reef-dev-cluster/faucet/server.js @@ -0,0 +1,696 @@ +#!/usr/bin/env node + +const http = require("node:http"); +const { ApiPromise, Keyring, WsProvider } = require("@polkadot/api"); + +const config = { + wsEndpoint: process.env.WS_ENDPOINT || "ws://127.0.0.1:9944", + evmRpcUrl: process.env.EVM_RPC_URL || "", + faucetSeed: process.env.FAUCET_SEED || "", + port: Number.parseInt(process.env.PORT || "8080", 10), + defaultAmount: String(process.env.DEFAULT_AMOUNT || "2000"), + maxAmount: String(process.env.MAX_AMOUNT || "2000"), + serviceName: process.env.SERVICE_NAME || "reef-faucet", +}; + +if (!config.faucetSeed) { + throw new Error("FAUCET_SEED is required"); +} + +let contextPromise = null; +let requestQueue = Promise.resolve(); + +function normalizeEvmAddress(value) { + const normalized = String(value || "").trim(); + if (!/^0x[0-9a-fA-F]{40}$/.test(normalized)) { + throw new Error(`Invalid EVM address: ${value}`); + } + return normalized; +} + +function parseUnits(value, decimals) { + const input = String(value).trim(); + if (!/^\d+(\.\d+)?$/.test(input)) { + throw new Error(`Invalid decimal amount: ${value}`); + } + + const [whole, fraction = ""] = input.split("."); + if (fraction.length > decimals) { + throw new Error(`Too many decimal places in ${value}`); + } + + const wholeUnits = BigInt(whole) * 10n ** BigInt(decimals); + const fractionUnits = fraction ? BigInt(fraction.padEnd(decimals, "0")) : 0n; + return wholeUnits + fractionUnits; +} + +function formatUnits(value, decimals) { + const negative = value < 0n; + const abs = negative ? -value : value; + const base = 10n ** BigInt(decimals); + const whole = abs / base; + const fraction = abs % base; + + if (fraction === 0n) { + return `${negative ? "-" : ""}${whole.toString()}`; + } + + return `${negative ? "-" : ""}${whole.toString()}.${fraction + .toString() + .padStart(decimals, "0") + .replace(/0+$/, "")}`; +} + +function fallbackAccountFromEvm(evmAddress) { + return `0x${evmAddress.slice(2).toLowerCase()}${"ee".repeat(12)}`; +} + +function decodeDispatchError(api, dispatchError) { + if (dispatchError && dispatchError.isModule) { + const decoded = api.registry.findMetaError(dispatchError.asModule); + return `${decoded.section}.${decoded.name}: ${decoded.docs.join(" ")}`; + } + + return dispatchError && dispatchError.toString ? dispatchError.toString() : String(dispatchError); +} + +async function signAndWait(api, tx, signer) { + return new Promise(async (resolve, reject) => { + let unsubscribe = null; + + try { + unsubscribe = await tx.signAndSend(signer, ({ dispatchError, events, status, txHash }) => { + if (dispatchError) { + if (unsubscribe) { + unsubscribe(); + } + reject(new Error(decodeDispatchError(api, dispatchError))); + return; + } + + if (status.isFinalized) { + if (unsubscribe) { + unsubscribe(); + } + resolve({ + txHash: txHash.toHex(), + finalizedBlock: status.asFinalized.toHex(), + eventCount: events.length, + }); + } + }); + } catch (error) { + if (unsubscribe) { + unsubscribe(); + } + reject(error); + } + }); +} + +async function maybeClaimDefaultAccount(api, signer) { + const storage = api.query && api.query.evmAccounts && api.query.evmAccounts.evmAddresses; + const extrinsic = api.tx && api.tx.evmAccounts && api.tx.evmAccounts.claimDefaultAccount; + + if (!storage || !extrinsic) { + return null; + } + + const current = await storage(signer.address); + if (!current.isEmpty) { + return current.toString(); + } + + await signAndWait(api, extrinsic(), signer); + return (await storage(signer.address)).toString(); +} + +function buildReviveTransfer(api, signerAddress, targetEvm, amountUnits) { + const reviveTransfer = api.tx && api.tx.revive && api.tx.revive.transfer; + if (!reviveTransfer) { + return null; + } + + const metaArgs = reviveTransfer.meta.toJSON().args || []; + const names = metaArgs.map((arg) => String((arg && arg.name) || "").toLowerCase()); + const types = metaArgs.map((arg) => JSON.stringify((arg && arg.type) || "").toLowerCase()); + const haystack = `${names.join(",")}|${types.join(",")}`; + + if (metaArgs.length === 2 && haystack.includes("h160")) { + return { + strategy: "revive.transfer(H160, Balance)", + tx: reviveTransfer(targetEvm, amountUnits), + }; + } + + if (metaArgs.length === 3 && haystack.includes("accountid") && haystack.includes("h160")) { + return { + strategy: "revive.transfer(AccountId, H160, Balance)", + tx: reviveTransfer(signerAddress, targetEvm, amountUnits), + }; + } + + return { + strategy: "revive.transfer(fallback call signature)", + tx: reviveTransfer(targetEvm, amountUnits), + }; +} + +async function getContext() { + if (!contextPromise) { + contextPromise = (async () => { + const provider = new WsProvider(config.wsEndpoint); + const api = await ApiPromise.create({ provider }); + const keyring = new Keyring({ type: "sr25519" }); + const sender = keyring.addFromUri(config.faucetSeed); + const nativeDecimals = api.registry.chainDecimals[0] || 12; + const tokenSymbol = api.registry.chainTokens[0] || "REEF"; + + return { api, nativeDecimals, provider, sender, tokenSymbol }; + })(); + } + + return contextPromise; +} + +function jsonResponse(response, statusCode, body) { + response.writeHead(statusCode, { "Content-Type": "application/json" }); + response.end(JSON.stringify(body)); +} + +function htmlResponse(response, statusCode, body) { + response.writeHead(statusCode, { "Content-Type": "text/html; charset=utf-8" }); + response.end(body); +} + +function escapeHtml(value) { + return String(value) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function wantsHtml(request) { + const accept = String(request.headers.accept || ""); + return accept.includes("text/html"); +} + +function buildInfoPayload() { + return { + service: config.serviceName, + status: "ok", + defaults: { + defaultAmount: config.defaultAmount, + maxAmount: config.maxAmount, + }, + upstream: { + wsEndpoint: config.wsEndpoint, + evmRpcUrl: config.evmRpcUrl || null, + }, + endpoints: { + health: { + method: "GET", + path: "/health", + }, + drip: { + method: "POST", + path: "/drip", + body: { + to: "0x0000000000000000000000000000000000000000", + amount: config.defaultAmount, + }, + }, + }, + }; +} + +function buildFrontendHtml() { + const bootPayload = JSON.stringify(buildInfoPayload()); + const title = escapeHtml(config.serviceName); + const defaultAmount = escapeHtml(config.defaultAmount); + const maxAmount = escapeHtml(config.maxAmount); + const wsEndpoint = escapeHtml(config.wsEndpoint); + const evmRpcUrl = escapeHtml(config.evmRpcUrl || "Not configured"); + + return ` + + + + + ${title} + + + +
+
+
+

Reef Faucet

+

${title}

+

Send test REEF to any EVM address without reaching for curl first.

+
+ +
+
+ Default amount + ${defaultAmount} REEF +
+
+ Max amount + ${maxAmount} REEF +
+
+ WS upstream + ${wsEndpoint} +
+
+ EVM upstream + ${evmRpcUrl} +
+
+ +
+ + + + +
+ + +
+
+ +
+
Ready.
+
+ + +
+
+ + + +`; +} + +async function parseRequestBody(request) { + const chunks = []; + for await (const chunk of request) { + chunks.push(chunk); + } + + if (chunks.length === 0) { + return {}; + } + + return JSON.parse(Buffer.concat(chunks).toString("utf8")); +} + +async function executeDrip(targetEvm, amountText) { + const { api, nativeDecimals, sender, tokenSymbol } = await getContext(); + const amountUnits = parseUnits(amountText, nativeDecimals); + const maxUnits = parseUnits(config.maxAmount, nativeDecimals); + + if (amountUnits <= 0n) { + throw new Error("Amount must be greater than zero"); + } + + if (amountUnits > maxUnits) { + throw new Error(`Amount exceeds MAX_AMOUNT (${config.maxAmount})`); + } + + await maybeClaimDefaultAccount(api, sender); + + const senderBefore = (await api.query.system.account(sender.address)).data.free.toBigInt(); + + let strategy = ""; + let result = null; + + try { + const revive = buildReviveTransfer(api, sender.address, targetEvm, amountUnits); + if (!revive) { + throw new Error("revive.transfer unavailable"); + } + strategy = revive.strategy; + result = await signAndWait(api, revive.tx, sender); + } catch (error) { + strategy = "balances.transferAllowDeath(AccountId32 fallback)"; + result = await signAndWait( + api, + api.tx.balances.transferAllowDeath(fallbackAccountFromEvm(targetEvm), amountUnits), + sender + ); + } + + const senderAfter = (await api.query.system.account(sender.address)).data.free.toBigInt(); + + return { + amount: amountText, + strategy, + tokenSymbol, + txHash: result.txHash, + finalizedBlock: result.finalizedBlock, + eventCount: result.eventCount, + sender: sender.address, + senderNativeBefore: formatUnits(senderBefore, nativeDecimals), + senderNativeAfter: formatUnits(senderAfter, nativeDecimals), + }; +} + +function enqueue(task) { + const current = requestQueue.then(task, task); + requestQueue = current.catch(() => undefined); + return current; +} + +const server = http.createServer(async (request, response) => { + try { + if (request.method === "GET" && request.url === "/favicon.ico") { + response.writeHead(204); + response.end(); + return; + } + + if (request.method === "GET" && request.url === "/") { + if (wantsHtml(request)) { + htmlResponse(response, 200, buildFrontendHtml()); + } else { + jsonResponse(response, 200, buildInfoPayload()); + } + return; + } + + if (request.method === "GET" && request.url === "/health") { + jsonResponse(response, 200, { status: "ok" }); + return; + } + + if (request.method === "POST" && request.url === "/drip") { + const payload = await parseRequestBody(request); + const targetEvm = normalizeEvmAddress(payload.to); + const amount = payload.amount ? String(payload.amount) : config.defaultAmount; + const result = await enqueue(() => executeDrip(targetEvm, amount)); + jsonResponse(response, 200, result); + return; + } + + jsonResponse(response, 404, { error: "Not found" }); + } catch (error) { + jsonResponse(response, 400, { error: error.message }); + } +}); + +server.listen(config.port, "0.0.0.0", () => { + console.log(`reef dev-cluster faucet listening on ${config.port}`); +}); diff --git a/blueprints/reef-dev-cluster/meta.json b/blueprints/reef-dev-cluster/meta.json new file mode 100644 index 000000000..05687870b --- /dev/null +++ b/blueprints/reef-dev-cluster/meta.json @@ -0,0 +1,19 @@ +{ + "id": "reef-dev-cluster", + "name": "Reef Chain - Dev Cluster", + "version": "1", + "description": "Self-contained Reef dev cluster with bootnode, validators, spec server, substrate RPC, EVM RPC, and faucet", + "links": { + "github": "https://github.com/reef-chain/chain-upgrade", + "website": "https://hub.docker.com/repository/docker/anukulpandey/reef-chain-node", + "docs": "https://docs.reef.io/" + }, + "logo": "reef.svg", + "tags": [ + "reef-chain", + "dev-cluster", + "self-hosted", + "evm", + "faucet" + ] +} diff --git a/blueprints/reef-dev-cluster/reef.svg b/blueprints/reef-dev-cluster/reef.svg new file mode 100644 index 000000000..598d7930a --- /dev/null +++ b/blueprints/reef-dev-cluster/reef.svg @@ -0,0 +1,68 @@ + + + + + + + + + + + diff --git a/blueprints/reef-dev-cluster/run-cluster.sh b/blueprints/reef-dev-cluster/run-cluster.sh new file mode 100755 index 000000000..4dca6dc68 --- /dev/null +++ b/blueprints/reef-dev-cluster/run-cluster.sh @@ -0,0 +1,549 @@ +#!/usr/bin/env bash + +set -euo pipefail + +NODE_BIN="${NODE_BIN:-reef-node}" +CHAIN_TEMPLATE="${CHAIN_TEMPLATE:-testnet-new}" +WORK_DIR="${WORK_DIR:-/tmp/reef-dev-cluster}" +STATE_DIR="${STATE_DIR:-/workspace/state}" +SEED_DIR="${SEED_DIR:-$STATE_DIR/seeds}" +NODE_KEY_DIR="${NODE_KEY_DIR:-$STATE_DIR/node-keys}" +OUTPUT_DIR="${OUTPUT_DIR:-/workspace/download}" +PLAIN_SPEC="${PLAIN_SPEC:-$WORK_DIR/local-chain-spec.json}" +UPDATED_SPEC="${UPDATED_SPEC:-$WORK_DIR/local-chain-spec-updated.json}" +SPEC_FILE="${SPEC_FILE:-$OUTPUT_DIR/local-chain-spec-raw.json}" +SPEC_HTTP_PORT="${SPEC_HTTP_PORT:-8001}" +BOOTNODE_P2P_PORT="${BOOTNODE_P2P_PORT:-30335}" +VALIDATOR1_P2P_PORT="${VALIDATOR1_P2P_PORT:-30333}" +VALIDATOR2_P2P_PORT="${VALIDATOR2_P2P_PORT:-30334}" +VALIDATOR3_P2P_PORT="${VALIDATOR3_P2P_PORT:-30336}" +RPC_NODE_P2P_PORT="${RPC_NODE_P2P_PORT:-30337}" +RPC_NODE_WS_PORT="${RPC_NODE_WS_PORT:-9944}" +RPC_NODE_PUBLIC_WS_PORT="${RPC_NODE_PUBLIC_WS_PORT:-9945}" +ETH_RPC_PORT="${ETH_RPC_PORT:-8545}" +FAUCET_PORT="${FAUCET_PORT:-8080}" +BOOTNODE_PROMETHEUS_PORT="${BOOTNODE_PROMETHEUS_PORT:-9615}" +VALIDATOR1_PROMETHEUS_PORT="${VALIDATOR1_PROMETHEUS_PORT:-9616}" +VALIDATOR2_PROMETHEUS_PORT="${VALIDATOR2_PROMETHEUS_PORT:-9617}" +VALIDATOR3_PROMETHEUS_PORT="${VALIDATOR3_PROMETHEUS_PORT:-9618}" +RPC_NODE_PROMETHEUS_PORT="${RPC_NODE_PROMETHEUS_PORT:-9619}" +DEFAULT_AMOUNT="${DEFAULT_AMOUNT:-2000}" +MAX_AMOUNT="${MAX_AMOUNT:-2000}" +TEMPLATE_ASSET_REF="${TEMPLATE_ASSET_REF:-reef-chain}" +TEMPLATE_ASSET_BASE_URL="${TEMPLATE_ASSET_BASE_URL:-https://raw.githubusercontent.com/anukulpandey/dokploy-templates/${TEMPLATE_ASSET_REF}/blueprints/reef-dev-cluster}" +FAUCET_DIR="${FAUCET_DIR:-/workspace/faucet}" +BOOTNODE_PEER_ID_FILE="$OUTPUT_DIR/bootnode_peer_id.txt" +BOOTNODE_COMPAT_FILE="$OUTPUT_DIR/bootnode_node_key.txt" +CLUSTER_INFO_FILE="$OUTPUT_DIR/cluster-info.json" + +PIDS=() +TAIL_PID="" + +cleanup() { + kill "$TAIL_PID" 2>/dev/null || true + if [ "${#PIDS[@]}" -gt 0 ]; then + kill "${PIDS[@]}" 2>/dev/null || true + fi +} + +trap cleanup EXIT + +trim_whitespace() { + printf '%s' "$1" | tr -d '[:space:]' +} + +canonicalize_seed() { + local name="$1" + local value + value=$(trim_whitespace "${2:-}") + + if [ -z "$value" ]; then + printf '\n' + return 0 + fi + + if [[ "$value" =~ ^0x[0-9A-Fa-f]{64}$ ]]; then + printf '0x%s\n' "$(printf '%s' "${value#0x}" | tr '[:upper:]' '[:lower:]')" + return 0 + fi + + if [[ "$value" =~ ^[0-9A-Fa-f]{64}$ ]]; then + printf '0x%s\n' "$(printf '%s' "$value" | tr '[:upper:]' '[:lower:]')" + return 0 + fi + + echo "Invalid seed for $name. Expected 64 hex bytes with optional 0x prefix." >&2 + exit 1 +} + +write_secret_file() { + local file="$1" + local value="$2" + + mkdir -p "$(dirname "$file")" + printf '%s\n' "$value" > "$file" + chmod 600 "$file" +} + +generate_seed() { + printf '0x%s\n' "$(od -An -N32 -tx1 /dev/urandom | tr -d ' \n' | tr '[:upper:]' '[:lower:]')" +} + +resolve_seed() { + local env_name="$1" + local file_name="$2" + local file="$SEED_DIR/$file_name" + local raw_value normalized source + + raw_value=$(trim_whitespace "${!env_name:-}") + if [ -n "$raw_value" ]; then + normalized=$(canonicalize_seed "$env_name" "$raw_value") + source="env" + write_secret_file "$file" "$normalized" + elif [ -f "$file" ]; then + normalized=$(canonicalize_seed "$env_name" "$(cat "$file")") + source="persisted" + write_secret_file "$file" "$normalized" + else + normalized=$(generate_seed) + source="generated" + write_secret_file "$file" "$normalized" + fi + + printf '%s|%s\n' "$normalized" "$source" +} + +resolve_optional_seed() { + local env_name="$1" + local file_name="$2" + local file="$SEED_DIR/$file_name" + local raw_value normalized source + + raw_value=$(trim_whitespace "${!env_name:-}") + if [ -n "$raw_value" ]; then + normalized=$(canonicalize_seed "$env_name" "$raw_value") + source="env" + write_secret_file "$file" "$normalized" + elif [ -f "$file" ]; then + normalized=$(canonicalize_seed "$env_name" "$(cat "$file")") + source="persisted" + write_secret_file "$file" "$normalized" + else + normalized="" + source="shared-v1" + fi + + printf '%s|%s\n' "$normalized" "$source" +} + +read_seed_result() { + local var_prefix="$1" + local result="$2" + local seed="${result%%|*}" + local source="${result##*|}" + + printf -v "${var_prefix}_SEED" '%s' "$seed" + printf -v "${var_prefix}_SOURCE" '%s' "$source" +} + +ensure_node_key_file() { + local name="$1" + local file="$NODE_KEY_DIR/${name}.key" + + if [ ! -s "$file" ]; then + mkdir -p "$NODE_KEY_DIR" + "$NODE_BIN" key generate-node-key --chain local > "$file" + chmod 600 "$file" + fi + + printf '%s\n' "$file" +} + +derive_address() { + local suri="$1" + local scheme="${2:-Sr25519}" + + "$NODE_BIN" key inspect --scheme "$scheme" "$suri" --output-type json \ + | grep -o '"ss58Address": "[^"]*"' \ + | cut -d'"' -f4 +} + +insert_keys() { + local base_path="$1" + local seed="$2" + + "$NODE_BIN" key insert --base-path "$base_path" --chain "$SPEC_FILE" --scheme Sr25519 --suri "$seed//babe" --key-type babe + "$NODE_BIN" key insert --base-path "$base_path" --chain "$SPEC_FILE" --scheme Ed25519 --suri "$seed//grandpa" --key-type gran + "$NODE_BIN" key insert --base-path "$base_path" --chain "$SPEC_FILE" --scheme Sr25519 --suri "$seed//im_online" --key-type imon + "$NODE_BIN" key insert --base-path "$base_path" --chain "$SPEC_FILE" --scheme Sr25519 --suri "$seed//authority_discovery" --key-type audi +} + +start_logged() { + local log_file="$1" + shift + + "$@" >"$log_file" 2>&1 & + PIDS+=("$!") +} + +write_ws_proxy_config() { + cat > /tmp/rpc-nginx.conf </dev/null || true + +echo "Resolved validator addresses:" +echo " - Validator1: $V1_ADDR ($V1_SOURCE)" +echo " - Validator2: $V2_ADDR ($V2_SOURCE)" +echo " - Validator3: $V3_ADDR ($V3_SOURCE)" +echo " - Faucet: $FAUCET_ADDR ($FAUCET_SOURCE)" +echo "Private state directory: $STATE_DIR" + +echo "Downloading local faucet sources..." +wget -q -O "$FAUCET_DIR/package.json" \ + "${TEMPLATE_ASSET_BASE_URL}/faucet/package.json" +wget -q -O "$FAUCET_DIR/server.js" \ + "${TEMPLATE_ASSET_BASE_URL}/faucet/server.js" + +echo "Installing faucet dependencies..." +( + cd "$FAUCET_DIR" + npm install --omit=dev --no-fund --no-audit +) + +echo "Generating chain spec..." +"$NODE_BIN" build-spec --chain "$CHAIN_TEMPLATE" --disable-default-bootnode > "$PLAIN_SPEC" + +python3 - "$PLAIN_SPEC" "$UPDATED_SPEC" \ + "$V1_ADDR" "$V1_BABE" "$V1_GRAN" "$V1_IMON" "$V1_AUDI" \ + "$V2_ADDR" "$V2_BABE" "$V2_GRAN" "$V2_IMON" "$V2_AUDI" \ + "$V3_ADDR" "$V3_BABE" "$V3_GRAN" "$V3_IMON" "$V3_AUDI" \ + "$FAUCET_ADDR" "$V1_ADDR" <<'PY' +import json +import sys + +input_file = sys.argv[1] +output_file = sys.argv[2] + +v1_addr, v1_babe, v1_gran, v1_imon, v1_audi = sys.argv[3:8] +v2_addr, v2_babe, v2_gran, v2_imon, v2_audi = sys.argv[8:13] +v3_addr, v3_babe, v3_gran, v3_imon, v3_audi = sys.argv[13:18] +faucet_addr = sys.argv[18] +default_faucet_addr = sys.argv[19] + +AMOUNT = 100000000000000000000000000 +STAKE = 1000000000000000000000000 + +with open(input_file, "r", encoding="utf-8") as handle: + spec = json.load(handle) + +balances = spec["genesis"]["runtimeGenesis"]["patch"]["balances"]["balances"] + +def upsert_balance(address, amount): + for entry in balances: + if entry[0] == address: + entry[1] = max(int(entry[1]), amount) + return + balances.append([address, amount]) + +for address in (v1_addr, v2_addr, v3_addr): + upsert_balance(address, AMOUNT) + +if faucet_addr != default_faucet_addr: + upsert_balance(faucet_addr, AMOUNT) + +spec["genesis"]["runtimeGenesis"]["patch"]["session"]["keys"] = [ + [ + v1_addr, + v1_addr, + { + "authority_discovery": v1_audi, + "babe": v1_babe, + "grandpa": v1_gran, + "im_online": v1_imon, + }, + ], + [ + v2_addr, + v2_addr, + { + "authority_discovery": v2_audi, + "babe": v2_babe, + "grandpa": v2_gran, + "im_online": v2_imon, + }, + ], + [ + v3_addr, + v3_addr, + { + "authority_discovery": v3_audi, + "babe": v3_babe, + "grandpa": v3_gran, + "im_online": v3_imon, + }, + ], +] + +spec["genesis"]["runtimeGenesis"]["patch"]["staking"]["invulnerables"] = [ + v1_addr, + v2_addr, + v3_addr, +] + +spec["genesis"]["runtimeGenesis"]["patch"]["staking"]["stakers"] = [ + [v1_addr, v1_addr, STAKE, "Validator"], + [v2_addr, v2_addr, STAKE, "Validator"], + [v3_addr, v3_addr, STAKE, "Validator"], +] + +with open(output_file, "w", encoding="utf-8") as handle: + json.dump(spec, handle, indent=2) +PY + +"$NODE_BIN" build-spec \ + --chain "$UPDATED_SPEC" \ + --disable-default-bootnode \ + --raw > "$SPEC_FILE" + +BOOTNODE_NODE_KEY_FILE=$(ensure_node_key_file bootnode) +V1_NODE_KEY_FILE=$(ensure_node_key_file validator1) +V2_NODE_KEY_FILE=$(ensure_node_key_file validator2) +V3_NODE_KEY_FILE=$(ensure_node_key_file validator3) +RPC_NODE_KEY_FILE=$(ensure_node_key_file rpc-node) + +BOOTNODE_PEER_ID=$("$NODE_BIN" key inspect-node-key --file "$BOOTNODE_NODE_KEY_FILE" 2>/dev/null | tail -n1 | tr -d '\r') +if [ -z "$BOOTNODE_PEER_ID" ]; then + echo "Failed to derive bootnode peer id" >&2 + exit 1 +fi + +printf '%s\n' "$BOOTNODE_PEER_ID" > "$BOOTNODE_PEER_ID_FILE" +printf '%s\n' "$BOOTNODE_PEER_ID" > "$BOOTNODE_COMPAT_FILE" +write_cluster_info + +echo "Bootnode peer ID: $BOOTNODE_PEER_ID" + +insert_keys /tmp/validator1 "$V1_SEED" +insert_keys /tmp/validator2 "$V2_SEED" +insert_keys /tmp/validator3 "$V3_SEED" + +python3 -m http.server "$SPEC_HTTP_PORT" --bind 0.0.0.0 --directory "$OUTPUT_DIR" >/tmp/spec-server.log 2>&1 & +PIDS+=("$!") + +BOOTNODE_MULTIADDR="/ip4/127.0.0.1/tcp/${BOOTNODE_P2P_PORT}/p2p/${BOOTNODE_PEER_ID}" + +write_ws_proxy_config + +start_logged /tmp/bootnode.log \ + "$NODE_BIN" \ + --base-path /tmp/bootnode \ + --chain "$SPEC_FILE" \ + --port "$BOOTNODE_P2P_PORT" \ + --prometheus-port "$BOOTNODE_PROMETHEUS_PORT" \ + --rpc-port 0 \ + --node-key-file "$BOOTNODE_NODE_KEY_FILE" \ + --name Bootnode \ + --no-telemetry + +start_logged /tmp/validator1.log \ + "$NODE_BIN" \ + --base-path /tmp/validator1 \ + --chain "$SPEC_FILE" \ + --port "$VALIDATOR1_P2P_PORT" \ + --prometheus-port "$VALIDATOR1_PROMETHEUS_PORT" \ + --rpc-port 0 \ + --node-key-file "$V1_NODE_KEY_FILE" \ + --bootnodes "$BOOTNODE_MULTIADDR" \ + --validator \ + --name Validator1 \ + --no-telemetry + +start_logged /tmp/validator2.log \ + "$NODE_BIN" \ + --base-path /tmp/validator2 \ + --chain "$SPEC_FILE" \ + --port "$VALIDATOR2_P2P_PORT" \ + --prometheus-port "$VALIDATOR2_PROMETHEUS_PORT" \ + --rpc-port 0 \ + --node-key-file "$V2_NODE_KEY_FILE" \ + --bootnodes "$BOOTNODE_MULTIADDR" \ + --validator \ + --name Validator2 \ + --no-telemetry + +start_logged /tmp/validator3.log \ + "$NODE_BIN" \ + --base-path /tmp/validator3 \ + --chain "$SPEC_FILE" \ + --port "$VALIDATOR3_P2P_PORT" \ + --prometheus-port "$VALIDATOR3_PROMETHEUS_PORT" \ + --rpc-port 0 \ + --node-key-file "$V3_NODE_KEY_FILE" \ + --bootnodes "$BOOTNODE_MULTIADDR" \ + --validator \ + --name Validator3 \ + --no-telemetry + +start_logged /tmp/rpc-node.log \ + "$NODE_BIN" \ + --base-path /tmp/rpc-node \ + --chain "$SPEC_FILE" \ + --port "$RPC_NODE_P2P_PORT" \ + --prometheus-port "$RPC_NODE_PROMETHEUS_PORT" \ + --node-key-file "$RPC_NODE_KEY_FILE" \ + --bootnodes "$BOOTNODE_MULTIADDR" \ + --rpc-external \ + --rpc-port "$RPC_NODE_WS_PORT" \ + --rpc-cors all \ + --rpc-methods Unsafe \ + --rpc-max-connections 10000 \ + --pruning archive \ + --name rpc-node \ + --no-telemetry + +start_logged /tmp/ws-proxy.log \ + nginx \ + -c /tmp/rpc-nginx.conf \ + -g 'daemon off;' + +start_logged /tmp/faucet.log \ + env \ + SERVICE_NAME="reef-dev-cluster-faucet" \ + PORT="$FAUCET_PORT" \ + WS_ENDPOINT="ws://127.0.0.1:${RPC_NODE_WS_PORT}" \ + EVM_RPC_URL="http://127.0.0.1:${ETH_RPC_PORT}" \ + FAUCET_SEED="$FAUCET_SEED" \ + DEFAULT_AMOUNT="$DEFAULT_AMOUNT" \ + MAX_AMOUNT="$MAX_AMOUNT" \ + npm --prefix "$FAUCET_DIR" start + +tail -F /tmp/spec-server.log /tmp/bootnode.log /tmp/validator1.log /tmp/validator2.log /tmp/validator3.log /tmp/rpc-node.log /tmp/ws-proxy.log /tmp/faucet.log & +TAIL_PID=$! + +wait -n "${PIDS[@]}" +STATUS=$? +exit "$STATUS" diff --git a/blueprints/reef-dev-cluster/template.toml b/blueprints/reef-dev-cluster/template.toml new file mode 100644 index 000000000..05345c2bd --- /dev/null +++ b/blueprints/reef-dev-cluster/template.toml @@ -0,0 +1,51 @@ +[variables] +# Optional advanced overrides. Leave blank to auto-generate and persist validator identities. +v1sec = "" +v2sec = "" +v3sec = "" + +# Optional dedicated faucet seed. Leave blank to reuse validator 1. +faucetsec = "" +DEFAULT_AMOUNT = "2000" +MAX_AMOUNT = "2000" + +[[config.domains]] +serviceName = "reef-dev-cluster" +port = 8001 +host = "${domain}" +path = "/" + +[[config.domains]] +serviceName = "reef-dev-cluster" +port = 8001 +host = "spec.${domain}" +path = "/" + +[[config.domains]] +serviceName = "reef-dev-cluster" +port = 9945 +host = "ws.${domain}" +path = "/" + +[[config.domains]] +serviceName = "reef-dev-cluster-eth-rpc" +port = 8545 +host = "eth.${domain}" +path = "/" + +[[config.domains]] +serviceName = "reef-dev-cluster" +port = 8080 +host = "faucet.${domain}" +path = "/" + +[config.env] +# Optional advanced overrides. Leave blank to auto-generate and persist validator identities. +v1sec = "" +v2sec = "" +v3sec = "" + +# Optional dedicated faucet seed. Leave blank to reuse validator 1. +faucetsec = "" +DEFAULT_AMOUNT = "2000" +MAX_AMOUNT = "2000" diff --git a/blueprints/reef-eth-rpc/docker-compose.yml b/blueprints/reef-eth-rpc/docker-compose.yml new file mode 100644 index 000000000..57ae2b8be --- /dev/null +++ b/blueprints/reef-eth-rpc/docker-compose.yml @@ -0,0 +1,16 @@ +version: "3.8" + +services: + reef-eth-rpc: + image: anukulpandey/reef-chain-eth-rpc:latest + restart: unless-stopped + working_dir: /workspace + volumes: + - ./output:/output + environment: + - PORT=${PORT} + - NODE_RPC_URL=${NODE_RPC_URL} + expose: + - "${PORT}" + entrypoint: /bin/sh + command: -c "eth-rpc --node-rpc-url $$NODE_RPC_URL --rpc-port $$PORT --rpc-external --rpc-cors all" diff --git a/blueprints/reef-eth-rpc/meta.json b/blueprints/reef-eth-rpc/meta.json new file mode 100644 index 000000000..86e1b3034 --- /dev/null +++ b/blueprints/reef-eth-rpc/meta.json @@ -0,0 +1,18 @@ +{ + "id": "reef-eth-rpc", + "name": "Reef Chain - ETH RPC", + "version": "1", + "description": "EVM layer rpc for a custom Reef Chain cluster", + "links": { + "github": "https://github.com/reef-chain/chain-upgrade", + "website": "https://hub.docker.com/repository/docker/anukulpandey/reef-chain-eth-rpc", + "docs": "https://docs.reef.io/" + }, + "logo": "reef.svg", + "tags": [ + "reef-chain", + "eth-rpc", + "self-hosted", + "keys" + ] +} diff --git a/blueprints/reef-eth-rpc/reef.svg b/blueprints/reef-eth-rpc/reef.svg new file mode 100644 index 000000000..598d7930a --- /dev/null +++ b/blueprints/reef-eth-rpc/reef.svg @@ -0,0 +1,68 @@ + + + + + + + + + + + diff --git a/blueprints/reef-eth-rpc/template.toml b/blueprints/reef-eth-rpc/template.toml new file mode 100644 index 000000000..c611c4d1b --- /dev/null +++ b/blueprints/reef-eth-rpc/template.toml @@ -0,0 +1,19 @@ +[variables] +PORT = "8545" +NODE_RPC_URL = "ws://ws.reef.host" + +[[config.domains]] +serviceName = "reef-eth-rpc" +port = 8545 +host = "${domain}" +path = "/" + +[[config.domains]] +serviceName = "reef-eth-rpc" +port = 8545 +host = "eth.${domain}" +path = "/" + +[config.env] +PORT = "8545" +NODE_RPC_URL = "ws://ws.reef.host" diff --git a/blueprints/reef-faucet/docker-compose.yml b/blueprints/reef-faucet/docker-compose.yml new file mode 100644 index 000000000..fb12738d9 --- /dev/null +++ b/blueprints/reef-faucet/docker-compose.yml @@ -0,0 +1,49 @@ +version: "3.8" + +services: + reef-faucet: + image: anukulpandey/reef-chain-node:latest + restart: unless-stopped + working_dir: /workspace + + expose: + - "${PORT}" + + environment: + SERVICE_NAME: reef-faucet + WS_ENDPOINT: ${WS_ENDPOINT} + EVM_RPC_URL: ${EVM_RPC_URL} + FAUCET_SEED: ${FAUCET_SEED} + PORT: ${PORT} + DEFAULT_AMOUNT: ${DEFAULT_AMOUNT} + MAX_AMOUNT: ${MAX_AMOUNT} + + volumes: + - ./output:/output + + entrypoint: /bin/sh + command: -c " + set -e && + + echo '📦 Installing dependencies...' && + apt-get update && + apt-get install -y wget curl ca-certificates gnupg && + mkdir -p /etc/apt/keyrings && + curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg && + echo 'deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_20.x nodistro main' > /etc/apt/sources.list.d/nodesource.list && + apt-get update && + apt-get install -y nodejs && + + echo '📥 Downloading faucet sources...' && + mkdir -p /workspace/faucet && + wget -O /workspace/faucet/package.json \ + https://raw.githubusercontent.com/anukulpandey/dokploy-templates/d8a82420dad013a3eb7e5d8afc0b9f2700a0045a/blueprints/reef-faucet/faucet/package.json && + wget -O /workspace/faucet/server.js \ + https://raw.githubusercontent.com/anukulpandey/dokploy-templates/d8a82420dad013a3eb7e5d8afc0b9f2700a0045a/blueprints/reef-faucet/faucet/server.js && + + echo '📦 Installing faucet npm dependencies...' && + cd /workspace/faucet && + npm install --omit=dev && + + echo '🚰 Starting Reef faucet on port $PORT' && + exec npm start" diff --git a/blueprints/reef-faucet/faucet/package.json b/blueprints/reef-faucet/faucet/package.json new file mode 100644 index 000000000..b2da90457 --- /dev/null +++ b/blueprints/reef-faucet/faucet/package.json @@ -0,0 +1,13 @@ +{ + "name": "reef-faucet", + "version": "1.0.0", + "private": true, + "description": "HTTP faucet for Reef deployments", + "main": "server.js", + "scripts": { + "start": "node server.js" + }, + "dependencies": { + "@polkadot/api": "^16.4.8" + } +} diff --git a/blueprints/reef-faucet/faucet/server.js b/blueprints/reef-faucet/faucet/server.js new file mode 100644 index 000000000..379ffc0da --- /dev/null +++ b/blueprints/reef-faucet/faucet/server.js @@ -0,0 +1,696 @@ +#!/usr/bin/env node + +const http = require("node:http"); +const { ApiPromise, Keyring, WsProvider } = require("@polkadot/api"); + +const config = { + wsEndpoint: process.env.WS_ENDPOINT || "ws://127.0.0.1:9944", + evmRpcUrl: process.env.EVM_RPC_URL || "", + faucetSeed: process.env.FAUCET_SEED || "", + port: Number.parseInt(process.env.PORT || "8080", 10), + defaultAmount: String(process.env.DEFAULT_AMOUNT || "2000"), + maxAmount: String(process.env.MAX_AMOUNT || "2000"), + serviceName: process.env.SERVICE_NAME || "reef-faucet", +}; + +if (!config.faucetSeed) { + throw new Error("FAUCET_SEED is required"); +} + +let contextPromise = null; +let requestQueue = Promise.resolve(); + +function normalizeEvmAddress(value) { + const normalized = String(value || "").trim(); + if (!/^0x[0-9a-fA-F]{40}$/.test(normalized)) { + throw new Error(`Invalid EVM address: ${value}`); + } + return normalized; +} + +function parseUnits(value, decimals) { + const input = String(value).trim(); + if (!/^\d+(\.\d+)?$/.test(input)) { + throw new Error(`Invalid decimal amount: ${value}`); + } + + const [whole, fraction = ""] = input.split("."); + if (fraction.length > decimals) { + throw new Error(`Too many decimal places in ${value}`); + } + + const wholeUnits = BigInt(whole) * 10n ** BigInt(decimals); + const fractionUnits = fraction ? BigInt(fraction.padEnd(decimals, "0")) : 0n; + return wholeUnits + fractionUnits; +} + +function formatUnits(value, decimals) { + const negative = value < 0n; + const abs = negative ? -value : value; + const base = 10n ** BigInt(decimals); + const whole = abs / base; + const fraction = abs % base; + + if (fraction === 0n) { + return `${negative ? "-" : ""}${whole.toString()}`; + } + + return `${negative ? "-" : ""}${whole.toString()}.${fraction + .toString() + .padStart(decimals, "0") + .replace(/0+$/, "")}`; +} + +function fallbackAccountFromEvm(evmAddress) { + return `0x${evmAddress.slice(2).toLowerCase()}${"ee".repeat(12)}`; +} + +function decodeDispatchError(api, dispatchError) { + if (dispatchError && dispatchError.isModule) { + const decoded = api.registry.findMetaError(dispatchError.asModule); + return `${decoded.section}.${decoded.name}: ${decoded.docs.join(" ")}`; + } + + return dispatchError && dispatchError.toString ? dispatchError.toString() : String(dispatchError); +} + +async function signAndWait(api, tx, signer) { + return new Promise(async (resolve, reject) => { + let unsubscribe = null; + + try { + unsubscribe = await tx.signAndSend(signer, ({ dispatchError, events, status, txHash }) => { + if (dispatchError) { + if (unsubscribe) { + unsubscribe(); + } + reject(new Error(decodeDispatchError(api, dispatchError))); + return; + } + + if (status.isFinalized) { + if (unsubscribe) { + unsubscribe(); + } + resolve({ + txHash: txHash.toHex(), + finalizedBlock: status.asFinalized.toHex(), + eventCount: events.length, + }); + } + }); + } catch (error) { + if (unsubscribe) { + unsubscribe(); + } + reject(error); + } + }); +} + +async function maybeClaimDefaultAccount(api, signer) { + const storage = api.query && api.query.evmAccounts && api.query.evmAccounts.evmAddresses; + const extrinsic = api.tx && api.tx.evmAccounts && api.tx.evmAccounts.claimDefaultAccount; + + if (!storage || !extrinsic) { + return null; + } + + const current = await storage(signer.address); + if (!current.isEmpty) { + return current.toString(); + } + + await signAndWait(api, extrinsic(), signer); + return (await storage(signer.address)).toString(); +} + +function buildReviveTransfer(api, signerAddress, targetEvm, amountUnits) { + const reviveTransfer = api.tx && api.tx.revive && api.tx.revive.transfer; + if (!reviveTransfer) { + return null; + } + + const metaArgs = reviveTransfer.meta.toJSON().args || []; + const names = metaArgs.map((arg) => String((arg && arg.name) || "").toLowerCase()); + const types = metaArgs.map((arg) => JSON.stringify((arg && arg.type) || "").toLowerCase()); + const haystack = `${names.join(",")}|${types.join(",")}`; + + if (metaArgs.length === 2 && haystack.includes("h160")) { + return { + strategy: "revive.transfer(H160, Balance)", + tx: reviveTransfer(targetEvm, amountUnits), + }; + } + + if (metaArgs.length === 3 && haystack.includes("accountid") && haystack.includes("h160")) { + return { + strategy: "revive.transfer(AccountId, H160, Balance)", + tx: reviveTransfer(signerAddress, targetEvm, amountUnits), + }; + } + + return { + strategy: "revive.transfer(fallback call signature)", + tx: reviveTransfer(targetEvm, amountUnits), + }; +} + +async function getContext() { + if (!contextPromise) { + contextPromise = (async () => { + const provider = new WsProvider(config.wsEndpoint); + const api = await ApiPromise.create({ provider }); + const keyring = new Keyring({ type: "sr25519" }); + const sender = keyring.addFromUri(config.faucetSeed); + const nativeDecimals = api.registry.chainDecimals[0] || 12; + const tokenSymbol = api.registry.chainTokens[0] || "REEF"; + + return { api, nativeDecimals, provider, sender, tokenSymbol }; + })(); + } + + return contextPromise; +} + +function jsonResponse(response, statusCode, body) { + response.writeHead(statusCode, { "Content-Type": "application/json" }); + response.end(JSON.stringify(body)); +} + +function htmlResponse(response, statusCode, body) { + response.writeHead(statusCode, { "Content-Type": "text/html; charset=utf-8" }); + response.end(body); +} + +function escapeHtml(value) { + return String(value) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function wantsHtml(request) { + const accept = String(request.headers.accept || ""); + return accept.includes("text/html"); +} + +function buildInfoPayload() { + return { + service: config.serviceName, + status: "ok", + defaults: { + defaultAmount: config.defaultAmount, + maxAmount: config.maxAmount, + }, + upstream: { + wsEndpoint: config.wsEndpoint, + evmRpcUrl: config.evmRpcUrl || null, + }, + endpoints: { + health: { + method: "GET", + path: "/health", + }, + drip: { + method: "POST", + path: "/drip", + body: { + to: "0x0000000000000000000000000000000000000000", + amount: config.defaultAmount, + }, + }, + }, + }; +} + +function buildFrontendHtml() { + const bootPayload = JSON.stringify(buildInfoPayload()); + const title = escapeHtml(config.serviceName); + const defaultAmount = escapeHtml(config.defaultAmount); + const maxAmount = escapeHtml(config.maxAmount); + const wsEndpoint = escapeHtml(config.wsEndpoint); + const evmRpcUrl = escapeHtml(config.evmRpcUrl || "Not configured"); + + return ` + + + + + ${title} + + + +
+
+
+

Reef Faucet

+

${title}

+

Send test REEF to any EVM address without reaching for curl first.

+
+ +
+
+ Default amount + ${defaultAmount} REEF +
+
+ Max amount + ${maxAmount} REEF +
+
+ WS upstream + ${wsEndpoint} +
+
+ EVM upstream + ${evmRpcUrl} +
+
+ +
+ + + + +
+ + +
+
+ +
+
Ready.
+
+ + +
+
+ + + +`; +} + +async function parseRequestBody(request) { + const chunks = []; + for await (const chunk of request) { + chunks.push(chunk); + } + + if (chunks.length === 0) { + return {}; + } + + return JSON.parse(Buffer.concat(chunks).toString("utf8")); +} + +async function executeDrip(targetEvm, amountText) { + const { api, nativeDecimals, sender, tokenSymbol } = await getContext(); + const amountUnits = parseUnits(amountText, nativeDecimals); + const maxUnits = parseUnits(config.maxAmount, nativeDecimals); + + if (amountUnits <= 0n) { + throw new Error("Amount must be greater than zero"); + } + + if (amountUnits > maxUnits) { + throw new Error(`Amount exceeds MAX_AMOUNT (${config.maxAmount})`); + } + + await maybeClaimDefaultAccount(api, sender); + + const senderBefore = (await api.query.system.account(sender.address)).data.free.toBigInt(); + + let strategy = ""; + let result = null; + + try { + const revive = buildReviveTransfer(api, sender.address, targetEvm, amountUnits); + if (!revive) { + throw new Error("revive.transfer unavailable"); + } + strategy = revive.strategy; + result = await signAndWait(api, revive.tx, sender); + } catch (error) { + strategy = "balances.transferAllowDeath(AccountId32 fallback)"; + result = await signAndWait( + api, + api.tx.balances.transferAllowDeath(fallbackAccountFromEvm(targetEvm), amountUnits), + sender + ); + } + + const senderAfter = (await api.query.system.account(sender.address)).data.free.toBigInt(); + + return { + amount: amountText, + strategy, + tokenSymbol, + txHash: result.txHash, + finalizedBlock: result.finalizedBlock, + eventCount: result.eventCount, + sender: sender.address, + senderNativeBefore: formatUnits(senderBefore, nativeDecimals), + senderNativeAfter: formatUnits(senderAfter, nativeDecimals), + }; +} + +function enqueue(task) { + const current = requestQueue.then(task, task); + requestQueue = current.catch(() => undefined); + return current; +} + +const server = http.createServer(async (request, response) => { + try { + if (request.method === "GET" && request.url === "/favicon.ico") { + response.writeHead(204); + response.end(); + return; + } + + if (request.method === "GET" && request.url === "/") { + if (wantsHtml(request)) { + htmlResponse(response, 200, buildFrontendHtml()); + } else { + jsonResponse(response, 200, buildInfoPayload()); + } + return; + } + + if (request.method === "GET" && request.url === "/health") { + jsonResponse(response, 200, { status: "ok" }); + return; + } + + if (request.method === "POST" && request.url === "/drip") { + const payload = await parseRequestBody(request); + const targetEvm = normalizeEvmAddress(payload.to); + const amount = payload.amount ? String(payload.amount) : config.defaultAmount; + const result = await enqueue(() => executeDrip(targetEvm, amount)); + jsonResponse(response, 200, result); + return; + } + + jsonResponse(response, 404, { error: "Not found" }); + } catch (error) { + jsonResponse(response, 400, { error: error.message }); + } +}); + +server.listen(config.port, "0.0.0.0", () => { + console.log(`${config.serviceName} listening on ${config.port}`); +}); diff --git a/blueprints/reef-faucet/meta.json b/blueprints/reef-faucet/meta.json new file mode 100644 index 000000000..eeba0bc95 --- /dev/null +++ b/blueprints/reef-faucet/meta.json @@ -0,0 +1,18 @@ +{ + "id": "reef-faucet", + "name": "Reef Chain - Faucet", + "version": "1", + "description": "HTTP faucet for sending native Reef funds to EVM addresses on a custom Reef Chain cluster", + "links": { + "github": "https://github.com/anukulpandey/dokploy-reef-chain-scripts", + "website": "https://hub.docker.com/repository/docker/anukulpandey/reef-chain-node", + "docs": "https://docs.reef.io/" + }, + "logo": "reef.svg", + "tags": [ + "reef-chain", + "faucet", + "self-hosted", + "evm" + ] +} diff --git a/blueprints/reef-faucet/reef.svg b/blueprints/reef-faucet/reef.svg new file mode 100644 index 000000000..598d7930a --- /dev/null +++ b/blueprints/reef-faucet/reef.svg @@ -0,0 +1,68 @@ + + + + + + + + + + + diff --git a/blueprints/reef-faucet/template.toml b/blueprints/reef-faucet/template.toml new file mode 100644 index 000000000..1b27dbee8 --- /dev/null +++ b/blueprints/reef-faucet/template.toml @@ -0,0 +1,27 @@ +[variables] +PORT = "8080" +WS_ENDPOINT = "ws://ws.reef.host" +EVM_RPC_URL = "http://eth.reef.host" +FAUCET_SEED = "" +DEFAULT_AMOUNT = "2000" +MAX_AMOUNT = "2000" + +[[config.domains]] +serviceName = "reef-faucet" +port = 8080 +host = "${domain}" +path = "/" + +[[config.domains]] +serviceName = "reef-faucet" +port = 8080 +host = "faucet.${domain}" +path = "/" + +[config.env] +PORT = "8080" +WS_ENDPOINT = "ws://ws.reef.host" +EVM_RPC_URL = "http://eth.reef.host" +FAUCET_SEED = "" +DEFAULT_AMOUNT = "2000" +MAX_AMOUNT = "2000" diff --git a/blueprints/reef-keygen/docker-compose.yml b/blueprints/reef-keygen/docker-compose.yml new file mode 100644 index 000000000..b6d332ce2 --- /dev/null +++ b/blueprints/reef-keygen/docker-compose.yml @@ -0,0 +1,41 @@ +version: "3.8" + +services: + reef-keygen: + image: anukulpandey/reef-chain-node:latest + restart: unless-stopped + working_dir: /workspace + volumes: + - ./output:/output + expose: + - "${PORT}" + entrypoint: /bin/sh + command: -c " + set -e && + + echo '📦 Installing python3...' && + apt-get update && + apt-get install -y python3 && + + mkdir -p /output && + chmod -R 777 /output && + + echo '=====================================' && + echo '🔑 Generating Validator Keys' && + echo '=====================================' && + + reef-node key generate --scheme Sr25519 --output-type json > /output/validator1.json && + reef-node key generate-node-key --chain local > /output/v1_node_key.txt && + + reef-node key generate --scheme Sr25519 --output-type json > /output/validator2.json && + reef-node key generate-node-key --chain local > /output/v2_node_key.txt && + + reef-node key generate --scheme Sr25519 --output-type json > /output/validator3.json && + reef-node key generate-node-key --chain local > /output/v3_node_key.txt && + + echo '✅ Keys generated:' && + ls -lah /output && + + echo '🌐 Starting HTTP server on port ${PORT}' && + cd /output && + python3 -m http.server ${PORT} --bind 0.0.0.0" diff --git a/blueprints/reef-keygen/meta.json b/blueprints/reef-keygen/meta.json new file mode 100644 index 000000000..9f1d7a4bd --- /dev/null +++ b/blueprints/reef-keygen/meta.json @@ -0,0 +1,18 @@ +{ + "id": "reef-keygen", + "name": "Reef Chain - Keys Generator", + "version": "1", + "description": "Generates 3 Validators keys for a custom Reef Chain cluster", + "links": { + "github": "https://github.com/reef-chain/chain-upgrade", + "website": "https://hub.docker.com/repository/docker/anukulpandey/reef-chain-node", + "docs": "https://docs.reef.io/" + }, + "logo": "reef.svg", + "tags": [ + "reef-chain", + "keys generator", + "self-hosted", + "keys" + ] +} diff --git a/blueprints/reef-keygen/reef.svg b/blueprints/reef-keygen/reef.svg new file mode 100644 index 000000000..598d7930a --- /dev/null +++ b/blueprints/reef-keygen/reef.svg @@ -0,0 +1,68 @@ + + + + + + + + + + + diff --git a/blueprints/reef-keygen/template.toml b/blueprints/reef-keygen/template.toml new file mode 100644 index 000000000..b8cde3e76 --- /dev/null +++ b/blueprints/reef-keygen/template.toml @@ -0,0 +1,11 @@ +[variables] +PORT = "48765" + +[[config.domains]] +serviceName = "reef-keygen" +port = 48765 +host = "${domain}" +path = "/" + +[config.env] +PORT = "48765" \ No newline at end of file diff --git a/blueprints/reef-rpc/docker-compose.yml b/blueprints/reef-rpc/docker-compose.yml new file mode 100644 index 000000000..2ea58ecb3 --- /dev/null +++ b/blueprints/reef-rpc/docker-compose.yml @@ -0,0 +1,66 @@ +version: "3.8" + +services: + reef-rpc: + image: anukulpandey/reef-chain-node:latest + restart: unless-stopped + working_dir: /workspace + + expose: + - "${P2P_PORT}" + - "${RPC_PORT}" + + environment: + SPEC_URL: ${SPEC_URL} + BOOTNODE_NODE_KEY: ${BOOTNODE_NODE_KEY} + BOOTNODE_IP: ${BOOTNODE_IP} + P2P_PORT: ${P2P_PORT} + RPC_PORT: ${RPC_PORT} + + volumes: + - ./output:/output + + entrypoint: /bin/sh + command: + - -c + - | + set -e + + echo '📦 Installing dependencies...' + apt-get update + apt-get install -y wget ca-certificates + + RPC_BASE_PATH=/tmp/rpc-node + NODE_KEY_FILE=/tmp/rpc_node_key.txt + CHAIN_FILE=/tmp/local-chain-spec-raw.json + PEER_ID_FILE=/tmp/bootnode_peer_id.txt + + echo '📥 Downloading chain spec and bootnode peer ID...' + wget -q -O "$$CHAIN_FILE" "$$SPEC_URL" + wget -q -O "$$PEER_ID_FILE" "$$BOOTNODE_NODE_KEY" + + BOOTNODE_PEER_ID=$$(tr -d '\r\n' < "$$PEER_ID_FILE") + if [ -z "$$BOOTNODE_PEER_ID" ]; then + echo 'Bootnode peer ID file is empty' >&2 + exit 1 + fi + + BOOTNODE_MULTIADDR="/ip4/$$BOOTNODE_IP/tcp/30335/p2p/$$BOOTNODE_PEER_ID" + + mkdir -p "$$RPC_BASE_PATH" + chmod 700 "$$RPC_BASE_PATH" + + reef-node key generate-node-key --chain local > "$$NODE_KEY_FILE" + chmod 600 "$$NODE_KEY_FILE" + + echo '🚀 Starting RPC node...' + exec reef-node \ + --base-path "$$RPC_BASE_PATH" \ + --chain "$$CHAIN_FILE" \ + --port "$$P2P_PORT" \ + --node-key-file "$$NODE_KEY_FILE" \ + --bootnodes "$$BOOTNODE_MULTIADDR" \ + --experimental-rpc-endpoint "listen-addr=0.0.0.0:$$RPC_PORT,cors=all,methods=unsafe,max-connections=10000" \ + --pruning archive \ + --name rpc-node \ + --no-telemetry diff --git a/blueprints/reef-rpc/meta.json b/blueprints/reef-rpc/meta.json new file mode 100644 index 000000000..4d07db746 --- /dev/null +++ b/blueprints/reef-rpc/meta.json @@ -0,0 +1,18 @@ +{ + "id": "reef-rpc", + "name": "Reef Chain - RPC Node", + "version": "1", + "description": "Runs RPC which connects to bootnode for a custom Reef Chain cluster", + "links": { + "github": "https://github.com/reef-chain/chain-upgrade", + "website": "https://hub.docker.com/repository/docker/anukulpandey/reef-chain-node", + "docs": "https://docs.reef.io/" + }, + "logo": "reef.svg", + "tags": [ + "reef-chain", + "validator", + "self-hosted", + "reef-validator" + ] +} diff --git a/blueprints/reef-rpc/reef.svg b/blueprints/reef-rpc/reef.svg new file mode 100644 index 000000000..598d7930a --- /dev/null +++ b/blueprints/reef-rpc/reef.svg @@ -0,0 +1,68 @@ + + + + + + + + + + + diff --git a/blueprints/reef-rpc/template.toml b/blueprints/reef-rpc/template.toml new file mode 100644 index 000000000..c146b6a27 --- /dev/null +++ b/blueprints/reef-rpc/template.toml @@ -0,0 +1,29 @@ +[variables] +SPEC_URL = "http://spec.reef.host/local-chain-spec-raw.json" +BOOTNODE_NODE_KEY="http://spec.reef.host/bootnode_node_key.txt" + +BOOTNODE_IP="72.60.35.83" + +P2P_PORT="30339" +RPC_PORT="9994" + +[[config.domains]] +serviceName = "reef-rpc" +port = 9994 +host = "${domain}" +path = "/" + +[[config.domains]] +serviceName = "reef-rpc" +port = 9994 +host = "ws.${domain}" +path = "/" + +[config.env] +SPEC_URL = "http://spec.reef.host/local-chain-spec-raw.json" +BOOTNODE_NODE_KEY="http://spec.reef.host/bootnode_node_key.txt" + +BOOTNODE_IP="72.60.35.83" + +P2P_PORT="30339" +RPC_PORT="9994" diff --git a/blueprints/reef-spec-gen/docker-compose.yml b/blueprints/reef-spec-gen/docker-compose.yml new file mode 100644 index 000000000..8f7baa274 --- /dev/null +++ b/blueprints/reef-spec-gen/docker-compose.yml @@ -0,0 +1,38 @@ +version: "3.8" + +services: + reef-spec-gen: + image: anukulpandey/reef-chain-node:latest + restart: unless-stopped + working_dir: /workspace + + volumes: + - ./output:/output + + expose: + - "8000" + + entrypoint: /bin/sh + command: -c " + set -e && + apt-get update && + apt-get install -y python3 python3-pip wget ca-certificates && + + echo '📥 Downloading API + scripts...' && + mkdir -p /workspace/scripts && + + wget -O /workspace/scripts/update-spec.py \ + https://raw.githubusercontent.com/anukulpandey/dokploy-reef-chain-scripts/52c3e30d06bba10936aea2db9740eafd76b3ac40/spec-generator/scripts/update-spec.py && + + wget -O /workspace/scripts/update-spec.sh \ + https://raw.githubusercontent.com/anukulpandey/dokploy-reef-chain-scripts/52c3e30d06bba10936aea2db9740eafd76b3ac40/spec-generator/scripts/update-spec.sh && + + wget -O /workspace/api.py \ + https://raw.githubusercontent.com/anukulpandey/dokploy-reef-chain-scripts/52c3e30d06bba10936aea2db9740eafd76b3ac40/spec-generator/api/api.py && + + chmod +x /workspace/scripts/update-spec.sh && + + pip3 install flask==3.1.0 && + + echo '🚀 Starting Spec Generator API on port 8000...' && + python3 /workspace/api.py" diff --git a/blueprints/reef-spec-gen/meta.json b/blueprints/reef-spec-gen/meta.json new file mode 100644 index 000000000..d6eed9e09 --- /dev/null +++ b/blueprints/reef-spec-gen/meta.json @@ -0,0 +1,18 @@ +{ + "id": "reef-spec-gen", + "name": "Reef Chain - Custom Spec Generator", + "version": "1", + "description": "Generates spec file for a custom Reef Chain cluster", + "links": { + "github": "https://github.com/reef-chain/chain-upgrade", + "website": "https://hub.docker.com/repository/docker/anukulpandey/reef-chain-node", + "docs": "https://docs.reef.io/" + }, + "logo": "reef.svg", + "tags": [ + "reef-chain", + "rpc", + "self-hosted", + "evm" + ] +} diff --git a/blueprints/reef-spec-gen/reef.svg b/blueprints/reef-spec-gen/reef.svg new file mode 100644 index 000000000..598d7930a --- /dev/null +++ b/blueprints/reef-spec-gen/reef.svg @@ -0,0 +1,68 @@ + + + + + + + + + + + diff --git a/blueprints/reef-spec-gen/template.toml b/blueprints/reef-spec-gen/template.toml new file mode 100644 index 000000000..c0412908f --- /dev/null +++ b/blueprints/reef-spec-gen/template.toml @@ -0,0 +1,15 @@ +[variables] + +[[config.domains]] +serviceName = "reef-spec-gen" +port = 8000 +host = "${domain}" +path = "/" + +[[config.domains]] +serviceName = "reef-spec-gen" +port = 8000 +host = "spec.${domain}" +path = "/" + +[config.env] diff --git a/blueprints/reef-validator/docker-compose.yml b/blueprints/reef-validator/docker-compose.yml new file mode 100644 index 000000000..1caaa4784 --- /dev/null +++ b/blueprints/reef-validator/docker-compose.yml @@ -0,0 +1,41 @@ +version: "3.8" + +services: + reef-validator: + image: anukulpandey/reef-chain-node:latest + restart: unless-stopped + working_dir: /workspace + + expose: + - "${P2P_PORT}" + + environment: + SPEC_URL: ${SPEC_URL} + BOOTNODE_NODE_KEY: ${BOOTNODE_NODE_KEY} + BOOTNODE_IP: ${BOOTNODE_IP} + V1SEED: ${V1SEED} + V1ADDR: ${V1ADDR} + P2P_PORT: ${P2P_PORT} + VALIDATOR_NAME: ${VALIDATOR_NAME} + + volumes: + - ./output:/output + + entrypoint: /bin/sh + command: -c " + set -e && + + echo '📦 Installing dependencies...' && + apt-get update && + apt-get install -y wget ca-certificates && + + echo '📥 Downloading start-validator.sh...' && + mkdir -p /workspace/validator/scripts && + wget -O /workspace/validator/scripts/start-validator.sh \ + https://raw.githubusercontent.com/anukulpandey/dokploy-reef-chain-scripts/52c3e30d06bba10936aea2db9740eafd76b3ac40/validator/scripts/start-validator.sh && + + echo '🔧 Making script executable...' && + chmod +x /workspace/validator/scripts/start-validator.sh && + + echo '🚀 Starting validator $VALIDATOR_NAME' && + exec /workspace/validator/scripts/start-validator.sh" diff --git a/blueprints/reef-validator/meta.json b/blueprints/reef-validator/meta.json new file mode 100644 index 000000000..45f8d9a6e --- /dev/null +++ b/blueprints/reef-validator/meta.json @@ -0,0 +1,18 @@ +{ + "id": "reef-validator", + "name": "Reef Chain - Validator", + "version": "1", + "description": "Runs Validator which connects to bootnode for a custom Reef Chain cluster", + "links": { + "github": "https://github.com/reef-chain/chain-upgrade", + "website": "https://hub.docker.com/repository/docker/anukulpandey/reef-chain-node", + "docs": "https://docs.reef.io/" + }, + "logo": "reef.svg", + "tags": [ + "reef-chain", + "validator", + "self-hosted", + "reef-validator" + ] +} diff --git a/blueprints/reef-validator/reef.svg b/blueprints/reef-validator/reef.svg new file mode 100644 index 000000000..598d7930a --- /dev/null +++ b/blueprints/reef-validator/reef.svg @@ -0,0 +1,68 @@ + + + + + + + + + + + diff --git a/blueprints/reef-validator/template.toml b/blueprints/reef-validator/template.toml new file mode 100644 index 000000000..c5e06fcf9 --- /dev/null +++ b/blueprints/reef-validator/template.toml @@ -0,0 +1,25 @@ +[variables] +V1ADDR = "5GH14oJ4A3VDLoC6nXutsTwS4dMwebDipNdSugD5zFnnWKp5" +V1SEED = "" + +SPEC_URL = "http://spec.reef.host/local-chain-spec-raw.json" +BOOTNODE_NODE_KEY="http://spec.reef.host/bootnode_node_key.txt" + +BOOTNODE_IP="72.60.35.83" + +VALIDATOR_NAME="Validator-1" + +P2P_PORT="30333" + +[config.env] +V1ADDR = "5GH14oJ4A3VDLoC6nXutsTwS4dMwebDipNdSugD5zFnnWKp5" +V1SEED = "" + +SPEC_URL = "http://spec.reef.host/local-chain-spec-raw.json" +BOOTNODE_NODE_KEY="http://spec.reef.host/bootnode_node_key.txt" + +BOOTNODE_IP="72.60.35.83" + +VALIDATOR_NAME="Validator-1" + +P2P_PORT="30333"