@xswap-link/sdk
Version:
JavaScript SDK for XSwap platform
567 lines (505 loc) • 16.8 kB
text/typescript
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-nocheck
import {
PublicKey,
VersionedTransaction,
Connection,
AccountMeta,
SystemProgram,
TransactionInstruction,
TransactionMessage,
AddressLookupTableAccount,
} from "@solana/web3.js";
import {
getAssociatedTokenAddress,
TOKEN_PROGRAM_ID,
NATIVE_MINT,
TOKEN_2022_PROGRAM_ID,
ASSOCIATED_TOKEN_PROGRAM_ID,
} from "@solana/spl-token";
import { BN } from "@coral-xyz/anchor";
import { Logger } from "../../utils/logger";
import { createErrorEnhancer } from "../../utils/errors";
import {
CCIPContext,
CCIPSendRequest,
CCIPSendOptions,
CCIPCoreConfig,
} from "../models";
import { CCIPAccountReader } from "./accounts";
import {
ccipSend,
CcipSendAccounts,
CcipSendArgs,
} from "../../bindings/instructions/ccipSend";
import {
findConfigPDA,
findDestChainStatePDA,
findNoncePDA,
findFeeBillingSignerPDA,
findFqConfigPDA,
findFqDestChainPDA,
findFqBillingTokenConfigPDA,
findFqPerChainPerTokenConfigPDA,
findRMNRemoteConfigPDA,
findRMNRemoteCursesPDA,
findTokenPoolChainConfigPDA,
} from "../../utils/pdas";
/**
* Sends a CCIP message
*
* @param context SDK context with provider, config and logger
* @param request Send request parameters
* @param accountReader Account reader instance
* @param computeBudgetInstruction Optional compute budget instruction
* @param sendOptions Optional send options (skipPreflight, etc.)
* @returns Transaction signature
*/
export async function sendCCIPMessage(
context: CCIPContext,
request: CCIPSendRequest,
accountReader: CCIPAccountReader,
computeBudgetInstruction?: TransactionInstruction,
sendOptions?: CCIPSendOptions,
): Promise<VersionedTransaction> {
if (!context.logger) {
throw new Error("Logger is required for sendCCIPMessage");
}
const logger = context.logger;
const config = context.config;
const connection = context.provider.connection;
const enhanceError = createErrorEnhancer(logger);
// Determine if we're using native SOL
const isNativeSol = request.feeToken.equals(PublicKey.default);
// For native SOL, we use NATIVE_MINT as the token mint
const feeTokenMint = isNativeSol ? NATIVE_MINT : request.feeToken;
// Determine the correct fee token program ID
let feeTokenProgramId = TOKEN_PROGRAM_ID;
if (!isNativeSol) {
try {
const feeTokenMintInfo = await connection.getAccountInfo(feeTokenMint);
if (feeTokenMintInfo) {
feeTokenProgramId = feeTokenMintInfo.owner;
} else {
feeTokenProgramId = TOKEN_2022_PROGRAM_ID;
}
} catch (error) {
feeTokenProgramId = TOKEN_2022_PROGRAM_ID;
}
}
const selectorBigInt = BigInt(request.destChainSelector.toString());
const signerPublicKey = context.provider.getAddress();
// Build the accounts for the ccipSend instruction
const accounts = await buildCCIPSendAccounts(
config,
selectorBigInt,
request,
feeTokenMint,
feeTokenProgramId,
isNativeSol,
signerPublicKey,
logger,
);
// Build token indexes and accounts
const { tokenIndexes, remainingAccounts, lookupTableList } =
await buildTokenAccountsForSend(
request,
connection,
feeTokenProgramId,
accountReader,
logger,
config,
signerPublicKey,
);
// Create the args for the ccipSend instruction
const args: CcipSendArgs = {
destChainSelector: request.destChainSelector,
message: {
receiver: request.receiver,
data: request.data,
tokenAmounts: request.tokenAmounts,
feeToken: request.feeToken,
extraArgs: request.extraArgs,
},
tokenIndexes: new Uint8Array(tokenIndexes),
};
// Create the ccipSend instruction
const instruction = ccipSend(args, accounts, config.ccipRouterProgramId);
// Add remaining accounts to the instruction
if (remainingAccounts.length > 0) {
instruction.keys.push(...remainingAccounts);
}
const { blockhash, lastValidBlockHeight } =
await connection.getLatestBlockhash({
commitment: "finalized", // Using finalized for longer validity
});
// Create the transaction instructions array
const instructions: TransactionInstruction[] = [];
// Add compute budget instruction if provided
if (computeBudgetInstruction) {
instructions.push(computeBudgetInstruction);
}
// Add the ccipSend instruction
instructions.push(instruction);
// Create the transaction
const messageV0 = new TransactionMessage({
payerKey: signerPublicKey,
recentBlockhash: blockhash,
instructions,
}).compileToV0Message(lookupTableList);
const tx = new VersionedTransaction(messageV0);
const signedTx = await context.provider.signTransaction(tx);
return signedTx;
}
/**
* Build accounts required for the ccipSend instruction
*/
async function buildCCIPSendAccounts(
config: CCIPCoreConfig,
selectorBigInt: bigint,
request: CCIPSendRequest,
feeTokenMint: PublicKey,
feeTokenProgramId: PublicKey,
isNativeSol: boolean,
signerPublicKey: PublicKey,
logger: Logger,
): Promise<CcipSendAccounts> {
const enhanceError = createErrorEnhancer(logger);
try {
logger.info(
`Building accounts for CCIP send to chain ${selectorBigInt.toString()}`,
);
logger.debug(
`Fee token: ${feeTokenMint.toString()} (${
isNativeSol ? "Native SOL" : "SPL Token"
})`,
);
// Find all the PDAs needed for the ccipSend instruction
const [configPDA] = findConfigPDA(config.ccipRouterProgramId);
const [destChainState] = findDestChainStatePDA(
selectorBigInt,
config.ccipRouterProgramId,
);
const [nonce] = findNoncePDA(
selectorBigInt,
signerPublicKey,
config.ccipRouterProgramId,
);
const [feeBillingSigner] = findFeeBillingSignerPDA(
config.ccipRouterProgramId,
);
const [feeQuoterConfig] = findFqConfigPDA(config.feeQuoterProgramId);
const [fqDestChain] = findFqDestChainPDA(
selectorBigInt,
config.feeQuoterProgramId,
);
const [fqBillingTokenConfig] = findFqBillingTokenConfigPDA(
feeTokenMint,
config.feeQuoterProgramId,
);
const [fqLinkBillingTokenConfig] = findFqBillingTokenConfigPDA(
config.linkTokenMint,
config.feeQuoterProgramId,
);
const [rmnRemoteCurses] = findRMNRemoteCursesPDA(config.rmnRemoteProgramId);
const [rmnRemoteConfig] = findRMNRemoteConfigPDA(config.rmnRemoteProgramId);
// Get the associated token accounts for the user and fee billing signer
logger.debug(
`Deriving token accounts for fee token: ${feeTokenMint.toString()}`,
);
const userFeeTokenAccount = isNativeSol
? PublicKey.default // For native SOL we use the default public key
: await getAssociatedTokenAddress(
feeTokenMint,
signerPublicKey,
true,
feeTokenProgramId,
ASSOCIATED_TOKEN_PROGRAM_ID,
);
const feeBillingSignerFeeTokenAccount = await getAssociatedTokenAddress(
feeTokenMint,
feeBillingSigner,
true,
feeTokenProgramId,
ASSOCIATED_TOKEN_PROGRAM_ID,
);
return {
authority: signerPublicKey,
config: configPDA,
destChainState: destChainState,
nonce: nonce,
systemProgram: SystemProgram.programId,
feeTokenProgram: feeTokenProgramId,
feeTokenMint: feeTokenMint,
feeTokenUserAssociatedAccount: userFeeTokenAccount,
feeTokenReceiver: feeBillingSignerFeeTokenAccount,
feeBillingSigner: feeBillingSigner,
feeQuoter: config.feeQuoterProgramId,
feeQuoterConfig: feeQuoterConfig,
feeQuoterDestChain: fqDestChain,
feeQuoterBillingTokenConfig: fqBillingTokenConfig,
feeQuoterLinkTokenConfig: fqLinkBillingTokenConfig,
rmnRemote: config.rmnRemoteProgramId,
rmnRemoteCurses: rmnRemoteCurses,
rmnRemoteConfig: rmnRemoteConfig,
};
} catch (error) {
// Use enhanceError to add context and properly log the error
throw enhanceError(error, {
operation: "buildCCIPSendAccounts",
destChainSelector: selectorBigInt.toString(),
feeToken: feeTokenMint.toString(),
isNativeSol: isNativeSol,
});
}
}
/**
* Build token accounts and indexes for CCIP send
*/
async function buildTokenAccountsForSend(
request: CCIPSendRequest,
connection: Connection,
feeTokenProgramId: PublicKey,
accountReader: CCIPAccountReader,
logger: Logger,
config: CCIPCoreConfig,
signerPublicKey: PublicKey,
): Promise<{
tokenIndexes: number[];
remainingAccounts: AccountMeta[];
lookupTableList: AddressLookupTableAccount[];
}> {
const enhanceError = createErrorEnhancer(logger);
logger.debug(
`Building token accounts for ${request.tokenAmounts.length} tokens`,
);
// Setup token accounts
const tokenIndexes: number[] = [];
const remainingAccounts: AccountMeta[] = [];
const lookupTableList: AddressLookupTableAccount[] = [];
let lastIndex = 0;
// Process each token amount
for (const tokenAmount of request.tokenAmounts) {
try {
const tokenMint = tokenAmount.token;
logger.debug(
`Processing token: ${tokenMint.toString()}, amount: ${tokenAmount.amount.toString()}`,
);
// Determine token program from token mint
let tokenProgram = feeTokenProgramId;
try {
const tokenMintInfo = await connection.getAccountInfo(tokenMint);
if (tokenMintInfo) {
tokenProgram = tokenMintInfo.owner;
logger.debug(
`Auto-detected token program: ${tokenProgram.toString()} for token: ${tokenMint.toString()}`,
);
} else {
logger.warn(
`Token mint info not found for ${tokenMint.toString()}, using fallback token program`,
);
}
} catch (error) {
logger.warn(
`Error determining token program, using fallback: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
// Get token admin registry for this token to access lookup table
const tokenAdminRegistry = await accountReader.getTokenAdminRegistry(
tokenMint,
);
logger.debug(
`Retrieved token admin registry for ${tokenMint.toString()}`,
);
// Get lookup table for this token
const lookupTable = await getLookupTableAccount(
connection,
tokenAdminRegistry.lookupTable,
logger,
);
lookupTableList.push(lookupTable);
// Get the lookup table addresses
const lookupTableAddresses = lookupTable.state.addresses;
// Extract pool program from lookup table
const poolProgram = getPoolProgram(lookupTableAddresses, logger);
// Get user token account - use the signer public key
const userTokenAccount = await getAssociatedTokenAddress(
tokenMint,
signerPublicKey,
true,
tokenProgram,
ASSOCIATED_TOKEN_PROGRAM_ID,
);
// Get token chain config
const [tokenBillingConfig] = findFqPerChainPerTokenConfigPDA(
BigInt(request.destChainSelector.toString()),
tokenMint,
config.feeQuoterProgramId,
);
// Get pool chain config
const [poolChainConfig] = findTokenPoolChainConfigPDA(
BigInt(request.destChainSelector.toString()),
tokenMint,
poolProgram,
);
// Build token accounts using lookup table
const tokenAccounts = buildTokenLookupAccounts(
userTokenAccount,
tokenBillingConfig,
poolChainConfig,
lookupTableAddresses,
tokenAdminRegistry.writableIndexes,
logger,
);
tokenIndexes.push(lastIndex);
const currentLen = tokenAccounts.length;
lastIndex += currentLen;
remainingAccounts.push(...tokenAccounts);
logger.debug(
`Added ${currentLen} token-specific accounts for ${tokenMint.toString()}`,
);
} catch (error) {
throw enhanceError(error, {
operation: "buildTokenAccountsForSend",
token: tokenAmount.token.toString(),
amount: tokenAmount.amount.toString(),
});
}
}
return { tokenIndexes, remainingAccounts, lookupTableList };
}
/**
* Gets an address lookup table account
*/
async function getLookupTableAccount(
connection: Connection,
lookupTableAddress: PublicKey,
logger: Logger,
): Promise<AddressLookupTableAccount> {
const enhanceError = createErrorEnhancer(logger);
logger.debug(`Fetching lookup table: ${lookupTableAddress.toString()}`);
const { value: lookupTableAccount } = await connection.getAddressLookupTable(
lookupTableAddress,
);
if (!lookupTableAccount) {
throw enhanceError(
new Error(`Lookup table not found: ${lookupTableAddress.toString()}`),
{
operation: "getLookupTableAccount",
lookupTableAddress: lookupTableAddress.toString(),
},
);
}
if (lookupTableAccount.state.addresses.length < 7) {
throw enhanceError(
new Error(
`Lookup table has insufficient accounts: ${lookupTableAccount.state.addresses.length} (needs at least 7)`,
),
{
operation: "getLookupTableAccount",
lookupTableAddress: lookupTableAddress.toString(),
addressCount: lookupTableAccount.state.addresses.length,
},
);
}
logger.trace(
`Lookup table fetched with ${lookupTableAccount.state.addresses.length} addresses`,
);
return lookupTableAccount;
}
/**
* Extracts the pool program from lookup table addresses
*/
function getPoolProgram(
lookupTableAddresses: PublicKey[],
logger: Logger,
): PublicKey {
const enhanceError = createErrorEnhancer(logger);
// The pool program is at index 2 in the lookup table
if (lookupTableAddresses.length <= 2) {
throw enhanceError(
new Error(
"Lookup table doesn't have enough entries to determine pool program",
),
{
operation: "getPoolProgram",
addressCount: lookupTableAddresses.length,
},
);
}
const poolProgram = lookupTableAddresses[2];
logger.debug(
`Using pool program: ${poolProgram.toString()} (index 2 in lookup table)`,
);
return poolProgram;
}
/**
* Build token accounts using lookup table
*/
function buildTokenLookupAccounts(
userTokenAccount: PublicKey,
tokenBillingConfig: PublicKey,
poolChainConfig: PublicKey,
lookupTableEntries: Array<PublicKey>,
writableIndexes: BN[],
logger: Logger,
): Array<AccountMeta> {
// First entry is the lookup table itself
const lookupTable = lookupTableEntries[0];
logger.trace("Building token lookup accounts", {
userTokenAccount: userTokenAccount.toString(),
tokenBillingConfig: tokenBillingConfig.toString(),
poolChainConfig: poolChainConfig.toString(),
lookupTableAddress: lookupTable.toString(),
entriesCount: lookupTableEntries.length,
});
// Build the token accounts with the correct writable flags
const accounts = [
{ pubkey: userTokenAccount, isSigner: false, isWritable: true },
{ pubkey: tokenBillingConfig, isSigner: false, isWritable: false },
{ pubkey: poolChainConfig, isSigner: false, isWritable: true },
// First account is the lookup table - must be non-writable
{ pubkey: lookupTable, isSigner: false, isWritable: false },
];
// Add the remaining lookup table entries with correct writable flags
const remainingAccounts = lookupTableEntries.slice(1).map((pubkey, index) => {
const isWrit = isWritable(index + 1, writableIndexes, logger);
return {
pubkey,
isSigner: false,
isWritable: isWrit,
};
});
return [...accounts, ...remainingAccounts];
}
/**
* Checks if an account should be writable based on writable indexes bitmap
*/
function isWritable(
index: number,
writableIndexes: BN[],
logger?: Logger,
): boolean {
// For the lookup table access, index 0 is determined by the program requirements
// The lookup table itself must be NON-writable
if (index === 0) {
return false;
}
// For other accounts, check the writable indexes bitmap
// Each BN in writableIndexes represents a 256-bit mask
const bnIndex = Math.floor(index / 128);
// In the Rust code, bits are set from left to right
const bitPosition = bnIndex === 0 ? 127 - (index % 128) : 255 - (index % 128);
if (bnIndex < writableIndexes.length) {
// Create a BN with the bit at the position we want to check
const mask = new BN(1).shln(bitPosition);
// Check if the bit is set using bitwise AND
const result = writableIndexes[bnIndex].and(mask);
// If the result is not zero, the bit is set
return !result.isZero();
}
// Default to non-writable if index is out of bounds
return false;
}