diff --git a/contracts/Cargo.toml b/contracts/Cargo.toml index 4e14fb1..94c17e6 100644 --- a/contracts/Cargo.toml +++ b/contracts/Cargo.toml @@ -4,7 +4,7 @@ members = [ "proxy", "storage", "subscription", - "invoice", + "fraud", "types", ] @@ -16,4 +16,8 @@ strip = "symbols" debug-assertions = false panic = "abort" codegen-units = 1 -lto = true \ No newline at end of file +lto = true + +[workspace.dependencies] +soroban-sdk = "21.0.0" +arbitrary = { version = "1.3", features = ["derive"] } diff --git a/contracts/fraud/Cargo.toml b/contracts/fraud/Cargo.toml new file mode 100644 index 0000000..c97eb08 --- /dev/null +++ b/contracts/fraud/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "subtrackr-fraud" +version = "0.2.0" +edition = "2021" +authors = ["SubTrackr Team"] +description = "SubTrackr fraud detection contract (Soroban)" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +soroban-sdk = "21.0.0" +subtrackr-types = { path = "../types" } + +[dev-dependencies] +soroban-sdk = { version = "21.0.0", features = ["testutils"] } diff --git a/contracts/fraud/src/lib.rs b/contracts/fraud/src/lib.rs new file mode 100644 index 0000000..125c689 --- /dev/null +++ b/contracts/fraud/src/lib.rs @@ -0,0 +1,486 @@ +#![no_std] + +use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, String, Vec}; +use subtrackr_types::{ + FraudAction, FraudCase, FraudReport, FraudReviewStatus, MerchantId, RiskScore, RiskSignal, + RiskSignalKind, SubscriptionId, +}; + +const HIGH_RISK_THRESHOLD: u32 = 80; +const REVIEW_THRESHOLD: u32 = 50; +const VELOCITY_WINDOW_SECS: u64 = 86_400; +const VELOCITY_LIMIT: u32 = 3; +const MAX_REVIEW_CASES: u32 = 5; + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +struct SubscriptionProfile { + subscription_id: SubscriptionId, + subscriber: Address, + merchant_id: MerchantId, + created_at: u64, + last_activity_at: u64, + expected_usage: u32, + observed_usage: u32, + chargebacks: u32, + is_flagged: bool, + is_blocked: bool, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +enum StorageKey { + Subscription(SubscriptionId), + SubscriberSubscriptions(Address), + MerchantSubscriptions(Address), + ReviewCase(SubscriptionId), +} + +fn push_unique_u64(items: &mut Vec, value: SubscriptionId) { + let mut i = 0u32; + while i < items.len() { + if items.get(i).unwrap() == value { + return; + } + i += 1; + } + items.push_back(value); +} + +fn push_unique_address(items: &mut Vec
, value: Address) { + let mut i = 0u32; + while i < items.len() { + if items.get(i).unwrap() == value { + return; + } + i += 1; + } + items.push_back(value); +} + +fn get_subscriptions(env: &Env, subscriber: &Address) -> Vec { + env.storage() + .persistent() + .get::<_, Vec>(&StorageKey::SubscriberSubscriptions(subscriber.clone())) + .unwrap_or_else(|| Vec::new(env)) +} + +fn get_merchant_subscriptions(env: &Env, merchant: &Address) -> Vec { + env.storage() + .persistent() + .get::<_, Vec>(&StorageKey::MerchantSubscriptions(merchant.clone())) + .unwrap_or_else(|| Vec::new(env)) +} + +fn save_subscriptions(env: &Env, subscriber: &Address, ids: &Vec) { + env.storage() + .persistent() + .set(&StorageKey::SubscriberSubscriptions(subscriber.clone()), &ids.clone()); +} + +fn save_merchant_subscriptions(env: &Env, merchant: &Address, ids: &Vec) { + env.storage() + .persistent() + .set(&StorageKey::MerchantSubscriptions(merchant.clone()), &ids.clone()); +} + +fn load_profile(env: &Env, subscription_id: SubscriptionId) -> Option { + env.storage() + .persistent() + .get(&StorageKey::Subscription(subscription_id)) +} + +fn save_profile(env: &Env, profile: &SubscriptionProfile) { + env.storage() + .persistent() + .set(&StorageKey::Subscription(profile.subscription_id), &profile.clone()); +} + +fn determine_velocity_score(env: &Env, profile: &SubscriptionProfile, ids: &Vec) -> u32 { + let now = env.ledger().timestamp(); + let mut recent_creations = 0u32; + let mut i = 0u32; + while i < ids.len() { + if let Some(sub_id) = ids.get(i) { + if let Some(other) = load_profile(env, sub_id) { + if now.saturating_sub(other.created_at) <= VELOCITY_WINDOW_SECS + && other.subscriber == profile.subscriber + { + recent_creations += 1; + } + } + } + i += 1; + } + + if recent_creations <= VELOCITY_LIMIT { + 0 + } else { + ((recent_creations - VELOCITY_LIMIT) * 15).min(40) + } +} + +fn determine_anomaly_score(profile: &SubscriptionProfile) -> u32 { + if profile.expected_usage == 0 && profile.observed_usage > 0 { + return 20; + } + + if profile.expected_usage == 0 { + return 0; + } + + if profile.observed_usage >= profile.expected_usage.saturating_mul(3) { + 35 + } else if profile.observed_usage >= profile.expected_usage.saturating_mul(2) { + 25 + } else if profile.observed_usage > profile.expected_usage { + 10 + } else { + 0 + } +} + +fn determine_chargeback_score(profile: &SubscriptionProfile) -> u32 { + match profile.chargebacks { + 0 => 0, + 1 => 30, + 2 => 50, + _ => 70, + } +} + +fn determine_action(score: u32) -> FraudAction { + if score >= HIGH_RISK_THRESHOLD { + FraudAction::Block + } else if score >= REVIEW_THRESHOLD { + FraudAction::Flag + } else { + FraudAction::Approve + } +} + +fn score_profile( + env: &Env, + profile: &SubscriptionProfile, + ids: &Vec, +) -> RiskScore { + let now = env.ledger().timestamp(); + let velocity_score = determine_velocity_score(env, profile, ids); + let anomaly_score = determine_anomaly_score(profile); + let chargeback_score = determine_chargeback_score(profile); + let total_score = (velocity_score + anomaly_score + chargeback_score).min(100); + + let mut signals = Vec::new(env); + if velocity_score > 0 { + signals.push_back(RiskSignal { + kind: RiskSignalKind::Velocity, + score: velocity_score, + detail: String::from_str(env, "rapid subscription creation"), + observed_at: now, + }); + } + if anomaly_score > 0 { + signals.push_back(RiskSignal { + kind: RiskSignalKind::UsageAnomaly, + score: anomaly_score, + detail: String::from_str(env, "usage pattern deviates from baseline"), + observed_at: now, + }); + } + if chargeback_score > 0 { + signals.push_back(RiskSignal { + kind: RiskSignalKind::Chargeback, + score: chargeback_score, + detail: String::from_str(env, "chargeback history predicts dispute risk"), + observed_at: now, + }); + } + + let reason = if chargeback_score >= anomaly_score && chargeback_score >= velocity_score { + String::from_str(env, "chargeback risk dominates") + } else if velocity_score >= anomaly_score { + String::from_str(env, "velocity risk is elevated") + } else { + String::from_str(env, "usage anomaly detected") + }; + + RiskScore { + subscriber: profile.subscriber.clone(), + subscription_id: profile.subscription_id, + merchant_id: profile.merchant_id.clone(), + total_score, + velocity_score, + anomaly_score, + chargeback_score, + action: determine_action(total_score), + reason, + assessed_at: now, + signals, + } +} + +fn persist_case(env: &Env, score: &RiskScore, status: FraudReviewStatus) -> FraudCase { + let case = FraudCase { + case_id: score.subscription_id, + subscription_id: score.subscription_id, + subscriber: score.subscriber.clone(), + merchant_id: score.merchant_id.clone(), + risk_score: score.total_score, + action: score.action.clone(), + status, + reason: score.reason.clone(), + created_at: score.assessed_at, + updated_at: score.assessed_at, + }; + + env.storage() + .persistent() + .set(&StorageKey::ReviewCase(score.subscription_id), &case.clone()); + case +} + +fn update_profile_action(env: &Env, subscription_id: SubscriptionId, score: &RiskScore) { + if let Some(mut profile) = load_profile(env, subscription_id) { + profile.is_flagged = matches!(score.action, FraudAction::Flag | FraudAction::Block); + profile.is_blocked = matches!(score.action, FraudAction::Block); + profile.last_activity_at = score.assessed_at; + save_profile(env, &profile); + } +} + +fn review_case_for_subscription(env: &Env, subscription_id: SubscriptionId) -> Option { + env.storage() + .persistent() + .get(&StorageKey::ReviewCase(subscription_id)) +} + +#[contract] +pub struct SubTrackrFraud; + +#[contractimpl] +impl SubTrackrFraud { + pub fn register_subscription( + env: Env, + subscriber: Address, + merchant_id: Address, + subscription_id: SubscriptionId, + created_at: u64, + ) { + subscriber.require_auth(); + + let profile = SubscriptionProfile { + subscription_id, + subscriber: subscriber.clone(), + merchant_id: merchant_id.clone(), + created_at, + last_activity_at: created_at, + expected_usage: 1, + observed_usage: 1, + chargebacks: 0, + is_flagged: false, + is_blocked: false, + }; + + save_profile(&env, &profile); + + let mut subscriber_ids = get_subscriptions(&env, &subscriber); + push_unique_u64(&mut subscriber_ids, subscription_id); + save_subscriptions(&env, &subscriber, &subscriber_ids); + + let mut merchant_ids = get_merchant_subscriptions(&env, &merchant_id); + push_unique_u64(&mut merchant_ids, subscription_id); + save_merchant_subscriptions(&env, &merchant_id, &merchant_ids); + } + + pub fn record_usage_pattern( + env: Env, + subscriber: Address, + subscription_id: SubscriptionId, + expected_usage: u32, + observed_usage: u32, + ) { + subscriber.require_auth(); + + if let Some(mut profile) = load_profile(&env, subscription_id) { + profile.expected_usage = expected_usage; + profile.observed_usage = observed_usage; + profile.last_activity_at = env.ledger().timestamp(); + save_profile(&env, &profile); + } + } + + pub fn record_chargeback( + env: Env, + subscriber: Address, + subscription_id: SubscriptionId, + ) { + subscriber.require_auth(); + + if let Some(mut profile) = load_profile(&env, subscription_id) { + profile.chargebacks = profile.chargebacks.saturating_add(1); + profile.last_activity_at = env.ledger().timestamp(); + save_profile(&env, &profile); + } + } + + pub fn assess_risk(env: Env, subscriber: Address) -> RiskScore { + let ids = get_subscriptions(&env, &subscriber); + if ids.len() == 0 { + return RiskScore { + subscriber: subscriber.clone(), + subscription_id: 0, + merchant_id: subscriber.clone(), + total_score: 0, + velocity_score: 0, + anomaly_score: 0, + chargeback_score: 0, + action: FraudAction::Approve, + reason: String::from_str(&env, "no subscription history"), + assessed_at: env.ledger().timestamp(), + signals: Vec::new(&env), + }; + } + + let mut highest = score_profile( + &env, + &load_profile(&env, ids.get(0).unwrap()).unwrap(), + &ids, + ); + + let mut i = 1u32; + while i < ids.len() { + if let Some(profile) = load_profile(&env, ids.get(i).unwrap()) { + let next = score_profile(&env, &profile, &ids); + if next.total_score > highest.total_score { + highest = next; + } + } + i += 1; + } + + highest + } + + pub fn flag_subscription(env: Env, subscription_id: SubscriptionId) { + if let Some(profile) = load_profile(&env, subscription_id) { + let ids = get_subscriptions(&env, &profile.subscriber); + let score = score_profile(&env, &profile, &ids); + let status = if matches!(score.action, FraudAction::Block) { + FraudReviewStatus::Escalated + } else { + FraudReviewStatus::Pending + }; + let case = persist_case(&env, &score, status); + update_profile_action(&env, subscription_id, &score); + env.events().publish( + (String::from_str(&env, "fraud_case_opened"), score.subscription_id), + (case.risk_score, case.action.clone()), + ); + } else { + panic!("Subscription not found"); + } + } + + pub fn resolve_case( + env: Env, + subscriber: Address, + subscription_id: SubscriptionId, + approved: bool, + ) { + subscriber.require_auth(); + if let Some(mut case) = review_case_for_subscription(&env, subscription_id) { + case.status = FraudReviewStatus::Reviewed; + case.updated_at = env.ledger().timestamp(); + case.action = if approved { + FraudAction::Approve + } else { + FraudAction::Block + }; + env.storage() + .persistent() + .set(&StorageKey::ReviewCase(subscription_id), &case.clone()); + } + } + + pub fn get_fraud_report(env: Env, merchant_id: Address) -> FraudReport { + let ids = get_merchant_subscriptions(&env, &merchant_id); + let mut total_risk = 0u32; + let mut flagged = 0u32; + let mut blocked = 0u32; + let mut manual_review = 0u32; + let mut velocity_alerts = 0u32; + let mut anomaly_alerts = 0u32; + let mut chargeback_predictions = 0u32; + let mut high_risk_subscribers: Vec
= Vec::new(&env); + let mut recent_cases: Vec = Vec::new(&env); + + let mut i = 0u32; + while i < ids.len() { + if let Some(profile) = load_profile(&env, ids.get(i).unwrap()) { + let subscriber_ids = get_subscriptions(&env, &profile.subscriber); + let score = score_profile(&env, &profile, &subscriber_ids); + total_risk += score.total_score; + + if matches!(score.action, FraudAction::Flag | FraudAction::Block) { + flagged += 1; + } + if matches!(score.action, FraudAction::Block) { + blocked += 1; + } + if score.total_score >= REVIEW_THRESHOLD { + manual_review += 1; + } + if score.velocity_score > 0 { + velocity_alerts += 1; + } + if score.anomaly_score > 0 { + anomaly_alerts += 1; + } + if score.chargeback_score > 0 { + chargeback_predictions += 1; + } + if score.total_score >= REVIEW_THRESHOLD { + push_unique_address(&mut high_risk_subscribers, score.subscriber.clone()); + } + + if let Some(case) = review_case_for_subscription(&env, score.subscription_id) { + recent_cases.push_back(case); + } else if score.total_score >= REVIEW_THRESHOLD { + recent_cases.push_back(persist_case(&env, &score, FraudReviewStatus::Pending)); + } + } + i += 1; + } + + let average_risk = if ids.len() == 0 { + 0 + } else { + total_risk / ids.len() + }; + + let mut trimmed_cases = Vec::new(&env); + let mut idx = recent_cases.len(); + let mut copied = 0u32; + while idx > 0 && copied < MAX_REVIEW_CASES { + idx -= 1; + if let Some(case) = recent_cases.get(idx) { + trimmed_cases.push_back(case); + copied += 1; + } + } + + FraudReport { + merchant_id, + total_subscriptions: ids.len(), + flagged_subscriptions: flagged, + blocked_subscriptions: blocked, + manual_review_count: manual_review, + average_risk, + velocity_alerts, + anomaly_alerts, + chargeback_predictions, + high_risk_subscribers: high_risk_subscribers.len(), + recent_cases: trimmed_cases, + } + } +} diff --git a/contracts/types/src/lib.rs b/contracts/types/src/lib.rs index a5b4447..86daa83 100644 --- a/contracts/types/src/lib.rs +++ b/contracts/types/src/lib.rs @@ -201,125 +201,90 @@ pub struct UpgradeEvent { pub executed_at: Timestamp, } +pub type SubscriptionId = u64; +pub type MerchantId = Address; + #[contracttype] #[derive(Clone, Debug, PartialEq)] -pub enum WebhookEventType { - SubscriptionCreated, - SubscriptionUpdated, - SubscriptionCancelled, - SubscriptionPaused, - SubscriptionResumed, - SubscriptionCharged, - RefundRequested, - RefundApproved, - RefundRejected, - TransferRequested, - TransferAccepted, +pub enum FraudAction { + Approve, + Flag, + Block, } #[contracttype] #[derive(Clone, Debug, PartialEq)] -pub enum WebhookDeliveryStatus { +pub enum FraudReviewStatus { Pending, - Retrying, - Delivered, - Failed, - Paused, - Skipped, + Reviewed, + Dismissed, + Escalated, } #[contracttype] #[derive(Clone, Debug, PartialEq)] -pub struct WebhookRetryPolicy { - pub max_retries: u32, - pub initial_delay_secs: u64, - pub max_delay_secs: u64, - pub backoff_factor: u32, +pub enum RiskSignalKind { + Velocity, + UsageAnomaly, + Chargeback, + PatternShift, + DeviceMismatch, } #[contracttype] #[derive(Clone, Debug, PartialEq)] -pub struct WebhookSubscriptionSnapshot { - pub id: u64, - pub plan_id: u64, - pub subscriber: Address, - pub status: SubscriptionStatus, - pub started_at: u64, - pub last_charged_at: u64, - pub next_charge_at: u64, - pub total_paid: i128, - pub total_gas_spent: u64, - pub charge_count: u32, - pub paused_at: u64, - pub pause_duration: u64, - pub refund_requested_amount: i128, +pub struct RiskSignal { + pub kind: RiskSignalKind, + pub score: u32, + pub detail: String, + pub observed_at: Timestamp, } #[contracttype] #[derive(Clone, Debug, PartialEq)] -pub struct WebhookPlanSnapshot { - pub id: u64, - pub merchant: Address, - pub name: String, - pub price: i128, - pub token: Address, - pub interval: Interval, - pub active: bool, - pub subscriber_count: u32, - pub created_at: u64, -} - -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct WebhookConfig { - pub id: u64, - pub merchant: Address, - pub url: String, - pub events: Vec, - pub secret_key: String, - pub retry_policy: WebhookRetryPolicy, - pub is_paused: bool, - pub created_at: u64, - pub updated_at: u64, - pub health_check_at: u64, - pub healthy: bool, - pub success_count: u64, - pub failure_count: u64, +pub struct RiskScore { + pub subscriber: Address, + pub subscription_id: SubscriptionId, + pub merchant_id: MerchantId, + pub total_score: u32, + pub velocity_score: u32, + pub anomaly_score: u32, + pub chargeback_score: u32, + pub action: FraudAction, + pub reason: String, + pub assessed_at: Timestamp, + pub signals: Vec, } #[contracttype] #[derive(Clone, Debug, PartialEq)] -pub struct WebhookEventPayload { - pub id: u64, - pub webhook_id: u64, - pub event_type: WebhookEventType, - pub merchant: Address, - pub occurred_at: u64, - pub subscription: WebhookSubscriptionSnapshot, - pub plan: WebhookPlanSnapshot, - pub previous_status: SubscriptionStatus, - pub current_status: SubscriptionStatus, +pub struct FraudCase { + pub case_id: u64, + pub subscription_id: SubscriptionId, + pub subscriber: Address, + pub merchant_id: MerchantId, + pub risk_score: u32, + pub action: FraudAction, + pub status: FraudReviewStatus, + pub reason: String, + pub created_at: Timestamp, + pub updated_at: Timestamp, } #[contracttype] #[derive(Clone, Debug, PartialEq)] -pub struct WebhookDelivery { - pub id: u64, - pub webhook_id: u64, - pub event_id: u64, - pub event_type: WebhookEventType, - pub payload: WebhookEventPayload, - pub status: WebhookDeliveryStatus, - pub attempts: u32, - pub max_attempts: u32, - pub next_retry_at: u64, - pub last_attempt_at: u64, - pub delivered_at: u64, - pub response_code: i32, - pub error_message: String, - pub signature: String, - pub created_at: u64, - pub updated_at: u64, +pub struct FraudReport { + pub merchant_id: MerchantId, + pub total_subscriptions: u32, + pub flagged_subscriptions: u32, + pub blocked_subscriptions: u32, + pub manual_review_count: u32, + pub average_risk: u32, + pub velocity_alerts: u32, + pub anomaly_alerts: u32, + pub chargeback_predictions: u32, + pub high_risk_subscribers: u32, + pub recent_cases: Vec, } /// Storage keys for the proxy contract state. diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index 38713fb..d136ef0 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -26,8 +26,7 @@ import ErrorDashboardScreen from '../screens/ErrorDashboardScreen'; import ImportScreen from '../screens/ImportScreen'; import ExportScreen from '../screens/ExportScreen'; import AdminDashboardScreen from '../screens/AdminDashboardScreen'; -import InvoiceListScreen from '../screens/InvoiceListScreen'; -import InvoiceDetailScreen from '../screens/InvoiceDetailScreen'; +import FraudDashboard from '../screens/FraudDashboard'; import { SegmentManagementScreen } from '../screens/SegmentManagementScreen'; import { SegmentDetailScreen } from '../screens/SegmentDetailScreen'; import { GamificationScreen } from '../screens/GamificationScreen'; @@ -178,35 +177,10 @@ const SettingsStack = () => ( component={ErrorDashboardScreen} options={{ title: 'Error Dashboard', headerShown: true }} /> - - - - - ); diff --git a/src/navigation/types.ts b/src/navigation/types.ts index f144e67..ea356c5 100644 --- a/src/navigation/types.ts +++ b/src/navigation/types.ts @@ -27,12 +27,7 @@ export type RootStackParamList = { SegmentManagement: undefined; SegmentDetail: { segmentId: string }; Gamification: undefined; - RevenueReport: undefined; - UsageDashboard: { subscriptionId: string; planId: string; name: string }; - MerchantOnboarding: undefined; - AffiliateDashboard: undefined; - LoyaltyDashboard: undefined; - CampaignManagement: undefined; + FraudDashboard: undefined; }; export type TabParamList = { diff --git a/src/screens/FraudDashboard.tsx b/src/screens/FraudDashboard.tsx new file mode 100644 index 0000000..f26dd4b --- /dev/null +++ b/src/screens/FraudDashboard.tsx @@ -0,0 +1,540 @@ +import React, { useMemo } from 'react'; +import { + SafeAreaView, + ScrollView, + StyleSheet, + Text, + TouchableOpacity, + View, + useWindowDimensions, +} from 'react-native'; +import { Card } from '../components/common/Card'; +import { Button } from '../components/common/Button'; +import { colors, spacing, typography, borderRadius } from '../utils/constants'; +import { useFraudStore } from '../store/fraudStore'; +import { FraudAction } from '../types/fraud'; + +const actionPalette: Record = { + approve: colors.success, + flag: colors.warning, + block: colors.error, +}; + +const FraudDashboard: React.FC = () => { + const { width } = useWindowDimensions(); + const isWide = width >= 980; + const { + merchants, + subscriptions, + assessments, + reviewQueue, + analytics, + refreshFraudSignals, + assessRisk, + approveSubscription, + blockSubscription, + resolveCase, + getFraudReport, + } = useFraudStore(); + + const highlightedReports = useMemo( + () => merchants.map((merchant) => getFraudReport(merchant.id)), + [merchants, getFraudReport] + ); + + const topRiskSubscriptions = useMemo( + () => [...subscriptions].sort((a, b) => b.riskScore - a.riskScore).slice(0, 5), + [subscriptions] + ); + + const renderMetric = (label: string, value: string, hint: string, color: string) => ( + + + {value} + {label} + {hint} + + ); + + return ( + + + + + Fraud Control Center + + Risk scoring, velocity checks, usage anomaly detection, chargeback prediction, and a + manual review queue for subscription operations. + + + +