From dd89b4058fed2d2b3e64e48634fde29b45b15dc8 Mon Sep 17 00:00:00 2001 From: felix11 Date: Wed, 22 Apr 2026 01:56:28 +0530 Subject: [PATCH 1/2] AutobalanceLp pending-rewards function implementation --- README.md | 15 +++ src/core/index.ts | 24 +++++ src/models/blockchain.ts | 156 +++++++++++++++++++++++++++----- src/models/portfolio.ts | 6 +- src/models/strategyContext.ts | 17 +--- src/strategies/autobalanceLp.ts | 127 ++++++++++++++++++++++++++ src/strategies/strategy.ts | 3 +- src/utils/poolMap.ts | 4 +- 8 files changed, 309 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index 8acf53e..b5ba35a 100644 --- a/README.md +++ b/README.md @@ -159,6 +159,21 @@ Get balance for a single pool. const balance = await sdk.getUserSinglePoolBalance(userAddress, '0x...'); ``` +##### autobalanceLpPendingRewardAmount(userAddress: string, poolId: string): Promise\ + +Get the user's pending non-ALPHA rewards for an `AutobalanceLp` pool. Returns a map of reward coin type +(with `0x` prefix) to pending amount as a decimal string. Throws if `poolId` does not correspond to an +`AutobalanceLp` pool. + +```typescript +interface UserAutoBalanceRewardAmounts { + [coinType: string]: string; // reward coin type (0x-prefixed) -> pending amount (decimal string) +} + +const rewards = await sdk.autobalanceLpPendingRewardAmount(userAddress, '0x...'); +// e.g. { '0x2::sui::SUI': '0.0123', '0x...::deep::DEEP': '4.56' } +``` + ##### getUserPortfolio(address: string, strategiesType?: StrategyType[]): Promise\ Get complete portfolio summary for a user address. diff --git a/src/core/index.ts b/src/core/index.ts index 1781224..b7d0aab 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -29,6 +29,10 @@ import { RouterDataV3 } from '@cetusprotocol/aggregator-sdk'; import { Strategy, StrategyType } from '../strategies/strategy.js'; import { LEGACY_ALPHA_POOL_RECEIPT, PACKAGE_IDS, VERSIONS } from '../utils/constants.js'; import { AlphaVaultStrategy } from '../strategies/alphaVault.js'; +import { + AutobalanceLpStrategy, + UserAutoBalanceRewardAmounts, +} from '../strategies/autobalanceLp.js'; import { ZapDepositStrategy } from '../strategies/zapDeposit.js'; import { LpStrategy } from '../strategies/lp.js'; import { SlushSingleAssetLoopingStrategy } from '../strategies/slushSingleAssetLooping.js'; @@ -100,6 +104,26 @@ export class AlphaFiSDK { return strategy.getBalance(address); } + /** + * Get the user's pending non-ALPHA rewards for an AutobalanceLp pool. + * This method is only valid for pools using the AutobalanceLp strategy + * + * @param userAddress - The user's wallet address + * @param poolId - The AutobalanceLp pool's object ID + * @returns A map of reward coin type (with `0x` prefix) to pending amount as a decimal string + * @throws If `poolId` does not correspond to an AutobalanceLp pool + */ + async autobalanceLpPendingRewardAmount( + userAddress: string, + poolId: string, + ): Promise { + const strategy = await this.portfolio.getPoolStrategy(userAddress, poolId); + if (!(strategy instanceof AutobalanceLpStrategy)) { + throw new Error(`Pool ${poolId} is not an AutobalanceLp pool`); + } + return strategy.pendingRewardAmount(userAddress); + } + /** * Get complete portfolio summary for a user address. * diff --git a/src/models/blockchain.ts b/src/models/blockchain.ts index 2e0bc49..f7a6f78 100644 --- a/src/models/blockchain.ts +++ b/src/models/blockchain.ts @@ -2,10 +2,11 @@ * Blockchain interface wrapper for Sui network operations using GraphQL and JSON-RPC clients. */ -import { CoinStruct, SuiClient } from '@mysten/sui/client'; +import { SuiClient } from '@mysten/sui/client'; import { SuiGraphQLClient } from '@mysten/sui/graphql'; import { graphql } from '@mysten/sui/graphql/schemas/latest'; import { Transaction } from '@mysten/sui/transactions'; +import { toBase64 } from '@mysten/sui/utils'; export type BlockchainOptions = { network: 'mainnet' | 'testnet' | 'devnet' | 'localnet'; @@ -47,29 +48,49 @@ export class Blockchain { } } + const query = graphql(` + query getCoins($address: SuiAddress!, $coinType: String!, $cursor: String) { + address(address: $address) { + objects(after: $cursor, filter: { type: $coinType }) { + pageInfo { + hasNextPage + endCursor + } + nodes { + address + } + } + } + } + `); + + const wrappedCoinType = `0x2::coin::Coin<${coinType}>`; let currentCursor: string | null | undefined = null; - let coins1: CoinStruct[] = []; + const coinObjectIds: string[] = []; do { - const response = await this.suiClient.getCoins({ - owner: address, - coinType, - cursor: currentCursor, + const response: any = await this.gqlClient.query({ + query, + variables: { address, coinType: wrappedCoinType, cursor: currentCursor }, }); - coins1 = coins1.concat(response.data); - if (response.hasNextPage && response.nextCursor) { - currentCursor = response.nextCursor; + const objects: any = response.data?.address?.objects; + if (objects?.nodes) { + for (const node of objects.nodes) { + if (node?.address) { + coinObjectIds.push(node.address); + } + } + } + if (objects?.pageInfo?.hasNextPage && objects.pageInfo.endCursor) { + currentCursor = objects.pageInfo.endCursor; } else break; } while (true); - if (coins1.length === 0) { + if (coinObjectIds.length === 0) { throw new Error(`No coins found for ${coinType} for owner ${address}`); } - const [coin] = tx.splitCoins(tx.object(coins1[0].coinObjectId), [0]); - tx.mergeCoins( - coin, - coins1.map((c) => c.coinObjectId), - ); + const [coin] = tx.splitCoins(tx.object(coinObjectIds[0]), [0]); + tx.mergeCoins(coin, coinObjectIds); if (amount) { const returnCoin = tx.splitCoins(coin, [amount]); @@ -98,24 +119,111 @@ export class Blockchain { return receiptOption; } + /** Simulate a transaction via GraphQL and return the raw simulation result. */ + async simulateTransaction(tx: Transaction, sender: string) { + tx.setSenderIfNotSet(sender); + const txBytes = await tx.build({ client: this.suiClient }); + const txBase64 = toBase64(txBytes); + + const query = graphql(` + query simulate($tx: JSON!) { + simulateTransaction(transaction: $tx, checksEnabled: true, doGasSelection: false) { + effects { + status + balanceChangesJson + gasEffects { + gasSummary { + computationCost + storageCost + storageRebate + nonRefundableStorageFee + } + } + } + outputs { + returnValues { + argument { + __typename + } + value { + type { + repr + } + json + display { + output + } + } + } + } + } + } + `); + + const result: any = await this.gqlClient.query({ + query, + variables: { tx: { bcs: { value: txBase64 } } }, + }); + + return result.data?.simulateTransaction; + } + /** Estimate gas budget for transaction execution. */ async getEstimatedGasBudget(tx: Transaction, sender: string): Promise { try { - const simResult = await this.suiClient.devInspectTransactionBlock({ - transactionBlock: tx, - sender, - }); - return ( - Number(simResult.effects.gasUsed.computationCost) + - Number(simResult.effects.gasUsed.nonRefundableStorageFee) + - 1e8 - ); + const simResult = await this.simulateTransaction(tx, sender); + const gasSummary = simResult?.effects?.gasEffects?.gasSummary; + if (!gasSummary) { + throw new Error('Simulation returned no gas summary'); + } + return Number(gasSummary.computationCost) + Number(gasSummary.nonRefundableStorageFee) + 1e8; } catch (err) { console.error(`Error estimating transaction gasBudget`, err); return undefined; } } + /** Get all coin balances owned by an address using GraphQL, paginated. */ + async getAllBalances(address: string): Promise<{ coinType: string; totalBalance: string }[]> { + const query = graphql(` + query getBalances($address: SuiAddress!, $cursor: String) { + address(address: $address) { + balances(after: $cursor) { + pageInfo { + hasNextPage + endCursor + } + nodes { + coinType { + repr + } + coinBalance + } + } + } + } + `); + + const balances: { coinType: string; totalBalance: string }[] = []; + const response: any = await this.gqlClient.query({ + query, + variables: { address }, + }); + const balancesConn: any = response.data?.address?.balances; + if (balancesConn?.nodes) { + for (const node of balancesConn.nodes) { + if (node?.coinType?.repr && node?.coinBalance) { + balances.push({ + coinType: node.coinType.repr, + totalBalance: node.coinBalance, + }); + } + } + } + + return balances; + } + /** Get object contents by ID using GraphQL. */ async getObject(objectId: string) { const query = graphql(` diff --git a/src/models/portfolio.ts b/src/models/portfolio.ts index ddb55d2..ebdcfcd 100644 --- a/src/models/portfolio.ts +++ b/src/models/portfolio.ts @@ -34,12 +34,10 @@ export class Portfolio { /** Get all coin balances in user's wallet. */ async getWalletCoins(userAddress: string): Promise> { - const res = await this.strategyContext.blockchain.suiClient.getAllBalances({ - owner: userAddress, - }); + const res = await this.strategyContext.blockchain.getAllBalances(userAddress); const resMap: Map = new Map(); - res.forEach((entry: { coinType: string; totalBalance: string }) => { + res.forEach((entry) => { resMap.set(normalizeStructTag(entry.coinType), entry.totalBalance); }); return resMap; diff --git a/src/models/strategyContext.ts b/src/models/strategyContext.ts index a8ad759..a9e53a8 100644 --- a/src/models/strategyContext.ts +++ b/src/models/strategyContext.ts @@ -482,7 +482,7 @@ export class StrategyContext { size: fields.pool_allocator.rewards.size, }, totalWeights: fields.pool_allocator.total_weights.contents.map((weight: any) => ({ - key: weight.key.name, + key: typeof weight.key === 'string' ? weight.key : weight.key?.name, value: weight.value, })), }, @@ -575,22 +575,15 @@ export class StrategyContext { private async fetchBucketTvl(): Promise { const FOUNTAIN = '0xbdf91f558c2b61662e5839db600198eda66d502e4c10c4fc5c683f9caca13359'; const FLASK = '0xc6ecc9731e15d182bc0a46ebe1754a779a4bfb165c201102ad51a36838a1a7b8'; - const fountain = await this.blockchain.suiClient.getObject({ - id: FOUNTAIN, - options: { showContent: true }, - }); - const flask = await this.blockchain.suiClient.getObject({ - id: FLASK, - options: { showContent: true }, - }); - const fountainFields = (fountain.data as any)?.content?.fields; - const flaskFields = (flask.data as any)?.content?.fields; + const objects = await this.blockchain.multiGetObjects([FOUNTAIN, FLASK]); + const fountainFields = objects.get(FOUNTAIN); + const flaskFields = objects.get(FLASK); if (!fountainFields || !flaskFields) { throw new Error('Failed to get fountain or flask fields'); } const totalSbuckInFountain = new Decimal(fountainFields.staked || '0'); const reserves = new Decimal(flaskFields.reserves || '0'); - const sbuckSupply = new Decimal(flaskFields.sbuck_supply?.fields?.value || '0'); + const sbuckSupply = new Decimal(flaskFields.sbuck_supply?.value || '0'); if (sbuckSupply.isZero()) { return new Decimal(0); } diff --git a/src/strategies/autobalanceLp.ts b/src/strategies/autobalanceLp.ts index df704b4..8660d26 100644 --- a/src/strategies/autobalanceLp.ts +++ b/src/strategies/autobalanceLp.ts @@ -538,6 +538,122 @@ export class AutobalanceLpStrategy extends BaseStrategy< return; } + async pendingRewardAmount(userAddress: string): Promise { + try { + const tx = new Transaction(); + this.collectReward(tx); + + if (this.poolLabel.assetA.name === 'SUI') { + tx.moveCall({ + target: `${this.poolLabel.packageId}::alphafi_bluefin_sui_first_pool::update_pool_v4`, + typeArguments: [this.poolLabel.assetA.type, this.poolLabel.assetB.type], + arguments: [ + tx.object(VERSIONS.AUTOBALANCE_LP), + tx.object(this.poolLabel.poolId), + tx.object(this.poolLabel.investorId), + tx.object(DISTRIBUTOR_OBJECT_ID), + tx.object(GLOBAL_CONFIGS.BLUEFIN), + tx.object(this.poolLabel.parentPoolId), + tx.object(CLOCK_PACKAGE_ID), + ], + }); + tx.moveCall({ + target: `${this.poolLabel.packageId}::alphafi_bluefin_sui_first_pool::get_cur_acc_per_xtoken`, + typeArguments: [this.poolLabel.assetA.type, this.poolLabel.assetB.type], + arguments: [tx.object(this.poolLabel.poolId)], + }); + } else if (this.poolLabel.assetB.name === 'SUI') { + tx.moveCall({ + target: `${this.poolLabel.packageId}::alphafi_bluefin_sui_second_pool::update_pool_v3`, + typeArguments: [this.poolLabel.assetA.type, this.poolLabel.assetB.type], + arguments: [ + tx.object(VERSIONS.AUTOBALANCE_LP), + tx.object(this.poolLabel.poolId), + tx.object(this.poolLabel.investorId), + tx.object(DISTRIBUTOR_OBJECT_ID), + tx.object(GLOBAL_CONFIGS.BLUEFIN), + tx.object(this.poolLabel.parentPoolId), + tx.object(CLOCK_PACKAGE_ID), + ], + }); + tx.moveCall({ + target: `${this.poolLabel.packageId}::alphafi_bluefin_sui_second_pool::get_cur_acc_per_xtoken`, + typeArguments: [this.poolLabel.assetA.type, this.poolLabel.assetB.type], + arguments: [tx.object(this.poolLabel.poolId)], + }); + } else { + tx.moveCall({ + target: `${this.poolLabel.packageId}::alphafi_bluefin_type_1_pool::update_pool_v3`, + typeArguments: [this.poolLabel.assetA.type, this.poolLabel.assetB.type], + arguments: [ + tx.object(VERSIONS.AUTOBALANCE_LP), + tx.object(this.poolLabel.poolId), + tx.object(this.poolLabel.investorId), + tx.object(DISTRIBUTOR_OBJECT_ID), + tx.object(GLOBAL_CONFIGS.BLUEFIN), + tx.object(this.poolLabel.parentPoolId), + tx.object(CLOCK_PACKAGE_ID), + ], + }); + tx.moveCall({ + target: `${this.poolLabel.packageId}::alphafi_bluefin_type_1_pool::get_cur_acc_per_xtoken`, + typeArguments: [this.poolLabel.assetA.type, this.poolLabel.assetB.type], + arguments: [tx.object(this.poolLabel.poolId)], + }); + } + + const res = await this.context.blockchain.simulateTransaction(tx, userAddress); + + const receipt = this.receiptObjects[0]; + if (!receipt) { + return {}; + } + + const userXtokenBalance = receipt.xTokenBalance; + const totalPendingRewardAmounts: UserAutoBalanceRewardAmounts = {}; + const userPendingReward: { [key in string]: string } = {}; + const curAcc: { [key in string]: string } = {}; + const lastAcc: { [key in string]: string } = {}; + + for (let i = 0; i < receipt.pendingRewards.length; i++) { + const rewardType = this.normalizeRewardType(receipt.pendingRewards[i].key); + userPendingReward[rewardType] = receipt.pendingRewards[i].value; + } + + const lastOutput = res?.outputs?.[res.outputs.length - 1]; + const currAccForAllRewards: Array<{ key: string; value: string }> = + lastOutput?.returnValues?.[0]?.value?.json?.contents ?? []; + currAccForAllRewards.forEach((reward) => { + curAcc[this.normalizeRewardType(reward.key)] = String(reward.value); + }); + + receipt.lastAccRewardPerXtoken.forEach((reward) => { + lastAcc[this.normalizeRewardType(reward.key)] = reward.value; + }); + + for (const type of Object.keys(curAcc)) { + const cur = new Decimal(curAcc[type]); + const last = new Decimal(type in lastAcc ? lastAcc[type] : '0'); + const pending = new Decimal(type in userPendingReward ? userPendingReward[type] : '0'); + const decimals = await this.context.getCoinDecimals(type); + + const totalPending = cur + .minus(last) + .mul(userXtokenBalance) + .div(1e36) + .plus(pending) + .div(Math.pow(10, decimals)) + .toString(); + totalPendingRewardAmounts[type] = totalPending; + } + + return totalPendingRewardAmounts; + } catch (e) { + console.error('error in calculate pending blue rewards', e); + return {}; + } + } + private collectReward(tx: Transaction) { if (this.poolLabel.assetA.name === 'SUI') { for (const reward of this.parentPoolObject.rewardInfos) { @@ -674,6 +790,13 @@ export class AutobalanceLpStrategy extends BaseStrategy< } return rewards; } + + private normalizeRewardType(rewardType: string): string { + if (rewardType.startsWith('0x')) { + return rewardType; + } + return `0x${rewardType}`; + } } /** @@ -748,6 +871,10 @@ export interface AutobalanceLpReceiptObject { xTokenBalance: string; } +export interface UserAutoBalanceRewardAmounts { + [key: string]: string; +} + /** * AutobalanceLp Pool Label configuration */ diff --git a/src/strategies/strategy.ts b/src/strategies/strategy.ts index 6572b49..849f35b 100644 --- a/src/strategies/strategy.ts +++ b/src/strategies/strategy.ts @@ -374,7 +374,8 @@ export abstract class BaseStrategy { - const key = entry?.key?.name; + const rawKey = entry?.key; + const key = typeof rawKey === 'string' ? rawKey : rawKey?.name; const value = entry?.value; return { key, value }; }); diff --git a/src/utils/poolMap.ts b/src/utils/poolMap.ts index fe5fa89..3262639 100644 --- a/src/utils/poolMap.ts +++ b/src/utils/poolMap.ts @@ -230,13 +230,13 @@ export const POOL_REGISTRY: Record = { cetus: '0x0fea99ed9c65068638963a81587c3b8cafb71dc38c545319f008f7e9feb2b5f8', }, [getCanonicalPairKey( - '0x27792d9fed7f9844eb4839566001bb6f6cb4804f66aa2da6fe1ee242d896881::coin::COIN', + '0x027792d9fed7f9844eb4839566001bb6f6cb4804f66aa2da6fe1ee242d896881::coin::COIN', '0x5d4b302506645c37ff133b98c4b50a5ae14841659738d6d733d59d0d217a93bf::coin::COIN', )]: { cetus: '0xaa57c66ba6ee8f2219376659f727f2b13d49ead66435aa99f57bb008a64a8042', }, [getCanonicalPairKey( - '0x27792d9fed7f9844eb4839566001bb6f6cb4804f66aa2da6fe1ee242d896881::coin::COIN', + '0x027792d9fed7f9844eb4839566001bb6f6cb4804f66aa2da6fe1ee242d896881::coin::COIN', '0xdba34672e30cb065b1f93e3ab55318768fd6fef66c15942c9f7cb846e2f900e7::usdc::USDC', )]: { bluefin: '0x38282481e3a024c50254c31ebfc4710e003fe1b219c0aa31482a860bd58c4ab0', From 4d5eb52b3e4dc81c8f7f2e4d63679adce4a90811 Mon Sep 17 00:00:00 2001 From: felix11 Date: Wed, 22 Apr 2026 12:00:01 +0530 Subject: [PATCH 2/2] pendingRewardAmount issue fixes --- src/core/index.ts | 7 +- src/models/blockchain.ts | 47 ++++--- src/models/types.ts | 43 +++++++ src/strategies/autobalanceLp.ts | 209 ++++++++++++++++---------------- 4 files changed, 181 insertions(+), 125 deletions(-) diff --git a/src/core/index.ts b/src/core/index.ts index b7d0aab..946f2a1 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -110,8 +110,11 @@ export class AlphaFiSDK { * * @param userAddress - The user's wallet address * @param poolId - The AutobalanceLp pool's object ID - * @returns A map of reward coin type (with `0x` prefix) to pending amount as a decimal string - * @throws If `poolId` does not correspond to an AutobalanceLp pool + * @returns A map of reward coin type (with `0x` prefix) to pending amount as a decimal string. + * Returns an empty map only when the user has no position in the pool. + * @throws If `poolId` does not correspond to an AutobalanceLp pool, or if the on-chain + * simulation / reward calculation fails. Errors are propagated so callers can distinguish + * failures from a genuinely empty reward state. */ async autobalanceLpPendingRewardAmount( userAddress: string, diff --git a/src/models/blockchain.ts b/src/models/blockchain.ts index f7a6f78..e65b8e4 100644 --- a/src/models/blockchain.ts +++ b/src/models/blockchain.ts @@ -7,6 +7,7 @@ import { SuiGraphQLClient } from '@mysten/sui/graphql'; import { graphql } from '@mysten/sui/graphql/schemas/latest'; import { Transaction } from '@mysten/sui/transactions'; import { toBase64 } from '@mysten/sui/utils'; +import type { SimulationGasSummary, SimulationResult } from './types.js'; export type BlockchainOptions = { network: 'mainnet' | 'testnet' | 'devnet' | 'localnet'; @@ -119,8 +120,11 @@ export class Blockchain { return receiptOption; } - /** Simulate a transaction via GraphQL and return the raw simulation result. */ - async simulateTransaction(tx: Transaction, sender: string) { + /** Simulate a transaction via GraphQL and return a typed simulation result. */ + async simulateTransaction( + tx: Transaction, + sender: string, + ): Promise { tx.setSenderIfNotSet(sender); const txBytes = await tx.build({ client: this.suiClient }); const txBase64 = toBase64(txBytes); @@ -160,19 +164,20 @@ export class Blockchain { } `); - const result: any = await this.gqlClient.query({ + const result = await this.gqlClient.query({ query, variables: { tx: { bcs: { value: txBase64 } } }, }); - return result.data?.simulateTransaction; + return result.data?.simulateTransaction as SimulationResult | undefined; } /** Estimate gas budget for transaction execution. */ async getEstimatedGasBudget(tx: Transaction, sender: string): Promise { try { const simResult = await this.simulateTransaction(tx, sender); - const gasSummary = simResult?.effects?.gasEffects?.gasSummary; + const gasSummary: SimulationGasSummary | null | undefined = + simResult?.effects?.gasEffects?.gasSummary; if (!gasSummary) { throw new Error('Simulation returned no gas summary'); } @@ -205,21 +210,27 @@ export class Blockchain { `); const balances: { coinType: string; totalBalance: string }[] = []; - const response: any = await this.gqlClient.query({ - query, - variables: { address }, - }); - const balancesConn: any = response.data?.address?.balances; - if (balancesConn?.nodes) { - for (const node of balancesConn.nodes) { - if (node?.coinType?.repr && node?.coinBalance) { - balances.push({ - coinType: node.coinType.repr, - totalBalance: node.coinBalance, - }); + let currentCursor: string | null | undefined = null; + do { + const response: any = await this.gqlClient.query({ + query, + variables: { address, cursor: currentCursor }, + }); + const balancesConn: any = response.data?.address?.balances; + if (balancesConn?.nodes) { + for (const node of balancesConn.nodes) { + if (node?.coinType?.repr && node?.coinBalance) { + balances.push({ + coinType: node.coinType.repr, + totalBalance: node.coinBalance, + }); + } } } - } + if (balancesConn?.pageInfo?.hasNextPage && balancesConn.pageInfo.endCursor) { + currentCursor = balancesConn.pageInfo.endCursor; + } else break; + } while (true); return balances; } diff --git a/src/models/types.ts b/src/models/types.ts index accdba8..2ad5332 100644 --- a/src/models/types.ts +++ b/src/models/types.ts @@ -123,6 +123,49 @@ export type AlphaFiReceipt = { imageUrl: string; }; +/** + * Typed shape of `simulateTransaction` GraphQL responses. + * Mirrors the selection set used by `Blockchain.simulateTransaction`. + * Scalar numeric fields arrive as `string | number` depending on size, + * so callers should coerce via `Number(..)` / `BigInt(..)` as appropriate. + */ +export interface SimulationGasSummary { + computationCost: string | number; + storageCost: string | number; + storageRebate: string | number; + nonRefundableStorageFee: string | number; +} + +export interface SimulationGasEffects { + gasSummary: SimulationGasSummary | null; +} + +export interface SimulationEffects { + status: string | null; + /** `JSON` scalar — shape not statically knowable; coerce at the call-site if needed. */ + balanceChangesJson: unknown; + gasEffects: SimulationGasEffects | null; +} + +export interface SimulationReturnValue { + argument: { __typename: string } | null; + value: { + type: { repr: string }; + /** Move value JSON-encoded; the concrete shape depends on the function's return type. */ + json: unknown; + display: unknown | null; + }; +} + +export interface SimulationOutput { + returnValues: SimulationReturnValue[]; +} + +export interface SimulationResult { + effects: SimulationEffects | null; + outputs: SimulationOutput[]; +} + export type DistributorObject = { airdropWallet: string; airdropWalletBalance: string; diff --git a/src/strategies/autobalanceLp.ts b/src/strategies/autobalanceLp.ts index 8660d26..5f6ef03 100644 --- a/src/strategies/autobalanceLp.ts +++ b/src/strategies/autobalanceLp.ts @@ -17,6 +17,14 @@ import { VERSIONS, } from '../utils/constants.js'; +/** + * Fixed-point precision scaling factor used by the on-chain `acc_reward_per_xtoken` + * accumulator (1e36). Pending rewards are computed as + * `(currentAcc - lastAcc) * userXtokenBalance / ACC_REWARD_PER_XTOKEN_PRECISION`, + * then divided by the reward coin's own decimal scale to produce a human amount. + */ +const ACC_REWARD_PER_XTOKEN_PRECISION = new Decimal(10).pow(36); + /** * AutobalanceLp Strategy for dual-asset liquidity pools with automatic rebalancing */ @@ -539,119 +547,84 @@ export class AutobalanceLpStrategy extends BaseStrategy< } async pendingRewardAmount(userAddress: string): Promise { - try { - const tx = new Transaction(); - this.collectReward(tx); + const tx = new Transaction(); + this.collectReward(tx); - if (this.poolLabel.assetA.name === 'SUI') { - tx.moveCall({ - target: `${this.poolLabel.packageId}::alphafi_bluefin_sui_first_pool::update_pool_v4`, - typeArguments: [this.poolLabel.assetA.type, this.poolLabel.assetB.type], - arguments: [ - tx.object(VERSIONS.AUTOBALANCE_LP), - tx.object(this.poolLabel.poolId), - tx.object(this.poolLabel.investorId), - tx.object(DISTRIBUTOR_OBJECT_ID), - tx.object(GLOBAL_CONFIGS.BLUEFIN), - tx.object(this.poolLabel.parentPoolId), - tx.object(CLOCK_PACKAGE_ID), - ], - }); - tx.moveCall({ - target: `${this.poolLabel.packageId}::alphafi_bluefin_sui_first_pool::get_cur_acc_per_xtoken`, - typeArguments: [this.poolLabel.assetA.type, this.poolLabel.assetB.type], - arguments: [tx.object(this.poolLabel.poolId)], - }); - } else if (this.poolLabel.assetB.name === 'SUI') { - tx.moveCall({ - target: `${this.poolLabel.packageId}::alphafi_bluefin_sui_second_pool::update_pool_v3`, - typeArguments: [this.poolLabel.assetA.type, this.poolLabel.assetB.type], - arguments: [ - tx.object(VERSIONS.AUTOBALANCE_LP), - tx.object(this.poolLabel.poolId), - tx.object(this.poolLabel.investorId), - tx.object(DISTRIBUTOR_OBJECT_ID), - tx.object(GLOBAL_CONFIGS.BLUEFIN), - tx.object(this.poolLabel.parentPoolId), - tx.object(CLOCK_PACKAGE_ID), - ], - }); - tx.moveCall({ - target: `${this.poolLabel.packageId}::alphafi_bluefin_sui_second_pool::get_cur_acc_per_xtoken`, - typeArguments: [this.poolLabel.assetA.type, this.poolLabel.assetB.type], - arguments: [tx.object(this.poolLabel.poolId)], - }); - } else { - tx.moveCall({ - target: `${this.poolLabel.packageId}::alphafi_bluefin_type_1_pool::update_pool_v3`, - typeArguments: [this.poolLabel.assetA.type, this.poolLabel.assetB.type], - arguments: [ - tx.object(VERSIONS.AUTOBALANCE_LP), - tx.object(this.poolLabel.poolId), - tx.object(this.poolLabel.investorId), - tx.object(DISTRIBUTOR_OBJECT_ID), - tx.object(GLOBAL_CONFIGS.BLUEFIN), - tx.object(this.poolLabel.parentPoolId), - tx.object(CLOCK_PACKAGE_ID), - ], - }); - tx.moveCall({ - target: `${this.poolLabel.packageId}::alphafi_bluefin_type_1_pool::get_cur_acc_per_xtoken`, - typeArguments: [this.poolLabel.assetA.type, this.poolLabel.assetB.type], - arguments: [tx.object(this.poolLabel.poolId)], - }); - } + const moduleName = this.getPoolModule(); + const updatePoolFn = this.getUpdatePoolFn(); + const typeArguments = [this.poolLabel.assetA.type, this.poolLabel.assetB.type]; - const res = await this.context.blockchain.simulateTransaction(tx, userAddress); + tx.moveCall({ + target: `${this.poolLabel.packageId}::${moduleName}::${updatePoolFn}`, + typeArguments, + arguments: [ + tx.object(VERSIONS.AUTOBALANCE_LP), + tx.object(this.poolLabel.poolId), + tx.object(this.poolLabel.investorId), + tx.object(DISTRIBUTOR_OBJECT_ID), + tx.object(GLOBAL_CONFIGS.BLUEFIN), + tx.object(this.poolLabel.parentPoolId), + tx.object(CLOCK_PACKAGE_ID), + ], + }); + tx.moveCall({ + target: `${this.poolLabel.packageId}::${moduleName}::get_cur_acc_per_xtoken`, + typeArguments, + arguments: [tx.object(this.poolLabel.poolId)], + }); - const receipt = this.receiptObjects[0]; - if (!receipt) { - return {}; - } + const res = await this.context.blockchain.simulateTransaction(tx, userAddress); - const userXtokenBalance = receipt.xTokenBalance; - const totalPendingRewardAmounts: UserAutoBalanceRewardAmounts = {}; - const userPendingReward: { [key in string]: string } = {}; - const curAcc: { [key in string]: string } = {}; - const lastAcc: { [key in string]: string } = {}; + const receipt = this.receiptObjects[0]; + if (!receipt) { + return {}; + } - for (let i = 0; i < receipt.pendingRewards.length; i++) { - const rewardType = this.normalizeRewardType(receipt.pendingRewards[i].key); - userPendingReward[rewardType] = receipt.pendingRewards[i].value; - } + const userXtokenBalance = receipt.xTokenBalance; + const totalPendingRewardAmounts: UserAutoBalanceRewardAmounts = {}; + const userPendingReward: { [key in string]: string } = {}; + const curAcc: { [key in string]: string } = {}; + const lastAcc: { [key in string]: string } = {}; - const lastOutput = res?.outputs?.[res.outputs.length - 1]; - const currAccForAllRewards: Array<{ key: string; value: string }> = - lastOutput?.returnValues?.[0]?.value?.json?.contents ?? []; - currAccForAllRewards.forEach((reward) => { - curAcc[this.normalizeRewardType(reward.key)] = String(reward.value); - }); - - receipt.lastAccRewardPerXtoken.forEach((reward) => { - lastAcc[this.normalizeRewardType(reward.key)] = reward.value; - }); - - for (const type of Object.keys(curAcc)) { - const cur = new Decimal(curAcc[type]); - const last = new Decimal(type in lastAcc ? lastAcc[type] : '0'); - const pending = new Decimal(type in userPendingReward ? userPendingReward[type] : '0'); - const decimals = await this.context.getCoinDecimals(type); - - const totalPending = cur - .minus(last) - .mul(userXtokenBalance) - .div(1e36) - .plus(pending) - .div(Math.pow(10, decimals)) - .toString(); - totalPendingRewardAmounts[type] = totalPending; - } + for (let i = 0; i < receipt.pendingRewards.length; i++) { + const rewardType = this.normalizeRewardType(receipt.pendingRewards[i].key); + userPendingReward[rewardType] = receipt.pendingRewards[i].value; + } - return totalPendingRewardAmounts; - } catch (e) { - console.error('error in calculate pending blue rewards', e); - return {}; + // `get_cur_acc_per_xtoken` returns a `VecMap` which GraphQL + // encodes as `{ contents: [{ key, value }, ...] }`. + const lastOutput = res?.outputs?.[res.outputs.length - 1]; + const rawJson = lastOutput?.returnValues?.[0]?.value?.json; + const currAccForAllRewards: Array<{ key: string; value: string }> = isVecMapJson(rawJson) + ? rawJson.contents + : []; + currAccForAllRewards.forEach((reward) => { + curAcc[this.normalizeRewardType(reward.key)] = String(reward.value); + }); + + receipt.lastAccRewardPerXtoken.forEach((reward) => { + lastAcc[this.normalizeRewardType(reward.key)] = reward.value; + }); + + for (const rewardType of Object.keys(curAcc)) { + const cur = new Decimal(curAcc[rewardType]); + const last = new Decimal(rewardType in lastAcc ? lastAcc[rewardType] : '0'); + const pending = new Decimal( + rewardType in userPendingReward ? userPendingReward[rewardType] : '0', + ); + const decimals = await this.context.getCoinDecimals(rewardType); + + const totalPending = cur + .minus(last) + .mul(userXtokenBalance) + .div(ACC_REWARD_PER_XTOKEN_PRECISION) + .plus(pending) + .div(Math.pow(10, decimals)) + .toString(); + totalPendingRewardAmounts[rewardType] = totalPending; } + + return totalPendingRewardAmounts; } private collectReward(tx: Transaction) { @@ -797,6 +770,32 @@ export class AutobalanceLpStrategy extends BaseStrategy< } return `0x${rewardType}`; } + + /** Get the Move module name for this pool's autobalance-lp variant. */ + private getPoolModule(): string { + if (this.poolLabel.assetA.name === 'SUI') return 'alphafi_bluefin_sui_first_pool'; + if (this.poolLabel.assetB.name === 'SUI') return 'alphafi_bluefin_sui_second_pool'; + return 'alphafi_bluefin_type_1_pool'; + } + + /** Get the versioned `update_pool` entry function for this pool variant. */ + private getUpdatePoolFn(): string { + return this.poolLabel.assetA.name === 'SUI' ? 'update_pool_v4' : 'update_pool_v3'; + } +} + +/** GraphQL JSON shape of a Move `VecMap` return value. */ +interface MoveVecMapJson { + contents: Array<{ key: string; value: string }>; +} + +/** Narrow a `SimulationReturnValue.value.json` blob to a `VecMap` shape. */ +function isVecMapJson(json: unknown): json is MoveVecMapJson { + return ( + typeof json === 'object' && + json !== null && + Array.isArray((json as { contents?: unknown }).contents) + ); } /**