Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
176 changes: 34 additions & 142 deletions PR_DESCRIPTION.md
Original file line number Diff line number Diff line change
@@ -1,156 +1,48 @@
## Summary

Implements a DAO-style governance system for the SwapTrade backend, enabling token holders to create proposals, vote token-weightedly, delegate voting power, and automatically execute passed proposals.
Implements robust circuit breakers, bulkheading patterns, graceful degradation, failover mechanisms, and automated recovery procedures across all external service calls to prevent cascading failures and ensure system stability.

## Architecture
## What Changed

### Domain Layer (`src/governance/`)
- **Registered `CircuitBreakerRecoveryService`** in `ErrorHandlingModule` to enable automated recovery of open circuit breakers via cron-based health checks
- **Added circuit breaker + bulkhead to `EmailService`** (SMTP/nodemailer) with graceful degradation — returns `false` instead of throwing when email delivery fails
- **Added circuit breaker + bulkhead to `SmsService`** (Twilio) with graceful degradation — returns `false` instead of throwing when SMS delivery fails, and degrades when Twilio client is not initialized
- **Added failover mechanism to `NotificationProcessor`** — when email or SMS fails, automatically attempts push notification delivery as a fallback channel
- **Updated `NotificationsModule`** to provide `CircuitBreakerService`, `BulkheadService`, and `CorrelationIdService`
- **Completed circuit breaker coverage for blockchain services** (Ethereum RPC, Stellar Horizon) with bulkhead isolation
- **Completed circuit breaker coverage for FCM mobile push** with graceful degradation
- **Added unit tests** for `CircuitBreakerService` (register, execute, getState, getMetrics, reset)
- **Added unit tests** for `BulkheadService` (createBulkhead, execute, getMetrics, resetMetrics, removeBulkhead)
- **Added chaos engineering tests** verifying cascading failure prevention, component isolation, graceful degradation, automated recovery, and failover under failure conditions

```
src/governance/
├── entities/
│ ├── governance-proposal.entity.ts
│ ├── governance-vote.entity.ts
│ ├── governance-discussion.entity.ts
│ ├── vote-delegation.entity.ts
│ ├── governance-execution.entity.ts
│ ├── governance-config.entity.ts
│ └── token-holding.entity.ts
├── enums/governance.enum.ts
├── constants/governance.constants.ts
├── dto/governance.dto.ts
├── services/
│ ├── governance.service.ts
│ ├── index.ts
│ └── governance.service.spec.ts
├── controllers/governance.controller.ts
└── governance.module.ts
```
## Why

### Technology Stack
Without circuit breakers and bulkheads, failures in external services (SMTP, Twilio, Ethereum RPC, Stellar Horizon, FCM) cascade through the system, causing widespread outages. This implementation ensures:

| Category | Technology |
|----------|-----------|
| Language | TypeScript 5.7 |
| Framework | NestJS 11 |
| ORM | TypeORM 0.3.x |
| Database | SQLite (dev) / PostgreSQL (prod) |
| Validation | class-validator |
| Testing | Jest 30 + ts-jest |
| Documentation | Swagger / OpenAPI |
- Failing components are isolated from the rest of the system
- The system degrades gracefully (returns fallback values) rather than failing completely
- Notifications failover to alternative channels when primary channels are unavailable
- Circuit breakers automatically recover after a cooldown period

### Entities
## Testing Performed

| Entity | Description |
|--------|-------------|
| `GovernanceProposal` | Proposal metadata, vote tallies, quorum, timing, execution payload |
| `GovernanceVote` | Individual votes with token weight, delegation info, and reason |
| `GovernanceDiscussion` | Threaded discussion (comments, amendments, informational) per proposal |
| `VoteDelegation` | Delegation records with term and active status |
| `GovernanceExecution` | Execution attempts with status, tx hash, and error log |
| `GovernanceConfig` | Mutable governance parameters (voting period, threshold, quorum, etc.) |
| `TokenHolding` | User token balances used for voting power |
- [x] Unit tests for `CircuitBreakerService` (8 test cases)
- [x] Unit tests for `BulkheadService` (9 test cases)
- [x] Chaos engineering resilience tests (12 test cases across 6 scenarios)
- [x] Lint
- [x] Build

### Services
## Edge Cases Considered

| Service | Responsibilities |
|---------|-----------------|
| `GovernanceService` | Core domain logic: token balance lookup, effective voting power (incl. delegations), quorum calculation, proposal pass/fail determination, vote eligibility, history queries |
| `ProposalManagementService` | Proposal lifecycle: `createProposal`, `approveProposal`, `cancelProposal`, `finalizeProposal` |
| `VotingService` | `castVote` with token-weight aggregation, anti-double-vote, anti-self-vote enforcement |
| `DelegationService` | `delegateVotes`, `revokeDelegation`, active/received delegation lookups |
| `DiscussionService` | `addMessage`, `getDiscussionThread` for proposal discussions |
| `ProposalExecutionService` | `executeProposal` for automatic execution of passed proposals with success/failure tracking |
- Circuit breaker opens after threshold failures and prevents cascading calls to failing services
- Bulkhead limits concurrent requests to prevent resource exhaustion
- Email/SMS services return `false` (not throw) when circuit is open — enabling graceful degradation
- Notification failover records the fallback channel in notification metadata
- Twilio client not initialized returns `false` instead of throwing
- Automated recovery service monitors and resets open circuit breakers after cooldown

### Enums & Constants
## Risks

- `ProposalType`: `PARAMETER_CHANGE`, `FEE_STRUCTURE`, `NEW_FEATURE`, `TREASURY`, `EMERGENCY`, `UPGRADE`
- `ProposalStatus`: `PENDING`, `ACTIVE`, `PASSED`, `EXECUTED`, `DEFEATED`, `CANCELLED`
- `VoteType`: `FOR`, `AGAINST`, `ABSTAIN`
- `DiscussionMessageType`: `COMMENT`, `AMENDMENT`, `INFORMATIONAL`
- `ExecutionStatus`: `PENDING`, `SUCCESS`, `FAILED`
None. All changes are additive — existing API contracts are preserved. Circuit breakers and bulkheads are internal infrastructure that do not change external API behavior.

Default Configuration:

| Key | Default Value | Description |
|-----|---------------|-------------|
| `VOTING_PERIOD_DAYS` | 7 | Standard proposal voting duration |
| `EMERGENCY_VOTING_PERIOD_DAYS` | 1 | Fast-track emergency proposal duration |
| `MIN_TOKEN_THRESHOLD` | 10,000 | Minimum tokens required to create a proposal |
| `QUORUM_PERCENTAGE` | 40% | Minimum participation of total supply |
| `PASS_THRESHOLD_PERCENTAGE` | 50% | Minimum winning vote percentage |
| `DELEGATION_PERIOD_DAYS` | 30 | Default delegation term |
| `MAX_PROPOSAL_TITLE_LENGTH` | 200 | Maximum proposal title characters |

### API Controllers

| Controller | Endpoints | Auth |
|------------|-----------|------|
| `GovernanceProposalController` | `POST /governance/proposals`, `GET /governance/proposals`, `GET /governance/proposals/:id`, `POST /governance/proposals/:id/approve`, `POST /governance/proposals/:id/cancel`, `POST /governance/proposals/:id/vote`, `POST /governance/proposals/:id/finalize`, `POST /governance/proposals/:id/discussions`, `GET /governance/proposals/:id/discussions` | JWT |
| `GovernanceDelegationController` | `POST /governance/delegation/delegate`, `POST /governance/delegation/revoke/:delegationId`, `GET /governance/delegation/my-delegations`, `GET /governance/delegation/received/:delegateeId` | JWT |
| `GovernanceHistoryController` | `GET /governance/history/voting`, `GET /governance/history/proposals` | JWT |
| `GovernanceConfigController` | `GET /governance/config` | JWT |

### Database Migration

`1750000000000-CreateGovernanceTables.ts` creates:

| Table | Key Columns |
|-------|-------------|
| `token_holdings` | userId, assetId, balance, lockedBalance |
| `governance_configs` | key (unique), value, description, updatedBy |
| `governance_proposals` | id (uuid), proposerId, type, title, description, status, forVotes, againstVotes, abstainVotes, quorumVotes, totalSupply, votingPeriodDays, startTime, endTime, executedAt |
| `governance_votes` | id (uuid), proposalId, voterId, voteType, weight, balanceAtVote, delegateTo, timestamp, reason (unique: proposalId + voterId) |
| `governance_discussions` | id (uuid), proposalId, authorId, messageType, content |
| `governance_executions` | id (uuid), proposalId, executionStatus, executedBy, transactionHash, errorMessage, executedAt |
| `vote_delegations` | id (uuid), delegatorId, delegateeId, proposalId (nullable), delegatedBalance, isActive, startTime, endTime (unique: delegatorId + delegateeId + isActive) |

### Business Rules Enforced

1. **Minimum Token Threshold**: Proposers must hold ≥ `MIN_TOKEN_THRESHOLD` tokens to create a proposal.
2. **Quorum**: `(forVotes + againstVotes + abstainVotes) >= (totalSupply * quorumPercentage / 100)`
3. **Pass Threshold**: `forVotes / totalVotes >= passThresholdPercentage` (only evaluated if quorum is met).
4. **Voting Periods**: Configurable per proposal type; emergency proposals use 1-day period.
5. **Anti-Double-Vote**: One vote per user per proposal.
6. **Anti-Self-Vote**: Proposers cannot vote on their own proposals.
7. **Token-Weighted Voting**: Vote weight = user token balance + sum of active, non-expired delegated balances.
8. **Delegation**: Cannot delegate to self; active delegations are exclusive per delegatee per delegator.
9. **Execution Safety**: Only `PASSED` proposals may be executed; execution state transitions to `EXECUTED` or back to `PASSED` on failure.

### Testing

`src/governance/services/governance.service.spec.ts` covers:

| Test Suite | Cases |
|------------|-------|
| `getVotingPeriodDays` | Returns default 7 |
| `getMinTokenThreshold` | Returns 10000 |
| `getQuorumPercentage` | Returns 40 |
| `getPassThresholdPercentage` | Returns 50 |
| `getMaxTitleLength` | Returns 200 |
| `getTokenBalance` | Found and not-found holdings |
| `canUserVote` | Allowed, own proposal, double vote |
| `checkQuorumMet` | Met and not-met scenarios |
| `proposalHasPassed` | Pass and quorum-fail |
| `getProposalHistory` | Ordered by date descending |
| `getVotingHistory` | User-specific votes |

### Module Registration

- `GovernanceModule` imported into `AppModule` alongside `InfrastructureModule` and `IdentityModule`
- All governance entities registered in `TypeOrmModule.forRoot` entity arrays in `AppModule`
- Migration file placed at `src/database/migrations/1750000000000-CreateGovernanceTables.ts`

## Acceptance Criteria Mapping

| Criterion | Implementation |
|-----------|---------------|
| Proposals require minimum token threshold to create | `ProposalManagementService.createProposal` checks balance ≥ `MIN_TOKEN_THRESHOLD` |
| Quorum requirements are enforced for all votes | `GovernanceService.checkQuorumMet` and `proposalHasPassed` |
| Voting periods are configurable (default 7 days) | `GovernanceService.getVotingPeriodDays` / `getEmergencyVotingPeriodDays` |
| Executable proposals modify system parameters automatically | `ProposalExecutionService.executeProposal` transitions status to `EXECUTED` |
| All governance actions are logged on-chain for transparency | `GovernanceExecution` records every execution attempt with tx hash |
| Users can delegate their voting power to representatives | `DelegationService.delegateVotes` / `revokeDelegation` |
| Complete governance history is publicly accessible | `GovernanceHistoryController` exposes voting and proposal history |

closes #387
Closes #403
14 changes: 12 additions & 2 deletions src/blockchain/blockchain.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,20 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { BlockchainTransaction } from './entities/blockchain-transaction.entity';
import { WalletAddress } from './entities/wallet-address.entity';
import { StellarService } from './services/stellar.service';
import { EthereumService } from './services/ethereum.service';
import { CircuitBreakerService } from '../common/services/circuit-breaker.service';
import { BulkheadService } from '../common/services/bulkhead.service';
import { CorrelationIdService } from '../common/services/correlation-id.service';

@Module({
imports: [TypeOrmModule.forFeature([BlockchainTransaction, WalletAddress])],
providers: [StellarService],
exports: [StellarService],
providers: [
StellarService,
EthereumService,
CircuitBreakerService,
BulkheadService,
CorrelationIdService,
],
exports: [StellarService, EthereumService, CircuitBreakerService, BulkheadService],
})
export class BlockchainModule {}
58 changes: 56 additions & 2 deletions src/blockchain/services/ethereum.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Injectable, Logger } from '@nestjs/common';
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
Expand All @@ -11,6 +11,8 @@ import {
} from '../entities/blockchain-transaction.entity';
import { WalletAddress } from '../entities/wallet-address.entity';
import { BlockchainException } from '../../error/exceptions/blockchain.exception';
import { CircuitBreakerService, CircuitBreakerOptions } from '../../common/services/circuit-breaker.service';
import { BulkheadService, BulkheadConfig } from '../../common/services/bulkhead.service';

// Minimal ERC-20 ABI for transfer event decoding
const ERC20_ABI = [
Expand All @@ -19,17 +21,21 @@ const ERC20_ABI = [
];

@Injectable()
export class EthereumService {
export class EthereumService implements OnModuleInit {
private readonly logger = new Logger(EthereumService.name);
private readonly provider: ethers.JsonRpcProvider;
private readonly usdcAddress: string;
private readonly circuitBreakerName = 'ethereum-rpc';
private readonly bulkheadName = 'ethereum-bulkhead';

constructor(
private readonly configService: ConfigService,
@InjectRepository(BlockchainTransaction)
private readonly txRepo: Repository<BlockchainTransaction>,
@InjectRepository(WalletAddress)
private readonly walletRepo: Repository<WalletAddress>,
private readonly circuitBreakerService: CircuitBreakerService,
private readonly bulkheadService: BulkheadService,
) {
const rpcUrl = this.configService.get<string>(
'ETHEREUM_RPC_URL',
Expand All @@ -42,6 +48,38 @@ export class EthereumService {
this.provider = new ethers.JsonRpcProvider(rpcUrl);
}

onModuleInit() {
// Register circuit breaker for Ethereum RPC calls
const circuitBreakerOptions: CircuitBreakerOptions = {
name: this.circuitBreakerName,
timeout: 30000,
errorThresholdPercentage: 50,
volumeThreshold: 10,
rollingCountTimeout: 60000,
rollingCountBuckets: 10,
fallback: async (error: Error, ...args: any[]) => {
this.logger.error(`Ethereum RPC circuit breaker fallback triggered: ${error.message}`);
throw BlockchainException.networkError({ error: 'Ethereum service unavailable', details: error.message });
},
};

this.circuitBreakerService.register(
async () => ({ success: true }),
circuitBreakerOptions,
);

// Create bulkhead for Ethereum operations
const bulkheadConfig: BulkheadConfig = {
name: this.bulkheadName,
maxConcurrent: 5,
maxQueueSize: 20,
timeout: 60000,
};

this.bulkheadService.createBulkhead(bulkheadConfig);
this.logger.log('Ethereum service initialized with circuit breaker and bulkhead');
}

/** Validate an Ethereum address format */
isValidAddress(address: string): boolean {
return ethers.isAddress(address);
Expand All @@ -68,6 +106,22 @@ export class EthereumService {
async verifyDeposit(
userId: string,
txHash: string,
): Promise<BlockchainTransaction> {
return this.bulkheadService.execute(
this.bulkheadName,
async () => {
return this.circuitBreakerService.execute(
this.circuitBreakerName,
async () => this.verifyDepositInternal(userId, txHash),
);
},
'verifyDeposit',
);
}

private async verifyDepositInternal(
userId: string,
txHash: string,
): Promise<BlockchainTransaction> {
const existing = await this.txRepo.findOne({ where: { txHash } });
if (existing) return existing;
Expand Down
Loading
Loading