Skip to content
Open
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
134 changes: 134 additions & 0 deletions .github/workflows/contract-deploy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
name: Contract Deployment Pipeline

on:
push:
branches:
- main
paths:
- "contracts/**"
- "scripts/deploy.sh"
- ".github/workflows/contract-deploy.yml"
pull_request:
types: [opened, synchronize, reopened]
branches:
- main
workflow_dispatch:
inputs:
environment:
description: 'Environment to deploy to'
required: true
default: 'testnet'
type: choice
options:
- testnet
- staging
- mainnet

jobs:
deploy-testnet:
name: Deploy to Testnet
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: testnet
permissions:
contents: read
actions: write
steps:
- uses: actions/checkout@v4

- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown

- name: Install Soroban CLI
run: cargo install --locked soroban-cli

- name: Deploy Contract
env:
SECRET_KEY: ${{ secrets.STELLAR_SECRET_KEY }}
NETWORK: testnet
NETWORK_PASSPHRASE: "Test SDF Network ; September 2015"
RPC_URL: "https://soroban-testnet.stellar.org:443"
run: ./scripts/deploy.sh

- name: Update Env Variables
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
CONTRACT_ID=$(cat contracts/contract_id.txt)
echo "CONTRACT_ID=$CONTRACT_ID" >> $GITHUB_ENV
echo "Contract deployed with ID: $CONTRACT_ID"
gh variable set STELLAR_CONTRACT_ID --body "$CONTRACT_ID" --env testnet || echo "Warning: Failed to set Github Variable"

deploy-staging:
name: Deploy to Staging
if: github.event_name == 'pull_request' && startsWith(github.head_ref, 'release-please')
runs-on: ubuntu-latest
environment: staging
permissions:
contents: read
actions: write
steps:
- uses: actions/checkout@v4

- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown

- name: Install Soroban CLI
run: cargo install --locked soroban-cli

- name: Deploy Contract
env:
SECRET_KEY: ${{ secrets.STELLAR_SECRET_KEY }}
NETWORK: testnet
NETWORK_PASSPHRASE: "Test SDF Network ; September 2015"
RPC_URL: "https://soroban-testnet.stellar.org:443"
run: ./scripts/deploy.sh

- name: Update Env Variables
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
CONTRACT_ID=$(cat contracts/contract_id.txt)
echo "CONTRACT_ID=$CONTRACT_ID" >> $GITHUB_ENV
echo "Contract deployed with ID: $CONTRACT_ID"
gh variable set STELLAR_CONTRACT_ID --body "$CONTRACT_ID" --env staging || echo "Warning: Failed to set Github Variable"

deploy-mainnet:
name: Deploy to Mainnet
if: github.event_name == 'workflow_dispatch' && github.event.inputs.environment == 'mainnet'
runs-on: ubuntu-latest
environment: mainnet
permissions:
contents: read
actions: write
steps:
- uses: actions/checkout@v4

- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown

- name: Install Soroban CLI
run: cargo install --locked soroban-cli

- name: Deploy Contract
env:
SECRET_KEY: ${{ secrets.STELLAR_MAINNET_SECRET_KEY }}
NETWORK: mainnet
NETWORK_PASSPHRASE: "Public Global Stellar Network ; September 2015"
RPC_URL: "https://soroban-mainnet.stellar.org:443"
run: ./scripts/deploy.sh

- name: Update Env Variables
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
CONTRACT_ID=$(cat contracts/contract_id.txt)
echo "CONTRACT_ID=$CONTRACT_ID" >> $GITHUB_ENV
echo "Contract deployed with ID: $CONTRACT_ID"
gh variable set STELLAR_CONTRACT_ID --body "$CONTRACT_ID" --env mainnet || echo "Warning: Failed to set Github Variable"
112 changes: 104 additions & 8 deletions contracts/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ pub struct Stream {
pub canceled: bool,
pub paused: bool,
pub pause_started_at: Option<u64>,
pub expired: bool,

pub metadata: Option<Map<String, String>>,
}
Expand All @@ -94,6 +95,7 @@ pub enum DataKey {
ChildToParent(u64),
NativeToken,
AllowedTokens,
GracePeriod,
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -230,6 +232,15 @@ pub struct StreamTransferred {
pub new_recipient: Address,
}

/// Emitted when a stream reaches its grace period without being fully claimed
#[contracttype]
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct StreamExpired {
pub stream_id: u64,
pub actor: Address,
pub timestamp: u64,
}

#[contract]
pub struct StellarStreamContract;

Expand Down Expand Up @@ -320,6 +331,7 @@ impl StellarStreamContract {
canceled: false,
paused: false,
pause_started_at: None,
expired: false,

metadata: metadata.clone(),
};
Expand Down Expand Up @@ -421,6 +433,7 @@ impl StellarStreamContract {
canceled: false,
paused: false,
pause_started_at: None,
expired: false,
metadata: None,
};

Expand Down Expand Up @@ -473,7 +486,7 @@ impl StellarStreamContract {
}

pub fn get_stream(env: Env, stream_id: u64) -> Stream {
read_stream(&env, stream_id)
check_and_expire(&env, stream_id, &env.current_contract_address())
}

pub fn get_next_stream_id(env: Env) -> u64 {
Expand All @@ -492,7 +505,10 @@ impl StellarStreamContract {
}

pub fn claimable(env: Env, stream_id: u64, at_time: u64) -> i128 {
let stream = read_stream(&env, stream_id);
let stream = check_and_expire(&env, stream_id, &env.current_contract_address());
if stream.expired {
return 0;
}
let vested = vested_amount(&stream, at_time);
let claimable = vested - stream.claimed_amount;
if claimable < 0 { 0 } else { claimable }
Expand All @@ -506,13 +522,18 @@ impl StellarStreamContract {
for stream_id in stream_ids.iter() {
let stream_opt: Option<Stream> = env.storage().persistent().get(&DataKey::Stream(stream_id));
let amount = match stream_opt {
Some(stream) => {
let vested = vested_amount(&stream, at_time);
let claimable = vested - stream.claimed_amount;
if claimable < 0 {
Some(_) => {
let stream = check_and_expire(&env, stream_id, &env.current_contract_address());
if stream.expired {
0
} else {
claimable
let vested = vested_amount(&stream, at_time);
let claimable = vested - stream.claimed_amount;
if claimable < 0 {
0
} else {
claimable
}
}
}
None => 0,
Expand All @@ -531,11 +552,14 @@ impl StellarStreamContract {
panic!("amount must be positive");
}

let mut stream = read_stream(&env, stream_id);
let mut stream = check_and_expire(&env, stream_id, &recipient);
if stream.recipient != recipient {
panic!("recipient mismatch");
}
recipient.require_auth();
if stream.expired {
panic!("stream expired");
}

let now = env.ledger().timestamp();
let claimable_now = Self::claimable(env.clone(), stream_id, now);
Expand Down Expand Up @@ -842,6 +866,56 @@ impl StellarStreamContract {
admin.require_auth();
env.storage().instance().set(&DataKey::Admin, &new_admin);
}

pub fn set_grace_period(env: Env, admin: Address, grace_period: u64) {
let admin_stored: Address = env.storage().instance().get(&DataKey::Admin).unwrap_or_else(|| panic!("contract not initialized"));
if admin_stored != admin { panic!("unauthorized"); }
admin.require_auth();
env.storage().instance().set(&DataKey::GracePeriod, &grace_period);
}

pub fn get_grace_period(env: Env) -> u64 {
env.storage().instance().get(&DataKey::GracePeriod).unwrap_or(2592000)
}

pub fn reclaim_expired(env: Env, stream_id: u64, sender: Address) -> i128 {
let mut stream = check_and_expire(&env, stream_id, &sender);
if stream.sender != sender {
panic!("sender mismatch");
}
sender.require_auth();

if !stream.expired {
panic!("stream not expired");
}

let now = env.ledger().timestamp();
let vested = vested_amount(&stream, now);
let unclaimed = vested - stream.claimed_amount;

if unclaimed <= 0 {
panic!("no unclaimed balance");
}

let is_native = stream.token.to_string() == String::from_str(&env, NATIVE_SENTINEL);
let actual_token = if is_native {
env.storage().instance().get(&DataKey::NativeToken).unwrap_or_else(|| panic!("not initialized"))
} else {
stream.token.clone()
};
let token_client = TokenClient::new(&env, &actual_token);
let contract_address = env.current_contract_address();

token_client.transfer(&contract_address, &sender, &unclaimed);

stream.claimed_amount += unclaimed;

env.storage()
.persistent()
.set(&DataKey::Stream(stream_id), &stream);

unclaimed
}
}

// ---------------------------------------------------------------------------
Expand All @@ -855,6 +929,28 @@ fn read_stream(env: &Env, stream_id: u64) -> Stream {
.unwrap_or_else(|| panic!("stream not found"))
}

fn check_and_expire(env: &Env, stream_id: u64, actor: &Address) -> Stream {
let mut stream = read_stream(env, stream_id);
if stream.expired {
return stream;
}
let grace_period: u64 = env.storage().instance().get(&DataKey::GracePeriod).unwrap_or(2592000); // 30 days
let now = env.ledger().timestamp();
if now > stream.end_time.saturating_add(grace_period) {
stream.expired = true;
env.events().publish(
(symbol_short!("Stream"), symbol_short!("Expired")),
StreamExpired {
stream_id,
actor: actor.clone(),
timestamp: now,
},
);
env.storage().persistent().set(&DataKey::Stream(stream_id), &stream);
}
stream
}

fn vested_amount(stream: &Stream, at_time: u64) -> i128 {
let effective_now = if stream.paused {
stream.pause_started_at.unwrap_or(at_time)
Expand Down
3 changes: 2 additions & 1 deletion scripts/deploy.sh
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ CONTRACTS_DIR="contracts"
CONTRACT_ID_FILE="contract_id.txt"
NETWORK_PASSPHRASE="${NETWORK_PASSPHRASE:-Test SDF Network ; September 2015}"
RPC_URL="${RPC_URL:-https://soroban-testnet.stellar.org:443}"
NETWORK="${NETWORK:-testnet}"

# Check for required environment variables
if [ -z "$SECRET_KEY" ]; then
Expand Down Expand Up @@ -93,7 +94,7 @@ echo -e "${YELLOW}Deploying contract to testnet...${NC}"
DEPLOY_OUTPUT=$(soroban contract deploy \
--wasm target/wasm32v1-none/release/stellar_stream.wasm \
--source-account "$SECRET_KEY" \
--network testnet \
--network "$NETWORK" \
--network-passphrase "$NETWORK_PASSPHRASE" \
--rpc-url "$RPC_URL" \
2>&1)
Expand Down