Skip to content
Merged
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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,7 @@ _tc_verify/
# Build artifacts
build_errors*.txt
*.log
contracts/migrations/history/*
!contracts/migrations/history/.gitkeep
contracts/migrations/snapshots/*
!contracts/migrations/snapshots/.gitkeep
49 changes: 48 additions & 1 deletion contracts/DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,42 @@ After deployment, you can verify that the contract is active by running:

Replace `<PROXY_ID>` with the proxy contract ID returned by the deployment script and `<NETWORK>` with `local`, `testnet`, or `public`.

## Migrations

For contract upgrades and cutovers, use the migration framework instead of ad-hoc redeploys:

```bash
export NETWORK="testnet"
export SOURCE_ACCOUNT="your-testnet-account-name"
export ADMIN_ADDRESS="GB..."
./scripts/run-migration.sh --network "$NETWORK" --source "$SOURCE_ACCOUNT" --admin "$ADMIN_ADDRESS"
```

What this does:
- Exports a plan and subscription snapshot from the active contract.
- Deploys and initializes a replacement contract.
- Validates the replacement contract's read paths.
- Updates `contracts/.env.<network>` only after validation passes.
- Records a rollback-ready history file in `contracts/migrations/history/`.

Dry-run example:

```bash
export NETWORK="testnet"
export SOURCE_ACCOUNT="your-testnet-account-name"
export ADMIN_ADDRESS="GB..."
./scripts/run-migration.sh --network "$NETWORK" --source "$SOURCE_ACCOUNT" --admin "$ADMIN_ADDRESS" --dry-run
```

Validate a target contract and inspect the exported snapshot:

```bash
./scripts/validate-migration.sh \
--network testnet \
--target-contract <NEW_CONTRACT_ID> \
--snapshot-dir contracts/migrations/snapshots/<SNAPSHOT_DIRECTORY>
```

### Explorer Source Verification

Some explorers (e.g., Stellar Expert / Soroban explorers) support attaching source bundles for transparency.
Expand Down Expand Up @@ -108,7 +144,6 @@ Notes:
### 1) Deploy a new implementation

Build and deploy the updated `subtrackr-subscription` contract.

You can use the helper script (deploy + schedule):

```bash
Expand Down Expand Up @@ -160,3 +195,15 @@ If the latest implementation is faulty, the proxy can schedule a rollback to the
Notes:
- Rollback changes the **implementation**, not the already-applied storage schema.
- Keep older implementations forward-compatible when possible (e.g., additive storage changes).

## Migration History Rollback

The migration framework added in this branch is still useful for operational cutovers that track an
active contract pointer outside the proxy upgrade path.

Restore the last recorded active contract from migration history with:

```bash
./scripts/rollback-migration.sh \
--history-file contracts/migrations/history/<MIGRATION_HISTORY_FILE>.env
```
1 change: 1 addition & 0 deletions contracts/migrations/.gitkeep
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

63 changes: 63 additions & 0 deletions contracts/migrations/001_blue_green_cutover.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
#!/bin/bash

set -euo pipefail

MIGRATION_ID="001_blue_green_cutover"
MIGRATION_DESCRIPTION="Blue-green Soroban contract redeploy with snapshot export, validation, and reversible cutover."
MIGRATION_STRATEGY="blue-green"

run_migration() {
local source_contract_id="$1"
local network="$2"
local source_account="$3"
local admin_address="$4"
local wasm_path="$5"
local dry_run="$6"

migration::ensure_workspace

local timestamp
timestamp="$(migration::timestamp)"

local snapshot_dir="$SNAPSHOTS_DIR/${MIGRATION_ID}_${network}_${timestamp}"
local history_file="$HISTORY_DIR/${MIGRATION_ID}_${network}_${timestamp}.env"
local previous_contract_id
previous_contract_id="$(migration::read_active_contract_id "$network")"

print_status "Exporting snapshot from source contract $source_contract_id"
if [ "$dry_run" = "true" ]; then
print_status "Dry run enabled; snapshot export skipped"
else
migration::export_snapshot "$source_contract_id" "$network" "$snapshot_dir"
fi

local new_contract_id="DRY_RUN_CONTRACT_ID"
if [ "$dry_run" = "true" ]; then
print_status "Dry run enabled; deployment and initialization skipped"
else
print_status "Deploying replacement contract using strategy: $MIGRATION_STRATEGY"
new_contract_id="$(migration::deploy_contract "$wasm_path" "$source_account" "$network")"
migration::initialize_contract "$new_contract_id" "$source_account" "$network" "$admin_address"
migration::validate_contract_access "$new_contract_id" "$network"
migration::write_active_contract_id "$network" "$new_contract_id"
fi

migration::write_history \
"$history_file" \
"MIGRATION_ID=$MIGRATION_ID" \
"MIGRATION_DESCRIPTION=$MIGRATION_DESCRIPTION" \
"NETWORK=$network" \
"STRATEGY=$MIGRATION_STRATEGY" \
"SOURCE_CONTRACT_ID=$source_contract_id" \
"PREVIOUS_CONTRACT_ID=$previous_contract_id" \
"NEW_CONTRACT_ID=$new_contract_id" \
"SNAPSHOT_DIR=$snapshot_dir" \
"ADMIN_ADDRESS=$admin_address" \
"STATUS=completed" \
"CREATED_AT=$timestamp"

print_success "Migration history written to $history_file"
if [ "$dry_run" != "true" ]; then
print_success "New active contract saved to contracts/.env.$network"
fi
}
28 changes: 28 additions & 0 deletions contracts/migrations/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Contract Migration Framework

This directory contains the SubTrackr contract migration framework for Soroban redeployments.

## Goals

- Export plan and subscription snapshots before a cutover.
- Support blue-green style redeployments for zero-downtime client cutovers.
- Validate the replacement contract before switching the active contract pointer.
- Keep a machine-readable history file for rollback and auditing.

## Structure

- `lib.sh`: shared migration helpers.
- `001_blue_green_cutover.sh`: baseline migration that snapshots the old contract, deploys a new one, initializes it, validates read access, and updates `contracts/.env.<network>`.
- `history/`: generated migration records.
- `snapshots/`: exported plan and subscription data from the source contract.

## Current Migration Model

Soroban contracts in this repository are immutable and the current contract does not expose admin import endpoints. Because of that, the migration framework treats migrations as:

1. Exporting the source contract's on-chain data for validation and operator review.
2. Deploying and initializing a replacement contract.
3. Switching the application's active contract pointer only after validation succeeds.
4. Allowing rollback by restoring the previous `CONTRACT_ID`.

That cutover model provides an operationally safe migration path today while leaving room for future state rehydration hooks.
1 change: 1 addition & 0 deletions contracts/migrations/history/.gitkeep
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

135 changes: 135 additions & 0 deletions contracts/migrations/lib.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
#!/bin/bash

set -euo pipefail

ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
CONTRACTS_DIR="$ROOT_DIR/contracts"
MIGRATIONS_DIR="$CONTRACTS_DIR/migrations"
HISTORY_DIR="$MIGRATIONS_DIR/history"
SNAPSHOTS_DIR="$MIGRATIONS_DIR/snapshots"

# shellcheck source=../../scripts/utils.sh
source "$ROOT_DIR/scripts/utils.sh"

migration::require_command() {
check_command "$1"
}

migration::require_env() {
validate_env "$1"
}

migration::ensure_workspace() {
mkdir -p "$HISTORY_DIR" "$SNAPSHOTS_DIR"
}

migration::timestamp() {
date -u +"%Y%m%dT%H%M%SZ"
}

migration::read_active_contract_id() {
local network="$1"
local env_file="$CONTRACTS_DIR/.env.$network"

if [ ! -f "$env_file" ]; then
return 0
fi

grep '^CONTRACT_ID=' "$env_file" | tail -n1 | cut -d'=' -f2-
}

migration::write_active_contract_id() {
local network="$1"
local contract_id="$2"
local env_file="$CONTRACTS_DIR/.env.$network"

printf 'CONTRACT_ID=%s\n' "$contract_id" > "$env_file"
}

migration::invoke_read() {
local contract_id="$1"
local network="$2"
shift 2

soroban contract invoke \
--id "$contract_id" \
--network "$network" \
-- "$@"
}

migration::export_snapshot() {
local contract_id="$1"
local network="$2"
local output_dir="$3"

mkdir -p "$output_dir/plans" "$output_dir/subscriptions"

local plan_count
local subscription_count

plan_count="$(migration::invoke_read "$contract_id" "$network" get_plan_count)"
subscription_count="$(migration::invoke_read "$contract_id" "$network" get_subscription_count)"

printf 'contract_id=%s\nnetwork=%s\nplan_count=%s\nsubscription_count=%s\n' \
"$contract_id" "$network" "$plan_count" "$subscription_count" > "$output_dir/summary.env"

local i
for ((i = 1; i <= plan_count; i++)); do
migration::invoke_read "$contract_id" "$network" get_plan --plan_id "$i" > "$output_dir/plans/$i.json"
done

for ((i = 1; i <= subscription_count; i++)); do
migration::invoke_read "$contract_id" "$network" get_subscription --subscription_id "$i" > "$output_dir/subscriptions/$i.json"
done
}

migration::build_contract() {
(
cd "$CONTRACTS_DIR"
cargo build --target wasm32-unknown-unknown --release
soroban contract optimize --wasm target/wasm32-unknown-unknown/release/subtrackr.wasm
)
}

migration::default_wasm_path() {
printf '%s\n' "$CONTRACTS_DIR/target/wasm32-unknown-unknown/release/subtrackr.optimized.wasm"
}

migration::deploy_contract() {
local wasm_path="$1"
local source_account="$2"
local network="$3"

soroban contract deploy \
--wasm "$wasm_path" \
--source "$source_account" \
--network "$network"
}

migration::initialize_contract() {
local contract_id="$1"
local source_account="$2"
local network="$3"
local admin_address="$4"

soroban contract invoke \
--id "$contract_id" \
--source "$source_account" \
--network "$network" \
-- initialize \
--admin "$admin_address"
}

migration::validate_contract_access() {
local contract_id="$1"
local network="$2"

migration::invoke_read "$contract_id" "$network" get_plan_count > /dev/null
migration::invoke_read "$contract_id" "$network" get_subscription_count > /dev/null
}

migration::write_history() {
local output_file="$1"
shift
printf '%s\n' "$@" > "$output_file"
}
1 change: 1 addition & 0 deletions contracts/migrations/snapshots/.gitkeep
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@
"contracts:fmt": "cd contracts && cargo fmt --check",
"contracts:clippy": "cd contracts && cargo clippy --all-targets -- -D warnings",
"contracts:build": "cd contracts && cargo build --release",
"contracts:migrate": "./scripts/run-migration.sh",
"contracts:migrate:validate": "./scripts/validate-migration.sh",
"contracts:migrate:rollback": "./scripts/rollback-migration.sh",
"contracts:verify": "cd contracts/subscription/certora && certoraRun ../src/lib.rs --verify SubTrackrSubscription:SubTrackrSubscription.spec --msg \"SubTrackr local formal verification\"",
"contracts:codegen": "typechain --target ethers-v5 --out-dir src/contracts/types \"src/contracts/abis/**/*.json\"",
"contracts:codegen:check": "npm run contracts:codegen && git diff --exit-code -- src/contracts/types src/contracts/abis",
Expand Down
46 changes: 46 additions & 0 deletions scripts/rollback-migration.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
#!/bin/bash

set -euo pipefail

ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"

# shellcheck source=./utils.sh
source "$ROOT_DIR/scripts/utils.sh"
# shellcheck source=../contracts/migrations/lib.sh
source "$ROOT_DIR/contracts/migrations/lib.sh"

HISTORY_FILE=""

while [[ $# -gt 0 ]]; do
case "$1" in
--history-file)
HISTORY_FILE="$2"
shift 2
;;
*)
print_error "Unknown argument: $1"
exit 1
;;
esac
done

if [ -z "$HISTORY_FILE" ]; then
print_error "Usage: ./scripts/rollback-migration.sh --history-file <contracts/migrations/history/*.env>"
exit 1
fi

if [ ! -f "$HISTORY_FILE" ]; then
print_error "History file not found: $HISTORY_FILE"
exit 1
fi

# shellcheck source=/dev/null
source "$HISTORY_FILE"

if [ -z "${NETWORK:-}" ] || [ -z "${PREVIOUS_CONTRACT_ID:-}" ]; then
print_error "History file is missing NETWORK or PREVIOUS_CONTRACT_ID"
exit 1
fi

migration::write_active_contract_id "$NETWORK" "$PREVIOUS_CONTRACT_ID"
print_success "Restored contracts/.env.$NETWORK to contract $PREVIOUS_CONTRACT_ID"
Loading
Loading