import { Transaction } from "@mysten/sui/transactions";
import { getFullnodeUrl, SuiClient } from "@mysten/sui/client";
import { normalizeStructTag } from "@mysten/sui/utils";
import { bcs } from "@mysten/sui.js/bcs";
import { moveInspect, getReserveData, getIncentiveAPY } from "../CallFunctions";
import { depositCoin } from "./commonFunctions";
import { getPoolsInfo, fetchCoinPrices } from "../PoolInfo";
import {
getConfig,
PriceFeedConfig,
pool,
noDepositCoinType,
} from "../../address";
import { V3Type, PoolData, Pool, PoolConfig } from "../../types";
const SECONDS_PER_DAY = 86400;
const RATE_MULTIPLIER = 1000;
/**
* Ensure that a coin type string starts with "0x".
* @param coinType - The original coin type.
* @returns The formatted coin type.
*/
function formatCoinType(coinType: string): string {
return coinType.startsWith("0x") ? coinType : "0x" + coinType;
}
export function registerStructs() {
bcs.registerStructType("ClaimableReward", {
asset_coin_type: "string",
reward_coin_type: "string",
user_claimable_reward: "u256",
user_claimed_reward: "u256",
rule_ids: "vector
",
});
}
// Inline helper functions to retrieve configuration keys.
const getPriceFeedKey = (coinType: string): string | undefined => {
const formattedCoinType = coinType.startsWith("0x")
? coinType
: `0x${coinType}`;
return Object.keys(PriceFeedConfig).find(
(key) => PriceFeedConfig[key].coinType === formattedCoinType
);
};
const getPoolKey = (coinType: string): string | undefined => {
const formattedCoinType = coinType.startsWith("0x")
? coinType
: `0x${coinType}`;
return Object.keys(pool).find(
(key) => normalizeStructTag(pool[key].type) === formattedCoinType
);
};
/**
* Fetch and group available v3 rewards for a user.
*
* @param {SuiClient} client - The Sui client instance used to interact with the blockchain.
* @param {string} userAddress - The blockchain address of the user whose rewards are being fetched.
* @param {boolean} [prettyPrint=true] - Whether to log the rewards data in a readable format.
* @returns {Promise} A promise resolving to the grouped rewards by asset type, or null if no rewards.
* @throws {Error} If fetching rewards data fails or returns undefined.
*/
export async function getAvailableRewards(
client: SuiClient,
checkAddress: string,
prettyPrint = true
): Promise {
// Fetch the protocol configuration
const protocolConfig = await getConfig();
// Create a new transaction instance
const tx = new Transaction();
// Call the Move function to fetch the user's claimable rewards
const rewardsData = await moveInspect(
tx,
client,
checkAddress,
`${protocolConfig.uiGetter}::incentive_v3_getter::get_user_atomic_claimable_rewards`,
[
tx.object("0x06"),
tx.object(protocolConfig.StorageId),
tx.object(protocolConfig.IncentiveV3),
tx.pure.address(checkAddress),
]
);
if (!rewardsData) {
throw new Error(
"Failed to fetch v3 rewards data: moveInspect returned undefined."
);
}
// Parse the raw rewards data into an array of reward objects.
// The data may be in the new tuple format (5 arrays) or the legacy object array format.
let rewardsList: V3Type.Reward[] = [];
if (Array.isArray(rewardsData)) {
// Check if the data is in the new tuple format (5 arrays and the first element is an array)
if (rewardsData.length === 5 && Array.isArray(rewardsData[0])) {
const count = rewardsData[0].length;
for (let i = 0; i < count; i++) {
rewardsList.push({
asset_coin_type: rewardsData[0][i],
reward_coin_type: rewardsData[1][i],
option: Number(rewardsData[2][i]),
// Ensure rule_ids is always an array
rule_ids: Array.isArray(rewardsData[3][i])
? rewardsData[3][i]
: [rewardsData[3][i]],
user_claimable_reward: Number(rewardsData[4][i]),
});
}
} else {
// Assume the data is in the legacy format: an array of reward objects
rewardsList = rewardsData;
}
}
if (rewardsList.length === 0) {
if (prettyPrint) {
console.log("No v3 rewards");
}
return null;
}
// Group rewards by asset coin type.
const groupedRewards: V3Type.GroupedRewards = {};
for (const reward of rewardsList) {
const {
asset_coin_type,
reward_coin_type,
option,
rule_ids,
user_claimable_reward,
} = reward;
// Retrieve configuration keys for asset and reward coin types.
const assetPriceFeedKey = getPriceFeedKey(asset_coin_type);
const rewardPriceFeedKey = getPriceFeedKey(reward_coin_type);
const assetPoolKey = getPoolKey(asset_coin_type);
const rewardPoolKey = getPoolKey(reward_coin_type);
// Skip reward if any necessary configuration is missing.
if (
!assetPriceFeedKey ||
!rewardPriceFeedKey ||
!assetPoolKey ||
!rewardPoolKey
) {
continue;
}
// Initialize rewards array for this asset if not already present.
if (!groupedRewards[asset_coin_type]) {
groupedRewards[asset_coin_type] = [];
}
// Convert the raw claimable reward into a human-readable value using the proper decimal precision.
const decimalPrecision = PriceFeedConfig[rewardPriceFeedKey].priceDecimal;
const convertedClaimable =
Number(user_claimable_reward) / Math.pow(10, decimalPrecision);
groupedRewards[asset_coin_type].push({
asset_id: pool[assetPoolKey].assetId.toString(),
reward_id: pool[rewardPoolKey].assetId.toString(),
reward_coin_type,
option,
rule_ids,
user_claimable_reward: convertedClaimable,
});
}
// If prettyPrint is enabled, log the grouped rewards in a user-friendly format.
if (prettyPrint) {
console.log(`-- V3 Available Rewards --`);
console.log(`address: ${checkAddress}`);
for (const [assetCoinType, rewards] of Object.entries(groupedRewards)) {
const assetKey = getPriceFeedKey(assetCoinType) ?? assetCoinType;
console.log(`Asset: ${assetKey}`);
rewards.forEach((reward, idx) => {
const rewardKey =
getPriceFeedKey(reward.reward_coin_type) ?? reward.reward_coin_type;
console.log(
` ${idx + 1}. Reward Coin: ${rewardKey}, Option: ${
reward.option
}, ` + `Claimable: ${reward.user_claimable_reward}`
);
});
}
}
return groupedRewards;
}
/**
* Retrieves the available rewards for a specific user in the protocol.
*
* This function communicates with the Sui blockchain to fetch and process
* claimable rewards for a user based on their interactions with the protocol.
*
* @param {SuiClient} client - The Sui client instance used to interact with the blockchain.
* @param {string} userAddress - The blockchain address of the user whose rewards are being fetched.
* @param {boolean} [prettyPrint=true] - Whether to log the rewards data in a readable format.
* @returns {Promise} A promise resolving to the grouped rewards by asset type, or null if no rewards.
* @throws {Error} If fetching rewards data fails or returns undefined.
*/
export async function getAvailableRewardsWithoutOption(
client: SuiClient,
userAddress: string,
prettyPrint = true
): Promise {
// Fetch the protocol configuration.
const protocolConfig = await getConfig();
// Register necessary Move structs.
registerStructs();
// Create a transaction and invoke the Move function to get user claimable rewards.
const tx = new Transaction();
const claimableRewardsCall = tx.moveCall({
target: `${protocolConfig.ProtocolPackage}::incentive_v3::get_user_claimable_rewards`,
arguments: [
tx.object("0x06"),
tx.object(protocolConfig.StorageId),
tx.object(protocolConfig.IncentiveV3),
tx.pure.address(userAddress),
],
});
const rewardsData = await moveInspect(
tx,
client,
userAddress,
`${protocolConfig.ProtocolPackage}::incentive_v3::parse_claimable_rewards`,
[claimableRewardsCall],
[],
"vector"
);
if (!rewardsData) {
throw new Error(
"Failed to fetch v3 rewards data: moveInspect returned undefined."
);
}
const rawRewards: V3Type.RewardsList = rewardsData[0];
if (rawRewards.length === 0) {
if (prettyPrint) {
console.log("No v3 rewards");
}
return null;
}
// Helper function: Retrieve the corresponding key from PriceFeedConfig based on coin type.
const getPriceFeedKey = (coinType: string): string | undefined =>
Object.keys(PriceFeedConfig).find(
(key) => PriceFeedConfig[key].coinType === `0x${coinType}`
);
// Helper function: Retrieve the corresponding key from pool based on coin type.
const getPoolKey = (coinType: string): string | undefined =>
Object.keys(pool).find(
(key) => normalizeStructTag(pool[key].type) === `0x${coinType}`
);
// Group the rewards by asset coin type.
const groupedRewards: V3Type.GroupedRewards = rawRewards.reduce(
(acc: V3Type.GroupedRewards, reward: V3Type.Reward) => {
const {
asset_coin_type,
reward_coin_type,
user_claimable_reward,
user_claimed_reward,
rule_ids,
} = reward;
// Retrieve configuration keys for asset and reward coin types.
const assetPriceFeedKey = getPriceFeedKey(asset_coin_type);
const rewardPriceFeedKey = getPriceFeedKey(reward_coin_type);
const assetPoolKey = getPoolKey(asset_coin_type);
const rewardPoolKey = getPoolKey(reward_coin_type);
// Skip this reward if any necessary configuration is missing.
if (
!assetPriceFeedKey ||
!rewardPriceFeedKey ||
!assetPoolKey ||
!rewardPoolKey
) {
return acc;
}
// Initialize the grouping for the asset coin type if not present.
if (!acc[asset_coin_type]) {
acc[asset_coin_type] = [];
}
// Determine decimal precision based on the reward coin's configuration.
const decimalPrecision = PriceFeedConfig[rewardPriceFeedKey].priceDecimal;
// Convert raw reward amounts to human-readable values.
const convertedClaimable =
Number(user_claimable_reward) / Math.pow(10, decimalPrecision);
const convertedClaimed =
Number(user_claimed_reward) / Math.pow(10, decimalPrecision);
// Append the reward details to the grouped rewards.
acc[asset_coin_type].push({
asset_id: pool[assetPoolKey].assetId.toString(),
reward_id: pool[rewardPoolKey].assetId.toString(),
reward_coin_type,
user_claimable_reward: convertedClaimable,
user_claimed_reward: convertedClaimed,
rule_ids,
});
return acc;
},
{} as V3Type.GroupedRewards
);
// If prettyPrint is enabled, log the rewards data in a human-readable format.
if (prettyPrint) {
console.log(`-- V3 Available Rewards --`);
for (const [assetCoinType, rewards] of Object.entries(groupedRewards)) {
// Map the asset coin type to a human-readable identifier.
const assetKey = getPriceFeedKey(assetCoinType) ?? assetCoinType;
console.log(`Asset: ${assetKey}`);
rewards.forEach((reward, idx) => {
const rewardKey =
getPriceFeedKey(reward.reward_coin_type) ?? reward.reward_coin_type;
console.log(
` ${idx + 1}. Reward Coin: ${rewardKey}, ` +
`Claimable: ${reward.user_claimable_reward}, Claimed: ${reward.user_claimed_reward}`
);
});
}
}
return groupedRewards;
}
/**
* Claim a specific reward by calling the Move entry function.
* @param tx The Transaction object.
* @param rewardInfo The minimal reward info, including asset_coin_type, reward_coin_type, rule_ids
*/
export async function claimRewardFunction(
tx: Transaction,
rewardInfo: V3Type.ClaimRewardInput
): Promise {
const config = await getConfig();
// console.log("Claiming reward:", rewardInfo);
// Find matching rewardFund from the pool config
let matchedRewardFund: string | null = null;
for (const key of Object.keys(pool) as (keyof Pool)[]) {
// e.g. "0x2::sui::SUI".slice(2) => "2::sui::SUI"
const normalizedType = normalizeStructTag(pool[key].type);
const coinTypeWithoutHex = normalizedType.startsWith("0x")
? normalizedType.slice(2)
: normalizedType;
const rewardCoinTypeWithoutHex = rewardInfo.reward_coin_type.startsWith(
"0x"
)
? rewardInfo.reward_coin_type.slice(2)
: rewardInfo.reward_coin_type;
if (coinTypeWithoutHex === rewardCoinTypeWithoutHex) {
matchedRewardFund = pool[key].rewardFundId;
break;
}
}
if (!matchedRewardFund) {
console.log(
`No matching rewardFund found for reward_coin_type: ${rewardInfo.reward_coin_type}`
);
return;
} else {
tx.moveCall({
target: `${config.ProtocolPackage}::incentive_v3::claim_reward_entry`,
arguments: [
tx.object("0x06"),
tx.object(config.IncentiveV3),
tx.object(config.StorageId),
tx.object(matchedRewardFund),
tx.pure.vector("string", rewardInfo.asset_vector),
tx.pure.vector("address", rewardInfo.rules_vector),
],
typeArguments: [rewardInfo.reward_coin_type],
});
}
}
/**
* Claim all rewards for a user by iterating through the grouped rewards.
* @param client SuiClient instance
* @param userAddress The address of the user to claim for
* @param existingTx (Optional) If provided, we append to this Transaction instead of creating a new one
* @returns The Transaction with all claim commands appended
*/
export async function claimAllRewardsPTB(
client: SuiClient,
userAddress: string,
existingTx?: Transaction
): Promise {
// Initialize the transaction object, use existingTx if provided
const tx = existingTx ?? new Transaction();
// Fetch the available grouped rewards for the user
const groupedRewards = await getAvailableRewardsWithoutOption(
client,
userAddress,
false
);
if (!groupedRewards) {
return tx;
}
// Object to store aggregated rewards by coin type
const rewardMap = new Map<
string,
{ assetIds: string[]; ruleIds: string[] }
>();
// Single-pass aggregation using Map for O(1) lookups
for (const [poolId, rewards] of Object.entries(groupedRewards)) {
for (const reward of rewards) {
const { reward_coin_type: coinType, rule_ids: ruleIds } = reward;
for (const ruleId of ruleIds) {
if (!rewardMap.has(coinType)) {
rewardMap.set(coinType, { assetIds: [], ruleIds: [] });
}
const group = rewardMap.get(coinType)!;
group.assetIds.push(poolId);
group.ruleIds.push(ruleId);
}
}
}
// Asynchronously create claim transaction instructions for each reward coin type
Array.from(rewardMap).map(async ([coinType, { assetIds, ruleIds }]) => {
const claimInput: V3Type.ClaimRewardInput = {
reward_coin_type: coinType,
asset_vector: assetIds,
rules_vector: ruleIds,
};
await claimRewardFunction(tx, claimInput);
});
return tx;
}
function filterRewardsByAssetId(groupedRewards: V3Type.GroupedRewards, assetId: string): V3Type.GroupedRewards {
const result: V3Type.GroupedRewards = {};
for (const assetCoinType in groupedRewards) {
if (groupedRewards.hasOwnProperty(assetCoinType)) {
const processedRewardsList = groupedRewards[assetCoinType];
const filteredRewards = processedRewardsList.filter(
(reward) => reward.asset_id === assetId
);
if (filteredRewards.length > 0) {
result[assetCoinType] = filteredRewards;
}
}
}
return result;
}
export async function claimRewardsByAssetIdPTB(
client: SuiClient,
userAddress: string,
assetId: number,
existingTx?: Transaction
): Promise {
// Initialize the transaction object, use existingTx if provided
const tx = existingTx ?? new Transaction();
// Fetch the available grouped rewards for the user
const groupedRewards = await getAvailableRewardsWithoutOption(
client,
userAddress,
false
);
if (!groupedRewards) {
return tx;
}
const filterGroupedRewards = filterRewardsByAssetId(groupedRewards, assetId.toString())
// Object to store aggregated rewards by coin type
const rewardMap = new Map<
string,
{ assetIds: string[]; ruleIds: string[] }
>();
// Single-pass aggregation using Map for O(1) lookups
for (const [poolId, rewards] of Object.entries(filterGroupedRewards)) {
for (const reward of rewards) {
const { reward_coin_type: coinType, rule_ids: ruleIds } = reward;
for (const ruleId of ruleIds) {
if (!rewardMap.has(coinType)) {
rewardMap.set(coinType, { assetIds: [], ruleIds: [] });
}
const group = rewardMap.get(coinType)!;
group.assetIds.push(poolId);
group.ruleIds.push(ruleId);
}
}
}
// Asynchronously create claim transaction instructions for each reward coin type
Array.from(rewardMap).map(async ([coinType, { assetIds, ruleIds }]) => {
const claimInput: V3Type.ClaimRewardInput = {
reward_coin_type: coinType,
asset_vector: assetIds,
rules_vector: ruleIds,
};
await claimRewardFunction(tx, claimInput);
});
return tx;
}
/**
* Claim a specific reward by calling the Move entry function.
* @param tx The Transaction object.
* @param rewardInfo The minimal reward info, including asset_coin_type, reward_coin_type, rule_ids
*/
export async function claimRewardResupplyFunction(
tx: Transaction,
rewardInfo: V3Type.ClaimRewardInput,
userAddress: string
): Promise {
const config = await getConfig();
// Find matching rewardFund from the pool config
let matchedRewardFund: string | null = null;
let toPoolConfig: PoolConfig | null = null;
for (const key of Object.keys(pool) as (keyof Pool)[]) {
// e.g. "0x2::sui::SUI".slice(2) => "2::sui::SUI"
const normalizedType = normalizeStructTag(pool[key].type);
const coinTypeWithoutHex = normalizedType.startsWith("0x")
? normalizedType.slice(2)
: normalizedType;
const rewardCoinTypeWithoutHex = rewardInfo.reward_coin_type.startsWith(
"0x"
)
? rewardInfo.reward_coin_type.slice(2)
: rewardInfo.reward_coin_type;
if (coinTypeWithoutHex === rewardCoinTypeWithoutHex) {
matchedRewardFund = pool[key].rewardFundId;
toPoolConfig = pool[key];
break;
}
}
if (!matchedRewardFund || !toPoolConfig) {
throw new Error(
`No matching rewardFund found for reward_coin_type: ${rewardInfo.reward_coin_type}`
);
}
// Construct the Move call
const reward_balance = tx.moveCall({
target: `${config.ProtocolPackage}::incentive_v3::claim_reward`,
arguments: [
tx.object("0x06"),
tx.object(config.IncentiveV3),
tx.object(config.StorageId),
tx.object(matchedRewardFund),
tx.pure.vector("string", rewardInfo.asset_vector),
tx.pure.vector("address", rewardInfo.rules_vector),
],
typeArguments: [toPoolConfig.type],
});
const [reward_coin]: any = tx.moveCall({
target: "0x2::coin::from_balance",
arguments: [reward_balance],
typeArguments: [toPoolConfig.type],
});
if (noDepositCoinType.includes(rewardInfo.reward_coin_type)) {
tx.transferObjects([reward_coin], userAddress);
} else {
const reward_coin_value = tx.moveCall({
target: "0x2::coin::value",
arguments: [reward_coin],
typeArguments: [toPoolConfig.type],
});
await depositCoin(tx, toPoolConfig, reward_coin, reward_coin_value);
}
}
/**
* Claim all rewards for a user by iterating through the grouped rewards.
* @param client SuiClient instance
* @param userAddress The address of the user to claim for
* @param existingTx (Optional) If provided, we append to this Transaction instead of creating a new one
* @returns The Transaction with all claim commands appended
*/
export async function claimAllRewardsResupplyPTB(
client: SuiClient,
userAddress: string,
existingTx?: Transaction
): Promise {
// Initialize the transaction object, use existingTx if provided
const tx = existingTx ?? new Transaction();
// Fetch the available grouped rewards for the user
const groupedRewards = await getAvailableRewardsWithoutOption(
client,
userAddress,
false
);
if (!groupedRewards) {
return tx;
}
// Object to store aggregated rewards by coin type
const rewardMap = new Map<
string,
{ assetIds: string[]; ruleIds: string[] }
>();
// Single-pass aggregation using Map for O(1) lookups
for (const [poolId, rewards] of Object.entries(groupedRewards)) {
for (const reward of rewards) {
const { reward_coin_type: coinType, rule_ids: ruleIds } = reward;
for (const ruleId of ruleIds) {
if (!rewardMap.has(coinType)) {
rewardMap.set(coinType, { assetIds: [], ruleIds: [] });
}
const group = rewardMap.get(coinType)!;
group.assetIds.push(poolId);
group.ruleIds.push(ruleId);
}
}
}
// Asynchronously create claim transaction instructions for each reward coin type
Array.from(rewardMap).map(async ([coinType, { assetIds, ruleIds }]) => {
const claimInput: V3Type.ClaimRewardInput = {
reward_coin_type: coinType,
asset_vector: assetIds,
rules_vector: ruleIds,
};
await claimRewardResupplyFunction(tx, claimInput, userAddress);
});
return tx;
}
export async function getBorrowFee(client: SuiClient): Promise {
const protocolConfig = await getConfig();
const rawData: any = await client.getObject({
id: protocolConfig.IncentiveV3,
options: { showType: true, showOwner: true, showContent: true },
});
const borrowFee = rawData.data.content.fields.borrow_fee_rate;
return Number(borrowFee) / 100;
}
/**
* Calculate the sum of reward rates and collect reward coin types.
*
* @param rules - Array of enabled rules.
* @param coinPriceMap - Mapping from coin types to their prices.
* @returns An object containing the total rate sum and a list of reward coin types.
*/
function calculateRateSumAndCoins(
rules: V3Type.ComputedRule[],
coinPriceMap: Record
): { rateSum: number; rewardCoins: string[] } {
return rules.reduce(
(acc, rule) => {
const ruleRate = Number(rule.rate) / 1e27; // Convert from large integer representation
const formattedRewardCoinType = formatCoinType(rule.rewardCoinType);
const rewardPrice = coinPriceMap[formattedRewardCoinType]?.value || 0;
const rewardDecimal =
Number(coinPriceMap[formattedRewardCoinType]?.decimals) || 9;
if (rewardPrice === 0) {
console.log(
`No price data found for reward coin type: ${rule.rewardCoinType} (${formattedRewardCoinType})`
);
}
if (!coinPriceMap[formattedRewardCoinType]?.decimals) {
console.log(
`No decimal data found for reward coin type: ${rule.rewardCoinType} (${formattedRewardCoinType})`
);
}
acc.rateSum += (ruleRate * rewardPrice) / Math.pow(10, rewardDecimal);
acc.rewardCoins.push(rule.rewardCoinType);
return acc;
},
{ rateSum: 0, rewardCoins: [] as string[] }
);
}
/**
* Compute the final APY based on the aggregated rate and the pool's asset value.
*
* Formula: (rateSum * RATE_MULTIPLIER * SECONDS_PER_DAY * 365 * 100) / totalValue
*
* @param rateSum - Aggregated rate sum after conversion.
* @param totalValue - Typically totalSupplyAmount * assetPrice.
* @returns The APY value, or 0 if totalValue <= 0.
*/
function apyFormula(rateSum: number, totalValue: number): number {
if (totalValue <= 0) return 0;
return (rateSum * RATE_MULTIPLIER * SECONDS_PER_DAY * 365 * 100) / totalValue;
}
/**
* Calculate APY information (supply and borrow) for a list of grouped asset pools.
*
* @param groupedPools - Grouped pool data after calling `groupByAssetCoinType`.
* @param poolsInfo - Full pool information (usually fetched from backend or a mock).
* @returns An array of APY result objects for each pool.
*/
export async function calculateApy(
groupedPools: V3Type.GroupedAssetPool[],
reserves: V3Type.ReserveData[],
coinPriceMap: Record
): Promise {
return groupedPools.map((group) => {
// Find the matching reserve data based on formatted coin type
const matchingReserve = reserves.find(
(r) => formatCoinType(r.coin_type) === formatCoinType(group.assetCoinType)
);
// Return default result if no matching reserve data or rules exist
if (!matchingReserve || !group.rules?.length) {
return {
asset: group.asset,
assetCoinType: group.assetCoinType,
supplyIncentiveApyInfo: { rewardCoin: [], apy: 0 },
borrowIncentiveApyInfo: { rewardCoin: [], apy: 0 },
};
}
// Get asset price from the price map
const assetPrice = coinPriceMap[group.assetCoinType] || 0;
const totalSupplyAmount = Number(matchingReserve.total_supply || 0);
const borrowedAmount = Number(matchingReserve.total_borrow || 0);
// Filter enabled rules (enabled and non-zero rate)
const enabledRules = group.rules.filter(
(rule) => rule.enable && rule.rate !== "0"
);
// Calculate Supply APY (option === 1)
const supplyRules = enabledRules.filter((r) => r.option === 1);
const { rateSum: supplyRateSum, rewardCoins: supplyRewardCoins } =
calculateRateSumAndCoins(supplyRules, coinPriceMap);
const supplyApy = apyFormula(
supplyRateSum,
(totalSupplyAmount / Math.pow(10, Number(9)) * assetPrice.value )
);
// Calculate Borrow APY (option === 3)
const borrowRules = enabledRules.filter((r) => r.option === 3);
const { rateSum: borrowRateSum, rewardCoins: borrowRewardCoins } =
calculateRateSumAndCoins(borrowRules, coinPriceMap);
const borrowApy = apyFormula(
borrowRateSum,
(borrowedAmount / Math.pow(10, Number(9)) * assetPrice.value)
);
return {
asset: group.asset,
assetCoinType: group.assetCoinType,
supplyIncentiveApyInfo: {
rewardCoin: supplyRewardCoins,
apy: supplyApy,
},
borrowIncentiveApyInfo: {
rewardCoin: borrowRewardCoins,
apy: borrowApy,
},
};
});
}
/**
* Group the raw incentive data by asset_coin_type.
*
* @param incentiveData - Data structure returned by the Sui client.
* @returns A list of grouped asset pools, each containing an array of rules.
*/
export function groupByAssetCoinType(
incentiveData: V3Type.IncentiveData
): V3Type.GroupedAssetPool[] {
const groupedMap = new Map();
const rawPools = incentiveData.data.content.fields.pools.fields.contents;
rawPools.forEach((poolEntry) => {
const assetPool = poolEntry.fields.value.fields;
const formattedCoinType = formatCoinType(assetPool.asset_coin_type);
const assetPoolKey = getPoolKey(formattedCoinType);
const { asset } = assetPool;
const rulesList = assetPool.rules.fields.contents;
if (!groupedMap.has(formattedCoinType)) {
groupedMap.set(formattedCoinType, {
asset,
assetSymbol: pool[assetPoolKey ?? ""]?.name || "",
assetCoinType: formattedCoinType,
rules: [],
});
}
const groupedPool = groupedMap.get(formattedCoinType)!;
rulesList.forEach((ruleEntry) => {
const rule = ruleEntry.fields.value.fields;
const formattedRewardCoinType = formatCoinType(rule.reward_coin_type);
const rewardPoolKey = getPoolKey(formattedRewardCoinType);
const rewardPriceFeedKey = getPriceFeedKey(formattedRewardCoinType);
groupedPool.rules.push({
ruleId: rule.id.id,
option: rule.option,
optionType:
rule.option === 1 ? "supply" : rule.option === 3 ? "borrow" : "",
rewardCoinType: rule.reward_coin_type,
rewardSymbol: (rewardPoolKey && pool[rewardPoolKey]?.name) || "",
rewardDecimal:
(rewardPriceFeedKey &&
PriceFeedConfig[rewardPriceFeedKey]?.priceDecimal) ||
-1,
rate: rule.rate,
enable: rule.enable,
});
});
});
return Array.from(groupedMap.values());
}
// Merges two arrays of reward coins and ensures uniqueness
const mergeRewardCoins = (coins1: string[], coins2: string[]): string[] => {
const addPrefix = (coin: string) =>
coin.startsWith("0x") ? coin : `0x${coin}`;
return Array.from(
new Set([...coins1.map(addPrefix), ...coins2.map(addPrefix)])
);
};
// Interface for V2 APY (Annual Percentage Yield) data structure
interface V2Apy {
asset_id: number;
apy: string;
coin_types: string[];
}
// Function to merge APY results from V2 and V3
async function mergeApyResults(
v3ApyResults: V3Type.ApyResult[], // V3 APY results
v2SupplyApy: V2Apy[], // V2 supply APY data
v2BorrowApy: V2Apy[] // V2 borrow APY data
): Promise {
// Helper function to calculate APY as a percentage
const calculateApyPercentage = (apyStr: string): number =>
(Number(apyStr) / 1e27) * 100;
// Helper function to get the asset's coin type, keep the "0x" prefix if present
const getFormattedCoinType = (assetId: number): string => {
const poolValues = Object.values(pool);
const poolEntry = poolValues.find((entry) => entry.assetId === assetId);
if (!poolEntry) return "";
return normalizeStructTag(poolEntry.type);
};
// Map to store merged V2 supply and borrow data by asset ID
const v2DataMap = new Map<
number,
{ supply: V3Type.IncentiveApyInfo; borrow: V3Type.IncentiveApyInfo }
>();
// Merge V2 supply data into v2DataMap
v2SupplyApy.forEach((supplyData) => {
const computedApy = calculateApyPercentage(supplyData.apy);
const existingData = v2DataMap.get(supplyData.asset_id) || {
supply: { apy: 0, rewardCoin: [] },
borrow: { apy: 0, rewardCoin: [] },
};
existingData.supply.apy += computedApy;
existingData.supply.rewardCoin = mergeRewardCoins(
existingData.supply.rewardCoin,
supplyData.coin_types
);
v2DataMap.set(supplyData.asset_id, existingData);
});
// Merge V2 borrow data into v2DataMap
v2BorrowApy.forEach((borrowData) => {
const computedApy = calculateApyPercentage(borrowData.apy);
const existingData = v2DataMap.get(borrowData.asset_id) || {
supply: { apy: 0, rewardCoin: [] },
borrow: { apy: 0, rewardCoin: [] },
};
existingData.borrow.apy += computedApy;
existingData.borrow.rewardCoin = mergeRewardCoins(
existingData.borrow.rewardCoin,
borrowData.coin_types
);
v2DataMap.set(borrowData.asset_id, existingData);
});
// Map to store the final merged APY results by asset ID
const finalApyResultsMap = new Map();
// First, add V3 data to the final map (by asset ID)
v3ApyResults.forEach((v3Data) => {
// Helper function to ensure '0x' prefix
const addPrefixToCoins = (coins: string[]): string[] =>
coins.map((coin) => (coin.startsWith("0x") ? coin : `0x${coin}`));
finalApyResultsMap.set(v3Data.asset, {
...v3Data,
supplyIncentiveApyInfo: {
...v3Data.supplyIncentiveApyInfo,
apy: Number(v3Data.supplyIncentiveApyInfo.apy.toFixed(4)),
rewardCoin: addPrefixToCoins(v3Data.supplyIncentiveApyInfo.rewardCoin),
},
borrowIncentiveApyInfo: {
...v3Data.borrowIncentiveApyInfo,
apy: Number(v3Data.borrowIncentiveApyInfo.apy.toFixed(4)),
rewardCoin: addPrefixToCoins(v3Data.borrowIncentiveApyInfo.rewardCoin),
},
assetCoinType: getFormattedCoinType(v3Data.asset),
});
});
// Then, merge the V2 data into the final map
v2DataMap.forEach((v2Data, assetId) => {
if (finalApyResultsMap.has(assetId)) {
const existingApyData = finalApyResultsMap.get(assetId)!;
existingApyData.supplyIncentiveApyInfo.apy = Number((existingApyData.supplyIncentiveApyInfo.apy + v2Data.supply.apy).toFixed(4));
existingApyData.supplyIncentiveApyInfo.rewardCoin = mergeRewardCoins(
existingApyData.supplyIncentiveApyInfo.rewardCoin,
v2Data.supply.rewardCoin
);
existingApyData.borrowIncentiveApyInfo.apy = Number((existingApyData.borrowIncentiveApyInfo.apy + v2Data.borrow.apy).toFixed(4));
existingApyData.borrowIncentiveApyInfo.rewardCoin = mergeRewardCoins(
existingApyData.borrowIncentiveApyInfo.rewardCoin,
v2Data.borrow.rewardCoin
);
} else {
finalApyResultsMap.set(assetId, {
asset: assetId,
// Ensure coin type is formatted correctly, regardless of whether it's a new asset or not
assetCoinType: getFormattedCoinType(assetId),
supplyIncentiveApyInfo: {
...v2Data.supply,
apy: Number(v2Data.supply.apy.toFixed(4)),
},
borrowIncentiveApyInfo: {
...v2Data.borrow,
apy: Number(v2Data.borrow.apy.toFixed(4)),
},
});
}
});
// Return the final merged list of APY results
return Array.from(finalApyResultsMap.values());
}
export async function getCurrentRules(
client: SuiClient
): Promise {
const config = await getConfig();
const rawData = await client.getObject({
id: config.IncentiveV3,
options: { showType: true, showOwner: true, showContent: true },
});
const incentiveData = rawData as unknown as V3Type.IncentiveData;
const groupedPools = groupByAssetCoinType(incentiveData);
const modifiedGroupedPools = groupedPools.map((pool) => ({
asset: pool.asset,
assetSymbol: pool.assetSymbol,
assetCoinType: pool.assetCoinType,
rules: pool.rules.map((rule) => ({
ruleId: rule.ruleId,
option: rule.option,
optionType: rule.optionType,
rewardSymbol: rule.rewardSymbol,
rewardCoinType: `0x${rule.rewardCoinType}`,
rate: rule.rate,
ratePerWeek:
rule.rewardDecimal === -1
? null
: ((Number(rule.rate) / 1e27) *
RATE_MULTIPLIER *
SECONDS_PER_DAY *
7) /
Math.pow(10, Number(rule.rewardDecimal)),
enable: rule.enable,
})),
}));
return modifiedGroupedPools;
}
/**
* Main function to fetch on-chain data and compute APY information for inter used.
*
* @param client - SuiClient instance used to fetch the raw data.
* @returns An array of final APY results for each pool.
*/
async function getPoolApyInter(
client: SuiClient
): Promise {
// 1. Get configuration
const config = await getConfig();
const userAddress =
"0xcda879cde94eeeae2dd6df58c9ededc60bcf2f7aedb79777e47d95b2cfb016c2";
// 2. Fetch ReserveData, IncentiveV3 data, and APY calculations in parallel
const [reserves, rawData, v2SupplyApy, v2BorrowApy] = await Promise.all([
getReserveData(config.StorageId, client),
client.getObject({
id: config.IncentiveV3,
options: { showType: true, showOwner: true, showContent: true },
}),
getIncentiveAPY(userAddress, client, 1),
getIncentiveAPY(userAddress, client, 3),
]);
// 3. Process incentive data
const incentiveData = rawData as unknown as V3Type.IncentiveData;
const groupedPools = groupByAssetCoinType(incentiveData);
// 4. Build a set of all coin types needed for price lookup
const coinTypeSet = new Set();
reserves.forEach((r: V3Type.ReserveData) => {
coinTypeSet.add(formatCoinType(r.coin_type));
});
groupedPools.forEach((group) => {
coinTypeSet.add(group.assetCoinType);
group.rules.forEach((rule) => {
coinTypeSet.add(formatCoinType(rule.rewardCoinType));
});
});
const coinTypes = Array.from(coinTypeSet);
// 5. Fetch coin price data
const coinPrices = await fetchCoinPrices(coinTypes, true);
const coinPriceMap: Record =
coinPrices?.reduce((map, price) => {
map[formatCoinType(price.coinType)] = {
value: price.value,
decimals: price.decimals,
};
return map;
}, {} as Record) || {};
// 6. Calculate APY using grouped incentive data and reserve data with price info
const v3Apy = await calculateApy(groupedPools, reserves, coinPriceMap);
// 7. Merge the APY results
return mergeApyResults(v3Apy, v2SupplyApy, v2BorrowApy);
}
/**
* Main function to fetch on-chain data and compute APY information for third party.
*
* @param client - SuiClient instance used to fetch the raw data.
* @returns An array of final APY results for each pool.
*/
export async function getPoolsApyOuter(
client: SuiClient,
Token?: string
): Promise {
// 1. Get configuration
const config = await getConfig();
const userAddress =
"0xcda879cde94eeeae2dd6df58c9ededc60bcf2f7aedb79777e47d95b2cfb016c2";
// 2. Fetch ReserveData, IncentiveV3 data, and APY calculations in parallel
const [reserves, rawData, v2SupplyApy, v2BorrowApy] = await Promise.all([
getReserveData(config.StorageId, client),
client.getObject({
id: config.IncentiveV3,
options: { showType: true, showOwner: true, showContent: true },
}),
getIncentiveAPY(userAddress, client, 1),
getIncentiveAPY(userAddress, client, 3),
]);
// 3. Process incentive data
const incentiveData = rawData as unknown as V3Type.IncentiveData;
const groupedPools = groupByAssetCoinType(incentiveData);
// 4. Build a set of all coin types needed for price lookup
const coinTypeSet = new Set();
reserves.forEach((r: V3Type.ReserveData) => {
coinTypeSet.add(formatCoinType(r.coin_type));
});
groupedPools.forEach((group) => {
coinTypeSet.add(group.assetCoinType);
group.rules.forEach((rule) => {
coinTypeSet.add(formatCoinType(rule.rewardCoinType));
});
});
const coinTypes = Array.from(coinTypeSet);
// 5. Fetch coin price data
const coinPrices = await fetchCoinPrices(coinTypes,false,Token);
const coinPriceMap: Record =
coinPrices?.reduce((map, price) => {
map[formatCoinType(price.coinType)] = {
value: price.value,
decimals: price.decimals,
};
return map;
}, {} as Record) || {};
// 6. Calculate APY using grouped incentive data and reserve data with price info
const v3Apy = await calculateApy(groupedPools, reserves, coinPriceMap);
// 7. Merge the APY results
return mergeApyResults(v3Apy, v2SupplyApy, v2BorrowApy);
}
function addPrefixIfNeeded(address: string): string {
if (!address.startsWith("0x")) {
return "0x" + address;
}
return address;
}
const transformPoolData = (data: PoolData[]): V3Type.ApyResult[] => {
return data.map(pool => ({
asset: pool.id,
assetCoinType: addPrefixIfNeeded(pool.coinType),
supplyIncentiveApyInfo: {
rewardCoin: pool.supplyIncentiveApyInfo?.rewardCoin || [],
apy: parseFloat(pool.supplyIncentiveApyInfo.boostedApr),
},
borrowIncentiveApyInfo: {
rewardCoin: pool.borrowIncentiveApyInfo?.rewardCoin || [],
apy: parseFloat(pool.borrowIncentiveApyInfo.boostedApr),
},
}));
};
export async function getPoolApy(client: SuiClient): Promise {
return getPoolsInfo()
.then(data => {
if (data) {
return transformPoolData(data);
} else {
return getPoolApyInter(client);
}
})
.catch(error => {
console.error(error);
throw error;
});
}
export async function getPoolsApy(client: SuiClient, Token?: string): Promise {
return getPoolsInfo()
.then(data => {
if (data) {
return transformPoolData(data);
} else {
return getPoolsApyOuter(client, Token);
}
})
.catch(error => {
console.error(error);
throw error;
});
}