Skip to content
Open
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
5 changes: 4 additions & 1 deletion backend/jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,8 @@ export default {
"^(\\.{1,2}/.*)\\.js$": "$1",
},
testMatch: ["**/*.test.ts", "**/*.spec.ts"],
setupFilesAfterEnv: ["<rootDir>/src/tests/jest.setup.ts"],
// setupFilesAfterEnv: ["<rootDir>/src/tests/jest.setup.ts"],

// 👇 Point to the actual location of setup.ts
setupFilesAfterEnv: ["<rootDir>/src/__tests__/setup.ts"],
};
20 changes: 20 additions & 0 deletions backend/src/__tests__/setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { db } from "../src/db/connection";
import Redis from "ioredis";

let redis: Redis;

beforeAll(async () => {
redis = new Redis(process.env.REDIS_URL);
if (db.connect) {
await db.connect();
}
});

afterAll(async () => {
if (redis) {
await redis.quit();
}
if (db.end) {
await db.end();
}
});
17 changes: 17 additions & 0 deletions backend/src/db/migrations/20260329_add_loan_events_indexes.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
-- Migration: Add missing indexes on loan_events table

-- Index for borrower history queries
CREATE INDEX IF NOT EXISTS idx_loan_events_borrower
ON loan_events(borrower);

-- Index for filtering by event type
CREATE INDEX IF NOT EXISTS idx_loan_events_event_type
ON loan_events(event_type);

-- Composite index for loan detail queries
CREATE INDEX IF NOT EXISTS idx_loan_events_loan_id_event_type
ON loan_events(loan_id, event_type);

-- Index for date range filters
CREATE INDEX IF NOT EXISTS idx_loan_events_created_at
ON loan_events(created_at);
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-- Migration: Add unique constraint to prevent duplicate loan events

ALTER TABLE loan_events
ADD CONSTRAINT unique_loan_event
UNIQUE (transaction_hash, event_index);
141 changes: 141 additions & 0 deletions backend/src/services/eventIndexer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import { eventStreamService } from "./eventStreamService.js";
import { notificationService, type NotificationType } from "./notificationService.js";
import { sorobanService } from "./sorobanService.js";
import { updateUserScoresBulk } from "./scoresService.js";
import { subscribeToContractEvents } from "./chainListener";
import db from "../db";

interface SorobanRawEvent {
id: string;
Expand Down Expand Up @@ -88,6 +90,125 @@ export class EventIndexer {
this.batchSize = configOrRpcUrl.batchSize ?? 100;
}

init() {
// Existing LoanManager subscription...
subscribeToContractEvents('LoanManager', this.handleLoanManagerEvent.bind(this));

// NEW: LendingPool events
subscribeToContractEvents('LendingPool', this.handleLendingPoolEvent.bind(this));

// NEW: RemittanceNFT events
subscribeToContractEvents('RemittanceNFT', this.handleRemittanceNFTEvent.bind(this));

// NEW: MultisigGovernance events
subscribeToContractEvents('MultisigGovernance', this.handleGovernanceEvent.bind(this));
}

async handleLendingPoolEvent(event: any) {
switch (event.type) {
case 'Deposit':
await db('lending_pool_deposits').insert({
userId: event.user,
amount: event.amount,
txHash: event.txHash,
blockNumber: event.blockNumber,
timestamp: new Date(),
});
break;
case 'Withdraw':
await db('lending_pool_withdrawals').insert({
userId: event.user,
amount: event.amount,
txHash: event.txHash,
blockNumber: event.blockNumber,
timestamp: new Date(),
});
break;
case 'EmergencyWithdraw':
await db('lending_pool_emergency').insert({
userId: event.user,
amount: event.amount,
txHash: event.txHash,
blockNumber: event.blockNumber,
timestamp: new Date(),
});
break;
}
}

async handleRemittanceNFTEvent(event: any) {
switch (event.type) {
case 'ScoreUpdated':
await db('nft_scores').insert({
nftId: event.nftId,
newScore: event.score,
txHash: event.txHash,
blockNumber: event.blockNumber,
timestamp: new Date(),
});
break;
case 'NFTSeized':
await db('nft_seizures').insert({
nftId: event.nftId,
reason: event.reason,
txHash: event.txHash,
blockNumber: event.blockNumber,
timestamp: new Date(),
});
break;
case 'NFTBurned':
await db('nft_burns').insert({
nftId: event.nftId,
txHash: event.txHash,
blockNumber: event.blockNumber,
timestamp: new Date(),
});
break;
case 'NFTMinted':
await db('nft_mints').insert({
nftId: event.nftId,
owner: event.owner,
txHash: event.txHash,
blockNumber: event.blockNumber,
timestamp: new Date(),
});
break;
}
}

async handleGovernanceEvent(event: any) {
switch (event.type) {
case 'ProposalCreated':
await db('governance_proposals').insert({
proposalId: event.proposalId,
creator: event.creator,
expiresAt: event.expiresAt,
txHash: event.txHash,
blockNumber: event.blockNumber,
timestamp: new Date(),
});
break;
case 'ProposalApproved':
await db('governance_approvals').insert({
proposalId: event.proposalId,
approver: event.approver,
txHash: event.txHash,
blockNumber: event.blockNumber,
timestamp: new Date(),
});
break;
case 'ProposalFinalized':
await db('governance_finalized').insert({
proposalId: event.proposalId,
executor: event.executor,
txHash: event.txHash,
blockNumber: event.blockNumber,
timestamp: new Date(),
});
break;
}
}

async start(): Promise<void> {
if (this.running) {
logger.warn("Indexer start requested while already running");
Expand Down Expand Up @@ -706,4 +827,24 @@ export class EventIndexer {
return null;
}
}
async handleLoanEvent(event: any) {
try {
await db('loan_events')
.insert({
transaction_hash: event.txHash,
event_index: event.index,
borrower: event.borrower,
loan_id: event.loanId,
event_type: event.type,
amount: event.amount,
created_at: new Date(event.timestamp),
})
.onConflict(['transaction_hash', 'event_index'])
.ignore(); // Prevent duplicates if re-indexing
} catch (err) {
console.error(`Failed to insert loan event: ${err.message}`);
}
}

}

123 changes: 123 additions & 0 deletions contracts/multisig_governance/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -576,3 +576,126 @@ impl GovernanceContract {
.expect("contract not initialized (4002)")
}
}

#[derive(scale::Encode, scale::Decode, Clone, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "std", derive(scale_info::TypeInfo))]
pub struct Proposal {
pub id: u64,
pub creator: AccountId,
pub approvals: Vec<AccountId>,
pub executed: bool,
pub expires_at: u64, // NEW: ledger height expiry
}

#[ink(storage)]
pub struct MultisigGovernance {
proposals: Mapping<u64, Proposal>,
next_proposal_id: u64,
expiry_window: u64, // NEW: configurable expiry window in ledgers
admin: AccountId,
}

impl MultisigGovernance {
#[ink(constructor)]
pub fn new(admin: AccountId, expiry_window: u64) -> Self {
Self {
proposals: Mapping::default(),
next_proposal_id: 0,
expiry_window,
admin,
}
}

#[ink(message)]
pub fn set_expiry_window(&mut self, new_window: u64) -> Result<(), String> {
if self.env().caller() != self.admin {
return Err(String::from("Only admin can set expiry window"));
}
self.expiry_window = new_window;
Ok(())
}

#[ink(message)]
pub fn create_proposal(&mut self) -> u64 {
let id = self.next_proposal_id;
self.next_proposal_id += 1;

let current_ledger = Self::current_ledger();
let expires_at = current_ledger + self.expiry_window;

let proposal = Proposal {
id,
creator: self.env().caller(),
approvals: Vec::new(),
executed: false,
expires_at,
};

self.proposals.insert(id, &proposal);
id
}

#[ink(message)]
pub fn approve_proposal(&mut self, proposal_id: u64) -> Result<(), String> {
let mut proposal = self
.proposals
.get(proposal_id)
.ok_or("Proposal not found")?;
let current_ledger = Self::current_ledger();

if current_ledger > proposal.expires_at {
return Err(String::from("Proposal expired"));
}

let caller = self.env().caller();
if !proposal.approvals.contains(&caller) {
proposal.approvals.push(caller);
}

self.proposals.insert(proposal_id, &proposal);
Ok(())
}

#[ink(message)]
pub fn finalize_proposal(&mut self, proposal_id: u64) -> Result<(), String> {
let mut proposal = self
.proposals
.get(proposal_id)
.ok_or("Proposal not found")?;
let current_ledger = Self::current_ledger();

if current_ledger > proposal.expires_at {
return Err(String::from("Proposal expired"));
}

if proposal.executed {
return Err(String::from("Already executed"));
}

// Execute logic here...
proposal.executed = true;
self.proposals.insert(proposal_id, &proposal);
Ok(())
}

#[ink(message)]
pub fn cancel_expired_proposal(&mut self, proposal_id: u64) -> Result<(), String> {
let proposal = self
.proposals
.get(proposal_id)
.ok_or("Proposal not found")?;
let current_ledger = Self::current_ledger();

if current_ledger <= proposal.expires_at {
return Err(String::from("Proposal not expired yet"));
}

self.proposals.remove(proposal_id);
Ok(())
}

fn current_ledger() -> u64 {
// Placeholder: integrate with environment ledger height
Self::env().block_number()
}
}
Loading