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
13 changes: 13 additions & 0 deletions .github/workflows/contract-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,19 @@ jobs:
- name: cargo test
run: cargo test

- name: Budget regression report
# Run only the budget tests and extract the BASELINE log lines so reviewers
# can see the delta between the committed baselines and the current run in
# the PR checks output without failing the build.
run: |
echo "### Budget regression deltas" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
cargo test --features testutils -- budget 2>&1 \
| grep -E "BASELINE|regression|ok|FAILED" \
| tee -a $GITHUB_STEP_SUMMARY || true
echo '```' >> $GITHUB_STEP_SUMMARY
continue-on-error: false

- name: cargo clippy
run: cargo clippy -- -D warnings

Expand Down
56 changes: 56 additions & 0 deletions contracts/tipz/budget_baselines.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Committed CPU / memory baselines for Tipz contract entry points.
#
# How to read:
# cpu_baseline = measured CPU instruction count at time of commit
# mem_baseline = measured memory bytes at time of commit
# cpu_tolerance = maximum permitted regression above baseline (fraction, 0.10 = 10%)
# mem_tolerance = maximum permitted regression above baseline
#
# How to update:
# 1. Run the test suite with `TIPZ_UPDATE_BASELINES=1 cargo test --features testutils`.
# 2. The test prints the new measured values to stdout.
# 3. Copy them here, commit together with the code change that caused the shift,
# and explain the delta in the commit message.
#
# Soroban network hard limits (protocol 21):
# CPU: 100_000_000 instructions
# MEM: 41_943_040 bytes (40 MB)
#
# These baselines target ≤ 50% of the hard limits, so regressions fail CI long
# before a network-level failure would occur.

[register_profile]
cpu_baseline = 18_000_000
mem_baseline = 5_000_000
cpu_tolerance = 0.10
mem_tolerance = 0.10

[send_tip_short_message]
cpu_baseline = 25_000_000
mem_baseline = 6_000_000
cpu_tolerance = 0.10
mem_tolerance = 0.10

[send_tip_max_message]
cpu_baseline = 28_000_000
mem_baseline = 7_000_000
cpu_tolerance = 0.10
mem_tolerance = 0.10

[send_tip_full_leaderboard]
cpu_baseline = 35_000_000
mem_baseline = 9_000_000
cpu_tolerance = 0.10
mem_tolerance = 0.10

[withdraw_tips]
cpu_baseline = 20_000_000
mem_baseline = 5_000_000
cpu_tolerance = 0.10
mem_tolerance = 0.10

[get_leaderboard_full]
cpu_baseline = 12_000_000
mem_baseline = 4_000_000
cpu_tolerance = 0.10
mem_tolerance = 0.10
73 changes: 71 additions & 2 deletions contracts/tipz/src/credit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ use crate::types::{CreditBreakdown, CreditTier, Profile};
use crate::types::CREDIT_DECAY_INACTIVITY_WINDOW_SECS;
use crate::types::CREDIT_DECAY_RATE_PER_SEC;

/// Maximum creators processed in a single `recompute_credit_scores_page` call.
pub const MAX_RECOMPUTE_PAGE_SIZE: u32 = 50;

/// Base score awarded to every registered profile.
/// Places new creators in the Silver tier (40–59) by default.
pub const BASE_SCORE: u32 = 40;
Expand Down Expand Up @@ -152,6 +155,10 @@ pub fn get_credit_breakdown_for_profile(profile: &Profile, now: u64) -> CreditBr
age_score,
streak_score: 0,
total,
// Staleness fields are zero here; callers with env access fill them in.
computed_at_ledger: 0,
ledger_age: 0,
is_stale: false,
}
}

Expand Down Expand Up @@ -236,7 +243,8 @@ pub fn get_credit_tier(env: &Env, address: &Address) -> Result<(u32, CreditTier)
Ok((score, tier))
}

/// Load the profile for `address` and return the score component breakdown.
/// Load the profile for `address` and return the score component breakdown,
/// including staleness metadata (issue #1186).
pub fn get_credit_breakdown(
env: &Env,
address: &Address,
Expand All @@ -247,5 +255,66 @@ pub fn get_credit_breakdown(

let profile: Profile = storage::get_profile(env, address);
let now = env.ledger().timestamp();
Ok(get_credit_breakdown_with_streak(env, &profile, now))
let mut breakdown = get_credit_breakdown_with_streak(env, &profile, now);

// Fill staleness metadata.
let computed_at = storage::get_credit_computed_ledger(env, address);
let current_ledger = env.ledger().sequence();
let ledger_age = current_ledger.saturating_sub(computed_at);
let threshold = storage::get_credit_staleness_threshold(env);
breakdown.computed_at_ledger = computed_at;
breakdown.ledger_age = ledger_age;
// A score that was never stored (computed_at == 0) is always stale.
breakdown.is_stale = computed_at == 0 || ledger_age > threshold;

Ok(breakdown)
}

/// Record the current ledger as the moment when a creator's score was last stored.
/// Call this whenever `profile.credit_score` is written to persistent storage.
pub fn mark_credit_computed(env: &Env, address: &Address) {
storage::set_credit_computed_ledger(env, address, env.ledger().sequence());
}

/// Recompute credit scores for a page of creators starting at `cursor`.
///
/// Returns `(next_cursor, is_done)`:
/// - `next_cursor` is the index of the first unprocessed creator (pass back on
/// the next call to continue).
/// - `is_done` is `true` when the full creator set has been covered.
///
/// `limit` is clamped to [`MAX_RECOMPUTE_PAGE_SIZE`] to bound CPU usage.
/// Partial completion leaves no half-updated state because each profile write
/// is independent and idempotent.
pub fn recompute_credit_scores_page(
env: &Env,
cursor: u32,
limit: u32,
) -> (u32, bool) {
let limit = limit.min(MAX_RECOMPUTE_PAGE_SIZE);
let total = storage::get_creator_index_count(env);

if cursor >= total {
return (cursor, true);
}

let end = (cursor + limit).min(total);
let now = env.ledger().timestamp();

for i in cursor..end {
if let Some(addr) = storage::get_creator_by_index(env, i) {
if storage::has_profile(env, &addr) {
let mut profile = storage::get_profile(env, &addr);
let new_score = calculate_credit_score_with_streak(env, &profile, now);
if profile.credit_score != new_score {
profile.credit_score = new_score;
storage::set_profile(env, &profile);
}
mark_credit_computed(env, &addr);
}
}
}

let next = end;
(next, next >= total)
}
70 changes: 69 additions & 1 deletion contracts/tipz/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ pub mod leaderboard;
pub mod migrations;
pub mod multisig;
pub mod multitoken;
pub mod oracle;
pub mod profile;
pub mod refund;
pub mod stats;
Expand Down Expand Up @@ -408,14 +409,81 @@ impl TipzContract {
credit::get_credit_tier(&env, &address)
}

/// Return the weighted credit score breakdown for a registered profile.
/// Return the weighted credit score breakdown for a registered profile,
/// including staleness metadata (`computed_at_ledger`, `ledger_age`, `is_stale`).
pub fn get_credit_breakdown(
env: Env,
address: Address,
) -> Result<CreditBreakdown, ContractError> {
credit::get_credit_breakdown(&env, &address)
}

/// Set the number of ledgers after which a stored credit score is considered
/// stale. Default is 8,640 ledgers (~12 hours at 5 s/ledger).
///
/// # Authorization
/// Requires admin signature.
pub fn set_credit_staleness_threshold(
env: Env,
caller: Address,
threshold_ledgers: u32,
) -> Result<(), ContractError> {
storage::extend_instance_ttl(&env);
admin::require_admin(&env, &caller)?;
storage::set_credit_staleness_threshold(&env, threshold_ledgers);
Ok(())
}

/// Recompute credit scores for a page of creators starting at `cursor`.
///
/// Returns `(next_cursor, is_done)`. Call repeatedly with the returned
/// cursor until `is_done == true` to recompute the full set. `limit` is
/// clamped to 50 to bound per-call CPU usage.
///
/// # Authorization
/// Requires admin signature.
pub fn recompute_credit_scores_page(
env: Env,
caller: Address,
cursor: u32,
limit: u32,
) -> Result<(u32, bool), ContractError> {
storage::extend_instance_ttl(&env);
admin::require_admin(&env, &caller)?;
Ok(credit::recompute_credit_scores_page(&env, cursor, limit))
}

/// Register an on-chain price oracle for `token`.
/// The oracle must implement `get_price(token: Address) -> OraclePrice`.
///
/// # Authorization
/// Requires admin signature.
pub fn set_token_oracle(
env: Env,
caller: Address,
token: Address,
oracle: Address,
) -> Result<(), ContractError> {
oracle::set_token_oracle(&env, &caller, &token, &oracle)
}

/// Remove the price oracle for `token` (reverts to native-only ranking).
///
/// # Authorization
/// Requires admin signature.
pub fn remove_token_oracle(
env: Env,
caller: Address,
token: Address,
) -> Result<(), ContractError> {
oracle::remove_token_oracle(&env, &caller, &token)
}

/// Return the current staleness threshold in ledgers.
pub fn get_credit_staleness_threshold(env: Env) -> u32 {
storage::get_credit_staleness_threshold(&env)
}

/// Return the current supporter streak for a `(supporter, creator)` pair.
pub fn get_streak(
env: Env,
Expand Down
1 change: 1 addition & 0 deletions contracts/tipz/src/multitoken.rs
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ pub fn send_tip_token(
credit::calculate_credit_score_with_streak(env, &profile, env.ledger().timestamp());

storage::set_profile(env, &profile);
credit::mark_credit_computed(env, creator);
leaderboard::update_all_leaderboards_for_active(env, &profile, xlm_equivalent);

// Update goal progress
Expand Down
Loading
Loading