diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 436ac46..f6ec943 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -79,3 +79,15 @@ jobs: - name: Run tests run: cargo test working-directory: contract + + - name: Install Stellar CLI + run: cargo install --locked stellar-cli + + - name: Deploy and verify on Futurenet + run: | + ./scripts/deploy.sh \ + --network futurenet \ + --source "ci-deployer-${{ github.run_id }}-${{ github.run_attempt }}" \ + --out ./deployed-ci.json \ + --env-out ./.env.deployed-ci + working-directory: contract diff --git a/.gitignore b/.gitignore index f371b04..1e60f55 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,11 @@ node_modules/ # Contract (Rust / Soroban) contract/target/ +contract/deployed.json +contract/.env.deployed +contract/deployed-ci.json +contract/.env.deployed-ci +contract/.stellar/ # Environment .env diff --git a/contract/Cargo.lock b/contract/Cargo.lock index 705464e..a16d94b 100644 --- a/contract/Cargo.lock +++ b/contract/Cargo.lock @@ -147,6 +147,14 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "content-access" +version = "0.1.0" +dependencies = [ + "myfans-lib", + "soroban-sdk", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -173,6 +181,13 @@ dependencies = [ "serde_json", ] +[[package]] +name = "creator-registry" +version = "0.1.0" +dependencies = [ + "soroban-sdk", +] + [[package]] name = "crypto-bigint" version = "0.5.5" @@ -357,6 +372,13 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "earnings" +version = "0.1.0" +dependencies = [ + "soroban-sdk", +] + [[package]] name = "ecdsa" version = "0.16.9" @@ -687,6 +709,20 @@ dependencies = [ "soroban-sdk", ] +[[package]] +name = "myfans-lib" +version = "0.1.0" +dependencies = [ + "soroban-sdk", +] + +[[package]] +name = "myfans-token" +version = "0.1.0" +dependencies = [ + "soroban-sdk", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -1309,6 +1345,14 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subscription" +version = "0.1.0" +dependencies = [ + "myfans-lib", + "soroban-sdk", +] + [[package]] name = "subtle" version = "2.6.1" @@ -1326,6 +1370,14 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "test-consumer" +version = "0.1.0" +dependencies = [ + "myfans-lib", + "soroban-sdk", +] + [[package]] name = "thiserror" version = "1.0.69" diff --git a/contract/Cargo.toml b/contract/Cargo.toml index 01e0c41..bc07f09 100644 --- a/contract/Cargo.toml +++ b/contract/Cargo.toml @@ -15,6 +15,19 @@ soroban-sdk = { version = "21.7.0", features = ["testutils"] } [workspace.dependencies] soroban-sdk = "21.7.0" +[workspace] +resolver = "2" +members = [ + ".", + "contracts/content-access", + "contracts/creator-registry", + "contracts/earnings", + "contracts/myfans-lib", + "contracts/myfans-token", + "contracts/subscription", + "contracts/test-consumer", +] + [profile.release] opt-level = "z" overflow-checks = true diff --git a/contract/README.md b/contract/README.md index 3e4b165..1b9df71 100644 --- a/contract/README.md +++ b/contract/README.md @@ -1,51 +1,102 @@ # MyFans Soroban Contracts -Soroban smart contracts for the MyFans decentralized content subscription platform. +Smart contracts and deployment automation for MyFans on Stellar/Soroban. -## Prerequisites +## Contracts deployed by script -- Rust 1.70+ -- `soroban-cli` (install: `cargo install soroban-cli`) +1. `myfans-token` +2. `creator-registry` +3. `subscription` +4. `content-access` +5. `earnings` -## Structure +The deploy script applies this order to keep initialization/dependency flow deterministic. -``` -contract/ -├── Cargo.toml # Workspace root -└── contracts/ - └── myfans-token/ # Stub contract -``` +## Prerequisites -## Build +- Rust stable +- `stellar-cli` installed: ```bash -cd contract -cargo build +cargo install --locked stellar-cli ``` -For optimized WASM: +## Network configuration + +`contract/scripts/deploy.sh` supports: + +- `--network futurenet` + - RPC: `https://rpc-futurenet.stellar.org:443` + - Passphrase: `Test SDF Future Network ; October 2022` +- `--network testnet` + - RPC: `https://rpc-testnet.stellar.org:443` + - Passphrase: `Test SDF Network ; September 2015` +- `--network mainnet` + - RPC: `https://rpc-mainnet.stellar.org:443` + - Passphrase: `Public Global Stellar Network ; September 2015` + +You can override either value with: + +- `--rpc-url ` +- `--network-passphrase ` + +## Funding account setup + +The deploy script requires a Stellar identity (`--source`). +CLI state is stored locally in `contract/.stellar` by default (override with `STELLAR_STATE_DIR`). + +- Futurenet/testnet: + - If the identity does not exist, the script generates it. + - The script funds it automatically (friendbot) unless `--no-fund` is set. +- Mainnet: + - Auto-generation/funding is disabled. + - Provide an existing funded source identity. + +Useful commands: + ```bash -cargo build --release --target wasm32-unknown-unknown +stellar keys generate myfans-deployer --network futurenet --fund +stellar keys public-key myfans-deployer +stellar keys fund myfans-deployer --network testnet ``` -## Test +## One-command deploy + +From repository root: ```bash -cd contract -cargo test +./contract/scripts/deploy.sh --network futurenet ``` -## Deploy (Testnet) +Deploy to testnet: ```bash -soroban contract build -soroban contract deploy \ - --wasm target/wasm32-unknown-unknown/release/myfans_token.wasm \ - --network testnet +./contract/scripts/deploy.sh --network testnet --source myfans-testnet-deployer ``` -## Next Steps +The script deploys all five contracts, initializes required contracts, then verifies each one by invoking a view method (`version`, `admin`, `has-access`). + +## Output files + +By default, deployment outputs are written to: + +- `contract/deployed.json` +- `contract/.env.deployed` + +Both include contract addresses/IDs and network metadata. + +Override paths with: + +- `--out ` +- `--env-out ` + +## CI verification + +GitHub Actions `contracts` job now includes: + +1. Build (`cargo build --target wasm32-unknown-unknown --release`) +2. Test (`cargo test`) +3. Deploy on Futurenet (`./scripts/deploy.sh --network futurenet ...`) +4. Verify contract responses during deploy -- Implement subscription lifecycle contract -- Add payment routing and fee logic -- Add access control functions +If contract deploy or verification fails, CI fails. diff --git a/contract/contracts/creator-registry/Cargo.toml b/contract/contracts/creator-registry/Cargo.toml new file mode 100644 index 0000000..aac524c --- /dev/null +++ b/contract/contracts/creator-registry/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "creator-registry" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +soroban-sdk = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/contract/contracts/creator-registry/src/lib.rs b/contract/contracts/creator-registry/src/lib.rs new file mode 100644 index 0000000..c8433ae --- /dev/null +++ b/contract/contracts/creator-registry/src/lib.rs @@ -0,0 +1,41 @@ +#![no_std] + +use soroban_sdk::{contract, contractimpl, contracttype, Address, Env}; + +#[contracttype] +enum DataKey { + Admin, + Creator(Address), +} + +#[contract] +pub struct CreatorRegistry; + +#[contractimpl] +impl CreatorRegistry { + pub fn init(env: Env, admin: Address) { + if env.storage().instance().has(&DataKey::Admin) { + panic!("already initialized"); + } + + admin.require_auth(); + env.storage().instance().set(&DataKey::Admin, &admin); + } + + pub fn admin(env: Env) -> Address { + env.storage().instance().get(&DataKey::Admin).unwrap() + } + + pub fn register(env: Env, creator: Address) { + let admin = Self::admin(env.clone()); + admin.require_auth(); + env.storage().instance().set(&DataKey::Creator(creator), &true); + } + + pub fn is_registered(env: Env, creator: Address) -> bool { + env.storage() + .instance() + .get(&DataKey::Creator(creator)) + .unwrap_or(false) + } +} diff --git a/contract/contracts/earnings/Cargo.toml b/contract/contracts/earnings/Cargo.toml new file mode 100644 index 0000000..0dd2525 --- /dev/null +++ b/contract/contracts/earnings/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "earnings" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +soroban-sdk = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/contract/contracts/earnings/src/lib.rs b/contract/contracts/earnings/src/lib.rs new file mode 100644 index 0000000..9863da1 --- /dev/null +++ b/contract/contracts/earnings/src/lib.rs @@ -0,0 +1,49 @@ +#![no_std] + +use soroban_sdk::{contract, contractimpl, contracttype, Address, Env}; + +#[contracttype] +enum DataKey { + Admin, + Earnings(Address), +} + +#[contract] +pub struct Earnings; + +#[contractimpl] +impl Earnings { + pub fn init(env: Env, admin: Address) { + if env.storage().instance().has(&DataKey::Admin) { + panic!("already initialized"); + } + + admin.require_auth(); + env.storage().instance().set(&DataKey::Admin, &admin); + } + + pub fn admin(env: Env) -> Address { + env.storage().instance().get(&DataKey::Admin).unwrap() + } + + pub fn record(env: Env, creator: Address, amount: i128) { + let admin = Self::admin(env.clone()); + admin.require_auth(); + + let current: i128 = env + .storage() + .instance() + .get(&DataKey::Earnings(creator.clone())) + .unwrap_or(0); + env.storage() + .instance() + .set(&DataKey::Earnings(creator), &(current + amount)); + } + + pub fn get_earnings(env: Env, creator: Address) -> i128 { + env.storage() + .instance() + .get(&DataKey::Earnings(creator)) + .unwrap_or(0) + } +} diff --git a/contract/contracts/myfans-lib/examples/usage.rs b/contract/contracts/myfans-lib/examples/usage.rs index 9b32540..135a185 100644 --- a/contract/contracts/myfans-lib/examples/usage.rs +++ b/contract/contracts/myfans-lib/examples/usage.rs @@ -1,5 +1,3 @@ -#![no_std] - //! Example contract demonstrating myfans-lib usage //! //! This shows how to import and use SubscriptionStatus and ContentType @@ -34,6 +32,8 @@ impl ExampleContract { } } +fn main() {} + #[cfg(test)] mod test { use super::*; @@ -57,4 +57,4 @@ mod test { assert!(client.requires_payment(&ContentType::Paid)); assert!(!client.requires_payment(&ContentType::Free)); } -} +} \ No newline at end of file diff --git a/contract/contracts/myfans-lib/src/lib.rs b/contract/contracts/myfans-lib/src/lib.rs index 87149b0..09ac671 100644 --- a/contract/contracts/myfans-lib/src/lib.rs +++ b/contract/contracts/myfans-lib/src/lib.rs @@ -31,7 +31,7 @@ pub enum ContentType { #[cfg(test)] mod tests { use super::*; - use soroban_sdk::{Env, IntoVal}; + use soroban_sdk::{Env, IntoVal, TryIntoVal, Val}; #[test] fn test_subscription_status_values() { @@ -52,22 +52,22 @@ mod tests { let env = Env::default(); let pending = SubscriptionStatus::Pending; - let val = pending.into_val(&env); + let val: Val = pending.into_val(&env); let decoded: SubscriptionStatus = val.try_into_val(&env).unwrap(); assert_eq!(decoded, SubscriptionStatus::Pending); let active = SubscriptionStatus::Active; - let val = active.into_val(&env); + let val: Val = active.into_val(&env); let decoded: SubscriptionStatus = val.try_into_val(&env).unwrap(); assert_eq!(decoded, SubscriptionStatus::Active); let cancelled = SubscriptionStatus::Cancelled; - let val = cancelled.into_val(&env); + let val: Val = cancelled.into_val(&env); let decoded: SubscriptionStatus = val.try_into_val(&env).unwrap(); assert_eq!(decoded, SubscriptionStatus::Cancelled); let expired = SubscriptionStatus::Expired; - let val = expired.into_val(&env); + let val: Val = expired.into_val(&env); let decoded: SubscriptionStatus = val.try_into_val(&env).unwrap(); assert_eq!(decoded, SubscriptionStatus::Expired); } @@ -77,12 +77,12 @@ mod tests { let env = Env::default(); let free = ContentType::Free; - let val = free.into_val(&env); + let val: Val = free.into_val(&env); let decoded: ContentType = val.try_into_val(&env).unwrap(); assert_eq!(decoded, ContentType::Free); let paid = ContentType::Paid; - let val = paid.into_val(&env); + let val: Val = paid.into_val(&env); let decoded: ContentType = val.try_into_val(&env).unwrap(); assert_eq!(decoded, ContentType::Paid); } diff --git a/contract/contracts/subscription/src/lib.rs b/contract/contracts/subscription/src/lib.rs index 4ab3736..f669920 100644 --- a/contract/contracts/subscription/src/lib.rs +++ b/contract/contracts/subscription/src/lib.rs @@ -38,6 +38,10 @@ impl MyfansContract { env.storage().instance().set(&DataKey::PlanCount, &0u32); } + pub fn admin(env: Env) -> Address { + env.storage().instance().get(&DataKey::Admin).unwrap() + } + pub fn create_plan(env: Env, creator: Address, asset: Address, amount: i128, interval_days: u32) -> u32 { creator.require_auth(); let count: u32 = env.storage().instance().get(&DataKey::PlanCount).unwrap_or(0); diff --git a/contract/contracts/subscription/src/test.rs b/contract/contracts/subscription/src/test.rs new file mode 100644 index 0000000..b2abe95 --- /dev/null +++ b/contract/contracts/subscription/src/test.rs @@ -0,0 +1,38 @@ +#![cfg(test)] + +use super::*; +use soroban_sdk::{testutils::Address as _, Address, Env}; + +#[test] +fn test_init_and_admin() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register_contract(None, MyfansContract); + let client = MyfansContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let fee_recipient = Address::generate(&env); + + client.init(&admin, &250, &fee_recipient); + + assert_eq!(client.admin(), admin); +} + +#[test] +fn test_is_subscriber_false_without_subscription() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register_contract(None, MyfansContract); + let client = MyfansContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let fee_recipient = Address::generate(&env); + let fan = Address::generate(&env); + let creator = Address::generate(&env); + + client.init(&admin, &0, &fee_recipient); + + assert!(!client.is_subscriber(&fan, &creator)); +} diff --git a/contract/scripts/deploy.sh b/contract/scripts/deploy.sh new file mode 100755 index 0000000..84eab0b --- /dev/null +++ b/contract/scripts/deploy.sh @@ -0,0 +1,254 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +STELLAR_STATE_DIR="${STELLAR_STATE_DIR:-$ROOT_DIR/.stellar}" +STELLAR=(stellar) + +mkdir -p "$STELLAR_STATE_DIR" +export XDG_CONFIG_HOME="$STELLAR_STATE_DIR" + +NETWORK="futurenet" +SOURCE_ACCOUNT="myfans-deployer" +OUTPUT_JSON="$ROOT_DIR/deployed.json" +OUTPUT_ENV="$ROOT_DIR/.env.deployed" +AUTO_FUND="true" + +usage() { + cat < Network name (default: futurenet) + --source Source account identity (default: myfans-deployer) + --rpc-url Override RPC URL + --network-passphrase Override network passphrase + --out Output JSON path (default: contract/deployed.json) + --env-out Output env path (default: contract/.env.deployed) + --no-fund Disable auto funding on futurenet/testnet + -h, --help Show this help +USAGE +} + +RPC_URL="" +NETWORK_PASSPHRASE="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --network) + NETWORK="$2" + shift 2 + ;; + --source) + SOURCE_ACCOUNT="$2" + shift 2 + ;; + --rpc-url) + RPC_URL="$2" + shift 2 + ;; + --network-passphrase) + NETWORK_PASSPHRASE="$2" + shift 2 + ;; + --out) + OUTPUT_JSON="$2" + shift 2 + ;; + --env-out) + OUTPUT_ENV="$2" + shift 2 + ;; + --no-fund) + AUTO_FUND="false" + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage + exit 1 + ;; + esac +done + +case "$NETWORK" in + futurenet) + DEFAULT_RPC_URL="https://rpc-futurenet.stellar.org:443" + DEFAULT_NETWORK_PASSPHRASE="Test SDF Future Network ; October 2022" + ;; + testnet) + DEFAULT_RPC_URL="https://rpc-testnet.stellar.org:443" + DEFAULT_NETWORK_PASSPHRASE="Test SDF Network ; September 2015" + ;; + mainnet) + DEFAULT_RPC_URL="https://rpc-mainnet.stellar.org:443" + DEFAULT_NETWORK_PASSPHRASE="Public Global Stellar Network ; September 2015" + ;; + *) + echo "Unsupported --network: $NETWORK" >&2 + exit 1 + ;; +esac + +RPC_URL="${RPC_URL:-$DEFAULT_RPC_URL}" +NETWORK_PASSPHRASE="${NETWORK_PASSPHRASE:-$DEFAULT_NETWORK_PASSPHRASE}" + +echo "[deploy] network=$NETWORK" +echo "[deploy] rpc=$RPC_URL" + +if ! command -v stellar >/dev/null 2>&1; then + echo "stellar CLI is required. Install: cargo install --locked stellar-cli" >&2 + exit 1 +fi + +if ! "${STELLAR[@]}" network ls | awk '{print $1}' | grep -qx "$NETWORK"; then + echo "[deploy] adding network profile '$NETWORK'" + "${STELLAR[@]}" network add "$NETWORK" \ + --rpc-url "$RPC_URL" \ + --network-passphrase "$NETWORK_PASSPHRASE" +fi + +if ! "${STELLAR[@]}" keys public-key "$SOURCE_ACCOUNT" >/dev/null 2>&1; then + if [[ "$NETWORK" == "mainnet" ]]; then + echo "Source account '$SOURCE_ACCOUNT' not found and auto-generation on mainnet is disabled." >&2 + exit 1 + fi + + echo "[deploy] generating source identity '$SOURCE_ACCOUNT'" + "${STELLAR[@]}" keys generate "$SOURCE_ACCOUNT" --network "$NETWORK" --rpc-url "$RPC_URL" --network-passphrase "$NETWORK_PASSPHRASE" +fi + +if [[ "$AUTO_FUND" == "true" && ( "$NETWORK" == "futurenet" || "$NETWORK" == "testnet" ) ]]; then + echo "[deploy] funding '$SOURCE_ACCOUNT' on $NETWORK" + "${STELLAR[@]}" keys fund "$SOURCE_ACCOUNT" --network "$NETWORK" --rpc-url "$RPC_URL" --network-passphrase "$NETWORK_PASSPHRASE" || true +fi + +SOURCE_PUBLIC_KEY="$("${STELLAR[@]}" keys public-key "$SOURCE_ACCOUNT")" +echo "[deploy] source=$SOURCE_PUBLIC_KEY" + +echo "[deploy] building contracts" +PACKAGES=( + "myfans-token" + "creator-registry" + "subscription" + "content-access" + "earnings" +) + +for package in "${PACKAGES[@]}"; do + "${STELLAR[@]}" -q contract build --manifest-path "$ROOT_DIR/Cargo.toml" --package "$package" +done + +deploy_contract() { + local package="$1" + local wasm_name="${package//-/_}.wasm" + local wasm_path + + wasm_path="$(find "$ROOT_DIR/target" -type f -path "*/release/$wasm_name" | head -n1 || true)" + if [[ -z "$wasm_path" ]]; then + echo "Unable to locate wasm for package '$package' after build." >&2 + exit 1 + fi + + echo "[deploy] deploying $package" + local contract_id + contract_id="$("${STELLAR[@]}" -q contract deploy \ + --wasm "$wasm_path" \ + --source-account "$SOURCE_ACCOUNT" \ + --network "$NETWORK" \ + --rpc-url "$RPC_URL" \ + --network-passphrase "$NETWORK_PASSPHRASE")" + + echo "$contract_id" +} + +invoke_contract() { + local contract_id="$1" + shift + + "${STELLAR[@]}" -q contract invoke \ + --id "$contract_id" \ + --source-account "$SOURCE_ACCOUNT" \ + --network "$NETWORK" \ + --rpc-url "$RPC_URL" \ + --network-passphrase "$NETWORK_PASSPHRASE" \ + -- "$@" +} + +invoke_contract_view() { + local contract_id="$1" + shift + + "${STELLAR[@]}" -q contract invoke \ + --id "$contract_id" \ + --source-account "$SOURCE_ACCOUNT" \ + --network "$NETWORK" \ + --rpc-url "$RPC_URL" \ + --network-passphrase "$NETWORK_PASSPHRASE" \ + --send no \ + -- "$@" +} + +TOKEN_ID="$(deploy_contract "myfans-token")" +CREATOR_REGISTRY_ID="$(deploy_contract "creator-registry")" +SUBSCRIPTION_ID="$(deploy_contract "subscription")" +CONTENT_ACCESS_ID="$(deploy_contract "content-access")" +EARNINGS_ID="$(deploy_contract "earnings")" + +# Initialize contracts that expose admin-based views. +invoke_contract "$CREATOR_REGISTRY_ID" init --admin "$SOURCE_PUBLIC_KEY" >/dev/null +invoke_contract "$SUBSCRIPTION_ID" init --admin "$SOURCE_PUBLIC_KEY" --fee-bps 0 --fee-recipient "$SOURCE_PUBLIC_KEY" >/dev/null +invoke_contract "$EARNINGS_ID" init --admin "$SOURCE_PUBLIC_KEY" >/dev/null + +# Verify each deployed contract responds. +TOKEN_VERIFY="$(invoke_contract_view "$TOKEN_ID" version)" +CREATOR_REGISTRY_VERIFY="$(invoke_contract_view "$CREATOR_REGISTRY_ID" admin)" +SUBSCRIPTION_VERIFY="$(invoke_contract_view "$SUBSCRIPTION_ID" admin)" +CONTENT_ACCESS_VERIFY="$(invoke_contract_view "$CONTENT_ACCESS_ID" has-access --buyer "$SOURCE_PUBLIC_KEY" --creator "$SOURCE_PUBLIC_KEY" --content-id 1)" +EARNINGS_VERIFY="$(invoke_contract_view "$EARNINGS_ID" admin)" + +mkdir -p "$(dirname "$OUTPUT_JSON")" "$(dirname "$OUTPUT_ENV")" + +cat > "$OUTPUT_JSON" < "$OUTPUT_ENV" <