Skip to content

fix(minibf): use inaccurate but compatible pool fees value - #762

Merged
scarmuega merged 2 commits into
mainfrom
fix/bf-fees
Oct 17, 2025
Merged

fix(minibf): use inaccurate but compatible pool fees value#762
scarmuega merged 2 commits into
mainfrom
fix/bf-fees

Conversation

@scarmuega

@scarmuega scarmuega commented Oct 17, 2025

Copy link
Copy Markdown
Member

Summary by CodeRabbit

  • New Features

    • Added tracking of fixed costs and margin costs for stake pool operations.
  • Refactor

    • Updated fee calculation logic to incorporate new cost parameters in pool history.

@coderabbitai

coderabbitai Bot commented Oct 17, 2025

Copy link
Copy Markdown

Walkthrough

The pull request extends StakeLog with two new fields (fixed_cost and margin_cost), updates their CBOR serialization indices, computes these values from pool parameters during stake log creation, and modifies pool fee calculations to incorporate the margin and fixed cost instead of relying solely on operator share.

Changes

Cohort / File(s) Summary
StakeLog Structure
crates/cardano/src/model.rs
Added public fields fixed_cost: u64 and margin_cost: Option<RationalNumber> to StakeLog; updated CBOR naming indices to place new fields at n(10) and n(11), with existing operator_share remaining at n(9).
Log Computation
crates/cardano/src/rupd/mod.rs
Extended log_work to compute fixed_cost and margin_cost from pool parameters and include them in the StakeLog payload during emission.
Fee Calculation
crates/minibf/src/routes/pools.rs
Introduced bf_compatible_fees() helper that derives pool fees using margin, rewards, and fixed cost (capped at total rewards); replaced operator_share-based fee computation in by_id_history with this new calculation.

Sequence Diagram

sequenceDiagram
    participant Pool as Pool Parameters
    participant LogWork as log_work()
    participant Log as StakeLog
    participant Route as Pool History Route
    participant Fees as Fee Calculation

    Pool->>LogWork: Provide fixed_cost, margin
    LogWork->>Log: Store fixed_cost + margin_cost
    Log->>Route: Pass StakeLog record
    Route->>Fees: bf_compatible_fees(log)
    rect rgb(200, 220, 255)
        Note over Fees: margin × rewards<br/>+ fixed_cost<br/>(capped at total_rewards)
    end
    Fees->>Route: Return computed fee
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

The changes involve new field additions (routine), CBOR index verification, new computation logic, and modified fee calculation. The modifications are cohesive and logically connected across three files but require careful review of the new fee calculation logic and index ordering.

Possibly related PRs

Poem

A rabbit hops through pool-fee glades, 🐰
Where costs and margins intertwine—
Fixed expenses, now counted in shades,
Make the fee-math finally align! ✨
New fields bloom, old indices blend,
Cost computation—the journey's end.

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed The PR title "fix(minibf): use inaccurate but compatible pool fees value" directly addresses the primary user-facing change in this changeset: modifying how pool fees are calculated in the minibf module. The title accurately reflects the main change in crates/minibf/src/routes/pools.rs, where the fee calculation is being replaced with a new bf_compatible_fees helper function that derives fees from margin, rewards, and fixed_cost. The supporting infrastructure changes in model.rs and rupd/mod.rs (adding fixed_cost and margin_cost fields to StakeLog) are foundational modifications that enable this main change. The title is clear, specific, and conveys meaningful information about both the module being fixed and the nature of the change.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix/bf-fees

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@scarmuega
scarmuega merged commit 40dd833 into main Oct 17, 2025
11 of 12 checks passed
@scarmuega
scarmuega deleted the fix/bf-fees branch October 17, 2025 12:28

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
crates/minibf/src/routes/pools.rs (1)

210-220: Consider documenting the default margin value assumption.

When margin_cost is None, the code defaults to Rational64::from_integer(0), meaning 0% margin. This assumes pools without margin data have no margin. Verify this is the intended behavior and consider adding a comment explaining this assumption.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between f661284 and de6877a.

📒 Files selected for processing (3)
  • crates/cardano/src/model.rs (1 hunks)
  • crates/cardano/src/rupd/mod.rs (2 hunks)
  • crates/minibf/src/routes/pools.rs (2 hunks)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (4)
  • GitHub Check: Test (ubuntu-latest)
  • GitHub Check: Test (windows-latest)
  • GitHub Check: Test (macos-14)
  • GitHub Check: Test (macos-13)
🔇 Additional comments (3)
crates/cardano/src/rupd/mod.rs (2)

148-149: LGTM!

The extraction of fixed_cost and margin_cost from pool parameters is straightforward and handles the None case appropriately with sensible defaults.


165-166: LGTM!

The new fields are correctly passed to the StakeLog constructor.

crates/minibf/src/routes/pools.rs (1)

274-274: LGTM!

Correctly replaced operator_share with the new bf_compatible_fees function to compute fees using the blockfrost-compatible formula.

Comment on lines +335 to +342

/// Fixed cost
#[n(10)]
pub fixed_cost: u64,

/// Margin cost
#[n(11)]
pub margin_cost: Option<RationalNumber>,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

Verify CBOR backward compatibility for new StakeLog fields.

Adding fixed_cost as a required field (non-Option) at index n(10) may break deserialization of existing StakeLog records that were serialized before this change. When the decoder encounters old records without this field, the behavior depends on minicbor's handling of missing fields.

While the struct derives Default, verify that:

  1. Existing serialized StakeLog records can still be deserialized
  2. Missing fields are handled gracefully (e.g., using default values)
  3. Consider making fixed_cost optional (Option<u64>) if backward compatibility is required

Run the following script to check for existing usage patterns and serialization tests:


🏁 Script executed:

#!/bin/bash
# Search for StakeLog serialization/deserialization tests
rg -nP --type=rust -C5 'StakeLog.*decode|StakeLog.*encode|decode.*StakeLog|encode.*StakeLog'

# Find existing StakeLog records that might need migration
rg -nP --type=rust -C3 'write_log_typed.*StakeLog|read_log.*StakeLog'

Length of output: 1646


I'll need to gather more information to verify this backward compatibility concern. Let me search for the complete StakeLog definition and test patterns.


Let me also search the web for information about minicbor's backward compatibility behavior:




🏁 Script executed:

#!/bin/bash
# Find the complete StakeLog struct definition
rg -nA30 'struct StakeLog' --type=rust

# Search for tests related to StakeLog or serialization in test files
fd '.*test\.rs|.*_test\.rs|tests/' --type f | head -20

# Look for test modules within model.rs
rg -nP --type=rust '#\[test\]|#\[cfg\(test\)]' crates/cardano/src/model.rs -A10

Length of output: 1600


🏁 Script executed:

#!/bin/bash
# Check minicbor's handling of missing fields during deserialization
# This searches online documentation or crate information
echo "Searching for minicbor usage patterns and missing field behavior..."

Length of output: 67


🌐 Web query:

minicbor Rust CBOR missing fields deserialization default values

💡 Result:

Short answer: use minicbor-derive field attributes or serde defaults.

  • With minicbor's derive (minicbor-derive): mark the field with #[cbor(default)] (or #[cbor(skip)] for skipped/always-defaulted fields). The field's type must implement Default; when the field is absent the decode fills it with Default::default(). Optional/Option fields become None if not present. [1][2]

  • If you use the serde bridge (minicbor_serde), use the usual serde mechanisms: #[serde(default)] on fields or implement Default for the type (or use Option which deserializes to None when missing). [5]

References:
[1] minicbor-derive docs (attributes: #[cbor(default)], #[cbor(skip)], Option behavior).
[2] minicbor crate docs / design notes (optional fields default to None, missing fields can use Default).
[5] minicbor_serde docs (use serde::Deserialize/Serialize and serde defaults).


I need to see the complete StakeLog definition including its derive macros to verify the backward compatibility concern.


---

🏁 Script executed:

```shell
#!/bin/bash
# Get the StakeLog struct with full definition including derive macros
rg -nB5 -A50 'pub struct StakeLog' crates/cardano/src/model.rs | head -70

# Search for #[cbor(default)] usage in model.rs to see if pattern is used elsewhere
rg -n '#\[cbor\(' crates/cardano/src/model.rs

Length of output: 1329


Add #[cbor(default)] attribute to fixed_cost field to enable backward compatibility.

The backward compatibility concern is valid. StakeLog derives Encode and Decode but does not use #[cbor(default)] on any fields. When deserializing old records serialized before fixed_cost (at index 10) was added, minicbor will fail to deserialize because:

  1. Old records lack the field at index 10
  2. Without #[cbor(default)], the required u64 field cannot be filled with a default value
  3. margin_cost as Option<T> is safe (becomes None when missing), but fixed_cost as a required u64 is not

Fix: Add #[cbor(default)] to the fixed_cost field:

/// Fixed cost
#[n(10)]
#[cbor(default)]
pub fixed_cost: u64,

This allows minicbor to use Default::default() for old records missing this field, preserving backward compatibility.

🤖 Prompt for AI Agents
In crates/cardano/src/model.rs around lines 335 to 342, the new required u64
field `fixed_cost` (index 10) lacks a #[cbor(default)] attribute causing
minicbor to fail deserializing older records that don't have this field; add the
#[cbor(default)] attribute to the `fixed_cost` field so minicbor will use
Default::default() when the field is absent, preserving backward compatibility
while leaving `margin_cost: Option<RationalNumber>` unchanged.

Comment on lines +204 to +236
// HACK: blockfrost dbsync version computes fees at the SQL query level using
// the formula: `FLOOR(fee + (rewards - fee) * margin)`.
//
// This is not strictly correct, as the operator share has much more involved
// formula. This method is a workaround to make the data compatible with the
// blockfrost dbsync version.
fn bf_compatible_fees(log: &StakeLog) -> u64 {
let margin = log
.margin_cost
.as_ref()
.map(
|pallas::ledger::primitives::RationalNumber {
numerator,
denominator,
}| num_rational::Rational64::new(*numerator as i64, *denominator as i64),
)
.unwrap_or(num_rational::Rational64::from_integer(0));

let rewards = num_rational::Rational64::from_integer(log.total_rewards as i64);
let fixed_cost = num_rational::Rational64::from_integer(log.fixed_cost as i64);

let variable_fees = (rewards - fixed_cost) * margin;
let fixed_fees = fixed_cost;

let fees = variable_fees + fixed_fees;
let fees = fees.to_integer() as u64;

if fees > log.total_rewards {
log.total_rewards
} else {
fees
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

Verify truncation behavior matches FLOOR semantics.

The implementation uses to_integer() (line 229) which truncates towards zero, while the comment on line 205 mentions FLOOR. For positive values, these are equivalent, but they differ for negative values:

  • FLOOR(-1.5) = -2
  • to_integer(-1.5) = -1

Given that pool fees should be non-negative in practice and the cap at total_rewards (lines 231-235) provides a safeguard, this is likely acceptable. However, verify that:

  1. The blockfrost dbsync formula truly uses FLOOR or if it also truncates
  2. Edge cases where fixed_cost > rewards produce compatible results

The cap preventing fees from exceeding total_rewards is a good safeguard.


🌐 Web query:

Does PostgreSQL FLOOR function and Rust Rational64 to_integer produce different results for negative numbers?

💡 Result:

Yes — they differ for negative values.

  • PostgreSQL FLOOR returns the largest integer <= x (rounds toward −∞). Example: FLOOR(-3.14) = -4. [1]
  • num-rational's Rational64::to_integer converts by truncation (rounds toward 0). Example: Ratio(-3,2).to_integer() = -1. [2]

So for negative non-integers FLOOR gives a more negative result than Rational64::to_integer. [1][2]

Sources:
[1] PostgreSQL FLOOR behavior (examples showing FLOOR(-32.65) → -33).
[2] num-rational Ratio::to_integer doc (described as converting to integer by rounding toward zero).


Implement proper FLOOR semantics or add explicit safeguard against negative fees.

PostgreSQL's FLOOR function rounds toward −∞ (e.g., FLOOR(-3.14) = -4), while Rust's Rational64::to_integer() truncates toward 0. The code uses to_integer() at line 229, which does not match the FLOOR behavior documented in the comment at line 205.

More critically, when fixed_cost > rewards, the intermediate calculation (rewards - fixed_cost) * margin becomes negative. If this outweighs fixed_cost, the result can be negative. The cap at lines 231-235 only prevents fees from exceeding total_rewards—it does not guard against negative fees, which violates the blockfrost dbsync formula semantics.

Fix: Either replace to_integer() with proper FLOOR logic (e.g., fees.floor().to_integer()), or add explicit handling to ensure fees remain non-negative.

🤖 Prompt for AI Agents
In crates/minibf/src/routes/pools.rs around lines 204 to 236, the fee
computation uses Rational64::to_integer() which truncates toward zero and can
produce incorrect values vs PostgreSQL FLOOR semantics and can yield negative
fees when fixed_cost > rewards; replace the truncation with explicit FLOOR
semantics (call the Rational64 floor operation before converting to integer) or,
if you prefer, clamp the computed fees to a minimum of 0 after flooring, then
cast to u64 and still apply the existing cap against log.total_rewards so the
returned value is always between 0 and total_rewards.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant