All checks were successful
Deploy / build-deploy (push) Successful in 3m40s
312 lines
15 KiB
Shell
Executable file
312 lines
15 KiB
Shell
Executable file
#!/bin/sh
|
|
# Turn on the self-hosted EURC rail in production.
|
|
#
|
|
# tools/enable-eurc.sh POOL_FILE validate, install, restart, verify
|
|
# tools/enable-eurc.sh POOL_FILE --append top up an existing pool (append-only)
|
|
# --host NAME ssh destination (default: hetzner — root via ~/.ssh/config)
|
|
# --chains FILE use this chains JSON instead of the built-in mainnet pair
|
|
# (how you rehearse against Sepolia — see deploy/README.md)
|
|
# --yes skip the contract confirmation prompt
|
|
#
|
|
# POOL_FILE is the list your wallet generated at home: one receiving address
|
|
# per line, # comments allowed. COPY it from the wallet, never retype — the
|
|
# server cannot verify EIP-55 checksums (and neither can this script: that
|
|
# needs keccak-256, which nothing in a stock shell provides), so a mistyped
|
|
# but well-formed address would be accepted and published to real buyers.
|
|
#
|
|
# What this automates is deploy/README.md "The EURC rail": install the chains
|
|
# file and the address pool, add EURC_CHAINS= to payments.env (setting that
|
|
# variable IS selecting the rail), restart, and prove the journal now says
|
|
# crypto=eurc. If the restart refuses — the rail's loader treats a bad pool as
|
|
# a startup refusal, not a degraded mode — the env line is rolled back and the
|
|
# service restarted bank-only, so a botched enable never takes checkout down.
|
|
#
|
|
# The remote pool is APPEND-ONLY once live: <pool>.cursor is an index into it,
|
|
# so rewriting or reordering re-issues addresses already bound to old orders.
|
|
# That is why an existing pool is a refusal without --append, and why --append
|
|
# adds only addresses the pool does not already hold.
|
|
|
|
set -eu
|
|
|
|
UNIT=catcrafts-server
|
|
CIRCLE_URL="https://developers.circle.com/stablecoins/eurc-contract-addresses"
|
|
|
|
POOL_SRC=""
|
|
HOST=hetzner
|
|
CHAINS_SRC=""
|
|
APPEND=0
|
|
ASSUME_YES=0
|
|
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--host) HOST="${2:?--host needs a value}"; shift 2 ;;
|
|
--chains) CHAINS_SRC="${2:?--chains needs a value}"; shift 2 ;;
|
|
--append) APPEND=1; shift ;;
|
|
--yes) ASSUME_YES=1; shift ;;
|
|
-h|--help) sed -n '2,28p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
|
-*) echo "enable-eurc: unknown option $1 (try --help)" >&2; exit 1 ;;
|
|
*) [ -n "$POOL_SRC" ] && { echo "enable-eurc: one pool file only" >&2; exit 1; }
|
|
POOL_SRC="$1"; shift ;;
|
|
esac
|
|
done
|
|
|
|
[ -n "$POOL_SRC" ] || { echo "enable-eurc: usage: tools/enable-eurc.sh POOL_FILE [--host H] [--chains F] [--append] [--yes]" >&2; exit 1; }
|
|
[ -r "$POOL_SRC" ] || { echo "enable-eurc: cannot read pool file '$POOL_SRC'" >&2; exit 1; }
|
|
|
|
WORK="$(mktemp -d)"
|
|
trap 'rm -rf "$WORK"' EXIT INT TERM
|
|
|
|
# ── validate the pool locally, by the server's own rules ─────────────────
|
|
#
|
|
# Mirror of EurcRail::LoadPool: strip # comments and whitespace, lowercase,
|
|
# require 0x + 40 hex, refuse duplicates (case-insensitively — the server
|
|
# lowercases before comparing, so "0xAB.." and "0xab.." are the same reuse
|
|
# bug). Refusing here means the service is never restarted into a refusal.
|
|
awk '
|
|
{ sub(/#.*/, ""); gsub(/^[ \t]+|[ \t\r]+$/, ""); if ($0 == "") next
|
|
addr = tolower($0)
|
|
if (addr !~ /^0x[0-9a-f]{40}$/) { printf "enable-eurc: pool line %d is not an address\n", NR > "/dev/stderr"; bad = 1; exit 1 }
|
|
if (addr in seen) { printf "enable-eurc: pool line %d duplicates an earlier address\n", NR > "/dev/stderr"; bad = 1; exit 1 }
|
|
seen[addr] = 1; print addr }
|
|
END { if (!bad && length(seen) == 0) { print "enable-eurc: pool file holds no addresses" > "/dev/stderr"; exit 1 } }
|
|
' "$POOL_SRC" > "$WORK/pool.txt"
|
|
|
|
COUNT=$(wc -l < "$WORK/pool.txt")
|
|
# The rail warns at 25 addresses left; starting anywhere near that is starting
|
|
# on the reserve tank.
|
|
if [ "$COUNT" -lt 50 ] && [ "$APPEND" -eq 0 ]; then
|
|
echo "enable-eurc: WARNING: only $COUNT addresses — the low-water warning fires at 25 left. Consider generating more before going live." >&2
|
|
fi
|
|
|
|
# ── the chains file ──────────────────────────────────────────────────────
|
|
#
|
|
# Built-in default is mainnet Base + Ethereum, Base first because file order
|
|
# is display order and its note is the fee nudge the buyer sees. Contracts
|
|
# must match Circle's list and nowhere else — matching the CONTRACT, not the
|
|
# ticker, is what makes a fake "EURC" worthless here — hence the prompt.
|
|
if [ -n "$CHAINS_SRC" ]; then
|
|
[ -r "$CHAINS_SRC" ] || { echo "enable-eurc: cannot read chains file '$CHAINS_SRC'" >&2; exit 1; }
|
|
cp "$CHAINS_SRC" "$WORK/chains.json"
|
|
else
|
|
# Several endpoints per chain, from DIFFERENT operators, because a payment
|
|
# is confirmed only when min_confirmations of them independently agree that
|
|
# the money is there — no single node's word settles an order. THREE per
|
|
# chain, always: public RPCs differ by VANTAGE POINT as well as uptime —
|
|
# 1RPC answers residential IPs but 403s requests from Hetzner, which a
|
|
# two-endpoint quorum polled from the Hetzner box can never survive (the
|
|
# CI testnet gate hit exactly this: 1-of-2 forever, order never settles).
|
|
# Any two of three agreeing settle; the corroboration loop stops at
|
|
# quorum, so the third is only dialed when one of the first two fails.
|
|
# All were checked to answer eth_chainId AND a balanceOf eth_call at
|
|
# block_tag=finalized, which is what this rail actually asks of them
|
|
# (Cloudflare's endpoint refuses finalized eth_call, which is why it is
|
|
# not here) — but check them FROM THE BOX, not from home: that is the
|
|
# vantage point that matters.
|
|
cat > "$WORK/chains.json" <<'JSON'
|
|
{"chains": [
|
|
{"name": "base",
|
|
"rpcs": ["https://mainnet.base.org",
|
|
"https://base-rpc.publicnode.com",
|
|
"https://1rpc.io/base"],
|
|
"min_confirmations": 2,
|
|
"contract": "0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42",
|
|
"chain_id": 8453, "note": "lowest network fees"},
|
|
{"name": "ethereum",
|
|
"rpcs": ["https://ethereum-rpc.publicnode.com",
|
|
"https://mainnet.gateway.tenderly.co",
|
|
"https://1rpc.io/eth"],
|
|
"min_confirmations": 2,
|
|
"contract": "0x1aBaEA1f7C830bD89Acc67eC4af516284b1bC33c",
|
|
"chain_id": 1}
|
|
]}
|
|
JSON
|
|
fi
|
|
|
|
if command -v jq >/dev/null 2>&1; then
|
|
jq -e '.chains | length > 0' "$WORK/chains.json" >/dev/null \
|
|
|| { echo "enable-eurc: chains file is not valid chains JSON" >&2; exit 1; }
|
|
fi
|
|
|
|
if [ "$ASSUME_YES" -eq 0 ]; then
|
|
echo "About to install these chains ($COUNT addresses in the pool):"
|
|
sed 's/^/ /' "$WORK/chains.json"
|
|
echo "Each chain lists several independent endpoints; a payment settles only"
|
|
echo "when min_confirmations of them agree, so no single node can fake one."
|
|
echo "Verify every contract against Circle's list — the only source that counts:"
|
|
echo " $CIRCLE_URL"
|
|
printf 'Contracts verified? Type yes to continue: '
|
|
read -r answer
|
|
[ "$answer" = "yes" ] || { echo "enable-eurc: aborted — nothing was touched." >&2; exit 1; }
|
|
fi
|
|
|
|
# ── the remote apply script ──────────────────────────────────────────────
|
|
#
|
|
# Everything travels in ONE ssh connection (a tar of chains.json, pool.txt and
|
|
# this script, unpacked and run on the box) because the host firewalls ssh
|
|
# with `ufw limit 22/tcp`: a chatty multi-connection script trips the limiter
|
|
# and the failure looks like a network fault, not a firewall choice.
|
|
cat > "$WORK/apply.sh" <<'REMOTE'
|
|
#!/bin/sh
|
|
set -eu
|
|
UNIT=catcrafts-server
|
|
work="$(dirname "$0")"
|
|
|
|
# Derive paths from the unit itself rather than hardcoding: the unit is the
|
|
# authority on where the ledger and env file live.
|
|
ORDERS=$(systemctl cat "$UNIT" | sed -n 's/^[[:space:]]*--orders=\([^ \\]*\).*/\1/p' | head -1)
|
|
[ -n "$ORDERS" ] || ORDERS=/var/lib/catcrafts/orders.jsonl
|
|
ENVF=$(systemctl cat "$UNIT" | sed -n 's/^EnvironmentFile=-\{0,1\}\(.*\)/\1/p' | head -1)
|
|
[ -n "$ENVF" ] || ENVF=/etc/catcrafts/payments.env
|
|
[ -e "$ENVF" ] || { echo "apply: $ENVF does not exist — is the Mollie side even configured?" >&2; exit 1; }
|
|
|
|
# Respect an explicit EURC_POOL override if one is already configured;
|
|
# otherwise the server's default: the pool hangs off the orders path.
|
|
POOL=$(sed -n 's/^EURC_POOL=//p' "$ENVF" | head -1)
|
|
[ -n "$POOL" ] || POOL="$ORDERS.eurc-addresses"
|
|
CHAINS_DEST=/etc/catcrafts/eurc-chains.json
|
|
|
|
SVC_USER=$(systemctl cat "$UNIT" | sed -n 's/^User=//p' | head -1)
|
|
[ -n "$SVC_USER" ] || SVC_USER=catcrafts
|
|
|
|
if [ -e "$POOL" ] && [ "${APPEND:-0}" != 1 ]; then
|
|
echo "apply: $POOL already exists. The pool is append-only (the cursor is an index into it) — rerun with --append to top it up. Refusing to overwrite." >&2
|
|
exit 1
|
|
fi
|
|
|
|
if [ -e "$POOL" ]; then
|
|
# Append only genuinely new addresses: a duplicate in the pool is a
|
|
# startup refusal, so filtering here is what keeps --append rerunnable.
|
|
added=0
|
|
while IFS= read -r addr; do
|
|
if ! grep -qixF "$addr" "$POOL"; then
|
|
printf '%s\n' "$addr" >> "$POOL"
|
|
added=$((added + 1))
|
|
fi
|
|
done < "$work/pool.txt"
|
|
echo "apply: appended $added new address(es) to $POOL"
|
|
else
|
|
install -o "$SVC_USER" -g "$SVC_USER" -m 0600 "$work/pool.txt" "$POOL"
|
|
echo "apply: installed $(wc -l < "$POOL") addresses at $POOL"
|
|
fi
|
|
|
|
# World-readable is fine — chain names and Circle's public contracts are not
|
|
# secrets, and the service user must be able to read it.
|
|
install -m 0644 "$work/chains.json" "$CHAINS_DEST"
|
|
|
|
# Append the rail selection, newline-safely, and remember whether WE are the
|
|
# ones who added it.
|
|
#
|
|
# Two bugs lived in the one-liner this replaces. First, payments.env is
|
|
# hand-maintained, so its last line may have no trailing newline — and then a
|
|
# bare >> concatenated onto it, turning MOLLIE_API_KEY=live_abc into
|
|
# MOLLIE_API_KEY=live_abcEURC_CHAINS=/etc/... : both rails broken, and the
|
|
# rollback below could not even see it because the line no longer started with
|
|
# EURC_CHAINS. Second, the rollback deleted EVERY EURC_CHAINS= line, including
|
|
# one the operator had set themselves pointing at a different chains file — so
|
|
# a timeout during an --append top-up of an already-live rail switched crypto
|
|
# off on a host where it had been working.
|
|
ADDED_ENV_LINE=0
|
|
if grep -q '^EURC_CHAINS=' "$ENVF"; then
|
|
echo "apply: EURC_CHAINS is already set in $ENVF — leaving it as it is"
|
|
else
|
|
# A file that does not end in a newline gets one before the append.
|
|
if [ -s "$ENVF" ] && [ "$(tail -c1 "$ENVF" | od -An -c | tr -d ' \n')" != '\\n' ]; then
|
|
printf '\n' >> "$ENVF"
|
|
fi
|
|
printf 'EURC_CHAINS=%s\n' "$CHAINS_DEST" >> "$ENVF"
|
|
ADDED_ENV_LINE=1
|
|
fi
|
|
|
|
# Not a bare command: under `set -e` a non-zero restart would abort the script
|
|
# here and the rollback below would never run, leaving EURC_CHAINS set and the
|
|
# service down. Type=simple returns 0 even when the process dies immediately,
|
|
# so today this is defensive — but the unit type is not this script's to
|
|
# guarantee.
|
|
systemctl restart "$UNIT" || echo "apply: systemctl restart reported failure" >&2
|
|
|
|
# Success is evidence from THIS invocation, and never the stdout listening
|
|
# line: under journald stdout is fully buffered, so that line arrives minutes
|
|
# to hours late or dies unflushed with the process — polling for it rolled
|
|
# back two perfectly healthy enables. What is prompt and truthful:
|
|
# - the rail loader's stderr line ("eurc: N chains, ...") — printed only
|
|
# when EURC_CHAINS selected the rail AND the pool + chains loaded, and
|
|
# - /api/healthz answering — the server is actually serving.
|
|
# Newer binaries print the listening line to stderr too; accept it as a
|
|
# third, sufficient signal when it shows up.
|
|
INV=$(systemctl show -p InvocationID --value "$UNIT")
|
|
PORT=$(systemctl cat "$UNIT" | sed -n 's/^ExecStart=.*--serve \([0-9]*\).*/\1/p' | head -1)
|
|
[ -n "$PORT" ] || PORT=8081
|
|
echo "apply: waiting for the rail to prove itself (up to 60s)..."
|
|
eurc_line=""
|
|
healthy=0
|
|
tries=0
|
|
while [ "$tries" -lt 60 ]; do
|
|
[ "$(systemctl is-active "$UNIT" || true)" = failed ] && break
|
|
inv_log=$(journalctl "_SYSTEMD_INVOCATION_ID=$INV" --no-pager 2>/dev/null || true)
|
|
if printf '%s' "$inv_log" | grep -q 'crypto=eurc'; then
|
|
eurc_line=$(printf '%s' "$inv_log" | grep 'crypto=eurc' | tail -1)
|
|
healthy=1
|
|
break
|
|
fi
|
|
eurc_line=$(printf '%s' "$inv_log" | grep -E 'eurc: [0-9]+ chains' | tail -1 || true)
|
|
if [ -n "$eurc_line" ] && curl -sf --max-time 2 "http://127.0.0.1:$PORT/api/healthz" >/dev/null 2>&1; then
|
|
healthy=1
|
|
break
|
|
fi
|
|
sleep 1
|
|
tries=$((tries + 1))
|
|
done
|
|
if systemctl is-active --quiet "$UNIT" && [ "$healthy" -eq 1 ]; then
|
|
printf '%s\n' "$eurc_line"
|
|
echo "apply: healthz answers on :$PORT"
|
|
echo "apply: EURC rail is LIVE"
|
|
exit 0
|
|
fi
|
|
|
|
# The enable failed — put the shop back the way it was before saying so. The
|
|
# pool and chains files stay (harmless without the env line); only the rail
|
|
# selection is rolled back, so bank checkout is never collateral damage.
|
|
echo "apply: service did not come up with crypto=eurc — rolling back" >&2
|
|
if [ "$ADDED_ENV_LINE" = 1 ]; then
|
|
# Only the exact line this run appended, and only if we appended it.
|
|
sed -i "\\|^EURC_CHAINS=$CHAINS_DEST\$|d" "$ENVF"
|
|
else
|
|
echo "apply: EURC_CHAINS was already configured before this run — leaving it" >&2
|
|
echo "apply: alone. Crypto stays as the operator had it; only the pool and" >&2
|
|
echo "apply: chains files this run installed remain." >&2
|
|
fi
|
|
systemctl restart "$UNIT" || true
|
|
echo "apply: rolled back. The refusal:" >&2
|
|
journalctl -u "$UNIT" --since "-60 seconds" --no-pager | tail -15 >&2
|
|
exit 1
|
|
REMOTE
|
|
|
|
tar -cf "$WORK/payload.tar" -C "$WORK" chains.json pool.txt apply.sh
|
|
|
|
echo "enable-eurc: applying on $HOST (one ssh connection)..."
|
|
if ! ssh "$HOST" "work=\$(mktemp -d) && trap 'rm -rf \"\$work\"' EXIT && tar xf - -C \"\$work\" && APPEND=$APPEND sh \"\$work/apply.sh\"" < "$WORK/payload.tar"; then
|
|
# apply.sh narrates its own rollback when the restart refused; a failure
|
|
# before that (ssh, tar, an apply refusal) means nothing was ever touched.
|
|
# Claiming either state from here would be a guess, so point at the output.
|
|
echo "enable-eurc: FAILED — see above for how far it got. apply.sh rolls back the rail selection itself if the restart refused." >&2
|
|
exit 1
|
|
fi
|
|
|
|
# Public proof, informative only: the donation form should now offer the
|
|
# payment choice (it renders no radios when only one rail exists).
|
|
if [ "$HOST" = hetzner ]; then
|
|
if curl -s --max-time 10 https://catcrafts.net/shop/donation | grep -q 'name="pay"'; then
|
|
echo "enable-eurc: catcrafts.net/shop/donation now offers the payment choice."
|
|
else
|
|
echo "enable-eurc: WARNING: the live donation page does not show the pay choice yet — check by hand." >&2
|
|
fi
|
|
fi
|
|
|
|
cat <<'DONE'
|
|
enable-eurc: done. Next:
|
|
1. Smoke test with real money, smallest denomination: a 1 euro donation
|
|
paid in EURC on Base exercises the whole path for ~a cent of fees.
|
|
2. Add the pool (+.cursor) to whatever backs up orders.jsonl.
|
|
3. Decide the sweep cadence BEFORE the first real donation arrives —
|
|
the shop holds EURC until you sell it for euros at an exchange.
|
|
DONE
|