From d915a5e56958a1dc7dcda78ad4ac768d432c0279 Mon Sep 17 00:00:00 2001 From: Vijit Singh Date: Mon, 3 Aug 2026 23:08:10 -0500 Subject: [PATCH] feat: verify the Tari payout address checksum host-side, both forms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Monero half of this gate shipped earlier: a well-shaped but checksum-invalid address is rejected before anything launches. The Tari payout address still had no equivalent — one mistyped character sailed through, and the first honest verdict came from the Tari node at merge-mine time, or never visibly at all. tari_address_type mirrors tari's own from_bytes: Bitcoin base58 (network and features bytes each encoded alone, then the rest) or the 256-emoji alphabet, then length (35 single / 67..323 dual with payment id), the 1-byte DammSum checksum, the mainnet network byte, and the feature bits. The algorithm, layouts, alphabet, and every test vector come from the tari repository's own source and test suite; the alphabet was extracted programmatically, never transcribed. A checksum-valid address for a Tari testnet gets its own verdict and message. No usable python3 degrades to "unchecked" — accepted, never a false reject. One gate, all surfaces: parse_and_validate_config (setup/apply, the wizard spool, the control runner all route through it), an inline retry in the interactive wizard, and a doctor verdict. Suite fixtures that used placeholder Tari addresses now use the reference-blessed vector. Verified additionally against the bench appliance's live payout-proven mainnet address (accepted; one flipped character fails as checksum) — the address itself stays out of the repo. Closes #845 Co-Authored-By: Claude Fable 5 --- docs/appliance.md | 10 +- docs/configuration.md | 2 +- pithead | 146 +++++++++++++++-- tests/stack/run.sh | 357 +++++++++++++++++++++++------------------- 4 files changed, 344 insertions(+), 171 deletions(-) diff --git a/docs/appliance.md b/docs/appliance.md index 0eaa1629..98bfc3d1 100644 --- a/docs/appliance.md +++ b/docs/appliance.md @@ -318,10 +318,12 @@ key-only, and only if you need it. **"Wrong token."** The token changes each time the setup service restarts — read the current one from the console. After five wrong attempts it mints a new one on purpose. -**The address was rejected.** You most likely pasted a subaddress (starts with `8`) or an -integrated address. Use your primary address, which starts with `4` and is 95 characters. -If the message says the checksum failed, at least one character of the address is wrong — -re-copy it from your wallet rather than fixing it by eye. +**The address was rejected.** For Monero you most likely pasted a subaddress (starts with +`8`) or an integrated address. Use your primary address, which starts with `4` and is 95 +characters. If the message says the checksum failed — for either the Monero or the Tari +address — at least one character is wrong: re-copy it from your wallet rather than fixing +it by eye. A Tari address rejected as the wrong network came from a testnet wallet; the +stack mines mainnet. **It came back on the old version after an update.** That is the safety mechanism working: the new version did not come up healthy, so the machine reverted. Nothing is lost. Check diff --git a/docs/configuration.md b/docs/configuration.md index d35b21c9..55675038 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -110,7 +110,7 @@ control channel will commit, are unaffected either way. | `monero.data_dir` | `auto` | Where the Monero blockchain lives on the host. `auto` = `./data/monero`. Point this at an existing `.bitmonero` directory to reuse a synced node. See [Reusing an existing node](#reusing-an-existing-node). | | `monero.mem_limit` | `auto` | Upper limit on the monerod container's memory, so a leak/runaway OOM-restarts monerod alone instead of the host's OOM-killer picking a victim. `auto` is a generous ceiling (6 GB) that won't trip during normal operation or initial sync. monerod's OOM-triggering memory is small (~0.1 GiB at rest, ~1–3 GiB during sync) while its multi-GB blockchain DB is reclaimable, memory-mapped page cache that the kernel evicts under pressure rather than OOM-killing. Lower it only to free RAM. Raise it for a full (unpruned) node doing a heavy initial sync on a fast disk, or if a low-RAM host ever OOMs monerod during IBD (it restarts and resumes; the on-disk chain is transactional, no data loss). Accepts any Docker memory value, e.g. `"8g"`. (Tari has its own `tari.mem_limit`; the dashboard, P2Pool, Tor, and the proxies are small and carry fixed conservative ceilings in `docker-compose.yml`.) | | `tari.mode` | `local` | `local` runs the bundled Tari base node; `remote` merge-mines against an external one (see `tari.remote` and [Remote Tari node](#remote-tari-node)). | -| `tari.wallet_address` | _required_ | Your Tari (Minotari) payout address. | +| `tari.wallet_address` | _required_ | Your Tari (Minotari) payout address, in either the base58 or the emoji form (single or dual, embedded payment id supported). Both forms carry a built-in checksum, and it is verified — a single mistyped character fails it, which otherwise means merge-mine rewards silently lost. A checksum-valid address for a Tari testnet is rejected too: the stack mines mainnet. `setup`/`apply` reject both cases. | | `tari.view_key` | _empty_ | The private **view** key for the Tari payout address, to confirm merge-mine payouts on-chain (#462, the Tari sibling of `monero.view_key`). When set, the stack runs a view-only `minotari_console_wallet` against your local Tari node and the dashboard shows confirmed Tari payouts beside the time-to-block estimate (see [Dashboard › Payout confirmation](dashboard.md#payout-confirmation)). Requires `tari.spend_public_key` too. **Security:** a view key can scan but never spend — yet it reveals every incoming amount and its timing to anyone who can read `config.json`/`.env`, so it is handled like `node_password` (never logged or echoed, kept in the owner-only `.env`, and delivered to the container via a tmpfs secret, never `docker inspect`). Local Tari node only. Empty (the default) leaves the feature off. | | `tari.spend_public_key` | _empty_ | The **public** spend key for the Tari payout address, exported alongside the view key (`minotari_console_wallet ... export-view-key-and-spend-key`; see [Dashboard › Exporting your keys](dashboard.md#exporting-your-keys)). Required whenever `tari.view_key` is set — a view-only Tari wallet is built from the private view key plus this public spend key. Public, not a secret. | | `tari.payout_scan_birthday` | `auto` | Where the view-only Tari wallet starts scanning on first creation (#462). Unlike Monero's block-height restore point, a Tari birthday is **days since the Unix epoch** (a u16, 0–65535). `auto` = today when the wallet is first made, so it tracks payouts forward without rescanning from genesis. Set an earlier day to backfill older payouts (slower first scan). Only affects the first wallet creation; ignored once the wallet exists. | diff --git a/pithead b/pithead index 1552c5d1..83a06f0a 100755 --- a/pithead +++ b/pithead @@ -1015,6 +1015,18 @@ doctor() { *) dr_warn "Monero payout address doesn't look like a primary address (expected 95 chars starting with 4)." ;; esac fi + # Same class of check for the Tari payout address: both forms carry a DammSum checksum. + local _tw + _tw=$(jq -r '.tari.wallet_address // empty' "$CONFIG_FILE" 2>/dev/null) + if [ -n "$_tw" ] && [ "$_tw" != "your_tari_wallet_address" ]; then + case "$(tari_address_type "$_tw")" in + ok) dr_ok "Tari payout address passes its checksum." ;; + unchecked) dr_info "Could not verify the Tari payout address checksum (no usable python3)." ;; + checksum) dr_fail "Tari payout address FAILS its checksum — at least one character is mistyped, and Tari rewards are silently lost. Re-copy the address from your Tari wallet into tari.wallet_address and run './pithead apply'." ;; + network) dr_fail "Tari payout address is for a different Tari network — this stack mines MAINNET Tari. Set tari.wallet_address to your mainnet address and run './pithead apply'." ;; + *) dr_warn "Tari payout address doesn't look like a valid Tari address (base58 or emoji form)." ;; + esac + fi fi # --- Memory (Linux only) --- @@ -4023,7 +4035,16 @@ wizard_ask_core() { *) echo " ✗ Not a valid Monero primary address (95 chars, starts with 4). Try again." >&2 ;; esac done - read -r -p "Enter Tari Wallet Address: " IN_TARI_WALLET || true + while :; do + read -r -p "Enter Tari Wallet Address (base58 or emoji form): " IN_TARI_WALLET || break + [ -z "$IN_TARI_WALLET" ] && break # the shared required-fields check below owns this + case "$(tari_address_type "$IN_TARI_WALLET")" in + ok | unchecked) break ;; + checksum) echo " ✗ Checksum failed — at least one character is mistyped. Re-copy the address from your Tari wallet." >&2 ;; + network) echo " ✗ That address is for a Tari testnet — this stack mines MAINNET Tari. Use your mainnet address." >&2 ;; + *) echo " ✗ Not a valid Tari address (base58 or emoji form). Try again." >&2 ;; + esac + done if [ -z "$IN_MONERO_WALLET" ] || [ -z "$IN_TARI_WALLET" ]; then error "Wallet addresses are required. Aborting." @@ -4325,6 +4346,105 @@ print(kind[0]) PYEOF } +# Tari payout-address verdict: ok | checksum | network | invalid | unchecked. Tari addresses come +# in base58 AND emoji forms, single or dual, with an optional embedded payment id (RFC-0155) — +# but every form carries a 1-byte DammSum checksum, so a mistyped character is detectable here +# instead of at the Tari node at merge-mine time, or never visibly at all (#845). The decode and +# the check order (length, checksum, network byte, feature bits) mirror tari's own from_bytes. +# +# python3 does the math, same as the Monero gate above. Without it the address is "unchecked" — +# accepted, the pre-gate behaviour, degraded, never a false reject. +tari_address_type() { + command -v python3 >/dev/null 2>&1 || { + echo "unchecked" + return + } + python3 - "$1" <<'PYEOF' 2>/dev/null || echo "unchecked" +import os +import sys + +# Bitcoin base58 (Tari uses the bs58 crate) — NOT Monero's block-wise scheme. In Tari's base58 +# form the network byte and the features byte are each encoded ALONE (one character each), then +# the remaining bytes as one base58 string. +_B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" + +# The 256-emoji alphabet from tari's emoji.rs, index = byte value. Every entry is a single +# codepoint, and tari's own parser matches codepoints exactly — so exact .find() is faithful. +_EMOJI = ( + "🐢📟🌈🌊🎯🐋🌙🤔🌕⭐🎋🌰🌴🌵🌲🌸🌹🌻🌽🍀🍁🍄🥑🍆🍇🍈🍉🍊🍋🍌🍍🍎" + "🍐🍑🍒🍓🍔🍕🍗🍚🍞🍟🥝🍣🍦🍩🍪🍫🍬🍭🍯🥐🍳🥄🍵🍶🍷🍸🍾🍺🍼🎀🎁🎂" + "🎃🤖🎈🎉🎒🎓🎠🎡🎢🎣🎤🎥🎧🎨🎩🎪🎬🎭🎮🎰🎱🎲🎳🎵🎷🎸🎹🎺🎻🎼🎽🎾" + "🎿🏀🏁🏆🏈⚽🏠🏥🏦🏭🏰🐀🐉🐊🐌🐍🦁🐐🐑🐔🙈🐗🐘🐙🐚🐛🐜🐝🐞🦋🐣🐨" + "🦀🐪🐬🐭🐮🐯🐰🦆🦂🐴🐵🐶🐷🐸🐺🐻🐼🐽🐾👀👅👑👒🧢💅👕👖👗👘👙💃👛" + "👞👟👠🥊👢👣🤡👻👽👾🤠👃💄💈💉💊💋👂💍💎💐💔🔒🧩💡💣💤💦💨💩➕💯" + "💰💳💵💺💻💼📈📜📌📎📖📿📡⏰📱📷🔋🔌🚰🔑🔔🔥🔦🔧🔨🔩🔪🔫🔬🔭🔮🔱" + "🗽😂😇😈🤑😍😎😱😷🤢👍👶🚀🚁🚂🚚🚑🚒🚓🛵🚗🚜🚢🚦🚧🚨🚪🚫🚲🚽🚿🧲" +) + + +def b58_decode(s): + n = 0 + for ch in s: + d = _B58.find(ch) + if d < 0: + return None + n = n * 58 + d + body = n.to_bytes((n.bit_length() + 7) // 8, "big") if n else b"" + return b"\x00" * (len(s) - len(s.lstrip("1"))) + body + + +def dammsum(data): + # DammSum over base 256 with coefficients [4,3,1] (mask 27); a valid array sums to 0. + r = 0 + for d in data: + r ^= d + overflow = r & 0x80 + r = (r << 1) & 0xFF + if overflow: + r ^= 27 + return r + + +try: + # Re-decode argv as strict UTF-8: under a C locale the interpreter may have decoded the + # emoji bytes with surrogateescape, which would silently miss the alphabet. + s = os.fsencode(sys.argv[1]).decode("utf-8") +except UnicodeDecodeError: + print("invalid") + sys.exit(0) + +if all(ord(c) < 128 for c in s): + if len(s) < 45: # tari's own minimum encoded length for the base58 form + print("invalid") + sys.exit(0) + parts = [b58_decode(s[0]), b58_decode(s[1]), b58_decode(s[2:])] + raw = None if any(p is None for p in parts) else b"".join(parts) +else: + raw = bytearray() + for c in s: + i = _EMOJI.find(c) + if i < 0: + raw = None + break + raw.append(i) + +# A single address is exactly 35 bytes; a dual one 67, plus up to 256 payment-id bytes. +if raw is None or not (len(raw) == 35 or 67 <= len(raw) <= 67 + 256): + print("invalid") +elif dammsum(raw) != 0: + print("checksum") +elif raw[0] != 0x00: + # 0x00 is mainnet — the only network this stack mines. The other assigned network bytes + # mean a real address for the wrong network, which deserves its own message; anything + # else is no Tari address at all. + print("network" if raw[0] in (0x01, 0x02, 0x10, 0x24, 0x26) else "invalid") +elif raw[1] & ~0b111: # unknown feature bits (known: one-sided 1, interactive 2, payment-id 4) + print("invalid") +else: + print("ok") +PYEOF +} + # When the dashboard onion is enabled but no password is set, generate a strong one and save it to # config.json (#343). Keeps the fail-closed onion usable without forcing the operator to invent a # 16+ char secret; the plaintext lives in owner-only config.json, exactly like a hand-set password @@ -4693,14 +4813,12 @@ parse_and_validate_config() { if [ -z "$MONERO_WALLET" ] || [ -z "$TARI_WALLET" ]; then error "Missing required wallet addresses in $CONFIG_FILE." fi - # tari.wallet_address gets no exact-format gate like monero's below: Tari addresses come in - # base58 AND emoji forms, single/dual, with optional payment IDs — length and charset both vary - # (RFC-0155), and p2pool passes the string straight to the Tari node, which accepts either form, - # so a strict regex would false-reject valid addresses. The untouched placeholder is caught by the - # combined check just below; also reject embedded whitespace here — no Tari address of either form - # has an ASCII space, and a space isn't a control char so the central guard above misses it, yet it - # would silently mine to a wrong address (the #250 failure mode). A subtler typo is the node's to - # reject at merge-mine time — the DammSum checksum gate that would catch it here is #845. + # tari.wallet_address gets no shape regex: Tari addresses come in base58 AND emoji forms, + # single/dual, with optional payment IDs — length and charset both vary (RFC-0155), so a + # regex would false-reject valid addresses. Instead the real gate is tari_address_type below + # (full decode + DammSum checksum, #845). Whitespace still gets its own message here — it's + # the likeliest paste error, a space isn't a control char so the central guard above misses + # it, and "invalid" alone wouldn't say where to look. case "$TARI_WALLET" in *[[:space:]]*) error "tari.wallet_address contains whitespace — a Tari address (base58 or emoji) has none. Check for a stray space or line break in $CONFIG_FILE." ;; esac @@ -4718,6 +4836,16 @@ parse_and_validate_config() { *) error "monero.wallet_address ('${MONERO_WALLET:0:6}…', ${#MONERO_WALLET} chars) is not a valid Monero primary address (expected 95 chars starting with 4)." ;; esac + # The Tari sibling of the gate above (#845): full decode + DammSum verdict, both address + # forms. "unchecked" (no usable python3) passes — degraded to the pre-gate behaviour, + # never a false reject. + case "$(tari_address_type "$TARI_WALLET")" in + ok | unchecked) ;; + checksum) error "tari.wallet_address fails its checksum — at least one character is mistyped, and a mistyped address means Tari rewards are silently lost. Re-copy the address (base58 or emoji form) from your Tari wallet and try again." ;; + network) error "tari.wallet_address is for a different Tari network (a testnet). This stack mines MAINNET Tari — use your mainnet address." ;; + *) error "tari.wallet_address (${#TARI_WALLET} chars) is not a valid Tari address in either the base58 or the emoji form. Copy it from your Tari wallet." ;; + esac + MONERO_MODE=$(jq -r '.monero.mode // "local"' "$CONFIG_FILE") case "$MONERO_MODE" in local | remote) ;; diff --git a/tests/stack/run.sh b/tests/stack/run.sh index 6635b321..5dc07703 100755 --- a/tests/stack/run.sh +++ b/tests/stack/run.sh @@ -1406,6 +1406,35 @@ printf '#!/usr/bin/env bash\nexit 127\n' >"$NOPY/python3" chmod +x "$NOPY/python3" assert_eq "monero_address_type: python3 unusable => shape-only primary" "$(PATH="$NOPY:$PATH" run_sourced "$SANDBOX" monero_address_type "4$_h94")" "primary" +echo "== unit: tari_address_type — DammSum over both address forms (#845) ==" +# The Tari sibling of the gate above. Both Tari forms (base58 and emoji) carry a 1-byte DammSum +# checksum; the decode and check order mirror tari's own from_bytes. The checksum-VALID fixture +# is the dual mainnet address hardcoded in tari's OWN test suite (test_serialize_deserialize_ +# dual_address: one-sided, known view/spend keys) — reference-blessed, never ours. The emoji +# fixture is that same address's byte-for-byte emoji form; the single-address fixture reuses the +# reference spend key with a recomputed checksum (no project publishes a single-form address). +# The invalid emoji strings are ALSO tari's own test vectors (invalid_emoji / invalid_checksum). +VALID_TARI="126J92Yow5y9UoRFd1DNujPmVFq9C1ZeiYWT95UKxz5Y1rzbfjtHg4SCZS1dk83ivzt3m2XRQHTaYUk9SwmyeCvy5BJ" +VALID_TARI_EMOJI="🐢📟🍼🌈🍓🚓➕🎸🍆🍷🎣🍗📿😂🥊⏰🍯👾🤔👒🍾👀🍼🌊🎷📟😈🚨👙🍈🌈🛵🤢🍔🔋👙🚽🤑🎽🎓🎓🐀🐜🐴🥄🚿📷💰👶👍🎉🍄🎢🔌🐋🚰🚑💅👢🦂🐬🐋🍗🍸🎹🏀🍄" +VALID_TARI_SINGLE="1224yPceFvbksLKQ8JE6APDzVY2D6P3SpXwB5LLC3BH4F7oF" +assert_eq "tari_address_type: reference dual base58 => ok" "$(run_sourced "$SANDBOX" tari_address_type "$VALID_TARI")" "ok" +assert_eq "tari_address_type: same address, emoji form => ok" "$(run_sourced "$SANDBOX" tari_address_type "$VALID_TARI_EMOJI")" "ok" +assert_eq "tari_address_type: single form => ok" "$(run_sourced "$SANDBOX" tari_address_type "$VALID_TARI_SINGLE")" "ok" +# One flipped character in each form must fail as "checksum", not pass — the whole point. +assert_eq "tari_address_type: one flipped base58 char => checksum" "$(run_sourced "$SANDBOX" tari_address_type "${VALID_TARI:0:90}B")" "checksum" +_TARI_66="🍗🌊🦂🍎🐛🔱🍟🚦🦆👃🐛🎼🛵🔮💋👙💦🍷👠🦀🐺🍪🚀🎮🎩👅🐔🐉🍍🥑💔📌🚧🐊💄🎥🎓🚗🎳🐛🚿💉🌴🧢🐵🎩👾👽🎃🤡👍🔮👒👽🎵👀🚨😷🎒👂👶🍄🏰🚑🌸🍁" +assert_eq "tari_address_type: tari's invalid-checksum emoji vector => checksum" "$(run_sourced "$SANDBOX" tari_address_type "${_TARI_66}🎒")" "checksum" +assert_eq "tari_address_type: tari's invalid-emoji vector => invalid" "$(run_sourced "$SANDBOX" tari_address_type "${_TARI_66}🎅")" "invalid" +assert_eq "tari_address_type: 66-emoji (too short for dual) => invalid" "$(run_sourced "$SANDBOX" tari_address_type "$_TARI_66")" "invalid" +# A checksum-valid address for the wrong network (esmeralda byte, checksum recomputed over the +# reference keys) is a REAL address someone pasted from a testnet wallet — its own verdict. +assert_eq "tari_address_type: esmeralda address => network" "$(run_sourced "$SANDBOX" tari_address_type "f26J92Yow5y9UoRFd1DNujPmVFq9C1ZeiYWT95UKxz5Y1rzbfjtHg4SCZS1dk83ivzt3m2XRQHTaYUk9SwmyeCvy5Cb")" "network" +# Unknown feature bits (0x09), checksum recomputed — decodes cleanly but is no address. +assert_eq "tari_address_type: unknown feature bits => invalid" "$(run_sourced "$SANDBOX" tari_address_type "1A6J92Yow5y9UoRFd1DNujPmVFq9C1ZeiYWT95UKxz5Y1rzbfjtHg4SCZS1dk83ivzt3m2XRQHTaYUk9SwmyeCvy5Dr")" "invalid" +assert_eq "tari_address_type: old placeholder => invalid" "$(run_sourced "$SANDBOX" tari_address_type "T")" "invalid" +# No usable python3: the address is "unchecked" — accepted, degraded, never a false reject. +assert_eq "tari_address_type: python3 unusable => unchecked" "$(PATH="$NOPY:$PATH" run_sourced "$SANDBOX" tari_address_type "$VALID_TARI")" "unchecked" + echo "== unit: dashboard auth (#8) ==" # Dashboard login (#8): enabling/changing is DEST (caddy is recreated), disabling is INFO. The bcrypt # hash is a secret and must never surface in the change preview; the internal fingerprint stays silent. @@ -2946,7 +2975,7 @@ EOF } WALLET="$VALID_PRIMARY" # checksum-valid mainnet primary (the XMRig donation address) — #250 gates the type, #829 the checksum seed_env -printf '{ "monero": {"mode":"local","wallet_address":"%s","node_username":"u","node_password":"p"}, "tari":{"wallet_address":"T"}, "p2pool":{"pool":"banana"}, "dashboard":{"secure":true,"host":"box.lan"} }\n' "$WALLET" >"$V/config.json" +printf '{ "monero": {"mode":"local","wallet_address":"%s","node_username":"u","node_password":"p"}, "tari":{"wallet_address":"'"$VALID_TARI"'"}, "p2pool":{"pool":"banana"}, "dashboard":{"secure":true,"host":"box.lan"} }\n' "$WALLET" >"$V/config.json" out="$(cd "$V" && PATH="$V/bin:$PATH" ./pithead apply -y 2>&1)" rc=$? assert_rc "invalid pool rejected" "$rc" "1" @@ -2954,7 +2983,7 @@ assert_contains "invalid pool message" "$out" "p2pool.pool" # A non-IP stratum_bind must be rejected before it reaches the compose port mapping. seed_env -printf '{ "monero": {"mode":"local","wallet_address":"%s","node_username":"u","node_password":"p"}, "tari":{"wallet_address":"T"}, "p2pool":{"pool":"main","stratum_bind":"not-an-ip"}, "dashboard":{"secure":true,"host":"box.lan"} }\n' "$WALLET" >"$V/config.json" +printf '{ "monero": {"mode":"local","wallet_address":"%s","node_username":"u","node_password":"p"}, "tari":{"wallet_address":"'"$VALID_TARI"'"}, "p2pool":{"pool":"main","stratum_bind":"not-an-ip"}, "dashboard":{"secure":true,"host":"box.lan"} }\n' "$WALLET" >"$V/config.json" out="$(cd "$V" && PATH="$V/bin:$PATH" ./pithead apply -y 2>&1)" rc=$? assert_rc "invalid stratum_bind rejected" "$rc" "1" @@ -2962,7 +2991,7 @@ assert_contains "invalid stratum_bind message" "$out" "p2pool.stratum_bind" # A dashboard.host with Caddyfile-breaking characters (space/braces) must be rejected before render. seed_env -printf '{ "monero": {"mode":"local","wallet_address":"%s","node_username":"u","node_password":"p"}, "tari":{"wallet_address":"T"}, "p2pool":{"pool":"main"}, "dashboard":{"secure":true,"host":"bad host{x}"} }\n' "$WALLET" >"$V/config.json" +printf '{ "monero": {"mode":"local","wallet_address":"%s","node_username":"u","node_password":"p"}, "tari":{"wallet_address":"'"$VALID_TARI"'"}, "p2pool":{"pool":"main"}, "dashboard":{"secure":true,"host":"bad host{x}"} }\n' "$WALLET" >"$V/config.json" out="$(cd "$V" && PATH="$V/bin:$PATH" ./pithead apply -y 2>&1)" rc=$? assert_rc "invalid dashboard.host rejected" "$rc" "1" @@ -2970,21 +2999,21 @@ assert_contains "invalid dashboard.host message" "$out" "dashboard.host" # proxy.donate_level must be an integer 0-99 (default 0); an out-of-range value is rejected (#173). seed_env -printf '{ "monero": {"mode":"local","wallet_address":"%s","node_username":"u","node_password":"p"}, "tari":{"wallet_address":"T"}, "p2pool":{"pool":"main"}, "proxy":{"donate_level":150}, "dashboard":{"secure":true,"host":"box.lan"} }\n' "$WALLET" >"$V/config.json" +printf '{ "monero": {"mode":"local","wallet_address":"%s","node_username":"u","node_password":"p"}, "tari":{"wallet_address":"'"$VALID_TARI"'"}, "p2pool":{"pool":"main"}, "proxy":{"donate_level":150}, "dashboard":{"secure":true,"host":"box.lan"} }\n' "$WALLET" >"$V/config.json" out="$(cd "$V" && PATH="$V/bin:$PATH" ./pithead apply -y 2>&1)" rc=$? assert_rc "out-of-range donate_level rejected" "$rc" "1" assert_contains "donate_level message" "$out" "proxy.donate_level" # Non-numeric donate_level is rejected (the "auto" sentinel was removed — the value is a plain integer). seed_env -printf '{ "monero": {"mode":"local","wallet_address":"%s","node_username":"u","node_password":"p"}, "tari":{"wallet_address":"T"}, "p2pool":{"pool":"main"}, "proxy":{"donate_level":"auto"}, "dashboard":{"secure":true,"host":"box.lan"} }\n' "$WALLET" >"$V/config.json" +printf '{ "monero": {"mode":"local","wallet_address":"%s","node_username":"u","node_password":"p"}, "tari":{"wallet_address":"'"$VALID_TARI"'"}, "p2pool":{"pool":"main"}, "proxy":{"donate_level":"auto"}, "dashboard":{"secure":true,"host":"box.lan"} }\n' "$WALLET" >"$V/config.json" out="$(cd "$V" && PATH="$V/bin:$PATH" ./pithead apply -y 2>&1)" rc=$? assert_rc "non-numeric donate_level rejected" "$rc" "1" # A stratum_password with a shell/.env-unsafe character (a space) is rejected before render (#152). seed_env -printf '{ "monero": {"mode":"local","wallet_address":"%s","node_username":"u","node_password":"p"}, "tari":{"wallet_address":"T"}, "p2pool":{"pool":"main","stratum_password":"bad pass"}, "dashboard":{"secure":true,"host":"box.lan"} }\n' "$WALLET" >"$V/config.json" +printf '{ "monero": {"mode":"local","wallet_address":"%s","node_username":"u","node_password":"p"}, "tari":{"wallet_address":"'"$VALID_TARI"'"}, "p2pool":{"pool":"main","stratum_password":"bad pass"}, "dashboard":{"secure":true,"host":"box.lan"} }\n' "$WALLET" >"$V/config.json" out="$(cd "$V" && PATH="$V/bin:$PATH" ./pithead apply -y 2>&1)" rc=$? assert_rc "unsafe stratum_password rejected" "$rc" "1" @@ -2994,7 +3023,7 @@ assert_contains "stratum_password message" "$out" "p2pool.stratum_password" # before they can render an unparseable compose port mapping. for bad_port in '"abc"' 0 65536; do seed_env - printf '{ "monero": {"mode":"local","wallet_address":"%s","node_username":"u","node_password":"p"}, "tari":{"wallet_address":"T"}, "p2pool":{"pool":"main","stratum_port":%s}, "dashboard":{"secure":true,"host":"box.lan"} }\n' "$WALLET" "$bad_port" >"$V/config.json" + printf '{ "monero": {"mode":"local","wallet_address":"%s","node_username":"u","node_password":"p"}, "tari":{"wallet_address":"'"$VALID_TARI"'"}, "p2pool":{"pool":"main","stratum_port":%s}, "dashboard":{"secure":true,"host":"box.lan"} }\n' "$WALLET" "$bad_port" >"$V/config.json" out="$(cd "$V" && PATH="$V/bin:$PATH" ./pithead apply -y 2>&1)" rc=$? assert_rc "invalid stratum_port $bad_port rejected" "$rc" "1" @@ -3005,7 +3034,7 @@ done # be silently dropped at dashboard runtime. host charset is the #122 guard (no port/path/userinfo). dw_case() { #