Convert snUSD into nUSD
Quote the one-day target-APR burn and redeem snUSD for nUSD immediately.
unstake burns snUSD and transfers the net nUSD to the owner in one
transaction. There is no request account, cooldown, claim window, or separate
claim instruction.
Quote the immediate exit
Use integer arithmetic that matches the program:
const BPS_DENOMINATOR = 10_000n;
const SECONDS_PER_DAY = 86_400n;
const SECONDS_PER_YEAR = 31_536_000n;
function mulDivUp(value: bigint, multiplier: bigint, denominator: bigint) {
if (denominator <= 0n) throw new Error("Invalid denominator");
if (value === 0n || multiplier === 0n) return 0n;
return (value * multiplier + denominator - 1n) / denominator;
}
export function quoteInstantUnstake(input: {
sharesSnusd6: bigint;
totalShares: bigint;
stakingVaultNusd6: bigint;
reservedPendingClaimsNusd6: bigint;
targetAprBps: bigint;
}) {
if (
input.sharesSnusd6 <= 0n
|| input.totalShares <= 0n
|| input.sharesSnusd6 > input.totalShares
|| input.targetAprBps < 0n
) throw new Error("Invalid unstake quote input");
if (input.reservedPendingClaimsNusd6 > input.stakingVaultNusd6) {
throw new Error("Recorded pending claims exceed staking assets");
}
const redeemableNusd6 =
input.stakingVaultNusd6 - input.reservedPendingClaimsNusd6;
const grossNusd6 =
input.sharesSnusd6 * redeemableNusd6 / input.totalShares;
const feeNusd6 = mulDivUp(
grossNusd6,
input.targetAprBps * SECONDS_PER_DAY,
BPS_DENOMINATOR * SECONDS_PER_YEAR,
);
if (grossNusd6 <= feeNusd6) {
throw new Error("Unstake amount is too small after the exit fee");
}
return {
grossNusd6,
feeNusd6,
netNusd6: grossNusd6 - feeNusd6,
};
}
export function minimumNusdOut(netQuote: bigint, slippageBps = 50n) {
if (netQuote <= 0n || slippageBps < 0n || slippageBps >= 10_000n) {
throw new Error("Invalid quote or slippage");
}
const minimum = netQuote * (10_000n - slippageBps) / 10_000n;
return minimum > 0n ? minimum : 1n;
}The fee is one day of the current stakerTargetAprBps, rounded up to the next
raw nUSD unit. At a 6% target APR:
fee = round up(gross nUSD × 600 / 3,650,000)
≈ 0.01644% of gross nUSDFor example, a gross exit value of 1,000 nUSD has a fee of approximately
0.164384 nUSD. The program burns this nUSD from the staking vault; it is not
sent to a fee recipient.
TypeScript example
import { BN } from "@coral-xyz/anchor";
import { PublicKey, Transaction } from "@solana/web3.js";
import { getAccount } from "@solana/spl-token";
import {
addAtaIfMissing,
ata,
simulateSignSend,
toBaseUnits,
type NestClient,
} from "../nest-client";
import { minimumNusdOut, quoteInstantUnstake } from "./unstake-quote";
export async function unstakeSnusd(
client: NestClient,
userShares: string,
slippageBps = 50n,
) {
const owner = client.wallet.publicKey;
const shares = toBaseUnits(userShares, 6);
if (shares <= 0n) throw new Error("Unstake shares must be positive");
const stakingState = new PublicKey(client.deployment.accounts.stakingState);
const protocol = new PublicKey(client.deployment.accounts.protocol);
const nestCoreProgram = new PublicKey(client.deployment.programs.nestCore);
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);
const stakingNusdVault =
new PublicKey(client.deployment.vaults.stakingNusdVault);
const ownerSnusdAccount = ata(snusdMint, owner, snusdTokenProgram);
const [state, protocolAccount, ownerSnusd] = await Promise.all([
client.stake.account.stakingState.fetch(stakingState),
client.core.account.protocol.fetch(protocol),
getAccount(
client.connection,
ownerSnusdAccount,
"confirmed",
snusdTokenProgram,
),
]);
if (state.paused) throw new Error("Staking is paused");
if (BigInt(protocolAccount.badDebtNusd.toString()) !== 0n) {
throw new Error("Unstaking is unavailable while bad debt is outstanding");
}
if (ownerSnusd.amount < shares) throw new Error("Insufficient snUSD balance");
const quote = quoteInstantUnstake({
sharesSnusd6: shares,
totalShares: BigInt(state.totalShares.toString()),
stakingVaultNusd6: BigInt(state.stakingVaultNusd.toString()),
reservedPendingClaimsNusd6: BigInt(state.reservedPendingClaims.toString()),
targetAprBps: BigInt(protocolAccount.stakerTargetAprBps.toString()),
});
const minNusdOut = minimumNusdOut(quote.netNusd6, slippageBps);
const transaction = new Transaction();
const ownerNusdAccount = await addAtaIfMissing(
client,
transaction,
nusdMint,
owner,
nusdTokenProgram,
);
const ownerNusdInfo = await client.connection.getAccountInfo(
ownerNusdAccount,
"confirmed",
);
const nusdBefore = ownerNusdInfo
? (await getAccount(
client.connection,
ownerNusdAccount,
"confirmed",
nusdTokenProgram,
)).amount
: 0n;
transaction.add(await client.stake.methods
.unstake(
new BN(shares.toString()),
new BN(minNusdOut.toString()),
)
.accountsStrict({
stakingState,
protocol,
nestCoreProgram,
nusdMint,
snusdMint,
ownerSnusdAccount,
stakingNusdVault,
ownerNusdAccount,
owner,
nusdTokenProgram,
snusdTokenProgram,
})
.instruction());
const signature = await simulateSignSend(client, transaction);
const [ownerSnusdAfter, ownerNusdAfter] = await Promise.all([
getAccount(
client.connection,
ownerSnusdAccount,
"confirmed",
snusdTokenProgram,
),
getAccount(
client.connection,
ownerNusdAccount,
"confirmed",
nusdTokenProgram,
),
]);
const burnedShares = ownerSnusd.amount - ownerSnusdAfter.amount;
const receivedNusd6 = ownerNusdAfter.amount - nusdBefore;
if (burnedShares !== shares || receivedNusd6 < minNusdOut) {
throw new Error(`Unstake verification failed for ${signature}`);
}
return {
signature,
burnedSnusd6: burnedShares,
receivedNusd6,
quotedGrossNusd6: quote.grossNusd6,
quotedFeeNusd6: quote.feeNusd6,
minimumNusdOut6: minNusdOut,
};
}The instruction checkpoints target revenue and reads the target APR from Nest
Core before calculating the settlement. Another transaction can change pool
state between the initial read and execution, so simulate the complete
transaction and use minNusdOut to enforce the user's bound.
The exit fee always applies
Wallet age and stake age are not inputs to the fee calculation. Every
unstake burns one day of the current target APR, including an unstake made
immediately after staking.