From 7639541642cc618128b5c0a88373eed81ee31b93 Mon Sep 17 00:00:00 2001 From: Andrey Kobrin Date: Mon, 6 Jul 2026 16:27:06 -0400 Subject: [PATCH 1/7] fix devnet upgrade account handling Record governance helper account details in the validator registry during setup and make proposal/vote helpers resolve usable proposer and voter accounts from on-chain state. Update the remote upgrade flow for v1.20.1 binaries and keep the EVM migration status registry tolerant of migrated governance state. --- devnet/config/binaries.json | 2 +- devnet/scripts/submit-upgrade-proposal.sh | 228 ++++++++++++++++++-- devnet/scripts/upgrade.sh | 4 +- devnet/scripts/validator-setup.sh | 8 +- devnet/scripts/vote-all.sh | 122 +++++++++-- devnet/tests/evmigration/migrate.go | 2 + devnet/tests/evmigration/migrate_test.go | 53 ++++- devnet/tests/evmigration/status_registry.go | 4 + 8 files changed, 385 insertions(+), 38 deletions(-) diff --git a/devnet/config/binaries.json b/devnet/config/binaries.json index 5ec8e076..3a01690c 100644 --- a/devnet/config/binaries.json +++ b/devnet/config/binaries.json @@ -111,7 +111,7 @@ "v1.20.1": { "bin_dir": "bin-v1.20.1", "lumera": { - "tag": "v1.20.1-rc1", + "tag": "v1.20.1", "asset": "lumera_linux_amd64.tar.gz" }, "supernode": { diff --git a/devnet/scripts/submit-upgrade-proposal.sh b/devnet/scripts/submit-upgrade-proposal.sh index a2b08897..fb997477 100755 --- a/devnet/scripts/submit-upgrade-proposal.sh +++ b/devnet/scripts/submit-upgrade-proposal.sh @@ -22,6 +22,8 @@ DEFAULT_DEPOSIT="10000000$DENOM" HOST_PROPOSAL_FILE="${LUMERA_SHARED}/upgrade_${VERSION}.json" CONTAINER_PROPOSAL_FILE="/shared/upgrade_${VERSION}.json" COMPOSE_FILE="../docker-compose.yml" +STATUS_DIR="${LUMERA_SHARED}/status" +ACCOUNT_REGISTRY_FILE="${STATUS_DIR}/${SERVICE}/accounts.json" get_current_height() { docker compose -f "$COMPOSE_FILE" exec "$SERVICE" \ @@ -34,7 +36,7 @@ get_current_height() { get_proposal_id_by_version_with_retry() { local version="$1" local attempts="${2:-1}" # Default to 1 attempt if not provided - local height_filter="$3" + local height_filter="${3:-}" local sleep_interval=2 local proposal_id="" local proposals_json @@ -56,10 +58,10 @@ get_proposal_id_by_version_with_retry() { .messages[]? | select( ((.type // .["@type"]) == "/cosmos.upgrade.v1beta1.MsgSoftwareUpgrade") - and (.value.plan.name == $version) + and ((.value.plan.name // .plan.name) == $version) and ( ($height == "") - or ((.value.plan.height // "0") | tonumber) >= ($height | tonumber) + or (((.value.plan.height // .plan.height // "0") | tonumber) >= ($height | tonumber)) ) ) ] | length > 0 @@ -106,7 +108,7 @@ get_proposal_height_by_id() { local proposal_id="$1" docker compose -f "$COMPOSE_FILE" exec "$SERVICE" \ lumerad query gov proposal "$proposal_id" --output json 2>/dev/null | - jq -r '.proposal.messages[]?.value.plan.height // empty' + jq -r '.proposal.messages[]? | (.value.plan.height // .plan.height // empty)' } validate_height_param() { @@ -134,6 +136,201 @@ get_min_deposit_from_gov_params() { echo "$output" } +account_exists() { + local address="$1" + [[ -n "$address" ]] || return 1 + docker compose -f "$COMPOSE_FILE" exec "$SERVICE" \ + lumerad query auth account "$address" --output json >/dev/null 2>&1 +} + +key_address() { + local key_name="$1" + docker compose -f "$COMPOSE_FILE" exec "$SERVICE" \ + lumerad keys show "$key_name" -a --keyring-backend "$KEYRING" 2>/dev/null | + tr -d '\r\n' +} + +key_name_for_address() { + local address="$1" + [[ -n "$address" ]] || return 1 + docker compose -f "$COMPOSE_FILE" exec "$SERVICE" \ + lumerad keys list --keyring-backend "$KEYRING" --output json 2>/dev/null | + jq -r --arg address "$address" ' + (if type == "array" then . else (.keys // []) end) + | map(select(.address == $address)) + | first + | .name // empty + ' 2>/dev/null | head -n 1 +} + +registry_mnemonic() { + local key_name="$1" + if [[ -f "$ACCOUNT_REGISTRY_FILE" ]]; then + jq -r --arg name "$key_name" ' + (map(select(.name == $name)) | first | .mnemonic) // empty + ' "$ACCOUNT_REGISTRY_FILE" 2>/dev/null + fi +} + +governance_mnemonic_file() { + printf '%s\n' "${STATUS_DIR}/${SERVICE}/governance-address-mnemonic" +} + +migration_new_address() { + local legacy_address="$1" + [[ -n "$legacy_address" ]] || return 1 + docker compose -f "$COMPOSE_FILE" exec "$SERVICE" \ + lumerad query evmigration migration-record "$legacy_address" --output json 2>/dev/null | + jq -r '.record.new_address // .migration_record.new_address // .new_address // empty' 2>/dev/null +} + +update_status_registry_address() { + local key_name="$1" + local new_address="$2" + local registry tmp + + [[ -n "$key_name" && -n "$new_address" && -d "$STATUS_DIR" ]] || return 0 + while IFS= read -r registry; do + if [[ ! -w "$registry" || ! -w "$(dirname "$registry")" ]]; then + echo "⚠️ Cannot update ${registry}; file is not writable by this user." >&2 + continue + fi + if ! jq -e --arg name "$key_name" 'any(.[]?; .name == $name)' "$registry" >/dev/null 2>&1; then + continue + fi + tmp="$(mktemp "${registry}.tmp.XXXXXX")" || return 0 + if jq --arg name "$key_name" --arg address "$new_address" ' + map(if .name == $name then .address = $address else . end) + ' "$registry" >"$tmp"; then + chmod 644 "$tmp" + mv "$tmp" "$registry" + else + rm -f "$tmp" + fi + done < <(find "$STATUS_DIR" -mindepth 2 -maxdepth 2 -name accounts.json -type f 2>/dev/null) +} + +recover_evm_key_from_mnemonic() { + local key_name="$1" + local mnemonic="$2" + local expected_address="$3" + local recovered_address key_json + + recovered_address="$(key_address "$key_name")" + if [[ -n "$recovered_address" ]]; then + if [[ "$recovered_address" != "$expected_address" ]]; then + echo "❌ Key ${key_name} exists but resolves to ${recovered_address}; expected migrated address ${expected_address}" >&2 + return 1 + fi + return 0 + fi + + key_json="$(docker compose -f "$COMPOSE_FILE" exec -T -e MNEMONIC="$mnemonic" "$SERVICE" \ + sh -c 'printf "%s\n" "$MNEMONIC" | lumerad keys add "$1" --recover --coin-type 60 --algo eth_secp256k1 --keyring-backend "$2" --output json' \ + _ "$key_name" "$KEYRING" 2>&1)" + recovered_address="$(printf '%s' "$key_json" | jq -r '.address // empty' 2>/dev/null || true)" + if [[ -z "$recovered_address" ]]; then + echo "❌ Failed to recover migrated key ${key_name} from mnemonic:" >&2 + echo "$key_json" >&2 + return 1 + fi + if [[ "$recovered_address" != "$expected_address" ]]; then + echo "❌ Recovered key ${key_name} resolved to ${recovered_address}; expected migrated address ${expected_address}" >&2 + return 1 + fi +} + +try_migrated_proposer() { + local legacy_key_name="$1" + local legacy_address="$2" + local migrated_address migrated_key_name mnemonic mnemonic_file + + migrated_address="$(migration_new_address "$legacy_address")" + if [[ -z "$migrated_address" || "$migrated_address" == "null" ]]; then + return 1 + fi + + migrated_key_name="$(key_name_for_address "$migrated_address")" + if [[ -z "$migrated_key_name" ]]; then + mnemonic="$(registry_mnemonic "$legacy_key_name")" + mnemonic_file="$(governance_mnemonic_file)" + if [[ -z "$mnemonic" && "$legacy_key_name" == "governance_key" && -s "$mnemonic_file" ]]; then + mnemonic="$(cat "$mnemonic_file")" + fi + if [[ -z "$mnemonic" ]]; then + echo "⚠️ ${legacy_key_name} migrated to ${migrated_address}, but no mnemonic was found in validator 1 registry." >&2 + return 1 + fi + migrated_key_name="${legacy_key_name}_evm" + recover_evm_key_from_mnemonic "$migrated_key_name" "$mnemonic" "$migrated_address" || return 1 + fi + + if ! account_exists "$migrated_address"; then + echo "❌ Migrated proposer ${migrated_key_name} (${migrated_address}) is not present on-chain" >&2 + return 1 + fi + + update_status_registry_address "$legacy_key_name" "$migrated_address" + if [[ "$legacy_key_name" == "governance_key" ]]; then + if [[ -w "$GOV_ADDRESS_FILE" || ( ! -e "$GOV_ADDRESS_FILE" && -w "$(dirname "$GOV_ADDRESS_FILE")" ) ]]; then + printf '%s\n' "$migrated_address" >"$GOV_ADDRESS_FILE" + else + echo "⚠️ Cannot update ${GOV_ADDRESS_FILE}; file is not writable by this user." >&2 + fi + fi + PROPOSER_KEY_NAME="$migrated_key_name" + PROPOSER_ADDRESS="$migrated_address" + echo "Governance proposer migrated: ${legacy_key_name} (${legacy_address}) -> ${PROPOSER_KEY_NAME} (${PROPOSER_ADDRESS})" + return 0 +} + +primary_validator_key_name() { + local key_name + key_name="$(jq -r --arg svc "$SERVICE" '.[] | select(.moniker == $svc or .name == $svc) | .key_name // empty' \ + "${LUMERA_SHARED}/config/validators.json" 2>/dev/null | head -n 1)" + if [[ -z "$key_name" || "$key_name" == "null" ]]; then + key_name="${SERVICE}_key" + fi + echo "$key_name" +} + +resolve_proposer() { + PROPOSER_KEY_NAME="governance_key" + PROPOSER_ADDRESS="$(key_address "$PROPOSER_KEY_NAME")" + + if [[ -z "$PROPOSER_ADDRESS" && -f "$GOV_ADDRESS_FILE" ]]; then + PROPOSER_ADDRESS="$(tr -d '\r\n' <"$GOV_ADDRESS_FILE")" + fi + + if account_exists "$PROPOSER_ADDRESS"; then + echo "Governance proposer: ${PROPOSER_KEY_NAME} (${PROPOSER_ADDRESS})" + return + fi + if try_migrated_proposer "$PROPOSER_KEY_NAME" "$PROPOSER_ADDRESS"; then + return + fi + + if [[ -n "$PROPOSER_ADDRESS" ]]; then + echo "⚠️ Governance helper account ${PROPOSER_ADDRESS} is not present on-chain; using primary validator proposer." >&2 + fi + + PROPOSER_KEY_NAME="$(primary_validator_key_name)" + PROPOSER_ADDRESS="$(key_address "$PROPOSER_KEY_NAME")" + if [[ -z "$PROPOSER_ADDRESS" ]]; then + echo "❌ Unable to resolve fallback proposer key ${PROPOSER_KEY_NAME}" >&2 + exit 1 + fi + if ! account_exists "$PROPOSER_ADDRESS"; then + if try_migrated_proposer "$PROPOSER_KEY_NAME" "$PROPOSER_ADDRESS"; then + return + fi + echo "❌ Fallback proposer ${PROPOSER_KEY_NAME} (${PROPOSER_ADDRESS}) is not present on-chain" >&2 + exit 1 + fi + + echo "Governance proposer: ${PROPOSER_KEY_NAME} (${PROPOSER_ADDRESS})" +} + submit_proposal() { AUTHORITY_ADDRESS=$(docker compose -f "$COMPOSE_FILE" exec "$SERVICE" \ lumerad query auth module-accounts --output json | @@ -171,20 +368,22 @@ EOF # Run inside supernova_validator_1 container PROPOSAL_OUTPUT=$(docker compose -f "$COMPOSE_FILE" exec "$SERVICE" \ lumerad tx gov submit-proposal "$CONTAINER_PROPOSAL_FILE" \ - --from "$GOV_ADDRESS" \ + --from "$PROPOSER_KEY_NAME" \ --chain-id "$CHAIN_ID" \ --keyring-backend "$KEYRING" \ --gas 400000 \ --fees 5000ulume \ --broadcast-mode sync \ --output json \ - --yes 2>/dev/null) + --yes 2>&1) echo "🔍 Proposal submission output:" >&2 - echo "$PROPOSAL_OUTPUT" | jq . >&2 + if ! echo "$PROPOSAL_OUTPUT" | jq . >&2; then + echo "$PROPOSAL_OUTPUT" >&2 + fi - TXHASH=$(echo "$PROPOSAL_OUTPUT" | jq -r '.txhash // empty') - LATEST_PROPOSAL_ID=$(get_proposal_id_by_version_with_retry "$VERSION" 5 "$UPGRADE_HEIGHT") + TXHASH=$(echo "$PROPOSAL_OUTPUT" | jq -r '.txhash // empty' 2>/dev/null || true) + LATEST_PROPOSAL_ID=$(get_proposal_id_by_version_with_retry "$VERSION" 15 "$UPGRADE_HEIGHT") if [[ -n "$LATEST_PROPOSAL_ID" ]]; then echo "✅ Proposal submitted successfully with ID: $LATEST_PROPOSAL_ID" >&2 @@ -224,7 +423,7 @@ submit_proposal_deposit() { DEPOSIT_OUTPUT=$(docker compose -f "$COMPOSE_FILE" exec "$SERVICE" \ lumerad tx gov deposit "$EXISTING_PROPOSAL_ID" "$DEPOSIT_AMOUNT" \ - --from "$GOV_ADDRESS" \ + --from "$PROPOSER_KEY_NAME" \ --chain-id "$CHAIN_ID" \ --keyring-backend "$KEYRING" \ --gas 300000 \ @@ -309,16 +508,13 @@ if [ $# -ne 2 ]; then exit 1 fi -# Read the governance address +# Read the governance helper address, then resolve a live proposer key. GOV_ADDRESS_FILE="${LUMERA_SHARED}/governance_address" if [ ! -f "$GOV_ADDRESS_FILE" ]; then - echo "❌ Governance address file not found at $GOV_ADDRESS_FILE" - exit 1 + echo "⚠️ Governance address file not found at $GOV_ADDRESS_FILE; using primary validator proposer if available." >&2 fi -GOV_ADDRESS=$(cat "$GOV_ADDRESS_FILE") -GOV_KEY_NAME="governance_key" -echo "Governance address: ${GOV_ADDRESS}" +resolve_proposer validate_height_param diff --git a/devnet/scripts/upgrade.sh b/devnet/scripts/upgrade.sh index 271335c7..3034a421 100755 --- a/devnet/scripts/upgrade.sh +++ b/devnet/scripts/upgrade.sh @@ -127,7 +127,7 @@ PROPOSAL_ID="$(docker compose -f "${COMPOSE_FILE}" exec -T supernova_validator_1 lumerad query gov proposals --output json | jq -r --arg name "${RELEASE_NAME}" ' .proposals | map(select( - (.messages[]?.value.plan.name == $name) + any(.messages[]?; (.value.plan.name // .plan.name // empty) == $name) and ((.status == "PROPOSAL_STATUS_VOTING_PERIOD") or (.status == "PROPOSAL_STATUS_DEPOSIT_PERIOD") or (.status == "PROPOSAL_STATUS_PASSED")) @@ -151,7 +151,7 @@ PROPOSAL_STATUS="" PROPOSAL_HEIGHT="" if [[ -n "${PROPOSAL_JSON}" ]]; then PROPOSAL_STATUS="$(echo "${PROPOSAL_JSON}" | jq -r '.proposal.status // .status // empty' 2>/dev/null || true)" - PROPOSAL_HEIGHT="$(echo "${PROPOSAL_JSON}" | jq -r '.proposal.messages[]?.value.plan.height // empty' 2>/dev/null | head -n 1 || true)" + PROPOSAL_HEIGHT="$(echo "${PROPOSAL_JSON}" | jq -r '.proposal.messages[]? | (.value.plan.height // .plan.height // empty)' 2>/dev/null | head -n 1 || true)" fi if [[ -n "${PROPOSAL_HEIGHT}" && "${PROPOSAL_HEIGHT}" =~ ^[0-9]+$ ]]; then diff --git a/devnet/scripts/validator-setup.sh b/devnet/scripts/validator-setup.sh index fc6c1fc0..be20b928 100755 --- a/devnet/scripts/validator-setup.sh +++ b/devnet/scripts/validator-setup.sh @@ -901,7 +901,13 @@ primary_validator_setup() { if [ -z "${gov_mnemonic:-}" ] && [ -s "${GOV_MNEMONIC_FILE}" ]; then gov_mnemonic="$(cat "${GOV_MNEMONIC_FILE}")" fi - printf '%s\n' "${gov_addr}" >${SHARED_DIR}/governance_address + if [ -z "${gov_mnemonic:-}" ]; then + gov_mnemonic="$(accounts_registry_get_field "governance_key" "mnemonic")" + fi + if [ -n "${gov_mnemonic:-}" ]; then + printf '%s\n' "${gov_mnemonic}" >"${GOV_MNEMONIC_FILE}" + fi + printf '%s\n' "${gov_addr}" >"${SHARED_DIR}/governance_address" run ${DAEMON} genesis add-genesis-account "${gov_addr}" "1000000000000${DENOM}" accounts_registry_upsert "governance_key" "${gov_addr}" "${gov_mnemonic:-}" "cosmos" "1000000000000${DENOM}" "genesis" "" diff --git a/devnet/scripts/vote-all.sh b/devnet/scripts/vote-all.sh index a7a2a96f..d325aa14 100755 --- a/devnet/scripts/vote-all.sh +++ b/devnet/scripts/vote-all.sh @@ -23,6 +23,78 @@ GAS_AMOUNT="250000" # fixed gas; headroom over a vote's real ~58k usage PRIMARY_KEY="supernova_validator_1_key" # fee-buffer source for vesting voters FEE_TOPUP="1000000ulume" # sent to a voter with no spendable balance FEE_MIN="5000" # minimum spendable ulume needed to pay a vote fee +PRIMARY_FUNDING_KEY="$PRIMARY_KEY" +PRIMARY_FUNDING_ADDRESS="" + +account_exists() { + local svc="$1" address="$2" + [[ -n "$address" ]] || return 1 + docker compose -f "$COMPOSE_FILE" exec -T "$svc" \ + lumerad query auth account "$address" --output json >/dev/null 2>&1 +} + +key_address() { + local svc="$1" key_name="$2" + docker compose -f "$COMPOSE_FILE" exec -T "$svc" \ + lumerad keys show "$key_name" -a --keyring-backend "$KEYRING_BACKEND" 2>/dev/null | + tr -d '\r\n' +} + +key_name_for_address() { + local svc="$1" address="$2" + [[ -n "$address" ]] || return 1 + docker compose -f "$COMPOSE_FILE" exec -T "$svc" \ + lumerad keys list --keyring-backend "$KEYRING_BACKEND" --output json 2>/dev/null | + jq -r --arg address "$address" ' + (if type == "array" then . else (.keys // []) end) + | map(select(.address == $address)) + | first + | .name // empty + ' 2>/dev/null | head -n 1 +} + +migration_new_address() { + local svc="$1" legacy_address="$2" + [[ -n "$legacy_address" ]] || return 1 + docker compose -f "$COMPOSE_FILE" exec -T "$svc" \ + lumerad query evmigration migration-record "$legacy_address" --output json 2>/dev/null | + jq -r '.record.new_address // .migration_record.new_address // .new_address // empty' 2>/dev/null +} + +resolve_live_key() { + local svc="$1" key_name="$2" + local address migrated_address migrated_key_name + + address="$(key_address "$svc" "$key_name")" + if account_exists "$svc" "$address"; then + printf '%s\t%s\n' "$key_name" "$address" + return 0 + fi + + migrated_address="$(migration_new_address "$svc" "$address")" + if [[ -z "$migrated_address" || "$migrated_address" == "null" ]]; then + return 1 + fi + + migrated_key_name="$(key_name_for_address "$svc" "$migrated_address")" + if [[ -z "$migrated_key_name" ]]; then + return 1 + fi + if ! account_exists "$svc" "$migrated_address"; then + return 1 + fi + + printf '%s\t%s\n' "$migrated_key_name" "$migrated_address" +} + +resolve_primary_funding_key() { + local resolved + if ! resolved="$(resolve_live_key "$SERVICE_NAME" "$PRIMARY_KEY")"; then + echo "❌ Unable to resolve live primary funding key from ${SERVICE_NAME}/${PRIMARY_KEY}" >&2 + return 1 + fi + IFS=$'\t' read -r PRIMARY_FUNDING_KEY PRIMARY_FUNDING_ADDRESS <<<"$resolved" +} # ensure_fee_funds tops up a voter that cannot self-pay the tx fee. Validators # whose operator account is a PermanentLocked (or otherwise vesting) account have @@ -40,9 +112,9 @@ ensure_fee_funds() { if [[ "$spendable" =~ ^[0-9]+$ ]] && [ "$spendable" -ge "$FEE_MIN" ]; then return 0 fi - echo " 💧 $svc voter has ${spendable}ulume spendable (< ${FEE_MIN}); topping up ${FEE_TOPUP} from ${SERVICE_NAME}/${PRIMARY_KEY}" + echo " 💧 $svc voter has ${spendable}ulume spendable (< ${FEE_MIN}); topping up ${FEE_TOPUP} from ${SERVICE_NAME}/${PRIMARY_FUNDING_KEY}" topup_json=$(docker compose -f "$COMPOSE_FILE" exec -T "$SERVICE_NAME" \ - lumerad tx bank send "$PRIMARY_KEY" "$addr" "$FEE_TOPUP" \ + lumerad tx bank send "$PRIMARY_FUNDING_KEY" "$addr" "$FEE_TOPUP" \ --keyring-backend "$KEYRING_BACKEND" --chain-id "$CHAIN_ID" \ --gas "$GAS_AMOUNT" --gas-prices 0.025ulume -y -o json 2>&1) topup_rc=$? @@ -95,17 +167,17 @@ is_multisig_validator() { # Echoes a broadcast-response-shaped JSON on stdout (matches the single-sig # path so the caller can parse txhash/code uniformly). cast_vote_multisig() { - local svc="$1" proposal="$2" - local key_name="${svc}_key" + local svc="$1" proposal="$2" key_name="$3" voter_address="$4" docker compose -f "$COMPOSE_FILE" exec -T "$svc" bash -s -- \ - "$key_name" "$proposal" "$CHAIN_ID" "$KEYRING_BACKEND" "$GAS_AMOUNT" "$FEES" <<'CONTAINER_SCRIPT' + "$key_name" "$voter_address" "$proposal" "$CHAIN_ID" "$KEYRING_BACKEND" "$GAS_AMOUNT" "$FEES" <<'CONTAINER_SCRIPT' set -euo pipefail KEY_NAME="$1" -PROPOSAL="$2" -CHAIN_ID="$3" -KEYRING_BACKEND="$4" -GAS_AMOUNT="$5" -FEES="$6" +MULTISIG_ADDR="$2" +PROPOSAL="$3" +CHAIN_ID="$4" +KEYRING_BACKEND="$5" +GAS_AMOUNT="$6" +FEES="$7" # multisig_sign_unsigned reads ${DAEMON}, ${KEYRING_BACKEND}, ${CHAIN_ID} from # the ambient shell; vote-all.sh isn't one of the setup scripts that normally @@ -115,11 +187,21 @@ export DAEMON KEYRING_BACKEND CHAIN_ID source /root/scripts/common.sh -MULTISIG_ADDR="$(lumerad keys show "$KEY_NAME" -a --keyring-backend "$KEYRING_BACKEND" | tr -d '\r\n')" ACCT_JSON="$(lumerad q auth account "$MULTISIG_ADDR" --output json 2>/dev/null)" ACC_NUM="$(printf '%s' "$ACCT_JSON" | jq -r '.. | objects | select(has("account_number")) | .account_number' | head -n1)" SEQ="$(printf '%s' "$ACCT_JSON" | jq -r '.. | objects | select(has("account_number")) | (.sequence // "0")' | head -n1)" SEQ="${SEQ:-0}" +SIGNER_1="${KEY_NAME}-signer-1" +SIGNER_2="${KEY_NAME}-signer-2" +if [[ "$KEY_NAME" == *-new-msig ]]; then + BASE_KEY="${KEY_NAME%-new-msig}" + SIGNER_1="${BASE_KEY}-new-signer-1" + SIGNER_2="${BASE_KEY}-new-signer-2" + if ! lumerad keys show "$SIGNER_1" --keyring-backend "$KEYRING_BACKEND" >/dev/null 2>&1; then + SIGNER_1="${BASE_KEY}-newmsig-1" + SIGNER_2="${BASE_KEY}-newmsig-2" + fi +fi UNSIGNED="$(mktemp /tmp/vote-unsigned.XXXXXX.json)" SIGNED="$(mktemp /tmp/vote-signed.XXXXXX.json)" @@ -135,7 +217,7 @@ lumerad tx gov vote "$PROPOSAL" yes \ --generate-only --output json >"$UNSIGNED" multisig_sign_unsigned "$UNSIGNED" "$KEY_NAME" "$MULTISIG_ADDR" \ - "${KEY_NAME}-signer-1" "${KEY_NAME}-signer-2" \ + "$SIGNER_1" "$SIGNER_2" \ "$ACC_NUM" "$SEQ" >"$SIGNED" lumerad tx broadcast "$SIGNED" --broadcast-mode sync --output json @@ -177,6 +259,8 @@ check_tally() { vote_all() { echo "🔍 Discovering validator services..." + resolve_primary_funding_key || exit 1 + echo "💧 Fee top-up source: ${SERVICE_NAME}/${PRIMARY_FUNDING_KEY} (${PRIMARY_FUNDING_ADDRESS})" # Vote from ALL validators including the primary (_1). The proposer is NOT # auto-voted by x/gov, so excluding _1 silently dropped the primary's (often @@ -191,10 +275,14 @@ vote_all() { echo "🔍 Processing $SERVICE..." KEY_NAME="${SERVICE}_key" - VOTER_ADDRESS=$(docker compose -f "$COMPOSE_FILE" exec "$SERVICE" \ - lumerad keys show "$KEY_NAME" -a --keyring-backend "$KEYRING_BACKEND" 2>/dev/null) + RESOLVED_VOTER="$(resolve_live_key "$SERVICE" "$KEY_NAME" || true)" + if [[ -z "$RESOLVED_VOTER" ]]; then + echo "❌ Unable to resolve live voter key for $SERVICE/$KEY_NAME; skipping vote" + continue + fi + IFS=$'\t' read -r VOTER_KEY_NAME VOTER_ADDRESS <<<"$RESOLVED_VOTER" - echo "🗳️ Voting YES on behalf of $SERVICE (address: $VOTER_ADDRESS)..." + echo "🗳️ Voting YES on behalf of $SERVICE (key: $VOTER_KEY_NAME, address: $VOTER_ADDRESS)..." # Make sure the voter can pay the fee (vesting accounts have 0 spendable). if ! ensure_fee_funds "$SERVICE" "$VOTER_ADDRESS"; then @@ -204,7 +292,7 @@ vote_all() { if is_multisig_validator "$SERVICE"; then echo " ($SERVICE is multisig; using offline 2-of-N signing flow)" - VOTE_JSON=$(cast_vote_multisig "$SERVICE" "$PROPOSAL_ID") + VOTE_JSON=$(cast_vote_multisig "$SERVICE" "$PROPOSAL_ID" "$VOTER_KEY_NAME" "$VOTER_ADDRESS") else if [ "$USE_GAS_AUTO" = "true" ]; then GAS_FLAGS=(--gas auto --gas-adjustment 1.3) @@ -214,7 +302,7 @@ vote_all() { VOTE_JSON=$(docker compose -f "$COMPOSE_FILE" exec "$SERVICE" \ lumerad tx gov vote "$PROPOSAL_ID" yes \ - --from "$VOTER_ADDRESS" \ + --from "$VOTER_KEY_NAME" \ --chain-id "$CHAIN_ID" \ --keyring-backend "$KEYRING_BACKEND" \ "${GAS_FLAGS[@]}" \ diff --git a/devnet/tests/evmigration/migrate.go b/devnet/tests/evmigration/migrate.go index 412c6c67..4adc56b6 100644 --- a/devnet/tests/evmigration/migrate.go +++ b/devnet/tests/evmigration/migrate.go @@ -418,6 +418,8 @@ func migrateOne(rec *AccountRecord) migrateResult { return migrateFailed } + updateStatusRegistryMigratedAccount(rec.Name, newAddress) + log.Printf(" OK: %s (%s) -> %s (%s)", rec.Name, rec.Address, newName, newAddress) return migrateNew } diff --git a/devnet/tests/evmigration/migrate_test.go b/devnet/tests/evmigration/migrate_test.go index cbb33e01..2bbcfdea 100644 --- a/devnet/tests/evmigration/migrate_test.go +++ b/devnet/tests/evmigration/migrate_test.go @@ -1,6 +1,11 @@ package main -import "testing" +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) func TestMigratedAccountBaseName(t *testing.T) { cases := map[string]string{ @@ -24,3 +29,49 @@ func TestMigratedAccountBaseName(t *testing.T) { } } } + +func TestUpdateStatusRegistryMigratedAccountKeepsNameAndMnemonic(t *testing.T) { + dir := t.TempDir() + accountsPath := filepath.Join(dir, "evmigration-accounts.json") + registryPath := filepath.Join(dir, "accounts.json") + oldFlagFile := *flagFile + *flagFile = accountsPath + t.Cleanup(func() { *flagFile = oldFlagFile }) + + const mnemonic = "fixture mnemonic preserved by registry update" + const legacyAddress = "lumera1zh6a6hz52plxdmrwe57u0m7wc8knflkl8ku8w9" + const newAddress = "lumera12yjcrfn3kueqrnjcjfrezkjquven2rxd257qxs" + if err := os.WriteFile(registryPath, []byte(`[ + { + "name": "governance_key", + "address": "`+legacyAddress+`", + "mnemonic": "`+mnemonic+`", + "type": "cosmos" + } +]`), 0o644); err != nil { + t.Fatalf("write registry fixture: %v", err) + } + + updateStatusRegistryMigratedAccount("governance_key", newAddress) + + data, err := os.ReadFile(registryPath) + if err != nil { + t.Fatalf("read registry: %v", err) + } + var accounts []statusRegistryAccount + if err := json.Unmarshal(data, &accounts); err != nil { + t.Fatalf("parse registry: %v", err) + } + if len(accounts) != 1 { + t.Fatalf("expected 1 account, got %d", len(accounts)) + } + if accounts[0].Name != "governance_key" { + t.Fatalf("name changed: got %q", accounts[0].Name) + } + if accounts[0].Address != newAddress { + t.Fatalf("address = %q, want %q", accounts[0].Address, newAddress) + } + if accounts[0].Mnemonic != mnemonic { + t.Fatalf("mnemonic changed: got %q", accounts[0].Mnemonic) + } +} diff --git a/devnet/tests/evmigration/status_registry.go b/devnet/tests/evmigration/status_registry.go index 42e4b845..67fdf81d 100644 --- a/devnet/tests/evmigration/status_registry.go +++ b/devnet/tests/evmigration/status_registry.go @@ -121,3 +121,7 @@ func updateStatusRegistryAddress(name, newAddr string) { } log.Printf(" updated account registry address for %s in %s", name, registryFile) } + +func updateStatusRegistryMigratedAccount(name, newAddr string) { + updateStatusRegistryAddress(name, newAddr) +} From f95b3633a48058542fd79d722d7bb84bb8d8ba95 Mon Sep 17 00:00:00 2001 From: Andrey Kobrin Date: Mon, 6 Jul 2026 23:00:52 -0400 Subject: [PATCH 2/7] fix devnet gen-activity CLI hangs --- devnet/tests/common/chaincli.go | 48 ++++++++++- devnet/tests/common/chaincli_test.go | 24 ++++++ devnet/tests/common/migration_keys.go | 4 +- devnet/tests/common/multisig.go | 3 +- devnet/tests/gen-activity/activity_plan.go | 2 +- .../tests/gen-activity/activity_plan_test.go | 8 ++ devnet/tests/gen-activity/activity_run.go | 27 +++++- devnet/tests/gen-activity/chain.go | 27 +++++- devnet/tests/gen-activity/funder.go | 15 +++- devnet/tests/gen-activity/main.go | 84 ++++++++++++++++--- 10 files changed, 217 insertions(+), 25 deletions(-) diff --git a/devnet/tests/common/chaincli.go b/devnet/tests/common/chaincli.go index d3efcf4f..00a1170a 100644 --- a/devnet/tests/common/chaincli.go +++ b/devnet/tests/common/chaincli.go @@ -1,9 +1,11 @@ package common import ( + "context" "encoding/json" "errors" "fmt" + "os" "os/exec" "strconv" "strings" @@ -63,10 +65,47 @@ func (c *ChainCLI) runWithFlags(includeNode, includeKeyring bool, args ...string base = append(base, "--home", c.Home) } all := append(append([]string{}, args...), base...) - out, err := exec.Command(c.Bin, all...).CombinedOutput() + out, err := combinedOutputNoDesktopBus(c.Bin, all...) return strings.TrimSpace(string(out)), err } +func combinedOutputNoDesktopBus(bin string, args ...string) ([]byte, error) { + cmd, cancel := commandNoDesktopBus(bin, args...) + defer cancel() + return cmd.CombinedOutput() +} + +func commandNoDesktopBus(bin string, args ...string) (*exec.Cmd, context.CancelFunc) { + ctx, cancel := context.WithTimeout(context.Background(), commandTimeout()) + cmd := exec.CommandContext(ctx, bin, args...) + cmd.Env = envWithoutDesktopBus() + return cmd, cancel +} + +func commandTimeout() time.Duration { + raw := strings.TrimSpace(os.Getenv("LUMERA_CLI_TIMEOUT")) + if raw == "" { + return 30 * time.Second + } + d, err := time.ParseDuration(raw) + if err != nil || d <= 0 { + return 30 * time.Second + } + return d +} + +func envWithoutDesktopBus() []string { + env := os.Environ() + out := make([]string, 0, len(env)) + for _, kv := range env { + if strings.HasPrefix(kv, "DBUS_SESSION_BUS_ADDRESS=") { + continue + } + out = append(out, kv) + } + return out +} + func (c *ChainCLI) keyringBackend() string { if c.KeyringBackend == "" { return "test" @@ -182,7 +221,7 @@ func (c *ChainCLI) Balance(addr string) (int64, error) { // ShowAddress returns the bech32 address for a keyring key name. func (c *ChainCLI) ShowAddress(name string) (string, error) { args := append([]string{"keys", "show", name, "--keyring-backend", c.keyringBackend(), "--address"}, c.homeArgs()...) - out, err := exec.Command(c.Bin, args...).CombinedOutput() + out, err := combinedOutputNoDesktopBus(c.Bin, args...) if err != nil { return "", fmt.Errorf("keys show %s: %s: %w", name, strings.TrimSpace(string(out)), err) } @@ -217,7 +256,7 @@ func (c *ChainCLI) AddKeyWithStyle(name string, style KeyStyle) (GeneratedKey, e "--algo", style.Algo, } args = append(args, c.homeArgs()...) - out, err := exec.Command(c.Bin, args...).CombinedOutput() + out, err := combinedOutputNoDesktopBus(c.Bin, args...) if err != nil { return GeneratedKey{}, fmt.Errorf("keys add %s: %s: %w", name, strings.TrimSpace(string(out)), err) } @@ -238,7 +277,8 @@ func (c *ChainCLI) AddKeyWithStyle(name string, style KeyStyle) (GeneratedKey, e // name already exists. func (c *ChainCLI) ImportKey(name, mnemonic string) (string, error) { args := append([]string{"keys", "add", name, "--keyring-backend", c.keyringBackend(), "--recover", "--output", "json"}, c.homeArgs()...) - cmd := exec.Command(c.Bin, args...) + cmd, cancel := commandNoDesktopBus(c.Bin, args...) + defer cancel() cmd.Stdin = strings.NewReader(mnemonic + "\n") out, err := cmd.CombinedOutput() if err != nil { diff --git a/devnet/tests/common/chaincli_test.go b/devnet/tests/common/chaincli_test.go index f8d99dc1..b5893a6d 100644 --- a/devnet/tests/common/chaincli_test.go +++ b/devnet/tests/common/chaincli_test.go @@ -35,6 +35,30 @@ func TestAddKeyWithStylePassesExplicitKeyStyleFlags(t *testing.T) { }) } +func TestEnvWithoutDesktopBusStripsDBusSession(t *testing.T) { + t.Setenv("DBUS_SESSION_BUS_ADDRESS", "unix:abstract=/tmp/dbus-test") + t.Setenv("LUMERA_TEST_KEEP", "yes") + + env := envWithoutDesktopBus() + for _, kv := range env { + if strings.HasPrefix(kv, "DBUS_SESSION_BUS_ADDRESS=") { + t.Fatalf("envWithoutDesktopBus kept DBUS_SESSION_BUS_ADDRESS: %q", kv) + } + } + if !containsEnv(env, "LUMERA_TEST_KEEP=yes") { + t.Fatalf("envWithoutDesktopBus dropped unrelated env var: %v", env) + } +} + +func containsEnv(env []string, want string) bool { + for _, kv := range env { + if kv == want { + return true + } + } + return false +} + func TestParseSyncBroadcastHandlesQueryTxResponse(t *testing.T) { out := `{ "tx_response": { diff --git a/devnet/tests/common/migration_keys.go b/devnet/tests/common/migration_keys.go index f17968a2..98390788 100644 --- a/devnet/tests/common/migration_keys.go +++ b/devnet/tests/common/migration_keys.go @@ -2,7 +2,6 @@ package common import ( "fmt" - "os/exec" "strconv" "strings" ) @@ -32,7 +31,8 @@ func recoverKeyArgs(name, keyringBackend string, style KeyStyle, home string) [] // coin-type-118 secp256k1 legacy key. Reruns should delete a stale key first. func (c *ChainCLI) ImportKeyWithStyle(name, mnemonic string, style KeyStyle) (string, error) { args := recoverKeyArgs(name, c.keyringBackend(), style, c.Home) - cmd := exec.Command(c.Bin, args...) + cmd, cancel := commandNoDesktopBus(c.Bin, args...) + defer cancel() cmd.Stdin = strings.NewReader(mnemonic + "\n") out, err := cmd.CombinedOutput() if err != nil { diff --git a/devnet/tests/common/multisig.go b/devnet/tests/common/multisig.go index e74d4098..f7c8b3a3 100644 --- a/devnet/tests/common/multisig.go +++ b/devnet/tests/common/multisig.go @@ -3,7 +3,6 @@ package common import ( "fmt" "os" - "os/exec" "strconv" "strings" "time" @@ -35,7 +34,7 @@ func NewMultisig(cli *ChainCLI) *Multisig { return &Multisig{ CLI: cli, exec: func(bin string, args ...string) (string, error) { - out, err := exec.Command(bin, args...).CombinedOutput() + out, err := combinedOutputNoDesktopBus(bin, args...) return strings.TrimSpace(string(out)), err }, } diff --git a/devnet/tests/gen-activity/activity_plan.go b/devnet/tests/gen-activity/activity_plan.go index be7b6548..3f65a182 100644 --- a/devnet/tests/gen-activity/activity_plan.go +++ b/devnet/tests/gen-activity/activity_plan.go @@ -113,7 +113,7 @@ func buildActivityPlans(accounts []*AccountRecord, validators []string, rng *ran // guaranteed-failing delegate/bank-send/action attempts. func activityTargets(reg *ActivityRegistry, newRecords []*AccountRecord, includeExisting bool) []*AccountRecord { var out []*AccountRecord - if includeExisting { + if includeExisting || len(newRecords) == 0 { for _, rec := range reg.Accounts { if rec.Funded && rec.Multisig == nil { out = append(out, rec) diff --git a/devnet/tests/gen-activity/activity_plan_test.go b/devnet/tests/gen-activity/activity_plan_test.go index 37482476..25b4ecd6 100644 --- a/devnet/tests/gen-activity/activity_plan_test.go +++ b/devnet/tests/gen-activity/activity_plan_test.go @@ -169,4 +169,12 @@ func TestActivityTargetsRespectExistingFlag(t *testing.T) { if withExisting[0] != existing || withExisting[1] != created { t.Fatalf("targets with existing activity = %v, want existing then created", withExisting) } + + resumeTargets := activityTargets(reg, nil, false) + if len(resumeTargets) != 2 { + t.Fatalf("targets on resume with no new records = %d, want 2 funded accounts", len(resumeTargets)) + } + if resumeTargets[0] != existing || resumeTargets[1] != created { + t.Fatalf("targets on resume with no new records = %v, want existing then created", resumeTargets) + } } diff --git a/devnet/tests/gen-activity/activity_run.go b/devnet/tests/gen-activity/activity_run.go index 38f6bc5a..c6c0f87e 100644 --- a/devnet/tests/gen-activity/activity_run.go +++ b/devnet/tests/gen-activity/activity_run.go @@ -4,6 +4,7 @@ import ( "fmt" "log" "sync" + "sync/atomic" "gen/tests/common" ) @@ -97,8 +98,17 @@ func runActivityWorkers(chain activityChain, accounts []*AccountRecord, planFor if parallelism < 1 { parallelism = 1 } + totalAccounts := len(accounts) + if totalAccounts == 0 { + log.Printf("activity progress: no accounts to process") + return + } + accountStep := progressEvery(totalAccounts) sem := make(chan struct{}, parallelism) var wg sync.WaitGroup + var completedAccounts atomic.Int64 + var completedActivities atomic.Int64 + log.Printf("activity progress: starting %d account(s) with parallelism %d", totalAccounts, parallelism) for _, acct := range accounts { wg.Add(1) go func(acct *AccountRecord) { @@ -106,14 +116,29 @@ func runActivityWorkers(chain activityChain, accounts []*AccountRecord, planFor sem <- struct{}{} defer func() { <-sem }() - for _, act := range planFor(acct) { + plan := planFor(acct) + log.Printf("activity progress: %s starting %d planned operation(s)", acct.Name, len(plan)) + localDone := 0 + for _, act := range plan { if err := executeActivity(chain, acct, act); err != nil { log.Printf(" WARN: %s %s failed: %v", acct.Name, act.Kind, err) + } else { + localDone++ + } + doneActs := completedActivities.Add(1) + if doneActs%50 == 0 { + log.Printf("activity progress: %d operation(s) attempted", doneActs) } } + doneAccounts := completedAccounts.Add(1) + if doneAccounts == int64(totalAccounts) || doneAccounts%int64(accountStep) == 0 { + log.Printf("activity progress: %d/%d account(s) completed; latest %s succeeded %d/%d operation(s)", + doneAccounts, totalAccounts, acct.Name, localDone, len(plan)) + } }(acct) } wg.Wait() + log.Printf("activity progress: completed %d account(s), attempted %d operation(s)", completedAccounts.Load(), completedActivities.Load()) } // reconcileReceivedGrants backfills the received-grant arrays: for every authz diff --git a/devnet/tests/gen-activity/chain.go b/devnet/tests/gen-activity/chain.go index b1b95bfc..6f86fefd 100644 --- a/devnet/tests/gen-activity/chain.go +++ b/devnet/tests/gen-activity/chain.go @@ -59,7 +59,14 @@ func randomFundingAmount(maxAmount int64, rng *rand.Rand) int64 { func generateAccounts(cli *common.ChainCLI, names []string, keyStyle common.KeyStyle) []*AccountRecord { now := time.Now().UTC().Format(time.RFC3339) recs := make([]*AccountRecord, 0, len(names)) - for _, name := range names { + total := len(names) + if total == 0 { + log.Printf("account key generation: nothing to create") + return recs + } + step := progressEvery(total) + log.Printf("account key generation: starting %d account(s)", total) + for i, name := range names { if cli.HasKey(name) { addr, err := cli.ShowAddress(name) if err != nil { @@ -76,10 +83,28 @@ func generateAccounts(cli *common.ChainCLI, names []string, keyStyle common.KeyS continue } recs = append(recs, newAccountRecord(name, gk.Address, gk.Mnemonic, gk.PubKey, keyStyle.Name(), now)) + done := i + 1 + if done == total || done%step == 0 { + log.Printf("account key generation: %d/%d processed (%d usable)", done, total, len(recs)) + } } + log.Printf("account key generation: completed %d/%d usable account(s)", len(recs), total) return recs } +func progressEvery(total int) int { + switch { + case total <= 10: + return 1 + case total <= 100: + return 10 + case total <= 500: + return 25 + default: + return 100 + } +} + func newAccountRecord(name, addr, mnemonic, pubkey, keyStyle, now string) *AccountRecord { return &AccountRecord{ AccountIdentity: common.AccountIdentity{ diff --git a/devnet/tests/gen-activity/funder.go b/devnet/tests/gen-activity/funder.go index 81ed4ff0..7a435fda 100644 --- a/devnet/tests/gen-activity/funder.go +++ b/devnet/tests/gen-activity/funder.go @@ -39,8 +39,10 @@ func FundAccounts(chain fundingChain, targets []*AccountRecord, amountFor func(* } funded := len(targets) - len(remaining) if len(remaining) == 0 { + log.Printf("funding: no unfunded bank account(s)") return funded, nil } + log.Printf("funding: starting %d bank account(s), already funded %d, batch-size %d, max-attempts %d", len(remaining), funded, batchSize, maxAttempts) for attempt := 1; attempt <= maxAttempts && len(remaining) > 0; attempt++ { accNum, startSeq, err := chain.FunderAccountSequence() @@ -50,8 +52,13 @@ func FundAccounts(chain fundingChain, targets []*AccountRecord, amountFor func(* // Burst each planned batch, then wait a block before the next batch. batches := common.PlanFundingBatches(startSeq, len(remaining), batchSize) + log.Printf("funding: attempt %d/%d with %d remaining account(s), account-number %d, start-sequence %d, %d batch(es)", + attempt, maxAttempts, len(remaining), accNum, startSeq, len(batches)) idx := 0 - for _, batch := range batches { + for batchIdx, batch := range batches { + batchStart := idx + 1 + log.Printf("funding: attempt %d batch %d/%d submitting %d transfer(s) (remaining positions %d-%d)", + attempt, batchIdx+1, len(batches), len(batch.Sequences), batchStart, batchStart+len(batch.Sequences)-1) for _, seq := range batch.Sequences { rec := remaining[idx] idx++ @@ -65,10 +72,13 @@ func FundAccounts(chain fundingChain, targets []*AccountRecord, amountFor func(* if waitErr := chain.WaitForNextBlock(); waitErr != nil { log.Printf(" WARN: wait for next block after funding batch: %v", waitErr) } + log.Printf("funding: attempt %d batch %d/%d submitted; checking next batch after block wait", attempt, batchIdx+1, len(batches)) } // Confirm and collect the accounts that still need funding. + log.Printf("funding: confirming %d account(s) after attempt %d", len(remaining), attempt) still := remaining[:0:0] + attemptFunded := 0 for _, rec := range remaining { ok, confErr := chain.IsFunded(rec.Address) if confErr != nil { @@ -78,10 +88,13 @@ func FundAccounts(chain fundingChain, targets []*AccountRecord, amountFor func(* rec.Funded = true rec.HasBalance = true funded++ + attemptFunded++ } else { still = append(still, rec) } } + log.Printf("funding: attempt %d confirmed %d newly funded account(s); total funded %d/%d", + attempt, attemptFunded, funded, len(targets)) remaining = still if len(remaining) > 0 { log.Printf(" INFO: %d account(s) unfunded after attempt %d/%d", len(remaining), attempt, maxAttempts) diff --git a/devnet/tests/gen-activity/main.go b/devnet/tests/gen-activity/main.go index f437a1f6..45c7e370 100644 --- a/devnet/tests/gen-activity/main.go +++ b/devnet/tests/gen-activity/main.go @@ -7,6 +7,7 @@ package main import ( + "context" "flag" "fmt" "log" @@ -14,6 +15,7 @@ import ( "os" "os/exec" "path/filepath" + "strings" "time" "gen/tests/common" @@ -175,24 +177,33 @@ func run(cfg *Config) error { } // Step 2: detect key style from the current lumerad runtime. + log.Printf("phase: detect chain key style") keyStyle := detectKeyStyle(cfg.Bin, cfg.EVMCutoverVer) log.Printf("key style: %s (algo=%s coin-type=%d)", keyStyle.Name(), keyStyle.Algo, keyStyle.CoinType) cli := newChainCLI(cfg) - // Steps 3-4: query validators and resolve the funder address. These are - // read-only; in dry-run they are best-effort so planning works without a - // node, but a live run requires both. - validators := queryValidators(cli, cfg.DryRun) - funderAddr := resolveFunder(cli, cfg.FundingKey, cfg.DryRun) - - // Step 5: load the registry if present, else start a new one. + // Step 5: load the registry if present, else start a new one. Load this + // before validator discovery so reruns can fall back to the last known set + // if the staking CLI query is unavailable. + log.Printf("phase: load registry") now := time.Now().UTC().Format(time.RFC3339) reg, err := loadOrCreateRegistry(cfg, keyStyle, now) if err != nil { return err } + // Steps 3-4: query validators and resolve the funder address. These are + // read-only; in dry-run they are best-effort so planning works without a + // node, but a live run requires both. + log.Printf("phase: query validators and funder") + validators := queryValidators(cli, cfg.DryRun, reg.Validators) + log.Printf("validators: %d discovered", len(validators)) + funderAddr := resolveFunder(cli, cfg.FundingKey, cfg.DryRun, reg.FunderAddress) + if funderAddr != "" { + log.Printf("funder: %s -> %s", cfg.FundingKey, funderAddr) + } + // Step 6: reconcile envelope metadata with the current run. An existing // account's recorded key style is never rewritten. reconcile(reg, cfg, keyStyle) @@ -206,6 +217,8 @@ func run(cfg *Config) error { // Decide how many new accounts to allocate this run. newCount := plannedNewAccountCount(cfg, reg) plannedNames := reg.AllocateNames(cfg.AccountPrefix, newCount) + log.Printf("plan: mode=%s existing=%d new-regular=%d multisig23=%d multisig35=%d permanent-locked=%d actions=%v dry-run=%v", + cfg.resolvedMode, len(reg.Accounts), len(plannedNames), cfg.NumMultisig23, cfg.NumMultisig35, cfg.NumPermanentLocked, cfg.Actions, cfg.DryRun) if cfg.DryRun { printPlan(cfg, reg, plannedNames) @@ -223,6 +236,7 @@ func run(cfg *Config) error { // Step 7-8: generate keys for the planned accounts and persist before any // funding so an interrupted run can resume. + log.Printf("phase: create/reuse regular account keys") newRecs := generateAccounts(cli, plannedNames, keyStyle) for _, rec := range newRecs { reg.UpsertAccount(rec) @@ -235,6 +249,7 @@ func run(cfg *Config) error { // Generate multisig accounts (2-of-3 / 3-of-5) alongside regular accounts. var newMultisig []*AccountRecord if specs := multisigPlan(cfg.NumMultisig23, cfg.NumMultisig35); len(specs) > 0 && !cfg.ActivityExisting { + log.Printf("phase: create multisig account keys") newMultisig = generateMultisigAccounts(cli, reg, cfg.AccountPrefix, specs, keyStyle) if err := reg.Save(cfg.AccountsPath, time.Now().UTC().Format(time.RFC3339)); err != nil { return fmt.Errorf("save registry after multisig key generation: %w", err) @@ -264,6 +279,7 @@ func run(cfg *Config) error { // Step 9: fund unfunded accounts via the single-funder batcher. chain := &cliFundingChain{cli: cli, funderKey: cfg.FundingKey, funderAddr: funderAddr, blockWait: 30 * time.Second} bankTargets, vestingTargets := splitFundingTargets(reg) + log.Printf("phase: fund accounts (bank-targets=%d vesting-targets=%d)", len(bankTargets), len(vestingTargets)) amountFor := func(*AccountRecord) string { return common.Coin{Amount: randomFundingAmount(cfg.maxAmount.Amount, rng), Denom: common.ChainDenom}.String() } @@ -294,6 +310,7 @@ func run(cfg *Config) error { // Step 10: per-account activity mix. Only funded accounts can transact, and // they serve as each other's peers for transfers and grants. + log.Printf("phase: generate account activity") generateActivity(cli, reg, newRecs, validators, cfg, rng) reconcileReceivedGrants(reg.Accounts) if err := reg.Save(cfg.AccountsPath, time.Now().UTC().Format(time.RFC3339)); err != nil { @@ -304,6 +321,7 @@ func run(cfg *Config) error { // default; -require-actions makes supernode unavailability or creation // failures fatal. if cfg.Actions { + log.Printf("phase: generate CASCADE actions") actionAccts := activityTargets(reg, newRecs, cfg.ActivityExisting) creator := newSDKActionCreator(cfg, cli) if err := generateActions(creator, cli, actionAccts, validators, cfg.actionStates, @@ -370,11 +388,17 @@ func newChainCLI(cfg *Config) *common.ChainCLI { } } -// queryValidators returns the validator set. In dry-run a query failure is a -// warning; a live run treats an empty set as fatal at the call site. -func queryValidators(cli *common.ChainCLI, dryRun bool) []string { +// queryValidators returns the validator set. If the live CLI query fails, a +// previously persisted registry validator set is good enough for reruns. +// Dry-run treats total failure as a warning; a live run treats an empty set as +// fatal at the call site. +func queryValidators(cli *common.ChainCLI, dryRun bool, fallback []string) []string { vals, err := cli.Validators() if err != nil { + if len(fallback) > 0 { + log.Printf("WARN: validator query failed, using %d validator(s) from registry: %v", len(fallback), err) + return append([]string(nil), fallback...) + } if dryRun { log.Printf("WARN: validator query failed (dry-run, continuing): %v", err) return nil @@ -385,10 +409,16 @@ func queryValidators(cli *common.ChainCLI, dryRun bool) []string { return vals } -// resolveFunder resolves the funder key's address, best-effort in dry-run. -func resolveFunder(cli *common.ChainCLI, key string, dryRun bool) string { +// resolveFunder resolves the funder key's address, best-effort in dry-run. On +// reruns, a registry funder address is a useful fallback when the local keyring +// command is temporarily unavailable. +func resolveFunder(cli *common.ChainCLI, key string, dryRun bool, fallback string) string { addr, err := cli.ShowAddress(key) if err != nil { + if fallback != "" { + log.Printf("WARN: funder address lookup failed, using registry address %s: %v", fallback, err) + return fallback + } if dryRun { log.Printf("WARN: funder address lookup failed (dry-run, continuing): %v", err) } else { @@ -420,7 +450,11 @@ func detectKeyStyle(bin, cutover string) common.KeyStyle { // detectLumeradVersion runs the binary's version command and extracts a semver. func detectLumeradVersion(bin string) (string, error) { for _, args := range [][]string{{"version"}, {"version", "--long"}} { - out, err := exec.Command(bin, args...).CombinedOutput() + ctx, cancel := context.WithTimeout(context.Background(), commandTimeout()) + cmd := exec.CommandContext(ctx, bin, args...) + cmd.Env = envWithoutDesktopBus() + out, err := cmd.CombinedOutput() + cancel() if err != nil { continue } @@ -431,6 +465,30 @@ func detectLumeradVersion(bin string) (string, error) { return "", fmt.Errorf("could not determine %s version", bin) } +func commandTimeout() time.Duration { + raw := strings.TrimSpace(os.Getenv("LUMERA_CLI_TIMEOUT")) + if raw == "" { + return 30 * time.Second + } + d, err := time.ParseDuration(raw) + if err != nil || d <= 0 { + return 30 * time.Second + } + return d +} + +func envWithoutDesktopBus() []string { + env := os.Environ() + out := make([]string, 0, len(env)) + for _, kv := range env { + if strings.HasPrefix(kv, "DBUS_SESSION_BUS_ADDRESS=") { + continue + } + out = append(out, kv) + } + return out +} + // loadOrCreateRegistry loads an existing registry or creates a fresh one. A // missing file creates a new registry; an unparseable file is a hard error. func loadOrCreateRegistry(cfg *Config, keyStyle common.KeyStyle, now string) (*ActivityRegistry, error) { From 39e82bd5c3bbf910867594cb5957bb540ef44878 Mon Sep 17 00:00:00 2001 From: Andrey Kobrin Date: Mon, 6 Jul 2026 23:20:35 -0400 Subject: [PATCH 3/7] log gen-activity runtime binary --- devnet/tests/gen-activity/main.go | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/devnet/tests/gen-activity/main.go b/devnet/tests/gen-activity/main.go index 45c7e370..d85be002 100644 --- a/devnet/tests/gen-activity/main.go +++ b/devnet/tests/gen-activity/main.go @@ -8,8 +8,11 @@ package main import ( "context" + "crypto/sha256" + "encoding/hex" "flag" "fmt" + "io" "log" "math/rand" "os" @@ -38,6 +41,7 @@ func main() { if err != nil { log.Fatalf("configuration: %v", err) } + logRuntimeBinary() if wizard { fc, _ := LoadFileConfig(cfg.ConfigPath) @@ -106,6 +110,33 @@ func executableDir() string { return filepath.Dir(exe) } +func logRuntimeBinary() { + exe, err := os.Executable() + if err != nil { + log.Printf("runtime binary: unknown (%v)", err) + return + } + hash, err := fileSHA256(exe) + if err != nil { + log.Printf("runtime binary: %s (hash unavailable: %v)", exe, err) + return + } + log.Printf("runtime binary: %s sha256=%s", exe, hash) +} + +func fileSHA256(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + defer f.Close() + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return "", err + } + return hex.EncodeToString(h.Sum(nil)), nil +} + // resolveConfigPath picks the config path to load when -config was not passed // explicitly. It prefers a file named like configPath sitting next to the // executable (exeDir) so the tool finds its config regardless of the current From 81b51bae93fc31180e15e22561775e9ad7f4f95a Mon Sep 17 00:00:00 2001 From: Andrey Kobrin Date: Mon, 6 Jul 2026 23:43:17 -0400 Subject: [PATCH 4/7] serialize devnet lumerad subprocesses --- devnet/tests/common/chaincli.go | 92 +++++++++++++++++++++++++-- devnet/tests/common/chaincli_test.go | 16 ++++- devnet/tests/common/migration_keys.go | 7 +- devnet/tests/gen-activity/main.go | 20 +++++- 4 files changed, 123 insertions(+), 12 deletions(-) diff --git a/devnet/tests/common/chaincli.go b/devnet/tests/common/chaincli.go index 00a1170a..61805bc0 100644 --- a/devnet/tests/common/chaincli.go +++ b/devnet/tests/common/chaincli.go @@ -9,6 +9,7 @@ import ( "os/exec" "strconv" "strings" + "sync" "time" ) @@ -70,16 +71,22 @@ func (c *ChainCLI) runWithFlags(includeNode, includeKeyring bool, args ...string } func combinedOutputNoDesktopBus(bin string, args ...string) ([]byte, error) { - cmd, cancel := commandNoDesktopBus(bin, args...) + cmd, cancel, ctx, release := commandNoDesktopBus(bin, args...) defer cancel() - return cmd.CombinedOutput() + defer release() + out, err := cmd.CombinedOutput() + if ctx.Err() == context.DeadlineExceeded { + return out, fmt.Errorf("timeout after %s running %s: %w", commandTimeout(), commandString(bin, args...), ctx.Err()) + } + return out, err } -func commandNoDesktopBus(bin string, args ...string) (*exec.Cmd, context.CancelFunc) { +func commandNoDesktopBus(bin string, args ...string) (*exec.Cmd, context.CancelFunc, context.Context, func()) { + release := acquireCommandSlot() ctx, cancel := context.WithTimeout(context.Background(), commandTimeout()) cmd := exec.CommandContext(ctx, bin, args...) cmd.Env = envWithoutDesktopBus() - return cmd, cancel + return cmd, cancel, ctx, release } func commandTimeout() time.Duration { @@ -94,16 +101,83 @@ func commandTimeout() time.Duration { return d } +var commandSlotState struct { + once sync.Once + ch chan struct{} +} + +func acquireCommandSlot() func() { + commandSlotState.once.Do(func() { + commandSlotState.ch = make(chan struct{}, commandParallelism()) + }) + commandSlotState.ch <- struct{}{} + return func() { <-commandSlotState.ch } +} + +func commandParallelism() int { + raw := strings.TrimSpace(os.Getenv("LUMERA_CLI_PARALLELISM")) + if raw == "" { + return 1 + } + n, err := strconv.Atoi(raw) + if err != nil || n <= 0 { + return 1 + } + return n +} + func envWithoutDesktopBus() []string { env := os.Environ() out := make([]string, 0, len(env)) for _, kv := range env { - if strings.HasPrefix(kv, "DBUS_SESSION_BUS_ADDRESS=") { + if isDesktopSessionEnv(kv) { + continue + } + if strings.HasPrefix(kv, "GOMAXPROCS=") { continue } out = append(out, kv) } - return out + return append(out, "GOMAXPROCS="+childGOMAXPROCS()) +} + +func isDesktopSessionEnv(kv string) bool { + for _, prefix := range []string{ + "DBUS_SESSION_BUS_ADDRESS=", + "DISPLAY=", + "WAYLAND_DISPLAY=", + "XDG_RUNTIME_DIR=", + "XDG_CURRENT_DESKTOP=", + "DESKTOP_SESSION=", + "GNOME_DESKTOP_SESSION_ID=", + "KDE_FULL_SESSION=", + } { + if strings.HasPrefix(kv, prefix) { + return true + } + } + return false +} + +func childGOMAXPROCS() string { + raw := strings.TrimSpace(os.Getenv("LUMERA_CHILD_GOMAXPROCS")) + if raw == "" { + return "1" + } + n, err := strconv.Atoi(raw) + if err != nil || n <= 0 { + return "1" + } + return strconv.Itoa(n) +} + +func commandString(bin string, args ...string) string { + parts := make([]string, 0, len(args)+1) + parts = append(parts, strconv.Quote(bin)) + for _, arg := range args { + parts = append(parts, strconv.Quote(arg)) + } + return strings.Join(parts, " ") } func (c *ChainCLI) keyringBackend() string { @@ -277,10 +351,14 @@ func (c *ChainCLI) AddKeyWithStyle(name string, style KeyStyle) (GeneratedKey, e // name already exists. func (c *ChainCLI) ImportKey(name, mnemonic string) (string, error) { args := append([]string{"keys", "add", name, "--keyring-backend", c.keyringBackend(), "--recover", "--output", "json"}, c.homeArgs()...) - cmd, cancel := commandNoDesktopBus(c.Bin, args...) + cmd, cancel, ctx, release := commandNoDesktopBus(c.Bin, args...) defer cancel() + defer release() cmd.Stdin = strings.NewReader(mnemonic + "\n") out, err := cmd.CombinedOutput() + if ctx.Err() == context.DeadlineExceeded { + return "", fmt.Errorf("keys add --recover %s: timeout after %s running %s: %w", name, commandTimeout(), commandString(c.Bin, args...), ctx.Err()) + } if err != nil { return "", fmt.Errorf("keys add --recover %s: %s: %w", name, strings.TrimSpace(string(out)), err) } diff --git a/devnet/tests/common/chaincli_test.go b/devnet/tests/common/chaincli_test.go index b5893a6d..5ce9a783 100644 --- a/devnet/tests/common/chaincli_test.go +++ b/devnet/tests/common/chaincli_test.go @@ -35,19 +35,29 @@ func TestAddKeyWithStylePassesExplicitKeyStyleFlags(t *testing.T) { }) } -func TestEnvWithoutDesktopBusStripsDBusSession(t *testing.T) { +func TestEnvWithoutDesktopBusStripsDesktopSession(t *testing.T) { t.Setenv("DBUS_SESSION_BUS_ADDRESS", "unix:abstract=/tmp/dbus-test") + t.Setenv("DISPLAY", ":0") + t.Setenv("WAYLAND_DISPLAY", "wayland-0") + t.Setenv("XDG_RUNTIME_DIR", "/mnt/wslg/runtime-dir") + t.Setenv("GOMAXPROCS", "99") t.Setenv("LUMERA_TEST_KEEP", "yes") env := envWithoutDesktopBus() for _, kv := range env { - if strings.HasPrefix(kv, "DBUS_SESSION_BUS_ADDRESS=") { - t.Fatalf("envWithoutDesktopBus kept DBUS_SESSION_BUS_ADDRESS: %q", kv) + if isDesktopSessionEnv(kv) { + t.Fatalf("envWithoutDesktopBus kept desktop session env: %q", kv) } } if !containsEnv(env, "LUMERA_TEST_KEEP=yes") { t.Fatalf("envWithoutDesktopBus dropped unrelated env var: %v", env) } + if !containsEnv(env, "GOMAXPROCS=1") { + t.Fatalf("envWithoutDesktopBus did not set child GOMAXPROCS=1: %v", env) + } + if containsEnv(env, "GOMAXPROCS=99") { + t.Fatalf("envWithoutDesktopBus kept parent GOMAXPROCS: %v", env) + } } func containsEnv(env []string, want string) bool { diff --git a/devnet/tests/common/migration_keys.go b/devnet/tests/common/migration_keys.go index 98390788..7fa91183 100644 --- a/devnet/tests/common/migration_keys.go +++ b/devnet/tests/common/migration_keys.go @@ -1,6 +1,7 @@ package common import ( + "context" "fmt" "strconv" "strings" @@ -31,10 +32,14 @@ func recoverKeyArgs(name, keyringBackend string, style KeyStyle, home string) [] // coin-type-118 secp256k1 legacy key. Reruns should delete a stale key first. func (c *ChainCLI) ImportKeyWithStyle(name, mnemonic string, style KeyStyle) (string, error) { args := recoverKeyArgs(name, c.keyringBackend(), style, c.Home) - cmd, cancel := commandNoDesktopBus(c.Bin, args...) + cmd, cancel, ctx, release := commandNoDesktopBus(c.Bin, args...) defer cancel() + defer release() cmd.Stdin = strings.NewReader(mnemonic + "\n") out, err := cmd.CombinedOutput() + if ctx.Err() == context.DeadlineExceeded { + return "", fmt.Errorf("keys add --recover %s: timeout after %s running %s: %w", name, commandTimeout(), commandString(c.Bin, args...), ctx.Err()) + } if err != nil { return "", fmt.Errorf("keys add --recover %s: %s: %w", name, strings.TrimSpace(string(out)), err) } diff --git a/devnet/tests/gen-activity/main.go b/devnet/tests/gen-activity/main.go index d85be002..e1c081dd 100644 --- a/devnet/tests/gen-activity/main.go +++ b/devnet/tests/gen-activity/main.go @@ -512,7 +512,7 @@ func envWithoutDesktopBus() []string { env := os.Environ() out := make([]string, 0, len(env)) for _, kv := range env { - if strings.HasPrefix(kv, "DBUS_SESSION_BUS_ADDRESS=") { + if isDesktopSessionEnv(kv) { continue } out = append(out, kv) @@ -520,6 +520,24 @@ func envWithoutDesktopBus() []string { return out } +func isDesktopSessionEnv(kv string) bool { + for _, prefix := range []string{ + "DBUS_SESSION_BUS_ADDRESS=", + "DISPLAY=", + "WAYLAND_DISPLAY=", + "XDG_RUNTIME_DIR=", + "XDG_CURRENT_DESKTOP=", + "DESKTOP_SESSION=", + "GNOME_DESKTOP_SESSION_ID=", + "KDE_FULL_SESSION=", + } { + if strings.HasPrefix(kv, prefix) { + return true + } + } + return false +} + // loadOrCreateRegistry loads an existing registry or creates a fresh one. A // missing file creates a new registry; an unparseable file is a hard error. func loadOrCreateRegistry(cfg *Config, keyStyle common.KeyStyle, now string) (*ActivityRegistry, error) { From d1ec7eb07578ea935ea9a325c06e9817b4250cb7 Mon Sep 17 00:00:00 2001 From: Andrey Kobrin Date: Tue, 7 Jul 2026 14:06:25 -0400 Subject: [PATCH 5/7] Prevent D-Bus autolaunch in gen activity CLI --- devnet/tests/common/chaincli.go | 13 +++++++- devnet/tests/common/chaincli_test.go | 16 ++++++++++ devnet/tests/gen-activity/env_test.go | 46 +++++++++++++++++++++++++++ devnet/tests/gen-activity/main.go | 12 ++++++- 4 files changed, 85 insertions(+), 2 deletions(-) create mode 100644 devnet/tests/gen-activity/env_test.go diff --git a/devnet/tests/common/chaincli.go b/devnet/tests/common/chaincli.go index 61805bc0..d486326d 100644 --- a/devnet/tests/common/chaincli.go +++ b/devnet/tests/common/chaincli.go @@ -136,9 +136,20 @@ func envWithoutDesktopBus() []string { if strings.HasPrefix(kv, "GOMAXPROCS=") { continue } + if strings.HasPrefix(kv, "LUMERA_KEYRING_BACKEND=") { + continue + } + if strings.HasPrefix(kv, "DISABLE_KWALLET=") { + continue + } out = append(out, kv) } - return append(out, "GOMAXPROCS="+childGOMAXPROCS()) + return append(out, + "DBUS_SESSION_BUS_ADDRESS=unix:path=/tmp/lumera-no-dbus-session-bus", + "DISABLE_KWALLET=1", + "GOMAXPROCS="+childGOMAXPROCS(), + "LUMERA_KEYRING_BACKEND=test", + ) } func isDesktopSessionEnv(kv string) bool { diff --git a/devnet/tests/common/chaincli_test.go b/devnet/tests/common/chaincli_test.go index 5ce9a783..70f073e4 100644 --- a/devnet/tests/common/chaincli_test.go +++ b/devnet/tests/common/chaincli_test.go @@ -41,14 +41,24 @@ func TestEnvWithoutDesktopBusStripsDesktopSession(t *testing.T) { t.Setenv("WAYLAND_DISPLAY", "wayland-0") t.Setenv("XDG_RUNTIME_DIR", "/mnt/wslg/runtime-dir") t.Setenv("GOMAXPROCS", "99") + t.Setenv("LUMERA_KEYRING_BACKEND", "secret-service") t.Setenv("LUMERA_TEST_KEEP", "yes") env := envWithoutDesktopBus() for _, kv := range env { + if kv == "DBUS_SESSION_BUS_ADDRESS=unix:path=/tmp/lumera-no-dbus-session-bus" { + continue + } if isDesktopSessionEnv(kv) { t.Fatalf("envWithoutDesktopBus kept desktop session env: %q", kv) } } + if !containsEnv(env, "DBUS_SESSION_BUS_ADDRESS=unix:path=/tmp/lumera-no-dbus-session-bus") { + t.Fatalf("envWithoutDesktopBus did not disable D-Bus autolaunch: %v", env) + } + if !containsEnv(env, "DISABLE_KWALLET=1") { + t.Fatalf("envWithoutDesktopBus did not disable KWallet probing: %v", env) + } if !containsEnv(env, "LUMERA_TEST_KEEP=yes") { t.Fatalf("envWithoutDesktopBus dropped unrelated env var: %v", env) } @@ -58,6 +68,12 @@ func TestEnvWithoutDesktopBusStripsDesktopSession(t *testing.T) { if containsEnv(env, "GOMAXPROCS=99") { t.Fatalf("envWithoutDesktopBus kept parent GOMAXPROCS: %v", env) } + if !containsEnv(env, "LUMERA_KEYRING_BACKEND=test") { + t.Fatalf("envWithoutDesktopBus did not force test keyring backend: %v", env) + } + if containsEnv(env, "LUMERA_KEYRING_BACKEND=secret-service") { + t.Fatalf("envWithoutDesktopBus kept parent keyring backend: %v", env) + } } func containsEnv(env []string, want string) bool { diff --git a/devnet/tests/gen-activity/env_test.go b/devnet/tests/gen-activity/env_test.go new file mode 100644 index 00000000..e1b453f1 --- /dev/null +++ b/devnet/tests/gen-activity/env_test.go @@ -0,0 +1,46 @@ +package main + +import "testing" + +func TestEnvWithoutDesktopBusForcesTestKeyringBackend(t *testing.T) { + t.Setenv("DBUS_SESSION_BUS_ADDRESS", "unix:path=/run/user/1000/bus") + t.Setenv("DISPLAY", ":1") + t.Setenv("WAYLAND_DISPLAY", "wayland-1") + t.Setenv("XDG_RUNTIME_DIR", "/run/user/1000") + t.Setenv("LUMERA_KEYRING_BACKEND", "secret-service") + t.Setenv("LUMERA_TEST_KEEP", "yes") + + env := envWithoutDesktopBus() + for _, kv := range env { + if kv == "DBUS_SESSION_BUS_ADDRESS=unix:path=/tmp/lumera-no-dbus-session-bus" { + continue + } + if isDesktopSessionEnv(kv) { + t.Fatalf("envWithoutDesktopBus kept desktop session env: %q", kv) + } + } + if !containsEnvValue(env, "DBUS_SESSION_BUS_ADDRESS=unix:path=/tmp/lumera-no-dbus-session-bus") { + t.Fatalf("envWithoutDesktopBus did not disable D-Bus autolaunch: %v", env) + } + if !containsEnvValue(env, "DISABLE_KWALLET=1") { + t.Fatalf("envWithoutDesktopBus did not disable KWallet probing: %v", env) + } + if !containsEnvValue(env, "LUMERA_TEST_KEEP=yes") { + t.Fatalf("envWithoutDesktopBus dropped unrelated env var: %v", env) + } + if !containsEnvValue(env, "LUMERA_KEYRING_BACKEND=test") { + t.Fatalf("envWithoutDesktopBus did not force test keyring backend: %v", env) + } + if containsEnvValue(env, "LUMERA_KEYRING_BACKEND=secret-service") { + t.Fatalf("envWithoutDesktopBus kept parent keyring backend: %v", env) + } +} + +func containsEnvValue(env []string, want string) bool { + for _, kv := range env { + if kv == want { + return true + } + } + return false +} diff --git a/devnet/tests/gen-activity/main.go b/devnet/tests/gen-activity/main.go index e1c081dd..d9f715c2 100644 --- a/devnet/tests/gen-activity/main.go +++ b/devnet/tests/gen-activity/main.go @@ -515,9 +515,19 @@ func envWithoutDesktopBus() []string { if isDesktopSessionEnv(kv) { continue } + if strings.HasPrefix(kv, "LUMERA_KEYRING_BACKEND=") { + continue + } + if strings.HasPrefix(kv, "DISABLE_KWALLET=") { + continue + } out = append(out, kv) } - return out + return append(out, + "DBUS_SESSION_BUS_ADDRESS=unix:path=/tmp/lumera-no-dbus-session-bus", + "DISABLE_KWALLET=1", + "LUMERA_KEYRING_BACKEND=test", + ) } func isDesktopSessionEnv(kv string) bool { From 606e780ede5635b27ba8a382a8d1eaf29c851cb5 Mon Sep 17 00:00:00 2001 From: Andrey Kobrin Date: Tue, 7 Jul 2026 20:06:02 -0400 Subject: [PATCH 6/7] Fix migration CLI recovery and tx waits --- devnet/tests/common/chaincli.go | 2 +- devnet/tests/common/migration.go | 10 ++++ devnet/tests/common/migration_keys.go | 18 +++++- devnet/tests/common/migration_keys_test.go | 68 ++++++++++++++++++++++ devnet/tests/common/migration_test.go | 36 ++++++++++++ scripts/evmigration-common.sh | 9 ++- scripts/migrate-batch.sh | 2 +- 7 files changed, 140 insertions(+), 5 deletions(-) diff --git a/devnet/tests/common/chaincli.go b/devnet/tests/common/chaincli.go index d486326d..aaf74eef 100644 --- a/devnet/tests/common/chaincli.go +++ b/devnet/tests/common/chaincli.go @@ -373,7 +373,7 @@ func (c *ChainCLI) ImportKey(name, mnemonic string) (string, error) { if err != nil { return "", fmt.Errorf("keys add --recover %s: %s: %w", name, strings.TrimSpace(string(out)), err) } - return c.ShowAddress(name) + return parseRecoveredKeyAddress(name, out) } // HasKey reports whether a key name already exists in the keyring. diff --git a/devnet/tests/common/migration.go b/devnet/tests/common/migration.go index 50ddae47..2ebdce89 100644 --- a/devnet/tests/common/migration.go +++ b/devnet/tests/common/migration.go @@ -93,11 +93,21 @@ func parseMigrationStats(out string) (MigrationStats, error) { func (c *ChainCLI) MigrationParams() (MigrationParams, error) { out, err := c.Run("query", "evmigration", "params") if err != nil { + if missingEVMigrationQuery(out) { + return MigrationParams{}, fmt.Errorf("configured lumerad binary %q does not support x/evmigration queries; use an EVM-enabled lumerad binary for migration mode: %s: %w", c.Bin, truncate(out, 200), err) + } return MigrationParams{}, fmt.Errorf("query evmigration params: %s: %w", truncate(out, 200), err) } return parseMigrationParams(out) } +func missingEVMigrationQuery(out string) bool { + low := strings.ToLower(out) + return strings.Contains(low, "available commands") && + (strings.Contains(low, "usage:\n lumerad query") || strings.Contains(low, "usage:\r\n lumerad query")) && + !strings.Contains(low, "evmigration") +} + // MigrationEstimate queries the migration estimate for a legacy address. func (c *ChainCLI) MigrationEstimate(addr string) (MigrationEstimate, error) { out, err := c.Run("query", "evmigration", "migration-estimate", addr) diff --git a/devnet/tests/common/migration_keys.go b/devnet/tests/common/migration_keys.go index 7fa91183..24fa618c 100644 --- a/devnet/tests/common/migration_keys.go +++ b/devnet/tests/common/migration_keys.go @@ -2,6 +2,7 @@ package common import ( "context" + "encoding/json" "fmt" "strconv" "strings" @@ -43,7 +44,22 @@ func (c *ChainCLI) ImportKeyWithStyle(name, mnemonic string, style KeyStyle) (st if err != nil { return "", fmt.Errorf("keys add --recover %s: %s: %w", name, strings.TrimSpace(string(out)), err) } - return c.ShowAddress(name) + return parseRecoveredKeyAddress(name, out) +} + +func parseRecoveredKeyAddress(name string, out []byte) (string, error) { + payload, ok := ExtractJSONPayload(string(out)) + if !ok { + return "", fmt.Errorf("keys add --recover %s: no JSON in output: %s", name, truncate(string(out), 200)) + } + var gk GeneratedKey + if err := json.Unmarshal([]byte(payload), &gk); err != nil { + return "", fmt.Errorf("parse keys add --recover %s output: %w", name, err) + } + if gk.Address == "" { + return "", fmt.Errorf("keys add --recover %s: missing address in output", name) + } + return gk.Address, nil } // claimLegacyAccountArgs builds the `tx evmigration claim-legacy-account` command diff --git a/devnet/tests/common/migration_keys_test.go b/devnet/tests/common/migration_keys_test.go index 766876e7..6486dc0f 100644 --- a/devnet/tests/common/migration_keys_test.go +++ b/devnet/tests/common/migration_keys_test.go @@ -1,8 +1,12 @@ package common import ( + "os" + "path/filepath" "strings" + "sync" "testing" + "time" ) func TestRecoverKeyArgsEVMStyle(t *testing.T) { @@ -52,3 +56,67 @@ func TestRecoverKeyArgsLegacyStyleWithHome(t *testing.T) { } } } + +func TestImportKeyWithStyleDoesNotReenterCommandSlot(t *testing.T) { + t.Setenv("LUMERA_CLI_PARALLELISM", "1") + resetCommandSlotsForTest() + t.Cleanup(resetCommandSlotsForTest) + + dir := t.TempDir() + argsPath := filepath.Join(dir, "args.txt") + bin := filepath.Join(dir, "lumerad") + script := "#!/bin/sh\n" + + "printf '%s\\n' \"$@\" >> " + shellQuote(argsPath) + "\n" + + "if [ \"$1\" = keys ] && [ \"$2\" = add ]; then\n" + + " cat >/dev/null\n" + + " printf '%s\\n' '{\"name\":\"alice\",\"address\":\"lumera1recovered\",\"pubkey\":\"pub\"}'\n" + + " exit 0\n" + + "fi\n" + + "if [ \"$1\" = keys ] && [ \"$2\" = show ]; then\n" + + " printf '%s\\n' 'unexpected nested keys show' >&2\n" + + " exit 2\n" + + "fi\n" + + "exit 1\n" + if err := os.WriteFile(bin, []byte(script), 0o755); err != nil { + t.Fatalf("write fake lumerad: %v", err) + } + + var ( + addr string + err error + ) + done := make(chan struct{}) + go func() { + defer close(done) + addr, err = (&ChainCLI{Bin: bin, KeyringBackend: "test"}).ImportKeyWithStyle("alice", "seed words", KeyStyleEVM) + }() + + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("ImportKeyWithStyle hung; likely re-entered the one-slot CLI semaphore") + } + if err != nil { + t.Fatalf("ImportKeyWithStyle returned error: %v", err) + } + if addr != "lumera1recovered" { + t.Fatalf("address = %q, want lumera1recovered", addr) + } + if got := strings.TrimSpace(string(mustReadFile(t, argsPath))); strings.Contains(got, "keys\nshow") { + t.Fatalf("ImportKeyWithStyle should not call nested keys show while holding the command slot; args:\n%s", got) + } +} + +func resetCommandSlotsForTest() { + commandSlotState.once = sync.Once{} + commandSlotState.ch = nil +} + +func mustReadFile(t *testing.T, path string) []byte { + t.Helper() + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + return b +} diff --git a/devnet/tests/common/migration_test.go b/devnet/tests/common/migration_test.go index a6a4504a..c9c56c75 100644 --- a/devnet/tests/common/migration_test.go +++ b/devnet/tests/common/migration_test.go @@ -2,6 +2,8 @@ package common import ( "encoding/json" + "os" + "path/filepath" "strings" "testing" ) @@ -88,6 +90,40 @@ func TestParseMigrationParams(t *testing.T) { }) } +func TestMigrationParamsReportsUnsupportedBinary(t *testing.T) { + dir := t.TempDir() + bin := filepath.Join(dir, "lumerad") + script := `#!/bin/sh +cat <<'OUT' +Querying subcommands + +Usage: + lumerad query [flags] + lumerad query [command] + +Available Commands: + action Querying commands for the action module + audit Audit query commands +OUT +exit 1 +` + if err := os.WriteFile(bin, []byte(script), 0o755); err != nil { + t.Fatalf("write fake lumerad: %v", err) + } + + _, err := (&ChainCLI{Bin: bin, ChainID: "lumera-devnet-1", RPC: "tcp://localhost:26657"}).MigrationParams() + if err == nil { + t.Fatal("expected unsupported evmigration binary error") + } + msg := err.Error() + if !strings.Contains(msg, "does not support x/evmigration queries") { + t.Fatalf("error %q missing unsupported evmigration context", msg) + } + if !strings.Contains(msg, bin) { + t.Fatalf("error %q missing configured binary path %q", msg, bin) + } +} + func TestParseMigrationStats(t *testing.T) { out := `{"total_migrated": "14", "total_legacy": "104", "total_legacy_staked": "30", "total_validators_migrated": "3", "total_validators_legacy": "2"}` s, err := parseMigrationStats(out) diff --git a/scripts/evmigration-common.sh b/scripts/evmigration-common.sh index ea9aa03e..7702d633 100755 --- a/scripts/evmigration-common.sh +++ b/scripts/evmigration-common.sh @@ -736,6 +736,11 @@ _handle_tx_query_json() { return 2 } +_query_tx_no_keyring() { + local hash="$1" + "$BIN" query tx "$hash" --node "$NODE" --output json +} + # wait_for_tx # Waits for the tx to commit using three paths in order: # 1. Fast path — `query tx `. Catches the case where the tx was @@ -770,7 +775,7 @@ wait_for_tx() { # NOTE: `lumerad q tx ` exits 0 with empty stdout (error goes to # stderr). So a "found" check is "stdout is non-empty AND parseable JSON", # not just "exit 0". Empty json → both nested ifs are false → fall through. - if json=$(lumerad_q tx "$hash" 2>/dev/null) && [[ -n "$json" ]]; then + if json=$(_query_tx_no_keyring "$hash" 2>/dev/null) && [[ -n "$json" ]]; then if _handle_tx_query_json "$hash" "$json" "$started"; then return 0 else @@ -801,7 +806,7 @@ wait_for_tx() { next_progress=10 while (( SECONDS - started < timeout )); do sleep 1 - if json=$(lumerad_q tx "$hash" 2>/dev/null) && [[ -n "$json" ]]; then + if json=$(_query_tx_no_keyring "$hash" 2>/dev/null) && [[ -n "$json" ]]; then if _handle_tx_query_json "$hash" "$json" "$started"; then return 0 else diff --git a/scripts/migrate-batch.sh b/scripts/migrate-batch.sh index 0958e430..e665c66c 100755 --- a/scripts/migrate-batch.sh +++ b/scripts/migrate-batch.sh @@ -284,7 +284,7 @@ _mb_classify_target() { # Walk the object for the first pub_key/pubkey @type we find. Matches the # priority-order traversal in auth_pubkey_type, without its hard-exit. pk_type_str=$(jq -r ' - [.. | objects | (.pub_key // .pubkey) | objects | .["@type"] // empty] + [.. | objects | (.pub_key // .pubkey // .public_key) | objects | (.["@type"] // .type // empty)] | map(select(. != "")) | first // ""' <<<"$auth_json" 2>/dev/null || echo "") fi From 713c4bab7687cf064141e1afa1c0144c205e9774 Mon Sep 17 00:00:00 2001 From: Andrey Kobrin Date: Wed, 8 Jul 2026 12:25:49 -0400 Subject: [PATCH 7/7] Fix release tarball version naming --- Makefile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 0f1fe46a..b0620313 100644 --- a/Makefile +++ b/Makefile @@ -42,6 +42,7 @@ COMMA := , BUILD_TAGS_VERSION := $(subst $(SPACE),$(COMMA),$(strip $(BUILD_TAGS))) GIT_HEAD_HASH ?= $(strip $(shell git rev-parse HEAD 2>/dev/null)) VERSION_TAG ?= $(strip $(shell tag_ref=$$(git for-each-ref --merged HEAD --sort=-creatordate --format='%(refname:strip=2)' refs/tags | head -n1); if [ -z "$$tag_ref" ]; then printf ''; else tag_name=$${tag_ref#v}; tag_commit=$$(git rev-list -n1 "$$tag_ref" 2>/dev/null); head_commit=$$(git rev-parse HEAD 2>/dev/null); if [ "$$tag_commit" = "$$head_commit" ]; then printf '%s' "$$tag_name"; else printf '%s-%s' "$$tag_name" "$$(git rev-parse --short=8 HEAD 2>/dev/null)"; fi; fi)) +RELEASE_VERSION_TAG ?= $(strip $(if $(VERSION_TAG),$(if $(filter v%,$(VERSION_TAG)),$(VERSION_TAG),v$(VERSION_TAG)))) BUILD_LDFLAGS = \ -X github.com/cosmos/cosmos-sdk/version.Name=$(APP_TITLE) \ -X github.com/cosmos/cosmos-sdk/version.AppName=$(APP_NAME)d \ @@ -183,7 +184,7 @@ release: go.sum build-proto openrpc mkdir -p $$outdir/scripts; \ cp scripts/evmigration-common.sh scripts/migrate-account.sh scripts/migrate-validator.sh scripts/migrate-multisig.sh $$outdir/scripts/; \ chmod +x $$outdir/scripts/migrate-account.sh $$outdir/scripts/migrate-validator.sh $$outdir/scripts/migrate-multisig.sh; \ - tar -C $$outdir -czf ${RELEASE_DIR}/${APP_NAME}_$${goos}_$${goarch}.tar.gz ${APP_BINARY} scripts; \ + tar -C $$outdir -czf ${RELEASE_DIR}/${APP_NAME}$(if $(RELEASE_VERSION_TAG),_${RELEASE_VERSION_TAG})_$${goos}_$${goarch}.tar.gz ${APP_BINARY} scripts; \ rm -rf $$outdir; \ done @(cd ${RELEASE_DIR} && sha256sum *.tar.gz > release_checksum)