Skip to content

fix(locked_in): pay real yield from shares instead of the projected APY - #47

Open
Ejirosam wants to merge 1 commit into
Lokt-In:stagingfrom
Ejirosam:fix/19-locked-in-real-yield
Open

Ejirosam wants to merge 1 commit into
Lokt-In:stagingfrom
Ejirosam:fix/19-locked-in-real-yield

Conversation

@Ejirosam

Copy link
Copy Markdown

Overview

lock() promised a yield computed from the APY table at lock time and unlock() paid amount + projected_yield out of the contract's pooled token balance. That balance is every user's principal, so an unfunded pool paid the first withdrawer's "yield" out of the second user's money.

This change replaces the promise with shares. A lock now holds shares of the pooled vault (crates/loktin-yield), and unlock() pays whatever those shares redeem for at that moment. If the pool earned nothing, a share is worth exactly what was paid for it, so two users who each lock 100 with no pool set both withdraw 100 and the contract is never short. projected_yield stays on Lock as a display estimate only, and is never paid as a promise.

The same file's two smaller defects are fixed: the duration range check now runs before the tier lookup (so InvalidDuration fires for 0 and 61 instead of DurationTierMissing), and one month is 365 / 12 days so a 12-month lock earns the advertised APY over a full 365-day year rather than 360 days.

Dependency note: loktin-yield (#37) and loktin-common (#38) were still unmerged on staging when this was written. This PR adds the minimal crates/loktin-yield crate that #40 requires (same API as #37's specification) so the fix is self-contained; if #37 lands first, this branch consumes it unchanged. The instance-TTL and 1-indexed-id behaviour is applied locally with helpers named to match loktin-common, which can replace them without a behavioural change.

Related Issue

#40 — "fix(locked_in): pay real yield instead of yield promised at lock time".

Changes

  • [ADD] crates/loktin-yield/Cargo.toml

    • New workspace library crate (rlib) holding the share math and the pool interface.
  • [ADD] crates/loktin-yield/src/lib.rs

    • shares_for_deposit, assets_for_shares, position_yield (ERC-4626-style, rounds down) and the Pool client trait matching mock_pool's supply / withdraw / get_position.
    • Documents loudly that a deposit too small to buy a whole share mints 0 and that callers must reject it.
  • [ADD] crates/loktin-yield/src/test.rs

    • 1:1 first deposit, no dilution of an earlier depositor, dust → 0 shares, principal + real gain on redemption, and the property that total redeemable never exceeds the vault.
  • [MODIFY] Cargo.toml / Cargo.lock

    • Workspace members become ["contracts/*", "crates/*"]; lockfile gains the loktin-yield package and the locked_in dependency edge.
  • [MODIFY] contracts/locked_in/Cargo.toml

    • Depends on loktin-yield via path.
  • [MODIFY] contracts/locked_in/src/types.rs

    • Lock gains shares; projected_yield is documented as a display-only estimate. DataKey gains TotalShares.
  • [MODIFY] contracts/locked_in/src/lib.rs

    • lock() prices the deposit against the vault's pre-deposit value, rejects a zero-share mint (Error::ZeroShares), records shares, and increments TotalShares. Duration range check moved ahead of the tier lookup. One month is now 2_628_000 s (365 / 12 days).
    • unlock() pays assets_for_shares(shares, total_shares, vault value), pulling the shortfall from the pool first and failing with Error::InsufficientLiquidity rather than underpaying. Local Pool trait removed in favour of loktin_yield::PoolClient.
    • Adds total_shares() / total_assets() reads and extends the instance TTL alongside persistent TTLs.
  • [MODIFY] contracts/locked_in/src/error.rs

    • Adds ZeroShares = 18 and InsufficientLiquidity = 19.
  • [MODIFY] contracts/locked_in/src/events.rs

    • Locked event carries shares alongside the display estimate.
  • [MODIFY] contracts/locked_in/src/test.rs

Verification Results

Produced entirely through the GitHub REST API (contents/trees/blobs), no local
clone and no local Rust toolchain was run. Every changed file was read back from
the API and the test expectations were derived by hand from the mock pool's
accrual formula (principal * apy_bps * elapsed / (10000 * SECONDS_PER_YEAR)).

Not run here: cargo test --workspace, cargo fmt, cargo clippy. CI for this repo
only covers keeper/node, so a maintainer or a follow-up run is needed to confirm
the workspace builds and the tests pass. The share math is covered independently
by crates/loktin-yield/src/test.rs.

New / updated tests:
crates/loktin-yield/src/test.rs
  first_deposit_mints_one_to_one
  second_depositor_does_not_dilute_the_first
  deposit_too_small_for_one_share_returns_zero
  withdrawal_after_appreciation_returns_principal_plus_real_gain
  total_redeemable_never_exceeds_the_pool
contracts/locked_in/src/test.rs
  test_invalid_duration_fires_for_zero_and_sixty_one
  test_zero_share_mint_is_rejected
  test_user_cannot_withdraw_more_than_shares_redeem_for
  test_two_users_no_pool_both_withdraw_in_full
  test_pool_yield_splits_evenly_and_vault_is_never_short
  test_unlock_pays_real_pool_yield
  test_lock_records_shares_and_display_estimate
  test_unlock_without_a_pool_returns_exactly_the_principal
Acceptance Criteria Status
Multi-user regression test using the #36 harness: two users lock, no pool set, both withdraw in full and the contract is never short ✅ test_two_users_no_pool_both_withdraw_in_full. Adds a local harness (funded_user, assert_solvent); #36 is still unmerged, so the helpers live in this test module and move to the shared crate when it lands.
Test proving a user cannot withdraw more than their shares redeem for ✅ test_user_cannot_withdraw_more_than_shares_redeem_for asserts a non-zero projected_yield but pays only the shares' real value.
Test proving InvalidDuration fires for duration 0 and for 61 ✅ test_invalid_duration_fires_for_zero_and_sixty_one asserts Err(Ok(Error::InvalidDuration)) for both; the range check now precedes the tier lookup.
Zero-share mint rejected, with a test ✅ shares <= 0 → Error::ZeroShares before the token transfer; test_zero_share_mint_is_rejected also asserts the balance is untouched and a whole-share deposit still works.
Instance TTL extended via loktin-common ⚠️ Instance TTL is now extended (extend_ttl_instance, called from the constructor and every state-changing entry point), but loktin-common (#38) is not on staging, so a local mirror with the same name is used.
IDs 1-indexed via loktin-common ⚠️ IDs are 1-indexed and now covered by test_lock_ids_are_one_indexed (they already started at 1 via next_id); the shared loktin-common::next_id is not on staging yet.
Month/year constant discrepancy resolved and documented ✅ One month is 2_628_000 s = 365 / 12 days, so 12 months is exactly SECONDS_PER_YEAR (365 days) and a "10% APY" tier pays 10%; documented at the constant and asserted in test_lock_records_shares_and_display_estimate.
cargo test --workspace passes ➖ Not run here (no local Rust toolchain in this workflow); tests were written against the mock pool's accrual formula and the workspace builds via the added crate/lockfile entries.

Closes #40

@drips-wave

drips-wave Bot commented Sep 26, 2026

Copy link
Copy Markdown

@Ejirosam Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@Ejirosam
Ejirosam force-pushed the fix/19-locked-in-real-yield branch from b102908 to f8ef699 Compare October 12, 2026 06:49
@Ejirosam
Ejirosam force-pushed the fix/19-locked-in-real-yield branch 2 times, most recently from 0bbc2cc to c376305 Compare October 15, 2026 00:13

This branch has not been deployed

No deployments
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.

fix(locked_in): pay real yield instead of yield promised at lock time

2 participants