import {
Address,
address,
AccountRole,
getAddressDecoder,
getAddressEncoder,
getProgramDerivedAddress,
Instruction,
IAccountMeta,
Rpc,
SolanaRpcApi,
} from '@solana/kit';
import { TOKEN_PROGRAM_ADDRESS } from '@solana-program/token';
// ── Program IDs ──────────────────────────────────────────────────────
export const VAULT_MINT_PROGRAM_ID: Address = address('9WUyNREiPDMgwMh5Gt81Fd3JpiCKxpjZ5Dpq9Bo1RhMV');
export const VAULT_STAKE_PROGRAM_ID: Address = address('97V7JsExNC6yFWu5KjK1FLfVkNVvtMpAFL5QkLWKEGxY');
// ── Virtual share constants (must match on-chain) ────────────────────
const VIRTUAL_SHARES = 1_000_000n;
const VIRTUAL_ASSETS = 1_000_000n;
// ── Anchor discriminator for "deposit" instruction ───────────────────
// sha256("global:deposit")[0..8] - same for both programs since the method name is "deposit"
const DEPOSIT_DISCRIMINATOR = new Uint8Array([242, 35, 198, 137, 82, 225, 242, 182]);
// ── Types ────────────────────────────────────────────────────────────
export interface VaultMintConfig {
vault: Address; // USDC mint
mint: Address; // wYLDS mint
vaultAuthority: Address;
redeemVault: Address;
bump: number;
paused: boolean;
allowedExternalMintProgram: Address;
}
export interface StakeConfig {
vault: Address; // wYLDS mint
mint: Address; // PRIME mint
unbondingPeriod: bigint;
bump: number;
paused: boolean;
}
export interface StakeVaultTokenAccountConfig {
vaultTokenAccount: Address;
vaultAuthority: Address;
bump: number;
}
export interface VaultTokenAccountConfig {
vaultTokenAccount: Address;
bump: number;
}
export interface HastraQuote {
inputMint: Address;
outputMint: Address;
inputAmount: bigint;
outputAmount: bigint;
/** For USDC->wYLDS this is always 1:1. For wYLDS->PRIME it uses virtual share math. */
exchangeRate: number;
}
// ── PDA derivation helpers ───────────────────────────────────────────
async function findPda(seeds: (Uint8Array | string)[], programId: Address): Promise
{
const seedBytes = seeds.map((s) => (typeof s === 'string' ? new TextEncoder().encode(s) : s));
const [pda] = await getProgramDerivedAddress({ seeds: seedBytes, programAddress: programId });
return pda;
}
async function findVaultMintConfigPda(): Promise {
return findPda(['config'], VAULT_MINT_PROGRAM_ID);
}
async function findVaultMintMintAuthorityPda(): Promise {
return findPda(['mint_authority'], VAULT_MINT_PROGRAM_ID);
}
async function findVaultTokenAccountConfigPda(configPda: Address): Promise {
return findPda(
['vault_token_account_config', new Uint8Array(getAddressEncoder().encode(configPda))],
VAULT_MINT_PROGRAM_ID,
);
}
async function findStakeConfigPda(): Promise {
return findPda(['stake_config'], VAULT_STAKE_PROGRAM_ID);
}
async function findStakeMintAuthorityPda(): Promise {
return findPda(['mint_authority'], VAULT_STAKE_PROGRAM_ID);
}
async function findStakeVaultAuthorityPda(): Promise {
return findPda(['vault_authority'], VAULT_STAKE_PROGRAM_ID);
}
async function findStakeVaultTokenAccountConfigPda(stakeConfigPda: Address): Promise {
return findPda(
['stake_vault_token_account_config', new Uint8Array(getAddressEncoder().encode(stakeConfigPda))],
VAULT_STAKE_PROGRAM_ID,
);
}
// ── Account deserialization ──────────────────────────────────────────
function readPubkey(data: Uint8Array, offset: number): Address {
return getAddressDecoder().decode(data.slice(offset, offset + 32));
}
function readU8(data: Uint8Array, offset: number): number {
return data[offset];
}
function readBool(data: Uint8Array, offset: number): boolean {
return data[offset] !== 0;
}
function readI64(data: Uint8Array, offset: number): bigint {
const view = new DataView(data.buffer, data.byteOffset + offset, 8);
return view.getBigInt64(0, true);
}
function readU64(data: Uint8Array, offset: number): bigint {
const view = new DataView(data.buffer, data.byteOffset + offset, 8);
return view.getBigUint64(0, true);
}
function readVecPubkeys(data: Uint8Array, offset: number): { keys: Address[]; bytesRead: number } {
const view = new DataView(data.buffer, data.byteOffset + offset, 4);
const len = view.getUint32(0, true);
if (offset + 4 + len * 32 > data.length) {
throw new Error(
`readVecPubkeys: expected ${len} pubkeys (${4 + len * 32} bytes) at offset ${offset}, but data is only ${data.length} bytes`,
);
}
const keys: Address[] = [];
let cursor = offset + 4;
for (let i = 0; i < len; i++) {
keys.push(readPubkey(data, cursor));
cursor += 32;
}
return { keys, bytesRead: cursor - offset };
}
function deserializeVaultMintConfig(data: Uint8Array): VaultMintConfig {
let offset = 8; // skip discriminator
const vault = readPubkey(data, offset);
offset += 32;
const mint = readPubkey(data, offset);
offset += 32;
const { bytesRead: freezeBytes } = readVecPubkeys(data, offset);
offset += freezeBytes;
const { bytesRead: rewardsBytes } = readVecPubkeys(data, offset);
offset += rewardsBytes;
const vaultAuthority = readPubkey(data, offset);
offset += 32;
const redeemVault = readPubkey(data, offset);
offset += 32;
const bump = readU8(data, offset);
offset += 1;
const paused = readBool(data, offset);
offset += 1;
const allowedExternalMintProgram = readPubkey(data, offset);
return { vault, mint, vaultAuthority, redeemVault, bump, paused, allowedExternalMintProgram };
}
function deserializeStakeConfig(data: Uint8Array): StakeConfig {
let offset = 8;
const vault = readPubkey(data, offset);
offset += 32;
const mint = readPubkey(data, offset);
offset += 32;
const unbondingPeriod = readI64(data, offset);
offset += 8;
const { bytesRead: freezeBytes } = readVecPubkeys(data, offset);
offset += freezeBytes;
const { bytesRead: rewardsBytes } = readVecPubkeys(data, offset);
offset += rewardsBytes;
const bump = readU8(data, offset);
offset += 1;
const paused = readBool(data, offset);
return { vault, mint, unbondingPeriod, bump, paused };
}
function deserializeVaultTokenAccountConfig(data: Uint8Array): VaultTokenAccountConfig {
let offset = 8;
const vaultTokenAccount = readPubkey(data, offset);
offset += 32;
const bump = readU8(data, offset);
return { vaultTokenAccount, bump };
}
function deserializeStakeVaultTokenAccountConfig(data: Uint8Array): StakeVaultTokenAccountConfig {
let offset = 8;
const vaultTokenAccount = readPubkey(data, offset);
offset += 32;
const vaultAuthority = readPubkey(data, offset);
offset += 32;
const bump = readU8(data, offset);
return { vaultTokenAccount, vaultAuthority, bump };
}
// ── Token account helpers ────────────────────────────────────────────
interface TokenAccountInfo {
mint: Address;
owner: Address;
amount: bigint;
}
function deserializeTokenAccount(data: Uint8Array): TokenAccountInfo {
return {
mint: readPubkey(data, 0),
owner: readPubkey(data, 32),
amount: readU64(data, 64),
};
}
interface MintInfo {
supply: bigint;
decimals: number;
}
function deserializeMint(data: Uint8Array): MintInfo {
// SPL Mint layout: 4 bytes mintAuthorityOption, 32 bytes mintAuthority, 8 bytes supply, 1 byte decimals
const supply = readU64(data, 36);
const decimals = readU8(data, 44);
return { supply, decimals };
}
// ── Instruction encoding ─────────────────────────────────────────────
function encodeU64LE(value: bigint): Uint8Array {
const buf = new Uint8Array(8);
const view = new DataView(buf.buffer);
view.setBigUint64(0, value, true);
return buf;
}
function buildInstruction(
programId: Address,
keys: IAccountMeta[],
discriminator: Uint8Array,
amount: bigint,
): Instruction {
const amountBytes = encodeU64LE(amount);
const data = new Uint8Array(discriminator.length + amountBytes.length);
data.set(discriminator, 0);
data.set(amountBytes, discriminator.length);
return {
programAddress: programId,
accounts: keys,
data,
};
}
// ── Main client ──────────────────────────────────────────────────────
/** Config cache TTL in seconds — refetch on-chain configs after this period */
const CONFIG_TTL_SECONDS = 5 * 60; // 5 minutes
export class HastraVaultClient {
private rpc: Rpc;
private vaultMintConfig: VaultMintConfig | null = null;
private vaultMintConfigFetchedAt = 0;
private stakeConfig: StakeConfig | null = null;
private stakeConfigFetchedAt = 0;
private vaultTokenAccountConfig: VaultTokenAccountConfig | null = null;
private stakeVaultTokenAccountConfig: StakeVaultTokenAccountConfig | null = null;
private vaultMintConfigPda: Address | null = null;
private vaultMintMintAuthorityPda: Address | null = null;
private vaultTokenAccountConfigPda: Address | null = null;
private stakeConfigPda: Address | null = null;
private stakeMintAuthorityPda: Address | null = null;
private stakeVaultAuthorityPda: Address | null = null;
private stakeVaultTokenAccountConfigPda: Address | null = null;
constructor(rpc: Rpc) {
this.rpc = rpc;
}
// ── Lazy PDA loaders ───────────────────────────────────────────────
private async getVaultMintConfigPda(): Promise {
if (!this.vaultMintConfigPda) {
this.vaultMintConfigPda = await findVaultMintConfigPda();
}
return this.vaultMintConfigPda;
}
private async getVaultMintMintAuthorityPda(): Promise {
if (!this.vaultMintMintAuthorityPda) {
this.vaultMintMintAuthorityPda = await findVaultMintMintAuthorityPda();
}
return this.vaultMintMintAuthorityPda;
}
private async getVaultTokenAccountConfigPda(): Promise {
if (!this.vaultTokenAccountConfigPda) {
const configPda = await this.getVaultMintConfigPda();
this.vaultTokenAccountConfigPda = await findVaultTokenAccountConfigPda(configPda);
}
return this.vaultTokenAccountConfigPda;
}
private async getStakeConfigPda(): Promise {
if (!this.stakeConfigPda) {
this.stakeConfigPda = await findStakeConfigPda();
}
return this.stakeConfigPda;
}
private async getStakeMintAuthorityPda(): Promise {
if (!this.stakeMintAuthorityPda) {
this.stakeMintAuthorityPda = await findStakeMintAuthorityPda();
}
return this.stakeMintAuthorityPda;
}
private async getStakeVaultAuthorityPda(): Promise {
if (!this.stakeVaultAuthorityPda) {
this.stakeVaultAuthorityPda = await findStakeVaultAuthorityPda();
}
return this.stakeVaultAuthorityPda;
}
private async getStakeVaultTokenAccountConfigPda(): Promise {
if (!this.stakeVaultTokenAccountConfigPda) {
const stakeConfigPda = await this.getStakeConfigPda();
this.stakeVaultTokenAccountConfigPda = await findStakeVaultTokenAccountConfigPda(stakeConfigPda);
}
return this.stakeVaultTokenAccountConfigPda;
}
// ── Account fetching ───────────────────────────────────────────────
private async fetchAccountData(addr: Address): Promise {
const resp = await this.rpc.getAccountInfo(addr, { encoding: 'base64' }).send();
if (!resp.value) {
throw new Error(`Account not found: ${addr}`);
}
const b64 = resp.value.data[0];
return new Uint8Array(Buffer.from(b64, 'base64'));
}
async getVaultMintConfig(): Promise {
const now = Date.now() / 1000;
if (!this.vaultMintConfig || now - this.vaultMintConfigFetchedAt > CONFIG_TTL_SECONDS) {
const configPda = await this.getVaultMintConfigPda();
const data = await this.fetchAccountData(configPda);
this.vaultMintConfig = deserializeVaultMintConfig(data);
this.vaultMintConfigFetchedAt = now;
}
return this.vaultMintConfig;
}
async getStakeConfig(): Promise {
const now = Date.now() / 1000;
if (!this.stakeConfig || now - this.stakeConfigFetchedAt > CONFIG_TTL_SECONDS) {
const stakeConfigPda = await this.getStakeConfigPda();
const data = await this.fetchAccountData(stakeConfigPda);
this.stakeConfig = deserializeStakeConfig(data);
this.stakeConfigFetchedAt = now;
}
return this.stakeConfig;
}
async getVaultTokenAccountConfig(): Promise {
if (!this.vaultTokenAccountConfig) {
const pda = await this.getVaultTokenAccountConfigPda();
const data = await this.fetchAccountData(pda);
this.vaultTokenAccountConfig = deserializeVaultTokenAccountConfig(data);
}
return this.vaultTokenAccountConfig;
}
async getStakeVaultTokenAccountConfig(): Promise {
if (!this.stakeVaultTokenAccountConfig) {
const pda = await this.getStakeVaultTokenAccountConfigPda();
const data = await this.fetchAccountData(pda);
this.stakeVaultTokenAccountConfig = deserializeStakeVaultTokenAccountConfig(data);
}
return this.stakeVaultTokenAccountConfig;
}
private async getTokenAccountInfo(addr: Address): Promise {
const data = await this.fetchAccountData(addr);
return deserializeTokenAccount(data);
}
private async getMintInfo(addr: Address): Promise {
const data = await this.fetchAccountData(addr);
return deserializeMint(data);
}
// ── Quote: USDC -> wYLDS ──────────────────────────────────────────
/**
* Quote for minting wYLDS from USDC via vault-mint.
* The exchange rate is always 1:1 (deposit amount = minted amount).
* Returns outputAmount 0 if the vault is paused.
*/
async quoteUsdcToWylds(usdcAmount: bigint): Promise {
const config = await this.getVaultMintConfig();
if (config.paused || usdcAmount === 0n) {
return {
inputMint: config.vault,
outputMint: config.mint,
inputAmount: usdcAmount,
outputAmount: 0n,
exchangeRate: 0,
};
}
return {
inputMint: config.vault,
outputMint: config.mint,
inputAmount: usdcAmount,
outputAmount: usdcAmount, // 1:1
exchangeRate: 1.0,
};
}
// ── Quote: wYLDS -> PRIME ─────────────────────────────────────────
/**
* Quote for staking wYLDS to receive PRIME via vault-stake.
* Uses virtual share accounting to calculate the output.
* Returns outputAmount 0 if the vault is paused.
*/
async quoteWyldsToPrime(wyldsAmount: bigint): Promise {
const stakeConfig = await this.getStakeConfig();
if (stakeConfig.paused) {
console.debug('HastraVaultClient: vault-stake is paused, wYLDS -> PRIME staking is disabled');
return {
inputMint: stakeConfig.vault,
outputMint: stakeConfig.mint,
inputAmount: wyldsAmount,
outputAmount: 0n,
exchangeRate: 0,
};
}
const stakeVaultTaConfig = await this.getStakeVaultTokenAccountConfig();
const [vaultAccountInfo, primeMintInfo] = await Promise.all([
this.getTokenAccountInfo(stakeVaultTaConfig.vaultTokenAccount),
this.getMintInfo(stakeConfig.mint),
]);
const vaultBalance = vaultAccountInfo.amount;
const totalShares = primeMintInfo.supply;
const sharesOut = calculateAssetsToShares(wyldsAmount, totalShares, vaultBalance);
const exchangeRate =
totalShares === 0n ? 1.0 : Number(vaultBalance + VIRTUAL_ASSETS) / Number(totalShares + VIRTUAL_SHARES);
return {
inputMint: stakeConfig.vault,
outputMint: stakeConfig.mint,
inputAmount: wyldsAmount,
outputAmount: sharesOut,
exchangeRate,
};
}
// ── Instructions: USDC -> wYLDS ────────────────────────────────────
/**
* Build instructions to deposit USDC and mint wYLDS (1:1).
*
* @param user - The user's wallet address (signer)
* @param userUsdcTokenAccount - The user's USDC token account
* @param userWyldsTokenAccount - The user's wYLDS token account
* @param amount - Amount of USDC to deposit (in smallest units, e.g. 1_000_000 = 1 USDC)
*/
async getDepositUsdcIxs(
user: Address,
userUsdcTokenAccount: Address,
userWyldsTokenAccount: Address,
amount: bigint,
): Promise {
const [configPda, vtaConfigPda, vtaConfig, config, mintAuthorityPda] = await Promise.all([
this.getVaultMintConfigPda(),
this.getVaultTokenAccountConfigPda(),
this.getVaultTokenAccountConfig(),
this.getVaultMintConfig(),
this.getVaultMintMintAuthorityPda(),
]);
const keys: IAccountMeta[] = [
{ address: configPda, role: AccountRole.READONLY },
{ address: vtaConfigPda, role: AccountRole.READONLY },
{ address: vtaConfig.vaultTokenAccount, role: AccountRole.WRITABLE },
{ address: config.mint, role: AccountRole.WRITABLE },
{ address: mintAuthorityPda, role: AccountRole.READONLY },
{ address: user, role: AccountRole.READONLY_SIGNER },
{ address: userUsdcTokenAccount, role: AccountRole.WRITABLE },
{ address: userWyldsTokenAccount, role: AccountRole.WRITABLE },
{ address: TOKEN_PROGRAM_ADDRESS, role: AccountRole.READONLY },
];
return [buildInstruction(VAULT_MINT_PROGRAM_ID, keys, DEPOSIT_DISCRIMINATOR, amount)];
}
// ── Instructions: wYLDS -> PRIME ───────────────────────────────────
/**
* Build instructions to stake wYLDS and receive PRIME.
*
* @param user - The user's wallet address (signer)
* @param userWyldsTokenAccount - The user's wYLDS token account
* @param userPrimeTokenAccount - The user's PRIME token account
* @param amount - Amount of wYLDS to stake (in smallest units)
*/
async getStakeWyldsIxs(
user: Address,
userWyldsTokenAccount: Address,
userPrimeTokenAccount: Address,
amount: bigint,
): Promise {
const [stakeConfigPda, stakeConfig, svtaConfigPda, svtaConfig, mintAuthorityPda, vaultAuthorityPda] =
await Promise.all([
this.getStakeConfigPda(),
this.getStakeConfig(),
this.getStakeVaultTokenAccountConfigPda(),
this.getStakeVaultTokenAccountConfig(),
this.getStakeMintAuthorityPda(),
this.getStakeVaultAuthorityPda(),
]);
const keys: IAccountMeta[] = [
{ address: stakeConfigPda, role: AccountRole.READONLY },
{ address: svtaConfigPda, role: AccountRole.READONLY },
{ address: svtaConfig.vaultTokenAccount, role: AccountRole.WRITABLE },
{ address: vaultAuthorityPda, role: AccountRole.READONLY },
{ address: stakeConfig.mint, role: AccountRole.WRITABLE },
{ address: stakeConfig.vault, role: AccountRole.WRITABLE },
{ address: mintAuthorityPda, role: AccountRole.READONLY },
{ address: user, role: AccountRole.READONLY_SIGNER },
{ address: userWyldsTokenAccount, role: AccountRole.WRITABLE },
{ address: userPrimeTokenAccount, role: AccountRole.WRITABLE },
{ address: TOKEN_PROGRAM_ADDRESS, role: AccountRole.READONLY },
];
return [buildInstruction(VAULT_STAKE_PROGRAM_ID, keys, DEPOSIT_DISCRIMINATOR, amount)];
}
}
// ── Pure math (matches on-chain virtual share logic) ─────────────────
export function calculateAssetsToShares(assets: bigint, totalShares: bigint, vaultBalance: bigint): bigint {
if (totalShares === 0n) {
return (assets * VIRTUAL_SHARES) / VIRTUAL_ASSETS;
}
return (assets * (totalShares + VIRTUAL_SHARES)) / (vaultBalance + VIRTUAL_ASSETS);
}
export function calculateSharesToAssets(shares: bigint, totalShares: bigint, vaultBalance: bigint): bigint {
if (totalShares === 0n) {
return 0n;
}
return (shares * (vaultBalance + VIRTUAL_ASSETS)) / (totalShares + VIRTUAL_SHARES);
}
export function calculateExchangeRate(totalShares: bigint, vaultBalance: bigint): bigint {
const SCALE = 1_000_000_000n;
if (totalShares === 0n) {
return SCALE;
}
return ((vaultBalance + VIRTUAL_ASSETS) * SCALE) / (totalShares + VIRTUAL_SHARES);
}