import { Account as TokenAccount, TOKEN_PROGRAM_ID } from '@solana/spl-token'; import { Connection, PublicKey } from '@solana/web3.js'; import { BN } from '@staratlas/anchor'; import { CargoIDLProgram, CargoPod, CargoType } from '@staratlas/cargo'; import { AsyncSigner, createAssociatedTokenAccountIdempotent, getParsedTokenAccountsByOwner, InstructionReturn, readAllFromRPC, } from '@staratlas/data-source'; import { SageIDLProgram } from './constants'; import { StarbasePlayer } from './starbasePlayer'; export const SAGE_CARGO_STAT_VALUE_INDEX = 0; /** * Gets the used up cargo space in the cargo pod * @param pod - the Cargo Pod account * @returns cargo space used in the cargo pod */ export const getUsedCargoSpace = (pod: CargoPod) => pod.stats[SAGE_CARGO_STAT_VALUE_INDEX]; /** * Gets the used up cargo space for a given token amount for the given cargo type * @param cargoType - the Cargo Type account * @param tokenAmount - the token amount * @returns cargo space used by the token amount */ export const getCargoSpaceUsedByTokenAmount = ( cargoType: CargoType, tokenAmount: BN, ) => tokenAmount.mul(cargoType.stats[SAGE_CARGO_STAT_VALUE_INDEX]); /** * Gets the token amount required to reach a target cargo pod stat value * * You can use this to get how much tokens are required to fill up the pod capacity * or to determine how many tokens will take a given amount of cargo space * @param cargoType - the Cargo Type account * @param targetCargoSpace - the target cargo space amount (aka target cargo stat) * @returns amount of tokens needed to ge to `targetCargoSpace` */ export const getTokenAmountToReachTargetStat = ( cargoType: CargoType, targetCargoSpace: BN, ) => { const statMultiplier = cargoType.stats[SAGE_CARGO_STAT_VALUE_INDEX]; if (statMultiplier.gt(new BN(0))) { return targetCargoSpace.div(statMultiplier); } else { // in this case the cargoType does not take up any cargo space return targetCargoSpace; } }; /** * Get StarbasePlayer accounts owned by the provided PlayerProfile * @param connection - Solana connection * @param sageProgram - the SAGE program * @param playerProfile - the player profile * @param gameId - the SAGE game id * @returns array of Starbase players */ export const getStarbasePlayersByProfile = async ( connection: Connection, sageProgram: SageIDLProgram, playerProfile: PublicKey, gameId: PublicKey, ) => { return await readAllFromRPC( connection, sageProgram, StarbasePlayer, 'processed', [ { memcmp: { offset: 8 + 1, bytes: playerProfile.toBase58(), }, }, { memcmp: { offset: 8 + 1 + 32, bytes: gameId.toBase58(), }, }, ], ); }; /** * Get CargoPod accounts owned by the provided authority * @param connection - Solana connection * @param cargoProgram - the SAGE program * @param authority - the authority * @returns array of Cargo Pods */ export const getCargoPodsByAuthority = async ( connection: Connection, cargoProgram: CargoIDLProgram, authority: PublicKey, ) => { return await readAllFromRPC(connection, cargoProgram, CargoPod, 'processed', [ { memcmp: { offset: 8 + 1 + 32, bytes: authority.toBase58(), }, }, ]); }; /** * @deprecated use `getPodCleanupInstructions` instead * * Clean up Starbase cargo pods for the provided StarbasePlayer account * * Removes any extra StarbasePlayer-owned cargo pods by transferring all the cargo to the main cargo pod at a Starbase. * @param connection - Solana connection * @param sageProgram - the SAGE program * @param cargoProgram - the Cargo program * @param starbasePlayer - the StarbasePlayer * @param starbase - the Starbase associated with the `starbasePlayer` * @param playerProfile - the player profile * @param profileFaction - the player profile's faction * @param cargoStatsDefinition - the cargo stats definition * @param gameId - the SAGE game id * @param gameState - the game state account associated with `gameId` * @param key - the key authorized to run this instruction, should have at least `ADD_REMOVE_CARGO` SAGE permissions * @param keyIndex - the index of the `key` in the `playerProfile` permissions * @returns InstructionReturn[] * */ export const cleanCargoPodsByStarbasePlayer = async ( connection: Connection, sageProgram: SageIDLProgram, cargoProgram: CargoIDLProgram, starbasePlayer: PublicKey, starbase: PublicKey, playerProfile: PublicKey, profileFaction: PublicKey, cargoStatsDefinition: PublicKey, gameId: PublicKey, gameState: PublicKey, key: AsyncSigner, keyIndex: number, ) => { const cleanInstructions: InstructionReturn[] = []; const newTokenAccounts: string[] = []; const cargoPods = await getCargoPodsByAuthority( connection, cargoProgram, starbasePlayer, ); if (cargoPods.length > 1) { let podsToClean = cargoPods.map((cargoPod) => { if (cargoPod.type === 'error') throw new Error('Error reading CargoPod account'); return cargoPod.data; }); const mainPod = podsToClean.reduce(function (prev, current) { return prev.data.openTokenAccounts > current.data.openTokenAccounts ? prev : current; }); podsToClean = podsToClean.filter((it) => !it.key.equals(mainPod.key)); for (let index2 = 0; index2 < podsToClean.length; index2++) { const thisPod = podsToClean[index2]; const podTokenAccounts = await getParsedTokenAccountsByOwner( connection, thisPod.key, ); if (podTokenAccounts.length > 0) { for (let index3 = 0; index3 < podTokenAccounts.length; index3++) { const tokenData = podTokenAccounts[index3]; const cargoType = CargoType.findAddress( cargoProgram, cargoStatsDefinition, tokenData.mint, thisPod.data.seqId, )[0]; if (Number(tokenData.delegatedAmount) > 0) { const tokenTo = createAssociatedTokenAccountIdempotent( tokenData.mint, mainPod.key, true, TOKEN_PROGRAM_ID, ); const tokenToBase58 = tokenTo.address.toBase58(); if (!newTokenAccounts.includes(tokenToBase58)) { newTokenAccounts.push(tokenToBase58); cleanInstructions.push(tokenTo.instructions); } const transferIx = StarbasePlayer.transferCargoAtStarbase( sageProgram, cargoProgram, starbasePlayer, key, 'funder', playerProfile, profileFaction, starbase, thisPod.key, mainPod.key, cargoType, cargoStatsDefinition, tokenData.address, tokenTo.address, tokenData.mint, gameId, gameState, { amount: new BN(tokenData.delegatedAmount.toString()), keyIndex, }, ); cleanInstructions.push(transferIx); } else { const closeIx = StarbasePlayer.closeStarbaseCargoTokenAccount( sageProgram, cargoProgram, starbasePlayer, key, 'funder', playerProfile, profileFaction, starbase, thisPod.key, cargoType, cargoStatsDefinition, tokenData.address, tokenData.mint, gameId, gameState, { keyIndex, }, ); cleanInstructions.push(closeIx); } } } cleanInstructions.push( StarbasePlayer.removeCargoPod( sageProgram, cargoProgram, starbasePlayer, key, playerProfile, profileFaction, 'funder', starbase, thisPod.key, gameId, gameState, { keyIndex, }, ), ); } } return cleanInstructions; }; interface PodCleanup { mainPod: PublicKey; podsAndTokensToClean: [PublicKey, TokenAccount[]][]; cargoSeqId: number; } /** * Returns the accounts that would be needed to clean up cargo pods at a Starbase * @param connection - SOlana connection object * @param cargoProgram - the Cargo program * @param starbasePlayer - the StarbasePlayer * @returns the accounts needed for cleanup as `PodCleanup` */ export const getCleanPodsByStarbasePlayerAccounts = async ( connection: Connection, cargoProgram: CargoIDLProgram, starbasePlayer: PublicKey, ): Promise => { const cargoPods = await getCargoPodsByAuthority( connection, cargoProgram, starbasePlayer, ); if (cargoPods.length > 1) { let podsToClean = cargoPods.map((cargoPod) => { if (cargoPod.type === 'error') throw new Error('Error reading CargoPod account'); return cargoPod.data; }); const mainPod = podsToClean.reduce(function (prev, current) { return prev.data.openTokenAccounts > current.data.openTokenAccounts ? prev : current; }); podsToClean = podsToClean.filter((it) => !it.key.equals(mainPod.key)); const podsAndTokensToClean: Array<[PublicKey, TokenAccount[]]> = []; for (let index2 = 0; index2 < podsToClean.length; index2++) { const thisPod = podsToClean[index2]; const podTokenAccounts = await getParsedTokenAccountsByOwner( connection, thisPod.key, ); const result: [PublicKey, TokenAccount[]] = [ thisPod.key, podTokenAccounts, ]; podsAndTokensToClean.push(result); } return { mainPod: mainPod.key, podsAndTokensToClean, cargoSeqId: mainPod.data.seqId, }; } }; /** * Get instructions for cleaning up cargo pods associated with a `starbasePlayer` account * @param podCleanup - pod cleanup accounts from `getCleanPodsByStarbasePlayerAccounts` * @param sageProgram - the SAGE program * @param cargoProgram - the Cargo program * @param starbasePlayer - the StarbasePlayer * @param starbase - the Starbase associated with the `starbasePlayer` * @param playerProfile - the player profile * @param profileFaction - the player profile's faction * @param cargoStatsDefinition - the cargo stats definition * @param gameId - the SAGE game id * @param gameState - the game state account associated with `gameId` * @param key - the key authorized to run this instruction, should have at least `ADD_REMOVE_CARGO` SAGE permissions * @param keyIndex - the index of the `key` in the `playerProfile` permissions * @returns InstructionReturn[] */ export const getPodCleanupInstructions = ( podCleanup: PodCleanup, sageProgram: SageIDLProgram, cargoProgram: CargoIDLProgram, starbasePlayer: PublicKey, starbase: PublicKey, playerProfile: PublicKey, profileFaction: PublicKey, cargoStatsDefinition: PublicKey, gameId: PublicKey, gameState: PublicKey, key: AsyncSigner, keyIndex: number, ) => { const cleanInstructions: InstructionReturn[] = []; const newTokenAccounts: string[] = []; if (podCleanup.podsAndTokensToClean.length > 1) { for ( let index2 = 0; index2 < podCleanup.podsAndTokensToClean.length; index2++ ) { const element = podCleanup.podsAndTokensToClean[index2]; const thisPodKey = element[0]; const podTokenAccounts = element[1]; if (podTokenAccounts.length > 0) { for (let index3 = 0; index3 < podTokenAccounts.length; index3++) { const tokenData = podTokenAccounts[index3]; const cargoType = CargoType.findAddress( cargoProgram, cargoStatsDefinition, tokenData.mint, podCleanup.cargoSeqId, )[0]; if (Number(tokenData.delegatedAmount) > 0) { const tokenTo = createAssociatedTokenAccountIdempotent( tokenData.mint, podCleanup.mainPod, true, TOKEN_PROGRAM_ID, ); const tokenToBase58 = tokenTo.address.toBase58(); if (!newTokenAccounts.includes(tokenToBase58)) { newTokenAccounts.push(tokenToBase58); cleanInstructions.push(tokenTo.instructions); } const transferIx = StarbasePlayer.transferCargoAtStarbase( sageProgram, cargoProgram, starbasePlayer, key, 'funder', playerProfile, profileFaction, starbase, thisPodKey, podCleanup.mainPod, cargoType, cargoStatsDefinition, tokenData.address, tokenTo.address, tokenData.mint, gameId, gameState, { amount: new BN(tokenData.delegatedAmount.toString()), keyIndex, }, ); cleanInstructions.push(transferIx); } else { const closeIx = StarbasePlayer.closeStarbaseCargoTokenAccount( sageProgram, cargoProgram, starbasePlayer, key, 'funder', playerProfile, profileFaction, starbase, thisPodKey, cargoType, cargoStatsDefinition, tokenData.address, tokenData.mint, gameId, gameState, { keyIndex, }, ); cleanInstructions.push(closeIx); } } } cleanInstructions.push( StarbasePlayer.removeCargoPod( sageProgram, cargoProgram, starbasePlayer, key, playerProfile, profileFaction, 'funder', starbase, thisPodKey, gameId, gameState, { keyIndex, }, ), ); } } return cleanInstructions; }; /** * Clean up Starbase cargo pods * * Checks if a PlayerProfile owns more than one cargo pod at the same Starbase and if so, removes * the extra cargo pods by transferring all the cargo to the main cargo pod at a Starbase. * @param connection - Solana connection * @param sageProgram - the SAGE program * @param cargoProgram - the Cargo program * @param playerProfile - the player profile * @param profileFaction - the player profile's faction * @param cargoStatsDefinition - the cargo stats definition * @param gameId - the SAGE game id * @param gameState - the game state account associated with `gameId` * @param key - the key authorized to run this instruction, should have at least `ADD_REMOVE_CARGO` SAGE permissions * @param keyIndex - the index of the `key` in the `playerProfile` permissions * @returns InstructionReturn[] */ export const cleanUpStarbaseCargoPods = async ( connection: Connection, sageProgram: SageIDLProgram, cargoProgram: CargoIDLProgram, playerProfile: PublicKey, profileFaction: PublicKey, cargoStatsDefinition: PublicKey, gameId: PublicKey, gameState: PublicKey, key: AsyncSigner, keyIndex: number, ) => { const cleanInstructions: InstructionReturn[] = []; const starbasePlayerObjects = await getStarbasePlayersByProfile( connection, sageProgram, playerProfile, gameId, ); if (starbasePlayerObjects.length > 0) { for (let index = 0; index < starbasePlayerObjects.length; index++) { const sbpObj = starbasePlayerObjects[index]; if (!sbpObj || sbpObj.type === 'error') { throw new Error('Error reading StarbasePlayer account'); } const cleanupAccounts = await getCleanPodsByStarbasePlayerAccounts( connection, cargoProgram, sbpObj.key, ); const instructions = cleanupAccounts ? getPodCleanupInstructions( cleanupAccounts, sageProgram, cargoProgram, sbpObj.key, sbpObj.data.data.starbase, playerProfile, profileFaction, cargoStatsDefinition, gameId, gameState, key, keyIndex, ) : []; if (instructions.length > 0) { cleanInstructions.push(...instructions); } } } else { console.log('No StarbasePlayer accounts'); } return cleanInstructions; }; /** * Find the certificate mint address * This mint represents cargo at a specific location (the starbase) * @param program - SAGE program * @param starbase - the Starbase * @param cargoMint - the cargo mint address * @param starbaseSeqId - the Starbase sequence id * @returns The PDA and bump respectively */ export const findCertificateMintAddress = ( program: SageIDLProgram, starbase: PublicKey, cargoMint: PublicKey, starbaseSeqId: number, ): [PublicKey, number] => { const arr = new ArrayBuffer(2); const view = new DataView(arr); view.setUint16(0, starbaseSeqId, true); const seqIdSeed = new Uint8Array(view.buffer); return PublicKey.findProgramAddressSync( [ Buffer.from('CertificateMint'), cargoMint.toBuffer(), starbase.toBuffer(), seqIdSeed, ], program.programId, ); }; /** * Find the local market account meta list address * * The account meta list is used to store all extra required * accounts needed when transferring tokens of mints that use the * Token 2022 program's transfer hook extension. * @param transferHookProgram - the transfer hook program * @param mint - the cargo mint * @returns The PDA and bump respectively */ export const findExtraAccountMetaListAddress = ( transferHookProgram: PublicKey, mint: PublicKey, ): [PublicKey, number] => { return PublicKey.findProgramAddressSync( [Buffer.from('extra-account-metas'), mint.toBuffer()], transferHookProgram, ); }; /** * Ensures that the return is a PublicKey instance * @param input - the input to normalize * @returns the PublicKey */ export const normalizePublicKey = (input: number[] | PublicKey): PublicKey => Array.isArray(input) ? new PublicKey(input) : input; /** * The divideIntoParts function divides a number n into p parts, ensuring that the sum of * the parts equals n. If n cannot be divided equally, the remainder is distributed across * the parts. The result is returned as an array of length p, where each element represents * a part of n. * @param n - The number to be divided. This represents the total sum that needs to be distributed. * @param p - The number of parts into which n should be divided. * @returns An array of length p containing the parts of n. The elements of the array will sum up to n. */ export function divideIntoParts(n: number, p: number): number[] { // Calculate the base value for each part const baseValue = Math.floor(n / p); // Calculate the remainder const remainder = n % p; // Create an array with 'p' elements, initially filled with the base value const result = Array(p).fill(baseValue); // Distribute the remainder by adding 1 to the first 'remainder' elements for (let i = 0; i < remainder; i++) { result[i] += 1; } return result; }