2026-08-19 23:55:01 +02:00
#!/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
2026-08-20 00:09:08 +02:00
# Several endpoints per chain, from DIFFERENT operators, because a payment
# is confirmed only when min_confirmations of them independently agree that
2026-08-20 01:49:15 +02:00
# 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.
2026-08-19 23:55:01 +02:00
cat > " $WORK /chains.json " <<'JSON'
{ "chains" : [
2026-08-20 00:09:08 +02:00
{ "name" : "base" ,
"rpcs" : [ "https://mainnet.base.org" ,
"https://base-rpc.publicnode.com" ,
"https://1rpc.io/base" ] ,
"min_confirmations" : 2,
2026-08-19 23:55:01 +02:00
"contract" : "0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42" ,
"chain_id" : 8453, "note" : "lowest network fees" } ,
2026-08-20 00:09:08 +02:00
{ "name" : "ethereum" ,
"rpcs" : [ "https://ethereum-rpc.publicnode.com" ,
2026-08-20 01:49:15 +02:00
"https://mainnet.gateway.tenderly.co" ,
2026-08-20 00:09:08 +02:00
"https://1rpc.io/eth" ] ,
"min_confirmations" : 2,
2026-08-19 23:55:01 +02:00
"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 "
2026-08-20 00:09:08 +02:00
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."
2026-08-19 23:55:01 +02:00
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
2026-08-20 20:15:47 +02:00
[ -e " $ENVF " ] || { echo " apply: $ENVF does not exist — is the bank rail even configured? " >& 2; exit 1; }
2026-08-19 23:55:01 +02:00
# 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
2026-08-20 20:15:47 +02:00
# bare >> concatenated onto it, turning TRANSFER_IBAN=NL00 into
# TRANSFER_IBAN=NL00EURC_CHAINS=/etc/... : both rails broken, and the
2026-08-19 23:55:01 +02:00
# 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