NestDevelopers
Stake nUSD

Convert snUSD into nUSD

Calculate snUSD value, request an unstake, claim nUSD, and recover an expired request.

The exit is asynchronous. request_unstake burns snUSD and creates a pending PDA. complete_unstake later calculates and transfers the final nUSD value.

Quote current nUSD value

export function quoteSnusdInNusd(input: {
  shares: bigint;
  totalShares: bigint;
  stakingVaultNusd6: bigint;
  reservedPendingClaimsNusd6: bigint;
}) {
  if (input.shares === 0n || input.totalShares === 0n) return 0n;
  const redeemable = input.stakingVaultNusd6 > input.reservedPendingClaimsNusd6
    ? input.stakingVaultNusd6 - input.reservedPendingClaimsNusd6
    : 0n;
  return input.shares * redeemable / input.totalShares;
}

This is a live quote, not an amount fixed at request time.

Request, claim, and recovery module

snusd-to-nusd.ts
import { BN } from "@coral-xyz/anchor";
import { PublicKey, SystemProgram, Transaction } from "@solana/web3.js";
import { getAccount } from "@solana/spl-token";
import {
  addAtaIfMissing,
  ata,
  confirmedChainTime,
  pendingWithdrawalPda,
  simulateSignSend,
  toBaseUnits,
  type NestClient,
} from "../nest-client";

function keys(client: NestClient) {
  const owner = client.wallet.publicKey;
  const stakeProgram = new PublicKey(client.deployment.programs.nestStake);
  const stakingState = new PublicKey(client.deployment.accounts.stakingState);
  const nusdMint = new PublicKey(client.deployment.mints.nusd);
  const snusdMint = new PublicKey(client.deployment.mints.snusd);
  const nusdTokenProgram = new PublicKey(client.deployment.tokenPrograms.nusd);
  const snusdTokenProgram = new PublicKey(client.deployment.tokenPrograms.snusd);
  return {
    owner,
    stakeProgram,
    stakingState,
    nusdMint,
    snusdMint,
    nusdTokenProgram,
    snusdTokenProgram,
    ownerSnusdAccount: ata(snusdMint, owner, snusdTokenProgram),
    pendingWithdrawal: pendingWithdrawalPda(stakeProgram, owner, stakingState),
  };
}

export async function requestUnstake(client: NestClient, snusdAmount: string) {
  const account = keys(client);
  const shares = toBaseUnits(snusdAmount, 6);
  if (shares <= 0n) throw new Error("Unstake shares must be positive");
  if (await client.connection.getAccountInfo(account.pendingWithdrawal, "confirmed")) {
    throw new Error("Finish or recover the existing pending unstake first");
  }
  const ownerSnusd = await getAccount(
    client.connection,
    account.ownerSnusdAccount,
    "confirmed",
    account.snusdTokenProgram,
  );
  if (ownerSnusd.amount < shares) throw new Error("Insufficient snUSD balance");

  const transaction = new Transaction().add(await client.stake.methods
    .requestUnstake(new BN(shares.toString()))
    .accountsStrict({
      stakingState: account.stakingState,
      snusdMint: account.snusdMint,
      ownerSnusdAccount: account.ownerSnusdAccount,
      pendingWithdrawal: account.pendingWithdrawal,
      owner: account.owner,
      snusdTokenProgram: account.snusdTokenProgram,
      systemProgram: SystemProgram.programId,
    })
    .instruction());
  const signature = await simulateSignSend(client, transaction);

  const [pending, snusdAfter, state] = await Promise.all([
    client.stake.account.pendingWithdrawalAccount.fetch(account.pendingWithdrawal),
    getAccount(
      client.connection,
      account.ownerSnusdAccount,
      "confirmed",
      account.snusdTokenProgram,
    ),
    client.stake.account.stakingState.fetch(account.stakingState),
  ]);
  if (
    BigInt(pending.shares.toString()) !== shares
    || ownerSnusd.amount - snusdAfter.amount !== shares
  ) throw new Error(`Unstake request verification failed for ${signature}`);

  const requestTs = BigInt(pending.requestTs.toString());
  return {
    signature,
    pendingWithdrawal: account.pendingWithdrawal,
    unlockTs: requestTs + BigInt(state.cooldownSeconds.toString()),
    claimDeadlineTs: BigInt(pending.claimDeadlineTs.toString()),
  };
}

export async function claimUnstakedNusd(client: NestClient) {
  const account = keys(client);
  const [pending, state, protocol] = await Promise.all([
    client.stake.account.pendingWithdrawalAccount.fetch(account.pendingWithdrawal),
    client.stake.account.stakingState.fetch(account.stakingState),
    client.core.account.protocol.fetch(new PublicKey(client.deployment.accounts.protocol)),
  ]);
  const now = await confirmedChainTime(client.connection);
  const unlock = BigInt(pending.requestTs.toString()) + BigInt(state.cooldownSeconds.toString());
  const deadline = BigInt(pending.claimDeadlineTs.toString());
  if (now < unlock) throw new Error(`Cooldown active until ${unlock}`);
  if (now > deadline) throw new Error("Claim window expired; restore snUSD instead");
  if (BigInt(protocol.badDebtNusd.toString()) !== 0n) {
    throw new Error("Claims are unavailable while protocol bad debt is outstanding");
  }

  const transaction = new Transaction();
  const ownerNusdAccount = await addAtaIfMissing(
    client,
    transaction,
    account.nusdMint,
    account.owner,
    account.nusdTokenProgram,
  );
  const nUsdBefore = await client.connection.getAccountInfo(ownerNusdAccount, "confirmed")
    ? (await getAccount(
        client.connection,
        ownerNusdAccount,
        "confirmed",
        account.nusdTokenProgram,
      )).amount
    : 0n;
  transaction.add(await client.stake.methods
    .completeUnstake()
    .accountsStrict({
      stakingState: account.stakingState,
      pendingWithdrawal: account.pendingWithdrawal,
      nusdMint: account.nusdMint,
      stakingNusdVault: new PublicKey(client.deployment.vaults.stakingNusdVault),
      ownerNusdAccount,
      protocol: new PublicKey(client.deployment.accounts.protocol),
      nestCoreProgram: new PublicKey(client.deployment.programs.nestCore),
      owner: account.owner,
      nusdTokenProgram: account.nusdTokenProgram,
    })
    .instruction());

  const signature = await simulateSignSend(client, transaction);
  const [nUsdAfter, pendingAfter] = await Promise.all([
    getAccount(client.connection, ownerNusdAccount, "confirmed", account.nusdTokenProgram),
    client.stake.account.pendingWithdrawalAccount.fetchNullable(account.pendingWithdrawal),
  ]);
  if (pendingAfter !== null || nUsdAfter.amount <= nUsdBefore) {
    throw new Error(`Unstake claim verification failed for ${signature}`);
  }
  return { signature, receivedNusd6: nUsdAfter.amount - nUsdBefore };
}

export async function restoreExpiredSnusd(client: NestClient) {
  const account = keys(client);
  const pending = await client.stake.account.pendingWithdrawalAccount.fetch(
    account.pendingWithdrawal,
  );
  const deadline = BigInt(pending.claimDeadlineTs.toString());
  if (await confirmedChainTime(client.connection) <= deadline) {
    throw new Error("Claim window is still active");
  }

  const transaction = new Transaction();
  const ownerSnusdAccount = await addAtaIfMissing(
    client,
    transaction,
    account.snusdMint,
    account.owner,
    account.snusdTokenProgram,
  );
  transaction.add(await client.stake.methods
    .cancelExpiredUnstake()
    .accountsStrict({
      stakingState: account.stakingState,
      pendingWithdrawal: account.pendingWithdrawal,
      snusdMint: account.snusdMint,
      ownerSnusdAccount,
      owner: account.owner,
      snusdTokenProgram: account.snusdTokenProgram,
    })
    .instruction());
  const signature = await simulateSignSend(client, transaction);
  const pendingAfter = await client.stake.account.pendingWithdrawalAccount.fetchNullable(
    account.pendingWithdrawal,
  );
  if (pendingAfter !== null) throw new Error(`Recovery verification failed for ${signature}`);
  return signature;
}

One pending request per wallet

The PDA seeds are fixed by owner and staking state. A wallet cannot create a second request until the first is claimed or restored after expiry.

On this page