Repository: interlay/interbtc (language: rust)

Issue:
Additional reward RPCs
**Is your feature request related to a problem? Please describe.**
Estimating rewards and APR downstream is complex due to the number of storage items and calculations required.

**Describe the solution you'd like**
Add new reward RPCs to estimate:
- [ ] the amount of rewards earned at the current distribution rate
- [ ] the effect of changing stake on the amount of earned rewards (escrow)



Discussion / hints:

Fix patch:
diff --git a/crates/escrow/src/lib.rs b/crates/escrow/src/lib.rs
index 641cf117d1..9e22eab57f 100644
--- a/crates/escrow/src/lib.rs
+++ b/crates/escrow/src/lib.rs
@@ -120,7 +120,7 @@ impl<Balance: AtLeast32BitUnsigned + Default + Copy, BlockNumber: AtLeast32BitUn
 
 #[derive(Default, Encode, Decode, Clone, TypeInfo, MaxEncodedLen)]
 pub struct LockedBalance<Balance, BlockNumber> {
-    amount: Balance,
+    pub amount: Balance,
     end: BlockNumber,
 }
 
@@ -515,6 +515,15 @@ impl<T: Config> Pallet<T> {
         Ok(())
     }
 
+    /// RPC helper
+    pub fn round_height_and_deposit_for(
+        who: &T::AccountId,
+        amount: BalanceOf<T>,
+        unlock_height: T::BlockNumber,
+    ) -> DispatchResult {
+        Self::deposit_for(who, amount, Self::round_height(unlock_height))
+    }
+
     fn remove_lock(who: &T::AccountId) -> DispatchResult {
         let old_locked = <Locked<T>>::take(who);
         let amount = old_locked.amount;
diff --git a/crates/fee/src/lib.rs b/crates/fee/src/lib.rs
index 891c745351..9af02d6098 100644
--- a/crates/fee/src/lib.rs
+++ b/crates/fee/src/lib.rs
@@ -526,8 +526,7 @@ impl<T: Config> Pallet<T> {
         )
     }
 
-    #[cfg_attr(feature = "integration-tests", visibility::make(pub))]
-    fn distribute_vault_rewards(vault_id: &DefaultVaultId<T>, currency_id: CurrencyId<T>) -> DispatchResult {
+    pub fn distribute_vault_rewards(vault_id: &DefaultVaultId<T>, currency_id: CurrencyId<T>) -> DispatchResult {
         let collateral_id = vault_id.collateral_currency();
 
         // push rewards based on collateral capacity
diff --git a/crates/reward/rpc/runtime-api/src/lib.rs b/crates/reward/rpc/runtime-api/src/lib.rs
index 20c14585c3..7dbd8ee654 100644
--- a/crates/reward/rpc/runtime-api/src/lib.rs
+++ b/crates/reward/rpc/runtime-api/src/lib.rs
@@ -7,16 +7,24 @@ use frame_support::dispatch::DispatchError;
 use oracle_rpc_runtime_api::BalanceWrapper;
 
 sp_api::decl_runtime_apis! {
-    pub trait RewardApi<AccountId, VaultId, CurrencyId, Balance> where
-    AccountId: Codec,
-    VaultId: Codec,
+    pub trait RewardApi<AccountId, VaultId, CurrencyId, Balance, BlockNumber, UnsignedFixedPoint> where
+        AccountId: Codec,
+        VaultId: Codec,
         CurrencyId: Codec,
-        Balance: Codec
+        Balance: Codec,
+        BlockNumber: Codec,
+        UnsignedFixedPoint: Codec,
     {
         /// Get a given user's rewards due
         fn compute_escrow_reward(account_id: AccountId, currency_id: CurrencyId) -> Result<BalanceWrapper<Balance>, DispatchError>;
 
         /// Get a given vault's rewards due
         fn compute_vault_reward(vault_id: VaultId, currency_id: CurrencyId) -> Result<BalanceWrapper<Balance>, DispatchError>;
+
+        /// Estimate staking reward rate for a one year period
+        fn estimate_escrow_reward_rate(account_id: AccountId, amount: Option<Balance>, lock_time: Option<BlockNumber>) -> Result<UnsignedFixedPoint, DispatchError>;
+
+        /// Estimate vault reward rate for a one year period
+        fn estimate_vault_reward_rate(vault_id: VaultId) -> Result<UnsignedFixedPoint, DispatchError>;
     }
 }
diff --git a/crates/reward/rpc/src/lib.rs b/crates/reward/rpc/src/lib.rs
index 6459c1e6eb..7c2082d3bd 100644
--- a/crates/reward/rpc/src/lib.rs
+++ b/crates/reward/rpc/src/lib.rs
@@ -19,12 +19,14 @@ use std::sync::Arc;
 pub use reward_rpc_runtime_api::RewardApi as RewardRuntimeApi;
 
 #[rpc(client, server)]
-pub trait RewardApi<BlockHash, AccountId, VaultId, CurrencyId, Balance>
+pub trait RewardApi<BlockHash, AccountId, VaultId, CurrencyId, Balance, BlockNumber, UnsignedFixedPoint>
 where
     Balance: Codec + MaybeDisplay + MaybeFromStr,
     AccountId: Codec,
     VaultId: Codec,
     CurrencyId: Codec,
+    BlockNumber: Codec,
+    UnsignedFixedPoint: Codec,
 {
     #[method(name = "reward_computeEscrowReward")]
     fn compute_escrow_reward(
@@ -42,6 +44,18 @@ where
         currency_id: CurrencyId,
         at: Option<BlockHash>,
     ) -> RpcResult<BalanceWrapper<Balance>>;
+
+    #[method(name = "reward_estimateEscrowRewardRate")]
+    fn estimate_escrow_reward_rate(
+        &self,
+        account_id: AccountId,
+        amount: Option<Balance>,
+        lock_time: Option<BlockNumber>,
+        at: Option<BlockHash>,
+    ) -> RpcResult<UnsignedFixedPoint>;
+
+    #[method(name = "reward_estimateVaultRewardRate")]
+    fn estimate_vault_reward_rate(&self, vault_id: VaultId, at: Option<BlockHash>) -> RpcResult<UnsignedFixedPoint>;
 }
 
 fn internal_err<T: ToString>(message: T) -> JsonRpseeError {
@@ -75,16 +89,19 @@ fn handle_response<T, E: std::fmt::Debug>(result: Result<Result<T, DispatchError
 }
 
 #[async_trait]
-impl<C, Block, AccountId, VaultId, CurrencyId, Balance>
-    RewardApiServer<<Block as BlockT>::Hash, AccountId, VaultId, CurrencyId, Balance> for Reward<C, Block>
+impl<C, Block, AccountId, VaultId, CurrencyId, Balance, BlockNumber, UnsignedFixedPoint>
+    RewardApiServer<<Block as BlockT>::Hash, AccountId, VaultId, CurrencyId, Balance, BlockNumber, UnsignedFixedPoint>
+    for Reward<C, Block>
 where
     Block: BlockT,
     C: Send + Sync + 'static + ProvideRuntimeApi<Block> + HeaderBackend<Block>,
-    C::Api: RewardRuntimeApi<Block, AccountId, VaultId, CurrencyId, Balance>,
+    C::Api: RewardRuntimeApi<Block, AccountId, VaultId, CurrencyId, Balance, BlockNumber, UnsignedFixedPoint>,
     AccountId: Codec,
     VaultId: Codec,
     CurrencyId: Codec,
     Balance: Codec + MaybeDisplay + MaybeFromStr,
+    BlockNumber: Codec,
+    UnsignedFixedPoint: Codec,
 {
     fn compute_escrow_reward(
         &self,
@@ -115,4 +132,34 @@ where
             "Unable to obtain the current reward".into(),
         )
     }
+
+    fn estimate_escrow_reward_rate(
+        &self,
+        account_id: AccountId,
+        amount: Option<Balance>,
+        lock_time: Option<BlockNumber>,
+        at: Option<<Block as BlockT>::Hash>,
+    ) -> RpcResult<UnsignedFixedPoint> {
+        let api = self.client.runtime_api();
+        let at = BlockId::hash(at.unwrap_or_else(|| self.client.info().best_hash));
+
+        handle_response(
+            api.estimate_escrow_reward_rate(&at, account_id, amount, lock_time),
+            "Unable to estimate the current reward".into(),
+        )
+    }
+
+    fn estimate_vault_reward_rate(
+        &self,
+        vault_id: VaultId,
+        at: Option<<Block as BlockT>::Hash>,
+    ) -> RpcResult<UnsignedFixedPoint> {
+        let api = self.client.runtime_api();
+        let at = BlockId::hash(at.unwrap_or_else(|| self.client.info().best_hash));
+
+        handle_response(
+            api.estimate_vault_reward_rate(&at, vault_id),
+            "Unable to estimate the current reward".into(),
+        )
+    }
 }
diff --git a/parachain/runtime/interlay/src/lib.rs b/parachain/runtime/interlay/src/lib.rs
index ce7bc32a2b..8241119b67 100644
--- a/parachain/runtime/interlay/src/lib.rs
+++ b/parachain/runtime/interlay/src/lib.rs
@@ -1495,7 +1495,9 @@ impl_runtime_apis! {
         AccountId,
         VaultId,
         CurrencyId,
-        Balance
+        Balance,
+        BlockNumber,
+        UnsignedFixedPoint
     > for Runtime {
         fn compute_escrow_reward(account_id: AccountId, currency_id: CurrencyId) -> Result<BalanceWrapper<Balance>, DispatchError> {
             let amount = <EscrowRewards as reward::RewardsApi<(), AccountId, Balance>>::compute_reward(&(), &account_id, currency_id)?;
@@ -1508,6 +1510,44 @@ impl_runtime_apis! {
             let balance = BalanceWrapper::<Balance> { amount };
             Ok(balance)
         }
+
+        fn estimate_escrow_reward_rate(
+            account_id: AccountId,
+            amount: Option<Balance>,
+            lock_time: Option<BlockNumber>,
+        ) -> Result<UnsignedFixedPoint, DispatchError> {
+            // withdraw previous rewards
+            <EscrowRewards as reward::RewardsApi<(), AccountId, Balance>>::withdraw_reward(&(), &account_id, NATIVE_CURRENCY_ID)?;
+            // increase amount and/or lock_time
+            Escrow::round_height_and_deposit_for(&account_id, amount.unwrap_or_default(), lock_time.unwrap_or_default())?;
+            // distribute rewards accrued over block count
+            let reward = EscrowAnnuity::min_reward_per_block().saturating_mul(YEARS.into());
+            <EscrowRewards as reward::RewardsApi<(), AccountId, Balance>>::distribute_reward(&(), NATIVE_CURRENCY_ID, reward)?;
+            let received = <EscrowRewards as reward::RewardsApi<(), AccountId, Balance>>::compute_reward(&(), &account_id, NATIVE_CURRENCY_ID)?;
+            // NOTE: total_locked is same currency as rewards
+            let total_locked = Escrow::locked_balance(&account_id).amount;
+            // rate is received / total_locked
+            Ok(UnsignedFixedPoint::checked_from_rational(received, total_locked).unwrap_or_default())
+        }
+
+        fn estimate_vault_reward_rate(
+            vault_id: VaultId,
+        ) -> Result<UnsignedFixedPoint, DispatchError> {
+            // distribute and withdraw previous rewards
+            Fee::distribute_vault_rewards(&vault_id, NATIVE_CURRENCY_ID)?;
+            <VaultStaking as reward::RewardsApi<(Option<Nonce>, VaultId), AccountId, Balance>>::withdraw_reward(&(None, vault_id.clone()), &vault_id.account_id, NATIVE_CURRENCY_ID)?;
+            // distribute rewards accrued over block count
+            let reward = VaultAnnuity::min_reward_per_block().saturating_mul(YEARS.into());
+            <VaultCapacity as reward::RewardsApi<(), CurrencyId, Balance>>::distribute_reward(&(), NATIVE_CURRENCY_ID, reward)?;
+            // compute and convert rewards
+            let received = Fee::compute_vault_rewards(&vault_id, &vault_id.account_id, NATIVE_CURRENCY_ID)?;
+            let received_as_wrapped = Oracle::collateral_to_wrapped(received, NATIVE_CURRENCY_ID)?;
+            // convert collateral stake to same currency
+            let collateral = <VaultStaking as reward::RewardsApi<(Option<Nonce>, VaultId), AccountId, Balance>>::get_stake(&(None, vault_id.clone()), &vault_id.account_id)?;
+            let collateral_as_wrapped = Oracle::collateral_to_wrapped(collateral, vault_id.collateral_currency())?;
+            // rate is received / collateral
+            Ok(UnsignedFixedPoint::checked_from_rational(received_as_wrapped, collateral_as_wrapped).unwrap_or_default())
+        }
     }
 
     impl issue_rpc_runtime_api::IssueApi<
diff --git a/parachain/runtime/kintsugi/src/lib.rs b/parachain/runtime/kintsugi/src/lib.rs
index 802dcdf220..1fe686bbe9 100644
--- a/parachain/runtime/kintsugi/src/lib.rs
+++ b/parachain/runtime/kintsugi/src/lib.rs
@@ -1481,7 +1481,9 @@ impl_runtime_apis! {
         AccountId,
         VaultId,
         CurrencyId,
-        Balance
+        Balance,
+        BlockNumber,
+        UnsignedFixedPoint
     > for Runtime {
         fn compute_escrow_reward(account_id: AccountId, currency_id: CurrencyId) -> Result<BalanceWrapper<Balance>, DispatchError> {
             let amount = <EscrowRewards as reward::RewardsApi<(), AccountId, Balance>>::compute_reward(&(), &account_id, currency_id)?;
@@ -1494,6 +1496,44 @@ impl_runtime_apis! {
             let balance = BalanceWrapper::<Balance> { amount };
             Ok(balance)
         }
+
+        fn estimate_escrow_reward_rate(
+            account_id: AccountId,
+            amount: Option<Balance>,
+            lock_time: Option<BlockNumber>,
+        ) -> Result<UnsignedFixedPoint, DispatchError> {
+            // withdraw previous rewards
+            <EscrowRewards as reward::RewardsApi<(), AccountId, Balance>>::withdraw_reward(&(), &account_id, NATIVE_CURRENCY_ID)?;
+            // increase amount and/or lock_time
+            Escrow::round_height_and_deposit_for(&account_id, amount.unwrap_or_default(), lock_time.unwrap_or_default())?;
+            // distribute rewards accrued over block count
+            let reward = EscrowAnnuity::min_reward_per_block().saturating_mul(YEARS.into());
+            <EscrowRewards as reward::RewardsApi<(), AccountId, Balance>>::distribute_reward(&(), NATIVE_CURRENCY_ID, reward)?;
+            let received = <EscrowRewards as reward::RewardsApi<(), AccountId, Balance>>::compute_reward(&(), &account_id, NATIVE_CURRENCY_ID)?;
+            // NOTE: total_locked is same currency as rewards
+            let total_locked = Escrow::locked_balance(&account_id).amount;
+            // rate is received / total_locked
+            Ok(UnsignedFixedPoint::checked_from_rational(received, total_locked).unwrap_or_default())
+        }
+
+        fn estimate_vault_reward_rate(
+            vault_id: VaultId,
+        ) -> Result<UnsignedFixedPoint, DispatchError> {
+            // distribute and withdraw previous rewards
+            Fee::distribute_vault_rewards(&vault_id, NATIVE_CURRENCY_ID)?;
+            <VaultStaking as reward::RewardsApi<(Option<Nonce>, VaultId), AccountId, Balance>>::withdraw_reward(&(None, vault_id.clone()), &vault_id.account_id, NATIVE_CURRENCY_ID)?;
+            // distribute rewards accrued over block count
+            let reward = VaultAnnuity::min_reward_per_block().saturating_mul(YEARS.into());
+            <VaultCapacity as reward::RewardsApi<(), CurrencyId, Balance>>::distribute_reward(&(), NATIVE_CURRENCY_ID, reward)?;
+            // compute and convert rewards
+            let received = Fee::compute_vault_rewards(&vault_id, &vault_id.account_id, NATIVE_CURRENCY_ID)?;
+            let received_as_wrapped = Oracle::collateral_to_wrapped(received, NATIVE_CURRENCY_ID)?;
+            // convert collateral stake to same currency
+            let collateral = <VaultStaking as reward::RewardsApi<(Option<Nonce>, VaultId), AccountId, Balance>>::get_stake(&(None, vault_id.clone()), &vault_id.account_id)?;
+            let collateral_as_wrapped = Oracle::collateral_to_wrapped(collateral, vault_id.collateral_currency())?;
+            // rate is received / collateral
+            Ok(UnsignedFixedPoint::checked_from_rational(received_as_wrapped, collateral_as_wrapped).unwrap_or_default())
+        }
     }
 
     impl issue_rpc_runtime_api::IssueApi<
diff --git a/parachain/src/service.rs b/parachain/src/service.rs
index 0af359781e..3051e4c31b 100644
--- a/parachain/src/service.rs
+++ b/parachain/src/service.rs
@@ -93,8 +93,15 @@ pub trait RuntimeApiCollection:
         AccountId,
         H256,
         replace::ReplaceRequest<AccountId, BlockNumber, Balance, CurrencyId>,
-    > + reward_rpc_runtime_api::RewardApi<Block, AccountId, VaultId<AccountId, CurrencyId>, CurrencyId, Balance>
-    + loans_rpc_runtime_api::LoansApi<Block, AccountId, Balance>
+    > + reward_rpc_runtime_api::RewardApi<
+        Block,
+        AccountId,
+        VaultId<AccountId, CurrencyId>,
+        CurrencyId,
+        Balance,
+        BlockNumber,
+        UnsignedFixedPoint,
+    > + loans_rpc_runtime_api::LoansApi<Block, AccountId, Balance>
 where
     <Self as sp_api::ApiExt<Block>>::StateBackend: sp_api::StateBackend<BlakeTwo256>,
 {
@@ -136,8 +143,15 @@ where
             AccountId,
             H256,
             replace::ReplaceRequest<AccountId, BlockNumber, Balance, CurrencyId>,
-        > + reward_rpc_runtime_api::RewardApi<Block, AccountId, VaultId<AccountId, CurrencyId>, CurrencyId, Balance>
-        + loans_rpc_runtime_api::LoansApi<Block, AccountId, Balance>,
+        > + reward_rpc_runtime_api::RewardApi<
+            Block,
+            AccountId,
+            VaultId<AccountId, CurrencyId>,
+            CurrencyId,
+            Balance,
+            BlockNumber,
+            UnsignedFixedPoint,
+        > + loans_rpc_runtime_api::LoansApi<Block, AccountId, Balance>,
     <Self as sp_api::ApiExt<Block>>::StateBackend: sp_api::StateBackend<BlakeTwo256>,
 {
 }
diff --git a/rpc/src/lib.rs b/rpc/src/lib.rs
index d53fb78ada..b4621ffdc5 100644
--- a/rpc/src/lib.rs
+++ b/rpc/src/lib.rs
@@ -69,7 +69,15 @@ where
         ReplaceRequest<AccountId, BlockNumber, Balance, CurrencyId>,
     >,
     C::Api: escrow_rpc::EscrowRuntimeApi<Block, AccountId, BlockNumber, Balance>,
-    C::Api: reward_rpc::RewardRuntimeApi<Block, AccountId, VaultId<AccountId, CurrencyId>, CurrencyId, Balance>,
+    C::Api: reward_rpc::RewardRuntimeApi<
+        Block,
+        AccountId,
+        VaultId<AccountId, CurrencyId>,
+        CurrencyId,
+        Balance,
+        BlockNumber,
+        FixedU128,
+    >,
     C::Api: loans_rpc::LoansRuntimeApi<Block, AccountId, Balance>,
     C::Api: BlockBuilder<Block>,
     P: TransactionPool + 'static,
diff --git a/standalone/runtime/src/lib.rs b/standalone/runtime/src/lib.rs
index 2010718263..b0cc7027b6 100644
--- a/standalone/runtime/src/lib.rs
+++ b/standalone/runtime/src/lib.rs
@@ -1391,7 +1391,9 @@ impl_runtime_apis! {
         AccountId,
         VaultId,
         CurrencyId,
-        Balance
+        Balance,
+        BlockNumber,
+        UnsignedFixedPoint
     > for Runtime {
         fn compute_escrow_reward(account_id: AccountId, currency_id: CurrencyId) -> Result<BalanceWrapper<Balance>, DispatchError> {
             let amount = <EscrowRewards as reward::RewardsApi<(), AccountId, Balance>>::compute_reward(&(), &account_id, currency_id)?;
@@ -1404,6 +1406,44 @@ impl_runtime_apis! {
             let balance = BalanceWrapper::<Balance> { amount };
             Ok(balance)
         }
+
+        fn estimate_escrow_reward_rate(
+            account_id: AccountId,
+            amount: Option<Balance>,
+            lock_time: Option<BlockNumber>,
+        ) -> Result<UnsignedFixedPoint, DispatchError> {
+            // withdraw previous rewards
+            <EscrowRewards as reward::RewardsApi<(), AccountId, Balance>>::withdraw_reward(&(), &account_id, NATIVE_CURRENCY_ID)?;
+            // increase amount and/or lock_time
+            Escrow::round_height_and_deposit_for(&account_id, amount.unwrap_or_default(), lock_time.unwrap_or_default())?;
+            // distribute rewards accrued over block count
+            let reward = EscrowAnnuity::min_reward_per_block().saturating_mul(YEARS.into());
+            <EscrowRewards as reward::RewardsApi<(), AccountId, Balance>>::distribute_reward(&(), NATIVE_CURRENCY_ID, reward)?;
+            let received = <EscrowRewards as reward::RewardsApi<(), AccountId, Balance>>::compute_reward(&(), &account_id, NATIVE_CURRENCY_ID)?;
+            // NOTE: total_locked is same currency as rewards
+            let total_locked = Escrow::locked_balance(&account_id).amount;
+            // rate is received / total_locked
+            Ok(UnsignedFixedPoint::checked_from_rational(received, total_locked).unwrap_or_default())
+        }
+
+        fn estimate_vault_reward_rate(
+            vault_id: VaultId,
+        ) -> Result<UnsignedFixedPoint, DispatchError> {
+            // distribute and withdraw previous rewards
+            Fee::distribute_vault_rewards(&vault_id, NATIVE_CURRENCY_ID)?;
+            <VaultStaking as reward::RewardsApi<(Option<Nonce>, VaultId), AccountId, Balance>>::withdraw_reward(&(None, vault_id.clone()), &vault_id.account_id, NATIVE_CURRENCY_ID)?;
+            // distribute rewards accrued over block count
+            let reward = VaultAnnuity::min_reward_per_block().saturating_mul(YEARS.into());
+            <VaultCapacity as reward::RewardsApi<(), CurrencyId, Balance>>::distribute_reward(&(), NATIVE_CURRENCY_ID, reward)?;
+            // compute and convert rewards
+            let received = Fee::compute_vault_rewards(&vault_id, &vault_id.account_id, NATIVE_CURRENCY_ID)?;
+            let received_as_wrapped = Oracle::collateral_to_wrapped(received, NATIVE_CURRENCY_ID)?;
+            // convert collateral stake to same currency
+            let collateral = <VaultStaking as reward::RewardsApi<(Option<Nonce>, VaultId), AccountId, Balance>>::get_stake(&(None, vault_id.clone()), &vault_id.account_id)?;
+            let collateral_as_wrapped = Oracle::collateral_to_wrapped(collateral, vault_id.collateral_currency())?;
+            // rate is received / collateral
+            Ok(UnsignedFixedPoint::checked_from_rational(received_as_wrapped, collateral_as_wrapped).unwrap_or_default())
+        }
     }
 
     impl issue_rpc_runtime_api::IssueApi<


Test patch:
diff --git a/parachain/runtime/testnet-interlay/src/lib.rs b/parachain/runtime/testnet-interlay/src/lib.rs
index ddeec87492..379759926c 100644
--- a/parachain/runtime/testnet-interlay/src/lib.rs
+++ b/parachain/runtime/testnet-interlay/src/lib.rs
@@ -1422,7 +1422,9 @@ impl_runtime_apis! {
         AccountId,
         VaultId,
         CurrencyId,
-        Balance
+        Balance,
+        BlockNumber,
+        UnsignedFixedPoint
     > for Runtime {
         fn compute_escrow_reward(account_id: AccountId, currency_id: CurrencyId) -> Result<BalanceWrapper<Balance>, DispatchError> {
             let amount = <EscrowRewards as reward::RewardsApi<(), AccountId, Balance>>::compute_reward(&(), &account_id, currency_id)?;
@@ -1435,6 +1437,44 @@ impl_runtime_apis! {
             let balance = BalanceWrapper::<Balance> { amount };
             Ok(balance)
         }
+
+        fn estimate_escrow_reward_rate(
+            account_id: AccountId,
+            amount: Option<Balance>,
+            lock_time: Option<BlockNumber>,
+        ) -> Result<UnsignedFixedPoint, DispatchError> {
+            // withdraw previous rewards
+            <EscrowRewards as reward::RewardsApi<(), AccountId, Balance>>::withdraw_reward(&(), &account_id, NATIVE_CURRENCY_ID)?;
+            // increase amount and/or lock_time
+            Escrow::round_height_and_deposit_for(&account_id, amount.unwrap_or_default(), lock_time.unwrap_or_default())?;
+            // distribute rewards accrued over block count
+            let reward = EscrowAnnuity::min_reward_per_block().saturating_mul(YEARS.into());
+            <EscrowRewards as reward::RewardsApi<(), AccountId, Balance>>::distribute_reward(&(), NATIVE_CURRENCY_ID, reward)?;
+            let received = <EscrowRewards as reward::RewardsApi<(), AccountId, Balance>>::compute_reward(&(), &account_id, NATIVE_CURRENCY_ID)?;
+            // NOTE: total_locked is same currency as rewards
+            let total_locked = Escrow::locked_balance(&account_id).amount;
+            // rate is received / total_locked
+            Ok(UnsignedFixedPoint::checked_from_rational(received, total_locked).unwrap_or_default())
+        }
+
+        fn estimate_vault_reward_rate(
+            vault_id: VaultId,
+        ) -> Result<UnsignedFixedPoint, DispatchError> {
+            // distribute and withdraw previous rewards
+            Fee::distribute_vault_rewards(&vault_id, NATIVE_CURRENCY_ID)?;
+            <VaultStaking as reward::RewardsApi<(Option<Nonce>, VaultId), AccountId, Balance>>::withdraw_reward(&(None, vault_id.clone()), &vault_id.account_id, NATIVE_CURRENCY_ID)?;
+            // distribute rewards accrued over block count
+            let reward = VaultAnnuity::min_reward_per_block().saturating_mul(YEARS.into());
+            <VaultCapacity as reward::RewardsApi<(), CurrencyId, Balance>>::distribute_reward(&(), NATIVE_CURRENCY_ID, reward)?;
+            // compute and convert rewards
+            let received = Fee::compute_vault_rewards(&vault_id, &vault_id.account_id, NATIVE_CURRENCY_ID)?;
+            let received_as_wrapped = Oracle::collateral_to_wrapped(received, NATIVE_CURRENCY_ID)?;
+            // convert collateral stake to same currency
+            let collateral = <VaultStaking as reward::RewardsApi<(Option<Nonce>, VaultId), AccountId, Balance>>::get_stake(&(None, vault_id.clone()), &vault_id.account_id)?;
+            let collateral_as_wrapped = Oracle::collateral_to_wrapped(collateral, vault_id.collateral_currency())?;
+            // rate is received / collateral
+            Ok(UnsignedFixedPoint::checked_from_rational(received_as_wrapped, collateral_as_wrapped).unwrap_or_default())
+        }
     }
 
 
diff --git a/parachain/runtime/testnet-kintsugi/src/lib.rs b/parachain/runtime/testnet-kintsugi/src/lib.rs
index 9942b8fca4..fe2d95035a 100644
--- a/parachain/runtime/testnet-kintsugi/src/lib.rs
+++ b/parachain/runtime/testnet-kintsugi/src/lib.rs
@@ -1443,7 +1443,9 @@ impl_runtime_apis! {
         AccountId,
         VaultId,
         CurrencyId,
-        Balance
+        Balance,
+        BlockNumber,
+        UnsignedFixedPoint
     > for Runtime {
         fn compute_escrow_reward(account_id: AccountId, currency_id: CurrencyId) -> Result<BalanceWrapper<Balance>, DispatchError> {
             let amount = <EscrowRewards as reward::RewardsApi<(), AccountId, Balance>>::compute_reward(&(), &account_id, currency_id)?;
@@ -1456,6 +1458,44 @@ impl_runtime_apis! {
             let balance = BalanceWrapper::<Balance> { amount };
             Ok(balance)
         }
+
+        fn estimate_escrow_reward_rate(
+            account_id: AccountId,
+            amount: Option<Balance>,
+            lock_time: Option<BlockNumber>,
+        ) -> Result<UnsignedFixedPoint, DispatchError> {
+            // withdraw previous rewards
+            <EscrowRewards as reward::RewardsApi<(), AccountId, Balance>>::withdraw_reward(&(), &account_id, NATIVE_CURRENCY_ID)?;
+            // increase amount and/or lock_time
+            Escrow::round_height_and_deposit_for(&account_id, amount.unwrap_or_default(), lock_time.unwrap_or_default())?;
+            // distribute rewards accrued over block count
+            let reward = EscrowAnnuity::min_reward_per_block().saturating_mul(YEARS.into());
+            <EscrowRewards as reward::RewardsApi<(), AccountId, Balance>>::distribute_reward(&(), NATIVE_CURRENCY_ID, reward)?;
+            let received = <EscrowRewards as reward::RewardsApi<(), AccountId, Balance>>::compute_reward(&(), &account_id, NATIVE_CURRENCY_ID)?;
+            // NOTE: total_locked is same currency as rewards
+            let total_locked = Escrow::locked_balance(&account_id).amount;
+            // rate is received / total_locked
+            Ok(UnsignedFixedPoint::checked_from_rational(received, total_locked).unwrap_or_default())
+        }
+
+        fn estimate_vault_reward_rate(
+            vault_id: VaultId,
+        ) -> Result<UnsignedFixedPoint, DispatchError> {
+            // distribute and withdraw previous rewards
+            Fee::distribute_vault_rewards(&vault_id, NATIVE_CURRENCY_ID)?;
+            <VaultStaking as reward::RewardsApi<(Option<Nonce>, VaultId), AccountId, Balance>>::withdraw_reward(&(None, vault_id.clone()), &vault_id.account_id, NATIVE_CURRENCY_ID)?;
+            // distribute rewards accrued over block count
+            let reward = VaultAnnuity::min_reward_per_block().saturating_mul(YEARS.into());
+            <VaultCapacity as reward::RewardsApi<(), CurrencyId, Balance>>::distribute_reward(&(), NATIVE_CURRENCY_ID, reward)?;
+            // compute and convert rewards
+            let received = Fee::compute_vault_rewards(&vault_id, &vault_id.account_id, NATIVE_CURRENCY_ID)?;
+            let received_as_wrapped = Oracle::collateral_to_wrapped(received, NATIVE_CURRENCY_ID)?;
+            // convert collateral stake to same currency
+            let collateral = <VaultStaking as reward::RewardsApi<(Option<Nonce>, VaultId), AccountId, Balance>>::get_stake(&(None, vault_id.clone()), &vault_id.account_id)?;
+            let collateral_as_wrapped = Oracle::collateral_to_wrapped(collateral, vault_id.collateral_currency())?;
+            // rate is received / collateral
+            Ok(UnsignedFixedPoint::checked_from_rational(received_as_wrapped, collateral_as_wrapped).unwrap_or_default())
+        }
     }
 
     impl issue_rpc_runtime_api::IssueApi<