Skip to content

fix(shared): compound the min deposit rate over the period - #452

Open
pucedoteth wants to merge 1 commit into
nadohq:mainfrom
pucedoteth:fix/min-deposit-rate-period-scaling
Open

fix(shared): compound the min deposit rate over the period#452
pucedoteth wants to merge 1 commit into
nadohq:mainfrom
pucedoteth:fix/min-deposit-rate-period-scaling

Conversation

@pucedoteth

@pucedoteth pucedoteth commented Aug 26, 2026

Copy link
Copy Markdown

The bug

calcBorrowRateForTimeRange compounds the borrow rate over seconds, then adds minDepositRate unchanged:

const borrowRateForTime =
  borrowRatePerSecond.plus(1).toNumber() ** toBigNumber(seconds).toNumber() - 1;
return toBigNumber(borrowRateForTime).plus(toBigNumber(minDepositRate));

minDepositRate is an annual figure — SpotProduct.minDepositRate is populated from removeDecimals(product.config.min_deposit_rate_x18) in queryDataMappers.ts, and it is a field on the same product already being passed in, so it is the natural thing for a caller to hand to this parameter. The two terms are in different units: one is a rate for seconds, the other is a rate for a year.

SpotEngine._updateState does not add it. It converts to a per-second rate, compounds it over dt, and multiplies the result into both multipliers:

int128 minDepositRatePerSecondX18 = minDepositRateX18.div(MathSD21x18.fromInt(31536000));
int128 minDepositRateMultiplierX18 = (ONE + minDepositRatePerSecondX18).pow(int128(dt));

state.cumulativeBorrowsMultiplierX18  = state.cumulativeBorrowsMultiplierX18.mul(minDepositRateMultiplierX18);
state.cumulativeDepositsMultiplierX18 = state.cumulativeDepositsMultiplierX18.mul(minDepositRateMultiplierX18);

With a 5% min deposit rate, a 3.5% borrow curve and 50% utilization, the SDK reports a full year of the floor no matter how short the window:

period SDK engine overstated by
1 hour 5.0004% 0.0010% 5153x
1 day 5.0096% 0.0233% 215x
30 days 5.2881% 0.7011% 7.5x
1 year 8.5620% 8.8717% ~1x

The error vanishing at exactly one year is the shape of a missing per-period conversion — a year is the only window where an annual rate added unscaled is nearly right.

The same problem on the deposit side

calcRealizedDepositRateForTimeRange returns 0 when nothing is borrowed:

const utilization = calcUtilizationRatio(product);
if (utilization.eq(0)) {
  return toBigNumber(0);
}

On chain, zero utilization only zeroes the borrower rate. The minDepositRate block runs regardless and multiplies the deposit multiplier, so depositors still earn the floor when the pool is idle — which is what a rate floor is for. It also added the annual rate to the period rate, the same way the borrow path did.

The fix

A shared helper produces the engine's minDepositRateMultiplier, and both functions apply it multiplicatively the way _updateState does. minDepositRate is now documented with @param on both — it was the only undocumented parameter on either.

calcRealizedDepositRateForTimeRange calls calcBorrowRateForTimeRange(product, seconds, 0) internally; a zero rate gives a multiplier of exactly 1, so that composition still yields the engine's borrowRateMultiplier - ONE.

Test plan

packages/shared/src/utils/interest.test.ts adds 8 cases pinned to the engine's formula rather than to the current output, covering an hour, a day, 30 days and a year, plus the idle-pool deposit case.

Reverting only interest.ts and keeping the tests fails with exactly the table above:

Expected: 0.000009703242916270227   (1 hour, engine)
Received: 0.0500039954415636        (1 hour, SDK)

Expected: 0.0002329038180453047     (1 day, engine)
Received: 0.05009589500359431       (1 day, SDK)
npx jest packages/shared     # 30 passed, 3 suites
tsc --noEmit                 # clean
eslint                       # clean

The repo's pre-commit gate (typecheck, eslint, depcruise, prettier) passes on the commit. Note that a full bun typecheck only resolves after bun run build, since the workspace packages resolve @nadohq/shared through dist/; packages/mobile-client/src/signing/signing.test.ts fails to load for the same reason on a clean checkout, unrelated to this change.

Note for the other SDKs

nado-python-sdk's calc_borrow_rate_in_period takes no min deposit rate at all, and calc_deposit_rate_in_period does not apply one, so it under-reports wherever this SDK over-reported. Happy to send the matching Python change if useful.

calcBorrowRateForTimeRange compounds the borrow rate over `seconds` and then
adds `minDepositRate` unchanged. minDepositRate is an annual figure —
SpotProduct.minDepositRate comes from removeDecimals(config.min_deposit_rate_x18)
— so a period rate and an annual rate were being summed.

SpotEngine._updateState does not add it. It converts to a per-second rate,
compounds it over dt, and multiplies the result into both the borrow and the
deposit multiplier:

    minDepositRatePerSecondX18 = minDepositRateX18 / 31536000
    minDepositRateMultiplierX18 = (ONE + minDepositRatePerSecondX18).pow(dt)
    cumulativeBorrowsMultiplierX18 *= minDepositRateMultiplierX18
    cumulativeDepositsMultiplierX18 *= minDepositRateMultiplierX18

With a 5% min deposit rate the SDK reported a full year of it no matter how
short the window — 5.0004% for one hour against 0.0010% on chain, and 5.0096%
for one day against 0.0233%. The error disappears at exactly one year, which
is the shape of a missing per-period conversion.

calcRealizedDepositRateForTimeRange had the second half of the same problem:
it returns 0 when nothing is borrowed, but the engine applies the min deposit
multiplier whether or not there are borrows, so depositors earn the floor in
that state rather than nothing.

Both now mirror the engine, and minDepositRate is documented on both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@claude claude 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.

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

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