diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index deeddb7b..e5627d19 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,6 +19,16 @@ jobs: - name: Run validation script run: python3 scripts/validate_deployments.py + validate-changelog: + name: Validate Changelog + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Check CHANGELOG.md format (Keep a Changelog) + run: python3 scripts/validate_changelog.py + - name: Run changelog validator self-tests + run: python3 scripts/test_validate_changelog.py + fmt: name: Formatting runs-on: ubuntu-latest @@ -128,14 +138,29 @@ jobs: path: | ~/.cargo/registry ~/.cargo/git - ~/.cargo/bin target key: ${{ runner.os }}-optimize-${{ hashFiles('rust-toolchain.toml', 'Cargo.lock', 'dongle-smartcontract/Cargo.lock') }} restore-keys: | ${{ runner.os }}-optimize- - - name: Install Stellar CLI with wasm-opt - run: cargo install --locked stellar-cli --features opt + # Installed as a prebuilt release binary rather than `cargo install + # stellar-cli`: the published crate now declares an MSRV newer than the + # toolchain this repository pins in rust-toolchain.toml, so building it + # from source fails immediately. The prebuilt binary already bundles + # wasm-opt (the `opt` feature) and installs in seconds instead of minutes. + - name: Install Stellar CLI (prebuilt, includes wasm-opt) + env: + STELLAR_CLI_VERSION: 27.1.0 + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/stellar-cli" + curl -sSfL \ + "https://github.com/stellar/stellar-cli/releases/download/v${STELLAR_CLI_VERSION}/stellar-cli-${STELLAR_CLI_VERSION}-x86_64-unknown-linux-gnu.tar.gz" \ + | tar -xz -C "$RUNNER_TEMP/stellar-cli" + echo "$RUNNER_TEMP/stellar-cli" >> "$GITHUB_PATH" + + - name: Verify Stellar CLI + run: stellar --version - name: Build WASM release run: cargo build -p dongle-contract --target wasm32-unknown-unknown --release diff --git a/.gitignore b/.gitignore index 1725f2fd..80ab794c 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,7 @@ dongle-smartcontract/test_output*.txt .agents/ skills-lock.json issue.md +# Python tooling caches (scripts/validate_*.py, scripts/test_*.py) +__pycache__/ +*.py[cod] +.pytest_cache/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 82727d5f..2a4309c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,52 @@ # Changelog -All notable changes to the Dongle Smart Contract are documented in this file. +All notable changes to the **Dongle Smart Contract** are documented in this file. -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +The format is based on [Keep a Changelog 1.1.0](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## How to read this file + +- Every release has its own `## [MAJOR.MINOR.PATCH] - YYYY-MM-DD` heading, newest first. +- Changes are grouped under the Keep a Changelog categories: `Added`, `Changed`, + `Deprecated`, `Removed`, `Fixed`, `Security`. +- Entries marked **BREAKING** change an on-chain interface (function signature, + storage key, event topic, or error code) and require operator action before or + during upgrade. See [`DEPLOYMENT.md`](DEPLOYMENT.md) for the upgrade procedure. +- Unreleased work lands under `## [Unreleased]` and is folded into the next + version at release time. + +## How to add an entry + +Contributors must add a bullet to `## [Unreleased]` in the same pull request as +their change. Keep entries user-facing, one line each, and reference the issue or +PR number where available: + +```markdown +## [Unreleased] + +### Added + +- New `get_config` view returning public contract configuration (#202). +``` + +Run `python3 scripts/validate_changelog.py` locally before pushing; CI runs the +same check. See [`docs/CONTRIBUTING.md`](docs/CONTRIBUTING.md#6-changelog-entries) +for the full policy. + +--- + ## [Unreleased] +### Added + +- `CHANGELOG.md` policy tooling: `scripts/validate_changelog.py` validates the + Keep a Changelog structure (heading order, SemVer versions, ISO dates, + allowed section names, link references, and crate-version agreement) and is + wired into CI as the `validate-changelog` job (#505). +- Changelog contribution policy documented in `docs/CONTRIBUTING.md`, including + a PR checklist item requiring an `Unreleased` entry. + ### Changed - **Repository hygiene:** Consolidated repository-root documentation. Reference @@ -18,111 +58,174 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Updated stale documentation links in `README.md`, `docs/`, `dongle-smartcontract/README.md`, and `bug-bounty/README.md` to point at the consolidated `docs/` paths. - -## [2026-08-01] - Dispute Flow, Pagination & Registry Hygiene +- Pinned the Rust toolchain (1.85.0) in CI workflows for reproducible builds + (#514). +- Timelocked admin proposals now verify the proposal payload hash before + execution. + +### Removed + +- Committed test output text files (`test_output.txt`, `test_final.txt`, + `test_output_latest.txt`) from the `dongle-smartcontract/` directory (#503). + +### Fixed + +- Documented previously undocumented verification events in + `docs/EVENTS_SCHEMA.md` (#508). +- Applied `cargo fmt --all` across the workspace, clearing the pre-existing + `rustfmt` drift in 18 source files that was failing the CI `Formatting` job + and blocking the `Build Contract` and `Optimize WASM` jobs. Formatting only — + no logic, signature, storage key, event or error code was changed. +- CI `Optimize WASM` job: install the Stellar CLI from its prebuilt release + binary (pinned to 27.1.0) instead of `cargo install --locked stellar-cli + --features opt`. The published crate now requires a rustc newer than the + toolchain pinned in `rust-toolchain.toml` (1.85.0), so building it from + source failed immediately; the prebuilt binary already bundles `wasm-opt` + and installs in seconds. +- `scripts/optimize_wasm.sh` now selects an optimizer at runtime + (`stellar contract optimize`, then `wasm-opt -Oz`), verifies the output file + was produced, and exits with actionable install instructions when no + optimizer is available. + +## [0.6.0] - 2026-08-01 + +_Dispute flow, pagination and registry hygiene._ ### Added - Duplicate project dispute flow (#151). -- Possible-next-state coverage for verification state machine. -- Project changelog CID feature (#157). +- Possible-next-state coverage for the verification state machine. +- Project changelog CID feature, letting projects publish their own release + notes off-chain (#157). ### Changed -- Renamed offset pagination cursor parameters to `start_index` (#351). -- Unified claim status handling across both claim workflows (#357). -- Consolidated registry helpers (`remove_item_from_vec`), removed unused - `FeeRefundRecord` scaffolding, and repaired `utils.rs` corruption. -- CI fix: return contract errors instead of raw panics for timelock and - endorsement operations. +- **BREAKING:** Renamed offset pagination cursor parameters to `start_index` + across all paginated views; callers passing the old parameter name must be + updated (#351). +- **BREAKING:** Unified claim status handling across both claim workflows, so a + single `ClaimStatus` enum is now returned by both paths (#357). +- Consolidated registry helpers into `remove_item_from_vec` and removed the + unused `FeeRefundRecord` scaffolding. + +### Fixed + +- Repaired corruption in `utils.rs` and restored the missing + `validate_report_reason_cid` function (#408). +- Timelock and endorsement operations now return `ContractError` values instead + of raw panics. + +## [0.5.0] - 2026-07-01 -## [2026-07-01] - Verification Expiry, Anti-Sybil & Safety Hardening +_Verification expiry, anti-Sybil controls and safety hardening._ ### Added - Verification expiry implementation (#131). -- Anti-Sybil review constraints (reviewer eligibility age, endorsements, - review fees). -- Emergency stop / contract pause with admin pause toggle. -- `get_config` view for public configuration read access. +- Anti-Sybil review constraints: reviewer eligibility age, endorsement + requirements and review fees. +- Emergency stop / contract pause with an admin pause toggle. +- `get_config` view for public configuration read access (#202). - `MIN_STRING_LEN` validation constants. - Admin console contract configuration view. ### Changed -- Replaced raw panics with `ContractError` variants; added 22 missing - `ContractError` variants (61-82). -- Collapsed ~20 near-identical `extend_*_ttl` storage helpers into a single - generic helper (#338). -- Consolidated CID validators and error handling. -- Extended `.gitignore` coverage for test output files; Makefile cleanup - (#384). +- **BREAKING:** Replaced raw panics with `ContractError` variants and added 22 + new error codes (61-82); integrators must map the new codes. See + [`docs/ERROR_CODES.md`](docs/ERROR_CODES.md). +- Collapsed roughly 20 near-identical `extend_*_ttl` storage helpers into a + single generic helper (#338). +- Consolidated CID validators and their error handling. +- Extended `.gitignore` coverage for test output files and cleaned up the + Makefile (#384). -## [2026-06-01] - Fee Handling, Endorsements & Deployment Readiness +### Security + +- Emergency pause blocks state-mutating entry points while active, limiting + blast radius during incident response. + +## [0.4.0] - 2026-06-01 + +_Fee handling, endorsements and deployment readiness._ ### Added - Comprehensive fee payment error handling and token transfer failure tests. -- Fee refund flow, expiry, config history, and SLA tracking. -- Native asset fee guardrails and token-only fee policy clarification. -- Historical verification records and admin multisig approval threshold. +- Fee refund flow, fee expiry, configuration history and SLA tracking. +- Native asset fee guardrails and a token-only fee policy. +- Historical verification records and an admin multisig approval threshold. - Metadata freeze policy for verified projects. - Project endorsements. -- Bug bounty metadata and `bounty_url` on projects. +- Bug bounty metadata and a `bounty_url` field on projects. - Optional project license field. -- Verification evidence updates while pending. -- Fee boundary validation, review tombstones, sorting, and review update cooldown. -- Fee payment payer getter, reserved names, verification assignment, and logo +- Verification evidence updates while a request is pending. +- Fee boundary validation, review tombstones, review sorting and a review + update cooldown. +- Fee payment payer getter, reserved names, verification assignment and logo asset guidelines. -- WASM optimization, invariant tests, and property-based pagination tests. +- WASM size optimization, invariant tests and property-based pagination tests. - Weighted rating calculation. ### Changed -- Soroban deployment and schema documentation. -- Example/schema JSON files co-located in `docs/`. +- Expanded Soroban deployment and schema documentation. +- Co-located example and schema JSON files in `docs/`. -## [2026-04-01] - TTL Management, Archive & Review Moderation +## [0.3.0] - 2026-04-01 + +_TTL management, archival and review moderation._ ### Added - TTL (time-to-live) management for data persistence. - Admin action logging. -- Project archive & reactivate feature. -- Project slug (URL-friendly stable identifiers). -- Review moderation (report/hide) feature. +- Project archive and reactivate feature. +- Project slugs (URL-friendly stable identifiers). +- Review moderation (report / hide) feature. ### Changed -- Verification evidence schema and update flows. +- Reworked the verification evidence schema and update flows. + +## [0.2.0] - 2026-02-01 -## [2026-02-01] - Extended Feature Set +_Extended feature set._ ### Added - Admin role management and access control system. - Project verification system with automated fee handling and admin controls. -- Review timestamps and review security/auditability improvements. +- Review timestamps plus review security and auditability improvements. - Strict project name validation and normalization policy. - Auth-matrix test coverage (#215). -- Region metadata, integrity hash, owner review block, and event snapshot - tests. +- Region metadata, integrity hash, owner review block and event snapshot tests. - Verified contract claims and sorting options. -- Project metadata CID schema and admin rotation playbook. +- Project metadata CID schema and the admin rotation playbook. + +## [0.1.0] - 2025-09-25 -## [2025-09-25] - Initial Contract +_Initial contract._ ### Added -- Initial Rust/Soroban smart contract structure. -- Core Dongle smart contract modules: project, review, verification, and fee - management, with associated types, events, and error handling. +- Initial Rust / Soroban smart contract structure. +- Core Dongle modules: project, review, verification and fee management, with + associated types, events and error handling. - On-chain review events and tests. - Dynamic ratings: type definitions, rating calculation module, `add_review`, - `update_review`, and `delete_review` with aggregate recalculation. + `update_review` and `delete_review` with aggregate recalculation. - Category enumeration and unique sequential project IDs. - Project registration with duplicate-prevention metadata validation. -- Project listing and retrieval by id/owner. +- Project listing and retrieval by id and by owner. - Emitted `ProjectRegistered` event. - Project review submission with IPFS CID. -- Review soft deletion and rating recalculation. +- Review soft deletion with rating recalculation. + +[Unreleased]: https://github.com/felladaniel36-hash/Dongle-Smartcontract/compare/v0.6.0...HEAD +[0.6.0]: https://github.com/felladaniel36-hash/Dongle-Smartcontract/compare/v0.5.0...v0.6.0 +[0.5.0]: https://github.com/felladaniel36-hash/Dongle-Smartcontract/compare/v0.4.0...v0.5.0 +[0.4.0]: https://github.com/felladaniel36-hash/Dongle-Smartcontract/compare/v0.3.0...v0.4.0 +[0.3.0]: https://github.com/felladaniel36-hash/Dongle-Smartcontract/compare/v0.2.0...v0.3.0 +[0.2.0]: https://github.com/felladaniel36-hash/Dongle-Smartcontract/compare/v0.1.0...v0.2.0 +[0.1.0]: https://github.com/felladaniel36-hash/Dongle-Smartcontract/releases/tag/v0.1.0 diff --git a/Cargo.lock b/Cargo.lock index dd3a7caa..31e26607 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -485,7 +485,7 @@ dependencies = [ [[package]] name = "dongle-contract" -version = "0.1.0" +version = "0.6.0" dependencies = [ "proptest", "soroban-sdk", diff --git a/README.md b/README.md index a163a970..7d0ff843 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ For detailed information, refer to: - **[Error Code Reference](docs/ERROR_CODES.md)** — Contract error codes and their meanings - **[Data Export Guide](docs/DATA_EXPORT_GUIDE.md)** — How indexers reconstruct contract state - **[Contributing Guidelines](docs/CONTRIBUTING.md)** — How to contribute, test, and submit PRs -- **[Changelog](CHANGELOG.md)** — Release history and notable changes +- **[Changelog](CHANGELOG.md)** — Release history, breaking changes and feature additions ([Keep a Changelog](https://keepachangelog.com/en/1.1.0/) + SemVer) ## Quick Start @@ -280,7 +280,7 @@ Dongle promotes: - [STORAGE_INDEXES.md](docs/STORAGE_INDEXES.md) - Storage index size strategy and read pagination - [CONTRACT_INTERFACE.md](docs/CONTRACT_INTERFACE.md) - Full contract function documentation - [CONTRIBUTING.md](docs/CONTRIBUTING.md) - Contribution guidelines and PR process -- [CHANGELOG.md](CHANGELOG.md) - Release history and notable changes +- [CHANGELOG.md](CHANGELOG.md) - Release history, breaking changes and feature additions (Keep a Changelog 1.1.0 format; validated in CI by `scripts/validate_changelog.py`) - [review-cid.schema.json](docs/review-cid.schema.json) - Off-chain JSON schema for review content CIDs - [review-cid.example.json](docs/review-cid.example.json) - Valid off-chain JSON review example - [verification-evidence.schema.json](verification-evidence.schema.json) - Off-chain JSON schema for verification evidence CIDs diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 713a1e1e..98e4ca2f 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -59,6 +59,7 @@ Before opening a Pull Request, verify that: - [ ] Target WASM contract builds successfully. - [ ] Existing API contracts and backward compatibility are maintained. - [ ] Clear documentation is provided for any new functions, events, or storage keys. +- [ ] A `CHANGELOG.md` entry was added under `## [Unreleased]` (see [section 6](#6-changelog-entries)) and `python3 scripts/validate_changelog.py` passes. ### PR Description Template When creating a PR, provide a structured description following this template: @@ -84,6 +85,9 @@ When creating a PR, provide a structured description following this template: - Description of tests added or updated - Commands executed to verify changes: `cargo test ` +## Changelog +- [ ] Entry added to `CHANGELOG.md` under `## [Unreleased]` (category: Added / Changed / Deprecated / Removed / Fixed / Security) + ## Related Issues Closes #[issue_number] ``` @@ -124,3 +128,72 @@ gh pr create \ ``` - **CI Test Failures**: If CI checks fail, reproduce locally via `cargo test`, apply necessary fixes, commit, and push updates to your feature branch. + +--- + +## 6. Changelog Entries + +This repository tracks version history in [`CHANGELOG.md`](../CHANGELOG.md), which +follows [Keep a Changelog 1.1.0](https://keepachangelog.com/en/1.1.0/) and +[Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +### Policy + +- **Every user-visible change requires a changelog entry in the same PR.** + Purely internal changes (test-only refactors, CI tweaks with no operator + impact, typo fixes) may be skipped. +- Add your bullet under `## [Unreleased]`, inside one of the six allowed + categories: `Added`, `Changed`, `Deprecated`, `Removed`, `Fixed`, `Security`. +- Create the category heading only if it does not already exist in + `## [Unreleased]`. +- Write one line per change, in the imperative/descriptive past style used by + existing entries, and reference the issue or PR number: `(#123)`. +- Prefix any change to an on-chain interface (function signature, storage key, + event topic, error code) with **BREAKING** and describe the required operator + or integrator action. + +### Example + +```markdown +## [Unreleased] + +### Added + +- `get_project_endorsements` view returning paginated endorsers (#412). + +### Changed + +- **BREAKING:** `list_projects` now takes `start_index` instead of `offset` (#351). +``` + +### Validation + +The changelog structure is machine-checked. Run the validator before pushing: + +```bash +python3 scripts/validate_changelog.py # validate CHANGELOG.md +python3 scripts/test_validate_changelog.py # self-tests for the validator +``` + +CI runs both in the `validate-changelog` job; a malformed changelog fails the +build. The validator enforces: + +1. `# Changelog` title plus Keep a Changelog and SemVer references. +2. An `## [Unreleased]` section placed above all releases. +3. Release headings of the form `## [X.Y.Z] - YYYY-MM-DD` with valid SemVer and + ISO-8601 dates, unique and ordered newest first. +4. Only the six Keep a Changelog categories, and no empty categories. +5. A link reference definition at the bottom for every version (and no orphans). +6. Agreement between the newest released version and the `dongle-contract` + crate version in `dongle-smartcontract/Cargo.toml`. + +### Cutting a Release + +1. Rename `## [Unreleased]` content into a new + `## [X.Y.Z] - YYYY-MM-DD` section and re-add an empty `## [Unreleased]` + heading with no categories removed from history. +2. Bump `version` in `dongle-smartcontract/Cargo.toml` (and refresh + `Cargo.lock`) to the same `X.Y.Z`. +3. Add the `[X.Y.Z]` compare link at the bottom and repoint `[Unreleased]` to + `compare/vX.Y.Z...HEAD`. +4. Run `python3 scripts/validate_changelog.py`, then tag the release `vX.Y.Z`. diff --git a/dongle-smartcontract/Cargo.lock b/dongle-smartcontract/Cargo.lock index d2c4fbd3..54c68633 100644 --- a/dongle-smartcontract/Cargo.lock +++ b/dongle-smartcontract/Cargo.lock @@ -347,7 +347,7 @@ dependencies = [ [[package]] name = "dongle-contract" -version = "0.1.0" +version = "0.6.0" dependencies = [ "soroban-sdk", ] diff --git a/dongle-smartcontract/Cargo.toml b/dongle-smartcontract/Cargo.toml index d05ab5c4..da14ffc4 100644 --- a/dongle-smartcontract/Cargo.toml +++ b/dongle-smartcontract/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dongle-contract" -version = "0.1.0" +version = "0.6.0" edition = "2021" [lib] diff --git a/dongle-smartcontract/src/changelog_registry.rs b/dongle-smartcontract/src/changelog_registry.rs index fff2acce..f6be0001 100644 --- a/dongle-smartcontract/src/changelog_registry.rs +++ b/dongle-smartcontract/src/changelog_registry.rs @@ -36,9 +36,9 @@ impl ChangelogRegistry { owner.require_auth(); // Verify project exists and caller is owner - let project = ProjectRegistry::get_project(env, project_id) - .ok_or(ContractError::ProjectNotFound)?; - + let project = + ProjectRegistry::get_project(env, project_id).ok_or(ContractError::ProjectNotFound)?; + if project.owner != owner { return Err(ContractError::Unauthorized); } @@ -105,13 +105,7 @@ impl ChangelogRegistry { StorageManager::extend_project_ttl(env, project_id); // Publish event - publish_changelog_added_event( - env, - changelog_id, - project_id, - owner, - cid, - ); + publish_changelog_added_event(env, changelog_id, project_id, owner, cid); Ok(changelog_id) } @@ -144,7 +138,7 @@ impl ChangelogRegistry { // Verify caller is project owner let project = ProjectRegistry::get_project(env, entry.project_id) .ok_or(ContractError::ProjectNotFound)?; - + if project.owner != owner { return Err(ContractError::Unauthorized); } @@ -227,7 +221,7 @@ impl ChangelogRegistry { Utils::bubble_sort_by(&mut entries, |a, b| a.created_at < b.created_at); } crate::types::ChangelogSortMode::Oldest => { - // For oldest first (ascending), swap when a.created_at > b.created_at + // For oldest first (ascending), swap when a.created_at > b.created_at Utils::bubble_sort_by(&mut entries, |a, b| a.created_at > b.created_at); } } @@ -235,7 +229,7 @@ impl ChangelogRegistry { // Apply pagination let end = (start + effective_limit).min(total); let mut paginated = Vec::new(env); - + for i in start..end { if let Some(entry) = entries.get(i) { paginated.push_back(entry.clone()); @@ -278,4 +272,4 @@ impl ChangelogRegistry { .get(&ExtensionKey::ProjectChangelogEntries(project_id)) .unwrap_or_else(|| Vec::new(env)) } -} \ No newline at end of file +} diff --git a/dongle-smartcontract/src/config_registry.rs b/dongle-smartcontract/src/config_registry.rs index 022b4d01..ee8bc363 100644 --- a/dongle-smartcontract/src/config_registry.rs +++ b/dongle-smartcontract/src/config_registry.rs @@ -40,9 +40,11 @@ impl ConfigRegistry { let key = ExtensionKey::Paused; let paused: bool = env.storage().persistent().get(&key).unwrap_or(false); if env.storage().persistent().has(&key) { - env.storage() - .persistent() - .extend_ttl(&key, LEDGER_THRESHOLD_CRITICAL, LEDGER_BUMP_CRITICAL); + env.storage().persistent().extend_ttl( + &key, + LEDGER_THRESHOLD_CRITICAL, + LEDGER_BUMP_CRITICAL, + ); } paused } diff --git a/dongle-smartcontract/src/dependency_registry.rs b/dongle-smartcontract/src/dependency_registry.rs index 4f783c32..35b379b2 100644 --- a/dongle-smartcontract/src/dependency_registry.rs +++ b/dongle-smartcontract/src/dependency_registry.rs @@ -133,8 +133,8 @@ impl DependencyRegistry { let mut buf = [0u8; 4 + 56]; buf[0..4].copy_from_slice(b"CTR:"); contract.copy_into_slice(&mut buf[4..60]); - let key_str = core::str::from_utf8(&buf[..60]) - .map_err(|_| ContractError::InvalidProjectData)?; + let key_str = + core::str::from_utf8(&buf[..60]).map_err(|_| ContractError::InvalidProjectData)?; return Ok(String::from_str(env, key_str)); } Err(ContractError::InvalidProjectData) diff --git a/dongle-smartcontract/src/emergency_pause.rs b/dongle-smartcontract/src/emergency_pause.rs index 83cf3822..2d3953e3 100644 --- a/dongle-smartcontract/src/emergency_pause.rs +++ b/dongle-smartcontract/src/emergency_pause.rs @@ -72,4 +72,3 @@ impl EmergencyPause { Ok(()) } } - diff --git a/dongle-smartcontract/src/events.rs b/dongle-smartcontract/src/events.rs index d1dd9eed..448c0356 100644 --- a/dongle-smartcontract/src/events.rs +++ b/dongle-smartcontract/src/events.rs @@ -2079,7 +2079,11 @@ pub fn publish_changelog_added_event( timestamp: env.ledger().timestamp(), }; env.events().publish( - (symbol_short!("CHANGELOG"), symbol_short!("ADDED"), project_id), + ( + symbol_short!("CHANGELOG"), + symbol_short!("ADDED"), + project_id, + ), event_data, ); } @@ -2097,7 +2101,11 @@ pub fn publish_changelog_removed_event( timestamp: env.ledger().timestamp(), }; env.events().publish( - (symbol_short!("CHANGELOG"), symbol_short!("REMOVED"), project_id), + ( + symbol_short!("CHANGELOG"), + symbol_short!("REMOVED"), + project_id, + ), event_data, ); } diff --git a/dongle-smartcontract/src/fee_manager.rs b/dongle-smartcontract/src/fee_manager.rs index fd2495ab..1ad33a78 100644 --- a/dongle-smartcontract/src/fee_manager.rs +++ b/dongle-smartcontract/src/fee_manager.rs @@ -171,9 +171,7 @@ impl FeeManager { operation: FeeOperation, amount: u128, ) -> Result<(), ContractError> { - env.storage() - .persistent() - .remove(&paid_key); + env.storage().persistent().remove(&paid_key); publish_fee_consumed_event(env, event_project_id, caller, operation, amount); Ok(()) } @@ -326,8 +324,8 @@ impl FeeManager { return Err(ContractError::InsufficientFee); } - let record = Self::get_fee_payment_details(env, project_id) - .ok_or(ContractError::InsufficientFee)?; + let record = + Self::get_fee_payment_details(env, project_id).ok_or(ContractError::InsufficientFee)?; // Authorization: Payer or Admin only let is_admin = crate::admin_manager::AdminManager::is_admin(env, &caller); @@ -348,7 +346,7 @@ impl FeeManager { if record.amount > 0 { let token_address = record.token.clone().ok_or(ContractError::FeeConfigNotSet)?; let treasury = Self::get_treasury(env)?; - + // Treasury authorization is required to transfer tokens out of the treasury treasury.require_auth(); let token_client = soroban_sdk::token::Client::new(env, &token_address); diff --git a/dongle-smartcontract/src/lib.rs b/dongle-smartcontract/src/lib.rs index 3ece8794..d95e1f1c 100644 --- a/dongle-smartcontract/src/lib.rs +++ b/dongle-smartcontract/src/lib.rs @@ -3,9 +3,9 @@ mod admin_action_log; mod admin_manager; -pub mod pagination; pub mod auth; mod bookmark_registry; +mod changelog_registry; mod collection_registry; mod config_registry; pub mod constants; @@ -13,11 +13,11 @@ mod dependency_registry; mod dispute_registry; mod emergency_pause; mod endorsement_registry; -mod changelog_registry; pub mod errors; pub mod events; mod featured_registry; mod fee_manager; +pub mod pagination; mod project_registry; pub mod rating_calculator; mod report_registry; @@ -35,13 +35,13 @@ mod tests; use crate::admin_action_log::AdminActionLog; use crate::admin_manager::AdminManager; +use crate::changelog_registry::ChangelogRegistry; use crate::collection_registry::CollectionRegistry; use crate::config_registry::ConfigRegistry; use crate::emergency_pause::EmergencyPause; use crate::errors::ContractError; use crate::featured_registry::FeaturedRegistry; use crate::fee_manager::FeeManager; -use crate::changelog_registry::ChangelogRegistry; use crate::project_registry::ProjectRegistry; use crate::report_registry::ReportRegistry; use crate::review_registry::ReviewRegistry; @@ -49,12 +49,12 @@ use crate::storage_keys::ExtensionKey; use crate::storage_manager::StorageManager; use crate::timelock_manager::TimelockManager; use crate::types::{ - AdminActionEntry, AdminProposal, ChangelogEntry, ChangelogSortMode, ClaimRequest, - ClaimStatus, Collection, ContractClaimRequest, ContractConfigView, DependencyRef, - DisputeResolutionAction, DisputeStatus, DuplicateDispute, FeeConfig, FeePaymentRecord, - Project, ProjectDependency, ProjectRegistrationParams, ProjectReport, ProjectSortMode, - ProjectStats, ProjectUpdateParams, ProposalPayload, Review, ReviewRevision, ReviewSortMode, - ReviewTombstone, SecurityContactStatus, TimelockAction, VerificationRecord, VerificationStatus, + AdminActionEntry, AdminProposal, ChangelogEntry, ChangelogSortMode, ClaimRequest, ClaimStatus, + Collection, ContractClaimRequest, ContractConfigView, DependencyRef, DisputeResolutionAction, + DisputeStatus, DuplicateDispute, FeeConfig, FeePaymentRecord, Project, ProjectDependency, + ProjectRegistrationParams, ProjectReport, ProjectSortMode, ProjectStats, ProjectUpdateParams, + ProposalPayload, Review, ReviewRevision, ReviewSortMode, ReviewTombstone, + SecurityContactStatus, TimelockAction, VerificationRecord, VerificationStatus, }; use crate::verification_registry::VerificationRegistry; use soroban_sdk::{contract, contractimpl, Address, Env, String, Vec}; @@ -668,17 +668,11 @@ impl DongleContract { VerificationRegistry::revoke_verification(&env, project_id, admin, reason) } - pub fn get_verification( - env: Env, - project_id: u64, - ) -> Option { + pub fn get_verification(env: Env, project_id: u64) -> Option { VerificationRegistry::get_verification(&env, project_id) } - pub fn get_verification_record( - env: Env, - request_id: u64, - ) -> Option { + pub fn get_verification_record(env: Env, request_id: u64) -> Option { VerificationRegistry::get_verification_record(&env, request_id) } @@ -998,7 +992,12 @@ impl DongleContract { } /// List projects by tag - Issue #125 - pub fn list_projects_by_tag(env: Env, tag: String, start_index: u32, limit: u32) -> Vec { + pub fn list_projects_by_tag( + env: Env, + tag: String, + start_index: u32, + limit: u32, + ) -> Vec { ProjectRegistry::list_projects_by_tag(&env, tag, start_index, limit) } @@ -1362,11 +1361,7 @@ impl DongleContract { // --- Bookmark Registry --- - pub fn bookmark_project( - env: Env, - project_id: u64, - user: Address, - ) -> Result<(), ContractError> { + pub fn bookmark_project(env: Env, project_id: u64, user: Address) -> Result<(), ContractError> { crate::bookmark_registry::BookmarkRegistry::bookmark_project(&env, project_id, user) } @@ -1388,11 +1383,7 @@ impl DongleContract { // --- Endorsement Registry --- - pub fn endorse_project( - env: Env, - project_id: u64, - user: Address, - ) -> Result<(), ContractError> { + pub fn endorse_project(env: Env, project_id: u64, user: Address) -> Result<(), ContractError> { crate::endorsement_registry::EndorsementRegistry::endorse_project(&env, project_id, user) } diff --git a/dongle-smartcontract/src/project_registry.rs b/dongle-smartcontract/src/project_registry.rs index c5d9e1ec..0a16675b 100644 --- a/dongle-smartcontract/src/project_registry.rs +++ b/dongle-smartcontract/src/project_registry.rs @@ -15,9 +15,8 @@ use crate::fee_manager::FeeManager; use crate::storage_keys::{ExtensionKey, StorageKey}; use crate::storage_manager::StorageManager; use crate::types::{ - ClaimKind, ClaimRequest, ClaimStatus, ContractClaimRequest, Project, - ProjectRegistrationParams, ProjectSortMode, ProjectUpdateParams, SecurityContactStatus, - VerificationStatus, + ClaimKind, ClaimRequest, ClaimStatus, ContractClaimRequest, Project, ProjectRegistrationParams, + ProjectSortMode, ProjectUpdateParams, SecurityContactStatus, VerificationStatus, }; use crate::utils::Utils; use soroban_sdk::{Address, Bytes, Env, String, Vec}; @@ -1173,7 +1172,12 @@ impl ProjectRegistry { } /// List projects by tag - Issue #125 - pub fn list_projects_by_tag(env: &Env, tag: String, start_index: u32, limit: u32) -> Vec { + pub fn list_projects_by_tag( + env: &Env, + tag: String, + start_index: u32, + limit: u32, + ) -> Vec { let effective_limit = if limit == 0 || limit > MAX_PAGE_LIMIT { MAX_PAGE_LIMIT } else { @@ -2017,8 +2021,7 @@ impl ProjectRegistry { region: Option, ) -> Result<(), ContractError> { caller.require_auth(); - let project = - Self::get_project(env, project_id).ok_or(ContractError::ProjectNotFound)?; + let project = Self::get_project(env, project_id).ok_or(ContractError::ProjectNotFound)?; if project.owner != caller { return Err(ContractError::Unauthorized); } @@ -2049,7 +2052,6 @@ impl ProjectRegistry { .get(&ExtensionKey::ProjectIntegrityHash(project_id)) } - /// Computes and stores a SHA-256 integrity hash over key project metadata fields. /// The hash input is the concatenation: name|slug|category|description (pipe-separated). pub fn store_integrity_hash( diff --git a/dongle-smartcontract/src/rating_calculator.rs b/dongle-smartcontract/src/rating_calculator.rs index e4563056..ecc29db7 100644 --- a/dongle-smartcontract/src/rating_calculator.rs +++ b/dongle-smartcontract/src/rating_calculator.rs @@ -338,4 +338,4 @@ mod tests { assert_eq!(count, 0); assert_eq!(avg, 0); } -} \ No newline at end of file +} diff --git a/dongle-smartcontract/src/review_registry/storage.rs b/dongle-smartcontract/src/review_registry/storage.rs index bea468b7..565b79e8 100644 --- a/dongle-smartcontract/src/review_registry/storage.rs +++ b/dongle-smartcontract/src/review_registry/storage.rs @@ -97,9 +97,7 @@ impl ReviewRegistry { // 2. Endorsement requirement check if config.require_endorsement { if !crate::endorsement_registry::EndorsementRegistry::has_endorsed( - env, - project_id, - reviewer, + env, project_id, reviewer, ) { return Err(ContractError::ReviewerNotEligible); } diff --git a/dongle-smartcontract/src/storage_manager.rs b/dongle-smartcontract/src/storage_manager.rs index bf92e9c4..a78eb73b 100644 --- a/dongle-smartcontract/src/storage_manager.rs +++ b/dongle-smartcontract/src/storage_manager.rs @@ -5,7 +5,7 @@ use crate::constants::*; use crate::storage_keys::{ExtensionKey, StorageKey}; -use soroban_sdk::{Address, Env, IntoVal, Val, String, Vec}; +use soroban_sdk::{Address, Env, IntoVal, String, Val, Vec}; /// Storage manager for TTL operations pub struct StorageManager; diff --git a/dongle-smartcontract/src/tests/changelog.rs b/dongle-smartcontract/src/tests/changelog.rs index 854b8831..e59de3ee 100644 --- a/dongle-smartcontract/src/tests/changelog.rs +++ b/dongle-smartcontract/src/tests/changelog.rs @@ -1,6 +1,9 @@ use crate::tests::fixtures::{create_test_project, setup_contract}; use crate::types::ChangelogSortMode; -use soroban_sdk::{testutils::{Address as _, Ledger}, Address, Env, String}; +use soroban_sdk::{ + testutils::{Address as _, Ledger}, + Address, Env, String, +}; #[test] fn test_add_changelog_entry_as_owner() { @@ -11,11 +14,15 @@ fn test_add_changelog_entry_as_owner() { let project_id = create_test_project(&client, &owner, "TestProject"); // Add a changelog entry - let cid = String::from_str(&env, "bafybeiboz75hbx2qg7g4j4vqaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + let cid = String::from_str( + &env, + "bafybeiboz75hbx2qg7g4j4vqaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ); let description = Some(String::from_str(&env, "Version 1.0.0 release")); - let changelog_id = client - .mock_all_auths() - .add_changelog_entry(&project_id, &owner, &cid, &description); + let changelog_id = + client + .mock_all_auths() + .add_changelog_entry(&project_id, &owner, &cid, &description); assert!(changelog_id > 0); @@ -45,12 +52,18 @@ fn test_add_changelog_entry_non_owner_fails() { let project_id = create_test_project(&client, &owner, "TestProject"); // Try to add changelog as non-owner - should fail - let cid = String::from_str(&env, "bafybeiboz75hbx2qg7g4j4vqbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"); + let cid = String::from_str( + &env, + "bafybeiboz75hbx2qg7g4j4vqbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + ); let description = Some(String::from_str(&env, "Version 1.0.0 release")); - - let result = client - .mock_all_auths() - .try_add_changelog_entry(&project_id, &non_owner, &cid, &description); + + let result = client.mock_all_auths().try_add_changelog_entry( + &project_id, + &non_owner, + &cid, + &description, + ); assert!(result.is_err()); } @@ -65,17 +78,23 @@ fn test_add_changelog_entry_invalid_cid_fails() { // Try to add changelog with empty CID - should fail let empty_cid = String::from_str(&env, ""); let description = Some(String::from_str(&env, "Empty CID test")); - - let result = client - .mock_all_auths() - .try_add_changelog_entry(&project_id, &owner, &empty_cid, &description); + + let result = client.mock_all_auths().try_add_changelog_entry( + &project_id, + &owner, + &empty_cid, + &description, + ); assert!(result.is_err()); // Try to add changelog with invalid CID - should fail let invalid_cid = String::from_str(&env, "invalid-cid"); - let result = client - .mock_all_auths() - .try_add_changelog_entry(&project_id, &owner, &invalid_cid, &description); + let result = client.mock_all_auths().try_add_changelog_entry( + &project_id, + &owner, + &invalid_cid, + &description, + ); assert!(result.is_err()); } @@ -88,18 +107,23 @@ fn test_add_duplicate_changelog_cid_fails() { let project_id = create_test_project(&client, &owner, "TestProject"); // Add first changelog entry - let cid = String::from_str(&env, "bafybeiboz75hbx2qg7g4j4vqccccccccccccccccccccccccccccccc"); + let cid = String::from_str( + &env, + "bafybeiboz75hbx2qg7g4j4vqccccccccccccccccccccccccccccccc", + ); let description1 = Some(String::from_str(&env, "Version 1.0.0")); - let changelog_id1 = client - .mock_all_auths() - .add_changelog_entry(&project_id, &owner, &cid, &description1); + let changelog_id1 = + client + .mock_all_auths() + .add_changelog_entry(&project_id, &owner, &cid, &description1); assert!(changelog_id1 > 0); // Try to add duplicate CID - should fail let description2 = Some(String::from_str(&env, "Duplicate test")); - let result = client - .mock_all_auths() - .try_add_changelog_entry(&project_id, &owner, &cid, &description2); + let result = + client + .mock_all_auths() + .try_add_changelog_entry(&project_id, &owner, &cid, &description2); assert!(result.is_err()); } @@ -112,11 +136,15 @@ fn test_remove_changelog_entry_as_owner() { let project_id = create_test_project(&client, &owner, "TestProject"); // Add a changelog entry - let cid = String::from_str(&env, "bafybeiboz75hbx2qg7g4j4vqdddddddddddddddddddddddddddddd"); + let cid = String::from_str( + &env, + "bafybeiboz75hbx2qg7g4j4vqdddddddddddddddddddddddddddddd", + ); let description = Some(String::from_str(&env, "Version 1.0.0 release")); - let changelog_id = client - .mock_all_auths() - .add_changelog_entry(&project_id, &owner, &cid, &description); + let changelog_id = + client + .mock_all_auths() + .add_changelog_entry(&project_id, &owner, &cid, &description); // Verify it exists let entry = client.get_changelog_entry(&changelog_id); @@ -146,11 +174,15 @@ fn test_remove_changelog_entry_non_owner_fails() { let project_id = create_test_project(&client, &owner, "TestProject"); // Add a changelog entry - let cid = String::from_str(&env, "bafybeiboz75hbx2qg7g4j4vqeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"); + let cid = String::from_str( + &env, + "bafybeiboz75hbx2qg7g4j4vqeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + ); let description = Some(String::from_str(&env, "Version 1.0.0 release")); - let changelog_id = client - .mock_all_auths() - .add_changelog_entry(&project_id, &owner, &cid, &description); + let changelog_id = + client + .mock_all_auths() + .add_changelog_entry(&project_id, &owner, &cid, &description); // Try to remove as non-owner - should fail let result = client @@ -165,7 +197,7 @@ fn test_remove_nonexistent_changelog_fails() { let (client, admin) = setup_contract(&env); let owner = Address::generate(&env); - + // Try to remove non-existent changelog - should fail let result = client .mock_all_auths() @@ -187,65 +219,63 @@ fn test_get_project_changelog_pagination() { "bafybeiboz75hbx2qg7g4j4vq1bbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "bafybeiboz75hbx2qg7g4j4vq2cccccccccccccccccccccccccccccc", "bafybeiboz75hbx2qg7g4j4vq3dddddddddddddddddddddddddddddd", - "bafybeiboz75hbx2qg7g4j4vq4eeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + "bafybeiboz75hbx2qg7g4j4vq4eeeeeeeeeeeeeeeeeeeeeeeeeeeeee", ]; let descriptions = [ "Version 1.0.0", "Version 1.0.1", - "Version 1.0.2", + "Version 1.0.2", "Version 1.0.3", - "Version 1.0.4" + "Version 1.0.4", ]; - + for i in 0..5 { let cid = String::from_str(&env, cids[i]); let description = Some(String::from_str(&env, descriptions[i])); client .mock_all_auths() .add_changelog_entry(&project_id, &owner, &cid, &description); - + // Advance ledger timestamp to ensure different created_at env.ledger().set_timestamp(env.ledger().timestamp() + 100); } // Test pagination with newest first - let changelog_newest = client.get_project_changelog( - &project_id, - &0, - &3, - &ChangelogSortMode::Newest, - ); + let changelog_newest = + client.get_project_changelog(&project_id, &0, &3, &ChangelogSortMode::Newest); assert_eq!(changelog_newest.len(), 3); - + // Verify they're in descending order (newest first) // In tests, created_at might be 0 for all entries, so we can only check >= for i in 0..changelog_newest.len() - 1 { let current = changelog_newest.get(i).unwrap().created_at; let next = changelog_newest.get(i + 1).unwrap().created_at; - assert!(current >= next, "Expected entry {} to have created_at >= entry {} ({} >= {})", i, i + 1, current, next); + assert!( + current >= next, + "Expected entry {} to have created_at >= entry {} ({} >= {})", + i, + i + 1, + current, + next + ); } // Test pagination with oldest first - let changelog_oldest = client.get_project_changelog( - &project_id, - &0, - &3, - &ChangelogSortMode::Oldest, - ); + let changelog_oldest = + client.get_project_changelog(&project_id, &0, &3, &ChangelogSortMode::Oldest); assert_eq!(changelog_oldest.len(), 3); - + // Verify they're in ascending order (oldest first) for i in 0..changelog_oldest.len() - 1 { - assert!(changelog_oldest.get(i).unwrap().created_at <= changelog_oldest.get(i + 1).unwrap().created_at); + assert!( + changelog_oldest.get(i).unwrap().created_at + <= changelog_oldest.get(i + 1).unwrap().created_at + ); } // Test pagination with offset - let changelog_offset = client.get_project_changelog( - &project_id, - &2, - &2, - &ChangelogSortMode::Newest, - ); + let changelog_offset = + client.get_project_changelog(&project_id, &2, &2, &ChangelogSortMode::Newest); assert_eq!(changelog_offset.len(), 2); } @@ -255,12 +285,7 @@ fn test_get_project_changelog_nonexistent_project() { let (client, admin) = setup_contract(&env); // Get changelog for non-existent project - should return empty list - let changelog = client.get_project_changelog( - &999, - &0, - &10, - &ChangelogSortMode::Newest, - ); + let changelog = client.get_project_changelog(&999, &0, &10, &ChangelogSortMode::Newest); assert_eq!(changelog.len(), 0); } @@ -279,15 +304,11 @@ fn test_get_changelog_count() { // Add some changelog entries let test_cids = [ "bafybeiboz75hbx2qg7g4j4vqaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "bafybeiboz75hbx2qg7g4j4vqbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - "bafybeiboz75hbx2qg7g4j4vqccccccccccccccccccccccccccccccc" - ]; - let test_descriptions = [ - "Version 1.0.0", - "Version 1.0.1", - "Version 1.0.2" + "bafybeiboz75hbx2qg7g4j4vqbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "bafybeiboz75hbx2qg7g4j4vqccccccccccccccccccccccccccccccc", ]; - + let test_descriptions = ["Version 1.0.0", "Version 1.0.1", "Version 1.0.2"]; + for i in 0..3 { let cid = String::from_str(&env, test_cids[i]); let description = Some(String::from_str(&env, test_descriptions[i])); @@ -313,20 +334,25 @@ fn test_changelog_while_paused_fails() { client.mock_all_auths().pause(&admin); // Try to add changelog while paused - should fail - let cid = String::from_str(&env, "bafybeiboz75hbx2qg7g4j4vqfffffffffffffffffffffffffffffff"); + let cid = String::from_str( + &env, + "bafybeiboz75hbx2qg7g4j4vqfffffffffffffffffffffffffffffff", + ); let description = Some(String::from_str(&env, "Version 1.0.0 release")); - - let result = client - .mock_all_auths() - .try_add_changelog_entry(&project_id, &owner, &cid, &description); + + let result = + client + .mock_all_auths() + .try_add_changelog_entry(&project_id, &owner, &cid, &description); assert!(result.is_err()); // Unpause the contract client.mock_all_auths().unpause(&admin); // Now should succeed - let changelog_id = client - .mock_all_auths() - .add_changelog_entry(&project_id, &owner, &cid, &description); + let changelog_id = + client + .mock_all_auths() + .add_changelog_entry(&project_id, &owner, &cid, &description); assert!(changelog_id > 0); -} \ No newline at end of file +} diff --git a/dongle-smartcontract/src/tests/config.rs b/dongle-smartcontract/src/tests/config.rs index c87ec885..7de17373 100644 --- a/dongle-smartcontract/src/tests/config.rs +++ b/dongle-smartcontract/src/tests/config.rs @@ -22,9 +22,15 @@ fn config_after_initialization_exposes_defaults_and_limits() { assert_eq!(config.fees.registration_fee, 0); assert_eq!(config.limits.max_page_limit, MAX_PAGE_LIMIT); assert_eq!(config.limits.max_projects_per_user, MAX_PROJECTS_PER_USER); - assert_eq!(config.limits.max_reviews_per_project, MAX_REVIEWS_PER_PROJECT); + assert_eq!( + config.limits.max_reviews_per_project, + MAX_REVIEWS_PER_PROJECT + ); assert_eq!(config.limits.max_name_len, MAX_NAME_LEN as u32); - assert_eq!(config.limits.max_description_len, MAX_DESCRIPTION_LEN as u32); + assert_eq!( + config.limits.max_description_len, + MAX_DESCRIPTION_LEN as u32 + ); assert_eq!( config.limits.verification_validity_period, VERIFICATION_VALIDITY_PERIOD diff --git a/dongle-smartcontract/src/tests/duplicate_dispute.rs b/dongle-smartcontract/src/tests/duplicate_dispute.rs index 1eaba05a..b1e1040e 100644 --- a/dongle-smartcontract/src/tests/duplicate_dispute.rs +++ b/dongle-smartcontract/src/tests/duplicate_dispute.rs @@ -68,7 +68,10 @@ fn test_resolve_dispute_archive() { let id1 = create_test_project(&client, &owner1, "OriginalProject"); let id2 = create_test_project(&client, &owner2, "DuplicateProject"); - let evidence_cid = String::from_str(&env, "QmTestEvidenceCid123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"); + let evidence_cid = String::from_str( + &env, + "QmTestEvidenceCid123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz", + ); let dispute_id = client @@ -102,7 +105,10 @@ fn test_resolve_dispute_link() { let id1 = create_test_project(&client, &owner1, "OriginalProject"); let id2 = create_test_project(&client, &owner2, "DuplicateProject"); - let evidence_cid = String::from_str(&env, "QmTestEvidenceCid123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"); + let evidence_cid = String::from_str( + &env, + "QmTestEvidenceCid123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz", + ); let dispute_id = client diff --git a/dongle-smartcontract/src/tests/multisig_and_history.rs b/dongle-smartcontract/src/tests/multisig_and_history.rs index 9012edff..92221703 100644 --- a/dongle-smartcontract/src/tests/multisig_and_history.rs +++ b/dongle-smartcontract/src/tests/multisig_and_history.rs @@ -163,10 +163,9 @@ fn test_execute_proposal_rejects_corrupted_payload() { let mut corrupted = proposal; corrupted.payload = ProposalPayload::AddAdmin(Address::generate(&env)); env.as_contract(&client.address, || { - env.storage().persistent().set( - &ExtensionKey::AdminProposal(proposal_id), - &corrupted, - ); + env.storage() + .persistent() + .set(&ExtensionKey::AdminProposal(proposal_id), &corrupted); }); // Execution must be rejected with PayloadHashMismatch and must not run the @@ -179,10 +178,7 @@ fn test_execute_proposal_rejects_corrupted_payload() { assert_eq!(proposal.status, ProposalStatus::Approved); // The corrupted payload's admin must not have been added. - let stored = client - .get_proposal(&proposal_id) - .unwrap() - .payload; + let stored = client.get_proposal(&proposal_id).unwrap().payload; if let ProposalPayload::AddAdmin(corrupted_admin) = stored { assert!(!client.is_admin(&corrupted_admin)); } else { diff --git a/dongle-smartcontract/src/types.rs b/dongle-smartcontract/src/types.rs index 83d3d979..5898b525 100644 --- a/dongle-smartcontract/src/types.rs +++ b/dongle-smartcontract/src/types.rs @@ -226,8 +226,6 @@ pub struct ProjectReport { pub timestamp: u64, } - - #[contracttype] #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum VerificationStatus { @@ -288,7 +286,6 @@ pub struct FeePaymentRecord { pub token: Option
, } - #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub struct FeeConfigHistoryEntry { diff --git a/dongle-smartcontract/src/utils.rs b/dongle-smartcontract/src/utils.rs index 7cf5e880..92eac348 100644 --- a/dongle-smartcontract/src/utils.rs +++ b/dongle-smartcontract/src/utils.rs @@ -18,7 +18,12 @@ impl Utils { // ──────────────────────────────────────────────────────────────────── /// Push `item` into `vec` only if it is not already present. - pub fn add_unique_to_vec + soroban_sdk::IntoVal>( + pub fn add_unique_to_vec< + T: PartialEq + + Clone + + soroban_sdk::TryFromVal + + soroban_sdk::IntoVal, + >( vec: &mut Vec, item: &T, ) -> bool { @@ -34,7 +39,12 @@ impl Utils { } /// Return a new Vec containing all items from `vec` except those equal to `item`. - pub fn remove_item_from_vec + soroban_sdk::IntoVal>( + pub fn remove_item_from_vec< + T: PartialEq + + Clone + + soroban_sdk::TryFromVal + + soroban_sdk::IntoVal, + >( env: &Env, vec: &Vec, item: &T, @@ -137,7 +147,6 @@ impl Utils { String::from_str(env, s) } - // ──────────────────────────────────────────────────────────────────── // Name / slug / field validation // ──────────────────────────────────────────────────────────────────── diff --git a/dongle-smartcontract/src/verification_registry/storage.rs b/dongle-smartcontract/src/verification_registry/storage.rs index 3dc9fd03..39bc54f0 100644 --- a/dongle-smartcontract/src/verification_registry/storage.rs +++ b/dongle-smartcontract/src/verification_registry/storage.rs @@ -1,7 +1,7 @@ //! Verification registry storage mutations: request/approve/reject, renewal, and assignment. -use crate::admin_manager::AdminManager; use crate::admin_action_log::AdminActionLog; +use crate::admin_manager::AdminManager; use crate::auth::{require_admin_auth, require_owner_auth}; use crate::constants::MAX_PAGE_LIMIT; use crate::errors::ContractError; @@ -305,10 +305,7 @@ impl VerificationRegistry { Ok(()) } - pub fn get_verification( - env: &Env, - project_id: u64, - ) -> Option { + pub fn get_verification(env: &Env, project_id: u64) -> Option { let request_id = env .storage() .persistent() @@ -318,10 +315,7 @@ impl VerificationRegistry { .get::<_, VerificationRecord>(&StorageKey::VerificationRecord(request_id)) } - pub fn get_verification_record( - env: &Env, - request_id: u64, - ) -> Option { + pub fn get_verification_record(env: &Env, request_id: u64) -> Option { env.storage() .persistent() .get::<_, VerificationRecord>(&StorageKey::VerificationRecord(request_id)) @@ -454,10 +448,7 @@ impl VerificationRegistry { } /// Get the admin assigned to review a verification request. - pub fn get_assigned_admin( - env: &Env, - project_id: u64, - ) -> Option
{ + pub fn get_assigned_admin(env: &Env, project_id: u64) -> Option
{ let record = Self::get_verification(env, project_id)?; record.assigned_admin } @@ -673,8 +664,8 @@ impl VerificationRegistry { let renewal = Self::get_renewal_request(env, project_id) .ok_or(ContractError::VerificationNotFound)?; - let mut verification = Self::get_verification(env, project_id) - .ok_or(ContractError::VerificationNotFound)?; + let mut verification = + Self::get_verification(env, project_id).ok_or(ContractError::VerificationNotFound)?; let mut project = ProjectRegistry::get_project(env, project_id).ok_or(ContractError::ProjectNotFound)?; @@ -749,8 +740,8 @@ impl VerificationRegistry { ProjectRegistry::get_project(env, project_id).ok_or(ContractError::ProjectNotFound)?; // Record must exist - let mut record = Self::get_verification(env, project_id) - .ok_or(ContractError::VerificationNotFound)?; + let mut record = + Self::get_verification(env, project_id).ok_or(ContractError::VerificationNotFound)?; // Can only renew an already-Verified record (not Pending / Rejected / Unverified) if record.status != VerificationStatus::Verified { @@ -763,10 +754,9 @@ impl VerificationRegistry { record.expires_at = new_expires_at; record.last_renewed_at = now; - env.storage().persistent().set( - &StorageKey::Verification(project_id), - &record.request_id, - ); + env.storage() + .persistent() + .set(&StorageKey::Verification(project_id), &record.request_id); env.storage() .persistent() .set(&StorageKey::VerificationRecord(record.request_id), &record); @@ -796,10 +786,7 @@ impl VerificationRegistry { Ok(()) } - pub fn get_renewal_request( - env: &Env, - project_id: u64, - ) -> Option { + pub fn get_renewal_request(env: &Env, project_id: u64) -> Option { env.storage() .persistent() .get(&StorageKey::VerificationRenewal(project_id)) @@ -838,8 +825,8 @@ impl VerificationRegistry { } pub fn is_verification_expired(env: &Env, project_id: u64) -> Result { - let verification = Self::get_verification(env, project_id) - .ok_or(ContractError::VerificationNotFound)?; + let verification = + Self::get_verification(env, project_id).ok_or(ContractError::VerificationNotFound)?; Ok(verification.expires_at != 0 && env.ledger().timestamp() > verification.expires_at) } diff --git a/scripts/optimize_wasm.sh b/scripts/optimize_wasm.sh index dd18a446..26767724 100755 --- a/scripts/optimize_wasm.sh +++ b/scripts/optimize_wasm.sh @@ -1,12 +1,23 @@ #!/usr/bin/env bash -# Optimize the compiled Dongle contract WASM artifact using the Stellar CLI. +# Optimize the compiled Dongle contract WASM artifact. # # Input: target/wasm32-unknown-unknown/release/dongle_contract.wasm # Output: target/wasm32-unknown-unknown/release/dongle_contract.optimized.wasm # -# Prerequisites: -# cargo install --locked stellar-cli --features opt -# The --features opt flag bundles wasm-opt into the stellar binary. +# Optimizer selection (first one found wins): +# 1. `stellar contract optimize` - Stellar CLI, bundles wasm-opt +# 2. `wasm-opt -Oz` - Binaryen, standalone +# +# Installing the Stellar CLI: +# Prefer the prebuilt release binary. `cargo install --locked stellar-cli` +# builds from source and requires a rustc newer than the toolchain pinned in +# rust-toolchain.toml, so it fails on this repository's pinned toolchain. +# +# VERSION=27.1.0 +# curl -sSfL "https://github.com/stellar/stellar-cli/releases/download/v${VERSION}/stellar-cli-${VERSION}-x86_64-unknown-linux-gnu.tar.gz" \ +# | tar -xz -C /usr/local/bin stellar +# +# Or install Binaryen instead (`apt-get install binaryen`, `brew install binaryen`). # # Usage: # ./scripts/optimize_wasm.sh [INPUT_WASM] [OUTPUT_WASM] @@ -28,7 +39,25 @@ fi BEFORE=$(wc -c < "$INPUT") echo "Optimizing $INPUT -> $OUTPUT" -stellar contract optimize --wasm "$INPUT" --wasm-out "$OUTPUT" + +if command -v stellar >/dev/null 2>&1; then + echo "Optimizer: stellar contract optimize ($(stellar --version 2>/dev/null | head -1))" + stellar contract optimize --wasm "$INPUT" --wasm-out "$OUTPUT" +elif command -v wasm-opt >/dev/null 2>&1; then + echo "Optimizer: wasm-opt ($(wasm-opt --version 2>/dev/null | head -1))" + wasm-opt -Oz --enable-bulk-memory --enable-mutable-globals "$INPUT" -o "$OUTPUT" +else + echo "Error: no WASM optimizer found on PATH (looked for 'stellar' and 'wasm-opt')." >&2 + echo "Install one of:" >&2 + echo " Stellar CLI (prebuilt): https://github.com/stellar/stellar-cli/releases" >&2 + echo " Binaryen: apt-get install binaryen | brew install binaryen" >&2 + exit 127 +fi + +if [ ! -f "$OUTPUT" ]; then + echo "Error: optimizer did not produce '$OUTPUT'" >&2 + exit 1 +fi AFTER=$(wc -c < "$OUTPUT") SAVED=$(( BEFORE - AFTER )) diff --git a/scripts/test_validate_changelog.py b/scripts/test_validate_changelog.py new file mode 100644 index 00000000..06499c9e --- /dev/null +++ b/scripts/test_validate_changelog.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +"""Self-tests for scripts/validate_changelog.py. + +Run with: python3 scripts/test_validate_changelog.py + +No third-party dependencies required so it can run anywhere CI runs Python. +""" + +from __future__ import annotations + +import os +import sys +import tempfile + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from validate_changelog import validate # noqa: E402 + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +HEADER = """# Changelog + +All notable changes are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +""" + +GOOD = ( + HEADER + + """ +## [Unreleased] + +### Added + +- Something new. + +## [0.2.0] - 2026-02-01 + +### Changed + +- Something changed. + +## [0.1.0] - 2025-09-25 + +### Added + +- Initial release. + +[Unreleased]: https://example.com/compare/v0.2.0...HEAD +[0.2.0]: https://example.com/compare/v0.1.0...v0.2.0 +[0.1.0]: https://example.com/releases/tag/v0.1.0 +""" +) + +FAILURES = 0 + + +def check(name: str, content: str | None, expect_ok: bool, needle: str = "") -> None: + global FAILURES + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "CHANGELOG.md") + if content is not None: + with open(path, "w", encoding="utf-8") as handle: + handle.write(content) + errors = validate(path, tmp) + ok = not errors + passed = ok == expect_ok and (not needle or any(needle in e for e in errors)) + status = "PASS" if passed else "FAIL" + if not passed: + FAILURES += 1 + print(f"[{status}] {name}") + if not passed: + print(f" expected_ok={expect_ok} errors={errors}") + + +def main() -> int: + check("valid changelog accepted", GOOD, True) + check("missing file rejected", None, False, "does not exist") + check("empty file rejected", "", False, "empty") + check("wrong title rejected", GOOD.replace("# Changelog", "# Notes", 1), False, "top-level heading") + check( + "missing Keep a Changelog reference rejected", + GOOD.replace("https://keepachangelog.com/en/1.1.0/", "https://example.com"), + False, + "Keep a Changelog format", + ) + check( + "missing Unreleased rejected", + GOOD.replace("## [Unreleased]\n\n### Added\n\n- Something new.\n", "").replace( + "[Unreleased]: https://example.com/compare/v0.2.0...HEAD\n", "" + ), + False, + "Unreleased", + ) + check( + "non-semver version rejected", + GOOD.replace("## [0.2.0] - 2026-02-01", "## [2026-02-01] - 2026-02-01"), + False, + "Semantic Version", + ) + check( + "bad date rejected", + GOOD.replace("## [0.2.0] - 2026-02-01", "## [0.2.0] - 2026-13-01"), + False, + "ISO-8601", + ) + check( + "unknown section rejected", + GOOD.replace("### Changed", "### Improvements"), + False, + "Keep a Changelog category", + ) + check( + "empty section rejected", + GOOD.replace("### Changed\n\n- Something changed.\n", "### Changed\n"), + False, + "no entries", + ) + check( + "out-of-order versions rejected", + GOOD.replace("## [0.2.0] - 2026-02-01", "## [0.0.9] - 2026-02-01").replace( + "[0.2.0]:", "[0.0.9]:" + ), + False, + "must be older than", + ) + check( + "missing link reference rejected", + GOOD.replace("[0.1.0]: https://example.com/releases/tag/v0.1.0\n", ""), + False, + "Missing link reference", + ) + check( + "orphan link reference rejected", + GOOD + "[9.9.9]: https://example.com/compare/v9.9.8...v9.9.9\n", + False, + "does not match any version heading", + ) + check( + "duplicate version rejected", + GOOD.replace("## [0.1.0] - 2025-09-25", "## [0.2.0] - 2025-09-25"), + False, + "more than once", + ) + + # The real repository changelog must also validate. + real_errors = validate(os.path.join(ROOT, "CHANGELOG.md"), ROOT) + if real_errors: + print(f"[FAIL] repository CHANGELOG.md valid -> {real_errors}") + globals()["FAILURES"] += 1 + else: + print("[PASS] repository CHANGELOG.md valid") + + if FAILURES: + print(f"\n{FAILURES} test(s) failed.") + return 1 + print("\nAll changelog validator tests passed.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/validate_changelog.py b/scripts/validate_changelog.py new file mode 100644 index 00000000..be88b5e4 --- /dev/null +++ b/scripts/validate_changelog.py @@ -0,0 +1,293 @@ +#!/usr/bin/env python3 +"""Validate CHANGELOG.md against the Keep a Changelog 1.1.0 conventions. + +Checks performed: + 1. CHANGELOG.md exists at the repository root and is non-empty. + 2. It starts with a top level ``# Changelog`` heading. + 3. It references Keep a Changelog and Semantic Versioning. + 4. It contains an ``## [Unreleased]`` section, placed before every release. + 5. Every release heading matches ``## [X.Y.Z] - YYYY-MM-DD`` with a valid + SemVer version and a valid ISO-8601 calendar date. + 6. Release versions are unique and listed newest first (descending SemVer), + with dates that never move forward as you scroll down. + 7. Every ``###`` subsection uses one of the six Keep a Changelog categories + and is not empty. + 8. Every version (including ``Unreleased``) has a matching link reference + definition at the bottom of the file, and no link definition is orphaned. + 9. The newest released version matches the ``version`` field of the + ``dongle-contract`` crate manifest. + +Exit code 0 on success, 1 on any validation error. +""" + +from __future__ import annotations + +import datetime +import os +import re +import sys + +ALLOWED_SECTIONS = ( + "Added", + "Changed", + "Deprecated", + "Removed", + "Fixed", + "Security", +) + +UNRELEASED_RE = re.compile(r"^##\s+\[Unreleased\]\s*$") +RELEASE_RE = re.compile(r"^##\s+\[(?P[^\]]+)\]\s+-\s+(?P\S+)\s*$") +ANY_H2_RE = re.compile(r"^##\s+(?P.+?)\s*$") +H3_RE = re.compile(r"^###\s+(?P<title>.+?)\s*$") +LINK_DEF_RE = re.compile(r"^\[(?P<label>[^\]]+)\]:\s*(?P<url>\S+)\s*$") +SEMVER_RE = re.compile( + r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)" + r"(?:-(?P<pre>[0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$" +) +CRATE_VERSION_RE = re.compile(r'^version\s*=\s*"([^"]+)"', re.MULTILINE) + + +def semver_key(version: str): + """Sort key for SemVer strings; pre-releases order before their release.""" + match = SEMVER_RE.match(version) + major, minor, patch = (int(part) for part in match.groups()[:3]) + pre = match.group("pre") + # A version without a pre-release sorts after one with a pre-release. + return (major, minor, patch, 1 if pre is None else 0, pre or "") + + +def crate_version(root_dir: str) -> str | None: + manifest = os.path.join(root_dir, "dongle-smartcontract", "Cargo.toml") + if not os.path.exists(manifest): + return None + with open(manifest, "r", encoding="utf-8") as handle: + # Only look at the [package] table, which comes first in this manifest. + content = handle.read().split("[lib]")[0] + match = CRATE_VERSION_RE.search(content) + return match.group(1) if match else None + + +def validate(path: str, root_dir: str) -> list[str]: + errors: list[str] = [] + + if not os.path.exists(path): + return [ + f"{path} does not exist. Every release-bearing repository must " + "track version history in a Keep a Changelog file." + ] + + with open(path, "r", encoding="utf-8") as handle: + lines = handle.read().splitlines() + + if not [line for line in lines if line.strip()]: + return [f"{path} is empty."] + + # 2 + 3: header requirements. + if lines[0].strip() != "# Changelog": + errors.append("Line 1 must be the top-level heading '# Changelog'.") + + body = "\n".join(lines).lower() + if "keepachangelog.com" not in body: + errors.append("Missing a reference to the Keep a Changelog format.") + if "semver.org" not in body: + errors.append("Missing a reference to Semantic Versioning.") + + # Parse structure. + releases: list[tuple[int, str, str]] = [] # (line_no, version, date) + labels: list[str] = [] + link_defs: dict[str, str] = {} + seen_unreleased = False + current_heading: str | None = None + current_section: str | None = None + section_has_content = True + in_code_fence = False + + for index, raw in enumerate(lines, start=1): + line = raw.rstrip() + + if line.lstrip().startswith("```"): + in_code_fence = not in_code_fence + continue + if in_code_fence: + continue + + link_match = LINK_DEF_RE.match(line) + if link_match: + label = link_match.group("label") + if label in link_defs: + errors.append(f"Line {index}: duplicate link definition [{label}].") + link_defs[label] = link_match.group("url") + continue + + h2 = ANY_H2_RE.match(line) + if h2: + if current_section is not None and not section_has_content: + errors.append( + f"Section '### {current_section}' under " + f"'{current_heading}' has no entries." + ) + current_section = None + section_has_content = True + + title = h2.group("title") + if UNRELEASED_RE.match(line): + if seen_unreleased: + errors.append(f"Line {index}: duplicate '## [Unreleased]' section.") + if releases: + errors.append( + f"Line {index}: '## [Unreleased]' must appear before all " + "released versions." + ) + seen_unreleased = True + labels.append("Unreleased") + current_heading = "Unreleased" + continue + + release = RELEASE_RE.match(line) + if release: + version = release.group("version") + date = release.group("date") + current_heading = line.strip() + + if not SEMVER_RE.match(version): + errors.append( + f"Line {index}: '{version}' is not a valid Semantic " + "Version (expected MAJOR.MINOR.PATCH)." + ) + try: + datetime.date.fromisoformat(date) + except ValueError: + errors.append( + f"Line {index}: '{date}' is not a valid ISO-8601 date " + "(expected YYYY-MM-DD)." + ) + releases.append((index, version, date)) + labels.append(version) + continue + + # Any other H2 is narrative prose (e.g. "How to add an entry"), + # which is allowed only before the first version section. + if seen_unreleased or releases: + errors.append( + f"Line {index}: unexpected heading '## {title}'. Version " + "headings must be '## [Unreleased]' or " + "'## [X.Y.Z] - YYYY-MM-DD'." + ) + current_heading = f"## {title}" + continue + + h3 = H3_RE.match(line) + if h3: + if current_section is not None and not section_has_content: + errors.append( + f"Section '### {current_section}' under " + f"'{current_heading}' has no entries." + ) + title = h3.group("title") + if seen_unreleased or releases: + if title not in ALLOWED_SECTIONS: + errors.append( + f"Line {index}: '### {title}' is not a Keep a Changelog " + f"category. Use one of: {', '.join(ALLOWED_SECTIONS)}." + ) + current_section = title + section_has_content = False + continue + + if current_section is not None and line.strip(): + section_has_content = True + + if current_section is not None and not section_has_content: + errors.append( + f"Section '### {current_section}' under '{current_heading}' has no entries." + ) + + # 4: Unreleased must exist. + if not seen_unreleased: + errors.append("Missing an '## [Unreleased]' section.") + + # 5/6: releases present, unique and ordered newest first. + if not releases: + errors.append("No released versions found ('## [X.Y.Z] - YYYY-MM-DD').") + + seen_versions: dict[str, int] = {} + for line_no, version, _date in releases: + if version in seen_versions: + errors.append( + f"Line {line_no}: version {version} is declared more than once " + f"(first seen on line {seen_versions[version]})." + ) + else: + seen_versions[version] = line_no + + valid = [ + (line_no, version, date) + for line_no, version, date in releases + if SEMVER_RE.match(version) + ] + for (prev_line, prev_v, prev_d), (line_no, version, date) in zip(valid, valid[1:]): + if semver_key(version) >= semver_key(prev_v): + errors.append( + f"Line {line_no}: version {version} must be older than {prev_v} " + f"declared on line {prev_line} (newest first)." + ) + try: + if datetime.date.fromisoformat(date) > datetime.date.fromisoformat(prev_d): + errors.append( + f"Line {line_no}: date {date} of {version} is newer than " + f"{prev_d} of {prev_v} (newest first)." + ) + except ValueError: + pass # Already reported above. + + # 8: link references. + for label in labels: + if label not in link_defs: + errors.append( + f"Missing link reference definition for [{label}] at the bottom " + "of the file." + ) + for label in link_defs: + if label not in labels: + errors.append( + f"Link reference [{label}] does not match any version heading." + ) + + # 9: crate version agreement. + latest_released = valid[0][1] if valid else None + manifest_version = crate_version(root_dir) + if latest_released and manifest_version and latest_released != manifest_version: + errors.append( + f"Latest released changelog version {latest_released} does not match " + f"the dongle-contract crate version {manifest_version} in " + "dongle-smartcontract/Cargo.toml." + ) + + return errors + + +def main() -> int: + script_dir = os.path.dirname(os.path.abspath(__file__)) + root_dir = os.path.dirname(script_dir) + changelog_path = os.path.join(root_dir, "CHANGELOG.md") + + print(f"Validating changelog: {changelog_path}") + errors = validate(changelog_path, root_dir) + + if errors: + print(f"\n{len(errors)} problem(s) found:\n") + for error in errors: + print(f" - {error}") + print( + "\nSee https://keepachangelog.com/en/1.1.0/ and " + "docs/CONTRIBUTING.md#6-changelog-entries" + ) + return 1 + + print("CHANGELOG.md is valid (Keep a Changelog 1.1.0 + SemVer).") + return 0 + + +if __name__ == "__main__": + sys.exit(main())