From ef08d359df0caa6191dec58cb0af9f394d4813e6 Mon Sep 17 00:00:00 2001 From: Rushikesh Garad Date: Sat, 12 Sep 2026 13:13:18 +0530 Subject: [PATCH 1/2] docs(runbook): add fee account trustline verification and alerting runbook (closes #141) - Add `./scripts/admin/verify-fee-trustline.sh` to pre-flight verify fee account trustlines and authorization status across registered tokens (mitigating DoS.15). - Update `docs/multisig-preflight.md` with fee account pre-flight trustline verification checklist before `register_supported_token` and `set_fee_account`. - Update `docs/contract-ops-runbook.md` with operational trustline validation procedures and event alerting guidelines on `FeeAccountUpdated` and `TokenRegistered`. - Link trustline check into `scripts/admin/verify-multisig.sh` next-step guidance. --- docs/contract-ops-runbook.md | 25 ++++++ docs/multisig-preflight.md | 14 ++++ scripts/admin/verify-fee-trustline.sh | 112 ++++++++++++++++++++++++++ scripts/admin/verify-multisig.sh | 3 +- 4 files changed, 153 insertions(+), 1 deletion(-) create mode 100755 scripts/admin/verify-fee-trustline.sh diff --git a/docs/contract-ops-runbook.md b/docs/contract-ops-runbook.md index 6bd52d4..e83fa7d 100644 --- a/docs/contract-ops-runbook.md +++ b/docs/contract-ops-runbook.md @@ -250,3 +250,28 @@ Everything in Section 4–5 is identical on mainnet except: - The cold recovery key actually lives in a safe; the daily signers are you + co-founder. - After rotation, **destroy the initial deploy key** (`shred -u`); it has no power post-rotation but leave nothing lying around. - The enumerable token index is complete **from genesis** — register USDC at deploy time and state enumeration is authoritative forever (no import-by-address needed, unlike the in-place-upgraded testnet contract). + + +--- + +## 5. Fee account trustline verification & event alerting (DoS.15) + +**Context:** +As identified in threat model finding **DoS.15**, `set_fee_account` performs no pre-flight verification, and `deposit_with_fee_at` / `release_with_fee_at` transfer protocol fees directly to `fee_account`. If the fee account lacks an active, authorized trustline for a supported token, user transactions (`create_event`, `add_funds`, and crowdfunding `claim_milestone`) will revert. + +### Pre-flight Verification Runbook +Before executing `register_supported_token` or `set_fee_account`: + +1. Run the trustline verification script against the fee account: + ```bash + ./scripts/admin/verify-fee-trustline.sh + ``` +2. Verify all checks pass: + - Account exists on target network. + - Trustline exists in `.balances[]` for each registered/target token. + - `is_authorized` is `true`. + +### Event Alerting & Monitoring +- Configure monitoring alerts on the indexer / event listener for: + - `FeeAccountUpdated`: Triggers automated check to verify the new fee account holds trustlines for all active supported tokens. + - `TokenRegistered`: Triggers verification that the current fee account holds a trustline for the newly registered token. diff --git a/docs/multisig-preflight.md b/docs/multisig-preflight.md index 3372b7b..aec0fbf 100644 --- a/docs/multisig-preflight.md +++ b/docs/multisig-preflight.md @@ -89,6 +89,20 @@ Before touching mainnet: The same procedure is required quarterly per policy §5.3; this just exercises it under realistic conditions before mainnet. + +--- + +## 4.B Fee account & token trustline pre-flight (DoS.15 mitigation) + +Before calling `register_supported_token` or rotating the fee account with `set_fee_account`: + +- [ ] Confirm the candidate `fee_account` G-address is funded and active on the target network. +- [ ] For every token already supported or proposed for registration, verify that `fee_account` holds an active, authorized trustline: + ```bash + ./scripts/admin/verify-fee-trustline.sh + ``` +- [ ] Verify that no un-trustlined or unauthorized token is registered, preventing contract fee collection reverts in `deposit_with_fee_at` / `release_with_fee_at`. + ## 5. Rotate admin authority Only after every box above is checked: diff --git a/scripts/admin/verify-fee-trustline.sh b/scripts/admin/verify-fee-trustline.sh new file mode 100755 index 0000000..25a658f --- /dev/null +++ b/scripts/admin/verify-fee-trustline.sh @@ -0,0 +1,112 @@ +#!/bin/bash +# +# verify-fee-trustline.sh: verify that the fee account holds active, authorized +# trustlines for all supported/candidate tokens before `register_supported_token` +# or `set_fee_account` is executed. +# +# Threat Model Reference: DoS.15 (Process control for unverified fee trustlines). +# Without a valid trustline, fee collection in `deposit_with_fee_at` / +# `release_with_fee_at` will revert, causing `create_event`, `add_funds`, and +# crowdfunding `claim_milestone` to fail. +# +# Usage: +# ./scripts/admin/verify-fee-trustline.sh [testnet|mainnet] [ASSET_CODE:ISSUER ...] +# +# Example: +# ./scripts/admin/verify-fee-trustline.sh GA... testnet USDC:GB... + +set -euo pipefail + +if ! command -v curl &>/dev/null; then + echo "Error: curl is required." >&2 + exit 1 +fi +if ! command -v jq &>/dev/null; then + echo "Error: jq is required (e.g., brew install jq or apt install jq)." >&2 + exit 1 +fi + +FEE_ACCOUNT=${1:-""} +NETWORK=${2:-"testnet"} +shift 2 || true +ASSETS=("$@") + +if [[ -z "$FEE_ACCOUNT" ]]; then + echo "Usage: $0 [testnet|mainnet] [ASSET_CODE:ISSUER ...]" >&2 + exit 1 +fi + +case "$NETWORK" in + testnet) HORIZON="https://horizon-testnet.stellar.org" ;; + mainnet) HORIZON="https://horizon.stellar.org" ;; + *) echo "Unknown network '$NETWORK'." >&2; exit 1 ;; +esac + +GREEN='\033[0;32m'; RED='\033[0;31m'; YELLOW='\033[1;33m'; NC='\033[0m' + +echo -e "${YELLOW}Verifying fee account ${FEE_ACCOUNT} on ${NETWORK}...${NC}" + +ACCOUNT_JSON=$(curl -sf "$HORIZON/accounts/$FEE_ACCOUNT" || true) +if [[ -z "$ACCOUNT_JSON" ]]; then + echo -e "${RED}Error: Fee account ${FEE_ACCOUNT} not found on ${NETWORK}.${NC}" >&2 + exit 1 +fi + +PASS=0 +FAIL=0 + +echo -e " ${GREEN}✓${NC} Account exists on ${NETWORK}" +PASS=$((PASS + 1)) + +# Inspect active balances on the fee account +BALANCES=$(echo "$ACCOUNT_JSON" | jq -r '.balances') + +if [[ ${#ASSETS[@]} -eq 0 ]]; then + echo -e "\n${YELLOW}Discovered trustlines/balances on fee account:${NC}" + echo "$BALANCES" | jq -r '.[] | " - " + (if .asset_type == "native" then "XLM (native)" else (.asset_code + ":" + .asset_issuer) end) + " (balance: " + .balance + ", authorized: " + (.is_authorized // true | tostring) + ")"' +else + echo -e "\n${YELLOW}Asserting required trustlines:${NC}" + for ASSET in "${ASSETS[@]}"; do + if [[ "$ASSET" == "XLM" || "$ASSET" == "native" ]]; then + HAS_NATIVE=$(echo "$BALANCES" | jq -r '[.[] | select(.asset_type == "native")] | length') + if [[ "$HAS_NATIVE" -gt 0 ]]; then + echo -e " ${GREEN}✓${NC} XLM (native balance available)" + PASS=$((PASS + 1)) + else + echo -e " ${RED}✗${NC} Missing native XLM balance on fee account" + FAIL=$((FAIL + 1)) + fi + continue + fi + + CODE="${ASSET%%:*}" + ISSUER="${ASSET##*:}" + + # Match either exact code+issuer or asset code + MATCH=$(echo "$BALANCES" | jq -r --arg code "$CODE" --arg issuer "$ISSUER" \ + '[.[] | select(.asset_code == $code and (.asset_issuer == $issuer or $code == $issuer))] | .[0]') + + if [[ -n "$MATCH" && "$MATCH" != "null" ]]; then + IS_AUTH=$(echo "$MATCH" | jq -r '.is_authorized // true') + if [[ "$IS_AUTH" == "true" ]]; then + echo -e " ${GREEN}✓${NC} Trustline active & authorized: ${ASSET}" + PASS=$((PASS + 1)) + else + echo -e " ${RED}✗${NC} Trustline exists but is NOT authorized: ${ASSET}" + FAIL=$((FAIL + 1)) + fi + else + echo -e " ${RED}✗${NC} Missing trustline for: ${ASSET}" + FAIL=$((FAIL + 1)) + fi + done +fi + +echo +if [[ $FAIL -gt 0 ]]; then + echo -e "${RED}FAIL: $FAIL check(s) failed; $PASS passed.${NC}" >&2 + echo "Do NOT register this token or rotate fee account until trustlines are established and authorized." >&2 + exit 1 +fi + +echo -e "${GREEN}PASS: Fee account trustline verification successful.${NC}" diff --git a/scripts/admin/verify-multisig.sh b/scripts/admin/verify-multisig.sh index 4dbcab5..46fb8a3 100755 --- a/scripts/admin/verify-multisig.sh +++ b/scripts/admin/verify-multisig.sh @@ -105,4 +105,5 @@ echo echo "Next steps:" echo " 1. Compare the printed signer addresses against the founder's roster." echo " 2. Confirm each signer can sign a test transaction (drill)." -echo " 3. Only then run set_admin per docs/mainnet-deploy-runbook.md §2.7." +echo " 3. Verify fee account trustlines via ./scripts/admin/verify-fee-trustline.sh (DoS.15)." +echo " 4. Only then run set_admin per docs/mainnet-deploy-runbook.md §2.7." From 4d3d371f59bb369979a7ef67be6cbd0593187af6 Mon Sep 17 00:00:00 2001 From: Rushikesh Garad Date: Sat, 12 Sep 2026 20:43:54 +0530 Subject: [PATCH 2/2] fix(scripts): enforce exact issuer match and fail-closed is_authorized check - Enforce exact `.asset_code == $code and .asset_issuer == $issuer` matching in `verify-fee-trustline.sh`. - Default missing `.is_authorized` to `false` (fail-closed validation). - Addresses review feedback from @almanax-ai[bot]. --- scripts/admin/verify-fee-trustline.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/admin/verify-fee-trustline.sh b/scripts/admin/verify-fee-trustline.sh index 25a658f..8325281 100755 --- a/scripts/admin/verify-fee-trustline.sh +++ b/scripts/admin/verify-fee-trustline.sh @@ -84,10 +84,10 @@ else # Match either exact code+issuer or asset code MATCH=$(echo "$BALANCES" | jq -r --arg code "$CODE" --arg issuer "$ISSUER" \ - '[.[] | select(.asset_code == $code and (.asset_issuer == $issuer or $code == $issuer))] | .[0]') + '[.[] | select(.asset_code == $code and .asset_issuer == $issuer)] | .[0]') if [[ -n "$MATCH" && "$MATCH" != "null" ]]; then - IS_AUTH=$(echo "$MATCH" | jq -r '.is_authorized // true') + IS_AUTH=$(echo "$MATCH" | jq -r '.is_authorized // false') if [[ "$IS_AUTH" == "true" ]]; then echo -e " ${GREEN}✓${NC} Trustline active & authorized: ${ASSET}" PASS=$((PASS + 1))