diff --git a/.eslintrc.json b/.eslintrc.json index d3d4ffd..4f69389 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -16,5 +16,19 @@ "@typescript-eslint/no-explicit-any": "warn", "no-console": ["warn", { "allow": ["warn", "error"] }] }, - "ignorePatterns": ["node_modules/", "dist/", "android/", "ios/", ".expo/", "src/contracts/types/"] + "ignorePatterns": [ + "node_modules/", + "dist/", + "android/", + "ios/", + ".expo/", + "src/contracts/types/" + ], + "settings": { + "import/resolver": { + "node": { + "extensions": [".js", ".jsx", ".ts", ".tsx"] + } + } + } } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9ebf753..0d5cad0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,7 +8,7 @@ on: env: NODE_VERSION: '20' - RUST_VERSION: '1.77' + RUST_VERSION: '1.85' jobs: commitlint: diff --git a/.prettierignore b/.prettierignore index 035d26d..07bd080 100644 --- a/.prettierignore +++ b/.prettierignore @@ -23,3 +23,6 @@ contracts/target/ # Backup/fixed package snapshots package-fixed.json package.json.backup +# GitHub API response dumps (UTF-16 encoded) +issue*.json +issues_summary.json diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cf9e509..1796e20 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -19,13 +19,13 @@ Thank you for taking the time to contribute SubTracker. This document covers eve ### Prerequisites -| Tool | Version | Purpose | -|------|---------|---------| -| Node.js | 20+ | Mobile app development | -| npm | bundled with Node | Package management | -| Rust | 1.77+ | Smart contract development | -| Expo CLI | latest | Running and building the app | -| Soroban CLI | latest | Deploying/interacting with contracts | +| Tool | Version | Purpose | +| ----------- | ----------------- | ------------------------------------ | +| Node.js | 20+ | Mobile app development | +| npm | bundled with Node | Package management | +| Rust | 1.77+ | Smart contract development | +| Expo CLI | latest | Running and building the app | +| Soroban CLI | latest | Deploying/interacting with contracts | ### Mobile App Setup @@ -60,11 +60,11 @@ npm run contracts:test Create a `.env` file at the project root if needed: -| Variable | Description | -|----------|-------------| -| `STELLAR_NETWORK` | `testnet` or `public` | -| `CONTRACT_ID` | Deployed Soroban subscription contract ID | -| `WEB3AUTH_CLIENT_ID` | Web3Auth client ID for social login | +| Variable | Description | +| -------------------- | ----------------------------------------- | +| `STELLAR_NETWORK` | `testnet` or `public` | +| `CONTRACT_ID` | Deployed Soroban subscription contract ID | +| `WEB3AUTH_CLIENT_ID` | Web3Auth client ID for social login | ### Generating Contract TypeScript Types @@ -149,17 +149,17 @@ This project uses **Conventional Commits**. Every commit message must follow thi ### Types -| Type | When to use | -|------|-------------| -| `feat` | New feature | -| `fix` | Bug fix | -| `chore` | Maintenance, dependency updates, tooling | -| `docs` | Documentation only | +| Type | When to use | +| ---------- | ----------------------------------------------- | +| `feat` | New feature | +| `fix` | Bug fix | +| `chore` | Maintenance, dependency updates, tooling | +| `docs` | Documentation only | | `refactor` | Code change that is neither a fix nor a feature | -| `test` | Adding or updating tests | -| `style` | Formatting, whitespace — no logic change | -| `ci` | CI/CD configuration changes | -| `perf` | Performance improvement | +| `test` | Adding or updating tests | +| `style` | Formatting, whitespace — no logic change | +| `ci` | CI/CD configuration changes | +| `perf` | Performance improvement | ### Scope (optional but encouraged) @@ -210,10 +210,10 @@ refactor/wallet-service-error-handling ### Protected Branches -| Branch | Purpose | -|--------|---------| -| `main` | Production-ready code — all CI must pass, PR required | -| `dev` / `develop` | Integration branch — CI required | +| Branch | Purpose | +| ----------------- | ----------------------------------------------------- | +| `main` | Production-ready code — all CI must pass, PR required | +| `dev` / `develop` | Integration branch — CI required | Never commit directly to `main`. All changes must go through a pull request. @@ -232,16 +232,16 @@ Never commit directly to `main`. All changes must go through a pull request. All of the following CI jobs must pass before a PR can be merged: -| Check | Command | -|-------|---------| -| Prettier format | `npm run format:check` | -| ESLint | `npm run lint` | -| TypeScript type check | `npm run typecheck` | -| Jest tests | `npm test` | -| Expo build | `npm run build` | -| Rust formatting | `npm run contracts:fmt` | -| Rust Clippy | `npm run contracts:clippy` | -| Rust tests | `npm run contracts:test` | +| Check | Command | +| --------------------- | -------------------------- | +| Prettier format | `npm run format:check` | +| ESLint | `npm run lint` | +| TypeScript type check | `npm run typecheck` | +| Jest tests | `npm test` | +| Expo build | `npm run build` | +| Rust formatting | `npm run contracts:fmt` | +| Rust Clippy | `npm run contracts:clippy` | +| Rust tests | `npm run contracts:test` | ### PR Checklist diff --git a/README.md b/README.md index 9090982..90e66b8 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,7 @@ SubTrackr/ ### 1. Clone the Repository First, clone the repository to your local machine: + ```bash git clone https://github.com/Smartdevs17/SubTrackr.git cd SubTrackr @@ -76,15 +77,18 @@ cd SubTrackr ### 2. Install Prerequisites #### Required for all development: + - **Node.js 20+**: We recommend using [nvm](https://github.com/nvm-sh/nvm) to manage Node.js versions: + ```bash # Install nvm (if not already installed) curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash - + # Install and use Node.js 20 nvm install 20 nvm use 20 ``` + - **Expo CLI**: Install the Expo command line tools globally: ```bash npm install -g expo-cli @@ -92,14 +96,17 @@ cd SubTrackr - **Freighter Wallet**: Install the [Freighter Wallet](https://freighter.app/) browser extension for Stellar transaction signing. #### Required only for smart contract development: + - **Rust**: Install Rust and the WASM target: + ```bash # Install Rust (if not already installed) curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh - + # Add WASM target rustup target add wasm32-unknown-unknown ``` + - **Soroban CLI**: Install the Soroban command line tools: ```bash cargo install --locked soroban-cli @@ -108,21 +115,23 @@ cd SubTrackr ### 3. Configure Environment Variables Create a `.env` file in the root directory of the project: + ```bash cp .env.example .env ``` > **Note**: If `.env.example` doesn't exist, create a new `.env` file with the following variables: -| Variable | Description | Example Value | -| -------------------- | ----------------------------------------- | ------------------------------------------ | -| `STELLAR_NETWORK` | `testnet` or `public` Stellar network | `testnet` | -| `CONTRACT_ID` | Deployed Soroban subscription contract ID | `CB64...` (your deployed contract address) | +| Variable | Description | Example Value | +| -------------------- | ----------------------------------------- | ----------------------------------------------------------------- | +| `STELLAR_NETWORK` | `testnet` or `public` Stellar network | `testnet` | +| `CONTRACT_ID` | Deployed Soroban subscription contract ID | `CB64...` (your deployed contract address) | | `WEB3AUTH_CLIENT_ID` | Web3Auth client ID for social login | Get one from [Web3Auth Dashboard](https://dashboard.web3auth.io/) | ### 4. Run the Mobile App Install dependencies and start the Expo development server: + ```bash # Install dependencies npm install @@ -132,6 +141,7 @@ npx expo start ``` You can then run the app on: + - **iOS Simulator**: Press `i` in the Expo terminal - **Android Emulator**: Press `a` in the Expo terminal - **Physical Device**: Scan the QR code with the Expo Go app (iOS/Android) @@ -139,6 +149,7 @@ You can then run the app on: ### 5. (Optional) Deploy Smart Contracts If you want to work on the smart contracts: + ```bash # Navigate to contracts directory cd contracts @@ -153,6 +164,7 @@ soroban contract deploy --wasm target/wasm32-unknown-unknown/release/subtrackr.w ### 6. Run Tests Run the test suite to ensure everything is working correctly: + ```bash # Run unit tests npm test @@ -220,4 +232,4 @@ npm run release:dry-run ## License -MIT \ No newline at end of file +MIT diff --git a/contracts/DEPLOYMENT.md b/contracts/DEPLOYMENT.md new file mode 100644 index 0000000..21d8947 --- /dev/null +++ b/contracts/DEPLOYMENT.md @@ -0,0 +1,72 @@ +# SubTrackr Contract Deployment Guide + +This guide describes how to deploy SubTrackr smart contracts to various Stellar networks using the provided automation scripts. + +## Prerequisites + +- [Soroban CLI](https://developers.stellar.org/docs/smart-contracts/getting-started/setup#install-the-soroban-cli) installed. +- [Rust](https://rustup.rs/) and `wasm32-unknown-unknown` target installed. +- A Stellar account with enough XLM for the target network. + +## Deployment Scripts + +All scripts are located in the `scripts/` directory at the project root. + +### 1. Local Deployment + +For development and testing on a local Soroban network. + +```bash +./scripts/deploy-local.sh +``` + +**Note**: Assumes a local network is running and an identity `alice` exists. + +### 2. Testnet Deployment + +For deploying to the Stellar Testnet. + +```bash +export SOROBAN_ACCOUNT="your-testnet-account-name" +export ADMIN_ADDRESS="GB..." +./scripts/deploy-testnet.sh +``` + +### 3. Mainnet Deployment + +For deploying to the Stellar Public network (Mainnet). + +```bash +export SOROBAN_ACCOUNT="your-mainnet-account-name" +export ADMIN_ADDRESS="GD..." +./scripts/deploy-mainnet.sh +``` + +**⚠️ WARNING**: Mainnet deployment costs real XLM. Ensure you have sufficient funds and have reviewed the contract code. + +## Environment Variables + +| Variable | Description | Required For | +|---|---|---| +| `SOROBAN_ACCOUNT` | The identity name (configured in Soroban CLI) or secret key to use for deployment. | Testnet, Mainnet | +| `ADMIN_ADDRESS` | The Stellar address that will be set as the contract admin during initialization. | Testnet, Mainnet | + +## Verification + +After deployment, you can verify that the contract is active by running: + +```bash +./scripts/verify.sh +``` + +Replace `` with the ID returned by the deployment script and `` with `local`, `testnet`, or `public`. + +## Rollback Procedure + +Since smart contracts on Soroban are immutable (unless explicitly designed otherwise), a "rollback" typically involves: + +1. Fixing the issue in the contract source code. +2. Deploying a new version of the contract. +3. Updating the application (frontend/backend) to use the new `CONTRACT_ID`. + +Ensure you keep track of the `CONTRACT_ID` for each deployment (these are automatically saved to `contracts/.env.`). diff --git a/contracts/src/lib.rs b/contracts/src/lib.rs index 5a4d391..e52555c 100644 --- a/contracts/src/lib.rs +++ b/contracts/src/lib.rs @@ -12,6 +12,8 @@ pub enum Interval { Yearly, // 31536000s (365 days) } +const MAX_PAUSE_DURATION: u64 = 2_592_000; // 30 days + impl Interval { pub fn seconds(&self) -> u64 { match self { @@ -59,6 +61,8 @@ pub struct Subscription { pub last_charged_at: u64, pub next_charge_at: u64, pub total_paid: i128, + pub paused_at: u64, + pub pause_duration: u64, pub refund_requested_amount: i128, } @@ -209,6 +213,8 @@ impl SubTrackrContract { last_charged_at: now, next_charge_at: now + plan.interval.seconds(), total_paid: 0, + paused_at: 0, + pause_duration: 0, refund_requested_amount: 0, }; @@ -277,6 +283,16 @@ impl SubTrackrContract { /// User pauses their subscription pub fn pause_subscription(env: Env, subscriber: Address, subscription_id: u64) { + Self::pause_by_subscriber(env, subscriber, subscription_id, MAX_PAUSE_DURATION); + } + + /// User pauses their subscription with a specific duration + pub fn pause_by_subscriber( + env: Env, + subscriber: Address, + subscription_id: u64, + duration: u64, + ) { subscriber.require_auth(); let mut sub: Subscription = env @@ -290,12 +306,24 @@ impl SubTrackrContract { sub.status == SubscriptionStatus::Active, "Only active subscriptions can be paused" ); + assert!( + duration <= MAX_PAUSE_DURATION, + "Pause duration exceeds limit" + ); sub.status = SubscriptionStatus::Paused; + sub.paused_at = env.ledger().timestamp(); + sub.pause_duration = duration; env.storage() .persistent() .set(&DataKey::Subscription(subscription_id), &sub); + + // Publish event + env.events().publish( + (String::from_str(&env, "subscription_paused"), subscriber), + (subscription_id, sub.paused_at, duration), + ); } /// User resumes a paused subscription @@ -310,7 +338,8 @@ impl SubTrackrContract { assert!(sub.subscriber == subscriber, "Only subscriber can resume"); assert!( - sub.status == SubscriptionStatus::Paused, + sub.status == SubscriptionStatus::Paused + || Self::check_and_resume_internal(&env, &mut sub), "Only paused subscriptions can be resumed" ); @@ -323,10 +352,18 @@ impl SubTrackrContract { sub.status = SubscriptionStatus::Active; sub.next_charge_at = now + plan.interval.seconds(); + sub.paused_at = 0; + sub.pause_duration = 0; env.storage() .persistent() .set(&DataKey::Subscription(subscription_id), &sub); + + // Publish event + env.events().publish( + (String::from_str(&env, "subscription_resumed"), subscriber), + subscription_id, + ); } // ── Payment Processing ── @@ -341,6 +378,13 @@ impl SubTrackrContract { sub.subscriber.require_auth(); + // Handle auto-resume if needed + if Self::check_and_resume_internal(&env, &mut sub) { + env.storage() + .persistent() + .set(&DataKey::Subscription(subscription_id), &sub); + } + assert!( sub.status == SubscriptionStatus::Active, "Subscription not active" @@ -490,10 +534,14 @@ impl SubTrackrContract { /// Get subscription details pub fn get_subscription(env: Env, subscription_id: u64) -> Subscription { - env.storage() + let mut sub: Subscription = env + .storage() .persistent() .get(&DataKey::Subscription(subscription_id)) - .expect("Subscription not found") + .expect("Subscription not found"); + + Self::check_and_resume_internal(&env, &mut sub); + sub } /// Get all subscription IDs for a user @@ -527,6 +575,21 @@ impl SubTrackrContract { .get(&DataKey::SubscriptionCount) .unwrap_or(0) } + + // ── Internal Helpers ── + + fn check_and_resume_internal(env: &Env, sub: &mut Subscription) -> bool { + if sub.status == SubscriptionStatus::Paused { + let now = env.ledger().timestamp(); + if now >= sub.paused_at + sub.pause_duration { + sub.status = SubscriptionStatus::Active; + sub.paused_at = 0; + sub.pause_duration = 0; + return true; + } + } + false + } } #[cfg(test)] @@ -721,6 +784,50 @@ mod test { assert!(resumed.next_charge_at > initial.next_charge_at); } + #[test] + #[should_panic(expected = "Pause duration exceeds limit")] + fn test_pause_by_subscriber_limit_enforced() { + let env = Env::default(); + let (client, _admin, _merchant, subscriber, _token) = setup(&env); + let sub_id = client.subscribe(&subscriber, &1); + + // Max is 30 days (2,592_000s). Try 31 days. + client.pause_by_subscriber(&subscriber, &sub_id, &2_678_400); + } + + #[test] + fn test_auto_resume() { + let env = Env::default(); + let (client, _admin, _merchant, subscriber, _token) = setup(&env); + let sub_id = client.subscribe(&subscriber, &1); + + // Pause for 1 day (86,400s) + client.pause_by_subscriber(&subscriber, &sub_id, &86_400); + let paused = client.get_subscription(&sub_id); + assert_eq!(paused.status, SubscriptionStatus::Paused); + + // Fast forward 2 days (172,800s) + env.ledger().with_mut(|li| { + li.timestamp += 172_800; + }); + + // get_subscription should now return Active due to auto-resume + let resumed = client.get_subscription(&sub_id); + assert_eq!(resumed.status, SubscriptionStatus::Active); + assert_eq!(resumed.paused_at, 0); + assert_eq!(resumed.pause_duration, 0); + + // charge_subscription should also work now + // But we need to make sure next_charge_at is reached + env.ledger().with_mut(|li| { + li.timestamp += Interval::Monthly.seconds(); + }); + client.charge_subscription(&sub_id); + + let charged = client.get_subscription(&sub_id); + assert_eq!(charged.total_paid, 500); + } + #[test] fn test_refund_flow() { let env = Env::default(); diff --git a/contracts/test_snapshots/test/test_cancel_subscription.1.json b/contracts/test_snapshots/test/test_cancel_subscription.1.json index 9056e7a..069902f 100644 --- a/contracts/test_snapshots/test/test_cancel_subscription.1.json +++ b/contracts/test_snapshots/test/test_cancel_subscription.1.json @@ -1185,4 +1185,4 @@ "failed_call": false } ] -} \ No newline at end of file +} diff --git a/contracts/test_snapshots/test/test_charge_paused_subscription.1.json b/contracts/test_snapshots/test/test_charge_paused_subscription.1.json index 002b7e2..51120d9 100644 --- a/contracts/test_snapshots/test/test_charge_paused_subscription.1.json +++ b/contracts/test_snapshots/test/test_charge_paused_subscription.1.json @@ -1065,4 +1065,4 @@ "failed_call": false } ] -} \ No newline at end of file +} diff --git a/contracts/test_snapshots/test/test_charge_subscription_not_due.1.json b/contracts/test_snapshots/test/test_charge_subscription_not_due.1.json index a20b5a8..144b1b3 100644 --- a/contracts/test_snapshots/test/test_charge_subscription_not_due.1.json +++ b/contracts/test_snapshots/test/test_charge_subscription_not_due.1.json @@ -956,4 +956,4 @@ "failed_call": false } ] -} \ No newline at end of file +} diff --git a/contracts/test_snapshots/test/test_create_plan_and_subscribe.1.json b/contracts/test_snapshots/test/test_create_plan_and_subscribe.1.json index a0256b3..cad7584 100644 --- a/contracts/test_snapshots/test/test_create_plan_and_subscribe.1.json +++ b/contracts/test_snapshots/test/test_create_plan_and_subscribe.1.json @@ -1178,4 +1178,4 @@ "failed_call": false } ] -} \ No newline at end of file +} diff --git a/contracts/test_snapshots/test/test_double_subscribe.1.json b/contracts/test_snapshots/test/test_double_subscribe.1.json index 68af056..5430947 100644 --- a/contracts/test_snapshots/test/test_double_subscribe.1.json +++ b/contracts/test_snapshots/test/test_double_subscribe.1.json @@ -969,4 +969,4 @@ "failed_call": false } ] -} \ No newline at end of file +} diff --git a/contracts/test_snapshots/test/test_merchant_cannot_subscribe.1.json b/contracts/test_snapshots/test/test_merchant_cannot_subscribe.1.json index c25b7eb..2acf309 100644 --- a/contracts/test_snapshots/test/test_merchant_cannot_subscribe.1.json +++ b/contracts/test_snapshots/test/test_merchant_cannot_subscribe.1.json @@ -681,4 +681,4 @@ "failed_call": false } ] -} \ No newline at end of file +} diff --git a/contracts/test_snapshots/test/test_non_subscriber_cannot_cancel.1.json b/contracts/test_snapshots/test/test_non_subscriber_cannot_cancel.1.json index ff9941e..49f6973 100644 --- a/contracts/test_snapshots/test/test_non_subscriber_cannot_cancel.1.json +++ b/contracts/test_snapshots/test/test_non_subscriber_cannot_cancel.1.json @@ -969,4 +969,4 @@ "failed_call": false } ] -} \ No newline at end of file +} diff --git a/contracts/test_snapshots/test/test_pause_and_resume.1.json b/contracts/test_snapshots/test/test_pause_and_resume.1.json index 9c24f51..d6c317e 100644 --- a/contracts/test_snapshots/test/test_pause_and_resume.1.json +++ b/contracts/test_snapshots/test/test_pause_and_resume.1.json @@ -1430,4 +1430,4 @@ "failed_call": false } ] -} \ No newline at end of file +} diff --git a/contracts/test_snapshots/test/test_plan_deactivation_existing_subscribers_unaffected.1.json b/contracts/test_snapshots/test/test_plan_deactivation_existing_subscribers_unaffected.1.json index 8d193ca..add6e0d 100644 --- a/contracts/test_snapshots/test/test_plan_deactivation_existing_subscribers_unaffected.1.json +++ b/contracts/test_snapshots/test/test_plan_deactivation_existing_subscribers_unaffected.1.json @@ -1294,4 +1294,4 @@ "failed_call": false } ] -} \ No newline at end of file +} diff --git a/contracts/test_snapshots/test/test_refund_flow.1.json b/contracts/test_snapshots/test/test_refund_flow.1.json index b6e5a3f..89e121d 100644 --- a/contracts/test_snapshots/test/test_refund_flow.1.json +++ b/contracts/test_snapshots/test/test_refund_flow.1.json @@ -1540,4 +1540,4 @@ "failed_call": false } ] -} \ No newline at end of file +} diff --git a/docs/API.md b/docs/API.md index 825e519..04d05c2 100644 --- a/docs/API.md +++ b/docs/API.md @@ -39,21 +39,21 @@ The Soroban smart contract (`contracts/src/lib.rs`) handles subscription plan cr Defines billing frequency for subscription plans. -| Variant | Duration | -|-------------|------------| -| `Weekly` | 604,800s | -| `Monthly` | 2,592,000s | -| `Quarterly` | 7,776,000s | -| `Yearly` | 31,536,000s| +| Variant | Duration | +| ----------- | ----------- | +| `Weekly` | 604,800s | +| `Monthly` | 2,592,000s | +| `Quarterly` | 7,776,000s | +| `Yearly` | 31,536,000s | #### SubscriptionStatus -| Variant | Description | -|-------------|------------------------------------| +| Variant | Description | +| ----------- | ----------------------------------- | | `Active` | Subscription is active and billable | -| `Paused` | Temporarily paused by subscriber | -| `Cancelled` | Permanently cancelled | -| `PastDue` | Payment failed or overdue | +| `Paused` | Temporarily paused by subscriber | +| `Cancelled` | Permanently cancelled | +| `PastDue` | Payment failed or overdue | #### Plan @@ -98,7 +98,7 @@ Set the contract admin. Must be called once before any other function. **Parameters:** | Name | Type | Description | -|---------|-----------|----------------------| +| ------- | --------- | -------------------- | | `admin` | `Address` | Admin wallet address | **Auth:** None (first-time setup only). @@ -123,19 +123,20 @@ Create a new subscription plan. Returns the plan ID. **Parameters:** -| Name | Type | Description | -|------------|------------|----------------------------------| -| `merchant` | `Address` | Plan owner address | -| `name` | `String` | Plan display name | -| `price` | `i128` | Price per interval in stroops | -| `token` | `Address` | Payment token contract address | -| `interval` | `Interval` | Billing frequency | +| Name | Type | Description | +| ---------- | ---------- | ------------------------------ | +| `merchant` | `Address` | Plan owner address | +| `name` | `String` | Plan display name | +| `price` | `i128` | Price per interval in stroops | +| `token` | `Address` | Payment token contract address | +| `interval` | `Interval` | Billing frequency | **Auth:** Requires `merchant` authorization. **Returns:** `u64` - the new plan ID. **Errors:** + - `"Price must be positive"` - if `price <= 0` **Example:** @@ -164,14 +165,15 @@ Deactivate a plan so no new subscribers can join. Existing subscribers continue **Parameters:** -| Name | Type | Description | -|------------|-----------|----------------------| -| `merchant` | `Address` | Plan owner address | +| Name | Type | Description | +| ---------- | --------- | ------------------------ | +| `merchant` | `Address` | Plan owner address | | `plan_id` | `u64` | ID of plan to deactivate | **Auth:** Requires `merchant` authorization. Must match plan owner. **Errors:** + - `"Only plan owner can deactivate"` - if caller is not the plan merchant --- @@ -185,7 +187,7 @@ Subscribe to an active plan. Processes the first payment immediately. Returns th **Parameters:** | Name | Type | Description | -|--------------|-----------|---------------------------| +| ------------ | --------- | ------------------------- | | `subscriber` | `Address` | Subscriber wallet address | | `plan_id` | `u64` | ID of the plan | @@ -194,6 +196,7 @@ Subscribe to an active plan. Processes the first payment immediately. Returns th **Returns:** `u64` - the new subscription ID. **Errors:** + - `"Plan is not active"` - if the plan has been deactivated - `"Merchant cannot self-subscribe"` - if subscriber is the plan merchant - `"Already subscribed to this plan"` - if subscriber has an active subscription to this plan @@ -221,14 +224,15 @@ Permanently cancel a subscription. Works on Active or Paused subscriptions. **Parameters:** -| Name | Type | Description | -|-------------------|-----------|--------------------------| -| `subscriber` | `Address` | Subscriber wallet address| -| `subscription_id` | `u64` | ID of the subscription | +| Name | Type | Description | +| ----------------- | --------- | ------------------------- | +| `subscriber` | `Address` | Subscriber wallet address | +| `subscription_id` | `u64` | ID of the subscription | **Auth:** Requires `subscriber` authorization. **Errors:** + - `"Only subscriber can cancel"` - if caller is not the subscriber #### `pause_subscription` @@ -237,14 +241,15 @@ Temporarily pause billing on a subscription. **Parameters:** -| Name | Type | Description | -|-------------------|-----------|--------------------------| -| `subscriber` | `Address` | Subscriber wallet address| -| `subscription_id` | `u64` | ID of the subscription | +| Name | Type | Description | +| ----------------- | --------- | ------------------------- | +| `subscriber` | `Address` | Subscriber wallet address | +| `subscription_id` | `u64` | ID of the subscription | **Auth:** Requires `subscriber` authorization. **Errors:** + - `"Only active subscriptions can be paused"` - if status is not Active #### `resume_subscription` @@ -253,14 +258,15 @@ Resume a paused subscription. **Parameters:** -| Name | Type | Description | -|-------------------|-----------|--------------------------| -| `subscriber` | `Address` | Subscriber wallet address| -| `subscription_id` | `u64` | ID of the subscription | +| Name | Type | Description | +| ----------------- | --------- | ------------------------- | +| `subscriber` | `Address` | Subscriber wallet address | +| `subscription_id` | `u64` | ID of the subscription | **Auth:** Requires `subscriber` authorization. **Errors:** + - `"Subscription not active"` - if status is not Paused (note: error message is reused) --- @@ -274,14 +280,16 @@ Process a due payment for an active subscription. Anyone can call this (permissi **Parameters:** | Name | Type | Description | -|-------------------|-------|------------------------| +| ----------------- | ----- | ---------------------- | | `subscription_id` | `u64` | ID of the subscription | **Errors:** + - `"Subscription not active"` - if status is not Active - `"Payment not yet due"` - if current time < `next_charge_at` **Side effects:** + - Transfers `plan.price` from subscriber to merchant via the plan's token contract - Updates `last_charged_at`, `next_charge_at`, and `total_paid` @@ -291,16 +299,18 @@ Submit a refund request for a subscription. **Parameters:** -| Name | Type | Description | -|-------------------|--------|---------------------------| -| `subscription_id` | `u64` | ID of the subscription | -| `amount` | `i128` | Requested refund amount | +| Name | Type | Description | +| ----------------- | ------ | ----------------------- | +| `subscription_id` | `u64` | ID of the subscription | +| `amount` | `i128` | Requested refund amount | **Errors:** + - `"Refund amount must be positive"` - if `amount <= 0` - `"Refund amount cannot exceed total paid"` - if `amount > total_paid` **Events:** + - `refund_requested(subscription_id, (subscriber, amount))` #### `approve_refund` @@ -310,15 +320,17 @@ Admin approves a pending refund. Transfers tokens from merchant to subscriber. **Parameters:** | Name | Type | Description | -|-------------------|-------|------------------------| +| ----------------- | ----- | ---------------------- | | `subscription_id` | `u64` | ID of the subscription | **Auth:** Requires admin authorization. **Errors:** + - `"No pending refund request"` - if `refund_requested_amount == 0` **Events:** + - `refund_approved(subscription_id, (subscriber, amount))` #### `reject_refund` @@ -328,15 +340,17 @@ Admin rejects a pending refund request, resetting the requested amount to zero. **Parameters:** | Name | Type | Description | -|-------------------|-------|------------------------| +| ----------------- | ----- | ---------------------- | | `subscription_id` | `u64` | ID of the subscription | **Auth:** Requires admin authorization. **Errors:** + - `"No pending refund request"` - if `refund_requested_amount == 0` **Events:** + - `refund_rejected(subscription_id, subscriber)` --- @@ -348,29 +362,29 @@ All query functions are read-only and require no authorization. #### `get_plan` | Parameter | Type | Returns | -|-----------|-------|---------| +| --------- | ----- | ------- | | `plan_id` | `u64` | `Plan` | #### `get_subscription` | Parameter | Type | Returns | -|-------------------|-------|----------------| +| ----------------- | ----- | -------------- | | `subscription_id` | `u64` | `Subscription` | #### `get_user_subscriptions` Returns all subscription IDs for a given subscriber. -| Parameter | Type | Returns | -|--------------|-----------|-------------| -| `subscriber` | `Address` | `Vec` | +| Parameter | Type | Returns | +| ------------ | --------- | ---------- | +| `subscriber` | `Address` | `Vec` | #### `get_merchant_plans` Returns all plan IDs for a given merchant. | Parameter | Type | Returns | -|------------|-----------|------------| +| ---------- | --------- | ---------- | | `merchant` | `Address` | `Vec` | #### `get_plan_count` @@ -378,7 +392,7 @@ Returns all plan IDs for a given merchant. Returns the total number of plans created. | Returns | -|---------| +| ------- | | `u64` | #### `get_subscription_count` @@ -386,7 +400,7 @@ Returns the total number of plans created. Returns the total number of subscriptions created. | Returns | -|---------| +| ------- | | `u64` | **Example (query):** @@ -421,11 +435,11 @@ soroban contract invoke \ The contract emits Soroban events for refund lifecycle actions. Subscribe to these via Soroban RPC event streaming. -| Event | Topic 1 | Data | -|--------------------|--------------------|----------------------------------| -| `refund_requested` | `subscription_id` | `(subscriber: Address, amount: i128)` | -| `refund_approved` | `subscription_id` | `(subscriber: Address, amount: i128)` | -| `refund_rejected` | `subscription_id` | `subscriber: Address` | +| Event | Topic 1 | Data | +| ------------------ | ----------------- | ------------------------------------- | +| `refund_requested` | `subscription_id` | `(subscriber: Address, amount: i128)` | +| `refund_approved` | `subscription_id` | `(subscriber: Address, amount: i128)` | +| `refund_rejected` | `subscription_id` | `subscriber: Address` | --- @@ -433,20 +447,20 @@ The contract emits Soroban events for refund lifecycle actions. Subscribe to the All smart contract errors are returned as string panics. -| Error Message | Function(s) | Cause | -|--------------------------------------------|--------------------------|----------------------------------------| -| `Price must be positive` | `create_plan` | Price is zero or negative | -| `Plan is not active` | `subscribe` | Plan was deactivated | -| `Merchant cannot self-subscribe` | `subscribe` | Subscriber address matches merchant | -| `Already subscribed to this plan` | `subscribe` | Duplicate active subscription | -| `Only subscriber can cancel` | `cancel_subscription` | Caller is not the subscriber | -| `Only active subscriptions can be paused` | `pause_subscription` | Subscription is not Active | -| `Subscription not active` | `resume_subscription`, `charge_subscription` | Subscription is not in expected state | -| `Payment not yet due` | `charge_subscription` | Current time < next_charge_at | -| `Refund amount must be positive` | `request_refund` | Amount is zero or negative | -| `Refund amount cannot exceed total paid` | `request_refund` | Amount > total_paid | -| `No pending refund request` | `approve_refund`, `reject_refund` | No refund was requested | -| `Only plan owner can deactivate` | `deactivate_plan` | Caller is not the plan merchant | +| Error Message | Function(s) | Cause | +| ----------------------------------------- | -------------------------------------------- | ------------------------------------- | +| `Price must be positive` | `create_plan` | Price is zero or negative | +| `Plan is not active` | `subscribe` | Plan was deactivated | +| `Merchant cannot self-subscribe` | `subscribe` | Subscriber address matches merchant | +| `Already subscribed to this plan` | `subscribe` | Duplicate active subscription | +| `Only subscriber can cancel` | `cancel_subscription` | Caller is not the subscriber | +| `Only active subscriptions can be paused` | `pause_subscription` | Subscription is not Active | +| `Subscription not active` | `resume_subscription`, `charge_subscription` | Subscription is not in expected state | +| `Payment not yet due` | `charge_subscription` | Current time < next_charge_at | +| `Refund amount must be positive` | `request_refund` | Amount is zero or negative | +| `Refund amount cannot exceed total paid` | `request_refund` | Amount > total_paid | +| `No pending refund request` | `approve_refund`, `reject_refund` | No refund was requested | +| `Only plan owner can deactivate` | `deactivate_plan` | Caller is not the plan merchant | --- @@ -510,9 +524,9 @@ Fetch native currency and USDC balances for a wallet. **Parameters:** -| Name | Type | Description | -|-----------|----------|------------------------| -| `address` | `string` | Wallet address | +| Name | Type | Description | +| --------- | -------- | ------------------------ | +| `address` | `string` | Wallet address | | `chainId` | `number` | Chain ID (1, 137, 42161) | **Returns:** Array of `TokenBalance`: @@ -559,12 +573,12 @@ Estimate gas for a simple token transfer. **Parameters:** -| Name | Type | Description | -|-----------|----------|--------------------| -| `from` | `string` | Sender address | -| `to` | `string` | Recipient address | -| `value` | `string` | Transfer value | -| `chainId` | `number` | Chain ID | +| Name | Type | Description | +| --------- | -------- | ----------------- | +| `from` | `string` | Sender address | +| `to` | `string` | Recipient address | +| `value` | `string` | Transfer value | +| `chainId` | `number` | Chain ID | #### `estimateSuperfluidCreateFlow(tokenSymbol, amountPerMonth, recipient, chainId): Promise` @@ -572,19 +586,19 @@ Estimate gas for creating a Superfluid stream. **Parameters:** -| Name | Type | Description | -|------------------|----------|-----------------------------| -| `tokenSymbol` | `string` | Token symbol (e.g. "USDC") | -| `amountPerMonth` | `string` | Monthly amount to stream | -| `recipient` | `string` | Recipient address | -| `chainId` | `number` | Chain ID | +| Name | Type | Description | +| ---------------- | -------- | -------------------------- | +| `tokenSymbol` | `string` | Token symbol (e.g. "USDC") | +| `amountPerMonth` | `string` | Monthly amount to stream | +| `recipient` | `string` | Recipient address | +| `chainId` | `number` | Chain ID | **GasEstimate:** ```typescript { - gasLimit: string; // Estimated gas units - gasPrice: string; // Gas price in wei + gasLimit: string; // Estimated gas units + gasPrice: string; // Gas price in wei estimatedCost: string; // Total cost formatted in native currency } ``` @@ -599,18 +613,18 @@ Create a continuous payment stream using Superfluid's Constant Flow Agreement (C **Parameters:** -| Name | Type | Description | -|------------------|----------|-----------------------------| -| `tokenSymbol` | `string` | Token symbol (e.g. "USDC") | -| `amountPerMonth` | `string` | Monthly amount to stream | -| `recipient` | `string` | Recipient address | -| `chainId` | `number` | Chain ID | +| Name | Type | Description | +| ---------------- | -------- | -------------------------- | +| `tokenSymbol` | `string` | Token symbol (e.g. "USDC") | +| `amountPerMonth` | `string` | Monthly amount to stream | +| `recipient` | `string` | Recipient address | +| `chainId` | `number` | Chain ID | **Returns:** ```typescript { - txHash: string; // Transaction hash + txHash: string; // Transaction hash streamId: string; // Superfluid stream identifier } ``` @@ -623,14 +637,14 @@ Create a time-locked vesting stream using Sablier V2. **Parameters:** -| Name | Type | Description | -|-------------|----------|--------------------------------| -| `token` | `string` | Token contract address | -| `amount` | `string` | Total amount to stream | -| `startTime` | `number` | Unix timestamp for stream start| -| `stopTime` | `number` | Unix timestamp for stream end | -| `recipient` | `string` | Recipient address | -| `chainId` | `number` | Chain ID | +| Name | Type | Description | +| ----------- | -------- | ------------------------------- | +| `token` | `string` | Token contract address | +| `amount` | `string` | Total amount to stream | +| `startTime` | `number` | Unix timestamp for stream start | +| `stopTime` | `number` | Unix timestamp for stream end | +| `recipient` | `string` | Recipient address | +| `chainId` | `number` | Chain ID | **Returns:** `string` - transaction hash. @@ -640,16 +654,16 @@ Create a time-locked vesting stream using Sablier V2. ### Wallet Error Codes -| Error Message | Cause | -|---------------|-------| -| `Wallet is not connected or does not expose a signing provider` | No active wallet connection with a signer | +| Error Message | Cause | +| ------------------------------------------------------------------------------------- | -------------------------------------------------------- | +| `Wallet is not connected or does not expose a signing provider` | No active wallet connection with a signer | | `Wallet network (X) does not match selected chain (Y). Switch network in your wallet` | Chain ID mismatch between wallet and requested operation | -| `Monthly amount is too small to stream (flow rate rounds to zero per second)` | Superfluid flow rate would be 0 | -| `Recipient must be a different address than your connected wallet` | Self-stream attempt | -| `No RPC configured for chain X` | Unsupported chain ID | -| `ARB is not supported as a Superfluid super token on this flow` | Unsupported super token | -| `Transaction was rejected in your wallet` | User rejected the wallet prompt | -| `Could not estimate gas for Superfluid createFlow` | Gas estimation failure | +| `Monthly amount is too small to stream (flow rate rounds to zero per second)` | Superfluid flow rate would be 0 | +| `Recipient must be a different address than your connected wallet` | Self-stream attempt | +| `No RPC configured for chain X` | Unsupported chain ID | +| `ARB is not supported as a Superfluid super token on this flow` | Unsupported super token | +| `Transaction was rejected in your wallet` | User rejected the wallet prompt | +| `Could not estimate gas for Superfluid createFlow` | Gas estimation failure | --- @@ -680,6 +694,7 @@ Request notification permissions from the user. Cancel all existing reminders and reschedule based on current subscriptions. Only schedules reminders for subscriptions where `isActive === true` and `notificationsEnabled !== false`. **Reminder timing:** + - 1 day before `nextBillingDate` if there is enough lead time - Otherwise, 1 hour before `nextBillingDate` @@ -688,6 +703,7 @@ Cancel all existing reminders and reschedule based on current subscriptions. Onl Display an immediate notification for a successful charge. **Notification content:** + - Title: `"Payment successful"` - Body: `"Your {name} subscription has been renewed."` @@ -696,6 +712,7 @@ Display an immediate notification for a successful charge. Display an immediate notification for a failed charge. **Notification content:** + - Title: `"Payment failed"` - Body: `"Could not renew {name}. {detail}"` or `"Could not renew {name}. Check your balance."` @@ -709,11 +726,11 @@ Attach listeners for notification taps. Returns a cleanup function. ### Notification Data Types -| Type | Value | Description | -|---------------------|---------------------|-----------------------| -| `RENEWAL_REMINDER` | `renewal_reminder` | Upcoming billing alert| -| `CHARGE_SUCCESS` | `charge_success` | Payment succeeded | -| `CHARGE_FAILED` | `charge_failed` | Payment failed | +| Type | Value | Description | +| ------------------ | ------------------ | ---------------------- | +| `RENEWAL_REMINDER` | `renewal_reminder` | Upcoming billing alert | +| `CHARGE_SUCCESS` | `charge_success` | Payment succeeded | +| `CHARGE_FAILED` | `charge_failed` | Payment failed | --- @@ -784,7 +801,7 @@ Defined in `src/types/api.ts`. privacy: { dataSharing: boolean; analytics: boolean; - }; + } } ``` @@ -830,25 +847,25 @@ Defined in `src/types/subscription.ts`. #### `SubscriptionCategory` -| Value | Description | -|----------------|-------------------| -| `streaming` | Streaming services| -| `software` | Software tools | -| `gaming` | Gaming services | -| `productivity` | Productivity apps | -| `fitness` | Fitness and health| -| `education` | Learning platforms| -| `finance` | Financial services| -| `other` | Uncategorized | +| Value | Description | +| -------------- | ------------------ | +| `streaming` | Streaming services | +| `software` | Software tools | +| `gaming` | Gaming services | +| `productivity` | Productivity apps | +| `fitness` | Fitness and health | +| `education` | Learning platforms | +| `finance` | Financial services | +| `other` | Uncategorized | #### `BillingCycle` -| Value | Description | -|-----------|--------------------| -| `monthly` | Billed monthly | -| `yearly` | Billed annually | -| `weekly` | Billed weekly | -| `custom` | Custom interval | +| Value | Description | +| --------- | --------------- | +| `monthly` | Billed monthly | +| `yearly` | Billed annually | +| `weekly` | Billed weekly | +| `custom` | Custom interval | #### `SubscriptionFormData` @@ -941,7 +958,7 @@ Defined in `src/types/wallet.ts`. name: string; symbol: string; decimals: number; - }; + } } ``` @@ -949,16 +966,16 @@ Defined in `src/types/wallet.ts`. ## Supported Chains -| Chain | Chain ID | RPC URL | -|----------|----------|----------------------------------| -| Ethereum | 1 | `https://cloudflare-eth.com` | -| Polygon | 137 | `https://polygon-rpc.com` | -| Arbitrum | 42161 | `https://arb1.arbitrum.io/rpc` | +| Chain | Chain ID | RPC URL | +| -------- | -------- | ------------------------------ | +| Ethereum | 1 | `https://cloudflare-eth.com` | +| Polygon | 137 | `https://polygon-rpc.com` | +| Arbitrum | 42161 | `https://arb1.arbitrum.io/rpc` | Additional chains defined in wallet types but not yet configured with RPC: | Chain | Chain ID | -|----------|----------| +| -------- | -------- | | Optimism | 10 | | Base | 8453 | @@ -969,16 +986,16 @@ Additional chains defined in wallet types but not yet configured with RPC: ### USDC Addresses by Chain | Chain | Address | -|----------|----------------------------------------------| -| Ethereum | `0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48`| -| Polygon | `0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174`| -| Arbitrum | `0xFF970A61A04b1cA14834A43f5dE4533eBDDB5CC8`| +| -------- | -------------------------------------------- | +| Ethereum | `0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48` | +| Polygon | `0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174` | +| Arbitrum | `0xFF970A61A04b1cA14834A43f5dE4533eBDDB5CC8` | ### Protocol Addresses | Protocol | Address | -|------------|----------------------------------------------| -| Sablier V2 | `0xAFb979d9afAd1aD27C5eFf4E27226E3AB9e5dCC9`| +| ---------- | -------------------------------------------- | +| Sablier V2 | `0xAFb979d9afAd1aD27C5eFf4E27226E3AB9e5dCC9` | ### Soroban Contract diff --git a/package-lock.json b/package-lock.json index 531fc86..6fbd34d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12883,6 +12883,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cli-table3": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.5.tgz", + "integrity": "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "string-width": "^4.2.0" + }, + "engines": { + "node": "10.* || >= 12.*" + }, + "optionalDependencies": { + "@colors/colors": "1.5.0" + } + }, "node_modules/cli-truncate": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.2.0.tgz", diff --git a/scripts/deploy-local.sh b/scripts/deploy-local.sh new file mode 100755 index 0000000..abc3ed0 --- /dev/null +++ b/scripts/deploy-local.sh @@ -0,0 +1,48 @@ +#!/bin/bash + +# SubTrackr Local Deployment Script +# Deploys smart contracts to a local Soroban network + +# Source utility functions +source "$(dirname "$0")/utils.sh" + +set -e + +print_status "🚀 Starting local deployment..." + +# Check prerequisites +check_command "soroban" +check_command "cargo" + +# Build and optimize contract +print_status "Building and optimizing contract..." +cd contracts +cargo build --target wasm32-unknown-unknown --release +soroban contract optimize --wasm target/wasm32-unknown-unknown/release/subtrackr.wasm + +# Deploy to local network +# Assumes a local network is running and an identity 'alice' exists +print_status "Deploying to local network..." +CONTRACT_ID=$(soroban contract deploy \ + --wasm target/wasm32-unknown-unknown/release/subtrackr.optimized.wasm \ + --source alice \ + --network local) + +print_success "Contract deployed successfully! ID: $CONTRACT_ID" + +# Initialize contract +# Use alice as admin for local testing +print_status "Initializing contract..." +soroban contract invoke \ + --id "$CONTRACT_ID" \ + --source alice \ + --network local \ + -- initialize \ + --admin alice + +print_success "Contract initialized successfully!" +echo "CONTRACT_ID=$CONTRACT_ID" > .env.local +print_status "Contract ID saved to contracts/.env.local" + +cd .. +print_success "🎉 Local deployment complete!" diff --git a/scripts/deploy-mainnet.sh b/scripts/deploy-mainnet.sh new file mode 100755 index 0000000..606adee --- /dev/null +++ b/scripts/deploy-mainnet.sh @@ -0,0 +1,58 @@ +#!/bin/bash + +# SubTrackr Mainnet Deployment Script +# Deploys smart contracts to the Stellar Public network + +# Source utility functions +source "$(dirname "$0")/utils.sh" + +set -e + +print_warning "⚠️ WARNING: You are about to deploy to the Stellar Public Mainnet!" +print_warning "Ensure that your account has enough XLM for transaction fees and minimum balance." +echo "" + +# Validate required environment variables +validate_env "SOROBAN_ACCOUNT" +validate_env "ADMIN_ADDRESS" + +read -p "Are you sure you want to proceed? (y/N): " -n 1 -r +echo +if [[ ! $REPLY =~ ^[Yy]$ ]]; then + print_status "Deployment cancelled." + exit 0 +fi + +# Check prerequisites +check_command "soroban" +check_command "cargo" + +print_status "Build and optimize contract..." +cd contracts +cargo build --target wasm32-unknown-unknown --release +soroban contract optimize --wasm target/wasm32-unknown-unknown/release/subtrackr.wasm + +# Deploy to Mainnet +print_status "Deploying to Mainnet using account: $SOROBAN_ACCOUNT" +CONTRACT_ID=$(soroban contract deploy \ + --wasm target/wasm32-unknown-unknown/release/subtrackr.optimized.wasm \ + --source "$SOROBAN_ACCOUNT" \ + --network public) + +print_success "Contract deployed successfully! ID: $CONTRACT_ID" + +# Initialize contract +print_status "Initializing contract with admin: $ADMIN_ADDRESS" +soroban contract invoke \ + --id "$CONTRACT_ID" \ + --source "$SOROBAN_ACCOUNT" \ + --network public \ + -- initialize \ + --admin "$ADMIN_ADDRESS" + +print_success "Contract initialized successfully!" +echo "CONTRACT_ID=$CONTRACT_ID" > .env.public +print_status "Contract ID saved to contracts/.env.public" + +cd .. +print_success "🎉 Mainnet deployment complete!" diff --git a/scripts/deploy-testnet.sh b/scripts/deploy-testnet.sh new file mode 100755 index 0000000..17a64bf --- /dev/null +++ b/scripts/deploy-testnet.sh @@ -0,0 +1,51 @@ +#!/bin/bash + +# SubTrackr Testnet Deployment Script +# Deploys smart contracts to the Stellar Testnet + +# Source utility functions +source "$(dirname "$0")/utils.sh" + +set -e + +print_status "🚀 Starting testnet deployment..." + +# Check prerequisites +check_command "soroban" +check_command "cargo" + +# Validate required environment variables +# SOROBAN_ACCOUNT: The identity name or secret key to use for deployment +# ADMIN_ADDRESS: The address to initialize the contract with as admin +validate_env "SOROBAN_ACCOUNT" +validate_env "ADMIN_ADDRESS" + +print_status "Build and optimize contract..." +cd contracts +cargo build --target wasm32-unknown-unknown --release +soroban contract optimize --wasm target/wasm32-unknown-unknown/release/subtrackr.wasm + +# Deploy to Testnet +print_status "Deploying to Testnet using account: $SOROBAN_ACCOUNT" +CONTRACT_ID=$(soroban contract deploy \ + --wasm target/wasm32-unknown-unknown/release/subtrackr.optimized.wasm \ + --source "$SOROBAN_ACCOUNT" \ + --network testnet) + +print_success "Contract deployed successfully! ID: $CONTRACT_ID" + +# Initialize contract +print_status "Initializing contract with admin: $ADMIN_ADDRESS" +soroban contract invoke \ + --id "$CONTRACT_ID" \ + --source "$SOROBAN_ACCOUNT" \ + --network testnet \ + -- initialize \ + --admin "$ADMIN_ADDRESS" + +print_success "Contract initialized successfully!" +echo "CONTRACT_ID=$CONTRACT_ID" > .env.testnet +print_status "Contract ID saved to contracts/.env.testnet" + +cd .. +print_success "🎉 Testnet deployment complete!" diff --git a/scripts/utils.sh b/scripts/utils.sh new file mode 100755 index 0000000..9f419c1 --- /dev/null +++ b/scripts/utils.sh @@ -0,0 +1,41 @@ +#!/bin/bash + +# Shared utility functions for SubTrackr deployment scripts + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +print_status() { + echo -e "${BLUE}[INFO]${NC} $1" +} + +print_success() { + echo -e "${GREEN}[SUCCESS]${NC} $1" +} + +print_warning() { + echo -e "${YELLOW}[WARNING]${NC} $1" +} + +print_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +check_command() { + if ! command -v $1 &> /dev/null; then + print_error "$1 is not installed. Please install it to proceed." + exit 1 + fi +} + +validate_env() { + local var_name=$1 + if [ -z "${!var_name}" ]; then + print_error "Environment variable $var_name is not set." + exit 1 + fi +} diff --git a/scripts/verify.sh b/scripts/verify.sh new file mode 100755 index 0000000..3ac3954 --- /dev/null +++ b/scripts/verify.sh @@ -0,0 +1,35 @@ +#!/bin/bash + +# SubTrackr Contract Verification Script +# Verifies a deployed contract by running simple queries + +# Source utility functions +source "$(dirname "$0")/utils.sh" + +set -e + +# Usage: ./verify.sh +if [ -z "$1" ] || [ -z "$2" ]; then + print_error "Usage: ./scripts/verify.sh " + exit 1 +fi + +CONTRACT_ID=$1 +NETWORK=$2 + +print_status "🔍 Verifying contract: $CONTRACT_ID on network: $NETWORK" + +# Check if contract is alive by querying the plan count +print_status "Querying plan count..." +PLAN_COUNT=$(soroban contract invoke \ + --id "$CONTRACT_ID" \ + --network "$NETWORK" \ + --source alice \ + -- get_plan_count) + +if [ $? -eq 0 ]; then + print_success "Contract verification successful! Plan count: $PLAN_COUNT" +else + print_error "Contract verification failed. Could not query plan count." + exit 1 +fi diff --git a/src/components/common/EmptyState.tsx b/src/components/common/EmptyState.tsx index a99276e..9431b98 100644 --- a/src/components/common/EmptyState.tsx +++ b/src/components/common/EmptyState.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { View, Text, TouchableOpacity, StyleSheet } from 'react-native'; +// eslint-disable-next-line import/no-unresolved import { colors, spacing, typography, borderRadius } from '../utils/constants'; interface EmptyStateProps { diff --git a/src/components/home/FilterBar.tsx b/src/components/home/FilterBar.tsx index 1a97f33..8102b40 100644 --- a/src/components/home/FilterBar.tsx +++ b/src/components/home/FilterBar.tsx @@ -118,4 +118,4 @@ const styles = StyleSheet.create({ fontWeight: '600', fontSize: 10, }, -}); \ No newline at end of file +}); diff --git a/src/components/home/FilterModal.tsx b/src/components/home/FilterModal.tsx index e0d7727..3c17256 100644 --- a/src/components/home/FilterModal.tsx +++ b/src/components/home/FilterModal.tsx @@ -183,7 +183,9 @@ export const FilterModal: React.FC = ({ styles.sortButtonText, sortBy === field && styles.sortButtonTextSelected, ]}> - {field === 'nextBilling' ? 'Next Billing' : field.charAt(0).toUpperCase() + field.slice(1)} + {field === 'nextBilling' + ? 'Next Billing' + : field.charAt(0).toUpperCase() + field.slice(1)} ))} @@ -195,14 +197,22 @@ export const FilterModal: React.FC = ({ setSortOrder('asc')}> - + ↑ Ascending setSortOrder('desc')}> - + ↓ Descending @@ -276,7 +286,12 @@ const styles = StyleSheet.create({ ...typography.body, }, priceRangeSeparator: { ...typography.body, color: colors.textSecondary }, - toggleContainer: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingVertical: spacing.sm }, + toggleContainer: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingVertical: spacing.sm, + }, toggleLabel: { ...typography.body, color: colors.text }, sortContainer: { gap: spacing.md }, sortRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.md }, @@ -293,7 +308,13 @@ const styles = StyleSheet.create({ sortButtonSelected: { backgroundColor: colors.primary, borderColor: colors.primary }, sortButtonText: { ...typography.body, color: colors.text }, sortButtonTextSelected: { color: colors.text, fontWeight: '600' }, - modalFooter: { flexDirection: 'row', gap: spacing.md, padding: spacing.lg, borderTopWidth: 1, borderTopColor: colors.border }, + modalFooter: { + flexDirection: 'row', + gap: spacing.md, + padding: spacing.lg, + borderTopWidth: 1, + borderTopColor: colors.border, + }, clearFiltersButton: { flex: 1, backgroundColor: colors.surface, @@ -304,6 +325,12 @@ const styles = StyleSheet.create({ borderColor: colors.border, }, clearFiltersButtonText: { ...typography.body, color: colors.text, fontWeight: '600' }, - applyFiltersButton: { flex: 1, backgroundColor: colors.primary, borderRadius: borderRadius.md, padding: spacing.md, alignItems: 'center' }, + applyFiltersButton: { + flex: 1, + backgroundColor: colors.primary, + borderRadius: borderRadius.md, + padding: spacing.md, + alignItems: 'center', + }, applyFiltersButtonText: { ...typography.body, color: colors.text, fontWeight: '600' }, -}); \ No newline at end of file +}); diff --git a/src/components/home/StatsCard.tsx b/src/components/home/StatsCard.tsx index 58bfbce..8b901ac 100644 --- a/src/components/home/StatsCard.tsx +++ b/src/components/home/StatsCard.tsx @@ -69,4 +69,4 @@ const styles = StyleSheet.create({ lineHeight: 22, minHeight: 22, }, -}); \ No newline at end of file +}); diff --git a/src/components/home/SubscriptionList.tsx b/src/components/home/SubscriptionList.tsx index fdfe5a7..bb3b7a6 100644 --- a/src/components/home/SubscriptionList.tsx +++ b/src/components/home/SubscriptionList.tsx @@ -18,7 +18,7 @@ interface SubscriptionListProps { } export const SubscriptionList: React.FC = ({ - subscriptions, + subscriptions: _subscriptions, activeSubscriptions, upcomingSubscriptions, hasSubscriptions, @@ -195,4 +195,4 @@ const styles = StyleSheet.create({ color: colors.text, fontWeight: '600', }, -}); \ No newline at end of file +}); diff --git a/src/hooks/useSubscriptionFilters.ts b/src/hooks/useSubscriptionFilters.ts index 48df9de..1ec19a7 100644 --- a/src/hooks/useSubscriptionFilters.ts +++ b/src/hooks/useSubscriptionFilters.ts @@ -15,35 +15,54 @@ export const useSubscriptionFilters = (subscriptions: Subscription[]) => { let filtered = subscriptions || []; if (searchQuery.trim()) { - filtered = filtered.filter(sub => - sub.name.toLowerCase().includes(searchQuery.toLowerCase()) || - sub.description?.toLowerCase().includes(searchQuery.toLowerCase()) + filtered = filtered.filter( + (sub) => + sub.name.toLowerCase().includes(searchQuery.toLowerCase()) || + sub.description?.toLowerCase().includes(searchQuery.toLowerCase()) ); } if (selectedCategories.length > 0) { - filtered = filtered.filter(sub => selectedCategories.includes(sub.category)); + filtered = filtered.filter((sub) => selectedCategories.includes(sub.category)); } if (selectedBillingCycles.length > 0) { - filtered = filtered.filter(sub => selectedBillingCycles.includes(sub.billingCycle)); + filtered = filtered.filter((sub) => selectedBillingCycles.includes(sub.billingCycle)); } - filtered = filtered.filter(sub => sub.price >= priceRange.min && sub.price <= priceRange.max); - if (showActiveOnly) filtered = filtered.filter(sub => sub.isActive); - if (showCryptoOnly) filtered = filtered.filter(sub => sub.isCryptoEnabled); + filtered = filtered.filter((sub) => sub.price >= priceRange.min && sub.price <= priceRange.max); + if (showActiveOnly) filtered = filtered.filter((sub) => sub.isActive); + if (showCryptoOnly) filtered = filtered.filter((sub) => sub.isCryptoEnabled); return [...filtered].sort((a, b) => { let comp = 0; switch (sortBy) { - case 'name': comp = a.name.localeCompare(b.name); break; - case 'price': comp = a.price - b.price; break; - case 'nextBilling': comp = new Date(a.nextBillingDate).getTime() - new Date(b.nextBillingDate).getTime(); break; - case 'category': comp = a.category.localeCompare(b.category); break; + case 'name': + comp = a.name.localeCompare(b.name); + break; + case 'price': + comp = a.price - b.price; + break; + case 'nextBilling': + comp = new Date(a.nextBillingDate).getTime() - new Date(b.nextBillingDate).getTime(); + break; + case 'category': + comp = a.category.localeCompare(b.category); + break; } return sortOrder === 'asc' ? comp : -comp; }); - }, [subscriptions, searchQuery, selectedCategories, selectedBillingCycles, priceRange, showActiveOnly, showCryptoOnly, sortBy, sortOrder]); + }, [ + subscriptions, + searchQuery, + selectedCategories, + selectedBillingCycles, + priceRange, + showActiveOnly, + showCryptoOnly, + sortBy, + sortOrder, + ]); const activeFilterCount = useMemo(() => { let count = 0; @@ -55,18 +74,35 @@ export const useSubscriptionFilters = (subscriptions: Subscription[]) => { if (showCryptoOnly) count++; if (sortBy !== 'name' || sortOrder !== 'asc') count++; return count; - }, [searchQuery, selectedCategories, selectedBillingCycles, priceRange, showActiveOnly, showCryptoOnly, sortBy, sortOrder]); + }, [ + searchQuery, + selectedCategories, + selectedBillingCycles, + priceRange, + showActiveOnly, + showCryptoOnly, + sortBy, + sortOrder, + ]); return { filters: { - searchQuery, setSearchQuery, - selectedCategories, setSelectedCategories, - selectedBillingCycles, setSelectedBillingCycles, - priceRange, setPriceRange, - showActiveOnly, setShowActiveOnly, - showCryptoOnly, setShowCryptoOnly, - sortBy, setSortBy, - sortOrder, setSortOrder, + searchQuery, + setSearchQuery, + selectedCategories, + setSelectedCategories, + selectedBillingCycles, + setSelectedBillingCycles, + priceRange, + setPriceRange, + showActiveOnly, + setShowActiveOnly, + showCryptoOnly, + setShowCryptoOnly, + sortBy, + setSortBy, + sortOrder, + setSortOrder, }, filteredAndSorted, activeFilterCount, @@ -80,6 +116,6 @@ export const useSubscriptionFilters = (subscriptions: Subscription[]) => { setShowCryptoOnly(false); setSortBy('name'); setSortOrder('asc'); - } + }, }; -}; \ No newline at end of file +}; diff --git a/src/screens/AddSubscriptionScreen.tsx b/src/screens/AddSubscriptionScreen.tsx index a4e806b..674c201 100644 --- a/src/screens/AddSubscriptionScreen.tsx +++ b/src/screens/AddSubscriptionScreen.tsx @@ -18,7 +18,6 @@ import { colors, spacing, typography, borderRadius } from '../utils/constants'; import { SubscriptionCategory, BillingCycle, SubscriptionFormData } from '../types/subscription'; import { useSubscriptionStore } from '../store'; import { Button } from '../components/common/Button'; -import { formatCurrency } from '../utils/formatting'; import DateTimePicker, { DateTimePickerEvent } from '@react-native-community/datetimepicker'; const AddSubscriptionScreen: React.FC = () => { @@ -75,7 +74,7 @@ const AddSubscriptionScreen: React.FC = () => { if (selectedDate) { handleInputChange('nextBillingDate', selectedDate); - + if (Platform.OS === 'android' && pickerMode === 'date') { setShowPicker(false); setTimeout(() => { @@ -237,10 +236,7 @@ const AddSubscriptionScreen: React.FC = () => { Next Billing Date * - + {formData.nextBillingDate.toLocaleString([], { dateStyle: 'medium', @@ -567,4 +563,4 @@ const styles = StyleSheet.create({ }, }); -export default AddSubscriptionScreen; \ No newline at end of file +export default AddSubscriptionScreen; diff --git a/src/screens/CryptoPaymentScreen.tsx b/src/screens/CryptoPaymentScreen.tsx index 374b7e9..88b0671 100644 --- a/src/screens/CryptoPaymentScreen.tsx +++ b/src/screens/CryptoPaymentScreen.tsx @@ -15,10 +15,10 @@ import { useNavigation, useRoute } from '@react-navigation/native'; import { colors, spacing, typography, borderRadius } from '../utils/constants'; import { Button } from '../components/common/Button'; import { Card } from '../components/common/Card'; -import walletServiceManager, { - TokenBalance, - GasEstimate, - WalletConnection +import walletServiceManager, { + TokenBalance, + GasEstimate, + WalletConnection, } from '../services/walletService'; interface RouteParams { @@ -45,7 +45,7 @@ const CryptoPaymentScreen: React.FC = () => { const [isLoading, setIsLoading] = useState(false); const [availableTokens, setAvailableTokens] = useState([]); - const [connection, setConnection] = useState(null); + const [connection, setConnection] = useState(null); useEffect(() => { loadWalletData(); @@ -139,7 +139,7 @@ const CryptoPaymentScreen: React.FC = () => { const handleCreateStream = async () => { if (!validateForm()) return; - + if (!isWalletConnected(connection)) { Alert.alert('Error', 'Wallet not connected'); return; diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index 0cebce9..b369561 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -21,12 +21,14 @@ type HomeNavigationProp = NativeStackNavigationProp; const HomeScreen: React.FC = () => { const navigation = useNavigation(); - const { subscriptions, stats, error, fetchSubscriptions, calculateStats, toggleSubscriptionStatus } = useSubscriptionStore(); + const { subscriptions, stats, fetchSubscriptions, calculateStats, toggleSubscriptionStatus } = + useSubscriptionStore(); const [refreshing, setRefreshing] = useState(false); const [upcomingSubscriptions, setUpcomingSubscriptions] = useState([]); // Use the new hook - const { filters, filteredAndSorted, activeFilterCount, hasActiveFilters, clearAllFilters } = useSubscriptionFilters(subscriptions); + const { filters, filteredAndSorted, activeFilterCount, hasActiveFilters, clearAllFilters } = + useSubscriptionFilters(subscriptions); const [showFilterModal, setShowFilterModal] = useState(false); useEffect(() => { @@ -46,31 +48,36 @@ const HomeScreen: React.FC = () => { return ( - } - > + + }> SubTrackr Manage your subscriptions - setShowFilterModal(true)} - hasActiveFilters={hasActiveFilters} - activeFilterCount={activeFilterCount} + setShowFilterModal(true)} + hasActiveFilters={hasActiveFilters} + activeFilterCount={activeFilterCount} /> - navigation.navigate('WalletConnect' as never)} + navigation.navigate('WalletConnect' as never)} /> - s.isActive)} + activeSubscriptions={filteredAndSorted.filter((s) => s.isActive)} upcomingSubscriptions={upcomingSubscriptions} hasSubscriptions={subscriptions.length > 0} hasActiveFilters={hasActiveFilters} @@ -83,16 +90,28 @@ const HomeScreen: React.FC = () => { {subscriptions.length > 0 && ( - navigation.navigate('AddSubscription' as never)} icon="+" size="large" /> + navigation.navigate('AddSubscription' as never)} + icon="+" + size="large" + /> )} - setShowFilterModal(false)} - {...filters} + setShowFilterModal(false)} + {...filters} clearAllFilters={clearAllFilters} - toggleCategory={(cat) => filters.setSelectedCategories(prev => prev.includes(cat) ? prev.filter(c => c !== cat) : [...prev, cat])} - toggleBillingCycle={(cycle) => filters.setSelectedBillingCycles(prev => prev.includes(cycle) ? prev.filter(c => c !== cycle) : [...prev, cycle])} + toggleCategory={(cat) => + filters.setSelectedCategories((prev) => + prev.includes(cat) ? prev.filter((c) => c !== cat) : [...prev, cat] + ) + } + toggleBillingCycle={(cycle) => + filters.setSelectedBillingCycles((prev) => + prev.includes(cycle) ? prev.filter((c) => c !== cycle) : [...prev, cycle] + ) + } /> ); @@ -104,7 +123,13 @@ const styles = StyleSheet.create({ header: { padding: spacing.lg, paddingBottom: spacing.md }, title: { ...typography.h1, color: colors.text, marginBottom: spacing.xs }, subtitle: { ...typography.body, color: colors.textSecondary }, - errorContainer: { backgroundColor: colors.error, padding: spacing.md, margin: spacing.lg, borderRadius: borderRadius.md, alignItems: 'center' }, + errorContainer: { + backgroundColor: colors.error, + padding: spacing.md, + margin: spacing.lg, + borderRadius: borderRadius.md, + alignItems: 'center', + }, errorText: { ...typography.body, color: colors.text }, }); diff --git a/src/screens/WalletConnectScreen.tsx b/src/screens/WalletConnectScreen.tsx index a9d74d2..a81d8ee 100644 --- a/src/screens/WalletConnectScreen.tsx +++ b/src/screens/WalletConnectScreen.tsx @@ -121,7 +121,7 @@ const WalletConnectScreen: React.FC = () => { if (connection?.address) { try { await Clipboard.setStringAsync(connection.address); - + if (Platform.OS === 'android') { Alert.alert('Copied', 'Address copied to clipboard'); } else { diff --git a/src/services/walletService.ts b/src/services/walletService.ts index 7919494..0c19a23 100644 --- a/src/services/walletService.ts +++ b/src/services/walletService.ts @@ -368,8 +368,8 @@ export class WalletServiceManager { // 1. Get Token Decimals & Parse Amount const erc20Abi = [ - "function decimals() view returns (uint8)", - "function approve(address spender, uint256 amount) returns (bool)" + 'function decimals() view returns (uint8)', + 'function approve(address spender, uint256 amount) returns (bool)', ]; const erc20 = new ethers.Contract(token, erc20Abi, signer); const decimals = await erc20.decimals(); @@ -384,14 +384,14 @@ export class WalletServiceManager { // 3. Create the Sablier Stream const abi = [ - "function createWithDurations(tuple(address sender, address recipient, uint128 totalAmount, address asset, bool cancelable, bool transferable, tuple(uint40 cliff, uint40 total) durations, address broker) params) external returns (uint256 streamId)" + 'function createWithDurations(tuple(address sender, address recipient, uint128 totalAmount, address asset, bool cancelable, bool transferable, tuple(uint40 cliff, uint40 total) durations, address broker) params) external returns (uint256 streamId)', ]; - + const sablierContract = new ethers.Contract(SABLIER_V2_LOCKUP_LINEAR, abi, signer); const sender = await signer.getAddress(); - + // Calculate duration in seconds - const totalDuration = Math.floor((stopTime - startTime) / 1000); + const totalDuration = Math.floor((stopTime - startTime) / 1000); const params = { sender: sender, @@ -402,9 +402,9 @@ export class WalletServiceManager { transferable: true, durations: { cliff: 0, - total: totalDuration + total: totalDuration, }, - broker: ethers.constants.AddressZero + broker: ethers.constants.AddressZero, }; const txCreate = await sablierContract.createWithDurations(params); diff --git a/src/utils/__tests__/formatting.test.ts b/src/utils/__tests__/formatting.test.ts index 05a8ba4..911a0ba 100644 --- a/src/utils/__tests__/formatting.test.ts +++ b/src/utils/__tests__/formatting.test.ts @@ -2,7 +2,7 @@ import { formatCurrency, formatRelativeDate, formatBillingCycle, - formatCategory + formatCategory, } from '../formatting'; import { SubscriptionCategory, BillingCycle } from '../../types/subscription'; @@ -19,7 +19,7 @@ describe('Formatting Utilities', () => { }); it('handles negative values correctly', () => { - expect(formatCurrency(-10.50)).toBe('-$10.50'); + expect(formatCurrency(-10.5)).toBe('-$10.50'); }); });