Skip to content

Commit b8ea7d9

Browse files
authored
feat: add contract migration tooling (#287)
1 parent 2b2bf27 commit b8ea7d9

12 files changed

Lines changed: 487 additions & 1 deletion

File tree

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,3 +81,7 @@ coverage/
8181
# Build artifacts
8282
build_errors*.txt
8383
*.log
84+
contracts/migrations/history/*
85+
!contracts/migrations/history/.gitkeep
86+
contracts/migrations/snapshots/*
87+
!contracts/migrations/snapshots/.gitkeep

contracts/DEPLOYMENT.md

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,42 @@ After deployment, you can verify that the contract is active by running:
7373

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

76+
## Migrations
77+
78+
For contract upgrades and cutovers, use the migration framework instead of ad-hoc redeploys:
79+
80+
```bash
81+
export NETWORK="testnet"
82+
export SOURCE_ACCOUNT="your-testnet-account-name"
83+
export ADMIN_ADDRESS="GB..."
84+
./scripts/run-migration.sh --network "$NETWORK" --source "$SOURCE_ACCOUNT" --admin "$ADMIN_ADDRESS"
85+
```
86+
87+
What this does:
88+
- Exports a plan and subscription snapshot from the active contract.
89+
- Deploys and initializes a replacement contract.
90+
- Validates the replacement contract's read paths.
91+
- Updates `contracts/.env.<network>` only after validation passes.
92+
- Records a rollback-ready history file in `contracts/migrations/history/`.
93+
94+
Dry-run example:
95+
96+
```bash
97+
export NETWORK="testnet"
98+
export SOURCE_ACCOUNT="your-testnet-account-name"
99+
export ADMIN_ADDRESS="GB..."
100+
./scripts/run-migration.sh --network "$NETWORK" --source "$SOURCE_ACCOUNT" --admin "$ADMIN_ADDRESS" --dry-run
101+
```
102+
103+
Validate a target contract and inspect the exported snapshot:
104+
105+
```bash
106+
./scripts/validate-migration.sh \
107+
--network testnet \
108+
--target-contract <NEW_CONTRACT_ID> \
109+
--snapshot-dir contracts/migrations/snapshots/<SNAPSHOT_DIRECTORY>
110+
```
111+
76112
### Explorer Source Verification
77113

78114
Some explorers (e.g., Stellar Expert / Soroban explorers) support attaching source bundles for transparency.
@@ -110,7 +146,6 @@ Notes:
110146
### 1) Deploy a new implementation
111147

112148
Build and deploy the updated `subtrackr-subscription` contract.
113-
114149
You can use the helper script (deploy + schedule):
115150

116151
```bash
@@ -166,3 +201,15 @@ Notes:
166201

167202
- Rollback changes the **implementation**, not the already-applied storage schema.
168203
- Keep older implementations forward-compatible when possible (e.g., additive storage changes).
204+
205+
## Migration History Rollback
206+
207+
The migration framework added in this branch is still useful for operational cutovers that track an
208+
active contract pointer outside the proxy upgrade path.
209+
210+
Restore the last recorded active contract from migration history with:
211+
212+
```bash
213+
./scripts/rollback-migration.sh \
214+
--history-file contracts/migrations/history/<MIGRATION_HISTORY_FILE>.env
215+
```

contracts/migrations/.gitkeep

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
#!/bin/bash
2+
3+
set -euo pipefail
4+
5+
MIGRATION_ID="001_blue_green_cutover"
6+
MIGRATION_DESCRIPTION="Blue-green Soroban contract redeploy with snapshot export, validation, and reversible cutover."
7+
MIGRATION_STRATEGY="blue-green"
8+
9+
run_migration() {
10+
local source_contract_id="$1"
11+
local network="$2"
12+
local source_account="$3"
13+
local admin_address="$4"
14+
local wasm_path="$5"
15+
local dry_run="$6"
16+
17+
migration::ensure_workspace
18+
19+
local timestamp
20+
timestamp="$(migration::timestamp)"
21+
22+
local snapshot_dir="$SNAPSHOTS_DIR/${MIGRATION_ID}_${network}_${timestamp}"
23+
local history_file="$HISTORY_DIR/${MIGRATION_ID}_${network}_${timestamp}.env"
24+
local previous_contract_id
25+
previous_contract_id="$(migration::read_active_contract_id "$network")"
26+
27+
print_status "Exporting snapshot from source contract $source_contract_id"
28+
if [ "$dry_run" = "true" ]; then
29+
print_status "Dry run enabled; snapshot export skipped"
30+
else
31+
migration::export_snapshot "$source_contract_id" "$network" "$snapshot_dir"
32+
fi
33+
34+
local new_contract_id="DRY_RUN_CONTRACT_ID"
35+
if [ "$dry_run" = "true" ]; then
36+
print_status "Dry run enabled; deployment and initialization skipped"
37+
else
38+
print_status "Deploying replacement contract using strategy: $MIGRATION_STRATEGY"
39+
new_contract_id="$(migration::deploy_contract "$wasm_path" "$source_account" "$network")"
40+
migration::initialize_contract "$new_contract_id" "$source_account" "$network" "$admin_address"
41+
migration::validate_contract_access "$new_contract_id" "$network"
42+
migration::write_active_contract_id "$network" "$new_contract_id"
43+
fi
44+
45+
migration::write_history \
46+
"$history_file" \
47+
"MIGRATION_ID=$MIGRATION_ID" \
48+
"MIGRATION_DESCRIPTION=$MIGRATION_DESCRIPTION" \
49+
"NETWORK=$network" \
50+
"STRATEGY=$MIGRATION_STRATEGY" \
51+
"SOURCE_CONTRACT_ID=$source_contract_id" \
52+
"PREVIOUS_CONTRACT_ID=$previous_contract_id" \
53+
"NEW_CONTRACT_ID=$new_contract_id" \
54+
"SNAPSHOT_DIR=$snapshot_dir" \
55+
"ADMIN_ADDRESS=$admin_address" \
56+
"STATUS=completed" \
57+
"CREATED_AT=$timestamp"
58+
59+
print_success "Migration history written to $history_file"
60+
if [ "$dry_run" != "true" ]; then
61+
print_success "New active contract saved to contracts/.env.$network"
62+
fi
63+
}

contracts/migrations/README.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# Contract Migration Framework
2+
3+
This directory contains the SubTrackr contract migration framework for Soroban redeployments.
4+
5+
## Goals
6+
7+
- Export plan and subscription snapshots before a cutover.
8+
- Support blue-green style redeployments for zero-downtime client cutovers.
9+
- Validate the replacement contract before switching the active contract pointer.
10+
- Keep a machine-readable history file for rollback and auditing.
11+
12+
## Structure
13+
14+
- `lib.sh`: shared migration helpers.
15+
- `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>`.
16+
- `history/`: generated migration records.
17+
- `snapshots/`: exported plan and subscription data from the source contract.
18+
19+
## Current Migration Model
20+
21+
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:
22+
23+
1. Exporting the source contract's on-chain data for validation and operator review.
24+
2. Deploying and initializing a replacement contract.
25+
3. Switching the application's active contract pointer only after validation succeeds.
26+
4. Allowing rollback by restoring the previous `CONTRACT_ID`.
27+
28+
That cutover model provides an operationally safe migration path today while leaving room for future state rehydration hooks.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+

contracts/migrations/lib.sh

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
#!/bin/bash
2+
3+
set -euo pipefail
4+
5+
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
6+
CONTRACTS_DIR="$ROOT_DIR/contracts"
7+
MIGRATIONS_DIR="$CONTRACTS_DIR/migrations"
8+
HISTORY_DIR="$MIGRATIONS_DIR/history"
9+
SNAPSHOTS_DIR="$MIGRATIONS_DIR/snapshots"
10+
11+
# shellcheck source=../../scripts/utils.sh
12+
source "$ROOT_DIR/scripts/utils.sh"
13+
14+
migration::require_command() {
15+
check_command "$1"
16+
}
17+
18+
migration::require_env() {
19+
validate_env "$1"
20+
}
21+
22+
migration::ensure_workspace() {
23+
mkdir -p "$HISTORY_DIR" "$SNAPSHOTS_DIR"
24+
}
25+
26+
migration::timestamp() {
27+
date -u +"%Y%m%dT%H%M%SZ"
28+
}
29+
30+
migration::read_active_contract_id() {
31+
local network="$1"
32+
local env_file="$CONTRACTS_DIR/.env.$network"
33+
34+
if [ ! -f "$env_file" ]; then
35+
return 0
36+
fi
37+
38+
grep '^CONTRACT_ID=' "$env_file" | tail -n1 | cut -d'=' -f2-
39+
}
40+
41+
migration::write_active_contract_id() {
42+
local network="$1"
43+
local contract_id="$2"
44+
local env_file="$CONTRACTS_DIR/.env.$network"
45+
46+
printf 'CONTRACT_ID=%s\n' "$contract_id" > "$env_file"
47+
}
48+
49+
migration::invoke_read() {
50+
local contract_id="$1"
51+
local network="$2"
52+
shift 2
53+
54+
soroban contract invoke \
55+
--id "$contract_id" \
56+
--network "$network" \
57+
-- "$@"
58+
}
59+
60+
migration::export_snapshot() {
61+
local contract_id="$1"
62+
local network="$2"
63+
local output_dir="$3"
64+
65+
mkdir -p "$output_dir/plans" "$output_dir/subscriptions"
66+
67+
local plan_count
68+
local subscription_count
69+
70+
plan_count="$(migration::invoke_read "$contract_id" "$network" get_plan_count)"
71+
subscription_count="$(migration::invoke_read "$contract_id" "$network" get_subscription_count)"
72+
73+
printf 'contract_id=%s\nnetwork=%s\nplan_count=%s\nsubscription_count=%s\n' \
74+
"$contract_id" "$network" "$plan_count" "$subscription_count" > "$output_dir/summary.env"
75+
76+
local i
77+
for ((i = 1; i <= plan_count; i++)); do
78+
migration::invoke_read "$contract_id" "$network" get_plan --plan_id "$i" > "$output_dir/plans/$i.json"
79+
done
80+
81+
for ((i = 1; i <= subscription_count; i++)); do
82+
migration::invoke_read "$contract_id" "$network" get_subscription --subscription_id "$i" > "$output_dir/subscriptions/$i.json"
83+
done
84+
}
85+
86+
migration::build_contract() {
87+
(
88+
cd "$CONTRACTS_DIR"
89+
cargo build --target wasm32-unknown-unknown --release
90+
soroban contract optimize --wasm target/wasm32-unknown-unknown/release/subtrackr.wasm
91+
)
92+
}
93+
94+
migration::default_wasm_path() {
95+
printf '%s\n' "$CONTRACTS_DIR/target/wasm32-unknown-unknown/release/subtrackr.optimized.wasm"
96+
}
97+
98+
migration::deploy_contract() {
99+
local wasm_path="$1"
100+
local source_account="$2"
101+
local network="$3"
102+
103+
soroban contract deploy \
104+
--wasm "$wasm_path" \
105+
--source "$source_account" \
106+
--network "$network"
107+
}
108+
109+
migration::initialize_contract() {
110+
local contract_id="$1"
111+
local source_account="$2"
112+
local network="$3"
113+
local admin_address="$4"
114+
115+
soroban contract invoke \
116+
--id "$contract_id" \
117+
--source "$source_account" \
118+
--network "$network" \
119+
-- initialize \
120+
--admin "$admin_address"
121+
}
122+
123+
migration::validate_contract_access() {
124+
local contract_id="$1"
125+
local network="$2"
126+
127+
migration::invoke_read "$contract_id" "$network" get_plan_count > /dev/null
128+
migration::invoke_read "$contract_id" "$network" get_subscription_count > /dev/null
129+
}
130+
131+
migration::write_history() {
132+
local output_file="$1"
133+
shift
134+
printf '%s\n' "$@" > "$output_file"
135+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@
2222
"contracts:fmt": "cd contracts && cargo fmt --check",
2323
"contracts:clippy": "cd contracts && cargo clippy --all-targets -- -D warnings",
2424
"contracts:build": "cd contracts && cargo build --release",
25+
"contracts:migrate": "./scripts/run-migration.sh",
26+
"contracts:migrate:validate": "./scripts/validate-migration.sh",
27+
"contracts:migrate:rollback": "./scripts/rollback-migration.sh",
2528
"contracts:verify": "cd contracts/subscription/certora && certoraRun ../src/lib.rs --verify SubTrackrSubscription:SubTrackrSubscription.spec --msg \"SubTrackr local formal verification\"",
2629
"contracts:codegen": "typechain --target ethers-v5 --out-dir src/contracts/types \"src/contracts/abis/**/*.json\"",
2730
"contracts:codegen:check": "npm run contracts:codegen && git diff --exit-code -- src/contracts/types src/contracts/abis",

scripts/rollback-migration.sh

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
#!/bin/bash
2+
3+
set -euo pipefail
4+
5+
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
6+
7+
# shellcheck source=./utils.sh
8+
source "$ROOT_DIR/scripts/utils.sh"
9+
# shellcheck source=../contracts/migrations/lib.sh
10+
source "$ROOT_DIR/contracts/migrations/lib.sh"
11+
12+
HISTORY_FILE=""
13+
14+
while [[ $# -gt 0 ]]; do
15+
case "$1" in
16+
--history-file)
17+
HISTORY_FILE="$2"
18+
shift 2
19+
;;
20+
*)
21+
print_error "Unknown argument: $1"
22+
exit 1
23+
;;
24+
esac
25+
done
26+
27+
if [ -z "$HISTORY_FILE" ]; then
28+
print_error "Usage: ./scripts/rollback-migration.sh --history-file <contracts/migrations/history/*.env>"
29+
exit 1
30+
fi
31+
32+
if [ ! -f "$HISTORY_FILE" ]; then
33+
print_error "History file not found: $HISTORY_FILE"
34+
exit 1
35+
fi
36+
37+
# shellcheck source=/dev/null
38+
source "$HISTORY_FILE"
39+
40+
if [ -z "${NETWORK:-}" ] || [ -z "${PREVIOUS_CONTRACT_ID:-}" ]; then
41+
print_error "History file is missing NETWORK or PREVIOUS_CONTRACT_ID"
42+
exit 1
43+
fi
44+
45+
migration::write_active_contract_id "$NETWORK" "$PREVIOUS_CONTRACT_ID"
46+
print_success "Restored contracts/.env.$NETWORK to contract $PREVIOUS_CONTRACT_ID"

0 commit comments

Comments
 (0)