Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,21 @@ Get balance for a single pool.
const balance = await sdk.getUserSinglePoolBalance(userAddress, '0x...');
```

##### autobalanceLpPendingRewardAmount(userAddress: string, poolId: string): Promise\<UserAutoBalanceRewardAmounts>

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\<UserPortfolioData>

Get complete portfolio summary for a user address.
Expand Down
27 changes: 27 additions & 0 deletions src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -100,6 +104,29 @@ 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.
* 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,
poolId: string,
): Promise<UserAutoBalanceRewardAmounts> {
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.
*
Expand Down
167 changes: 143 additions & 24 deletions src/models/blockchain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@
* 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';
import type { SimulationGasSummary, SimulationResult } from './types.js';

export type BlockchainOptions = {
network: 'mainnet' | 'testnet' | 'devnet' | 'localnet';
Expand Down Expand Up @@ -47,29 +49,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]);
Expand Down Expand Up @@ -98,24 +120,121 @@ export class Blockchain {
return receiptOption;
}

/** Simulate a transaction via GraphQL and return a typed simulation result. */
async simulateTransaction(
tx: Transaction,
sender: string,
): Promise<SimulationResult | undefined> {
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) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Untyped simulation response. simulateTransaction returns any, and downstream code navigates deeply into res?.outputs?.[res.outputs.length - 1]?.returnValues?.[0]?.value?.json?.contents. If the GraphQL schema changes, this will silently break at runtime.

A typed response interface would catch regressions at compile time.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed

effects {
status
balanceChangesJson
gasEffects {
gasSummary {
computationCost
storageCost
storageRebate
nonRefundableStorageFee
}
}
}
outputs {
returnValues {
argument {
__typename
}
value {
type {
repr
}
json
display {
output
}
}
}
}
}
}
`);

const result = await this.gqlClient.query({
query,
variables: { tx: { bcs: { value: txBase64 } } },
});

return result.data?.simulateTransaction as SimulationResult | undefined;
}

/** Estimate gas budget for transaction execution. */
async getEstimatedGasBudget(tx: Transaction, sender: string): Promise<number | undefined> {
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: SimulationGasSummary | null | undefined =
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;
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: getAllBalances is not actually paginated. The query accepts a $cursor variable and the response includes pageInfo, but this method never loops — it fetches one page and returns. If a user holds more coin types than fit in a single page, balances will be silently truncated.

The getCoins migration above correctly paginates with a do/while loop — this method needs the same treatment.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed

/** 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 }[] = [];
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;
}

/** Get object contents by ID using GraphQL. */
async getObject(objectId: string) {
const query = graphql(`
Expand Down
6 changes: 2 additions & 4 deletions src/models/portfolio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,10 @@ export class Portfolio {

/** Get all coin balances in user's wallet. */
async getWalletCoins(userAddress: string): Promise<Map<string, string>> {
const res = await this.strategyContext.blockchain.suiClient.getAllBalances({
owner: userAddress,
});
const res = await this.strategyContext.blockchain.getAllBalances(userAddress);

const resMap: Map<string, string> = new Map();
res.forEach((entry: { coinType: string; totalBalance: string }) => {
res.forEach((entry) => {
resMap.set(normalizeStructTag(entry.coinType), entry.totalBalance);
});
return resMap;
Expand Down
17 changes: 5 additions & 12 deletions src/models/strategyContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})),
},
Expand Down Expand Up @@ -575,22 +575,15 @@ export class StrategyContext {
private async fetchBucketTvl(): Promise<Decimal> {
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);
}
Expand Down
43 changes: 43 additions & 0 deletions src/models/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading