From 103e2d8b055e5d2eb4a868c25c36fe6f73328e03 Mon Sep 17 00:00:00 2001 From: mijinummi Date: Sun, 29 Mar 2026 14:47:39 +0100 Subject: [PATCH 1/8] Add Proposal Expiry to MultisigGovernance (#436) --- contracts/multisig_governance/src/lib.rs | 114 +++++++++++++++++++++++ 1 file changed, 114 insertions(+) diff --git a/contracts/multisig_governance/src/lib.rs b/contracts/multisig_governance/src/lib.rs index 7c4bfbd4..c0568fe0 100644 --- a/contracts/multisig_governance/src/lib.rs +++ b/contracts/multisig_governance/src/lib.rs @@ -591,3 +591,117 @@ 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, + pub executed: bool, + pub expires_at: u64, // NEW: ledger height expiry +} + +#[ink(storage)] +pub struct MultisigGovernance { + proposals: Mapping, + 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() + } +} From 48bb681ff219f832896f82438377ec6d6a0e93b8 Mon Sep 17 00:00:00 2001 From: mijinummi Date: Mon, 30 Mar 2026 13:47:04 +0100 Subject: [PATCH 2/8] style: apply cargo fmt --- contracts/multisig_governance/src/lib.rs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/contracts/multisig_governance/src/lib.rs b/contracts/multisig_governance/src/lib.rs index c0568fe0..327ed178 100644 --- a/contracts/multisig_governance/src/lib.rs +++ b/contracts/multisig_governance/src/lib.rs @@ -652,7 +652,10 @@ impl MultisigGovernance { #[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 mut proposal = self + .proposals + .get(proposal_id) + .ok_or("Proposal not found")?; let current_ledger = Self::current_ledger(); if current_ledger > proposal.expires_at { @@ -670,7 +673,10 @@ impl MultisigGovernance { #[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 mut proposal = self + .proposals + .get(proposal_id) + .ok_or("Proposal not found")?; let current_ledger = Self::current_ledger(); if current_ledger > proposal.expires_at { @@ -689,7 +695,10 @@ impl MultisigGovernance { #[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 proposal = self + .proposals + .get(proposal_id) + .ok_or("Proposal not found")?; let current_ledger = Self::current_ledger(); if current_ledger <= proposal.expires_at { From cc1de844748e0e4b53be1df0f8ed2e306c6ce324 Mon Sep 17 00:00:00 2001 From: mijinummi Date: Mon, 30 Mar 2026 22:09:26 +0100 Subject: [PATCH 3/8] merge: resolve conflicts in jest config and eventIndexer during rebase --- backend/jest.config.ts | 5 +- backend/src/services/eventIndexer.ts | 121 +++++++++++++++++++++++++++ 2 files changed, 124 insertions(+), 2 deletions(-) diff --git a/backend/jest.config.ts b/backend/jest.config.ts index a7ec450d..e8d448e8 100644 --- a/backend/jest.config.ts +++ b/backend/jest.config.ts @@ -15,5 +15,6 @@ export default { "^(\\.{1,2}/.*)\\.js$": "$1", }, testMatch: ["**/*.test.ts", "**/*.spec.ts"], - setupFilesAfterEnv: ["/src/tests/jest.setup.ts"], -}; + + setupFilesAfterEnv: ["/src/__tests__/setup.ts"], +}; \ No newline at end of file diff --git a/backend/src/services/eventIndexer.ts b/backend/src/services/eventIndexer.ts index 70c9655d..2e135168 100644 --- a/backend/src/services/eventIndexer.ts +++ b/backend/src/services/eventIndexer.ts @@ -17,6 +17,8 @@ import { } 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; @@ -91,6 +93,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 { if (this.running) { logger.warn("Indexer start requested while already running"); From 3dfec172e4b537bd4ff9078783570601f81c9ef0 Mon Sep 17 00:00:00 2001 From: mijinummi Date: Sun, 29 Mar 2026 14:57:42 +0100 Subject: [PATCH 4/8] Add Missing Indexes on loan_events Table (#438) --- .../20260329_add_loan_events_indexes.sql | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 backend/src/db/migrations/20260329_add_loan_events_indexes.sql diff --git a/backend/src/db/migrations/20260329_add_loan_events_indexes.sql b/backend/src/db/migrations/20260329_add_loan_events_indexes.sql new file mode 100644 index 00000000..93d1561a --- /dev/null +++ b/backend/src/db/migrations/20260329_add_loan_events_indexes.sql @@ -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); From 62950547c095920e83d503a9bfacec01a89d77c4 Mon Sep 17 00:00:00 2001 From: mijinummi Date: Sun, 29 Mar 2026 15:02:05 +0100 Subject: [PATCH 5/8] Add Unique Constraint to Prevent Duplicate Indexed Events (#439) --- ...0329_add_unique_constraint_loan_events.sql | 5 +++++ backend/src/services/eventIndexer.ts | 20 +++++++++++++++++++ 2 files changed, 25 insertions(+) create mode 100644 backend/src/db/migrations/20260329_add_unique_constraint_loan_events.sql diff --git a/backend/src/db/migrations/20260329_add_unique_constraint_loan_events.sql b/backend/src/db/migrations/20260329_add_unique_constraint_loan_events.sql new file mode 100644 index 00000000..9b4d51f3 --- /dev/null +++ b/backend/src/db/migrations/20260329_add_unique_constraint_loan_events.sql @@ -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); diff --git a/backend/src/services/eventIndexer.ts b/backend/src/services/eventIndexer.ts index 2e135168..64f0ee22 100644 --- a/backend/src/services/eventIndexer.ts +++ b/backend/src/services/eventIndexer.ts @@ -830,4 +830,24 @@ init() { 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}`); + } } + +} + From 6ec450435cbf50da3cd1f8ad6723afe5324e36f2 Mon Sep 17 00:00:00 2001 From: mijinummi Date: Tue, 31 Mar 2026 09:05:20 +0100 Subject: [PATCH 6/8] fix: resolve conflicts in jest config and eventIndexer --- backend/jest.config.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/backend/jest.config.ts b/backend/jest.config.ts index e8d448e8..a7ec450d 100644 --- a/backend/jest.config.ts +++ b/backend/jest.config.ts @@ -15,6 +15,5 @@ export default { "^(\\.{1,2}/.*)\\.js$": "$1", }, testMatch: ["**/*.test.ts", "**/*.spec.ts"], - - setupFilesAfterEnv: ["/src/__tests__/setup.ts"], -}; \ No newline at end of file + setupFilesAfterEnv: ["/src/tests/jest.setup.ts"], +}; From 49756a66194981cf5d22269d347d564150061f4b Mon Sep 17 00:00:00 2001 From: mijinummi Date: Tue, 31 Mar 2026 09:17:28 +0100 Subject: [PATCH 7/8] fix: convert migration to .js and rename to avoid conflict --- .../1784000000014_add_loan_events_indexes.js | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 backend/migrations/1784000000014_add_loan_events_indexes.js diff --git a/backend/migrations/1784000000014_add_loan_events_indexes.js b/backend/migrations/1784000000014_add_loan_events_indexes.js new file mode 100644 index 00000000..099080c1 --- /dev/null +++ b/backend/migrations/1784000000014_add_loan_events_indexes.js @@ -0,0 +1,20 @@ +// 1784000000014_add_loan_events_indexes.js + +export async function up(knex) { + await knex.raw(` + -- Index for borrower history queries + CREATE INDEX IF NOT EXISTS loan_events_borrower_idx + ON loan_events (borrower_id); + + -- Index for loan status queries + CREATE INDEX IF NOT EXISTS loan_events_status_idx + ON loan_events (loan_id, status); + `); +} + +export async function down(knex) { + await knex.raw(` + DROP INDEX IF EXISTS loan_events_borrower_idx; + DROP INDEX IF EXISTS loan_events_status_idx; + `); +} From 55a0b6339d1ea06c10cdb5c6e95f43980e6211cc Mon Sep 17 00:00:00 2001 From: mijinummi Date: Tue, 31 Mar 2026 09:41:04 +0100 Subject: [PATCH 8/8] fix: correct ESM imports and add chainListener stub for contract event subscriptions --- ...l => 20260330_add_loan_events_indexes.sql} | 0 backend/src/services/chainListener.ts | 19 +++++++++++++++ backend/src/services/eventIndexer.ts | 24 ++----------------- 3 files changed, 21 insertions(+), 22 deletions(-) rename backend/src/db/migrations/{1700000000000_add-loan-events-type-index.sql => 20260330_add_loan_events_indexes.sql} (100%) create mode 100644 backend/src/services/chainListener.ts diff --git a/backend/src/db/migrations/1700000000000_add-loan-events-type-index.sql b/backend/src/db/migrations/20260330_add_loan_events_indexes.sql similarity index 100% rename from backend/src/db/migrations/1700000000000_add-loan-events-type-index.sql rename to backend/src/db/migrations/20260330_add_loan_events_indexes.sql diff --git a/backend/src/services/chainListener.ts b/backend/src/services/chainListener.ts new file mode 100644 index 00000000..abd38e65 --- /dev/null +++ b/backend/src/services/chainListener.ts @@ -0,0 +1,19 @@ +// backend/src/services/chainListener.ts + +import logger from "../utils/logger.js"; + +/** + * Subscribe to contract events by contract name. + * In a real implementation, you’d hook into Soroban RPC or your event stream. + */ +export function subscribeToContractEvents( + contractName: string, + handler: (event: any) => Promise | void, +) { + logger.info(`Subscribed to contract events for ${contractName}`); + + // Example: you might wire this up to your event stream service + // eventStreamService.on(contractName, handler); + + // For now, this is just a placeholder so imports resolve. +} diff --git a/backend/src/services/eventIndexer.ts b/backend/src/services/eventIndexer.ts index 64f0ee22..06aa5e37 100644 --- a/backend/src/services/eventIndexer.ts +++ b/backend/src/services/eventIndexer.ts @@ -17,8 +17,8 @@ import { } from "./notificationService.js"; import { sorobanService } from "./sorobanService.js"; import { updateUserScoresBulk } from "./scoresService.js"; -import { subscribeToContractEvents } from "./chainListener"; -import db from "../db"; +import { subscribeToContractEvents } from "../services/chainListener.js"; +import db from "../db/connection.js"; interface SorobanRawEvent { id: string; @@ -830,24 +830,4 @@ init() { 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}`); - } } - -} -