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
12 changes: 9 additions & 3 deletions .github/workflows/security.yml
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,12 @@ jobs:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: gitleaks/gitleaks-action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Run Gitleaks
run: |
docker run --rm \
-v "$PWD:/repo" \
zricethezav/gitleaks:latest detect \
--source=/repo \
--config=/repo/gitleaks.toml \
--redact \
--verbose
11 changes: 11 additions & 0 deletions .gitleaks.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
[allowlist]
description = "Allowlist mock keys in tests and mock screens"
paths = [
'''mobileapp/app/backup-key\.tsx''',
'''backend/tests/payment_xdr_test\.rs''',
'''mobileapp/src/services/walletSecurity\.ts''',
'''mobileapp/app/returning-user/index\.tsx'''
]
regexes = [
'''SCZANGBA5YHTNYVVV33H6MNWUQY7CZJM2ZCQMFRPY2DXNYBM6BKMB5M7'''
]
4 changes: 4 additions & 0 deletions .gitleaksignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
19f9a4d42bc7cd1a3e5134a9c8d4d371e3bfc7c4:mobileapp/src/services/walletSecurity.ts:generic-api-key:23
55e72531f87f0b13f2922e77b0205406e9cd7007:backend/tests/payment_xdr_test.rs:generic-api-key:132
904e0a1f21f5c4e7f0e23612aea2130ebddd5f15:mobileapp/app/backup-key.tsx:generic-api-key:20
42a2f51ac9cb64a74f585e98b47eff22d3899fa7:mobileapp/app/backup-key.tsx:generic-api-key:14
6 changes: 1 addition & 5 deletions backend/.cargo/audit.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,7 @@
[advisories]
ignore = [
"RUSTSEC-2023-0071", # rsa: Marvin Attack - no fix available
]

# Allow unmaintained crate warnings for now
[warnings]
allow = [
"RUSTSEC-2024-0436", # paste unmaintained
"RUSTSEC-2025-0134", # rustls-pemfile unmaintained
"RUSTSEC-2024-0363", # sqlx 0.7: wait for 0.8 upgrade
]
3 changes: 2 additions & 1 deletion backend/src/api/bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -333,7 +333,8 @@ pub async fn run_status_poller(state: BridgeState) {
let new_conf = status.confirmations.max(known_conf);
// Only write when something actually changed.
if status.status != BridgeStatusKind::Pending || new_conf != known_conf {
update_status(&state.pool, &tx_hash, status.status.as_str(), new_conf).await;
update_status(&state.pool, &tx_hash, status.status.as_str(), new_conf)
.await;
}
}
Err(e) => {
Expand Down
47 changes: 22 additions & 25 deletions backend/src/api/yield.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,7 @@ pub struct YieldBalanceResponse {
pub apy: f64,
}

pub async fn get_balance(
State(pool): State<sqlx::PgPool>,
auth: AuthUser,
) -> impl IntoResponse {
pub async fn get_balance(State(pool): State<sqlx::PgPool>, auth: AuthUser) -> impl IntoResponse {
let balance = match crate::db::r#yield::get_or_create_yield_balance(&pool, auth.id).await {
Ok(b) => b,
Err(e) => {
Expand Down Expand Up @@ -91,23 +88,22 @@ pub async fn get_history(
let limit = params.limit.unwrap_or(20).clamp(1, 100);
let offset = params.offset.unwrap_or(0).max(0);

let total: i64 = match sqlx::query_scalar(
"SELECT COUNT(*) FROM yield_transactions WHERE user_id = $1",
)
.bind(auth.id)
.fetch_one(&pool)
.await
{
Ok(n) => n,
Err(e) => {
tracing::error!("yield history count error: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "Failed to count yield transactions" })),
)
.into_response();
}
};
let total: i64 =
match sqlx::query_scalar("SELECT COUNT(*) FROM yield_transactions WHERE user_id = $1")
.bind(auth.id)
.fetch_one(&pool)
.await
{
Ok(n) => n,
Err(e) => {
tracing::error!("yield history count error: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "Failed to count yield transactions" })),
)
.into_response();
}
};

let rows = match sqlx::query(
r#"
Expand Down Expand Up @@ -291,7 +287,8 @@ pub async fn deposit(
};

// Build Stellar transaction envelope XDR for the user's wallet to sign.
let envelope_xdr = build_stellar_envelope_xdr(&auth.address, "yield_deposit", payload.amount, &tx_hash);
let envelope_xdr =
build_stellar_envelope_xdr(&auth.address, "yield_deposit", payload.amount, &tx_hash);

Json(DepositResponse {
available_balance: updated.available_balance,
Expand Down Expand Up @@ -356,8 +353,7 @@ pub async fn withdraw(
let tx_hash = format!("zaps-yield-withdraw-{}", Uuid::new_v4());

if let Err(e) =
crate::db::r#yield::process_yield_withdrawal(&pool, auth.id, payload.amount, &tx_hash)
.await
crate::db::r#yield::process_yield_withdrawal(&pool, auth.id, payload.amount, &tx_hash).await
{
tracing::error!("yield withdrawal DB error: {:?}", e);
return (
Expand All @@ -379,7 +375,8 @@ pub async fn withdraw(
}
};

let envelope_xdr = build_stellar_envelope_xdr(&auth.address, "yield_withdraw", payload.amount, &tx_hash);
let envelope_xdr =
build_stellar_envelope_xdr(&auth.address, "yield_withdraw", payload.amount, &tx_hash);

Json(WithdrawResponse {
available_balance: updated.available_balance,
Expand Down
25 changes: 10 additions & 15 deletions backend/src/db/yield.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use super::models::{UserYieldBalance, YieldRateHistory, YieldTransaction};
use sqlx::{PgPool, Postgres, Row, Transaction};
use uuid::Uuid;
use super::models::{UserYieldBalance, YieldTransaction, YieldRateHistory};

/// Get a user's yield balance or create one with zero balance if it doesn't exist
pub async fn get_or_create_yield_balance(
Expand All @@ -13,7 +13,7 @@ pub async fn get_or_create_yield_balance(
VALUES ($1, 0, 0, NOW())
ON CONFLICT (user_id) DO UPDATE SET updated_at = NOW()
RETURNING user_id, available_balance, earning_balance, updated_at
"#
"#,
)
.bind(user_id)
.fetch_one(pool)
Expand Down Expand Up @@ -54,7 +54,7 @@ pub async fn process_yield_deposit_tx(
r#"
INSERT INTO yield_transactions (user_id, tx_hash, type, amount, created_at)
VALUES ($1, $2, 'DEPOSIT', $3, NOW())
"#
"#,
)
.bind(user_id)
.bind(tx_hash)
Expand All @@ -70,7 +70,7 @@ pub async fn process_yield_deposit_tx(
ON CONFLICT (user_id) DO UPDATE
SET earning_balance = user_yield_balances.earning_balance + $2,
updated_at = NOW()
"#
"#,
)
.bind(user_id)
.bind(amount)
Expand Down Expand Up @@ -107,7 +107,7 @@ pub async fn process_yield_withdrawal_tx(
r#"
INSERT INTO yield_transactions (user_id, tx_hash, type, amount, created_at)
VALUES ($1, $2, 'WITHDRAW', $3, NOW())
"#
"#,
)
.bind(user_id)
.bind(tx_hash)
Expand All @@ -124,7 +124,7 @@ pub async fn process_yield_withdrawal_tx(
available_balance = available_balance + $2,
updated_at = NOW()
WHERE user_id = $1
"#
"#,
)
.bind(user_id)
.bind(amount)
Expand All @@ -135,15 +135,12 @@ pub async fn process_yield_withdrawal_tx(
}

/// Log an APY update
pub async fn log_yield_rate_update(
pool: &PgPool,
apy: i32,
) -> Result<(), sqlx::Error> {
pub async fn log_yield_rate_update(pool: &PgPool, apy: i32) -> Result<(), sqlx::Error> {
sqlx::query(
r#"
INSERT INTO yield_rates_history (apy, created_at)
VALUES ($1, NOW())
"#
"#,
)
.bind(apy)
.execute(pool)
Expand All @@ -153,15 +150,13 @@ pub async fn log_yield_rate_update(
}

/// Get the current (latest) APY
pub async fn get_current_yield_rate(
pool: &PgPool,
) -> Result<Option<i32>, sqlx::Error> {
pub async fn get_current_yield_rate(pool: &PgPool) -> Result<Option<i32>, sqlx::Error> {
let rate = sqlx::query_scalar(
r#"
SELECT apy FROM yield_rates_history
ORDER BY created_at DESC
LIMIT 1
"#
"#,
)
.fetch_optional(pool)
.await?;
Expand Down
19 changes: 12 additions & 7 deletions backend/src/indexer/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,23 @@ pub fn parse_zaps_event(topic: &str, value: &Value) -> ZapsEvent {
let address = find_nested_string(value, "address").unwrap_or_default();
let amount = find_nested_i64(value, "amount").unwrap_or_default();
let tx_hash = extract_tx_hash(value);

ZapsEvent::YieldDeposited(YieldDepositedEvent { address, amount, tx_hash })

ZapsEvent::YieldDeposited(YieldDepositedEvent {
address,
amount,
tx_hash,
})
}
"YieldWithdrawn" => {
let address = find_nested_string(value, "address").unwrap_or_default();
let amount = find_nested_i64(value, "amount").unwrap_or_default();
let tx_hash = extract_tx_hash(value);

ZapsEvent::YieldWithdrawn(YieldWithdrawnEvent { address, amount, tx_hash })
ZapsEvent::YieldWithdrawn(YieldWithdrawnEvent {
address,
amount,
tx_hash,
})
}
"YieldRateUpdated" => {
let apy = find_nested_i64(value, "apy").unwrap_or_default() as i32;
Expand Down Expand Up @@ -77,10 +85,7 @@ pub fn find_nested_i64(value: &Value, key: &str) -> Option<i64> {
Value::String(text) => text.parse::<i64>().ok(),
_ => None,
})
.or_else(|| {
map.values()
.find_map(|nested| find_nested_i64(nested, key))
}),
.or_else(|| map.values().find_map(|nested| find_nested_i64(nested, key))),
Value::Array(items) => items.iter().find_map(|item| find_nested_i64(item, key)),
_ => None,
}
Expand Down
42 changes: 27 additions & 15 deletions backend/src/indexer/worker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ use std::{env, error::Error, time::Duration};
use uuid::Uuid;

use super::parser::{parse_zaps_event, ZapsEvent};
use crate::db::r#yield::{process_yield_deposit_tx, process_yield_withdrawal_tx, log_yield_rate_update};
use crate::db::r#yield::{
log_yield_rate_update, process_yield_deposit_tx, process_yield_withdrawal_tx,
};

const INDEXER_CURSOR_KEY: &str = "stellar_event_cursor";
const DEFAULT_POLL_INTERVAL: Duration = Duration::from_secs(3);
Expand Down Expand Up @@ -56,15 +58,15 @@ pub async fn run(
let mut tx = pool.begin().await?;

for event in &events {
// Try to extract topic from event. Soroban RPC typically returns topics as an array of XDR strings,
// but since the existing code uses `find_nested_string`, we'll try to guess the event type
// Try to extract topic from event. Soroban RPC typically returns topics as an array of XDR strings,
// but since the existing code uses `find_nested_string`, we'll try to guess the event type
// or assume the topic is available in the payload somehow (e.g. decoded by a proxy or we check fields).
// For now, we will use a heuristic: if it has "apy", it's YieldRateUpdated.
// Otherwise we try extracting topic.

let topic_hint = super::parser::find_nested_string(event, "topic_symbol")
.or_else(|| super::parser::find_nested_string(event, "event_type"));

let guessed_topic = if let Some(t) = topic_hint {
t
} else if super::parser::find_nested_i64(event, "apy").is_some() {
Expand All @@ -79,14 +81,24 @@ pub async fn run(

match parse_zaps_event(&guessed_topic, event) {
ZapsEvent::YieldDeposited(e) => {
let user_id = get_or_create_user_id(&e.address, &pool).await.unwrap_or_else(|_| Uuid::new_v4());
if let Err(err) = process_yield_deposit_tx(&mut tx, user_id, e.amount, &e.tx_hash).await {
let user_id = get_or_create_user_id(&e.address, &pool)
.await
.unwrap_or_else(|_| Uuid::new_v4());
if let Err(err) =
process_yield_deposit_tx(&mut tx, user_id, e.amount, &e.tx_hash)
.await
{
tracing::warn!("Failed to process YieldDeposited event: {err}");
}
}
ZapsEvent::YieldWithdrawn(e) => {
let user_id = get_or_create_user_id(&e.address, &pool).await.unwrap_or_else(|_| Uuid::new_v4());
if let Err(err) = process_yield_withdrawal_tx(&mut tx, user_id, e.amount, &e.tx_hash).await {
let user_id = get_or_create_user_id(&e.address, &pool)
.await
.unwrap_or_else(|_| Uuid::new_v4());
if let Err(err) =
process_yield_withdrawal_tx(&mut tx, user_id, e.amount, &e.tx_hash)
.await
{
tracing::warn!("Failed to process YieldWithdrawn event: {err}");
}
}
Expand All @@ -98,9 +110,12 @@ pub async fn run(
ZapsEvent::Unknown => {
if let Some(payment_event) = extract_social_payment_event(event) {
if let Err(err) =
process_social_payment_event(payment_event, &pool, &mut tx).await
process_social_payment_event(payment_event, &pool, &mut tx)
.await
{
tracing::warn!("Failed to process Stellar payment event: {err}");
tracing::warn!(
"Failed to process Stellar payment event: {err}"
);
}
}
}
Expand Down Expand Up @@ -323,10 +338,7 @@ fn find_nested_i64(value: &Value, key: &str) -> Option<i64> {
Value::String(text) => text.parse::<i64>().ok(),
_ => None,
})
.or_else(|| {
map.values()
.find_map(|nested| find_nested_i64(nested, key))
}),
.or_else(|| map.values().find_map(|nested| find_nested_i64(nested, key))),
Value::Array(items) => items.iter().find_map(|item| find_nested_i64(item, key)),
_ => None,
}
Expand Down
Loading
Loading