Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,16 @@ jobs:

steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Check protocol version release ratchet
env:
BASE_REF: ${{ github.event.pull_request.base.sha || github.event.before }}
run: bash scripts/check-protocol-version-bump.sh

- name: Test protocol version release ratchet
run: bash scripts/check-protocol-version-bump.test.sh

- name: Detect contract-impacting changes
id: changes
Expand Down Expand Up @@ -96,11 +106,13 @@ jobs:
cargo test --test wasm_build_size_budget --verbose

- name: Run Cargo tests
if: github.event_name != 'pull_request' || steps.changes.outputs.contracts == 'true'
run: |
source $HOME/.cargo/env
cargo test --verbose

- name: Test coverage (baseline + admin gate)
if: github.event_name != 'pull_request' || steps.changes.outputs.contracts == 'true'
run: |
source $HOME/.cargo/env
cargo install cargo-llvm-cov
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ npm run dev
- [`docs/CURRENCY_WHITELIST.md`](docs/CURRENCY_WHITELIST.md): How tokens are added to and removed from the currency whitelist — contributor guide covering entrypoints, auth model, enforcement points, and test patterns.
- [`docs/ERROR_CODES.md`](docs/ERROR_CODES.md): Complete catalog of every contract error code (QuickLendXError and FreshnessError) with numeric codes, ABI symbols, and meanings.
- [`docs/GOVERNANCE.md`](docs/GOVERNANCE.md): Governance model, admin handover (one-step and two-step) flow, and the emergency-withdraw timelock — operator-facing.
- [`docs/CONTRACT_VERSION_COMPATIBILITY.md`](docs/CONTRACT_VERSION_COMPATIBILITY.md): Protocol version compatibility and the CI release ratchet for `PROTOCOL_VERSION`.

## Contribution

Expand Down
26 changes: 26 additions & 0 deletions docs/CONTRACT_VERSION_COMPATIBILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,29 @@ stellar-cli contract invoke \

The current protocol version is **1**. All `0.1.x` deployments report `1`.

#### Release ratchet

CI enforces an explicit release ratchet: if
[`quicklendx-contracts/Cargo.toml`](../quicklendx-contracts/Cargo.toml)
changes its `[package].version`, then
[`PROTOCOL_VERSION`](../quicklendx-contracts/src/init.rs) must increase in the
same change. The gate is implemented by
[`scripts/check-protocol-version-bump.sh`](../scripts/check-protocol-version-bump.sh)
and is tested by
[`scripts/check-protocol-version-bump.test.sh`](../scripts/check-protocol-version-bump.test.sh).

Run it locally before cutting a contract package release:

```bash
scripts/check-protocol-version-bump.sh
scripts/check-protocol-version-bump.test.sh
```

This ratchet is intentionally stricter than the storage compatibility policy
below. Patch and minor releases can remain backward-compatible, but a packaged
contract release still increments `PROTOCOL_VERSION` so operators can detect the
new release with `get_version`.

#### Upgrade policy

The bump rules are documented next to the constant in
Expand Down Expand Up @@ -142,6 +165,9 @@ When you bump the contract WASM:

- [ ] Decide the release type (patch / minor / major) using the
[upgrade policy](#upgrade-policy) table.
- [ ] If `quicklendx-contracts/Cargo.toml` `[package].version` changes, bump
`PROTOCOL_VERSION` in [`src/init.rs`](../quicklendx-contracts/src/init.rs)
and run `scripts/check-protocol-version-bump.sh`.
- [ ] For a **major** release, bump `PROTOCOL_VERSION` in
[`src/init.rs`](../quicklendx-contracts/src/init.rs) **before** building
the WASM, and document the migration.
Expand Down
18 changes: 18 additions & 0 deletions docs/UPGRADE_PATHS.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,24 @@ soroban contract invoke \

`get_version()` returns the value written to storage during `initialize()`, so it reflects the protocol version that was active when the contract was first set up — not the version of the currently-running WASM. After a WASM-only upgrade that does not bump `PROTOCOL_VERSION`, `get_version()` still returns the old value.

### Release ratchet CI gate

The repository treats a change to `quicklendx-contracts/Cargo.toml`
`[package].version` as a contract package release. CI runs
`scripts/check-protocol-version-bump.sh` and fails if that package version
changes without a matching increase to `PROTOCOL_VERSION` in `src/init.rs`.

Run the same gate locally before opening a release PR:

```bash
scripts/check-protocol-version-bump.sh
scripts/check-protocol-version-bump.test.sh
```

The ratchet makes release visibility explicit for operators and downstream
integrators. Storage compatibility is still governed by the migration guidance
below: a version increase does not by itself mean a storage migration is needed.

---

## Upgrade compatibility matrix
Expand Down
116 changes: 116 additions & 0 deletions scripts/check-protocol-version-bump.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
#!/usr/bin/env bash
# Enforce that contract package releases ratchet the public protocol version.

set -euo pipefail

MANIFEST_PATH="quicklendx-contracts/Cargo.toml"
INIT_PATH="quicklendx-contracts/src/init.rs"

repo_root="$(git rev-parse --show-toplevel)"
cd "${repo_root}"

head_ref="${HEAD_REF:-HEAD}"
base_ref="${BASE_REF:-}"

if [[ -z "${base_ref}" ]]; then
if [[ -n "${GITHUB_BASE_REF:-}" ]]; then
base_ref="origin/${GITHUB_BASE_REF}"
elif git rev-parse --verify --quiet origin/main >/dev/null; then
base_ref="origin/main"
elif git rev-parse --verify --quiet HEAD~1 >/dev/null; then
base_ref="HEAD~1"
else
base_ref="HEAD"
fi
fi

if [[ "${base_ref}" =~ ^0+$ ]]; then
echo "OK: no previous base commit for this event; protocol version ratchet skipped."
exit 0
fi

if ! git rev-parse --verify --quiet "${base_ref}^{commit}" >/dev/null; then
if [[ -n "${GITHUB_BASE_REF:-}" ]]; then
git fetch --no-tags --depth=1 origin "${GITHUB_BASE_REF}" >/dev/null 2>&1 || true
base_ref="FETCH_HEAD"
fi
fi

if ! git rev-parse --verify --quiet "${base_ref}^{commit}" >/dev/null; then
echo "ERROR: unable to resolve base ref '${base_ref}' for protocol version check." >&2
exit 1
fi

if ! git rev-parse --verify --quiet "${head_ref}^{commit}" >/dev/null; then
echo "ERROR: unable to resolve head ref '${head_ref}' for protocol version check." >&2
exit 1
fi

extract_package_version() {
local ref="$1"
git show "${ref}:${MANIFEST_PATH}" 2>/dev/null | awk '
/^\[package\]/ { in_package = 1; next }
/^\[/ { in_package = 0 }
in_package && $1 == "version" {
value = $0
sub(/^[^=]*=[[:space:]]*"/, "", value)
sub(/".*$/, "", value)
print value
exit
}
'
}

extract_protocol_version() {
local ref="$1"
git show "${ref}:${INIT_PATH}" 2>/dev/null | awk '
/^[[:space:]]*pub[[:space:]]+const[[:space:]]+PROTOCOL_VERSION[[:space:]]*:/ {
value = $0
sub(/^.*=[[:space:]]*/, "", value)
sub(/;.*/, "", value)
gsub(/[[:space:]]/, "", value)
print value
exit
}
'
}

base_package_version="$(extract_package_version "${base_ref}")"
head_package_version="$(extract_package_version "${head_ref}")"

if [[ -z "${base_package_version}" || -z "${head_package_version}" ]]; then
echo "ERROR: unable to read [package].version from ${MANIFEST_PATH}." >&2
exit 1
fi

if [[ "${base_package_version}" == "${head_package_version}" ]]; then
echo "OK: ${MANIFEST_PATH} package version unchanged (${head_package_version}); no protocol ratchet required."
exit 0
fi

base_protocol_version="$(extract_protocol_version "${base_ref}")"
head_protocol_version="$(extract_protocol_version "${head_ref}")"

if [[ -z "${base_protocol_version}" || -z "${head_protocol_version}" ]]; then
echo "ERROR: unable to read PROTOCOL_VERSION from ${INIT_PATH}." >&2
exit 1
fi

if ! [[ "${base_protocol_version}" =~ ^[0-9]+$ && "${head_protocol_version}" =~ ^[0-9]+$ ]]; then
echo "ERROR: PROTOCOL_VERSION must be an unsigned integer." >&2
echo " base=${base_protocol_version}, head=${head_protocol_version}" >&2
exit 1
fi

if (( head_protocol_version <= base_protocol_version )); then
cat >&2 <<MSG
ERROR: ${MANIFEST_PATH} package version changed (${base_package_version} -> ${head_package_version})
but ${INIT_PATH} PROTOCOL_VERSION did not increase (${base_protocol_version} -> ${head_protocol_version}).

Every contract package release must ratchet PROTOCOL_VERSION so operators and
downstream consumers can detect the release via get_version().
MSG
exit 1
fi

echo "OK: contract package version changed (${base_package_version} -> ${head_package_version}) and PROTOCOL_VERSION increased (${base_protocol_version} -> ${head_protocol_version})."
100 changes: 100 additions & 0 deletions scripts/check-protocol-version-bump.test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
#!/usr/bin/env bash
# Self-test for scripts/check-protocol-version-bump.sh.

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CHECK_SCRIPT="${SCRIPT_DIR}/check-protocol-version-bump.sh"
TMP_DIR="$(mktemp -d)"
trap 'rm -rf "${TMP_DIR}"' EXIT

make_repo() {
local name="$1"
local package_version="$2"
local protocol_version="$3"
local dir="${TMP_DIR}/${name}"

mkdir -p "${dir}/quicklendx-contracts/src"
git -C "${dir}" init --quiet
git -C "${dir}" config user.email "test@example.invalid"
git -C "${dir}" config user.name "Protocol Version Gate Test"

write_contract_files "${dir}" "${package_version}" "${protocol_version}"
git -C "${dir}" add quicklendx-contracts/Cargo.toml quicklendx-contracts/src/init.rs
git -C "${dir}" commit --quiet -m "base"

printf '%s\n' "${dir}"
}

write_contract_files() {
local dir="$1"
local package_version="$2"
local protocol_version="$3"

cat > "${dir}/quicklendx-contracts/Cargo.toml" <<TOML
[package]
name = "quicklendx-contracts"
version = "${package_version}"
edition = "2021"
TOML

cat > "${dir}/quicklendx-contracts/src/init.rs" <<RS
pub const PROTOCOL_VERSION: u32 = ${protocol_version};
RS
}

commit_release_change() {
local dir="$1"
local package_version="$2"
local protocol_version="$3"

write_contract_files "${dir}" "${package_version}" "${protocol_version}"
git -C "${dir}" add quicklendx-contracts/Cargo.toml quicklendx-contracts/src/init.rs
git -C "${dir}" commit --allow-empty --quiet -m "release change"
}

expect_success() {
local dir="$1"
local label="$2"

(
cd "${dir}"
BASE_REF=HEAD~1 HEAD_REF=HEAD bash "${CHECK_SCRIPT}" >/tmp/protocol-version-success.log 2>&1
) || {
echo "FAIL: expected success for ${label}" >&2
cat /tmp/protocol-version-success.log >&2
exit 1
}
}

expect_failure() {
local dir="$1"
local label="$2"

if (
cd "${dir}"
BASE_REF=HEAD~1 HEAD_REF=HEAD bash "${CHECK_SCRIPT}" >/tmp/protocol-version-failure.log 2>&1
); then
echo "FAIL: expected failure for ${label}" >&2
cat /tmp/protocol-version-failure.log >&2
exit 1
fi
}

no_version_change_repo="$(make_repo no-version-change 0.1.0 1)"
commit_release_change "${no_version_change_repo}" 0.1.0 1
expect_success "${no_version_change_repo}" "unchanged package version"

missing_bump_repo="$(make_repo missing-bump 0.1.0 1)"
commit_release_change "${missing_bump_repo}" 0.1.1 1
expect_failure "${missing_bump_repo}" "package version change without protocol bump"

valid_bump_repo="$(make_repo valid-bump 0.1.0 1)"
commit_release_change "${valid_bump_repo}" 0.1.1 2
expect_success "${valid_bump_repo}" "package version change with protocol bump"

decrease_repo="$(make_repo decrease 0.1.0 2)"
commit_release_change "${decrease_repo}" 0.1.1 1
expect_failure "${decrease_repo}" "package version change with protocol decrease"

echo "OK: protocol version bump gate self-test passed."
Loading