@symmetry-hq/baskets-sdk
Version:
Software Development Kit for interacting with Symmetry Baskets Program
484 lines (448 loc) • 19.5 kB
text/typescript
import { PublicKey, TransactionSignature, AddressLookupTableAccount, TransactionInstruction, ComputeBudgetProgram, VersionedTransaction, TransactionMessage, Connection } from '@solana/web3.js';
import { Basket } from './basketState';
import { BasketError, TokenSettings, ADDITIONAL_UNITS, TransactionToSend } from './config';
import { buildUpdateCurrentWeightsIx, buildWithdrawBeforeRebalanceIx, buildDepositAfterRebalanceIx } from './instructionsBuilder';
import { generateJupTxData, getAddressLookupTableAccounts, signVersionedTransactions, sendSignedTransactions, getOraclePrices, delay } from './utils';
import { BN, Program } from '@coral-xyz/anchor';
import { BasketsIDL } from './basketsIDL';
import { getAssociatedTokenAddressSync } from './splTokenHelpers';
// Updated type definitions
type RebalanceInfo = { over: TokenRebalanceInfo[], under: TokenRebalanceInfo[] };
type TokenRebalanceInfo = { value: number, token: number, amount: number, iT?: boolean, rR?: boolean, uM: number };
type RebalanceAmounts = { from: number, to: number, tokenAmount: number, value: number };
type JupiterSwapData = {
addressLookupTableAddresses: string[];
swapInstruction: any;
setupInstructions: any[];
res: { inAmount: number };
tokenAmount: number;
swapValue: number,
};
/**
* Fetches the current timestamp from the confirmed slot on the Solana blockchain.
* This is used to determine when rebalances should occur based on basket settings.
* @param connection The Solana connection
* @returns The current timestamp or a default value
*/
export async function getConfirmedTimestamp(connection: Connection, basket: Basket): Promise<number> {
const blockTime = await connection.getBlockTime(await connection.getSlot("confirmed")).catch((e) => null);
return (blockTime !== null && basket.data.sellState.toNumber() == 0) ? blockTime : 2000000000;
}
/**
* Fetches the lookup table account from the Solana blockchain.
* Lookup tables are used to optimize transaction size and cost for complex operations like rebalancing.
* @param connection The Solana connection
* @returns The lookup table account
*/
export async function getLookupTableAccount(connection: Connection, lookupTableAccount: PublicKey): Promise<AddressLookupTableAccount> {
const result = await connection.getAddressLookupTable(lookupTableAccount);
//@ts-ignore
return result.value;
}
/**
* Determines if force rebalance is needed for actively managed baskets.
* This allows basket managers to trigger rebalances regardless of other conditions.
* @param basket The basket to check
* @param wallet The wallet to use
* @returns True if force rebalance is needed, false otherwise
*/
export function isForceRebalanceNeeded(basket: Basket, walletPublicKey: PublicKey): boolean {
if (basket.data.sellState.toNumber() == 1 && basket.data.rebalanceSellState.toNumber() == 2)
return true;
return walletPublicKey.equals(basket.data.manager) && basket.data.activelyManaged.toNumber() === 1;
}
/**
* Gets and sorts the rebalance info for a basket.
* This function calculates which tokens are over or under their target weights,
* considering the basket's rebalance threshold and current market prices.
* @param basket The basket to rebalance
* @param oraclePriceData Oracle price data for accurate token valuation
* @param timestamp Current timestamp
* @param tokenList The token list
* @returns Sorted rebalance info
*/
export function getSortedRebalanceInfo(basket: Basket, oraclePriceData: number[], timestamp: number, tokenList: any): RebalanceInfo {
const rebalanceInfos = getFlashRebalanceInfo(basket, tokenList, oraclePriceData, timestamp);
rebalanceInfos.over.sort((a, b) => b.value - a.value);
rebalanceInfos.under.sort((a, b) => b.value - a.value);
return rebalanceInfos;
}
/**
* Builds rebalance transactions for a basket.
* This function creates transactions to adjust token weights to match their target weights,
* considering factors like rebalance threshold, interval, and slippage settings.
* @param basket The basket to rebalance
* @param rebalanceInfos Information about tokens that need rebalancing
* @param oraclePriceData Current oracle price data for tokens
* @param forceRebalance Whether to force rebalance regardless of conditions
* @param lookupTableAccount The lookup table account for the transaction
* @param wallet The wallet to use for the transaction
* @param connection The Solana connection
* @param program The program instance
* @param tokenList List of tokens in the basket
* @param lamports Amount of lamports to use
* @returns An array of transactions to send to the Solana blockchain
*/
export async function buildRebalanceTransactions(
basket: Basket,
rebalanceInfos: RebalanceInfo,
oraclePriceData: number[],
forceRebalance: boolean,
lookups: AddressLookupTableAccount[],
maxAllowedAccounts: number,
walletPublicKey: PublicKey,
connection: Connection,
program: Program<BasketsIDL>,
tokenList: TokenSettings[],
lamports: number,
updateOraclesTxData: TransactionToSend[],
softCap: number,
hardCap: number,
underTokens: number,
overTokens: number,
jupAPIkey: string,
): Promise<TransactionToSend[]> {
let txsToSend: TransactionToSend[] = updateOraclesTxData;
let size = txsToSend.length;
if (basket.data.sellState.toNumber() != 0)
{ overTokens = rebalanceInfos.over.length; underTokens = rebalanceInfos.under.length }
// Iterate over overweighted and underweighted tokens
for (const over of rebalanceInfos.over.slice(0, overTokens)) {
for (const under of rebalanceInfos.under.slice(0, underTokens)) {
// Check if we should process this rebalance based on thresholds and intervals
if (!shouldProcessRebalance(over, under, forceRebalance)) continue;
// Calculate the amounts to rebalance
const { from, to, tokenAmount, value } = calculateRebalanceAmounts(
over,
under,
oraclePriceData,
tokenList,
hardCap,
);
if (value <= softCap && !forceRebalance && basket.data.sellState.toNumber() == 0) continue;
// Get Jupiter swap data for the rebalance
const jupData = await getJupiterSwapData(
from, to, tokenAmount, maxAllowedAccounts,
basket, oraclePriceData, walletPublicKey, tokenList, jupAPIkey
);
if (!jupData) continue;
if (jupData.swapValue <= softCap && !forceRebalance && basket.data.sellState.toNumber() == 0) continue;
// Build the rebalance transaction
const tx = await buildRebalanceTransaction(
basket, from, to, jupData.tokenAmount, jupData,
lookups, walletPublicKey, connection,
program, tokenList, lamports,
);
txsToSend.push(tx);
// Update rebalance info values
over.value -= jupData.swapValue;
under.value -= jupData.swapValue;
}
}
if (txsToSend.length == size) return [];
return txsToSend;
}
/**
* Determines if a rebalance should be processed.
* This function checks if the rebalance is necessary based on the basket's settings and current state.
* @param over Over-weighted token info
* @param under Under-weighted token info
* @param forceRebalance Whether to force rebalance
* @returns True if rebalance should be processed, false otherwise
*/
export function shouldProcessRebalance(over: TokenRebalanceInfo, under: TokenRebalanceInfo, forceRebalance: boolean): boolean {
if (over.value <= 0 || under.value <= 0) return false;
if (!forceRebalance && (over.iT && under.iT)) return false; // Both tokens are within threshold
if (!forceRebalance && (over.rR && under.rR)) return false; // Both tokens were recently rebalanced
return true;
}
/**
* Calculates rebalance amounts.
* This function determines how much of each token should be swapped to bring them closer to their target weights.
* @param over Over-weighted token info
* @param under Under-weighted token info
* @param oraclePriceData Oracle price data
* @param tokenList The token list
* @returns Calculated rebalance amounts
*/
export function calculateRebalanceAmounts(
over: TokenRebalanceInfo,
under: TokenRebalanceInfo,
oraclePriceData: number[],
tokenList: any,
hardCap: number
): RebalanceAmounts {
const from = over.token;
const to = under.token;
const maxAmountUn = Math.min(hardCap, under.value) / oraclePriceData[from] * 10 ** tokenList[from].decimals;
const maxAmountOv = Math.min(hardCap, over.value) / oraclePriceData[from] * 10 ** tokenList[from].decimals;
let tokenAmount = Math.min(Math.floor(Math.min(maxAmountUn, maxAmountOv) * 0.995), over.amount);
if (maxAmountUn > maxAmountOv && over.uM !== 0) tokenAmount = over.uM;
const value = tokenAmount * oraclePriceData[from] / 10 ** tokenList[from].decimals;
return { from, to, tokenAmount, value };
}
/**
* Gets Jupiter swap data.
* This function prepares the data needed for a token swap using Jupiter DEX aggregator.
* @param from From token
* @param to To token
* @param tokenAmount Token amount
* @param basket Basket
* @param oraclePriceData Oracle price data
* @param wallet The wallet to use
* @param tokenList The token list
* @returns Jupiter swap data or null if failed
*/
export async function getJupiterSwapData(
from: number,
to: number,
tokenAmount: number,
maxAllowedAccounts: number,
basket: Basket,
oraclePriceData: number[],
walletPublicKey: PublicKey,
tokenList: any,
jupAPIkey: string,
): Promise<JupiterSwapData | null> {
const data = await generateJupTxData(
walletPublicKey,
tokenList[from].tokenMint,
tokenList[to].tokenMint,
tokenAmount,
maxAllowedAccounts,
basket.data.rebalanceSlippage.toNumber(),
oraclePriceData[from] / 10 ** tokenList[from].decimals,
oraclePriceData[to] / 10 ** tokenList[to].decimals,
jupAPIkey
).catch((e) => {
console.log("---------- Error ------------");
console.log("Jup Tx Data", e.message);
console.log("---------- End Error ------------");
return null;
});
return data;
}
/**
* Builds a rebalance transaction.
* This function creates a transaction that will perform the actual rebalancing of tokens in the basket on the Solana blockchain.
* @param basket The basket to rebalance
* @param from From token
* @param to To token
* @param tokenAmount Token amount
* @param jupData Jupiter swap data
* @param lookupTableAccount Lookup table account
* @param wallet The wallet to use
* @param connection The Solana connection
* @param program The program to use
* @param tokenList The token list
* @param lamports The lamports to use
* @returns A transaction to send to the Solana blockchain
*/
export async function buildRebalanceTransaction(
basket: Basket,
from: number,
to: number,
tokenAmount: number,
jupData: JupiterSwapData,
lookups: AddressLookupTableAccount[],
walletPublicKey: PublicKey,
connection: Connection,
program: Program<BasketsIDL>,
tokenList: TokenSettings[],
lamports: number
): Promise<TransactionToSend> {
const { addressLookupTableAddresses, swapInstruction, setupInstructions } = jupData;
const processedSetupInstructions = processInstructions(setupInstructions);
const processedSwapInstruction = processInstruction(swapInstruction);
const lookupTableAccounts = await getAddressLookupTableAccounts(connection, addressLookupTableAddresses);
return {
payerKey: walletPublicKey,
instructions: [
await buildUpdateCurrentWeightsIx(program, basket, tokenList),
...processedSetupInstructions,
await buildWithdrawBeforeRebalanceIx(
program, walletPublicKey, basket, tokenList, from, to, tokenAmount
),
processedSwapInstruction,
await buildDepositAfterRebalanceIx(
program, walletPublicKey, basket, tokenList, to
),
ComputeBudgetProgram.setComputeUnitLimit({units: ADDITIONAL_UNITS}),
ComputeBudgetProgram.setComputeUnitPrice({microLamports: lamports})
],
lookupTables: [...lookupTableAccounts, ...lookups]
};
}
/**
* Processes instructions by converting pubkeys to PublicKey objects.
* @param instructions Instructions to process
* @returns Processed instructions
*/
export function processInstructions(instructions: any[]): TransactionInstruction[] {
return instructions.map(processInstruction);
}
/**
* Processes a single instruction by converting pubkeys to PublicKey objects.
* @param instruction Instruction to process
* @returns Processed instruction
*/
export function processInstruction(instruction: any): TransactionInstruction {
return {
programId: new PublicKey(instruction.programId),
keys: instruction.accounts.map((a: any) => ({ ...a, pubkey: new PublicKey(a.pubkey) })),
data: Buffer.from(instruction.data, "base64")
};
}
/**
* Signs and sends transactions to the Solana blockchain.
* @param txsToSend Transactions to send
* @param connection The Solana connection
* @param wallet The wallet to use
* @returns An array of transaction signatures
*/
export async function signAndSendTransactions(
txsToSend: TransactionToSend[],
connection: Connection,
wallet: any,
confirmFirst: number,
): Promise<TransactionSignature[]> {
if (txsToSend.length == 0) return [];
const blockhash = (await connection.getLatestBlockhash("confirmed")).blockhash;
const signedTransactions = await signVersionedTransactions(
wallet,
txsToSend.map(tx => new VersionedTransaction(
new TransactionMessage({
payerKey: tx.payerKey,
recentBlockhash: blockhash,
instructions: tx.instructions,
}).compileToV0Message(tx.lookupTables)
))
).catch((e) => { console.log("Sign V Transactions", e); return []; });
return sendSignedTransactions(connection, signedTransactions, confirmFirst);
}
/**
* Calculates the flash rebalance amounts for a basket of tokens.
* This function determines which tokens need to be rebalanced based on their current weights,
* target weights, and the basket's rebalance threshold.
*
* @param numTokens - The number of tokens in the basket
* @param timestamp - The current timestamp
* @param lastRebalanceTime - Array of timestamps for the last rebalance of each token
* @param rebalanceInterval - The interval between rebalances
* @param currentCompToken - Array of current token compositions
* @param currentCompAmount - Array of current token amounts
* @param targetWeights - Array of target weights for each token
* @param weightSum - The sum of all target weights
* @param tokenList - List of token settings
* @param rebalanceThreshold - The threshold for rebalancing
* @param oraclePriceData - Array of current oracle prices for each token
*
* @returns An object containing arrays of over-weighted and under-weighted tokens
*/
export function calculateFlashRebalanceAmounts(
numTokens: number,
timestamp: number,
lastRebalanceTime: number[],
rebalanceInterval: number,
currentCompToken: number[],
currentCompAmount: number[],
targetWeights: number[],
weightSum: number,
tokenList: TokenSettings[],
rebalanceThreshold: number,
oraclePriceData: number[],
): {
over: TokenRebalanceInfo[]
under: TokenRebalanceInfo[]
} {
const currentValues: number[] = [];
let basketWorth = 0;
// Calculate current values and total basket worth
for (let i = 0; i < numTokens; i++) {
const price = oraclePriceData[currentCompToken[i]];
const tokenAmount = currentCompAmount[i] / Math.pow(10, tokenList[currentCompToken[i]].decimals);
const tokenValue = price * tokenAmount;
currentValues.push(tokenValue);
basketWorth += tokenValue;
}
// Return empty arrays if basket is worth nothing
if (basketWorth === 0) return { over: [], under: [] };
const res: { over: TokenRebalanceInfo[], under: TokenRebalanceInfo[] } = { over: [], under: [] };
// Determine over-weighted and under-weighted tokens
for (let i = 0; i < numTokens; i++) {
const currentPercentage = (currentValues[i] / basketWorth) * 10000;
const targetPercentage = Math.floor((targetWeights[i] / weightSum) * 10000);
const recentlyRebalanced = (lastRebalanceTime[i] + rebalanceInterval > timestamp);
const inThresholds = (
currentPercentage >= targetPercentage * (1 - rebalanceThreshold / 10000) &&
currentPercentage <= targetPercentage * (1 + rebalanceThreshold / 10000)
);
const diffOnChain = Math.floor(currentPercentage) == 0 ? 0 :
Math.floor(
currentCompAmount[i] *
Math.floor(Math.floor(currentPercentage) - targetPercentage) /
Math.floor(currentPercentage)
);
// Create TokenRebalanceInfo object
const item: TokenRebalanceInfo = {
token: currentCompToken[i],
value: Math.abs((currentPercentage - targetPercentage) * basketWorth) / 10000,
amount: targetPercentage == 0 ? currentCompAmount[i] : diffOnChain,
rR: recentlyRebalanced,
iT: inThresholds,
uM: targetPercentage === 0 ? currentCompAmount[i] : 0
};
// Categorize as over-weighted or under-weighted
if (currentValues[i] >= targetPercentage * basketWorth / 10000) {
res.over.push(item);
} else {
res.under.push(item);
}
}
return res;
}
/**
* Retrieves flash rebalance information for a given basket.
*
* @param basket - The basket to analyze
* @param tokenList - List of token settings
* @param oraclePriceData - Array of current oracle prices for each token
* @param timestamp - The current timestamp
*
* @returns An object containing arrays of over-weighted and under-weighted tokens
*/
export function getFlashRebalanceInfo(
basket: Basket,
tokenList: TokenSettings[],
oraclePriceData: number[],
timestamp: number,
): {
over: TokenRebalanceInfo[]
under: TokenRebalanceInfo[]
} {
// Extract relevant data from the basket
const {
currentCompToken,
currentCompAmount,
targetWeight,
numOfTokens,
rebalanceThreshold,
weightSum,
rebalanceInterval,
lastRebalanceTime
} = basket.data;
// Call calculateFlashRebalanceAmounts with extracted and parsed data
return calculateFlashRebalanceAmounts(
parseInt(numOfTokens.toString()),
timestamp,
lastRebalanceTime.map((x: BN) => parseInt(x.toString())),
parseInt(rebalanceInterval.toString()),
currentCompToken.map((x: BN) => parseInt(x.toString())),
currentCompAmount.map((x: BN) => parseInt(x.toString())),
targetWeight.map((x: BN) => parseInt(x.toString())),
parseInt(weightSum.toString()),
tokenList,
parseInt(rebalanceThreshold.toString()),
oraclePriceData,
);
}