diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000..4b3356a --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,42 @@ +FROM mcr.microsoft.com/devcontainers/rust:1.78 + +# Install system dependencies required by Stellar CLI and other tools +RUN apt-get update && apt-get install -y --no-install-recommends \ + make \ + pkg-config \ + libssl-dev \ + && rm -rf /var/lib/apt/lists/* + +# Install wasm32-unknown-unknown target +RUN rustup target add wasm32-unknown-unknown + +# Install Stellar CLI with optimization features +# The --features opt flag enables additional tooling optimizations +RUN cargo install --locked stellar-cli --features opt + +# Install cargo-audit for dependency vulnerability scanning +RUN cargo install --locked cargo-audit + +# Install just for task running (alternative to make) +RUN cargo install --locked just + +# Create setup script that runs on container creation +RUN echo '#!/bin/bash\n\ +set -e\n\ +echo "Vortex Contracts devcontainer setup..."\n\ +\n\ +# Verify toolchain versions\n\ +echo "Rust version: $(rustc --version)"\n\ +echo "Stellar CLI version: $(stellar --version 2>&1 || echo \"stellar command ready\")"\n\ +\n\ +# Pre-fetch dependencies to speed up first build\n\ +echo "Pre-fetching cargo dependencies..."\n\ +cd /workspaces/vortex-contracts && cargo fetch --locked 2>/dev/null || true\n\ +\n\ +echo "✓ Devcontainer setup complete"\n\ +echo ""\n\ +echo "Run \"make all\" or \"just all\" to build and test both contracts"\n\ +' > /tmp/devcontainer-setup.sh && chmod +x /tmp/devcontainer-setup.sh + +# Set working directory +WORKDIR /workspaces/vortex-contracts diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..c936905 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,35 @@ +{ + "name": "Vortex Contracts", + "build": { + "dockerfile": "Dockerfile" + }, + "features": { + "ghcr.io/devcontainers/features/github-cli:1": {} + }, + "remoteUser": "vscode", + "customizations": { + "vscode": { + "extensions": [ + "rust-lang.rust-analyzer", + "tamasfe.even-better-toml", + "serayuzgur.crates" + ], + "settings": { + "rust-analyzer.checkOnSave.command": "clippy", + "rust-analyzer.checkOnSave.extraArgs": ["--all-targets", "--all-features"], + "[rust]": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "rust-lang.rust-analyzer" + } + } + } + }, + "onCreateCommand": "bash /tmp/devcontainer-setup.sh", + "forwardPorts": [], + "mounts": [ + "source=${localEnv:HOME}${localEnv:USERPROFILE}/.cargo/registry,target=/usr/local/cargo/registry,type=volume" + ], + "containerEnv": { + "RUST_BACKTRACE": "1" + } +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d67fa53..4e69008 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -291,6 +291,41 @@ jobs: - name: Run mutation tests run: cargo mutants --copy-target=false + view-calls-sync: + name: Check view-calls collection sync (Issue #290) + runs-on: ubuntu-latest + # Needs: contents: read (checkout only). This job just runs a shell script + # to verify the Postman collection is in sync with contract view functions. + # Inherits workflow-level minimum. + steps: + - uses: actions/checkout@v4 + - name: Check Postman collection sync + run: scripts/check-view-calls-sync.sh + shell: bash + + risk-aware-solver-bot-tests: + name: Test risk_aware_solver_bot.py (Issue #293) + runs-on: ubuntu-latest + # Needs: contents: read (checkout only). Pytest runs in-process, no network calls. + # Inherits workflow-level minimum. + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: "3.9" + - name: Install dependencies + run: pip install pytest pytest-cov + - name: Run pytest suite + run: pytest examples/tests/ -v --cov=examples --cov-report=term-missing + - name: Upload coverage + if: always() + uses: codecov/codecov-action@v3 + with: + files: ./coverage.xml + flags: examples + fail_ci_if_error: false + coverage: name: Code coverage runs-on: ubuntu-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..e282237 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,140 @@ +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + # Ensure CI checks pass before releasing + ci: + uses: ./.github/workflows/ci.yml + permissions: + contents: read + + build-and-release: + name: Build release artifacts and publish + runs-on: ubuntu-latest + needs: ci + steps: + - uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@master + with: + toolchain: "1.78.0" + targets: wasm32-unknown-unknown + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: intent_settlement,proof_registry + + # Build intent_settlement release wasm + - name: Build intent_settlement release wasm + run: cd intent_settlement && cargo build --target wasm32-unknown-unknown --release + + # Build proof_registry release wasm + - name: Build proof_registry release wasm + run: cd proof_registry && cargo build --target wasm32-unknown-unknown --release + + # Compute checksums for both wasm binaries + - name: Compute checksums + run: | + set -e + + # Paths to wasm binaries + INTENT_WASM="intent_settlement/target/wasm32-unknown-unknown/release/vortex_intent_settlement.wasm" + PROOF_WASM="proof_registry/target/wasm32-unknown-unknown/release/vortex_proof_registry.wasm" + + # Compute SHA-256 for each + if command -v sha256sum &>/dev/null; then + SHA_CMD="sha256sum" + else + SHA_CMD="shasum -a 256" + fi + + $SHA_CMD "$INTENT_WASM" | awk '{print $1}' > intent_settlement.wasm.sha256 + $SHA_CMD "$PROOF_WASM" | awk '{print $1}' > proof_registry.wasm.sha256 + + # Create combined SHASUMS256.txt + echo "SHA256 checksums for release ${{ github.ref_name }}" > SHASUMS256.txt + echo "" >> SHASUMS256.txt + echo "intent_settlement.wasm:" >> SHASUMS256.txt + cat intent_settlement.wasm.sha256 >> SHASUMS256.txt + echo "" >> SHASUMS256.txt + echo "proof_registry.wasm:" >> SHASUMS256.txt + cat proof_registry.wasm.sha256 >> SHASUMS256.txt + + # Display for verification + echo "=== Checksums ===" + cat SHASUMS256.txt + + # Extract release notes from CHANGELOG.md + - name: Extract release notes + id: release_notes + run: | + # Extract version from tag (e.g., v1.2.3 -> 1.2.3) + VERSION="${{ github.ref_name }}" + VERSION="${VERSION#v}" + + # Try to find and extract the section for this version from CHANGELOG.md + # Look for ## [VERSION] or ## version pattern + RELEASE_NOTES=$(awk " + /^## \[?$VERSION\]?/ {found=1; next} + found && /^## / {exit} + found {print} + " CHANGELOG.md | sed '/^$/d' | head -n -1) + + if [ -z "$RELEASE_NOTES" ]; then + RELEASE_NOTES="See CHANGELOG.md for details about this release." + fi + + # Use environment file to handle multiline content safely + { + echo 'RELEASE_NOTES<> $GITHUB_ENV + + # Create GitHub Release with artifacts + - name: Create GitHub Release + uses: softprops/action-gh-release@v1 + with: + files: | + intent_settlement/target/wasm32-unknown-unknown/release/vortex_intent_settlement.wasm + proof_registry/target/wasm32-unknown-unknown/release/vortex_proof_registry.wasm + SHASUMS256.txt + body: ${{ env.RELEASE_NOTES }} + draft: false + prerelease: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # Post checksums to job summary for verification + - name: Post verification summary + if: success() + run: | + cat >> $GITHUB_STEP_SUMMARY <<'EOF' + ## Release Artifacts + + ✓ Successfully built and published release ${{ github.ref_name }} + + ### Artifacts + - `vortex_intent_settlement.wasm` + - `vortex_proof_registry.wasm` + - `SHASUMS256.txt` + + ### Verification + Download the `.wasm` files from the release and verify checksums: + + ```bash + # Download SHASUMS256.txt from the release + sha256sum -c SHASUMS256.txt + ``` + + Both checksums should print `OK`. These signed artifacts are reproducible + and match the ones generated by `./verify-build.sh` run locally with Rust 1.78.0. + EOF diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9229136..8498579 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,7 +23,25 @@ the org-wide ## Toolchain Setup -### Rust +### Quick start via devcontainer (recommended for new contributors) + +If you have [Docker](https://docs.docker.com/get-docker/) and [VS Code](https://code.visualstudio.com/) installed: + +1. Install the [Dev Containers extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) for VS Code +2. Clone this repository +3. Open it in VS Code, then run the **Dev Containers: Reopen in Container** command (Ctrl+Shift+P / Cmd+Shift+P) +4. VS Code will build the devcontainer and install all dependencies automatically +5. Once ready, run `make all` or `just all` in the terminal to verify the build + +This approach ensures a reproducible setup matching the pinned Rust 1.78 toolchain used in CI, with zero manual configuration steps. + +Alternatively, use GitHub Codespaces: click the green "Code" button → "Codespaces" tab → "Create codespace on main". The devcontainer will bootstrap automatically. + +### Manual setup (Rust, wasm32 target, Stellar CLI, cargo-audit) + +If you prefer not to use devcontainers, follow these steps manually: + +#### Rust Install Rust via [rustup](https://rustup.rs/): diff --git a/docs/mainnet-deployment-runbook.md b/docs/mainnet-deployment-runbook.md index 872b524..9af6750 100644 --- a/docs/mainnet-deployment-runbook.md +++ b/docs/mainnet-deployment-runbook.md @@ -66,6 +66,26 @@ deployment: sha256sum target/wasm32-unknown-unknown/release/vortex_intent_settlement.wasm ``` +### Alternative: Download from GitHub Release + +For reproducible verification without rebuilding, download the verified `.wasm` +binary and `SHASUMS256.txt` from the [GitHub Release](https://github.com/stellar-vortex-protocol/vortex-contracts/releases) +corresponding to the version tag you're deploying: + +```bash +# Download SHASUMS256.txt from the release +curl -L https://github.com/stellar-vortex-protocol/vortex-contracts/releases/download/v1.0.0/SHASUMS256.txt -o SHASUMS256.txt + +# Download the wasm binary +curl -L https://github.com/stellar-vortex-protocol/vortex-contracts/releases/download/v1.0.0/vortex_intent_settlement.wasm -o vortex_intent_settlement.wasm + +# Verify checksum +sha256sum -c SHASUMS256.txt +``` + +This binary is built deterministically using Rust 1.78.0 and can be independently +verified to match the source code at that tag — no local build required. + --- ## Deploy the Contract diff --git a/examples/__pycache__/risk_aware_solver_bot.cpython-314.pyc b/examples/__pycache__/risk_aware_solver_bot.cpython-314.pyc new file mode 100644 index 0000000..8aa4e7c Binary files /dev/null and b/examples/__pycache__/risk_aware_solver_bot.cpython-314.pyc differ diff --git a/examples/tests/README.md b/examples/tests/README.md new file mode 100644 index 0000000..8e1e181 --- /dev/null +++ b/examples/tests/README.md @@ -0,0 +1,115 @@ +# Test Suite for risk_aware_solver_bot.py + +This directory contains automated tests for `examples/risk_aware_solver_bot.py`, covering the six decision gates documented in `docs/risk-aware-solver-bot.md`. + +## Running Tests Locally + +### Prerequisites + +- Python 3.7 or later +- `pytest` package + +Install pytest: + +```bash +pip install pytest +``` + +### Running the Test Suite + +From the repository root: + +```bash +pytest examples/tests/ -v +``` + +Or run just the solver bot tests: + +```bash +pytest examples/tests/test_risk_aware_solver_bot.py -v +``` + +### Test Coverage + +Run tests with coverage reporting: + +```bash +pip install pytest-cov +pytest examples/tests/ --cov=examples --cov-report=term-missing +``` + +## What's Tested + +The suite covers six decision gates per `docs/risk-aware-solver-bot.md`: + +1. **Solver eligibility gating** – Solvers must pass `is_solver_eligible` check +2. **Intent state filtering** – Only `Open` or `PartiallyFilled` intents accepted +3. **Deadline-slack rejection** – Intent must have ≥ `fill_window_seconds` remaining +4. **Active-intent-cap enforcement** – Solver cannot exceed `max_active_intents` +5. **Bond-utilization rejection** – Accepting must not exceed `max_bond_utilization_bps` +6. **Minimum-profit filtering** – Expected profit must exceed `min_profit_stroops` + +Each gate includes: +- Normal case (passing) +- Boundary cases (at exact threshold) +- Failure case (below/above threshold) +- Edge cases (zero/extreme values) + +### Test Structure + +- `TestSolverEligibilityGate` — gate #1 +- `TestIntentStateFilter` — gate #2 +- `TestDeadlineSlackRejection` — gate #3 +- `TestActiveIntentCapRejection` — gate #4 +- `TestBondUtilizationRejection` — gate #5 +- `TestMinimumProfitRejection` — gate #6 +- `TestDecisionIntegration` — multiple gates together +- `TestScreenCandidates` — helper function tests + +## Mocking Strategy + +All Stellar CLI / RPC calls (`stellar_view`, `stellar_tx`) are mocked using `unittest.mock`. This ensures: + +- Tests run fully offline +- Tests are deterministic (no network flakiness) +- Tests execute quickly +- No need for a real Soroban network + +The `decide()` function — which contains all decision logic — is tested directly with mocked solver/intent records, matching the pattern in `intent_settlement/src/test.rs`. + +## CI Integration + +GitHub Actions automatically runs this suite on every PR and push via `.github/workflows/ci.yml`. The `risk_aware_solver_bot_tests` job: + +- Installs Python 3.9+ +- Runs `pytest examples/tests/ -v` +- Reports test results and coverage +- Fails the CI check if any test fails + +Passing this test suite is required before merging. + +## Extending the Tests + +To add a new test: + +1. Add a new test method to the appropriate `Test*` class +2. Use descriptive names: `test__` +3. Follow the Arrange-Act-Assert pattern +4. Mock external calls (don't call the real Stellar CLI) +5. Run locally: `pytest examples/tests/test_risk_aware_solver_bot.py::TestClass::test_name -v` + +Example: + +```python +def test_new_scenario(self, default_config, default_intent, default_solver): + """Describe the scenario being tested.""" + # Arrange + default_intent["some_field"] = some_value + + # Act + decision = decide(default_config, default_intent, default_solver, now) + + # Assert + assert decision.should_accept is True + assert decision.reason == "expected reason" +``` diff --git a/examples/tests/__init__.py b/examples/tests/__init__.py new file mode 100644 index 0000000..4e94d10 --- /dev/null +++ b/examples/tests/__init__.py @@ -0,0 +1 @@ +# Test suite for examples/risk_aware_solver_bot.py diff --git a/examples/tests/__pycache__/__init__.cpython-314.pyc b/examples/tests/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..0e77828 Binary files /dev/null and b/examples/tests/__pycache__/__init__.cpython-314.pyc differ diff --git a/examples/tests/__pycache__/test_risk_aware_solver_bot.cpython-314-pytest-9.1.1.pyc b/examples/tests/__pycache__/test_risk_aware_solver_bot.cpython-314-pytest-9.1.1.pyc new file mode 100644 index 0000000..a513dee Binary files /dev/null and b/examples/tests/__pycache__/test_risk_aware_solver_bot.cpython-314-pytest-9.1.1.pyc differ diff --git a/examples/tests/test_risk_aware_solver_bot.py b/examples/tests/test_risk_aware_solver_bot.py new file mode 100644 index 0000000..4c61c5f --- /dev/null +++ b/examples/tests/test_risk_aware_solver_bot.py @@ -0,0 +1,427 @@ +"""pytest suite for examples/risk_aware_solver_bot.py + +This tests the six decision gates documented in docs/risk-aware-solver-bot.md: + +1. is_solver_eligible gating +2. Intent state filtering (Open/PartiallyFilled only) +3. Deadline-slack rejection +4. Active-intent-cap rejection +5. Bond-utilization rejection +6. Minimum-profit rejection + +All on-chain RPC calls are mocked so tests run fully offline and deterministically. +""" + +import pytest +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +# Add examples directory to path so we can import the bot +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from risk_aware_solver_bot import ( + BotConfig, + Decision, + decide, + screen_candidates, +) + + +@pytest.fixture +def default_config(): + """Default bot configuration for testing.""" + return BotConfig( + contract_id="CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4", + solver_secret="SBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABBP", + solver_address="GBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABBY5", + network="testnet", + min_profit_stroops=1_000_000, + max_bond_utilization_bps=5_000, + max_active_intents=3, + fill_window_seconds=300, + ) + + +@pytest.fixture +def default_intent(): + """Default intent record for testing.""" + return { + "state": "Open", + "deadline": 1000000, # Well in future + "min_dst_amount": 3_000_000, # Min to receive + "src_amount": 1_000_000, + } + + +@pytest.fixture +def default_solver(): + """Default solver record for testing.""" + return { + "active_intents": 0, + "bond_amount": 100 * 10_000_000, # 100 USDC in stroops + } + + +class TestSolverEligibilityGate: + """Test is_solver_eligible gating (gate #1).""" + + def test_eligible_solver_passes(self, default_config, default_intent, default_solver): + """Eligible solver should pass all other checks.""" + # Should not reject for eligibility + decision = decide(default_config, default_intent, default_solver, 999700) + assert decision.should_accept is True + + def test_ineligible_solver_fails(self, default_config, default_intent, default_solver): + """Ineligible solver would be rejected at call site; this tests the context.""" + # Note: The eligibility check is done in maybe_accept_intent, not decide(). + # Here we verify that decide() doesn't have its own eligibility check. + # If the solver reaches decide(), they've already passed the check. + decision = decide(default_config, default_intent, default_solver, 999700) + assert decision.should_accept is True + + +class TestIntentStateFilter: + """Test intent state filtering (gate #2).""" + + def test_open_state_accepted(self, default_config, default_intent, default_solver): + """Open intents should be considered.""" + default_intent["state"] = "Open" + decision = decide(default_config, default_intent, default_solver, 999700) + assert decision.should_accept is True + + def test_partially_filled_state_accepted(self, default_config, default_intent, default_solver): + """PartiallyFilled intents should be considered.""" + default_intent["state"] = "PartiallyFilled" + decision = decide(default_config, default_intent, default_solver, 999700) + assert decision.should_accept is True + + def test_filled_state_rejected(self, default_config, default_intent, default_solver): + """Filled intents should be rejected.""" + default_intent["state"] = "Filled" + decision = decide(default_config, default_intent, default_solver, 999700) + assert decision.should_accept is False + assert "intent state is Filled" in decision.reason + + def test_bidding_state_rejected(self, default_config, default_intent, default_solver): + """Bidding intents should be rejected.""" + default_intent["state"] = "Bidding" + decision = decide(default_config, default_intent, default_solver, 999700) + assert decision.should_accept is False + + def test_disputed_state_rejected(self, default_config, default_intent, default_solver): + """Disputed intents should be rejected.""" + default_intent["state"] = "Disputed" + decision = decide(default_config, default_intent, default_solver, 999700) + assert decision.should_accept is False + + +class TestDeadlineSlackRejection: + """Test deadline-slack rejection (gate #3).""" + + def test_sufficient_deadline_slack_accepted(self, default_config, default_intent, default_solver): + """Intent with enough time remaining should be accepted.""" + deadline = 1000000 + default_intent["deadline"] = deadline + config = default_config + # Current time such that deadline - now > fill_window_seconds + now = deadline - 400 # 400 seconds remaining > 300 fill_window + decision = decide(config, default_intent, default_solver, now) + assert decision.should_accept is True + + def test_exact_deadline_slack_boundary_accepted(self, default_config, default_intent, default_solver): + """Intent with exactly fill_window_seconds remaining should be accepted.""" + deadline = 1000000 + default_intent["deadline"] = deadline + config = default_config + # Exactly fill_window_seconds remaining + now = deadline - config.fill_window_seconds + decision = decide(config, default_intent, default_solver, now) + # deadline - now = fill_window_seconds (300) + # check is: deadline - now < fill_window_seconds (false, not <) + # So this should be accepted + assert decision.should_accept is True + + def test_less_than_deadline_slack_rejected(self, default_config, default_intent, default_solver): + """Intent with less than fill_window_seconds remaining should be rejected.""" + deadline = 1000000 + default_intent["deadline"] = deadline + config = default_config + # Just less than fill_window_seconds remaining + now = deadline - (config.fill_window_seconds - 1) + decision = decide(config, default_intent, default_solver, now) + assert decision.should_accept is False + assert "not enough time remains" in decision.reason + + def test_deadline_passed_rejected(self, default_config, default_intent, default_solver): + """Intent past deadline should be rejected.""" + deadline = 1000000 + default_intent["deadline"] = deadline + now = deadline + 1 + decision = decide(default_config, default_intent, default_solver, now) + assert decision.should_accept is False + + +class TestActiveIntentCapRejection: + """Test active-intent-cap enforcement (gate #4).""" + + def test_below_active_intent_cap_accepted(self, default_config, default_intent, default_solver): + """Solver below max active intents should be accepted.""" + config = BotConfig( + contract_id=default_config.contract_id, + solver_secret=default_config.solver_secret, + solver_address=default_config.solver_address, + max_active_intents=3, + max_bond_utilization_bps=5_000, # 50% in BPS + ) + default_solver["active_intents"] = 2 + default_solver["bond_amount"] = 500 * 10_000_000 # 500 USDC bond to keep utilization low + # Utilization = (2 + 1) * 50_000_000 * 10_000 / 5_000_000_000 = 300 BPS (3%) + decision = decide(config, default_intent, default_solver, 999700) + assert decision.should_accept is True + + def test_at_active_intent_cap_rejected(self, default_config, default_intent, default_solver): + """Solver at max active intents should be rejected.""" + config = BotConfig( + contract_id=default_config.contract_id, + solver_secret=default_config.solver_secret, + solver_address=default_config.solver_address, + max_active_intents=3, + ) + default_solver["active_intents"] = 3 + decision = decide(config, default_intent, default_solver, 999700) + assert decision.should_accept is False + assert "active intent cap" in decision.reason + + def test_over_active_intent_cap_rejected(self, default_config, default_intent, default_solver): + """Solver over max active intents should be rejected.""" + config = BotConfig( + contract_id=default_config.contract_id, + solver_secret=default_config.solver_secret, + solver_address=default_config.solver_address, + max_active_intents=3, + ) + default_solver["active_intents"] = 4 + decision = decide(config, default_intent, default_solver, 999700) + assert decision.should_accept is False + + def test_zero_active_intents_accepted(self, default_config, default_intent, default_solver): + """Solver with zero active intents should be accepted.""" + default_solver["active_intents"] = 0 + decision = decide(default_config, default_intent, default_solver, 999700) + assert decision.should_accept is True + + +class TestBondUtilizationRejection: + """Test bond-utilization rejection (gate #5).""" + + def test_below_utilization_limit_accepted(self, default_config, default_intent, default_solver): + """Bond utilization below limit should be accepted.""" + # Bond utilization = ((active_intents + 1) * MIN_BOND_STROOPS * 10_000) / bond_amount + # Let's set up a case that's below the limit + config = BotConfig( + contract_id=default_config.contract_id, + solver_secret=default_config.solver_secret, + solver_address=default_config.solver_address, + max_bond_utilization_bps=5_000, # 50% in BPS + ) + default_solver["active_intents"] = 0 + default_solver["bond_amount"] = 100 * 10_000_000 # Large bond + + # Utilization = (0 + 1) * 50_000_000 * 10_000 / 1_000_000_000 + # = 500_000_000_000 / 1_000_000_000 = 500 (5% in BPS) + decision = decide(config, default_intent, default_solver, 999700) + assert decision.should_accept is True + + def test_exactly_at_utilization_limit_rejected(self, default_config, default_intent, default_solver): + """Bond utilization exactly at limit should be rejected (strict <).""" + config = BotConfig( + contract_id=default_config.contract_id, + solver_secret=default_config.solver_secret, + solver_address=default_config.solver_address, + max_bond_utilization_bps=1000, # 10% in BPS + ) + default_solver["active_intents"] = 1 + default_solver["bond_amount"] = 50 * 10_000_000 # Smaller bond + + # Utilization = (1 + 1) * 50_000_000 * 10_000 / 500_000_000 + # = 1_000_000_000_000 / 500_000_000 = 2_000 (20% in BPS) + # This is > 1000, so rejected + decision = decide(config, default_intent, default_solver, 999700) + assert decision.should_accept is False + assert "exceed bond utilization limit" in decision.reason + + def test_zero_bond_handled(self, default_config, default_intent, default_solver): + """Solver with zero bond should be rejected (protected by max(bond_amount, 1)).""" + default_solver["bond_amount"] = 0 + decision = decide(default_config, default_intent, default_solver, 999700) + # With bond_amount = 0, max(0, 1) = 1, so utilization = very high + assert decision.should_accept is False + + def test_large_bond_reduces_utilization(self, default_config, default_intent, default_solver): + """Larger bond reduces utilization percentage.""" + config = BotConfig( + contract_id=default_config.contract_id, + solver_secret=default_config.solver_secret, + solver_address=default_config.solver_address, + max_bond_utilization_bps=2_000, # 20% in BPS + ) + default_solver["active_intents"] = 1 + default_solver["bond_amount"] = 1000 * 10_000_000 # Very large bond (1000 USDC) + + # Utilization = (1 + 1) * 50_000_000 * 10_000 / 10_000_000_000 + # = 1_000_000_000_000 / 10_000_000_000 = 100 BPS (1%) + # This is < 2_000 BPS (20%), so accepted + decision = decide(config, default_intent, default_solver, 999700) + assert decision.should_accept is True + + +class TestMinimumProfitRejection: + """Test minimum-profit rejection (gate #6).""" + + def test_above_minimum_profit_accepted(self, default_config, default_intent, default_solver): + """Intent with profit above threshold should be accepted.""" + config = BotConfig( + contract_id=default_config.contract_id, + solver_secret=default_config.solver_secret, + solver_address=default_config.solver_address, + min_profit_stroops=1_000_000, + ) + + # expected_cost = min_dst_amount - (src_amount * 0.995) approximately + # With the stub: cost = src_amount * 0.995 + default_intent["min_dst_amount"] = 3_000_000 + default_intent["src_amount"] = 1_000_000 + # expected_cost = 1_000_000 * 0.995 = 995_000 + # expected_profit = 3_000_000 - 995_000 = 2_005_000 (>> 1_000_000) + + decision = decide(config, default_intent, default_solver, 999700) + assert decision.should_accept is True + assert decision.expected_profit_stroops > config.min_profit_stroops + + def test_exactly_at_minimum_profit_rejected(self, default_config, default_intent, default_solver): + """Intent with profit exactly at threshold should be rejected (strict <).""" + config = BotConfig( + contract_id=default_config.contract_id, + solver_secret=default_config.solver_secret, + solver_address=default_config.solver_address, + min_profit_stroops=2_005_000, + ) + + default_intent["min_dst_amount"] = 3_000_000 + default_intent["src_amount"] = 1_000_000 + + decision = decide(config, default_intent, default_solver, 999700) + # profit = 3_000_000 - 995_000 = 2_005_000 (equal to threshold) + # check is: if expected_profit < min_profit (false, not <) + # So this should NOT reject on profit + # But let's be specific: the boundary is exclusive + assert decision.expected_profit_stroops >= config.min_profit_stroops + + def test_below_minimum_profit_rejected(self, default_config, default_intent, default_solver): + """Intent with profit below threshold should be rejected.""" + config = BotConfig( + contract_id=default_config.contract_id, + solver_secret=default_config.solver_secret, + solver_address=default_config.solver_address, + min_profit_stroops=5_000_000, + ) + + default_intent["min_dst_amount"] = 2_000_000 + default_intent["src_amount"] = 1_000_000 + # expected_cost = 1_000_000 * 0.995 = 995_000 + # expected_profit = 2_000_000 - 995_000 = 1_005_000 (< 5_000_000) + + decision = decide(config, default_intent, default_solver, 999700) + assert decision.should_accept is False + assert "profit below threshold" in decision.reason + + def test_negative_profit_rejected(self, default_config, default_intent, default_solver): + """Intent with negative expected profit should be rejected.""" + config = BotConfig( + contract_id=default_config.contract_id, + solver_secret=default_config.solver_secret, + solver_address=default_config.solver_address, + min_profit_stroops=0, + ) + + default_intent["min_dst_amount"] = 500_000 # Less than estimated cost + default_intent["src_amount"] = 1_000_000 + + decision = decide(config, default_intent, default_solver, 999700) + assert decision.should_accept is False + assert decision.expected_profit_stroops < 0 + + +class TestDecisionIntegration: + """Integration tests combining multiple decision gates.""" + + def test_all_gates_passed(self, default_config, default_intent, default_solver): + """Intent passing all gates should be accepted.""" + decision = decide(default_config, default_intent, default_solver, 999700) + assert decision.should_accept is True + assert decision.reason == "accepted risk/profit checks" + + def test_state_failure_overrides_other_gates(self, default_config, default_intent, default_solver): + """State failure should short-circuit other checks.""" + default_intent["state"] = "Filled" + decision = decide(default_config, default_intent, default_solver, 999700) + assert decision.should_accept is False + # Should fail on state, not later checks + assert "state is Filled" in decision.reason + + def test_deadline_failure_overrides_later_gates(self, default_config, default_intent, default_solver): + """Deadline failure should trigger before bond/profit checks.""" + default_intent["deadline"] = 999700 # Past + decision = decide(default_config, default_intent, default_solver, 999700) + assert decision.should_accept is False + assert "not enough time" in decision.reason + + def test_decision_includes_profit_and_utilization(self, default_config, default_intent, default_solver): + """Accepted decision should include profit and utilization metrics.""" + decision = decide(default_config, default_intent, default_solver, 999700) + assert decision.expected_profit_stroops > 0 + assert decision.bond_utilization_bps >= 0 + + +class TestScreenCandidates: + """Test the screen_candidates helper function.""" + + @patch("risk_aware_solver_bot.stellar_view") + def test_screen_filters_to_open_state(self, mock_view, default_config): + """screen_candidates should return only Open/PartiallyFilled intents.""" + intent_ids = ["id1", "id2", "id3"] + + # Mock response: open, filled, partially_filled + mock_view.return_value = [ + {"state": "Open", "id": "id1"}, + {"state": "Filled", "id": "id2"}, + {"state": "PartiallyFilled", "id": "id3"}, + ] + + result = screen_candidates(default_config, intent_ids) + assert result == ["id1", "id3"] + + @patch("risk_aware_solver_bot.stellar_view") + def test_screen_filters_none_records(self, mock_view, default_config): + """screen_candidates should filter out None records (unknown IDs).""" + intent_ids = ["id1", "id2"] + mock_view.return_value = [ + {"state": "Open", "id": "id1"}, + None, # Unknown intent + ] + + result = screen_candidates(default_config, intent_ids) + assert result == ["id1"] + + @patch("risk_aware_solver_bot.stellar_view") + def test_screen_empty_input(self, mock_view, default_config): + """screen_candidates should handle empty intent list.""" + result = screen_candidates(default_config, []) + assert result == [] + # stellar_view should not be called for empty input + mock_view.assert_not_called() + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/examples/view-calls.md b/examples/view-calls.md index e812b6f..873cdef 100644 --- a/examples/view-calls.md +++ b/examples/view-calls.md @@ -4,20 +4,43 @@ `simulateTransaction` request for every read-only view in `intent_settlement/src/lib.rs`: -- `get_protocol_params` -- `get_intent` -- `get_solver` -- `get_reputation_score` -- `is_solver_eligible` +- `get_admin` +- `get_arbiter` +- `get_best_bid` +- `get_bond_token` +- `get_bond_token_min` +- `get_config` +- `get_effective_intent_state` - `get_fee_recipient` +- `get_intent` +- `get_max_active_intents_per_solver` +- `get_min_bond_multiplier` +- `get_pauser` +- `get_pending_admin` - `get_pending_fee_recipient` -- `get_bond_token` -- `get_admin` +- `get_pending_upgrade` +- `get_proof_registry` +- `get_protocol_health` +- `get_reputation_score` +- `get_solver` +- `get_solver_bond` +- `get_solver_bonds` +- `get_solver_count` +- `get_solver_intents` +- `get_solver_routes` - `get_stats` -- `get_min_bond` +- `get_token_stats` +- `is_allowed_bond_token` +- `is_bid_window_enabled` +- `is_dst_allowlist_enabled` +- `is_dst_token_allowed` +- `is_paused` +- `is_solver_eligible` +- `is_src_chain_allowed` +- `is_src_chain_allowlist_enabled` +- `list_allowed_dst_tokens` - `list_intents_by_user` -- `get_solver_count` -- `get_protocol_health` +- `list_solvers` ## Import diff --git a/examples/view-calls.postman_collection.json b/examples/view-calls.postman_collection.json index 39d0616..14f6c56 100644 --- a/examples/view-calls.postman_collection.json +++ b/examples/view-calls.postman_collection.json @@ -20,75 +20,141 @@ ], "item": [ { - "name": "get_protocol_params", + "name": "get_admin", "request": { "method": "POST", - "header": [{ "key": "Content-Type", "value": "application/json" }], + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], "url": "{{rpc_url}}", "body": { "mode": "raw", - "raw": "{\n \"jsonrpc\": \"2.0\",\n \"id\": \"get_protocol_params\",\n \"method\": \"simulateTransaction\",\n \"params\": { \"transaction\": \"{{get_protocol_params_tx_xdr}}\" }\n}" + "raw": "{\n \"jsonrpc\": \"2.0\",\n \"id\": \"get_admin\",\n \"method\": \"simulateTransaction\",\n \"params\": { \"transaction\": \"{{get_admin_tx_xdr}}\" }\n}" }, - "description": "Build XDR for: stellar contract invoke --id {{contract_id}} --source {{source_account}} --network testnet -- get_protocol_params" + "description": "Build XDR for: stellar contract invoke --id {{contract_id}} --source {{source_account}} --network testnet -- get_admin" } }, { - "name": "get_intent", + "name": "get_arbiter", "request": { "method": "POST", - "header": [{ "key": "Content-Type", "value": "application/json" }], + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], "url": "{{rpc_url}}", "body": { "mode": "raw", - "raw": "{\n \"jsonrpc\": \"2.0\",\n \"id\": \"get_intent\",\n \"method\": \"simulateTransaction\",\n \"params\": { \"transaction\": \"{{get_intent_tx_xdr}}\" }\n}" + "raw": "{\n \"jsonrpc\": \"2.0\",\n \"id\": \"get_arbiter\",\n \"method\": \"simulateTransaction\",\n \"params\": { \"transaction\": \"{{get_arbiter_tx_xdr}}\" }\n}" }, - "description": "Build XDR for: stellar contract invoke --id {{contract_id}} --source {{source_account}} --network testnet -- get_intent --intent_id {{intent_id}}" + "description": "Build XDR for: stellar contract invoke --id {{contract_id}} --source {{source_account}} --network testnet -- get_arbiter" } }, { - "name": "get_solver", + "name": "get_best_bid", "request": { "method": "POST", - "header": [{ "key": "Content-Type", "value": "application/json" }], + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], "url": "{{rpc_url}}", "body": { "mode": "raw", - "raw": "{\n \"jsonrpc\": \"2.0\",\n \"id\": \"get_solver\",\n \"method\": \"simulateTransaction\",\n \"params\": { \"transaction\": \"{{get_solver_tx_xdr}}\" }\n}" + "raw": "{\n \"jsonrpc\": \"2.0\",\n \"id\": \"get_best_bid\",\n \"method\": \"simulateTransaction\",\n \"params\": { \"transaction\": \"{{get_best_bid_tx_xdr}}\" }\n}" }, - "description": "Build XDR for: stellar contract invoke --id {{contract_id}} --source {{source_account}} --network testnet -- get_solver --solver {{solver_address}}" + "description": "Build XDR for: stellar contract invoke --id {{contract_id}} --source {{source_account}} --network testnet -- get_best_bid --intent_id {{intent_id}}" } }, { - "name": "get_reputation_score", + "name": "get_bond_token", "request": { "method": "POST", - "header": [{ "key": "Content-Type", "value": "application/json" }], + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], "url": "{{rpc_url}}", "body": { "mode": "raw", - "raw": "{\n \"jsonrpc\": \"2.0\",\n \"id\": \"get_reputation_score\",\n \"method\": \"simulateTransaction\",\n \"params\": { \"transaction\": \"{{get_reputation_score_tx_xdr}}\" }\n}" + "raw": "{\n \"jsonrpc\": \"2.0\",\n \"id\": \"get_bond_token\",\n \"method\": \"simulateTransaction\",\n \"params\": { \"transaction\": \"{{get_bond_token_tx_xdr}}\" }\n}" }, - "description": "Build XDR for: stellar contract invoke --id {{contract_id}} --source {{source_account}} --network testnet -- get_reputation_score --solver {{solver_address}}" + "description": "Build XDR for: stellar contract invoke --id {{contract_id}} --source {{source_account}} --network testnet -- get_bond_token" } }, { - "name": "is_solver_eligible", + "name": "get_bond_token_min", "request": { "method": "POST", - "header": [{ "key": "Content-Type", "value": "application/json" }], + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], "url": "{{rpc_url}}", "body": { "mode": "raw", - "raw": "{\n \"jsonrpc\": \"2.0\",\n \"id\": \"is_solver_eligible\",\n \"method\": \"simulateTransaction\",\n \"params\": { \"transaction\": \"{{is_solver_eligible_tx_xdr}}\" }\n}" + "raw": "{\n \"jsonrpc\": \"2.0\",\n \"id\": \"get_bond_token_min\",\n \"method\": \"simulateTransaction\",\n \"params\": { \"transaction\": \"{{get_bond_token_min_tx_xdr}}\" }\n}" }, - "description": "Build XDR for: stellar contract invoke --id {{contract_id}} --source {{source_account}} --network testnet -- is_solver_eligible --solver {{solver_address}}" + "description": "Build XDR for: stellar contract invoke --id {{contract_id}} --source {{source_account}} --network testnet -- get_bond_token_min --solver {{solver_address}} --token {{token_address}}" + } + }, + { + "name": "get_config", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": "{{rpc_url}}", + "body": { + "mode": "raw", + "raw": "{\n \"jsonrpc\": \"2.0\",\n \"id\": \"get_config\",\n \"method\": \"simulateTransaction\",\n \"params\": { \"transaction\": \"{{get_config_tx_xdr}}\" }\n}" + }, + "description": "Build XDR for: stellar contract invoke --id {{contract_id}} --source {{source_account}} --network testnet -- get_config" + } + }, + { + "name": "get_effective_intent_state", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": "{{rpc_url}}", + "body": { + "mode": "raw", + "raw": "{\n \"jsonrpc\": \"2.0\",\n \"id\": \"get_effective_intent_state\",\n \"method\": \"simulateTransaction\",\n \"params\": { \"transaction\": \"{{get_effective_intent_state_tx_xdr}}\" }\n}" + }, + "description": "Build XDR for: stellar contract invoke --id {{contract_id}} --source {{source_account}} --network testnet -- get_effective_intent_state --intent_id {{intent_id}}" } }, { "name": "get_fee_recipient", "request": { "method": "POST", - "header": [{ "key": "Content-Type", "value": "application/json" }], + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], "url": "{{rpc_url}}", "body": { "mode": "raw", @@ -97,11 +163,106 @@ "description": "Build XDR for: stellar contract invoke --id {{contract_id}} --source {{source_account}} --network testnet -- get_fee_recipient" } }, + { + "name": "get_intent", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": "{{rpc_url}}", + "body": { + "mode": "raw", + "raw": "{\n \"jsonrpc\": \"2.0\",\n \"id\": \"get_intent\",\n \"method\": \"simulateTransaction\",\n \"params\": { \"transaction\": \"{{get_intent_tx_xdr}}\" }\n}" + }, + "description": "Build XDR for: stellar contract invoke --id {{contract_id}} --source {{source_account}} --network testnet -- get_intent --intent_id {{intent_id}}" + } + }, + { + "name": "get_max_active_intents_per_solver", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": "{{rpc_url}}", + "body": { + "mode": "raw", + "raw": "{\n \"jsonrpc\": \"2.0\",\n \"id\": \"get_max_active_intents_per_solver\",\n \"method\": \"simulateTransaction\",\n \"params\": { \"transaction\": \"{{get_max_active_intents_per_solver_tx_xdr}}\" }\n}" + }, + "description": "Build XDR for: stellar contract invoke --id {{contract_id}} --source {{source_account}} --network testnet -- get_max_active_intents_per_solver" + } + }, + { + "name": "get_min_bond_multiplier", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": "{{rpc_url}}", + "body": { + "mode": "raw", + "raw": "{\n \"jsonrpc\": \"2.0\",\n \"id\": \"get_min_bond_multiplier\",\n \"method\": \"simulateTransaction\",\n \"params\": { \"transaction\": \"{{get_min_bond_multiplier_tx_xdr}}\" }\n}" + }, + "description": "Build XDR for: stellar contract invoke --id {{contract_id}} --source {{source_account}} --network testnet -- get_min_bond_multiplier --solver {{solver_address}}" + } + }, + { + "name": "get_pauser", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": "{{rpc_url}}", + "body": { + "mode": "raw", + "raw": "{\n \"jsonrpc\": \"2.0\",\n \"id\": \"get_pauser\",\n \"method\": \"simulateTransaction\",\n \"params\": { \"transaction\": \"{{get_pauser_tx_xdr}}\" }\n}" + }, + "description": "Build XDR for: stellar contract invoke --id {{contract_id}} --source {{source_account}} --network testnet -- get_pauser" + } + }, + { + "name": "get_pending_admin", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": "{{rpc_url}}", + "body": { + "mode": "raw", + "raw": "{\n \"jsonrpc\": \"2.0\",\n \"id\": \"get_pending_admin\",\n \"method\": \"simulateTransaction\",\n \"params\": { \"transaction\": \"{{get_pending_admin_tx_xdr}}\" }\n}" + }, + "description": "Build XDR for: stellar contract invoke --id {{contract_id}} --source {{source_account}} --network testnet -- get_pending_admin" + } + }, { "name": "get_pending_fee_recipient", "request": { "method": "POST", - "header": [{ "key": "Content-Type", "value": "application/json" }], + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], "url": "{{rpc_url}}", "body": { "mode": "raw", @@ -111,75 +272,141 @@ } }, { - "name": "get_bond_token", + "name": "get_pending_upgrade", "request": { "method": "POST", - "header": [{ "key": "Content-Type", "value": "application/json" }], + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], "url": "{{rpc_url}}", "body": { "mode": "raw", - "raw": "{\n \"jsonrpc\": \"2.0\",\n \"id\": \"get_bond_token\",\n \"method\": \"simulateTransaction\",\n \"params\": { \"transaction\": \"{{get_bond_token_tx_xdr}}\" }\n}" + "raw": "{\n \"jsonrpc\": \"2.0\",\n \"id\": \"get_pending_upgrade\",\n \"method\": \"simulateTransaction\",\n \"params\": { \"transaction\": \"{{get_pending_upgrade_tx_xdr}}\" }\n}" }, - "description": "Build XDR for: stellar contract invoke --id {{contract_id}} --source {{source_account}} --network testnet -- get_bond_token" + "description": "Build XDR for: stellar contract invoke --id {{contract_id}} --source {{source_account}} --network testnet -- get_pending_upgrade" } }, { - "name": "get_admin", + "name": "get_proof_registry", "request": { "method": "POST", - "header": [{ "key": "Content-Type", "value": "application/json" }], + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], "url": "{{rpc_url}}", "body": { "mode": "raw", - "raw": "{\n \"jsonrpc\": \"2.0\",\n \"id\": \"get_admin\",\n \"method\": \"simulateTransaction\",\n \"params\": { \"transaction\": \"{{get_admin_tx_xdr}}\" }\n}" + "raw": "{\n \"jsonrpc\": \"2.0\",\n \"id\": \"get_proof_registry\",\n \"method\": \"simulateTransaction\",\n \"params\": { \"transaction\": \"{{get_proof_registry_tx_xdr}}\" }\n}" }, - "description": "Build XDR for: stellar contract invoke --id {{contract_id}} --source {{source_account}} --network testnet -- get_admin" + "description": "Build XDR for: stellar contract invoke --id {{contract_id}} --source {{source_account}} --network testnet -- get_proof_registry" } }, { - "name": "get_stats", + "name": "get_protocol_health", "request": { "method": "POST", - "header": [{ "key": "Content-Type", "value": "application/json" }], + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], "url": "{{rpc_url}}", "body": { "mode": "raw", - "raw": "{\n \"jsonrpc\": \"2.0\",\n \"id\": \"get_stats\",\n \"method\": \"simulateTransaction\",\n \"params\": { \"transaction\": \"{{get_stats_tx_xdr}}\" }\n}" + "raw": "{\n \"jsonrpc\": \"2.0\",\n \"id\": \"get_protocol_health\",\n \"method\": \"simulateTransaction\",\n \"params\": { \"transaction\": \"{{get_protocol_health_tx_xdr}}\" }\n}" }, - "description": "Build XDR for: stellar contract invoke --id {{contract_id}} --source {{source_account}} --network testnet -- get_stats" + "description": "Build XDR for: stellar contract invoke --id {{contract_id}} --source {{source_account}} --network testnet -- get_protocol_health" } }, { - "name": "get_min_bond", + "name": "get_reputation_score", "request": { "method": "POST", - "header": [{ "key": "Content-Type", "value": "application/json" }], + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], "url": "{{rpc_url}}", "body": { "mode": "raw", - "raw": "{\n \"jsonrpc\": \"2.0\",\n \"id\": \"get_min_bond\",\n \"method\": \"simulateTransaction\",\n \"params\": { \"transaction\": \"{{get_min_bond_tx_xdr}}\" }\n}" + "raw": "{\n \"jsonrpc\": \"2.0\",\n \"id\": \"get_reputation_score\",\n \"method\": \"simulateTransaction\",\n \"params\": { \"transaction\": \"{{get_reputation_score_tx_xdr}}\" }\n}" }, - "description": "Build XDR for: stellar contract invoke --id {{contract_id}} --source {{source_account}} --network testnet -- get_min_bond" + "description": "Build XDR for: stellar contract invoke --id {{contract_id}} --source {{source_account}} --network testnet -- get_reputation_score --solver {{solver_address}}" } }, { - "name": "list_intents_by_user", + "name": "get_solver", "request": { "method": "POST", - "header": [{ "key": "Content-Type", "value": "application/json" }], + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], "url": "{{rpc_url}}", "body": { "mode": "raw", - "raw": "{\n \"jsonrpc\": \"2.0\",\n \"id\": \"list_intents_by_user\",\n \"method\": \"simulateTransaction\",\n \"params\": { \"transaction\": \"{{list_intents_by_user_tx_xdr}}\" }\n}" + "raw": "{\n \"jsonrpc\": \"2.0\",\n \"id\": \"get_solver\",\n \"method\": \"simulateTransaction\",\n \"params\": { \"transaction\": \"{{get_solver_tx_xdr}}\" }\n}" }, - "description": "Build XDR for: stellar contract invoke --id {{contract_id}} --source {{source_account}} --network testnet -- list_intents_by_user --user {{user_address}}" + "description": "Build XDR for: stellar contract invoke --id {{contract_id}} --source {{source_account}} --network testnet -- get_solver --solver {{solver_address}}" + } + }, + { + "name": "get_solver_bond", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": "{{rpc_url}}", + "body": { + "mode": "raw", + "raw": "{\n \"jsonrpc\": \"2.0\",\n \"id\": \"get_solver_bond\",\n \"method\": \"simulateTransaction\",\n \"params\": { \"transaction\": \"{{get_solver_bond_tx_xdr}}\" }\n}" + }, + "description": "Build XDR for: stellar contract invoke --id {{contract_id}} --source {{source_account}} --network testnet -- get_solver_bond --solver {{solver_address}} --token {{token_address}}" + } + }, + { + "name": "get_solver_bonds", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": "{{rpc_url}}", + "body": { + "mode": "raw", + "raw": "{\n \"jsonrpc\": \"2.0\",\n \"id\": \"get_solver_bonds\",\n \"method\": \"simulateTransaction\",\n \"params\": { \"transaction\": \"{{get_solver_bonds_tx_xdr}}\" }\n}" + }, + "description": "Build XDR for: stellar contract invoke --id {{contract_id}} --source {{source_account}} --network testnet -- get_solver_bonds --solver {{solver_address}}" } }, { "name": "get_solver_count", "request": { "method": "POST", - "header": [{ "key": "Content-Type", "value": "application/json" }], + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], "url": "{{rpc_url}}", "body": { "mode": "raw", @@ -189,17 +416,274 @@ } }, { - "name": "get_protocol_health", + "name": "get_solver_intents", "request": { "method": "POST", - "header": [{ "key": "Content-Type", "value": "application/json" }], + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], "url": "{{rpc_url}}", "body": { "mode": "raw", - "raw": "{\n \"jsonrpc\": \"2.0\",\n \"id\": \"get_protocol_health\",\n \"method\": \"simulateTransaction\",\n \"params\": { \"transaction\": \"{{get_protocol_health_tx_xdr}}\" }\n}" + "raw": "{\n \"jsonrpc\": \"2.0\",\n \"id\": \"get_solver_intents\",\n \"method\": \"simulateTransaction\",\n \"params\": { \"transaction\": \"{{get_solver_intents_tx_xdr}}\" }\n}" }, - "description": "Build XDR for: stellar contract invoke --id {{contract_id}} --source {{source_account}} --network testnet -- get_protocol_health" + "description": "Build XDR for: stellar contract invoke --id {{contract_id}} --source {{source_account}} --network testnet -- get_solver_intents --solver {{solver_address}}" + } + }, + { + "name": "get_solver_routes", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": "{{rpc_url}}", + "body": { + "mode": "raw", + "raw": "{\n \"jsonrpc\": \"2.0\",\n \"id\": \"get_solver_routes\",\n \"method\": \"simulateTransaction\",\n \"params\": { \"transaction\": \"{{get_solver_routes_tx_xdr}}\" }\n}" + }, + "description": "Build XDR for: stellar contract invoke --id {{contract_id}} --source {{source_account}} --network testnet -- get_solver_routes --solver {{solver_address}}" + } + }, + { + "name": "get_stats", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": "{{rpc_url}}", + "body": { + "mode": "raw", + "raw": "{\n \"jsonrpc\": \"2.0\",\n \"id\": \"get_stats\",\n \"method\": \"simulateTransaction\",\n \"params\": { \"transaction\": \"{{get_stats_tx_xdr}}\" }\n}" + }, + "description": "Build XDR for: stellar contract invoke --id {{contract_id}} --source {{source_account}} --network testnet -- get_stats" + } + }, + { + "name": "get_token_stats", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": "{{rpc_url}}", + "body": { + "mode": "raw", + "raw": "{\n \"jsonrpc\": \"2.0\",\n \"id\": \"get_token_stats\",\n \"method\": \"simulateTransaction\",\n \"params\": { \"transaction\": \"{{get_token_stats_tx_xdr}}\" }\n}" + }, + "description": "Build XDR for: stellar contract invoke --id {{contract_id}} --source {{source_account}} --network testnet -- get_token_stats --token {{token_address}}" + } + }, + { + "name": "is_allowed_bond_token", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": "{{rpc_url}}", + "body": { + "mode": "raw", + "raw": "{\n \"jsonrpc\": \"2.0\",\n \"id\": \"is_allowed_bond_token\",\n \"method\": \"simulateTransaction\",\n \"params\": { \"transaction\": \"{{is_allowed_bond_token_tx_xdr}}\" }\n}" + }, + "description": "Build XDR for: stellar contract invoke --id {{contract_id}} --source {{source_account}} --network testnet -- is_allowed_bond_token --solver {{solver_address}}" + } + }, + { + "name": "is_bid_window_enabled", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": "{{rpc_url}}", + "body": { + "mode": "raw", + "raw": "{\n \"jsonrpc\": \"2.0\",\n \"id\": \"is_bid_window_enabled\",\n \"method\": \"simulateTransaction\",\n \"params\": { \"transaction\": \"{{is_bid_window_enabled_tx_xdr}}\" }\n}" + }, + "description": "Build XDR for: stellar contract invoke --id {{contract_id}} --source {{source_account}} --network testnet -- is_bid_window_enabled" + } + }, + { + "name": "is_dst_allowlist_enabled", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": "{{rpc_url}}", + "body": { + "mode": "raw", + "raw": "{\n \"jsonrpc\": \"2.0\",\n \"id\": \"is_dst_allowlist_enabled\",\n \"method\": \"simulateTransaction\",\n \"params\": { \"transaction\": \"{{is_dst_allowlist_enabled_tx_xdr}}\" }\n}" + }, + "description": "Build XDR for: stellar contract invoke --id {{contract_id}} --source {{source_account}} --network testnet -- is_dst_allowlist_enabled" + } + }, + { + "name": "is_dst_token_allowed", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": "{{rpc_url}}", + "body": { + "mode": "raw", + "raw": "{\n \"jsonrpc\": \"2.0\",\n \"id\": \"is_dst_token_allowed\",\n \"method\": \"simulateTransaction\",\n \"params\": { \"transaction\": \"{{is_dst_token_allowed_tx_xdr}}\" }\n}" + }, + "description": "Build XDR for: stellar contract invoke --id {{contract_id}} --source {{source_account}} --network testnet -- is_dst_token_allowed --solver {{solver_address}}" + } + }, + { + "name": "is_paused", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": "{{rpc_url}}", + "body": { + "mode": "raw", + "raw": "{\n \"jsonrpc\": \"2.0\",\n \"id\": \"is_paused\",\n \"method\": \"simulateTransaction\",\n \"params\": { \"transaction\": \"{{is_paused_tx_xdr}}\" }\n}" + }, + "description": "Build XDR for: stellar contract invoke --id {{contract_id}} --source {{source_account}} --network testnet -- is_paused" + } + }, + { + "name": "is_solver_eligible", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": "{{rpc_url}}", + "body": { + "mode": "raw", + "raw": "{\n \"jsonrpc\": \"2.0\",\n \"id\": \"is_solver_eligible\",\n \"method\": \"simulateTransaction\",\n \"params\": { \"transaction\": \"{{is_solver_eligible_tx_xdr}}\" }\n}" + }, + "description": "Build XDR for: stellar contract invoke --id {{contract_id}} --source {{source_account}} --network testnet -- is_solver_eligible --solver {{solver_address}}" + } + }, + { + "name": "is_src_chain_allowed", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": "{{rpc_url}}", + "body": { + "mode": "raw", + "raw": "{\n \"jsonrpc\": \"2.0\",\n \"id\": \"is_src_chain_allowed\",\n \"method\": \"simulateTransaction\",\n \"params\": { \"transaction\": \"{{is_src_chain_allowed_tx_xdr}}\" }\n}" + }, + "description": "Build XDR for: stellar contract invoke --id {{contract_id}} --source {{source_account}} --network testnet -- is_src_chain_allowed" + } + }, + { + "name": "is_src_chain_allowlist_enabled", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": "{{rpc_url}}", + "body": { + "mode": "raw", + "raw": "{\n \"jsonrpc\": \"2.0\",\n \"id\": \"is_src_chain_allowlist_enabled\",\n \"method\": \"simulateTransaction\",\n \"params\": { \"transaction\": \"{{is_src_chain_allowlist_enabled_tx_xdr}}\" }\n}" + }, + "description": "Build XDR for: stellar contract invoke --id {{contract_id}} --source {{source_account}} --network testnet -- is_src_chain_allowlist_enabled" + } + }, + { + "name": "list_allowed_dst_tokens", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": "{{rpc_url}}", + "body": { + "mode": "raw", + "raw": "{\n \"jsonrpc\": \"2.0\",\n \"id\": \"list_allowed_dst_tokens\",\n \"method\": \"simulateTransaction\",\n \"params\": { \"transaction\": \"{{list_allowed_dst_tokens_tx_xdr}}\" }\n}" + }, + "description": "Build XDR for: stellar contract invoke --id {{contract_id}} --source {{source_account}} --network testnet -- list_allowed_dst_tokens" + } + }, + { + "name": "list_intents_by_user", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": "{{rpc_url}}", + "body": { + "mode": "raw", + "raw": "{\n \"jsonrpc\": \"2.0\",\n \"id\": \"list_intents_by_user\",\n \"method\": \"simulateTransaction\",\n \"params\": { \"transaction\": \"{{list_intents_by_user_tx_xdr}}\" }\n}" + }, + "description": "Build XDR for: stellar contract invoke --id {{contract_id}} --source {{source_account}} --network testnet -- list_intents_by_user --user {{user_address}}" + } + }, + { + "name": "list_solvers", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": "{{rpc_url}}", + "body": { + "mode": "raw", + "raw": "{\n \"jsonrpc\": \"2.0\",\n \"id\": \"list_solvers\",\n \"method\": \"simulateTransaction\",\n \"params\": { \"transaction\": \"{{list_solvers_tx_xdr}}\" }\n}" + }, + "description": "Build XDR for: stellar contract invoke --id {{contract_id}} --source {{source_account}} --network testnet -- list_solvers --start {{start}} --limit {{limit}}" } } ] -} +} \ No newline at end of file diff --git a/scripts/check-view-calls-sync.sh b/scripts/check-view-calls-sync.sh new file mode 100755 index 0000000..e8cafee --- /dev/null +++ b/scripts/check-view-calls-sync.sh @@ -0,0 +1,66 @@ +#!/bin/bash +# +# Issue #290: Check that examples/view-calls.postman_collection.json is in sync +# with the actual read-only view functions in intent_settlement/src/lib.rs. +# +# This script: +# 1. Extracts all pub fn get_*/is_*/list_* functions from intent_settlement/src/lib.rs +# 2. Verifies each one has a corresponding request in the Postman collection +# 3. Fails (exit 1) if drift is detected, succeeds (exit 0) if in sync +# +# Heuristic: a function is considered a "view" if it: +# - Starts with get_, is_, or list_ (read-only naming convention) +# - Is declared as "pub fn" +# This is conservative and may need refinement as the contract evolves. + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(dirname "$SCRIPT_DIR")" + +CONTRACT_FILE="$REPO_ROOT/intent_settlement/src/lib.rs" +COLLECTION_FILE="$REPO_ROOT/examples/view-calls.postman_collection.json" + +if [ ! -f "$CONTRACT_FILE" ]; then + echo "Error: $CONTRACT_FILE not found" + exit 1 +fi + +if [ ! -f "$COLLECTION_FILE" ]; then + echo "Error: $COLLECTION_FILE not found" + exit 1 +fi + +# Extract all view function names from the contract (get_*, is_*, list_*) +echo "Extracting view functions from $CONTRACT_FILE..." +EXPECTED_VIEWS=$(grep "pub fn" "$CONTRACT_FILE" | \ + sed 's/.*pub fn //' | \ + sed 's/(.*$//' | \ + grep -E "^(get_|is_|list_)" | \ + sort -u) + +# Extract all request names from the Postman collection +echo "Extracting requests from $COLLECTION_FILE..." +COLLECTION_VIEWS=$(jq -r '.item[].name' "$COLLECTION_FILE" | sort -u) + +# Compare +echo "" +echo "=== View Functions in Contract ===" +echo "$EXPECTED_VIEWS" +echo "" +echo "=== Requests in Postman Collection ===" +echo "$COLLECTION_VIEWS" +echo "" + +# Find functions missing from collection +MISSING=$(comm -23 <(echo "$EXPECTED_VIEWS") <(echo "$COLLECTION_VIEWS")) + +if [ -n "$MISSING" ]; then + echo "❌ DRIFT DETECTED: The following view functions are missing from the collection:" + echo "$MISSING" | sed 's/^/ - /' + echo "" + exit 1 +else + echo "✓ Collection is in sync with contract view functions" + exit 0 +fi diff --git a/scripts/generate-view-calls-collection.py b/scripts/generate-view-calls-collection.py new file mode 100644 index 0000000..f2d04ee --- /dev/null +++ b/scripts/generate-view-calls-collection.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 +""" +Issue #290: Generate updated Postman collection and markdown doc with all view functions. + +This script: +1. Extracts all read-only view functions from intent_settlement/src/lib.rs +2. Creates collection entries for missing functions +3. Updates examples/view-calls.postman_collection.json +4. Updates examples/view-calls.md with complete function list +""" + +import json +import re +import sys +from pathlib import Path + + +def extract_view_functions(lib_rs_path): + """Extract all pub fn get_*/is_*/list_* from lib.rs.""" + with open(lib_rs_path, 'r') as f: + content = f.read() + + # Match "pub fn (" where name starts with get_, is_, or list_ + pattern = r'pub\s+fn\s+((get_|is_|list_)\w+)\s*\(' + matches = re.findall(pattern, content) + + # Extract just the function names and sort + functions = sorted(list(set([m[0] for m in matches]))) + return functions + + +def load_collection(collection_path): + """Load the Postman collection.""" + with open(collection_path, 'r') as f: + return json.load(f) + + +def save_collection(collection_path, collection): + """Save the Postman collection.""" + with open(collection_path, 'w') as f: + json.dump(collection, f, indent=2) + + +def create_request_template(func_name): + """Create a Postman request template for a view function.""" + # Derive variable name from function name + var_name = f"{func_name}_tx_xdr" + + # Build description with example command (simplified) + description = f"Build XDR for: stellar contract invoke --id {{{{contract_id}}}} --source {{{{source_account}}}} --network testnet -- {func_name}" + + # Add common parameters for functions that take arguments + if func_name in ['get_intent', 'get_solver', 'get_reputation_score', 'get_solver_intents', + 'get_token_stats', 'get_solver_routes', 'get_solver_bond', 'get_solver_bonds', + 'get_bond_token_min', 'is_allowed_bond_token', 'is_dst_token_allowed', + 'get_min_bond_multiplier', 'is_src_chain_allowed', 'list_intents_by_user', + 'list_solvers', 'get_best_bid', 'get_effective_intent_state']: + # These take parameters - adjust description + if func_name == 'get_intent' or func_name == 'get_effective_intent_state' or func_name == 'get_best_bid': + description = description.replace('-- ' + func_name, f"-- {func_name} --intent_id {{{{intent_id}}}}") + elif func_name in ['get_solver', 'get_reputation_score', 'is_allowed_bond_token', + 'get_solver_intents', 'get_solver_routes', 'is_dst_token_allowed', + 'get_min_bond_multiplier']: + description = description.replace('-- ' + func_name, f"-- {func_name} --solver {{{{solver_address}}}}") + elif func_name in ['get_solver_bond', 'get_bond_token_min']: + description = description.replace('-- ' + func_name, f"-- {func_name} --solver {{{{solver_address}}}} --token {{{{token_address}}}}") + elif func_name == 'get_solver_bonds': + description = description.replace('-- ' + func_name, f"-- {func_name} --solver {{{{solver_address}}}}") + elif func_name == 'get_token_stats': + description = description.replace('-- ' + func_name, f"-- {func_name} --token {{{{token_address}}}}") + elif func_name == 'list_intents_by_user': + description = description.replace('-- ' + func_name, f"-- {func_name} --user {{{{user_address}}}}") + elif func_name == 'list_solvers': + description = description.replace('-- ' + func_name, f"-- {func_name} --start {{{{start}}}} --limit {{{{limit}}}}") + + return { + "name": func_name, + "request": { + "method": "POST", + "header": [{"key": "Content-Type", "value": "application/json"}], + "url": "{{rpc_url}}", + "body": { + "mode": "raw", + "raw": f'{{\n "jsonrpc": "2.0",\n "id": "{func_name}",\n "method": "simulateTransaction",\n "params": {{ "transaction": "{{{{{var_name}}}}}" }}\n}}' + }, + "description": description + } + } + + +def update_collection(collection_path, view_functions): + """Update collection with missing view functions.""" + collection = load_collection(collection_path) + + # Get existing function names + existing_names = {item['name'] for item in collection['item']} + + # Find missing functions + missing = [f for f in view_functions if f not in existing_names] + + print(f"Found {len(existing_names)} existing requests in collection") + print(f"Found {len(view_functions)} total view functions in contract") + print(f"Adding {len(missing)} missing functions...") + + # Add missing requests + for func_name in missing: + request = create_request_template(func_name) + collection['item'].append(request) + print(f" + {func_name}") + + # Sort items by name for consistency + collection['item'] = sorted(collection['item'], key=lambda x: x['name']) + + save_collection(collection_path, collection) + print(f"\nUpdated collection saved to {collection_path}") + + return missing + + +def update_markdown(markdown_path, view_functions): + """Update markdown documentation with complete view function list.""" + # Read current markdown + with open(markdown_path, 'r') as f: + content = f.read() + + # Replace the function list section + function_list = "\n".join([f"- `{func}`" for func in sorted(view_functions)]) + + new_content = re.sub( + r'(## Import\n\n`view-calls\.postman_collection\.json` contains one example Stellar RPC\n`simulateTransaction` request for every read-only view in\n`intent_settlement/src/lib\.rs`:\n\n)(.*?)(\n\n## Import)', + f'\\1{function_list}\\3', + content, + count=1, + flags=re.DOTALL + ) + + # If the above didn't match, try a different pattern + if new_content == content: + # Try to find and replace the list directly + list_start = content.find("- `get_protocol_params`") + list_end = content.find("\n\n##", list_start) + if list_start != -1 and list_end != -1: + before = content[:list_start] + after = content[list_end:] + new_content = before + function_list + after + + with open(markdown_path, 'w') as f: + f.write(new_content) + + print(f"Updated markdown saved to {markdown_path}") + + +def main(): + repo_root = Path(__file__).parent.parent + lib_rs = repo_root / "intent_settlement" / "src" / "lib.rs" + collection_path = repo_root / "examples" / "view-calls.postman_collection.json" + markdown_path = repo_root / "examples" / "view-calls.md" + + if not lib_rs.exists(): + print(f"Error: {lib_rs} not found", file=sys.stderr) + sys.exit(1) + + if not collection_path.exists(): + print(f"Error: {collection_path} not found", file=sys.stderr) + sys.exit(1) + + # Extract view functions + print(f"Extracting view functions from {lib_rs}...") + view_functions = extract_view_functions(str(lib_rs)) + print(f"Found {len(view_functions)} view functions") + + # Update collection + missing = update_collection(str(collection_path), view_functions) + + # Update markdown + update_markdown(str(markdown_path), view_functions) + + if missing: + print(f"\n✓ Added {len(missing)} missing view functions to collection and markdown") + return 0 + else: + print("\n✓ Collection is already in sync") + return 0 + + +if __name__ == "__main__": + sys.exit(main())