Перейти к содержанию

Investment Lifecycle Architecture

Обзор

UPDATED: 2025-11-03 - Mobile-First MVP

Документация жизненного цикла инвестиций в Saga DeFi Platform - mobile-first MVP с auto-approve flow и multi-network test support.

Investment Flow Overview

High-Level Architecture (Mobile-First MVP)

┌─────────────────────────────────────────────────────────────┐
│              Mobile-First Investment Lifecycle               │
│                                                              │
│  [User Deposits (Optional: Auto-Invest Enabled)]            │
│       │                                                      │
│       ▼                                                      │
│  ┌──────────┐  Auto-Approve  ┌──────────┐  Yield   ┌──────────┐
│  │  CREATE  │───────────────>│  ACTIVE  │─────────>│COMPLETED │
│  └──────────┘   Immediate     └──────────┘  Config  └──────────┘
│                                     │                       │
│                                     │ User Withdraws       │
│                                     ▼                       │
│                               ┌──────────┐                 │
│                               │WITHDRAWN │<────────────────┘
│                               └──────────┘
│  KEY CHANGES:
│  - NO PENDING STATE: Investments immediately "active"
│  - NO BLOCKCHAIN INTERACTION: No staking/unstaking
│  - AUTO-INVEST: Automatic investment creation on deposit
│  - WITHDRAWAL: From general balance (not specific investment)
└─────────────────────────────────────────────────────────────┘

Legacy vs Mobile-First Comparison

Feature Legacy (Pre-2025-11-03) Mobile-First MVP (2025-11-03)
Status Flow pending → active → unstaked CREATE → active → completed
Approval Manual admin approval Auto-approve (immediate active)
Blockchain Direct staking interaction NO blockchain interaction
Deposit Addresses HD Wallet (BIP44) Multi-network TEST addresses
Auto-Invest Not supported Automatic investment on deposit
Withdrawal From specific investment From general balance
Networks Localhost only (chain_id 1337) Tron/EVM/Solana (TEST mode)

Mobile-First MVP Implementation (2025-11-03)

Key Files

Backend: - backend/shared/routing/user_api_router.go - Auto-invest deposit handler (lines ~550-580) - backend/shared/services/canonical/investment_service.go - Auto-approve logic (status="active") - backend/shared/services/multi_network_test_provider.go - Multi-network TEST address generation

Frontend: - frontend/user-app/src/components/dashboard/MobileDashboard.tsx - Main mobile-first container - frontend/user-app/src/components/dashboard/DepositSection.tsx - Multi-network deposits + auto-invest toggle - frontend/user-app/src/components/dashboard/WithdrawalSection.tsx - Withdrawal from general balance - frontend/user-app/src/components/dashboard/PortfolioSection.tsx - Portfolio overview with strategy chart - frontend/user-app/src/components/modals/TransactionDetailsModal.tsx - Investment details modal

MVP Flow

  1. Deposit → User deposits via multi-network TEST address (Tron/EVM/Solana)
  2. Auto-Invest (Optional) → If auto_invest_enabled=true, automatically creates investment with selected strategy
  3. Investment Status → Immediately "active" (no pending, no blockchain interaction)
  4. Yield Config → APY configured in config/limits.yaml (5%/10%/20%)
  5. Withdrawal → From general balance (not specific investment)

Database Changes

Users Table (Auto-Invest Fields):

auto_invest_enabled BOOLEAN DEFAULT TRUE,
auto_invest_strategy VARCHAR(20) DEFAULT 'conservative'

Investments Table (Status Flow): - CREATE → status='active' (immediate, no pending) - User withdrawal → status='completed'

Multi-Network Support: - wallets.network_id INTEGER (1=Tron, 2=EVM, 3=Solana) - TEST address formats documented in docs/developers/testing/multi-network-test-addresses.md


Legacy Architecture Reference (Pre-2025-11-03)

⚠️ NOTE: The sections below document the LEGACY investment lifecycle with blockchain interaction, pending states, and manual admin approval. This architecture was replaced by the Mobile-First MVP on 2025-11-03. Kept for reference.

Investment Strategies

Available APY Options

Saga предлагает 3 стратегии инвестирования с разными уровнями доходности:

Стратегия APY Smart Contract stToken Минимум Максимум
Conservative 5% StakingProtocol5 stUSDC5 $10 $10,000
Balanced 10% StakingProtocol10 stUSDC10 $10 $10,000
Aggressive 20% StakingProtocol20 stUSDC20 $10 $10,000

Configuration в UnifiedConfig:

# config/limits.yaml
limits:
  investment_strategies:
    - id: 1
      name: "Conservative"
      apy: 5.0
      contract_address: "0x2279B7A0a67DB372996a5FaB50D91eAA73d2eBe6"
      st_token_address: "0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0"
      min_amount: "10.00"
      max_amount: "10000.00"

    - id: 2
      name: "Balanced"
      apy: 10.0
      contract_address: "0x610178dA211FEF7D417bC0e6FeD39F05609AD788"
      st_token_address: "0xDc64a140Aa3E981100a9becA4E685f962f0cF6C9"
      min_amount: "10.00"
      max_amount: "10000.00"

    - id: 3
      name: "Aggressive"
      apy: 20.0
      contract_address: "0xA51c1fc2f0D1a1b8494Ed1FE312d7C3a78Ed91C0"
      st_token_address: "0x0165878A594ca255338adfa4d48449f69242Eb8F"
      min_amount: "10.00"
      max_amount: "10000.00"

Lifecycle Stages

1. Strategy Selection (Frontend)

User Interface:

// frontend/user-app/src/components/InvestmentStrategies.tsx
const InvestmentStrategies = () => {
  const strategies = [
    { id: 1, name: 'Conservative', apy: 5, risk: 'Low' },
    { id: 2, name: 'Balanced', apy: 10, risk: 'Medium' },
    { id: 3, name: 'Aggressive', apy: 20, risk: 'High' }
  ];

  return (
    <div className="strategies-grid">
      {strategies.map(strategy => (
        <StrategyCard
          key={strategy.id}
          strategy={strategy}
          onSelect={() => handleSelectStrategy(strategy.id)}
        />
      ))}
    </div>
  );
};

2. Investment Creation (Pending State)

Frontend Request:

// User submits investment
const createInvestment = async (strategyId: number, amount: string) => {
  const response = await fetch('/api/investments', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${jwtToken}`
    },
    body: JSON.stringify({
      strategy_id: strategyId,
      amount: amount,
      currency: 'USDC'
    })
  });

  return response.json(); // { id, status: "pending", ... }
};

Backend Handler:

// backend/shared/routing/investment_handler.go
func (h *InvestmentHandler) CreateInvestment(w http.ResponseWriter, r *http.Request) {
    var req InvestmentRequest
    json.NewDecoder(r.Body).Decode(&req)

    // 1. Валидация amount через canonical validator
    validationResult := canonical.ValidateInvestmentAmount(
        req.Amount,
        req.Currency,
        req.StrategyID,
    )

    if !validationResult.IsValid {
        http_helpers.SendError(w, validationResult.ErrorMessage, http.StatusBadRequest)
        return
    }

    // 2. Проверка баланса пользователя
    userID := r.Context().Value("user_id").(int64)
    balance, err := h.balanceService.GetUserBalance(ctx, userID)
    if balance.LessThan(req.Amount) {
        http_helpers.SendError(w, "Insufficient balance", http.StatusBadRequest)
        return
    }

    // 3. Создание investment через service
    investment, err := h.service.CreateInvestment(ctx, userID, req)
    if err != nil {
        http_helpers.SendError(w, err.Error(), http.StatusInternalServerError)
        return
    }

    http_helpers.SendSuccess(w, investment)
}

Service Layer:

// backend/shared/services/investment_service.go
func (s *InvestmentService) CreateInvestment(ctx context.Context, userID int64, req InvestmentRequest) (*Investment, error) {
    // 1. Загрузка strategy конфигурации
    strategy := s.config.GetInvestmentStrategy(req.StrategyID)
    if strategy == nil {
        return nil, errors.New("invalid strategy ID")
    }

    // 2. Создание investment записи в pending state
    investment := &Investment{
        UserID:     userID,
        StrategyID: req.StrategyID,
        Amount:     req.Amount,
        Currency:   req.Currency,
        APY:        strategy.APY,
        Status:     "pending",
        CreatedAt:  time.Now().UTC(),
    }

    // 3. Сохранение в БД
    err := s.repo.CreateInvestment(ctx, investment)
    if err != nil {
        return nil, err
    }

    // 4. Инициация blockchain staking (асинхронно)
    go s.blockchainService.StakeTokens(investment)

    logger.InfoStructured("Investment created",
        "investment_id", investment.ID,
        "user_id", userID,
        "amount", req.Amount.String(),
        "strategy_id", req.StrategyID,
        "status", "pending",
    )

    return investment, nil
}

3. Blockchain Staking (Pending → Active)

Blockchain Integration:

// backend/shared/services/blockchain_staking_service.go
func (s *BlockchainStakingService) StakeTokens(investment *Investment) {
    // 1. Подключение к blockchain
    client, err := ethclient.Dial(s.config.GetBlockchainRPCURL())
    if err != nil {
        s.markInvestmentFailed(investment, err)
        return
    }

    // 2. Загрузка USDC контракта
    usdcAddress := common.HexToAddress(s.config.GetUSDCContractAddress())
    usdc, err := contracts.NewTestUSDC(usdcAddress, client)
    if err != nil {
        s.markInvestmentFailed(investment, err)
        return
    }

    // 3. Загрузка strategy configuration
    strategy := s.config.GetInvestmentStrategy(investment.StrategyID)
    stakingAddress := common.HexToAddress(strategy.ContractAddress)
    staking, err := contracts.NewStakingProtocol(stakingAddress, client)
    if err != nil {
        s.markInvestmentFailed(investment, err)
        return
    }

    // 4. Approve USDC для staking контракта
    auth, err := s.getTransactionAuth(client, investment.UserID)
    if err != nil {
        s.markInvestmentFailed(investment, err)
        return
    }

    amount := investment.Amount.ToBigInt()
    approveTx, err := usdc.Approve(auth, stakingAddress, amount)
    if err != nil {
        s.markInvestmentFailed(investment, err)
        return
    }

    // Ожидание approval transaction
    approveReceipt, err := bind.WaitMined(ctx, client, approveTx)
    if err != nil || approveReceipt.Status != types.ReceiptStatusSuccessful {
        s.markInvestmentFailed(investment, errors.New("approval failed"))
        return
    }

    // 5. Stake USDC → получение stTokens
    stakeTx, err := staking.Stake(auth, amount)
    if err != nil {
        s.markInvestmentFailed(investment, err)
        return
    }

    // Ожидание stake transaction
    stakeReceipt, err := bind.WaitMined(ctx, client, stakeTx)
    if err != nil || stakeReceipt.Status != types.ReceiptStatusSuccessful {
        s.markInvestmentFailed(investment, errors.New("staking failed"))
        return
    }

    // 6. Обновление investment на active с blockchain данными
    investment.Status = "active"
    investment.StakeTxHash = stakeTx.Hash().Hex()
    investment.StakedAt = time.Now().UTC()
    investment.StTokenAddress = strategy.StTokenAddress

    s.repo.UpdateInvestment(ctx, investment)

    logger.InfoStructured("Investment staked successfully",
        "investment_id", investment.ID,
        "stake_tx_hash", stakeTx.Hash().Hex(),
        "st_token_address", strategy.StTokenAddress,
    )
}

func (s *BlockchainStakingService) markInvestmentFailed(investment *Investment, err error) {
    investment.Status = "failed"
    investment.ErrorMessage = err.Error()
    investment.FailedAt = time.Now().UTC()

    s.repo.UpdateInvestment(context.Background(), investment)

    logger.ErrorStructured("Investment staking failed",
        "investment_id", investment.ID,
        "error", err.Error(),
    )
}

4. Interest Accrual (Active State)

Compound Interest Calculation:

Smart contracts реализуют continuous compound interest через simulateDay() функцию:

// blockchain/contracts/StakingProtocol.sol
function simulateDay() external {
    uint256 totalStaked = usdcToken.balanceOf(address(this));
    uint256 interest = (totalStaked * apy) / 365 / 100;

    // Mint новые USDC для покрытия interest
    usdcToken.mint(address(this), interest);

    // Exchange rate обновляется автоматически через rebase
    // stToken holders получают пропорциональный интерес
}

Backend Query для Current Value:

// backend/shared/services/investment_value_service.go (Integration-Only)
func (s *InvestmentValueService) GetCurrentValue(ctx context.Context, investment *Investment) (SafeDecimal, error) {
    // Integration-Only: NO BLOCKCHAIN INTERACTION
    // Current value calculated from internal records + yield configuration

    // 1. Get base investment amount from database
    baseAmount := investment.Amount

    // 2. Calculate time-based yield from internal configuration
    createdTime := investment.CreatedAt
    currentTime := time.Now()
    daysPassed := currentTime.Sub(createdTime).Hours() / 24

    // 3. Apply yield rate from strategy configuration
    strategy, err := s.strategyRepo.GetByID(ctx, investment.StrategyID)
    if err != nil {
        return SafeDecimal{}, err
    }

    // 4. Calculate compound interest (NO blockchain calls)
    // Formula: A = P(1 + r/n)^(nt)
    // Where: P = principal, r = annual rate, t = time in years, n = compounding frequency
    annualRate := strategy.YieldRate.Float64() / 100 // Convert percentage
    dailyRate := annualRate / 365                    // Daily compounding
    compound := math.Pow(1+dailyRate, daysPassed)

    // 5. Calculate current value
    currentValue := baseAmount.Float64() * compound

    // ❌ NO getUserWalletAddress() - Integration-Only architecture
    // ❌ NO smart contract calls - All data from internal database
    return SafeDecimalFromFloat64(currentValue, 6), nil
}

5. Maturity & Unstaking

User Initiates Unstake:

// Frontend unstake request
const unstakeInvestment = async (investmentId: number) => {
  const response = await fetch(`/api/investments/${investmentId}/unstake`, {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${jwtToken}`
    }
  });

  return response.json();
};

Backend Unstaking:

// backend/shared/routing/investment_handler.go
func (h *InvestmentHandler) UnstakeInvestment(w http.ResponseWriter, r *http.Request) {
    investmentID := chi.URLParam(r, "id")
    userID := r.Context().Value("user_id").(int64)

    // Unstake через service
    investment, err := h.service.UnstakeInvestment(ctx, investmentID, userID)
    if err != nil {
        http_helpers.SendError(w, err.Error(), http.StatusInternalServerError)
        return
    }

    http_helpers.SendSuccess(w, investment)
}

Investment Withdrawal (Integration-Only):

func (s *InvestmentService) UnstakeInvestment(ctx context.Context, investmentID string, userID int64) (*Investment, error) {
    // Integration-Only: NO BLOCKCHAIN INTERACTION
    // Withdrawal handled through Fordefi enterprise custody

    // 1. Load investment
    investment, err := s.repo.GetInvestmentByID(ctx, investmentID)
    if err != nil {
        return nil, err
    }

    // Security check
    if investment.UserID != userID {
        return nil, errors.New("unauthorized")
    }

    if investment.Status != "active" {
        return nil, errors.New("investment is not active")
    }

    // 2. Calculate final value for withdrawal (NO blockchain calls)
    currentValue, err := s.GetCurrentValue(ctx, investment)
    if err != nil {
        return nil, err
    }

    // 3. Create withdrawal request via Fordefi API
    withdrawalRequest := &FordefiWithdrawalRequest{
        UserID:        userID,
        Amount:        currentValue,
        Currency:      investment.Currency,
        InvestmentID:  investmentID,
        RequestedAt:   time.Now().UTC(),
    }

    withdrawalID, err := s.fordefiClient.CreateWithdrawal(ctx, withdrawalRequest)
    if err != nil {
        return nil, fmt.Errorf("failed to create withdrawal: %v", err)
    }

    // 4. Update investment status (completed, not unstaked)
    investment.Status = "completed"
    investment.CompletedAt = time.Now().UTC()
    investment.WithdrawalID = withdrawalID // Fordefi reference

    err = s.repo.UpdateInvestment(ctx, investment)
    if err != nil {
        return nil, err
    }

    // 5. Update user balance (add withdrawn amount)
    err = s.balanceService.AddBalance(ctx, userID, currentValue, investment.Currency)
    if err != nil {
        return nil, err
    }

    logger.InfoStructured("Investment withdrawal initiated",
        "investment_id", investmentID,
        "withdrawal_id", withdrawalID,
        "amount", currentValue.String(),
        "currency", investment.Currency,
    )

    // ❌ NO getUserWalletAddress() - Integration-Only architecture
    // ❌ NO blockchain transactions - All via Fordefi API
    return investment, nil
}

Database Schema

CREATE TABLE investments (
    id BIGSERIAL PRIMARY KEY,
    user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    strategy_id INT NOT NULL,
    amount NUMERIC(78,0) NOT NULL, -- Initial investment (SafeDecimal)
    currency VARCHAR(10) NOT NULL DEFAULT 'USDC',
    apy NUMERIC(5,2) NOT NULL, -- Annual Percentage Yield
    status VARCHAR(20) NOT NULL DEFAULT 'pending',

    -- Blockchain data
    stake_tx_hash VARCHAR(66),
    unstake_tx_hash VARCHAR(66),
    st_token_address VARCHAR(42),

    -- Timestamps
    created_at TIMESTAMP NOT NULL DEFAULT NOW(),
    staked_at TIMESTAMP,
    unstaked_at TIMESTAMP,
    failed_at TIMESTAMP,

    -- Error handling
    error_message TEXT,

    updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);

-- Индексы
CREATE INDEX idx_investments_user_id ON investments(user_id);
CREATE INDEX idx_investments_status ON investments(status);
CREATE INDEX idx_investments_strategy_id ON investments(strategy_id);

Testing

Integration Test Example

// backend/tests/integration/investment_lifecycle_test.go
func TestInvestmentLifecycle(t *testing.T) {
    server := setupTestServer()
    defer server.Close()

    // 1. Create investment
    investmentReq := InvestmentRequest{
        StrategyID: 2, // Balanced (10% APY)
        Amount:     SafeDecimalFromString("100.00"),
        Currency:   "USDC",
    }

    resp := makeAuthRequest(server, "POST", "/api/investments", investmentReq, userToken)
    var investment Investment
    json.Unmarshal(resp.Body, &investment)

    assert.Equal(t, "pending", investment.Status)
    assert.Equal(t, int64(2), investment.StrategyID)

    // 2. Wait for blockchain staking (~10 seconds)
    time.Sleep(15 * time.Second)

    // 3. Check status updated to active
    getResp := makeAuthRequest(server, "GET",
        fmt.Sprintf("/api/investments/%d", investment.ID),
        nil, userToken)

    var activeInvestment Investment
    json.Unmarshal(getResp.Body, &activeInvestment)
    assert.Equal(t, "active", activeInvestment.Status)
    assert.NotEmpty(t, activeInvestment.StakeTxHash)

    // 4. Simulate interest accrual (blockchain simulateDay)
    // ... call simulateDay() on staking contract ...

    // 5. Get current value (should be > initial amount)
    valueResp := makeAuthRequest(server, "GET",
        fmt.Sprintf("/api/investments/%d/value", investment.ID),
        nil, userToken)

    var value InvestmentValue
    json.Unmarshal(valueResp.Body, &value)
    assert.True(t, value.CurrentValue.GreaterThan(investment.Amount))

    // 6. Unstake investment
    unstakeResp := makeAuthRequest(server, "POST",
        fmt.Sprintf("/api/investments/%d/unstake", investment.ID),
        nil, userToken)

    var unstakedInvestment Investment
    json.Unmarshal(unstakeResp.Body, &unstakedInvestment)
    assert.Equal(t, "unstaked", unstakedInvestment.Status)
    assert.NotEmpty(t, unstakedInvestment.UnstakeTxHash)
}

API Endpoints Reference

  • POST /api/investments - Create investment
  • GET /api/investments - List user's investments
  • GET /api/investments/{id} - Get investment details
  • GET /api/investments/{id}/value - Get current value with interest
  • POST /api/investments/{id}/unstake - Unstake investment



📋 Метаданные

Версия: 2.4.82

Обновлено: 2025-10-21

Статус: Published