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
56 changes: 34 additions & 22 deletions backend/migrations/1800000000000_keyset-pagination-seq-columns.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export const shorthands = undefined;
*/
export const up = async (pgm) => {
// ─── Add seq identity columns for keyset pagination ───────────────────

// 1. contract_events (formerly loan_events)
pgm.addColumn('contract_events', {
seq: {
Expand Down Expand Up @@ -78,29 +78,41 @@ export const up = async (pgm) => {
// ─── Create composite seek indexes ────────────────────────────────────

// contract_events: (created_at DESC, seq DESC) for keyset pagination
pgm.createIndex('contract_events', [
{ name: 'created_at', direction: 'DESC' },
{ name: 'seq', direction: 'DESC' },
], {
name: 'idx_contract_events_seek',
});
pgm.createIndex(
'contract_events',
[
{ name: 'created_at', direction: 'DESC' },
{ name: 'seq', direction: 'DESC' },
],
{
name: 'idx_contract_events_seek',
},
);

// remittances: (created_at DESC, seq DESC) for keyset pagination
pgm.createIndex('remittances', [
{ name: 'created_at', direction: 'DESC' },
{ name: 'seq', direction: 'DESC' },
], {
name: 'idx_remittances_seek',
});
pgm.createIndex(
'remittances',
[
{ name: 'created_at', direction: 'DESC' },
{ name: 'seq', direction: 'DESC' },
],
{
name: 'idx_remittances_seek',
},
);

// loan_disputes: (created_at DESC, seq DESC) for keyset pagination (if exists)
if (disputesTableExists.rows[0].exists) {
pgm.createIndex('loan_disputes', [
{ name: 'created_at', direction: 'DESC' },
{ name: 'seq', direction: 'DESC' },
], {
name: 'idx_loan_disputes_seek',
});
pgm.createIndex(
'loan_disputes',
[
{ name: 'created_at', direction: 'DESC' },
{ name: 'seq', direction: 'DESC' },
],
{
name: 'idx_loan_disputes_seek',
},
);
}
};

Expand All @@ -110,8 +122,8 @@ export const up = async (pgm) => {
*/
export const down = async (pgm) => {
// Drop seek indexes
pgm.dropIndex('contract_events', 'idx_contract_events_seek');
pgm.dropIndex('remittances', 'idx_remittances_seek');
pgm.dropIndex('contract_events', [], { name: 'idx_contract_events_seek' });
pgm.dropIndex('remittances', [], { name: 'idx_remittances_seek' });

const disputesTableExists = await pgm.db.query(`
SELECT EXISTS (
Expand All @@ -121,7 +133,7 @@ export const down = async (pgm) => {
`);

if (disputesTableExists.rows[0].exists) {
pgm.dropIndex('loan_disputes', 'idx_loan_disputes_seek');
pgm.dropIndex('loan_disputes', [], { name: 'idx_loan_disputes_seek' });
}

// Drop seq columns
Expand Down
26 changes: 26 additions & 0 deletions backend/migrations/1802000000000_money_stroops_integer.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,34 @@ const MONEY_COLUMNS = [
},
];

// The backward-compat `loan_events` view (1788000000018_unified-contract-events)
// selects `contract_events.amount`, and PostgreSQL refuses to retype a column a
// view depends on. Drop the view around the ALTER and recreate it verbatim.
const CREATE_LOAN_EVENTS_VIEW = `
CREATE VIEW loan_events AS
SELECT
id,
event_id,
event_type,
loan_id,
address AS borrower,
amount,
ledger,
ledger_closed_at,
tx_hash,
contract_id,
topics,
value,
created_at
FROM contract_events;
`;

/**
* @param pgm {import('node-pg-migrate').MigrationBuilder}
* @returns {void}
*/
export const up = (pgm) => {
pgm.sql('DROP VIEW IF EXISTS loan_events;');
for (const { table, column, constraint } of MONEY_COLUMNS) {
// Round any pre-existing fractional values down to whole stroops before
// the CHECK is added, so historical rows (if any ever slipped in with a
Expand All @@ -70,15 +93,18 @@ export const up = (pgm) => {
check: `"${column}" IS NULL OR "${column}" = trunc("${column}")`,
});
}
pgm.sql(CREATE_LOAN_EVENTS_VIEW);
};

/**
* @param pgm {import('node-pg-migrate').MigrationBuilder}
* @returns {void}
*/
export const down = (pgm) => {
pgm.sql('DROP VIEW IF EXISTS loan_events;');
for (const { table, column, constraint } of [...MONEY_COLUMNS].reverse()) {
pgm.dropConstraint(table, constraint);
pgm.alterColumn(table, column, { type: 'numeric' });
}
pgm.sql(CREATE_LOAN_EVENTS_VIEW);
};
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ function routeQueries(opts: {
const { backfilled = 0, unresolved = [], matchByBorrower = {} } = opts;
mockQuery.mockImplementation(async (sql: string, params?: unknown[]) => {
if (sql.includes('/* backfill */')) return { rows: [], rowCount: backfilled };
if (sql.includes('/* fetch-unresolved */')) return { rows: unresolved, rowCount: unresolved.length };
if (sql.includes('/* fetch-unresolved */'))
return { rows: unresolved, rowCount: unresolved.length };
if (sql.includes('/* match-score */')) {
const borrower = String(params?.[0] ?? '');
const ledger = matchByBorrower[borrower];
Expand Down
16 changes: 11 additions & 5 deletions backend/src/services/crossContractReconciler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,11 @@ class CrossContractReconciler {
return row?.ledger == null ? null : Number(row.ledger);
}

private async markReconciled(id: number, scoreLedger: number | null, applied: boolean): Promise<void> {
private async markReconciled(
id: number,
scoreLedger: number | null,
applied: boolean,
): Promise<void> {
await query(
`/* update */
UPDATE cross_contract_reconciliation
Expand Down Expand Up @@ -247,10 +251,12 @@ class CrossContractReconciler {
const onChainScore = await sorobanService.getOnChainCreditScore(row.borrower);
corrections.set(row.borrower, onChainScore);
} catch (err) {
logger.withContext().error('cross_contract_reconciliation.autocorrect.lookup_failed', {
borrower: row.borrower,
error: err,
});
logger
.withContext()
.error('cross_contract_reconciliation.autocorrect.lookup_failed', {
borrower: row.borrower,
error: err,
});
}
}
} else {
Expand Down
9 changes: 9 additions & 0 deletions contracts/fuzz/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

52 changes: 36 additions & 16 deletions contracts/fuzz/fuzz_targets/lending_pool_fuzz.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use lending_pool::{LendingPool, LendingPoolClient};
use libfuzzer_sys::fuzz_target;
use soroban_sdk::testutils::Address as _;
use soroban_sdk::token::{Client as TokenClient, StellarAssetClient};
use soroban_sdk::{Address, Env, Symbol, IntoVal, Val};
use soroban_sdk::{Address, Env, IntoVal, Symbol, Val};
use std::collections::HashMap;
use std::panic::AssertUnwindSafe;

Expand Down Expand Up @@ -34,7 +34,10 @@ struct Operation {
is_deposit: bool,
}

fn setup_token_contract<'a>(env: &Env, admin: &Address) -> (Address, StellarAssetClient<'a>, TokenClient<'a>) {
fn setup_token_contract<'a>(
env: &Env,
admin: &Address,
) -> (Address, StellarAssetClient<'a>, TokenClient<'a>) {
let contract_id = env.register_stellar_asset_contract_v2(admin.clone());
let stellar_asset_client = StellarAssetClient::new(env, &contract_id.address());
let token_client = TokenClient::new(env, &contract_id.address());
Expand All @@ -60,7 +63,7 @@ fuzz_target!(|data: FuzzAction| {
match data {
FuzzAction::Deposit { user_id, amount } => {
let user = Address::generate(&env);

// Skip invalid amounts
if amount <= 0 {
return;
Expand All @@ -76,9 +79,13 @@ fuzz_target!(|data: FuzzAction| {
let balance = pool_client.get_deposit(&user, &token_id);
assert!(balance >= 0, "Balance should never be negative");
assert_eq!(balance, amount, "Balance should match deposited amount");

// Verify pool token balance
assert_eq!(token_client.balance(&pool_id), amount, "Pool token balance should match deposit");
assert_eq!(
token_client.balance(&pool_id),
amount,
"Pool token balance should match deposit"
);
}
}

Expand All @@ -96,7 +103,7 @@ fuzz_target!(|data: FuzzAction| {
None => return,
};
stellar_asset_client.mint(&user, &deposit_amount);
pool_client.deposit(&user, &token_id, &deposit_amount);
pool_client.deposit(&user, &token_id, &deposit_amount, &0);

let balance_before = pool_client.get_deposit(&user, &token_id);
let result = rcall!(&env, pool_client, "withdraw", (user, amount));
Expand All @@ -111,9 +118,13 @@ fuzz_target!(|data: FuzzAction| {
"Balance should decrease by withdrawal amount"
);
assert!(balance_after >= 0, "Balance should never be negative");

// Verify pool token balance
assert_eq!(token_client.balance(&pool_id), deposit_amount - amount, "Pool token balance mismatch after withdrawal");
assert_eq!(
token_client.balance(&pool_id),
deposit_amount - amount,
"Pool token balance mismatch after withdrawal"
);
}
}

Expand All @@ -130,19 +141,26 @@ fuzz_target!(|data: FuzzAction| {
let mut total_expected_deposits = 0i128;

for op in operations {
let user_addr = users.entry(op.user_id).or_insert_with(|| Address::generate(&env)).clone();
let user_addr = users
.entry(op.user_id)
.or_insert_with(|| Address::generate(&env))
.clone();

if op.is_deposit {
if op.amount <= 0 { continue; }

if op.amount <= 0 {
continue;
}

stellar_asset_client.mint(&user_addr, &op.amount);
let result = rcall!(&env, pool_client, "deposit", (user_addr, op.amount));
if result.is_ok() {
total_expected_deposits += op.amount;
}
} else {
if op.amount <= 0 { continue; }

if op.amount <= 0 {
continue;
}

// Attempt withdrawal
let result = rcall!(&env, pool_client, "withdraw", (user_addr, op.amount));
if result.is_ok() {
Expand All @@ -165,12 +183,14 @@ fuzz_target!(|data: FuzzAction| {
total_expected_deposits,
"Total deposits should match pool token balance"
);

// Verify all individual balances are non-negative
for (_, user_addr) in users {
assert!(pool_client.get_deposit(&user_addr, &token_id) >= 0, "Individual balance should never be negative");
assert!(
pool_client.get_deposit(&user_addr, &token_id) >= 0,
"Individual balance should never be negative"
);
}
}
}
});

Loading
Loading