From 0b88e7338260b03eb02f1ac9791cdb34feb3136e Mon Sep 17 00:00:00 2001 From: liuky Date: Tue, 6 Jan 2026 16:30:17 +0900 Subject: [PATCH 1/5] add API for intermediate operation --- deposit_pool/sources/deposit_pool.move | 84 ++++++++++++++++++-------- deposit_pool/sources/reward_pool.move | 17 +++++- 2 files changed, 72 insertions(+), 29 deletions(-) diff --git a/deposit_pool/sources/deposit_pool.move b/deposit_pool/sources/deposit_pool.move index 5dbef31..fb80b2f 100644 --- a/deposit_pool/sources/deposit_pool.move +++ b/deposit_pool/sources/deposit_pool.move @@ -2,6 +2,7 @@ /// Users can lock their tokens for different time periods with varying APY rates. module deposit_pool::deposit_pool; +use std::option::{none, some}; use std::u128::pow; use sui::bag::{Self, Bag}; use sui::balance::{zero, Balance}; @@ -10,7 +11,7 @@ use sui::coin::{TreasuryCap, Coin}; use sui::dynamic_field as df; use sui::object::id; use sui::table::{Self, Table}; -use sui::token; +use sui::token::{Self, Token}; // ===================== Error Codes================ /// Error when caller is not the admin @@ -36,7 +37,7 @@ const EInvalidExtendTerm: u64 = 9; // ====================== Const ================= /// Current version of the contract -const VERSION: u64 = 1; +const VERSION: u64 = 2; /// Milliseconds in one day (<2^27) const MS_PER_DAY: u64 = 86400000; const DAY_PER_YEAR: u128 = 365; @@ -135,6 +136,20 @@ public fun deposit( clock: &Clock, ctx: &mut TxContext, ) { + transfer::transfer( + do_deposit(pool, coin, term, expected_apy, clock, ctx), + recipient, + ); +} + +public fun do_deposit( + pool: &mut DepositPool, + coin: Coin, + term: u32, + expected_apy: Option, + clock: &Clock, + ctx: &mut TxContext, +): Receipt { assert!(pool.version == VERSION, EWrongVersion); let (lock_term, apy) = if (pool.return_rates.contains(term)) { @@ -146,19 +161,18 @@ public fun deposit( // if expected_apy has some value, ensure fetched apy is higer. assert!(expected_apy.destroy_or!(0)<=apy, EApyMismatched); - transfer::transfer( - Receipt { - id: object::new(ctx), - pool_id: object::id(pool), - amount: coin.value(), - issue_at_ms: clock.timestamp_ms(), - mature_at_ms: clock.timestamp_ms()+(lock_term as u64)*MS_PER_DAY, // secure within u64 - apy: apy, - }, - recipient, - ); + let receipt = Receipt { + id: object::new(ctx), + pool_id: object::id(pool), + amount: coin.value(), + issue_at_ms: clock.timestamp_ms(), + mature_at_ms: clock.timestamp_ms()+(lock_term as u64)*MS_PER_DAY, // secure within u64 + apy: apy, + }; pool.balance.join(coin.into_balance()); + + receipt } /// Withdraws base tokens and claims loyalty tokens if eligible @@ -168,6 +182,22 @@ entry fun withdraw( clock: &Clock, ctx: &mut TxContext, ) { + let (coin, token) = do_withdrawal(pool, receipt, clock, ctx); + + coin.destroy!(|c| transfer::public_transfer(c, ctx.sender())); + + token.destroy!(|token| { + let req = token::transfer(token, ctx.sender(), ctx); + token::confirm_with_treasury_cap(&mut pool.treasury_cap, req, ctx); + }) +} + +public fun do_withdrawal( + pool: &mut DepositPool, + receipt: Receipt, + clock: &Clock, + ctx: &mut TxContext, +): (Option>, Option>) { assert!(pool.version == VERSION, EWrongVersion); assert!(receipt.pool_id == object::id(pool), EWrongPool); @@ -191,7 +221,7 @@ entry fun withdraw( clock.timestamp_ms() + pool.options[KEY_WITHDRAWAL_PENDING], ); transfer::transfer(receipt, ctx.sender()); - return + return (none(), none()) } }; @@ -205,21 +235,23 @@ entry fun withdraw( issue_at_ms, apy, ); - if (token_amount>0) { - let token = token::mint( - &mut pool.treasury_cap, - token_amount, - ctx, - ); - let req = token::transfer(token, ctx.sender(), ctx); - - token::confirm_with_treasury_cap(&mut pool.treasury_cap, req, ctx); - }; id.delete(); + let value = pool.balance.split(amount).into_coin(ctx); + if (token_amount>0) { + return ( + some(value), + some( + token::mint( + &mut pool.treasury_cap, + token_amount, + ctx, + ), + ), + ) + }; - // return base - transfer::public_transfer(pool.balance.split(amount).into_coin(ctx), ctx.sender()); + (some(value), none()) } /// Upgrade the term of premature deposit receipt if support diff --git a/deposit_pool/sources/reward_pool.move b/deposit_pool/sources/reward_pool.move index 4ce274c..b1a4c6c 100644 --- a/deposit_pool/sources/reward_pool.move +++ b/deposit_pool/sources/reward_pool.move @@ -88,8 +88,7 @@ entry fun refresh(pool: &mut RewardPool, coin: }); } -/// Claims rewards by spending loyalty tokens. Transfers reward tokens to the user -/// and emits a redeem event. +/// Claims rewards and transfers reward tokens to the user #[allow(lint(self_transfer))] public fun claim( pool: &mut RewardPool, @@ -98,6 +97,17 @@ public fun claim( expected_return: Option, ctx: &mut TxContext, ) { + transfer::public_transfer(pool.do_claim(token, policy, expected_return, ctx), ctx.sender()); +} + +/// Claims rewards by spending loyalty tokens and emits a redeem event. +public fun do_claim( + pool: &mut RewardPool, + token: Token, + policy: &mut TokenPolicy, + expected_return: Option, + ctx: &mut TxContext, +): Coin { let claim_amount = pool.exchange_rate.exchange_amount(token.value()); // add expect return constraint @@ -110,12 +120,13 @@ public fun claim( let (token_name, amount, user_address, _) = confirm_request_mut(policy, req, ctx); - transfer::public_transfer(pool.balance.split(claim_amount).into_coin(ctx), ctx.sender()); event::emit(RewardRedeemedEvent { token_name, amount, user_address, }); + + pool.balance.split(claim_amount).into_coin(ctx) } #[allow(lint(self_transfer))] From f1878b0186eb0521db7e9271390438817a61ddc5 Mon Sep 17 00:00:00 2001 From: liuky Date: Tue, 6 Jan 2026 22:09:14 +0900 Subject: [PATCH 2/5] enable receipt wrapper and add tests --- README.md | 3 + deposit_pool/sources/deposit_pool.move | 82 ++++++- deposit_pool/tests/deposit_pool_tests.move | 272 +++++++++++++++++++++ deposit_pool/tests/integration_tests.move | 154 ++++++++++++ 4 files changed, 509 insertions(+), 2 deletions(-) create mode 100644 deposit_pool/tests/integration_tests.move diff --git a/README.md b/README.md index ce982f7..8d297a8 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,8 @@ deposit_pool::deposit_pool::deposit(pool, base_coin, term_days, clock, recipient deposit_pool::deposit_pool::withdrawal(pool, receipt, clock, ctx); ``` +`do_deposit` and `do_withdraw` allows PTB operation, while the `do_deposit` usually should be used only when the pool enabled receipt wrapper, otherwise no method can consume the receipt. + ### Reward Pool Module The reward pool module manages reward pools and allows users to claim rewards by spending loyalty tokens. Pools can be refreshed with more rewards, and events are emitted for transparency. @@ -105,6 +107,7 @@ deposit_pool::reward_program::reward_fresh(pool, additional_reward_coin); deposit_pool::reward_program::claim(pool, loyalty_token, policy, Some(minimum_expected_reward), ctx); ``` +`do_claim` can be used for PTB operation. ## Deployment The project includes an automated deployment script that: diff --git a/deposit_pool/sources/deposit_pool.move b/deposit_pool/sources/deposit_pool.move index fb80b2f..fdf3349 100644 --- a/deposit_pool/sources/deposit_pool.move +++ b/deposit_pool/sources/deposit_pool.move @@ -34,6 +34,8 @@ const EApyMismatched: u64 = 7; const ENotSupportExtendTerm: u64 = 8; /// Error when user tries to extend term with wrong parameters; const EInvalidExtendTerm: u64 = 9; +/// Error when the user tries to create a receipt wrapper that is not supported; +const ENotSupportReceiptWrapper: u64 = 10; // ====================== Const ================= /// Current version of the contract @@ -51,6 +53,9 @@ const KEY_WITHDRAWAL_PENDING: u8 = 2; /// Option key for enable upgrade the term for higher apy (bool), optional. const KEY_SUPPORT_TERM_EXTENSION: u8 = 3; +/// Option key for enable upgrade the term for higher apy (bool), optional. +const KEY_SUPPORT_RECEIPT_WRAPER_EXTENSION: u8 = 4; + public struct AdminCap has key, store { id: UID, } @@ -89,6 +94,21 @@ public struct Receipt has key { apy: u16, } +/// Receipt given to users when they deposit tokens +public struct ReceiptWrapper has key, store { + id: UID, + /// ID of the pool where deposit was made + pool_id: ID, + /// Amount of base tokens deposited + amount: u64, + /// Timestamp when deposit was made + issue_at_ms: u64, + /// Timestamp when lock period ends + mature_at_ms: u64, + /// a shifted apy, need to be divided by 10**`pool.rate_decimal` + apy: u16, +} + /// Initializes a new deposit pool with base APY and configuration entry fun new( treasury_cap: TreasuryCap, @@ -137,7 +157,7 @@ public fun deposit( ctx: &mut TxContext, ) { transfer::transfer( - do_deposit(pool, coin, term, expected_apy, clock, ctx), + pool.do_deposit(coin, term, expected_apy, clock, ctx), recipient, ); } @@ -182,7 +202,7 @@ entry fun withdraw( clock: &Clock, ctx: &mut TxContext, ) { - let (coin, token) = do_withdrawal(pool, receipt, clock, ctx); + let (coin, token) = pool.do_withdrawal(receipt, clock, ctx); coin.destroy!(|c| transfer::public_transfer(c, ctx.sender())); @@ -375,6 +395,64 @@ public fun add_reward_program( transfer::public_transfer(policy_cap, tx_context::sender(ctx)); } +// allow to store a receipt through a wrapper +public fun enable_receipt_wrapper( + pool: &mut DepositPool, + admin: &mut AdminCap, +) { + assert!(pool.admin_cap_id == object::id(admin), ENotAdmin); + assert!(pool.version == VERSION, EWrongVersion); + + pool.options.add(KEY_SUPPORT_RECEIPT_WRAPER_EXTENSION, true); +} + +public fun receipt_to_wrapper( + pool: &DepositPool, + receipt: Receipt, + ctx: &mut TxContext, +): ReceiptWrapper { + assert!(pool.version == VERSION, EWrongVersion); + assert!(receipt.pool_id == object::id(pool), EWrongPool); + assert!(pool.options.contains(KEY_SUPPORT_RECEIPT_WRAPER_EXTENSION), ENotSupportReceiptWrapper); + + // consume receipt + let Receipt { id, pool_id, amount, issue_at_ms, mature_at_ms, apy } = receipt; + id.delete(); + + // create receipt wrapper + ReceiptWrapper { + id: object::new(ctx), + pool_id, + amount, + issue_at_ms, + mature_at_ms, + apy, + } +} + +public fun wrapper_to_receipt( + pool: &DepositPool, + wrapper: ReceiptWrapper, + ctx: &mut TxContext, +): Receipt { + assert!(pool.version == VERSION, EWrongVersion); + assert!(wrapper.pool_id == object::id(pool), EWrongPool); + + // consume receipt wrapper + let ReceiptWrapper { id, pool_id, amount, issue_at_ms, mature_at_ms, apy } = wrapper; + id.delete(); + + // create receipt + Receipt { + id: object::new(ctx), + pool_id, + amount, + issue_at_ms, + mature_at_ms, + apy, + } +} + /// Upgrades the pool to a new version entry fun migrate(pool: &mut DepositPool, admin: &AdminCap) { assert!(pool.admin_cap_id == object::id(admin), ENotAdmin); diff --git a/deposit_pool/tests/deposit_pool_tests.move b/deposit_pool/tests/deposit_pool_tests.move index 641d1ce..f71d800 100644 --- a/deposit_pool/tests/deposit_pool_tests.move +++ b/deposit_pool/tests/deposit_pool_tests.move @@ -1384,3 +1384,275 @@ fun test_upgrade_to_a_lower_term() { clock.destroy_for_testing(); scenario.end(); } + +#[test] +fun test_withdraw_and_redeposit_in_same_transaction() { + let mut scenario = ts::begin(ADMIN); + // Create treasury cap for Loyalty token + let loyalty_cap = coin::create_treasury_cap_for_testing(scenario.ctx()); + + // Initialize pool + deposit_pool::new( + loyalty_cap, + Base_APY, + none(), + true, + 0, + scenario.ctx(), + ); + + scenario.next_tx(ADMIN); + let mut pool = ts::take_shared>(&scenario); + let mut admin_cap = ts::take_from_address(&scenario, ADMIN); + + // Add a lock term + pool.upsert_lock_term(&mut admin_cap, Lock_DAY, Base_APY); + + // enable wrapper for stroing receipt + pool.enable_receipt_wrapper(&mut admin_cap); + + ts::return_to_address(ADMIN, admin_cap); + + // Setup clock + let mut clock = clock::create_for_testing(scenario.ctx()); + clock::set_for_testing(&mut clock, 0); + + // First deposit + scenario.next_tx(USER); + { + let coin_base = coin::mint_for_testing(Deposit, scenario.ctx()); + + pool.deposit( + coin_base, + Lock_DAY, + USER, + none(), + &clock, + scenario.ctx(), + ); + }; + + // Advance clock past maturity + clock.increment_for_testing(Lock_DAY as u64 * MS_PER_DAY); + + // Withdraw and immediately redeposit in the same transaction + scenario.next_tx(USER); + { + let receipt = ts::take_from_address(&scenario, USER); + + // Withdraw the previously deposited tokens + let (coin_base_opt, _loyalty_tokens) = pool.do_withdrawal(receipt, &clock, scenario.ctx()); + + // burn loyalty token; + _loyalty_tokens.destroy!(|t| t.burn_for_testing()); + + // Extract the coin from Option + let coin_base = coin_base_opt.destroy_some(); + + // Verify we got the correct amount back + assert!(coin::value(&coin_base) == Deposit, 1); + + // Now redeposit the withdrawn coins for another lock period + let new_receipt = pool.do_deposit( + coin_base, + Lock_DAY, + none(), + &clock, + scenario.ctx(), + ); + + // Transfer the new receipt to the user + let wrapper = pool.receipt_to_wrapper(new_receipt, scenario.ctx()); + // now wrapper can be transferred publicly + transfer::public_transfer(wrapper, USER); + ts::return_shared(pool); + }; + + clock.destroy_for_testing(); + scenario.end(); +} + +#[test] +fun test_receipt_wrapper_enable_and_convert() { + let mut scenario = init_deposit_pool(true, 0); + let mut clock = clock::create_for_testing(scenario.ctx()); + clock::set_for_testing(&mut clock, 0); + + scenario.next_tx(ADMIN); + { + let mut pool = ts::take_shared>(&scenario); + let mut admin_cap = ts::take_from_address(&scenario, ADMIN); + + // Enable receipt wrapper feature + pool.enable_receipt_wrapper(&mut admin_cap); + + ts::return_to_address(ADMIN, admin_cap); + ts::return_shared(pool); + }; + + // Create a deposit + scenario.next_tx(USER); + { + let mut pool = ts::take_shared>(&scenario); + let coin_base = coin::mint_for_testing(Deposit, scenario.ctx()); + + let receipt = pool.do_deposit( + coin_base, + Lock_DAY, + none(), + &clock, + scenario.ctx(), + ); + + // Convert receipt to wrapper + let wrapper = pool.receipt_to_wrapper(receipt, scenario.ctx()); + + // Convert wrapper back to receipt + let new_receipt = pool.wrapper_to_receipt(wrapper, scenario.ctx()); + + pool.withdraw(new_receipt, &clock, scenario.ctx()); + ts::return_shared(pool); + }; + + clock.destroy_for_testing(); + scenario.end(); +} + +#[test] +#[expected_failure(abort_code = deposit_pool::ENotSupportReceiptWrapper)] +fun test_receipt_wrapper_without_enable() { + let mut scenario = init_deposit_pool(true, 0); + let mut clock = clock::create_for_testing(scenario.ctx()); + clock::set_for_testing(&mut clock, 0); + + // Create a deposit without enabling wrapper feature + scenario.next_tx(USER); + { + let mut pool = ts::take_shared>(&scenario); + let coin_base = coin::mint_for_testing(Deposit, scenario.ctx()); + + let receipt = pool.do_deposit( + coin_base, + Lock_DAY, + none(), + &clock, + scenario.ctx(), + ); + + // Try to convert to wrapper without enabling - should fail + let wrapper = pool.receipt_to_wrapper(receipt, scenario.ctx()); + transfer::public_transfer(wrapper, USER); + ts::return_shared(pool); + }; + + clock.destroy_for_testing(); + scenario.end(); +} + +#[test] +fun test_receipt_wrapper_store_and_retrieve() { + let mut scenario = init_deposit_pool(true, 0); + let mut clock = clock::create_for_testing(scenario.ctx()); + clock::set_for_testing(&mut clock, 0); + + scenario.next_tx(ADMIN); + { + let mut pool = ts::take_shared>(&scenario); + let mut admin_cap = ts::take_from_address(&scenario, ADMIN); + + // Enable receipt wrapper feature + pool.enable_receipt_wrapper(&mut admin_cap); + + ts::return_to_address(ADMIN, admin_cap); + ts::return_shared(pool); + }; + + // Create a deposit and store wrapper in dynamic field + scenario.next_tx(USER); + { + let mut pool = ts::take_shared>(&scenario); + let coin_base = coin::mint_for_testing(Deposit, scenario.ctx()); + + let receipt = pool.do_deposit( + coin_base, + Lock_DAY, + none(), + &clock, + scenario.ctx(), + ); + + // Convert to wrapper + let wrapper = pool.receipt_to_wrapper(receipt, scenario.ctx()); + + // Store wrapper in a temporary object (simulating storage) + // For this test, we'll just verify the wrapper contains correct data and convert back + let retrieved_wrapper = wrapper; + + // Convert back to receipt + let final_receipt = pool.wrapper_to_receipt(retrieved_wrapper, scenario.ctx()); + + pool.withdraw(final_receipt, &clock, scenario.ctx()); + ts::return_shared(pool); + }; + + clock.destroy_for_testing(); + scenario.end(); +} + +#[test] +fun test_receipt_wrapper_public_transfer() { + let mut scenario = init_deposit_pool(true, 0); + let mut clock = clock::create_for_testing(scenario.ctx()); + clock::set_for_testing(&mut clock, 0); + + scenario.next_tx(ADMIN); + { + let mut pool = ts::take_shared>(&scenario); + let mut admin_cap = ts::take_from_address(&scenario, ADMIN); + + // Enable receipt wrapper feature + pool.enable_receipt_wrapper(&mut admin_cap); + + ts::return_to_address(ADMIN, admin_cap); + ts::return_shared(pool); + }; + + // Create a deposit and transfer wrapper (which is public_transfer enabled via store ability) + scenario.next_tx(USER); + { + let mut pool = ts::take_shared>(&scenario); + let coin_base = coin::mint_for_testing(Deposit, scenario.ctx()); + + let receipt = pool.do_deposit( + coin_base, + Lock_DAY, + none(), + &clock, + scenario.ctx(), + ); + + // Convert to wrapper and transfer to another address + let wrapper = pool.receipt_to_wrapper(receipt, scenario.ctx()); + + // Wrapper can be publicly transferred because it has store ability + transfer::public_transfer(wrapper, @0xC0FF); + + ts::return_shared(pool); + }; + + // Retrieve wrapper from the other address and convert back + scenario.next_tx(@0xC0FF); + { + let mut pool = ts::take_shared>(&scenario); + let wrapper = ts::take_from_address(&scenario, @0xC0FF); + + // Convert back to receipt + let receipt = pool.wrapper_to_receipt(wrapper, scenario.ctx()); + + pool.withdraw(receipt, &clock, scenario.ctx()); + ts::return_shared(pool); + }; + + clock.destroy_for_testing(); + scenario.end(); +} diff --git a/deposit_pool/tests/integration_tests.move b/deposit_pool/tests/integration_tests.move new file mode 100644 index 0000000..ca5435a --- /dev/null +++ b/deposit_pool/tests/integration_tests.move @@ -0,0 +1,154 @@ +#[test_only] +module deposit_pool::integration_tests; + +use deposit_pool::deposit_pool::{Self, DepositPool, Receipt}; +use deposit_pool::reward_pool::{Self as reward_pool, RewardPool}; +use std::option::none; +use sui::clock; +use sui::coin; +use sui::test_scenario as ts; +use sui::token::{Self, TokenPolicy}; + +const ADMIN: address = @0xA11ce; +const USER: address = @0xB0B; +const Base_APY: u16 = 5; +const Deposit: u64 = 1000000000; +const Lock_DAY: u32 = 365; +const MS_PER_DAY: u64 = 86400000; +const DEFAULT_DECIMAL: u8 = 2; +const INITIAL_REWARD_SUPPLY: u64 = 100000000000; + +// 1 Loyalty = 10 Reward +const LOYALTY_RER_UNIT: u64 = 1; +const REWARD_PER_UNIT: u64 = 10; + +public struct Loyalty has drop {} +public struct Base has drop {} +public struct Reward has drop {} + +#[test] +fun test_withdraw_redeem_and_redeposit_in_same_transaction() { + let mut scenario = ts::begin(ADMIN); + let loyalty_cap = coin::create_treasury_cap_for_testing(scenario.ctx()); + + deposit_pool::new( + loyalty_cap, + Base_APY, + none(), + true, + 0, + scenario.ctx(), + ); + + // Initialize reward pool + let reward_coins = coin::mint_for_testing(INITIAL_REWARD_SUPPLY, scenario.ctx()); + reward_pool::new( + reward_coins, + LOYALTY_RER_UNIT, + REWARD_PER_UNIT, + scenario.ctx(), + ); + + // Setup token policy for Loyalty token + scenario.next_tx(ADMIN); + let loyalty_cap = coin::create_treasury_cap_for_testing(scenario.ctx()); + let (mut policy, policy_cap) = token::new_policy(&loyalty_cap, scenario.ctx()); + + token::add_rule_for_action( + &mut policy, + &policy_cap, + token::spend_action(), + scenario.ctx(), + ); + + token::share_policy(policy); + transfer::public_transfer(policy_cap, ADMIN); + transfer::public_freeze_object(loyalty_cap); + + scenario.next_tx(ADMIN); + let mut pool = ts::take_shared>(&scenario); + let mut admin_cap = ts::take_from_address(&scenario, ADMIN); + + // Add lock terms + pool.upsert_lock_term(&mut admin_cap, Lock_DAY, Base_APY * 2); + + // enable wrapper for stroing receipt + pool.enable_receipt_wrapper(&mut admin_cap); + + ts::return_to_address(ADMIN, admin_cap); + + let mut clock = clock::create_for_testing(scenario.ctx()); + clock::set_for_testing(&mut clock, 0); + + // First deposit + scenario.next_tx(USER); + { + let coin_base = coin::mint_for_testing(Deposit, scenario.ctx()); + + pool.deposit( + coin_base, + Lock_DAY, + USER, + none(), + &clock, + scenario.ctx(), + ); + }; + + // Advance clock past maturity + clock.increment_for_testing(Lock_DAY as u64 * MS_PER_DAY); + + // Withdraw, redeem loyalty tokens via reward pool, and redeposit base coins in same transaction + scenario.next_tx(USER); + { + let receipt = ts::take_from_address(&scenario, USER); + + // Withdraw: get base coins and loyalty token rewards + let (coin_base_opt, loyalty_tokens_opt) = pool.do_withdrawal( + receipt, + &clock, + scenario.ctx(), + ); + + let coin_base = coin_base_opt.destroy_some(); + let loyalty_tokens = loyalty_tokens_opt.destroy_some(); + + // Verify withdrawal amounts + assert!(coin::value(&coin_base) == Deposit, 1); + + // Calculate expected loyalty token reward + let expected_reward = + (((Deposit as u128) * (2 * Base_APY as u128)) / 10_u128.pow(DEFAULT_DECIMAL)) * (Lock_DAY as u128) / 365; + assert!(loyalty_tokens.value() == (expected_reward as u64), 2); + + // Redeem loyalty tokens via reward pool + let mut reward_pool = ts::take_shared>(&scenario); + let mut policy = ts::take_shared>(&scenario); + + let reward = reward_pool.do_claim(loyalty_tokens, &mut policy, none(), scenario.ctx()); + + assert!(reward.value() == (expected_reward as u64)*10, 2); + reward.burn_for_testing(); + + // Redeposit the base coins for another lock period + let receipt = pool.do_deposit( + coin_base, + Lock_DAY, + none(), + &clock, + scenario.ctx(), + ); + + // Transfer the new receipt to the user + let wrapper = pool.receipt_to_wrapper(receipt, scenario.ctx()); + // now wrapper can be transferred publicly + transfer::public_transfer(wrapper, USER); + + ts::return_shared(pool); + ts::return_shared(policy); + ts::return_shared(reward_pool); + }; + + clock.destroy_for_testing(); + scenario.end(); +} From 0d5081f1878ef6bcf0ab7775955b7f9657a8bc35 Mon Sep 17 00:00:00 2001 From: liuky Date: Wed, 7 Jan 2026 21:56:05 +0900 Subject: [PATCH 3/5] configure stop post maturity yield --- deposit_pool/sources/deposit_pool.move | 22 +++- deposit_pool/tests/deposit_pool_tests.move | 126 +++++++++++++++++++++ 2 files changed, 147 insertions(+), 1 deletion(-) diff --git a/deposit_pool/sources/deposit_pool.move b/deposit_pool/sources/deposit_pool.move index fdf3349..b46685a 100644 --- a/deposit_pool/sources/deposit_pool.move +++ b/deposit_pool/sources/deposit_pool.move @@ -56,6 +56,10 @@ const KEY_SUPPORT_TERM_EXTENSION: u8 = 3; /// Option key for enable upgrade the term for higher apy (bool), optional. const KEY_SUPPORT_RECEIPT_WRAPER_EXTENSION: u8 = 4; +/// Option key that affect the loyalty yield such that no additional reward +/// after the lock term +const KEY_STOP_POST_MATRURITY_YIELD: u8 = 5; + public struct AdminCap has key, store { id: UID, } @@ -406,6 +410,17 @@ public fun enable_receipt_wrapper( pool.options.add(KEY_SUPPORT_RECEIPT_WRAPER_EXTENSION, true); } +// allow to store a receipt through a wrapper +public fun stop_post_maturity_yield( + pool: &mut DepositPool, + admin: &mut AdminCap, +) { + assert!(pool.admin_cap_id == object::id(admin), ENotAdmin); + assert!(pool.version == VERSION, EWrongVersion); + + pool.options.add(KEY_STOP_POST_MATRURITY_YIELD, true); +} + public fun receipt_to_wrapper( pool: &DepositPool, receipt: Receipt, @@ -480,7 +495,12 @@ fun calculate_token_amount( return 0 }; - let eligible_term: u64 = (withdraw_at_ms - issue_at_ms)/MS_PER_DAY; // >(&scenario); + add_lock_term_for_testing(&mut scenario, &mut pool, Lock_DAY, Base_APY); + + // Setup clock + let mut clock = clock::create_for_testing(scenario.ctx()); + clock::set_for_testing(&mut clock, 0); + + scenario.next_tx(USER); + { + let coin_base = coin::mint_for_testing(Deposit, scenario.ctx()); + + pool.deposit( + coin_base, + Lock_DAY, + @0xB0B, + none(), + &clock, + scenario.ctx(), + ); + }; + + // Advance clock past maturity by additional 30 days (post-maturity period) + clock.increment_for_testing(MS_PER_DAY * (Lock_DAY as u64 + 30)); + + scenario.next_tx(USER); + { + let receipt = ts::take_from_sender(&scenario); + + pool.withdraw(receipt, &clock, scenario.ctx()); + }; + + scenario.next_tx(USER); + { + let returned_coin = ts::take_from_address>(&scenario, USER); + assert!(coin::value(&returned_coin) == Deposit, 1); + + // Without stop_post_maturity_yield, eligible_term should be the actual withdrawal time + // (Lock_DAY + 30) instead of just Lock_DAY + let expected_rewards = + (Deposit as u128 * (Base_APY as u128))/(10_u128.pow(DEFAULT_DECIMAL)); + // eligible term = (Lock_DAY + 30) = 90 days + let expected_rewards = (90_u128 * expected_rewards)/365; + + let reward = ts::take_from_address>(&scenario, USER); + assert!(reward.value() == expected_rewards as u64, 2); + + reward.burn_for_testing(); + returned_coin.burn_for_testing(); + }; + + ts::return_shared(pool); + clock.destroy_for_testing(); + scenario.end(); +} + +#[test] +fun test_stop_post_maturity_yield_enabled() { + let mut scenario = init_deposit_pool(true, 0); + + let mut pool = ts::take_shared>(&scenario); + add_lock_term_for_testing(&mut scenario, &mut pool, Lock_DAY, Base_APY); + + // Setup clock + let mut clock = clock::create_for_testing(scenario.ctx()); + clock::set_for_testing(&mut clock, 0); + + // Enable stop_post_maturity_yield feature + scenario.next_tx(ADMIN); + { + let mut admin_cap = ts::take_from_address(&scenario, ADMIN); + pool.stop_post_maturity_yield(&mut admin_cap); + ts::return_to_address(ADMIN, admin_cap); + }; + + scenario.next_tx(USER); + { + let coin_base = coin::mint_for_testing(Deposit, scenario.ctx()); + + pool.deposit( + coin_base, + Lock_DAY, + @0xB0B, + none(), + &clock, + scenario.ctx(), + ); + }; + + // Advance clock past maturity by additional 30 days (post-maturity period) + clock.increment_for_testing(MS_PER_DAY * (Lock_DAY as u64 + 30)); + + scenario.next_tx(USER); + { + let receipt = ts::take_from_sender(&scenario); + + pool.withdraw(receipt, &clock, scenario.ctx()); + }; + + scenario.next_tx(USER); + { + let returned_coin = ts::take_from_address>(&scenario, USER); + assert!(coin::value(&returned_coin) == Deposit, 1); + + // With stop_post_maturity_yield enabled, eligible_term should be fixed to Lock_DAY + // regardless of when the withdrawal actually happens + let expected_rewards = + (Deposit as u128 * (Base_APY as u128))/(10_u128.pow(DEFAULT_DECIMAL)); + // eligible term = Lock_DAY = 60 days (not 90) + let expected_rewards = (60_u128 * expected_rewards)/365; + + let reward = ts::take_from_address>(&scenario, USER); + assert!(reward.value() == expected_rewards as u64, 2); + + reward.burn_for_testing(); + returned_coin.burn_for_testing(); + }; + + ts::return_shared(pool); + clock.destroy_for_testing(); + scenario.end(); +} From d9c42eaa619c21ffbc9317a1107b666342a9bb4b Mon Sep 17 00:00:00 2001 From: liuky Date: Thu, 22 Jan 2026 15:29:14 +0900 Subject: [PATCH 4/5] move receipt handle out from do_withdrawal in case pending --- deposit_pool/sources/deposit_pool.move | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/deposit_pool/sources/deposit_pool.move b/deposit_pool/sources/deposit_pool.move index b46685a..c90da33 100644 --- a/deposit_pool/sources/deposit_pool.move +++ b/deposit_pool/sources/deposit_pool.move @@ -206,14 +206,17 @@ entry fun withdraw( clock: &Clock, ctx: &mut TxContext, ) { - let (coin, token) = pool.do_withdrawal(receipt, clock, ctx); + let (coin, token, receipt) = pool.do_withdrawal(receipt, clock, ctx); coin.destroy!(|c| transfer::public_transfer(c, ctx.sender())); token.destroy!(|token| { let req = token::transfer(token, ctx.sender(), ctx); token::confirm_with_treasury_cap(&mut pool.treasury_cap, req, ctx); - }) + }); + + // if receipt exists, prior two are none, so this one need to be safely transferred to the sender. + receipt.destroy!(|r| transfer::transfer(r, ctx.sender())); } public fun do_withdrawal( @@ -221,7 +224,7 @@ public fun do_withdrawal( receipt: Receipt, clock: &Clock, ctx: &mut TxContext, -): (Option>, Option>) { +): (Option>, Option>, Option) { assert!(pool.version == VERSION, EWrongVersion); assert!(receipt.pool_id == object::id(pool), EWrongPool); @@ -244,8 +247,7 @@ public fun do_withdrawal( id(&receipt), clock.timestamp_ms() + pool.options[KEY_WITHDRAWAL_PENDING], ); - transfer::transfer(receipt, ctx.sender()); - return (none(), none()) + return (none(), none(), some(receipt)) } }; @@ -272,10 +274,11 @@ public fun do_withdrawal( ctx, ), ), + none(), ) }; - (some(value), none()) + (some(value), none(), none()) } /// Upgrade the term of premature deposit receipt if support From d823168ffc0693ec17533300b6d32ad9e0ed974e Mon Sep 17 00:00:00 2001 From: liuky Date: Thu, 22 Jan 2026 19:55:15 +0900 Subject: [PATCH 5/5] move receipt handle out from do_withdrawal in case pending --- deposit_pool/tests/deposit_pool_tests.move | 80 +++++++++++++++++++++- deposit_pool/tests/integration_tests.move | 3 +- 2 files changed, 81 insertions(+), 2 deletions(-) diff --git a/deposit_pool/tests/deposit_pool_tests.move b/deposit_pool/tests/deposit_pool_tests.move index 78e0237..9c02dc9 100644 --- a/deposit_pool/tests/deposit_pool_tests.move +++ b/deposit_pool/tests/deposit_pool_tests.move @@ -862,6 +862,79 @@ fun test_cancel_pending_withdrawal() { scenario.end(); } +#[test] +fun test_do_withdrawal_with_pending_first_call_returns_receipt() { + let mut scenario = init_deposit_pool(true, Pending_DAY); + // Enable withdrawal pending for Pending_DAY days + + // Setup clock + let mut clock = clock::create_for_testing(scenario.ctx()); + clock::set_for_testing(&mut clock, 0); + + scenario.next_tx(USER); + { + let mut pool = ts::take_shared>(&scenario); + let coin_base = coin::mint_for_testing(Deposit, scenario.ctx()); + + // Deposit without lock term (use default 0-day term) + let receipt = pool.do_deposit( + coin_base, + 0, + none(), + &clock, + scenario.ctx(), + ); + + // First call to do_withdrawal should return receipt (pending window not passed) + let (coin_opt, token_opt, receipt_opt) = pool.do_withdrawal( + receipt, + &clock, + scenario.ctx(), + ); + + // Verify that only receipt is returned, coins and tokens are none + assert!(coin_opt.is_none(), 1); + assert!(token_opt.is_none(), 2); + assert!(receipt_opt.is_some(), 3); + + coin_opt.destroy_none(); + token_opt.destroy_none(); + // Extract receipt for second withdrawal + let receipt = receipt_opt.destroy_some(); + + // Advance clock past the pending period + clock.increment_for_testing(Pending_DAY as u64 * MS_PER_DAY); + + scenario.next_tx(USER); + + // Second call to do_withdrawal should succeed and return coins and tokens + let (coin_opt2, token_opt2, receipt_opt2) = pool.do_withdrawal( + receipt, + &clock, + scenario.ctx(), + ); + + // Verify that coins are returned + assert!(coin_opt2.is_some(), 4); + assert!(receipt_opt2.is_none(), 5); + + // Verify the coin value + let returned_coin = coin_opt2.destroy_some(); + assert!(coin::value(&returned_coin) == Deposit, 6); + + // Early withdrawal (before maturity), so no tokens + assert!(token_opt2.is_none(), 7); + + returned_coin.burn_for_testing(); + token_opt2.destroy!(|t| t.burn_for_testing()); + receipt_opt2.destroy_none(); + ts::return_shared(pool); + }; + + clock.destroy_for_testing(); + scenario.end(); +} + fun init_deposit_pool(ealry_withdrawal: bool, pending: u32): Scenario { let mut scenario = ts::begin(ADMIN); // Create treasury cap for Loyalty token @@ -1441,10 +1514,15 @@ fun test_withdraw_and_redeposit_in_same_transaction() { let receipt = ts::take_from_address(&scenario, USER); // Withdraw the previously deposited tokens - let (coin_base_opt, _loyalty_tokens) = pool.do_withdrawal(receipt, &clock, scenario.ctx()); + let (coin_base_opt, _loyalty_tokens, _receipt) = pool.do_withdrawal( + receipt, + &clock, + scenario.ctx(), + ); // burn loyalty token; _loyalty_tokens.destroy!(|t| t.burn_for_testing()); + _receipt.destroy_none(); // Extract the coin from Option let coin_base = coin_base_opt.destroy_some(); diff --git a/deposit_pool/tests/integration_tests.move b/deposit_pool/tests/integration_tests.move index ca5435a..63b56c1 100644 --- a/deposit_pool/tests/integration_tests.move +++ b/deposit_pool/tests/integration_tests.move @@ -104,7 +104,7 @@ fun test_withdraw_redeem_and_redeposit_in_same_transaction() { let receipt = ts::take_from_address(&scenario, USER); // Withdraw: get base coins and loyalty token rewards - let (coin_base_opt, loyalty_tokens_opt) = pool.do_withdrawal( + let (coin_base_opt, loyalty_tokens_opt, receipt_opt) = pool.do_withdrawal( receipt, &clock, scenario.ctx(), @@ -112,6 +112,7 @@ fun test_withdraw_redeem_and_redeposit_in_same_transaction() { let coin_base = coin_base_opt.destroy_some(); let loyalty_tokens = loyalty_tokens_opt.destroy_some(); + receipt_opt.destroy_none(); // Verify withdrawal amounts assert!(coin::value(&coin_base) == Deposit, 1);