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
28 changes: 27 additions & 1 deletion .github/workflows/rust.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,4 +52,30 @@ jobs:
run: cargo test

- name: Build WebAssembly (build)
run: cargo build --target wasm32-unknown-unknown --release
run: cargo build --target wasm32-unknown-unknown --release

- name: Run Gas Tracking (Current Branch)
run: |
chmod +x ../scripts/track_gas.sh
../scripts/track_gas.sh
cp ../gas_report.json /tmp/pr_gas.json
cp ../scripts/track_gas.sh /tmp/track_gas.sh
cp ../scripts/compare_gas.py /tmp/compare_gas.py

- name: Run Gas Tracking (Base Branch)
if: github.event_name == 'pull_request'
run: |
git fetch origin ${{ github.base_ref }}
git checkout origin/${{ github.base_ref }} || true
/tmp/track_gas.sh || true
cp ../gas_report.json /tmp/base_gas.json || echo "[]" > /tmp/base_gas.json
git checkout - || true

- name: Compare Gas and Post PR Comment
if: github.event_name == 'pull_request'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
chmod +x /tmp/compare_gas.py
/tmp/compare_gas.py /tmp/base_gas.json /tmp/pr_gas.json > /tmp/gas_diff.md
gh pr comment ${{ github.event.pull_request.number }} --body-file /tmp/gas_diff.md || true
56 changes: 56 additions & 0 deletions contract/MIGRATION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Smart Contract Migration on Soroban

This document outlines the standardized operational process for introducing, releasing, and migrating smart contract code changes across `testnet` and `futurenet`.

Because SoroTask relies on a `Persistent` state environment where task configurations and balances persist across runs, replacing our `.wasm` logic using the new *upgrade execution route* prevents any state-loss that would normally occur from deploying a new contract ID.

---

## 🏗 Prerequisites
Ensure that:
1. You have the latest `soroban-cli` installed and configured.
2. An `admin` identity is securely accessible via your CLI keys: `soroban keys generate admin`
3. Network variables (`testnet`/`futurenet`) are bound in the CLI config.

---

## 🚀 1. Initial Deployment (New Instance)

To deploy the service from scratch (usually only done during the initial project bootstrap):

```bash
# Deploys, installs, and initializes the state
./scripts/deploy.sh testnet <TOKEN_ADDRESS> <ADMIN_ADDRESS>
```

**What this does:**
- Triggers `cargo build --target wasm32-unknown-unknown --release`
- Uploads the `.wasm` binary up to the designated network.
- Resolves the contract ID and automatically invokes the `init()` method passing `--token` and importantly the `--admin` parameter mapped to the `DataKey::Admin` slot.

---

## 🔄 2. Migration & Upgrade (Overwriting Existing Instance)

Once live, tasks register and gas funds execute natively. When we require bug fixes or new features logically mapped inside the contract, we execute an Upgrade!

```bash
# Installs new compiled code and instructs the old instance to transition over
./scripts/upgrade.sh testnet <EXISTING_CONTRACT_ID>
```

**What this does:**
- Repackages the workspace to fetch the newest binary.
- Instead of using `deploy`, it relies on the `install` CLI to get the explicit **hash** of the `.wasm` bytecode on the ledger.
- Communicates directly to your existing smart contract ID, providing the `--new_wasm_hash`.
- The contract invokes `admin.require_auth()` via native Soroban traits ensuring only the deployed admin controls lifecycle migrations.
- Executes `env.deployer().update_current_contract_wasm(new_wasm_hash)`.

### Operational Hazards to Monitor:
- **Testnet/Futurenet Ledger Resets:** Keep an eye out for Stellar Network Resets where the global ledger is dumped. You will need to start from Phase 1 `deploy.sh` rather than an upgrade.
- **Admin Configuration Change:** If in the future we want to *change* the admin, an additional function like `transfer_admin` would need parity inside the Rust contract execution.

---
## 👷🏻 Continuous Integration

These scripts are fully compatible with CI/CD flows utilizing the standard `$SOROBAN_SECRET_KEY` variable mapping from GitHub equivalents.
47 changes: 40 additions & 7 deletions contract/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ pub enum DataKey {
Task(u64),
Counter,
Token,
Admin,
}

#[contracttype]
Expand Down Expand Up @@ -312,12 +313,13 @@ impl SoroTaskContract {
}
}

/// Initializes the contract with a gas token.
pub fn init(env: Env, token: Address) {
/// Initializes the contract with a gas token and an admin.
pub fn init(env: Env, token: Address, admin: Address) {
if env.storage().instance().has(&DataKey::Token) {
panic!("Already initialized");
}
env.storage().instance().set(&DataKey::Token, &token);
env.storage().instance().set(&DataKey::Admin, &admin);
}

/// Deposits gas tokens to a task's balance.
Expand Down Expand Up @@ -430,12 +432,22 @@ impl SoroTaskContract {
.get(&DataKey::Token)
.expect("Not initialized")
}

/// Upgrades the contract to a new Wasm execution hash.
pub fn upgrade(env: Env, new_wasm_hash: soroban_sdk::BytesN<32>) {
let admin: Address = env.storage().instance().get(&DataKey::Admin).expect("Not initialized");
admin.require_auth();
env.deployer().update_current_contract_wasm(new_wasm_hash);
}
}

// ============================================================================
// Tests
// ============================================================================

#[cfg(test)]
mod test_gas;

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -881,7 +893,8 @@ mod tests {
let token_client = soroban_sdk::token::Client::new(&env, &token_address);
let token_admin_client = soroban_sdk::token::StellarAssetClient::new(&env, &token_address);

client.init(&token_address);
let admin = Address::generate(&env);
client.init(&token_address, &admin);

let target = env.register_contract(None, MockTarget);
let mut cfg = base_config(&env, target);
Expand Down Expand Up @@ -912,7 +925,8 @@ mod tests {

let token_id = env.register_stellar_asset_contract_v2(Address::generate(&env));
let token_address = token_id.address();
client.init(&token_address);
let admin = Address::generate(&env);
client.init(&token_address, &admin);

let target = env.register_contract(None, MockTarget);
let mut cfg = base_config(&env, target);
Expand Down Expand Up @@ -976,7 +990,8 @@ mod tests {
let token_client = soroban_sdk::token::Client::new(&env, &token_address);
let token_admin_client = soroban_sdk::token::StellarAssetClient::new(&env, &token_address);

client.init(&token_address);
let admin = Address::generate(&env);
client.init(&token_address, &admin);

let target = env.register_contract(None, MockTarget);
let mut cfg = base_config(&env, target);
Expand Down Expand Up @@ -1022,7 +1037,8 @@ mod tests {
let token_admin = Address::generate(&env);
let token_id = env.register_stellar_asset_contract_v2(token_admin.clone());
let token_address = token_id.address();
client.init(&token_address);
let admin = Address::generate(&env);
client.init(&token_address, &admin);

let target = env.register_contract(None, MockTarget);
let mut cfg = base_config(&env, target);
Expand Down Expand Up @@ -1085,7 +1101,8 @@ mod tests {
let token_client = soroban_sdk::token::Client::new(&env, &token_address);
let token_admin_client = soroban_sdk::token::StellarAssetClient::new(&env, &token_address);

client.init(&token_address);
let admin = Address::generate(&env);
client.init(&token_address, &admin);

let target = env.register_contract(None, MockTarget);
let mut cfg = base_config(&env, target);
Expand Down Expand Up @@ -1117,4 +1134,20 @@ mod tests {
soroban_sdk::Symbol::new(&env, "TaskCancelled")
);
}

#[test]
fn test_upgrade_admin_only() {
let (env, id) = setup();
let client = SoroTaskContractClient::new(&env, &id);

let token_id = env.register_stellar_asset_contract_v2(Address::generate(&env));
let token_address = token_id.address();
let admin = Address::generate(&env);
client.init(&token_address, &admin);

let new_wasm_hash = soroban_sdk::BytesN::from_array(&env, &[1; 32]);

// As env is using mock_all_auths(), this successfully completes mapping to admin authority
client.upgrade(&new_wasm_hash);
}
}
160 changes: 160 additions & 0 deletions contract/src/test_gas.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
#![cfg(test)]

use crate::{SoroTaskContract, SoroTaskContractClient, TaskConfig};
use soroban_sdk::{
testutils::{Address as _, Ledger},
vec, Address, Env, Symbol, Vec, contract, contractimpl,
};

#[contract]
pub struct MockTarget;

#[contractimpl]
impl MockTarget {
pub fn ping(_env: Env) -> bool {
true
}
}

fn setup() -> (Env, SoroTaskContractClient<'static>) {
let env = Env::default();
env.mock_all_auths();
let id = env.register_contract(None, SoroTaskContract);
let client = SoroTaskContractClient::new(&env, &id);
(env, client)
}

fn base_config(env: &Env, target: Address) -> TaskConfig {
TaskConfig {
creator: Address::generate(env),
target,
function: Symbol::new(env, "ping"),
args: Vec::new(env),
resolver: None,
interval: 3_600,
last_run: 0,
gas_balance: 1_000,
whitelist: Vec::new(env),
}
}

fn track_gas<F>(env: &Env, name: &str, operation: F)
where
F: FnOnce(),
{
env.cost_estimate().budget().reset_tracker();
operation();
let cpu = env.cost_estimate().budget().cpu_instruction_cost();
let mem = env.cost_estimate().budget().memory_bytes_cost();
println!("GAS_TRACKER: {{\"function\": \"{}\", \"cpu\": {}, \"mem\": {}}}", name, cpu, mem);
}

#[test]
fn test_gas_init() {
let (env, client) = setup();
let token_admin = Address::generate(&env);
let token_id = env.register_stellar_asset_contract_v2(token_admin);
let token_address = token_id.address();
let admin = Address::generate(&env);

track_gas(&env, "init", || {
client.init(&token_address, &admin);
});
}

#[test]
fn test_gas_register() {
let (env, client) = setup();
let target = env.register_contract(None, MockTarget);
let cfg = base_config(&env, target);

track_gas(&env, "register", || {
client.register(&cfg);
});
}

#[test]
fn test_gas_deposit() {
let (env, client) = setup();

// Setup token
let token_admin = Address::generate(&env);
let token_id = env.register_stellar_asset_contract_v2(token_admin.clone());
let token_address = token_id.address();
let token_admin_client = soroban_sdk::token::StellarAssetClient::new(&env, &token_address);
let admin = Address::generate(&env);
client.init(&token_address, &admin);

let target = env.register_contract(None, MockTarget);
let cfg = base_config(&env, target);
let task_id = client.register(&cfg);

// Mint tokens
token_admin_client.mint(&cfg.creator, &5000);

track_gas(&env, "deposit_gas", || {
client.deposit_gas(&task_id, &cfg.creator, &2000);
});
}

#[test]
fn test_gas_withdraw() {
let (env, client) = setup();

let token_admin = Address::generate(&env);
let token_id = env.register_stellar_asset_contract_v2(token_admin.clone());
let token_address = token_id.address();
let admin = Address::generate(&env);
client.init(&token_address, &admin);

let target = env.register_contract(None, MockTarget);
let mut cfg = base_config(&env, target);
cfg.gas_balance = 1000;
let task_id = client.register(&cfg);

track_gas(&env, "withdraw_gas", || {
client.withdraw_gas(&task_id, &500);
});
}

#[test]
fn test_gas_execute() {
let (env, client) = setup();

let token_admin = Address::generate(&env);
let token_id = env.register_stellar_asset_contract_v2(token_admin.clone());
let token_address = token_id.address();
let admin = Address::generate(&env);
client.init(&token_address, &admin);

let target = env.register_contract(None, MockTarget);
let cfg = base_config(&env, target);
let task_id = client.register(&cfg);

let keeper = Address::generate(&env);
env.ledger().set_timestamp(99999); // Ensure it's runnable

track_gas(&env, "execute", || {
client.execute(&keeper, &task_id);
});
}

#[test]
fn test_gas_cancel() {
let (env, client) = setup();

let token_admin = Address::generate(&env);
let token_id = env.register_stellar_asset_contract_v2(token_admin.clone());
let token_address = token_id.address();
let admin = Address::generate(&env);
client.init(&token_address, &admin);

let target = env.register_contract(None, MockTarget);
let mut cfg = base_config(&env, target);
cfg.gas_balance = 500;
let task_id = client.register(&cfg);

track_gas(&env, "cancel_task", || {
client.cancel_task(&task_id);
});
}
Loading