diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index e18520c..28ee5ee 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -40,6 +40,8 @@ jobs: - name: Run cargo fmt run: cargo fmt --all -- --check - uses: stellar/stellar-cli@v26.0.0 + - name: Install stellar-scaffold (just build uses `stellar scaffold build`) + run: cargo binstall -y stellar-scaffold-cli@0.0.24 - name: build since clippy needs contracts to be built run: just build - name: Run cargo clippy diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 13f6208..32d24df 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -30,7 +30,11 @@ jobs: - run: rustup update - run: rustup target add wasm32v1-none - uses: taiki-e/install-action@just + - uses: taiki-e/install-action@nextest + - uses: cargo-bins/cargo-binstall@main - uses: stellar/stellar-cli@v26.0.0 + - name: Install stellar-scaffold (just build uses `stellar scaffold build`) + run: cargo binstall -y stellar-scaffold-cli@0.0.24 - uses: mozilla-actions/sccache-action@v0.0.10 # `just test` builds the contracts (so the test wasms exist for # `contractimport!`) and then runs the tests, which execute in the diff --git a/.gitignore b/.gitignore index 454e38c..61e79a8 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,6 @@ test_snapshots/ .vscode/ .idea/ .DS_Store + +# Per-run state from e2e-real-tansu-testnet.sh setup +contracts/registry-tansu-manager/e2e-real-tansu-state-*.env diff --git a/Cargo.lock b/Cargo.lock index 41178ac..27ae80e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1253,6 +1253,16 @@ dependencies = [ "stellar-xdr 26.0.0", ] +[[package]] +name = "registry-tansu-manager" +version = "0.1.0" +dependencies = [ + "soroban-sdk", + "soroban-sdk-tools", + "stellar-registry", + "tansu-stub", +] + [[package]] name = "rfc6979" version = "0.4.0" @@ -1878,6 +1888,13 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tansu-stub" +version = "0.0.0" +dependencies = [ + "soroban-sdk", +] + [[package]] name = "thiserror" version = "1.0.69" diff --git a/Cargo.toml b/Cargo.toml index 7e25e5a..a7ae52a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ resolver = "2" members = [ "contracts/registry", + "contracts/registry-tansu-manager", "contracts/test/*", ] diff --git a/contracts/registry-tansu-manager/Cargo.toml b/contracts/registry-tansu-manager/Cargo.toml new file mode 100644 index 0000000..e4e046c --- /dev/null +++ b/contracts/registry-tansu-manager/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "registry-tansu-manager" +description = "Tansu-DAO-gated manager for the stellar registry." +version = "0.1.0" +license = "Apache-2.0" +edition = "2021" +publish = false +repository.workspace = true + +[lib] +crate-type = ["cdylib"] +doctest = false + +[dependencies] +soroban-sdk = { workspace = true } +soroban-sdk-tools = "0.1.2" +stellar-registry = { workspace = true } +# Build-order signal so `stellar scaffold build` produces `tansu_stub.wasm` +# before this crate compiles `import_contract_client!(tansu_stub)`. The stub +# is the single source of truth for the Tansu proposal types — pattern mirrors +# `contracts/registry`'s dev-dep on `hello_world`. +tansu-stub = { path = "../test/tansu-stub" } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } +soroban-sdk-tools = { version = "0.1.2", features = ["testutils"] } + +[package.metadata.stellar] +cargo_inherit = true diff --git a/contracts/registry-tansu-manager/e2e-fast-tansu-testnet.sh b/contracts/registry-tansu-manager/e2e-fast-tansu-testnet.sh new file mode 100755 index 0000000..3a10594 --- /dev/null +++ b/contracts/registry-tansu-manager/e2e-fast-tansu-testnet.sh @@ -0,0 +1,270 @@ +#!/usr/bin/env bash +# Fast e2e against a custom-built Tansu that exposes per-project +# `min_voting_period` AND `execute_delay` on `register(...)`. Lets us exercise +# the same flow as e2e-real-tansu-testnet.sh in ~2 minutes instead of 48+ hours +# (stock Tansu has 24h MIN_VOTING_PERIOD + 24h TIMELOCK_DELAY). +# +# Custom Tansu (testnet): CDK7JBIIP6E75HOYLGRGWAHQLT6JUNUXQ7GNOYS3NAP26GISUXJ26UON +# https://stellar.expert/explorer/testnet/contract/CDK7JBIIP6E75HOYLGRGWAHQLT6JUNUXQ7GNOYS3NAP26GISUXJ26UON +# +# Flow (single phase, single `manager.trigger` tx for the publish): +# 1. Register a fresh Tansu project with min_voting_period=$MIN_VOTING_PERIOD seconds +# 2. Add maintainer + voter as Tansu members +# 3. Upload registry.wasm (payload), deploy registry, deploy manager, set_manager +# 4. Create proposal whose outcome is `registry.publish_hash(...)` +# targeting the registry directly. +# 5. Vote Approve from the second account +# 6. Sleep until past voting_ends_at + execute_delay +# 7. Call `manager.trigger(proposal_id, maintainer)`. The manager reads the +# proposal under its configured `(project_key, proposal_id)`, takes the +# single approved-branch outcome, and pre-authorizes that exact +# sub-call via `env.authorize_as_current_contract`. It then calls +# `Tansu.execute(maintainer, project_key, proposal_id, _, _)` which +# tallies votes, flips the proposal to Approved, and auto-invokes the +# outcome — the pre-authorization satisfies the registry's +# `manager.require_auth()` so publish_hash runs in the same tx. +# 8. Assert registry.fetch_hash returns the wasm hash we uploaded +# 9. Replay guard via second manager.trigger (Tansu rejects ProposalActive +# on the already-executed proposal, which trigger propagates) +# +# Env (all optional): +# NETWORK Stellar network alias (default: testnet) +# TANSU_ID Tansu contract id (default: custom-built testnet Tansu above) +# MIN_VOTING_PERIOD Seconds. Default 60. Must be ≥ ~30s to leave room for tx propagation. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +WASM_DIR="$REPO_ROOT/target/stellar/local" + +NETWORK="${NETWORK:-testnet}" +TANSU_ID="${TANSU_ID:-CDK7JBIIP6E75HOYLGRGWAHQLT6JUNUXQ7GNOYS3NAP26GISUXJ26UON}" +MIN_VOTING_PERIOD="${MIN_VOTING_PERIOD:-60}" +# Seconds between voting_ends_at and when Tansu.execute is callable. The custom +# Tansu rejects 0 (InvalidVotingPeriod / #212) — any positive value is fine. +EXECUTE_DELAY="${EXECUTE_DELAY:-60}" +PAYLOAD_VERSION="${PAYLOAD_VERSION:-0.1.0}" +RUN_ID="${RUN_ID:-$(date +%s)}" + +# Payload published to the registry is the registry wasm itself. +REGISTRY_WASM="$WASM_DIR/registry.wasm" +PAYLOAD_WASM="$REGISTRY_WASM" +MANAGER_WASM="$WASM_DIR/registry_tansu_manager.wasm" + +for w in "$REGISTRY_WASM" "$MANAGER_WASM"; do + [[ -f "$w" ]] || { echo "❌ missing $w — run \`just build\` first" >&2; exit 1; } +done + +if ! stellar network ls 2>/dev/null | grep -qx "$NETWORK"; then + echo "❌ stellar network '$NETWORK' is not configured" >&2; exit 1 +fi + +ensure_account() { + local id="$1" + if ! stellar keys ls 2>/dev/null | grep -qx "$id"; then + echo "==> Generating + funding $id on $NETWORK" + stellar keys generate --network "$NETWORK" --fund "$id" >/dev/null + fi +} + +invoke() { stellar contract invoke --network "$NETWORK" "$@"; } + +MAINTAINER_ID="${MAINTAINER_ID:-tansu-fast-${RUN_ID}}" +VOTER_ID="${VOTER_ID:-tansu-fast-voter-${RUN_ID}}" +ensure_account "$MAINTAINER_ID" +ensure_account "$VOTER_ID" +MAINTAINER_ADDR=$(stellar keys address "$MAINTAINER_ID") +VOTER_ADDR=$(stellar keys address "$VOTER_ID") + +# Tansu name validation (SorobanDomain): ≤15 chars, lowercase [a-z] only — no +# digits or hyphens. Readable prefix + a random lowercase tag for uniqueness. +# (`|| true` swallows the SIGPIPE `head` raises on `tr` under `set -o pipefail`.) +rand=$(LC_ALL=C tr -dc 'a-z' Network: $NETWORK" +echo "==> Tansu (custom): $TANSU_ID" +echo "==> Run id: $RUN_ID" +echo "==> Maintainer: $MAINTAINER_ID ($MAINTAINER_ADDR)" +echo "==> Voter: $VOTER_ID ($VOTER_ADDR)" +echo "==> min_voting_period: ${MIN_VOTING_PERIOD}s" +echo "==> execute_delay: ${EXECUTE_DELAY}s" + +# 1. Register project with short min_voting_period + execute_delay. +echo "==> Registering project '$PROJECT_NAME'" +PROJECT_KEY_RAW=$(invoke --id "$TANSU_ID" --source "$MAINTAINER_ID" --send=yes \ + -- register \ + --maintainer "$MAINTAINER_ADDR" \ + --name "$PROJECT_NAME" \ + --maintainers "[\"$MAINTAINER_ADDR\"]" \ + --url "https://example.invalid/${PROJECT_NAME}" \ + --ipfs "QmExampleIpfs0000000000000000000000000000000000" \ + --min_voting_period "$MIN_VOTING_PERIOD" \ + --execute_delay "$EXECUTE_DELAY") +PROJECT_KEY="${PROJECT_KEY_RAW//\"/}" +echo " project_key: $PROJECT_KEY" + +# Sanity-check: confirm both per-project knobs took. +ACTUAL_MVP=$(invoke --id "$TANSU_ID" --source "$MAINTAINER_ID" \ + -- get_min_voting_period --project_key "$PROJECT_KEY") +ACTUAL_EXD=$(invoke --id "$TANSU_ID" --source "$MAINTAINER_ID" \ + -- get_execute_delay --project_key "$PROJECT_KEY") +echo " confirmed: min_voting_period=${ACTUAL_MVP}s execute_delay=${ACTUAL_EXD}s" + +# 2. Members. Already-existing members from a prior run will trip MemberAlreadyExist; +# ignore that case so this script can be re-run with sticky $MAINTAINER_ID/$VOTER_ID. +echo "==> Adding maintainer + voter as members" +for who in "$MAINTAINER_ID:$MAINTAINER_ADDR:maintainer" "$VOTER_ID:$VOTER_ADDR:voter"; do + IFS=: read -r src addr role <<<"$who" + out=$(invoke --id "$TANSU_ID" --source "$src" --send=yes \ + -- add_member --member_address "$addr" --meta "tansu-fast $role" 2>&1 || true) + if grep -q "MemberAlreadyExist\|#205" <<<"$out"; then + echo " $role $addr: already a member (ok)" + elif grep -q "✅ Transaction submitted successfully" <<<"$out"; then + echo " $role $addr: added" + else + echo "$out" >&2 + echo "❌ add_member failed for $role" >&2 + exit 1 + fi +done + +# 3. Upload registry.wasm (payload). +echo "==> Uploading registry.wasm (payload)" +PAYLOAD_HASH=$(stellar contract upload --wasm "$PAYLOAD_WASM" \ + --source "$MAINTAINER_ID" --network "$NETWORK") +echo " hash: $PAYLOAD_HASH" + +# 4. Deploy registry (admin=manager=$MAINTAINER initially) and the manager contract. +echo "==> Deploying registry" +REGISTRY_ID=$(stellar contract deploy --wasm "$REGISTRY_WASM" \ + --source "$MAINTAINER_ID" --network "$NETWORK" \ + --alias "registry-tansu-fast-${RUN_ID}" \ + -- --admin "$MAINTAINER_ADDR" --manager "\"$MAINTAINER_ADDR\"") +echo " registry: $REGISTRY_ID" + +echo "==> Deploying registry-tansu-manager" +MANAGER_ID=$(stellar contract deploy --wasm "$MANAGER_WASM" \ + --source "$MAINTAINER_ID" --network "$NETWORK" \ + --alias "manager-tansu-fast-${RUN_ID}" \ + -- \ + --tansu "$TANSU_ID" \ + --project_key "$PROJECT_KEY" \ + --registry "$REGISTRY_ID") +echo " manager: $MANAGER_ID" + +echo "==> Installing manager contract on registry" +invoke --id "$REGISTRY_ID" --source "$MAINTAINER_ID" --send=yes \ + -- set_manager --new_manager "$MANAGER_ID" >/dev/null + +# 4b. Hand Tansu maintainership over to the manager. After this, the manager +# is the sole project maintainer — so when `trigger` calls Tansu.execute, +# Tansu's `maintainer.require_auth` is satisfied by contract-implicit +# auth (manager is the direct caller) and the recorder doesn't need to +# synthesize a non-root auth entry. +echo "==> Tansu.update_config — replace maintainers with [$MANAGER_ID]" +invoke --id "$TANSU_ID" --source "$MAINTAINER_ID" --send=yes \ + -- update_config \ + --maintainer "$MAINTAINER_ADDR" \ + --key "$PROJECT_KEY" \ + --maintainers "[\"$MANAGER_ID\"]" \ + --url "https://example.invalid/${PROJECT_NAME}" \ + --ipfs "QmExampleIpfs0000000000000000000000000000000000" >/dev/null + +# 5. Create proposal whose outcome targets registry.publish_hash directly. +# The manager pre-authorizes this specific outcome via +# `authorize_as_current_contract` inside `trigger`, then drives +# Tansu.execute itself — single tx, no non-root auth gymnastics. +NOW=$(date +%s) +VOTING_ENDS_AT=$((NOW + MIN_VOTING_PERIOD + 15)) +echo "==> Creating proposal (voting_ends_at=$VOTING_ENDS_AT, in ~$((VOTING_ENDS_AT-NOW))s)" +OUTCOME=$(cat < Voting Approve as voter" +VOTE_PAYLOAD=$(cat </dev/null + +# 7. Wait until voting_ends_at + execute_delay + a slack for ledger time lag. +WAIT_UNTIL=$((VOTING_ENDS_AT + EXECUTE_DELAY + 20)) +while (( $(date +%s) < WAIT_UNTIL )); do + remain=$((WAIT_UNTIL - $(date +%s))) + printf "\r==> Waiting for voting period + execute_delay (%ds remaining)... " "$remain" + sleep 5 +done +echo "" + +# 8. manager.trigger drives Tansu.execute + the publish in one tx. +echo "==> manager.trigger -> Tansu.execute -> registry.publish_hash (single tx)" +invoke --id "$MANAGER_ID" --source "$MAINTAINER_ID" --send=yes \ + -- trigger --proposal_id "$PROPOSAL_ID" >/dev/null + +# 9. Verify the publish landed. +echo "==> Verifying registry has registry@$PAYLOAD_VERSION -> $PAYLOAD_HASH" +PUBLISHED_HASH_RAW=$(invoke --id "$REGISTRY_ID" --source "$MAINTAINER_ID" \ + -- fetch_hash --wasm_name registry --version "\"$PAYLOAD_VERSION\"") +PUBLISHED_HASH="${PUBLISHED_HASH_RAW//\"/}" +if [[ "$PUBLISHED_HASH" == "$PAYLOAD_HASH" ]]; then + echo " ✓ registry resolved registry@$PAYLOAD_VERSION -> $PUBLISHED_HASH" +else + echo " ❌ registry returned $PUBLISHED_HASH, expected $PAYLOAD_HASH" >&2 + exit 1 +fi + +# 10. Replay guard — second manager.trigger calls into Tansu.execute again, +# which panics with ProposalActive (#402) because the proposal is no +# longer Active. +echo "==> Replay check — second manager.trigger must fail (ProposalActive)" +REPLAY_OUT=$(invoke --id "$MANAGER_ID" --source "$MAINTAINER_ID" --send=yes \ + -- trigger --proposal_id "$PROPOSAL_ID" 2>&1 || true) +if grep -qE 'ProposalActive|Error\(Contract, ?#402\)' <<<"$REPLAY_OUT"; then + echo " ✓ replay rejected by Tansu" +else + echo " ❌ replay was NOT rejected" >&2 + echo "$REPLAY_OUT" >&2 + exit 1 +fi + +cat < Approved (via manager.trigger) + payload: $PAYLOAD_HASH @ $PAYLOAD_VERSION +EOF diff --git a/contracts/registry-tansu-manager/e2e-real-tansu-testnet.sh b/contracts/registry-tansu-manager/e2e-real-tansu-testnet.sh new file mode 100755 index 0000000..2dcc798 --- /dev/null +++ b/contracts/registry-tansu-manager/e2e-real-tansu-testnet.sh @@ -0,0 +1,352 @@ +#!/usr/bin/env bash +# End-to-end test of registry-tansu-manager against the **live** Tansu DAO on +# Stellar testnet — exercises the full vote-then-execute cycle, no stub. +# +# Two-phase, because Tansu enforces a 24-hour minimum voting period (hardcoded +# `MIN_VOTING_PERIOD = 24*3600` in contract_dao.rs, no env override): +# +# $ ./e2e-real-tansu-testnet.sh setup +# -> registers a fresh Tansu project, deploys registry + manager, +# uploads registry.wasm (payload), creates a publish_hash proposal on Tansu, +# votes yes, saves state to a sidecar file. +# -> prints the exact follow-up command + timestamp. +# +# $ ./e2e-real-tansu-testnet.sh finalize [state-file] # ≥ 24h later +# -> calls Tansu.execute (Active -> Approved), +# calls manager.execute (forwards to registry.publish_hash), +# verifies the published wasm hash on the registry. +# +# Live Tansu (testnet): +# CBXKUSLQPVF35FYURR5C42BPYA5UOVDXX2ELKIM2CAJMCI6HXG2BHGZA +# https://stellar.expert/explorer/testnet/contract/CBXKUSLQPVF35FYURR5C42BPYA5UOVDXX2ELKIM2CAJMCI6HXG2BHGZA +# Collateral token (testnet XLM via native SAC): +# CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC +# Proposal collateral: 7 XLM (PROPOSAL_COLLATERAL); voting also takes 2 XLM +# per voter (VOTE_COLLATERAL). Both refunded on Tansu.execute. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +WASM_DIR="$REPO_ROOT/target/stellar/local" + +NETWORK="${NETWORK:-testnet}" +TANSU_ID="${TANSU_ID:-CBXKUSLQPVF35FYURR5C42BPYA5UOVDXX2ELKIM2CAJMCI6HXG2BHGZA}" +# Payload published to the registry is the registry wasm itself. +REGISTRY_WASM="$WASM_DIR/registry.wasm" +PAYLOAD_WASM="$REGISTRY_WASM" +MANAGER_WASM="$WASM_DIR/registry_tansu_manager.wasm" + +usage() { + cat <&2 +Usage: $0 [state-file] + + setup Run phase 1: register Tansu project, deploy registry + manager, + create + vote on proposal. Writes state to: + $SCRIPT_DIR/e2e-real-tansu-state-.env + finalize Run phase 2 (after voting_ends_at): + $0 finalize [path-to-state-file] + If state-file is omitted, picks the most-recent state file in $SCRIPT_DIR. + +Env: + NETWORK Stellar network alias (default: testnet) + TANSU_ID Tansu contract id (default: live testnet Tansu) +EOF + exit 1 +} + +[[ $# -ge 1 ]] || usage +PHASE="$1"; shift || true + +require_network() { + if ! stellar network ls 2>/dev/null | grep -qx "$NETWORK"; then + echo "❌ stellar network '$NETWORK' is not configured" >&2; exit 1 + fi +} + +ensure_account() { + local id="$1" + if ! stellar keys ls 2>/dev/null | grep -qx "$id"; then + echo "==> Generating + funding $id on $NETWORK" + stellar keys generate --network "$NETWORK" --fund "$id" >/dev/null + fi +} + +invoke() { stellar contract invoke --network "$NETWORK" "$@"; } + +# --------------------------------------------------------------------------- +# Phase 1: setup +# --------------------------------------------------------------------------- +phase_setup() { + require_network + for w in "$REGISTRY_WASM" "$MANAGER_WASM"; do + [[ -f "$w" ]] || { echo "❌ missing $w — run \`just build\` first" >&2; exit 1; } + done + + RUN_ID="${RUN_ID:-$(date +%s)}" + STATE_FILE="$SCRIPT_DIR/e2e-real-tansu-state-${RUN_ID}.env" + PAYLOAD_VERSION="${PAYLOAD_VERSION:-0.1.0}" + # Tansu enforces project name ≤ 15 chars. The name is also registered on + # SorobanDomain under TLD .xlm, whose `validate_domain` requires bytes in + # `[a-z]` only — no digits, no hyphens, no uppercase. Use a readable prefix + # plus a random lowercase tag; the chosen name is persisted to the state + # file below, so `finalize` reuses it. + # (`|| true` swallows the SIGPIPE `head` raises on `tr` under `pipefail`.) + if [[ -z "${PROJECT_NAME:-}" ]]; then + rand=$(LC_ALL=C tr -dc 'a-z' Network: $NETWORK" + echo "==> Tansu: $TANSU_ID" + echo "==> Run id: $RUN_ID" + echo "==> Maintainer: $MAINTAINER_ID ($MAINTAINER_ADDR)" + echo "==> Voter: $VOTER_ID ($VOTER_ADDR)" + echo "==> State file: $STATE_FILE" + + # 1. Register a fresh project on Tansu. The function returns the project_key + # (Bytes — keccak256(name) inside Tansu). We capture it for the manager + # constructor and proposal-target lookup. + echo "==> Registering Tansu project '$PROJECT_NAME'" + PROJECT_KEY_RAW=$(invoke --id "$TANSU_ID" --source "$MAINTAINER_ID" \ + --send=yes -- register \ + --maintainer "$MAINTAINER_ADDR" \ + --name "$PROJECT_NAME" \ + --maintainers "[\"$MAINTAINER_ADDR\"]" \ + --url "https://example.invalid/${PROJECT_NAME}" \ + --ipfs "QmExampleIpfs0000000000000000000000000000000000") + # Strip quotes from the returned hex Bytes literal. + PROJECT_KEY="${PROJECT_KEY_RAW//\"/}" + echo " project_key: $PROJECT_KEY" + + # 2. Add both maintainer and voter as Tansu members. Tansu auto-adds the + # proposer to the Abstain group on `create_proposal`, so the maintainer + # (proposer) can't be the one casting an Approve — we need a second + # account whose default vote weight of 1 is enough to carry a single- + # voter Approve over the proposer's Abstain. + echo "==> Adding maintainer + voter as Tansu members" + invoke --id "$TANSU_ID" --source "$MAINTAINER_ID" --send=yes \ + -- add_member \ + --member_address "$MAINTAINER_ADDR" \ + --meta "tansu-e2e maintainer" >/dev/null + invoke --id "$TANSU_ID" --source "$VOTER_ID" --send=yes \ + -- add_member \ + --member_address "$VOTER_ADDR" \ + --meta "tansu-e2e voter" >/dev/null + + # 3. Upload registry.wasm (payload) to get the hash the proposal will register. + echo "==> Uploading registry.wasm (payload)" + PAYLOAD_HASH=$(stellar contract upload --wasm "$PAYLOAD_WASM" \ + --source "$MAINTAINER_ID" --network "$NETWORK") + echo " hash: $PAYLOAD_HASH" + + # 4. Deploy a fresh registry — admin & manager both set to the G account + # initially, so we can swap the manager to the manager contract before + # handing publishing power over to the DAO. + echo "==> Deploying registry (admin=manager=$MAINTAINER_ID)" + REGISTRY_ID=$(stellar contract deploy --wasm "$REGISTRY_WASM" \ + --source "$MAINTAINER_ID" --network "$NETWORK" \ + --alias "registry-tansu-e2e-${RUN_ID}" \ + -- --admin "$MAINTAINER_ADDR" --manager "\"$MAINTAINER_ADDR\"") + echo " registry: $REGISTRY_ID" + + # 5. Deploy registry-tansu-manager, pointing at LIVE Tansu + our registry. + echo "==> Deploying registry-tansu-manager" + MANAGER_ID=$(stellar contract deploy --wasm "$MANAGER_WASM" \ + --source "$MAINTAINER_ID" --network "$NETWORK" \ + --alias "manager-tansu-e2e-${RUN_ID}" \ + -- \ + --tansu "$TANSU_ID" \ + --project_key "$PROJECT_KEY" \ + --registry "$REGISTRY_ID") + echo " manager: $MANAGER_ID" + + # 6. Swap registry's manager to the manager contract. From here on, all + # manager-gated registry ops MUST come through Tansu proposals. + echo "==> Installing manager contract on registry" + invoke --id "$REGISTRY_ID" --source "$MAINTAINER_ID" --send=yes \ + -- set_manager --new_manager "$MANAGER_ID" >/dev/null + + # 6b. Hand Tansu maintainership over to the manager. After this, when the + # finalize phase calls `manager.trigger(proposal_id)`, the manager is + # the direct caller of Tansu.execute, so Tansu's internal + # `maintainer.require_auth` is satisfied by contract-implicit auth + # (no auth entry needed, no non-root recording issue). + echo "==> Tansu.update_config — replace maintainers with [$MANAGER_ID]" + invoke --id "$TANSU_ID" --source "$MAINTAINER_ID" --send=yes \ + -- update_config \ + --maintainer "$MAINTAINER_ADDR" \ + --key "$PROJECT_KEY" \ + --maintainers "[\"$MANAGER_ID\"]" \ + --url "https://example.invalid/${PROJECT_NAME}" \ + --ipfs "QmExampleIpfs0000000000000000000000000000000000" >/dev/null + + # 7. Build the proposal. Outcome targets registry.publish_hash directly — + # the manager pre-authorizes this specific call via + # `authorize_as_current_contract` inside `trigger` so the registry's + # `manager.require_auth` is satisfied. + NOW=$(date +%s) + VOTING_ENDS_AT=$((NOW + 24*3600 + 600)) # 24h + 10min cushion + PROPOSAL_TITLE="${PROPOSAL_TITLE:-Add registry@${PAYLOAD_VERSION} to registry}" + OUTCOME=$(cat < Creating Tansu proposal (voting_ends_at=$VOTING_ENDS_AT)" + PROPOSAL_ID_RAW=$(invoke --id "$TANSU_ID" --source "$MAINTAINER_ID" --send=yes \ + -- create_proposal \ + --proposer "$MAINTAINER_ADDR" \ + --project_key "$PROJECT_KEY" \ + --title "$PROPOSAL_TITLE" \ + --ipfs "QmExampleIpfs1111111111111111111111111111111111" \ + --voting_ends_at "$VOTING_ENDS_AT" \ + --public_voting true \ + --outcome_contracts "$OUTCOME") + PROPOSAL_ID="${PROPOSAL_ID_RAW//\"/}" + echo " proposal_id: $PROPOSAL_ID" + + # 8. Vote Approve as the second account. The proposer was auto-added to + # Abstain when `create_proposal` ran; this Approve out-votes that. + echo "==> Voting Approve as $VOTER_ID" + VOTE_PAYLOAD=$(cat </dev/null + + # 9. Save state for the finalize phase. + cat > "$STATE_FILE" </dev/null | head -n1 || true) + fi + [[ -n "$state_file" && -f "$state_file" ]] || { + echo "❌ no state file (looked in $SCRIPT_DIR/e2e-real-tansu-state-*.env)" >&2; exit 1 + } + # shellcheck source=/dev/null + source "$state_file" + echo "==> State file: $state_file" + echo "==> Run id: $RUN_ID" + + NOW=$(date +%s) + if (( NOW < VOTING_ENDS_AT )); then + echo "❌ Voting period hasn't ended yet. Earliest: $(date -u -d "@$VOTING_ENDS_AT" +%FT%TZ) (in $(( (VOTING_ENDS_AT - NOW) / 60 ))m)" >&2 + exit 1 + fi + + # 1. manager.trigger drives Tansu.execute + the publish in one tx. The + # manager (set as Tansu maintainer in setup step 6b) is the direct + # caller of Tansu.execute, satisfying Tansu's + # `maintainer.require_auth`. The manager pre-authorizes the registry + # publish via `authorize_as_current_contract`, satisfying the + # registry's `manager.require_auth`. Single tx. + echo "==> manager.trigger -> Tansu.execute -> registry.publish_hash (single tx)" + invoke --id "$MANAGER_ID" --source "$MAINTAINER_ID" --send=yes \ + -- trigger --proposal_id "$PROPOSAL_ID" >/dev/null + + # 2. Verify the registry now has registry@version pointing at our uploaded hash. + echo "==> Verifying registry has registry@$PAYLOAD_VERSION -> $PAYLOAD_HASH" + PUBLISHED_HASH_RAW=$(invoke --id "$REGISTRY_ID" --source "$MAINTAINER_ID" \ + -- fetch_hash --wasm_name registry --version "\"$PAYLOAD_VERSION\"") + PUBLISHED_HASH="${PUBLISHED_HASH_RAW//\"/}" + if [[ "$PUBLISHED_HASH" == "$PAYLOAD_HASH" ]]; then + echo " ✓ registry resolved registry@$PAYLOAD_VERSION -> $PUBLISHED_HASH" + else + echo " ❌ registry returned $PUBLISHED_HASH, expected $PAYLOAD_HASH" >&2 + exit 1 + fi + + # 3. Replay guard — Tansu's own ProposalActive check. + echo "==> Replay check — second manager.trigger must fail (ProposalActive)" + REPLAY_OUT=$(invoke --id "$MANAGER_ID" --source "$MAINTAINER_ID" --send=yes \ + -- trigger --proposal_id "$PROPOSAL_ID" 2>&1 || true) + if grep -qE 'ProposalActive|Error\(Contract, ?#402\)' <<<"$REPLAY_OUT"; then + echo " ✓ replay rejected by Tansu" + else + echo " ❌ replay was NOT rejected" >&2 + echo "$REPLAY_OUT" >&2 + exit 1 + fi + + cat < Approved (via manager.trigger) + payload: $PAYLOAD_HASH @ $PAYLOAD_VERSION +EOF +} + +case "$PHASE" in + setup) phase_setup "$@" ;; + finalize) phase_finalize "$@" ;; + *) usage ;; +esac diff --git a/contracts/registry-tansu-manager/e2e-testnet.sh b/contracts/registry-tansu-manager/e2e-testnet.sh new file mode 100755 index 0000000..5d3aa91 --- /dev/null +++ b/contracts/registry-tansu-manager/e2e-testnet.sh @@ -0,0 +1,211 @@ +#!/usr/bin/env bash +# End-to-end test of the registry-tansu-manager flow against testnet +# (or any configured stellar network via $NETWORK). +# +# The published + deployed payload is the registry contract's own wasm: the +# proposal deploys a *subregistry* (root = the root registry from step 1), which +# exercises the manager→registry deploy path against the registry's real +# 3-arg `__constructor(admin, manager, root)`. +# +# Flow: +# 1. Deploy a fresh root registry (admin as bootstrap manager). +# 2. Publish the registry wasm to that registry under the name `registry`. +# 3. Deploy a tansu-stub (stand-in for the Tansu DAO; implements +# `get_proposal` + a Tansu-like `execute` that auto-invokes the outcome). +# 4. Deploy the registry-tansu-manager, pointing at the stub + registry. +# 5. Admin installs the manager on the registry. +# 6. Plant an `Approved` deploy-proposal on the stub (deploys a subregistry). +# 7. Call manager.trigger(proposal_id). The manager reads the proposal, +# pre-authorizes the outcome (registry.deploy) via +# `env.authorize_as_current_contract`, then calls stub.execute — which +# auto-invokes the outcome. The registry's manager.require_auth() is +# satisfied by the pre-authorization, so the deploy lands in one tx. +# 8. Verify: the registry resolves the deployed subregistry and it responds +# to a read call (`manager()`). +# 9. Replay guard: second trigger(proposal_id) returns ProposalActive (#402). +# +# Usage: contracts/registry-tansu-manager/e2e-testnet.sh +# Env vars: +# NETWORK Stellar network alias (default: testnet; must be in `stellar network ls`). +# RUN_ID Suffix appended to ephemeral identities/aliases (default: epoch). +# PROPOSAL_ID Proposal id to use (default: 1). +# PAYLOAD_VERSION Version published for the registry payload (default: 0.1.0). +# CONTRACT_NAME Name the registry gives the deployed subregistry (default: subregistry-$RUN_ID). + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +WASM_DIR="$REPO_ROOT/target/stellar/local" + +NETWORK="${NETWORK:-testnet}" +RUN_ID="${RUN_ID:-$(date +%s)}" +PROPOSAL_ID="${PROPOSAL_ID:-1}" +PAYLOAD_VERSION="${PAYLOAD_VERSION:-0.1.0}" +CONTRACT_NAME="${CONTRACT_NAME:-subregistry-${RUN_ID}}" +# 32-byte arbitrary project_key, hex-encoded. Tansu uses keccak256(name); we +# just need a stable 32-byte value the manager can store and the stub can key on. +PROJECT_KEY="aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899" + +# The registry wasm is both the registry we stand up (step 1) and the payload +# the proposal publishes + deploys as a subregistry (steps 2, 7). +REGISTRY_WASM="$WASM_DIR/registry.wasm" +MANAGER_WASM="$WASM_DIR/registry_tansu_manager.wasm" +STUB_WASM="$WASM_DIR/tansu_stub.wasm" + +for w in "$REGISTRY_WASM" "$MANAGER_WASM" "$STUB_WASM"; do + if [ ! -f "$w" ]; then + echo "❌ missing $w — run \`just build\` first" >&2 + exit 1 + fi +done + +# Ensure the network alias exists locally. +if ! stellar network ls 2>/dev/null | grep -qx "$NETWORK"; then + echo "❌ stellar network '$NETWORK' is not configured; run \`stellar network add\` first" >&2 + exit 1 +fi + +ADMIN_ID="${ADMIN_ID:-e2e-admin-${RUN_ID}}" +AUTHOR_ID="${AUTHOR_ID:-e2e-author-${RUN_ID}}" +CALLER_ID="${CALLER_ID:-e2e-caller-${RUN_ID}}" + +ensure_account() { + local id="$1" + if ! stellar keys ls 2>/dev/null | grep -qx "$id"; then + echo "==> Generating + funding $id on $NETWORK" + stellar keys generate --network "$NETWORK" --fund "$id" >/dev/null + fi +} +ensure_account "$ADMIN_ID" +ensure_account "$AUTHOR_ID" +ensure_account "$CALLER_ID" + +ADMIN_ADDR=$(stellar keys address "$ADMIN_ID") +AUTHOR_ADDR=$(stellar keys address "$AUTHOR_ID") + +echo "==> Network: $NETWORK" +echo "==> Run id: $RUN_ID" +echo "==> Admin: $ADMIN_ID ($ADMIN_ADDR)" +echo "==> Author: $AUTHOR_ID ($AUTHOR_ADDR)" + +# 1. Registry — root registry requires a manager at construction; bootstrap +# with admin as the initial manager, then swap to the real manager contract +# in step 5. +echo "==> Deploying registry" +REGISTRY_ID=$(stellar contract deploy --wasm "$REGISTRY_WASM" \ + --source "$ADMIN_ID" --network "$NETWORK" \ + --alias "registry-e2e-${RUN_ID}" \ + -- --admin "$ADMIN_ADDR" --manager "\"$ADMIN_ADDR\"") +echo " registry: $REGISTRY_ID" + +# 2. Upload the registry wasm and have admin-as-manager publish it on the +# author's behalf under the name `registry`. With a manager set, the registry +# requires manager auth for the first publish under a given wasm name; the +# recorded author is still $AUTHOR_ADDR. +echo "==> Uploading registry.wasm (payload)" +PAYLOAD_HASH=$(stellar contract upload --wasm "$REGISTRY_WASM" \ + --source "$ADMIN_ID" --network "$NETWORK") +echo " hash: $PAYLOAD_HASH" + +echo "==> Publishing registry@$PAYLOAD_VERSION (author=$AUTHOR_ADDR, manager=$ADMIN_ID)" +stellar contract invoke --id "$REGISTRY_ID" \ + --source "$ADMIN_ID" --network "$NETWORK" \ + -- publish_hash \ + --wasm_name registry \ + --author "$AUTHOR_ADDR" \ + --wasm_hash "$PAYLOAD_HASH" \ + --version "$PAYLOAD_VERSION" + +# 3. Tansu stub. +echo "==> Deploying tansu-stub" +TANSU_ID=$(stellar contract deploy --wasm "$STUB_WASM" \ + --source "$ADMIN_ID" --network "$NETWORK" \ + --alias "tansu-stub-${RUN_ID}") +echo " stub: $TANSU_ID" + +# 4. Manager pointing at the stub + registry. +echo "==> Deploying registry-tansu-manager" +MANAGER_ID=$(stellar contract deploy --wasm "$MANAGER_WASM" \ + --source "$ADMIN_ID" --network "$NETWORK" \ + --alias "manager-e2e-${RUN_ID}" \ + -- \ + --tansu "$TANSU_ID" \ + --project_key "$PROJECT_KEY" \ + --registry "$REGISTRY_ID") +echo " manager: $MANAGER_ID" + +# 5. Install the manager on the registry. +echo "==> Installing manager on registry" +stellar contract invoke --id "$REGISTRY_ID" \ + --source "$ADMIN_ID" --network "$NETWORK" \ + -- set_manager --new_manager "$MANAGER_ID" + +# 6. Plant an Approved deploy-proposal on the stub. The outcome deploys a +# subregistry: init = registry __constructor(admin, manager=None, +# root=$REGISTRY_ID). `--manager` is omitted (None) so the deployed instance +# defers to $REGISTRY_ID as root rather than auto-deploying `unverified`. +echo "==> Planting Approved deploy-proposal #$PROPOSAL_ID for contract '$CONTRACT_NAME'" +stellar contract invoke --id "$TANSU_ID" \ + --source "$ADMIN_ID" --network "$NETWORK" \ + -- set_deploy_proposal \ + --project_key "$PROJECT_KEY" \ + --proposal_id "$PROPOSAL_ID" \ + --registry "$REGISTRY_ID" \ + --wasm_name "registry" \ + --version "\"$PAYLOAD_VERSION\"" \ + --contract_name "$CONTRACT_NAME" \ + --admin "$ADMIN_ADDR" \ + --root "$REGISTRY_ID" + +# 7. Drive the proposal via manager.trigger. The manager reads the proposal +# from the stub, pre-authorizes the single outcome (registry.deploy) via +# `env.authorize_as_current_contract`, then calls the stub's +# `execute(...)`. The stub mimics real Tansu: auto-invokes the outcome via +# XCC; the registry's `manager.require_auth()` is satisfied by the +# pre-authorization, so the deploy lands in the same tx. +echo "==> Driving proposal via manager.trigger" +stellar contract invoke --id "$MANAGER_ID" \ + --source "$CALLER_ID" --network "$NETWORK" \ + -- trigger --proposal_id "$PROPOSAL_ID" + +# 8. Verify the registry now resolves the deployed contract. +echo "==> Resolving deployed contract via registry" +DEPLOYED_RAW=$(stellar contract invoke --id "$REGISTRY_ID" \ + --source "$CALLER_ID" --network "$NETWORK" \ + -- fetch_contract_id --contract_name "$CONTRACT_NAME") +DEPLOYED="${DEPLOYED_RAW//\"/}" +echo " deployed: $DEPLOYED" + +# The deployed payload is a registry, not hello — prove it's live with a +# read-only `manager()` call (a subregistry deployed with manager=None returns +# null). +echo "==> Calling manager() on the deployed subregistry" +DEPLOYED_MANAGER=$(stellar contract invoke --id "$DEPLOYED" \ + --source "$CALLER_ID" --network "$NETWORK" \ + -- manager) +echo " manager() = $DEPLOYED_MANAGER" + +# 9. Replay guard — Tansu's own `if proposal.status != Active { panic }` +# (mirrored by the stub as `Error::ProposalActive = 402`). +echo "==> Re-triggering proposal — must fail with ProposalActive" +REPLAY_OUT=$(stellar contract invoke --id "$MANAGER_ID" \ + --source "$CALLER_ID" --network "$NETWORK" \ + -- trigger --proposal_id "$PROPOSAL_ID" 2>&1 || true) +if grep -qE 'ProposalActive|Error\(Contract, ?#402\)' <<<"$REPLAY_OUT"; then + echo " ✓ replay rejected" +else + echo " ❌ replay was NOT rejected" >&2 + echo "----- replay attempt output -----" >&2 + echo "$REPLAY_OUT" >&2 + exit 1 +fi + +cat <, + /// Tansu workspace key this manager represents. All Tansu lookups are + /// keyed by this — a wrong-project caller can't piggyback. + project_key: InstanceItem, + /// Registry this manager is the manager of. Recorded for inspection; + /// `trigger` doesn't read it directly because it uses whatever outcome + /// the (project_key-gated) proposal carries. + registry: InstanceItem
, +} + +#[contract] +pub struct RegistryTansuManager; + +#[contractimpl] +impl RegistryTansuManager { + pub fn __constructor(env: &Env, tansu: &Address, project_key: &Bytes, registry: &Address) { + Storage::set_tansu(env, tansu); + Storage::set_project_key(env, project_key); + Storage::set_registry(env, registry); + } + + pub fn tansu(env: &Env) -> Address { + Storage::get_tansu(env).unwrap() + } + + pub fn project_key(env: &Env) -> Bytes { + Storage::get_project_key(env).unwrap() + } + + pub fn registry(env: &Env) -> Address { + Storage::get_registry(env).unwrap() + } + + /// Drive a Tansu proposal through to outcome execution in one transaction. + /// + /// Flow: + /// + /// 1. Read the proposal from this manager's configured Tansu under this + /// manager's configured `project_key`. Wrong-project callers can't + /// construct a working invocation — `get_proposal` is keyed by + /// `(project_key, proposal_id)` Tansu-side, so any mismatched proposal + /// decodes to whatever lives at that key in *our* DAO or panics. + /// 2. Take the single approved-branch outcome (`outcome_contracts[0]`): + /// its `address`, `execute_fn`, and `args`. + /// 3. Pre-authorize **this contract's auth** for exactly that one + /// sub-call via `env.authorize_as_current_contract(...)`. Nothing + /// else gets authorized. The auth entry is scoped to one specific + /// `(contract, fn, args)` triple. + /// 4. Call `Tansu.execute(maintainer, project_key, proposal_id, _, _)`. + /// Tansu tallies the votes, sets the proposal to its terminal status, + /// and (on `Approved`) auto-invokes the outcome. When that outcome + /// reaches `manager.require_auth()`, the host matches it against the + /// pre-authorization from step 3 and lets the call run. + /// + /// For this to work the manager must be the Tansu project's maintainer + /// (set up at deploy time via `Tansu::register(..., maintainers=[manager])` + /// or `update_config`). That way the manager is the direct caller of + /// `Tansu::execute`, so Tansu's internal `maintainer.require_auth()` is + /// satisfied by contract-implicit auth — no auth entry needed for the + /// maintainer requirement, no non-root recording issue. + /// + /// Tansu's own `if proposal.status != Active` guard inside `execute` + /// prevents the same proposal being triggered twice — no separate + /// replay guard needed here. + pub fn trigger(env: &Env, proposal_id: u32) -> Result<(), Error> { + let tansu = Storage::get_tansu(env).unwrap(); + let project_key = Storage::get_project_key(env).unwrap(); + + let proposal = + tansu_stub::Client::new(env, &tansu).get_proposal(&project_key, &proposal_id); + let outcomes = proposal + .outcome_contracts + .ok_or(Error::NoOutcomeContracts)?; + if outcomes.len() != 1 { + return Err(Error::MultipleOutcomes); + } + let oc = outcomes.get(0).unwrap(); + + env.authorize_as_current_contract(vec![ + env, + InvokerContractAuthEntry::Contract(SubContractInvocation { + context: ContractContext { + contract: oc.address.clone(), + fn_name: oc.execute_fn.clone(), + args: oc.args.clone(), + }, + sub_invocations: Vec::new(env), + }), + ]); + + // Tansu.execute(maintainer, project_key, proposal_id, tallies, seeds). + // maintainer = self — must match the project's `maintainers` list in + // Tansu (configured at registration / update_config time). + let _: Val = env.invoke_contract( + &tansu, + &Symbol::new(env, "execute"), + vec![ + env, + env.current_contract_address().into_val(env), + project_key.into_val(env), + proposal_id.into_val(env), + None::>.into_val(env), + None::>.into_val(env), + ], + ); + Ok(()) + } +} + +#[cfg(test)] +mod test; diff --git a/contracts/registry-tansu-manager/src/test.rs b/contracts/registry-tansu-manager/src/test.rs new file mode 100644 index 0000000..1345800 --- /dev/null +++ b/contracts/registry-tansu-manager/src/test.rs @@ -0,0 +1,29 @@ +#![allow(clippy::needless_pass_by_value)] + +extern crate std; + +use soroban_sdk::{testutils::Address as _, Address, Bytes, Env}; + +use crate::{RegistryTansuManager, RegistryTansuManagerClient}; + +// Wasm-import the standalone tansu-stub so we can register it as the Tansu +// the manager queries during `__check_auth`. +mod tansu_stub_wasm { + soroban_sdk_tools::contractimport!(file = "../../target/stellar/local/tansu_stub.wasm"); +} + +#[test] +fn constructor_stores_values() { + let env = Env::default(); + let tansu = env.register(tansu_stub_wasm::WASM, ()); + let registry = Address::generate(&env); + let project_key = Bytes::from_slice(&env, &[7u8; 16]); + let manager = env.register( + RegistryTansuManager, + (tansu.clone(), project_key.clone(), registry.clone()), + ); + let client = RegistryTansuManagerClient::new(&env, &manager); + assert_eq!(client.tansu(), tansu); + assert_eq!(client.project_key(), project_key); + assert_eq!(client.registry(), registry); +} diff --git a/contracts/test/tansu-stub/Cargo.toml b/contracts/test/tansu-stub/Cargo.toml new file mode 100644 index 0000000..98ccbde --- /dev/null +++ b/contracts/test/tansu-stub/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "tansu-stub" +description = "Minimal stand-in for the Tansu DAO contract. Used by the e2e script for the registry-tansu-manager flow on testnet — it lets a caller plant a synthetic `Approved` proposal so `RegistryTansuManager::execute(proposal_id)` can be exercised without running Tansu's real voting cycle (collateral, members, 24h voting period)." +version = "0.0.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib"] +doctest = false + +[dependencies] +soroban-sdk = { workspace = true } + +[package.metadata.stellar] +cargo_inherit = true +# Marks this crate as a contract-build dependency so `stellar scaffold build` +# orders it before any package that lists it under `[dependencies]` (e.g. +# `registry-tansu-manager`, which `import_contract_client!`s tansu_stub.wasm +# at compile time). Without this, the topo sort can't see the edge and a +# clean-checkout build (CI) fails because tansu_stub.wasm isn't there yet. +contract = true diff --git a/contracts/test/tansu-stub/src/lib.rs b/contracts/test/tansu-stub/src/lib.rs new file mode 100644 index 0000000..c763dac --- /dev/null +++ b/contracts/test/tansu-stub/src/lib.rs @@ -0,0 +1,269 @@ +#![no_std] +#![allow( + clippy::too_many_arguments, + clippy::needless_pass_by_value, + clippy::used_underscore_binding +)] + +use soroban_sdk::{ + contract, contracterror, contractimpl, contracttype, panic_with_error, vec, Address, Bytes, + BytesN, Env, IntoVal, String, Symbol, Val, Vec, +}; + +// Tansu Proposal types — kept in lock-step with both `Consulting-Manao/tansu` +// `contracts/tansu/src/types.rs` and `contracts/registry-tansu-manager/src/lib.rs`. +// Duplicated here (rather than path-dep'd) because linking the manager crate as +// an `rlib` would re-export its `#[contractimpl]` functions into this stub's +// wasm. + +#[contracttype] +#[derive(Clone)] +pub enum VoteChoice { + Approve, + Reject, + Abstain, +} + +#[contracttype] +#[derive(Clone)] +pub struct PublicVote { + pub address: Address, + pub weight: u32, + pub vote_choice: VoteChoice, +} + +#[contracttype] +#[derive(Clone)] +pub struct AnonymousVote { + pub address: Address, + pub weight: u32, + pub encrypted_seeds: Vec, + pub encrypted_votes: Vec, + pub commitments: Vec>, +} + +#[contracttype] +#[derive(Clone)] +#[allow(clippy::large_enum_variant)] +pub enum Vote { + PublicVote(PublicVote), + AnonymousVote(AnonymousVote), +} + +#[contracttype] +#[derive(Clone)] +pub struct VoteData { + pub voting_ends_at: u64, + pub public_voting: bool, + pub token_contract: Option
, + pub votes: Vec, +} + +#[contracttype] +#[derive(Clone)] +pub enum ProposalStatus { + Active, + Approved, + Rejected, + Cancelled, + Malicious, +} + +#[contracttype] +#[derive(Clone)] +pub struct OutcomeContract { + pub address: Address, + pub execute_fn: Symbol, + pub args: Vec, +} + +#[contracttype] +#[derive(Clone)] +pub struct Proposal { + pub id: u32, + pub title: String, + pub proposer: Address, + pub ipfs: String, + pub vote_data: VoteData, + pub status: ProposalStatus, + pub outcome_contracts: Option>, +} + +#[contracttype] +enum Key { + Proposal(Bytes, u32), + /// Marker set when `execute(...)` runs for a (`project_key`, `proposal_id`). + /// On a second call we panic with [`Error::ProposalActive`] to mirror + /// Tansu's status guard. + Executed(Bytes, u32), +} + +/// Subset of `Consulting-Manao/tansu`'s `ContractErrors` that this stub +/// surfaces — keeps the same numeric codes so test harnesses can match by +/// `Error(Contract, #N)` the same way they would against real Tansu. +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum Error { + /// The proposal has already been executed once (Tansu would say the + /// proposal isn't `Active` anymore). + ProposalActive = 402, +} + +#[contract] +pub struct TansuStub; + +#[contractimpl] +impl TansuStub { + /// Tansu's real `get_proposal(project_key, proposal_id) -> Proposal`. + /// The stub stores proposals planted via `set_*_proposal` helpers. + pub fn get_proposal(env: &Env, project_key: Bytes, proposal_id: u32) -> Proposal { + env.storage() + .persistent() + .get(&Key::Proposal(project_key, proposal_id)) + .unwrap() + } + + /// Stand-in for `Consulting-Manao/tansu`'s `execute`. Real Tansu tallies + /// votes, flips the proposal's status, then auto-invokes the + /// matching-branch outcome via `try_invoke_contract`. The stub skips the + /// tally (proposals are planted as `Approved` directly) but reproduces + /// the same auto-invocation + the `if proposal.status != Active` replay + /// guard, so callers like `RegistryTansuManager::trigger` can exercise + /// the full flow without needing live Tansu. + /// + /// `maintainer`, `tallies`, `seeds` are accepted for signature parity + /// with real Tansu's CLI shape; the stub ignores them. + pub fn execute( + env: &Env, + _maintainer: Address, + project_key: Bytes, + proposal_id: u32, + _tallies: Option>, + _seeds: Option>, + ) -> ProposalStatus { + let exec_key = Key::Executed(project_key.clone(), proposal_id); + if env.storage().persistent().has(&exec_key) { + panic_with_error!(env, Error::ProposalActive); + } + + let proposal: Proposal = env + .storage() + .persistent() + .get(&Key::Proposal(project_key, proposal_id)) + .unwrap(); + + // Auto-invoke the approved-branch outcome (index 0 in real Tansu). + if let Some(outcomes) = &proposal.outcome_contracts { + if let Some(oc) = outcomes.get(0) { + let _: Val = env.invoke_contract(&oc.address, &oc.execute_fn, oc.args.clone()); + } + } + + env.storage().persistent().set(&exec_key, &true); + proposal.status + } + + /// Plant an arbitrary, fully-formed `Proposal`. Used by callers that need + /// non-`Approved` states or unusual outcome shapes (e.g. unit tests that + /// exercise every rejection path in `RegistryTansuManager::execute`). + pub fn set_proposal(env: &Env, project_key: Bytes, proposal: Proposal) { + env.storage() + .persistent() + .set(&Key::Proposal(project_key, proposal.id), &proposal); + } + + /// Plant an `Approved` proposal whose single outcome is + /// `registry.deploy(wasm_name, version, contract_name, admin, init, deployer)`. + /// + /// `init` is built as the on-chain Registry contract's + /// `__constructor(admin, manager, root)` argument list, so the deployed + /// payload is a (sub)registry instance — the contract the e2e scripts + /// publish and deploy now that the `hello` example contract is gone. Pass + /// `root = Some()` (and `manager = None`) to deploy a + /// subregistry; omitting both would make the deployed instance a root + /// registry, which requires a manager and auto-deploys `unverified`. + /// Use [`set_proposal_outcome`] for any other constructor shape. + pub fn set_deploy_proposal( + env: &Env, + project_key: Bytes, + proposal_id: u32, + registry: Address, + wasm_name: String, + version: Option, + contract_name: String, + admin: Address, + manager: Option
, + root: Option
, + deployer: Option
, + ) { + let init: Option> = Some(vec![ + env, + admin.clone().into_val(env), + manager.into_val(env), + root.into_val(env), + ]); + let args: Vec = vec![ + env, + wasm_name.into_val(env), + version.into_val(env), + contract_name.into_val(env), + admin.into_val(env), + init.into_val(env), + deployer.into_val(env), + ]; + Self::store( + env, + project_key, + proposal_id, + registry, + Symbol::new(env, "deploy"), + args, + ); + } + + /// Plant a fully custom `Approved` proposal — caller supplies the outcome + /// `(target, fn_name, args)` directly. + pub fn set_proposal_outcome( + env: &Env, + project_key: Bytes, + proposal_id: u32, + target: Address, + fn_name: Symbol, + args: Vec, + ) { + Self::store(env, project_key, proposal_id, target, fn_name, args); + } + + fn store( + env: &Env, + project_key: Bytes, + proposal_id: u32, + target: Address, + fn_name: Symbol, + args: Vec, + ) { + let outcome = OutcomeContract { + address: target, + execute_fn: fn_name, + args, + }; + let proposal = Proposal { + id: proposal_id, + title: String::from_str(env, ""), + proposer: env.current_contract_address(), + ipfs: String::from_str(env, ""), + vote_data: VoteData { + voting_ends_at: 0, + public_voting: true, + token_contract: None, + votes: Vec::::new(env), + }, + status: ProposalStatus::Approved, + outcome_contracts: Some(vec![env, outcome]), + }; + env.storage() + .persistent() + .set(&Key::Proposal(project_key.clone(), proposal_id), &proposal); + } +} diff --git a/justfile b/justfile index 8a9e359..7c066b6 100644 --- a/justfile +++ b/justfile @@ -3,6 +3,9 @@ set dotenv-load := true export PATH := './target/bin:' + env_var('PATH') export CONFIG_DIR := 'target/' export CI_BUILD := env_var_or_default('CI_BUILD', '') +# Stage built wasm under target/stellar/local/ — the path the registry tests' +# `contractimport!` and the manager's `import_contract_client!` resolve against. +export STELLAR_NETWORK := env_var_or_default('STELLAR_NETWORK', 'local') [private] path: @@ -17,14 +20,20 @@ stellar +args: build_contract p: stellar contract build --profile contracts --package {{ p }} -# Build all contracts with the size-optimized profile +# Build all contracts with the size-optimized profile. Uses `stellar scaffold +# build` (not plain `stellar contract build`) so wasm is staged to +# target/stellar//, which the registry tests' `contractimport!` and +# registry-tansu-manager's `import_contract_client!(tansu_stub)` both resolve +# against. STELLAR_NETWORK defaults to `local` (see stellar-build), matching the +# `target/stellar/local/...` paths the tests import from. build: - stellar contract build --profile contracts + stellar scaffold build --profile contracts -# Setup git hooks and pin the stellar-cli version +# Setup git hooks and pin the CLI versions setup: git config core.hooksPath .githooks -cargo binstall -y stellar-cli --version 26.0.0 --force --install-path ./target/bin + -cargo binstall -y stellar-scaffold-cli --version 0.0.24 --force --install-path ./target/bin # Tests import compiled fixture wasm via `contractimport!`, so build first test: build