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¶
- Deposit → User deposits via multi-network TEST address (Tron/EVM/Solana)
- Auto-Invest (Optional) → If
auto_invest_enabled=true, automatically creates investment with selected strategy - Investment Status → Immediately "active" (no pending, no blockchain interaction)
- Yield Config → APY configured in
config/limits.yaml(5%/10%/20%) - Withdrawal → From general balance (not specific investment)
Database Changes¶
Users Table (Auto-Invest Fields):
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)
}
Related Documentation¶
API Endpoints Reference¶
POST /api/investments- Create investmentGET /api/investments- List user's investmentsGET /api/investments/{id}- Get investment detailsGET /api/investments/{id}/value- Get current value with interestPOST /api/investments/{id}/unstake- Unstake investment
📋 Метаданные¶
Версия: 2.4.82
Обновлено: 2025-10-21
Статус: Published