@elmntl/jlpd-sdk
v2.0.0
Published
SDK for JLP.D (JLP Deconstructed) by Elemental — p-stv-core, jlpd-strategy, and elemental-lend clients
Readme
@elmntl/jlpd-sdk
TypeScript SDK for the Elemental Vaults on-chain stack. Wraps three Solana programs through one consistent surface so frontends, indexers, and back-end services don't need to hand-roll discriminators, PDA derivation, or account layouts.
LP surface vs admin/operator surface
An LP-facing frontend only ever needs the root export of pStvCore (and,
read-only, elementalLend/jlpdStrategy for dashboards). Genesis/operator/
keeper-only builders are namespaced OUT of the root under .admin so they
never show up in LP-facing autocomplete or bundles — this is a deliberate
surface fence, not an oversight (round-6 hardening; breaking re-org, see
CHANGELOG).
| Surface | What's there | Who calls it |
| --- | --- | --- |
| pStvCore.* (root) | createDepositIx, createRequestWithdrawIx, createClaimWithdrawIx, createInstantWithdrawIx, createProcessDelayedDepositIx (round-7 LOW-5 — permissionless, completes an LP's own delayed deposit); buildDepositContext, buildClaimWithdrawContext; checkDepositReadiness/checkClaimReadiness/checkRequestWithdrawReadiness; fetchVaultDashboard; PDA/account/event/error/price/LUT/send-tx helpers | LP frontends, wallets |
| pStvCore.admin.* | createInitOrUpdateStvIx, createSeedStvIx, createAcceptVaultAdminIx, createCloseStvIx, createAddManagerIx/createRemoveManagerIx, createDepositToStrategyIx/createWithdrawFromStrategyIx, createOverrideClaimWithdrawIx, createProcessEpochIx, createTripCircuitBreakerIx, freeze/unfreeze WR + evX builders, migrate admin builders (createMigrateLendIx/createMigrateRequestIx/createMigrateExecuteIx/createMigrateCancelIx), buildProcessEpochContext/buildDepositToStrategyContext | Vault admin (cold/warm), managers, keepers — genesis, epoch cranking, sweep cranking, freeze/override circuit breakers. Never an LP flow. |
| elementalLend.admin.* | Strategy init/close/reset, manager-role management, manager-signed kVault/Jupiter Lend sweep/unsweep builders, buildProtocolActionIxs (round-7 MED-3 — manager-signed protocol deposit/withdraw, derives a ManagerRole PDA) | Manager/keeper, invoked by p-stv-core's own admin flows — never an LP |
| jlpdStrategy.admin.* | Strategy-state init/close, config init, manager-role management | Admin/keeper genesis only |
elemental-lend-v2 is experimental and undeployed — not exported from the
SDK root at all (no elementalLendV2 namespace; the module stays on disk,
unpublished).
@elmntl/jlpd-sdk
├── /common shared types, buffer helpers, connection type, discriminators
├── /p-stv-core vault management — deposits, withdraws, epochs, fees (level N)
├── /elemental-lend idle-base lending sweep into Kamino + Jupiter Lend Earn (level N − 1)
├── /jlpd-strategy JLP-Deconstructed yield strategy + lend-adapter wiring (level N + 1 / N + 2)
└── /onyc-strategy onyc/USDC strategy (STV 16) — error-code messages todayAt a glance
| Module | Wraps program | What it gives you |
| ---------------------------- | ----------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| @elmntl/jlpd-sdk/p-stv-core | PSTVH77GiPqA3msXmpjAyUXdh3MytK37GCPbPzWu3Rc | STV / GlobalConfig / WithdrawRequest / ManagerRole accounts; all 14 instruction builders, 5 LP-facing at root (createDepositIx, createRequestWithdrawIx, createClaimWithdrawIx, createInstantWithdrawIx, createProcessDelayedDepositIx) + 9 admin/operator under .admin (createProcessEpochIx, createMigrateLendIx, …) — see "LP surface vs admin/operator surface" above; event decoders (18 event types); the sendSmartTx smart sender (auto CU sim, priority fee, LUT merge); wSOL wrap/unwrap helpers; USD price fetcher; fetchVaultDashboard (live NAV/pps/evX-position reader); resolveVaultError (on-chain VaultError → LP-facing message) |
| @elmntl/jlpd-sdk/elemental-lend | EHaGVh7p6xSxZsq4CcEEZhnV5adDvWg1gS1Rc5rLiBpC | StrategyState / StvPosition / ManagerRole accounts; CPI + protocol instruction builders; Kamino kVault and Jupiter Lend Earn account-resolution helpers; auto-route / auto-unroute account builders for sweep flows |
| @elmntl/jlpd-sdk/jlpd-strategy | GXqt4ZH2UUBsLWwMNJiZMXza3q7xEGChfW8XjVRjLxr5 | JlpdConfig / per-asset StrategyState / StvPosition; CPI + rebalance instruction builders (createSwapJlpIx, createSettleYieldIx, createDepositToAdapterIx, …); Jupiter swap quote / instruction fetchers; on-chain JLP price + custody readers |
| @elmntl/jlpd-sdk/onyc-strategy | 8PGHppQRB8GSN7Mh1Mptv7J1WZ1JqZCghG7AawrNoJZ | resolveOnycStrategyError — the onyc/USDC strategy's OWN OnycStrategyError space mapped to LP-facing messages (separate error space from p-stv-core's VaultError — see that function's doc). Narrower today than the other modules: no PDA/account/instruction helpers yet, error messages only |
| @elmntl/jlpd-sdk/common | (no program) | Buffer reads with bounds checks, ATA derivation, the canonical SolanaConnection structural type, the StrategyStateHeader / StvPosition interfaces shared across every strategy program, and the generic resolveProgramError/extractCustomProgramErrorCode custom-error parser every program's error map is built on |
Module layout
Every program-specific module follows the same file shape, so you only have to learn the pattern once:
<module>/
├── constants.ts program ID, seeds, instruction discriminators, sizes, flags
├── pda.ts findXxxPda() helpers — pure synchronous PDA derivation
├── types.ts account interfaces + event types
├── accounts.ts deserializeXxx() + fetchXxx() readers + GPA queries
├── instructions.ts createXxxIx() instruction builders
├── events.ts event decoders (p-stv-core: 1-byte disc; Anchor: 8-byte)
└── (module-specific files)
Module-specific files:
p-stv-core: remaining-accounts.ts, send-tx.ts, sol-wrap.ts, prices.ts,
readiness.ts, withdraw-requests.ts, errors.ts, dashboard.ts
elemental-lend: kamino-vault.ts, jupiter-lend.ts, protocol-actions.ts
jlpd-strategy: swap-jlp.ts, settle-yield.ts, jlp-data.ts,
jlp-borrow.ts, jupusd-earn.ts, adapter.tsonyc-strategy is an exception to the standard shape above: it wraps a
strategy program p-stv-core CPIs into, not a program this SDK issues its own
instructions against, so it currently only has errors.ts. Its own numeric
Custom(n) error space is entirely separate from p-stv-core's — see
resolveOnycStrategyError's doc for how the two relate.
Naming conventions hold across modules:
| Pattern | Returns | Side effects |
| --------------- | ------------------------- | ------------ |
| findXxxPda | [PublicKey, number] | none |
| deserializeXxx| typed account | none |
| fetchXxx | typed account | one RPC call |
| createXxxIx | TransactionInstruction | none |
| buildXxx* | helper objects | none |
build* is reserved for helpers that produce ephemeral data (remaining accounts, batched instruction lists). Anything that returns a wire-ready instruction is named createXxxIx.
Naming exceptions worth knowing
Each program has its own ManagerRole PDA scoped to a different anchor account. To prevent same-named imports from shadowing each other, the helpers carry a per-program prefix:
| Program | Function | PDA seeds |
| --------------- | ------------------------- | ---------------------------------------- |
| p-stv-core | findStvManagerRolePda | ["manager", stv, manager] |
| elemental-lend | findLendManagerRolePda | ["manager", strategy_state, manager] |
| jlpd-strategy | findJlpdManagerRolePda | ["manager", config, manager] |
Cross-cutting design
SolanaConnection (structural type)
Every helper that needs RPC accepts SolanaConnection, defined in common/connection.ts as a Pick<Connection, ...> of just the methods we use. This means callers can pass any version of @solana/web3.js without nominal type mismatches across SDK / consumer versions. Narrower call sites use a further Pick (e.g. ProtocolActionConnection in elemental-lend/protocol-actions.ts is Pick<SolanaConnection, "getAccountInfo" | "getMultipleAccountsInfo">).
Numeric convention (BN vs number)
Documented at the top of p-stv-core/types.ts:
- u64 fields are deserialized as
BN(bn.js) - u32 / u16 / u8 fields are deserialized as plain JavaScript
number
Mixing the two does not auto-convert and silently truncates — when adding new fields to any account interface, follow the rule.
Ix builder u64 args accept BN | number | bigint. createDepositIx /
createRequestWithdrawIx / createWithdrawFromStrategyIx /
createInstantWithdrawIx / createSeedStvIx / InitOrUpdateStvArgs.vaultId
/ findStvPda / findEvMintPda (and every other u64-typed builder arg in
p-stv-core/instructions.ts and pda.ts) accept a native bigint in
addition to BN/number — fetchVaultDashboard returns bigint
throughout (see that section below), so a displayed value flows straight
into a deposit/withdraw amount with no new BN(x.toString()) bridge. All
three input types serialize byte-identically for the same logical value
(common/toBN, the single normalization point every u64 write funnels
through); common/__tests__/buffer.test.ts and
p-stv-core/__tests__/bigint-args.test.ts hex-compare BN vs number vs
bigint encodings to lock this down.
Discriminators
- p-stv-core (Pinocchio) uses 1-byte instruction discriminators (0x00–0x0C, 13 instructions). Account discriminators are still 8 bytes for Anchor compatibility.
- elemental-lend and jlpd-strategy are Anchor 0.32.1 programs and use 8-byte instruction discriminators throughout.
- Account discriminators that are truly shared across programs (
ManagerRole,StrategyState,StvPosition) live once incommon/constants.tsand are re-exported by each module.
Each constant is documented inline with the source — for example IX_ADD_MANAGER in elemental-lend has a // sha256("global:add_manager")[..8] comment so an auditor can re-derive it.
Buffer reads
common/buffer.ts provides bounds-checked readers (readPubkey, readU64, readU32, readU16, readU8) and the corresponding writers / optional-encoders. Each read helper validates offset + width <= data.length and throws a descriptive RangeError rather than silently reading garbage when the offset is wrong.
Send-tx layer
p-stv-core/send-tx.ts exposes sendSmartTx(connection, instructions, payer, signTransaction, options?):
- Pre-checks the legacy serialized size to decide if a versioned tx is needed (no double-sign).
- Fetches blockhash + every default LUT (lend / stv / jlpd) + Helius priority-fee in parallel.
- Simulates with an inflated CU limit, takes the actual
unitsConsumed, applies a 5% buffer, and prepends the rightComputeBudgetPrograminstructions. - Falls back from legacy → versioned automatically when CU instructions push the size over
MAX_LEGACY_SIZE. - Confirms via
lastValidBlockHeight.
The signTransaction callback is generic over Transaction | VersionedTransaction so wallets pass the same callback the SDK uses on either path. The optional rpcUrl field in SmartTxOptions is the only way the SDK reaches a Helius endpoint — it never reads private fields off the connection object.
Address Lookup Tables
p-stv-core/send-tx.ts exports DEFAULT_LUT_ADDRESSES:
{
lend: PublicKey, // Elemental Lend infrastructure
stv: PublicKey, // p-STV Core vaults / evMints / vault ATAs
jlpd: PublicKey, // JLPD Strategy state / positions / oracles
}sendSmartTx always fetches all three. Consumers building transactions outside sendSmartTx should import these and merge them into their own LUT list. Server-side jobs (jlpd-server/src/jobs/ltv-rebalance.ts) use this same export — the SDK is the single source of truth.
Installation
npm install @elmntl/jlpd-sdk
# or
yarn add @elmntl/jlpd-sdk
# or
pnpm add @elmntl/jlpd-sdk
@elmntl/jlpd-sdkis the only current package.@elemental-stv-core/sdkand@elemental-vaults/sdkare earlier names for this same package —@elemental-stv-core/sdkis deprecated on the npm registry (frozen at0.13.4, missing the0.14.0breaking claim-redirect removal) and neither alias receives new releases. If you see either name in an older script or doc in this monorepo, treat it as historical and install@elmntl/jlpd-sdkinstead —npm install @elemental-stv-core/sdkfrom a clean environment gets you a stale, unmaintained copy with a real ABI difference (seeCHANGELOG.md's0.14.0entry).
Peer dependencies must be installed in the consumer:
npm install @solana/web3.js@^1.95.0 @solana/spl-token@^0.4.0Quick examples
Deposit base into a vault from a frontend
Every deposit into a strategy-linked STV must carry a strategy NAV tail.
Core CPIs the strategy's update_aum to price pre-deposit NAV, so
remainingAccounts needs [strategyState(w), stvPosition(ro), strategyProgram(ro)]
(plus a lend tail first, if the vault has lendProgram active) — and
protocolAumCount/autoRouteCount must be wired from the SAME buildDepositContext
call that built remainingAccounts. All three are optional params on
createDepositIx that silently default to empty/zero — dropping any one of
them compiles fine and builds an instruction that reverts on-chain
(IncompleteStrategyAccounts) with no TypeScript error to warn you. The
example below is copy-paste correct for vault 16 ("onyc", base USDC,
strategy-linked, no lend) — spread the FULL ctx object into createDepositIx,
never just one field off it.
import BN from "bn.js";
import { TOKEN_PROGRAM_ID } from "@solana/spl-token";
import {
findConfigPda,
findStvPda,
findEvMintPda,
fetchStv,
buildDepositContext,
createDepositIx,
checkDepositReadiness,
sendSmartTx,
} from "@elmntl/jlpd-sdk/p-stv-core";
import { findAta, createIdempotentAtaIx } from "@elmntl/jlpd-sdk/common";
// Inputs assumed from the caller's context:
// connection: Connection — RPC connection
// user: PublicKey — the connected wallet's public key
// signTransaction — the connected wallet's sign callback
const VAULT_ID = 16; // onyc / USDC
const [config] = findConfigPda();
const [stv] = findStvPda(VAULT_ID);
const [evMint] = findEvMintPda(VAULT_ID);
// 1. Fetch the STV. Needed both to build the VaultInfo below (baseMint,
// lendProgram, strategy) and to derive the ATAs that follow (baseMint,
// feeReceiver).
const stvState = await fetchStv(connection, VAULT_ID);
// 2. Preflight the strategy-freshness gate BEFORE building a transaction — a
// stale strategy reverts StaleStrategyData. This mirrors that on-chain
// gate exactly, so the UI can show "vault pricing is refreshing, try again
// shortly" instead of letting the user pay gas for a guaranteed revert.
//
// EXHAUSTIVE switch, not a single `=== "stale"` check (round-7 MED-1) —
// `DepositReadiness` has 4 members; blocking on only one and falling
// through on everything else means a NON-"ok", NON-"stale" status (e.g.
// `unknownFreshnessCapability`, which p-stv-core's own capability
// registry can legitimately return for a strategy program this SDK
// doesn't ship canonical support for) silently proceeds into a
// deterministic on-chain revert instead of being surfaced to the LP.
const readiness = await checkDepositReadiness(connection, stvState);
switch (readiness.status) {
case "ok":
break; // proceed to build + send below
case "stale":
throw new Error(readiness.hint); // or: disable the deposit button and render readiness.hint
case "unsupportedAllocator":
throw new Error("this helper does not preflight allocator vaults — see checkDepositReadiness's own doc");
case "unknownFreshnessCapability":
// Every strategy this SDK ships canonical support for (onyc, JLPD) is
// ALWAYS registered — see common/strategy-refresh.ts's own doc — so
// reaching this branch for vault 16 specifically would itself be a bug
// report, not a routine "try again" state. Still handled explicitly:
// never silently treat an unrecognized status as safe to proceed past.
throw new Error(
`no freshness capability registered for strategy ${readiness.strategyProgramId.toBase58()}`,
);
}
// 3. Build the full remaining-accounts tail (lend, if active — onyc/vault 16
// has none — plus the strategy pair + strategy program).
const ctx = await buildDepositContext(connection, {
address: stv,
baseMint: stvState.baseMint,
lendProgram: stvState.lendProgram,
strategy: stvState.strategy,
tokenProgram: TOKEN_PROGRAM_ID,
});
const vaultAta = findAta(stvState.baseMint, stv, TOKEN_PROGRAM_ID);
const userBaseAta = findAta(stvState.baseMint, user, TOKEN_PROGRAM_ID);
const userEvAta = findAta(evMint, user, TOKEN_PROGRAM_ID);
const feeReceiverEvAta = findAta(evMint, stvState.feeReceiver, TOKEN_PROGRAM_ID);
const amount = new BN(1_000_000_000); // 1,000 USDC (6 decimals)
const minShares = new BN(0); // set a real slippage floor in production
const ix = createDepositIx({
user, config, stv, vaultAta, userBaseAta, userEvAta, evMint, feeReceiverEvAta,
baseMint: stvState.baseMint, tokenProgram: TOKEN_PROGRAM_ID,
amount, minShares,
// The three fields a broken example once dropped — always spread the FULL ctx:
remainingAccounts: ctx.remainingAccounts,
protocolAumCount: ctx.protocolAumCount,
autoRouteCount: ctx.autoRouteCount,
});
const sig = await sendSmartTx(
connection,
[
...ctx.preInstructions,
createIdempotentAtaIx(user, evMint, user, TOKEN_PROGRAM_ID), // no-op if userEvAta already exists
ix,
],
user,
signTransaction,
);A parity test (src/p-stv-core/__tests__/readme-deposit-example.test.ts) builds
this exact instruction and asserts it is account-for-account and byte-identical
to the mainnet-proven p-stv-core/scripts/deposit-onyc-stv.ts's hand-built
equivalent — so a regression here fails npm test, not a real user's transaction.
Staleness: preflight-and-block, or self-heal (opt-in)
The example above uses Pattern A — preflight and block: check
checkDepositReadiness and disable the deposit button on "stale", adding
no extra RPC call on the happy path. There is a second, opt-in pattern for a
UI that would rather absorb the refresh itself than show a "try again
shortly" message:
Pattern B — self-heal. Pass { selfHealStaleness: true } as
buildDepositContext's 4th argument and it looks up the linked strategy's
freshness CAPABILITY (round-6 — no age-sampling, no on-chain read of the
strategy's last_updated at all): a needsExternalRefresh strategy (e.g.
onyc) gets a registered refresh instruction bundled into
ctx.preInstructions unconditionally, every call — not "only when a
sampled timestamp looks stale" — because an extra refresh is idempotent and
~cheap on-chain, and unlike any age-sampling scheme it stays correct at the
actual transaction-submission boundary, not just at the moment this function
ran. A selfRefreshing strategy is a no-op. This is the SAME capability
model buildProcessEpochContext("lockPps") already uses for the crank path
(see below) — registerStrategyRefreshRequirement/getStrategyFreshnessCapability
(@elmntl/jlpd-sdk/common) is the shared registry both go through.
import "@elmntl/jlpd-sdk/onyc-strategy"; // registers onyc's refresh_aum builder AND its needsExternalRefresh capability — required for self-heal to find it
const ctx = await buildDepositContext(connection, vault, payer, { selfHealStaleness: true });
// ctx.preInstructions is [] for a selfRefreshing strategy (or no strategy at
// all), or [refreshAumIx] for a needsExternalRefresh one like onyc — either
// way, spread it in.
const ix = createDepositIx({ /* ... */ remainingAccounts: ctx.remainingAccounts, protocolAumCount: ctx.protocolAumCount, autoRouteCount: ctx.autoRouteCount });
const sig = await sendSmartTx(connection, [...ctx.preInstructions, ix], user, signTransaction);selfHealStaleness is off by default — omitting the 4th argument (or
passing {} / { selfHealStaleness: false }) is byte-for-byte the same
buildDepositContext behavior this SDK has always had; existing callers are
unaffected unless they explicitly opt in. Like buildProcessEpochContext,
this throws StrategyRefreshRequiredError — fails closed — if the strategy
needs an external refresh and no refresh builder is registered for it, and
throws a plain Error if the strategy's capability was never registered at
all (UNKNOWN — round-6: there is no default in either direction; see
common/strategy-refresh.ts's own doc for why an earlier "unregistered =
self-refreshing" default silently misclassified canonical JLPD). Covered by
src/p-stv-core/__tests__/deposit-self-heal.test.ts, including a dedicated
back-compat suite proving the option's absence changes nothing.
Which instructions need the strategy tail?
remainingAccounts + protocolAumCount/autoRouteCount are not always
required — only the instructions that compute a LIVE strategy NAV need them.
Verified per-handler against p-stv-core/programs/p-stv-core/src (a
"user vs. manager" grouping does NOT track this — check per instruction):
| Instruction | Needs the strategy tail? | Staleness threshold |
| --- | --- | --- |
| deposit | Yes, when stv.strategy != PublicKey.default and the STV is not an allocator | stv.strategyStalenessThreshold \|\| DEFAULT_STRATEGY_STALENESS_THRESHOLD (24 h default) |
| instant_withdraw | Yes (same rule) | same 24 h-default field |
| process_delayed_deposit | Yes (same rule) | same 24 h-default field |
| deposit_to_strategy / withdraw_from_strategy (manager-only) | Yes, but via named strategyState/stvPosition args, not remainingAccounts | same 24 h-default field |
| process_epoch — first call (stv.epochPps == 0) | Yes | PROCESS_EPOCH_STRATEGY_STALENESS_THRESHOLD — 30 seconds, not 24 h; buildProcessEpochContext(connection, vault, "lockPps") checks this itself and auto-bundles the refresh (see below) rather than leaving the caller to remember it |
| process_epoch — continuation call (stv.epochPps != 0) | No — price is already locked; remainingAccounts is the list of WithdrawRequest PDAs to settle instead | n/a |
| request_withdraw | No — no NAV computation at all | n/a |
| claim_withdraw | No STRATEGY tail — pays out at the already-locked wr.pps, never CPIs the strategy. But NOT bare-instruction-safe on a lend-active vault (round-5 MED-1): core unconditionally parses claim_withdraw's 4 fixed LEND accounts before checking idle liquidity at all (claim.rs:348) — ALWAYS build through buildClaimWithdrawContext, never a bare createClaimWithdrawIx, regardless of checkClaimReadiness's status. See "Check withdrawal-request status" below. | n/a |
protocolAumCount/autoRouteCount/autoUnrouteCount are non-zero only when
the vault has an active lendProgram (stv.lendProgram != PublicKey.default)
— onyc/vault 16 has none, so all are always 0 for it. buildDepositContext /
buildClaimWithdrawContext / buildDepositToStrategyContext /
buildProcessEpochContext compute the correct non-zero values for vaults
that DO have lend active — including, as of round-5, auto-unroute for a
Kamino-registered protocol, not just a static Jupiter-pool lookup (MED-2; see
buildClaimWithdrawContext's own doc comment in remaining-accounts.ts).
Always take the counts from the same context object you took
remainingAccounts from.
DEFAULT_STRATEGY_STALENESS_THRESHOLD and PROCESS_EPOCH_STRATEGY_STALENESS_THRESHOLD
are exported from @elmntl/jlpd-sdk/p-stv-core — read the real values from
there rather than hardcoding them a third time.
process_epoch's first call: the strategy refresh is bundled for you
The mainnet-proven crank (process-epoch-onyc.ts) always sends onyc's own
refresh_aum immediately before process_epoch's first call in the SAME
transaction — onyc's update_aum CPI (the one process_epoch itself
triggers) does not stamp last_updated fresh for an already-solvent
strategy, so without the standalone refresh_aum, the 30-second staleness
check reverts StaleStrategyData on essentially every call.
buildProcessEpochContext(connection, vault, "lockPps") reproduces this for
you: it checks the linked strategy's registered freshness CAPABILITY
(round-6 — not a sampled last_updated timestamp) and, for a
needsExternalRefresh strategy (onyc, JLPD), looks up a
StrategyRefreshBuilder registered for vault.strategy and populates
ctx.preInstructions with the refresh instruction UNCONDITIONALLY — every
call, not only "when a sample looks stale" — since an extra refresh is
idempotent and ~free on-chain, and this is the only way to stay correct at
the actual submission boundary rather than at the moment this function
happens to sample state. A selfRefreshing strategy is a no-op (no extra
instruction, no extra RPC read). If no builder is registered for a
needsExternalRefresh strategy program, it throws
StrategyRefreshRequiredError — fails closed — rather than silently
handing back a context that's guaranteed to revert on submission.
This is admin/keeper-only (epoch cranking) — buildProcessEpochContext and
createProcessEpochIx live under the .admin fence (round-6 MED-1), not the
LP root:
import { buildProcessEpochContext, createProcessEpochIx } from "@elmntl/jlpd-sdk/p-stv-core/admin";
// Registers onyc's refresh_aum builder as a side effect — required for the
// auto-bundling above to find it for an onyc-linked vault.
import "@elmntl/jlpd-sdk/onyc-strategy";
const ctx = await buildProcessEpochContext(connection, vault, "lockPps");
const ix = createProcessEpochIx({
payer, config, stv, evMint, feeReceiverEvAta, escrowEvAta, vaultAta, tokenProgram,
advanceEpoch: false,
remainingAccounts: ctx.remainingAccounts,
protocolAumCount: ctx.protocolAumCount,
});
// For a `needsExternalRefresh` strategy (onyc, jlpd) ctx.preInstructions ALWAYS
// contains the bundled refreshAumIx — the refresh is unconditional, never
// freshness-sampled. Spread it in verbatim; do not reimplement conditional logic.
const sig = await sendSmartTx(connection, [...ctx.preInstructions, ix], payer, signTransaction);A parity test (src/p-stv-core/__tests__/process-epoch-context.test.ts) builds
this exact two-instruction bundle and asserts it is account-for-account and
byte-identical to process-epoch-onyc.ts's own hand-built
[refreshAumIx, processEpochIx] pair.
For a strategy program other than onyc, call
registerStrategyRefreshBuilder(programId, builder)
(@elmntl/jlpd-sdk/common) yourself before calling buildProcessEpochContext
— see onyc-strategy/refresh-aum.ts for the shape a builder implements.
Preflight a request-withdraw
request_withdraw has no strategy tail and no on-chain NAV computation — its
own guard chain is a set of vault/protocol flags, the current epoch state,
and the caller's own evX balance. checkRequestWithdrawReadiness mirrors
that chain, in the SAME order request_withdraw.rs checks it, completing
the preflight trilogy alongside checkDepositReadiness/checkClaimReadiness:
import {
checkRequestWithdrawReadiness,
findWithdrawRequestPda,
createRequestWithdrawIx,
fetchGlobalConfig,
} from "@elmntl/jlpd-sdk/p-stv-core";
import { findAta } from "@elmntl/jlpd-sdk/common";
import BN from "bn.js";
const shares = new BN(500_000); // evX to queue for withdrawal
const globalConfig = await fetchGlobalConfig(connection); // optional — omit to let the function fetch it itself
const readiness = await checkRequestWithdrawReadiness(connection, stvState, { user, shares, globalConfig });
switch (readiness.status) {
case "ok":
break; // proceed to build + send below
case "insufficientEvBalance":
console.log(`have ${readiness.available.toString()} evX, need ${readiness.requested.toString()}`);
return;
case "seedFloorViolation":
console.log(`would drop supply to ${readiness.supplyAfterBurn.toString()}, below the seed floor of ${readiness.floor.toString()}`);
return;
case "epochAlreadyProcessed":
console.log("the crank is mid-cycle — try again after it advances to the next epoch");
return;
default:
// protocolPaused / vaultPaused / withdrawalsDisabled / vaultNotSeeded /
// wrongWithdrawMode / noSharesRequested, plus the existingRequest*
// family (round-7 MED-4 — a same-epoch top-up onto an already-open
// WithdrawRequest that's frozen / migrate-kind / has invalid
// flags-or-destination-fields / fails PDA self-verification / epoch- or
// user-mismatched / already priced; see readiness.ts's own doc on
// RequestWithdrawReadiness for the full list) — disable the button;
// each needs a different admin action or client-side fix, not a retry.
console.log(readiness.status);
return;
}
const [config] = findConfigPda();
const [withdrawRequest, wrBump] = findWithdrawRequestPda(stv, user, stvState.currentEpochId);
const userEvAta = findAta(evMint, user, TOKEN_PROGRAM_ID);
const escrowEvAta = findAta(evMint, stv, TOKEN_PROGRAM_ID);
const ix = createRequestWithdrawIx({
user, config, stv, withdrawRequest, userEvAta, escrowEvAta, evMint,
tokenProgram: TOKEN_PROGRAM_ID, shares, wrBump,
});
const sig = await sendSmartTx(connection, [ix], user, signTransaction);Covered by src/p-stv-core/__tests__/request-withdraw-readiness.test.ts — one
case per guard, in handler order, plus the bigint/BN/number acceptance of
the shares param.
Check withdrawal-request status and enumerate a user's requests
"claimable" and "claimableLendActive" mean ELIGIBLE TO ATTEMPT with a
successfully built claim context — NEITHER status, by itself, guarantees
claim_withdraw will succeed (round-5 MED-1). checkClaimReadiness is a
unified, fail-closed preflight that mirrors execute_claim/claim_withdraw.rs's
own check order exactly: protocol-wide pause, then wr.pps == 0, then
migrate-kind, then frozen, then STV-paused, then the claim timelock, then the
withdraw rate limit, then vault liquidity. A request can look superficially
"ready" (priced, timelock passed) and still be guaranteed to revert for any of
the earlier reasons — those are now first-class statuses (protocolPaused,
migrateRequest, frozen, vaultPaused, rateLimited), not folded into a
narrower "claimable" the way an earlier version of this helper did.
On ANY lend-active vault, "claimable" is not enough on its own — you MUST
build through buildClaimWithdrawContext, never a bare createClaimWithdrawIx.
Core unconditionally parses this instruction's 4 fixed lend accounts BEFORE it
ever checks idle liquidity (claim.rs:348) — so even a plain "claimable"
status reverts on a lend-active vault if you build a bare instruction without
them. buildClaimWithdrawContext derives those accounts (plus the mandatory
protocol-AUM segment and, if needed, an auto-unroute route) for you — see
"Claim base from an unlocked WithdrawRequest" below and step 5 of the full
walkthrough. This SDK cannot predict the protocol's own available liquidity,
so neither status is a guarantee the on-chain unsweep will find enough —
only that the TRANSACTION ITSELF is well-formed and eligible to attempt.
"claimableLendActive" is NOT a block, despite the name overlap with
"vaultUnderfunded"'s shape. Both fire when idle vault_ata balance alone
is short of the owed amount, but for a lend-active STV (stv.lendProgram !=
PublicKey.default), execute_claim refreshes lend AUM and unsweeps the exact
deficit from the lend protocol BEFORE checking liquidity
(utils/claim.rs:338) — only if the lend protocol itself can't cover the
deficit does the claim actually revert. This SDK does not read
protocol-specific available-liquidity state (Kamino reserve liquidity,
Jupiter Lend pool liquidity, …) to predict that outcome, so it reports this
distinct, non-blocking status instead of guessing wrong in either direction.
Attempt on "claimable" OR "claimableLendActive" (always via
buildClaimWithdrawContext); treat every other status as non-sendable.
(Vault 16 / onyc has no lend program, so it can only ever report "claimable"
or the genuine "vaultUnderfunded" block — this distinction matters for a
lend-active vault.)
import {
fetchUserWithdrawRequests,
checkClaimReadiness,
buildClaimWithdrawContext,
fetchGlobalConfig,
} from "@elmntl/jlpd-sdk/p-stv-core";
// Fetch GlobalConfig once and pass it to both calls below — optional (each
// function fetches it itself when omitted), but saves an RPC round trip when
// checking readiness for more than one request in the same render.
const globalConfig = await fetchGlobalConfig(connection);
// All open (still-claimable-or-pending) requests for this user on this STV,
// each with a derived display status — pending / migrateRequest / frozen /
// vaultPaused / protocolPaused / priced / rateLimited / claimable /
// claimableLendActive / awaiting-liquidity — plus gross/net/fee payout
// figures. Returns [] once a request has been claimed — the WithdrawRequest
// account is closed on claim, so there is nothing left to decode a "claimed"
// status from; treat "present before, absent now" as claimed/closed in the UI.
const requests = await fetchUserWithdrawRequests(connection, { stv, user, globalConfig });
// Or, for a single already-known request:
const wr = await fetchWithdrawRequest(connection, stv, user, epochId);
const readiness = await checkClaimReadiness(connection, stvState, wr, globalConfig);
switch (readiness.status) {
case "claimable":
case "claimableLendActive":
// Both are ELIGIBLE TO ATTEMPT — always build through
// buildClaimWithdrawContext (never a bare createClaimWithdrawIx), so a
// lend-active vault's mandatory lend accounts are present. See step 5
// of the full walkthrough below for the complete build+send.
break;
case "vaultUnderfunded":
console.log(`vault needs ${readiness.owed.sub(readiness.available).toString()} more base before this claim can pay out`);
break;
case "rateLimited":
console.log(`daily withdraw cap reached: ${readiness.dailyWithdrawnBase.toString()} / ${readiness.dailyLimit.toString()} — try again after the window resets`);
break;
case "notYetClaimable":
console.log(`claimable after ${new Date(readiness.availableAfter * 1000).toISOString()}`);
break;
case "migrateRequest":
case "frozen":
case "vaultPaused":
case "protocolPaused":
case "epochNotCranked":
// disable the claim button; each of these needs a different admin/manager action, not a retry from the user
break;
}Render an LP dashboard (NAV, pps, and a user's position)
fetchVaultDashboard mirrors the EXACT NAV/pps formula p-stv-core's own
on-chain code uses (utils/nav.rs::compute_nav/compute_pps) for a
single-strategy, no-lend, non-allocator STV — onyc/vault 16 is exactly this
shape. It returns both raw bigints (for further math) and pre-formatted
decimal strings (for display) — see formatRawAmount's doc for why this
module uses bigint instead of BN/Number (no float precision loss on
large values). It throws for a lend-active or allocator vault rather than
silently under-reporting NAV — read the thrown message for what's missing.
import { fetchVaultDashboard } from "@elmntl/jlpd-sdk/p-stv-core";
const dashboard = await fetchVaultDashboard(connection, { stv, user });
console.log(`NAV: ${dashboard.navFormatted}`);
console.log(`Price per share: ${dashboard.ppsFormatted}`);
console.log(`Total evX supply: ${dashboard.evSupplyFormatted}`);
if (dashboard.user) {
console.log(`Your balance: ${dashboard.user.evBalanceFormatted} evX`);
console.log(`Your value: ${dashboard.user.valueBaseFormatted}`);
console.log(`Your share: ${(dashboard.user.shareOfVaultBps / 100).toFixed(2)}%`);
}Staleness note: for a strategy-linked vault, this NAV is only as fresh as
the strategy's last_updated — dashboard.strategyLastUpdated is included at
no extra RPC cost (the account is already being read for pps), but this
helper does not gate on it. Pair it with checkDepositReadiness before
enabling a deposit button; see that function's own doc/example above.
A parity test (src/p-stv-core/__tests__/readme-dashboard-example.test.ts)
runs this exact snippet against a fixture connection and asserts every
displayed line matches the documented values — including a cross-check
against p-stv-core's own Rust NAV unit-test fixtures
(instructions/user/deposit.rs's test_nav_with_single_strategy and
friends), not just this SDK's own arithmetic.
End-to-end LP journey: connect → view → deposit → request-withdraw → (operator crank) → claim
Every piece above, chained into one walkthrough for vault 16. This is the
full LP lifecycle — the pieces are individually parity-tested (see each
section above); this section just shows them in sequence. Two steps
(marked below) happen OFF the LP's device: an operator has to crank
process_epoch before a queued request becomes claimable — that's the
same operational dependency checkClaimReadiness's "epochNotCranked"
status surfaces, not something the LP's own wallet can do.
import BN from "bn.js";
import { TOKEN_PROGRAM_ID } from "@solana/spl-token";
import {
findConfigPda, findStvPda, findEvMintPda, findWithdrawRequestPda,
fetchStv, fetchVaultDashboard, fetchUserWithdrawRequests,
buildDepositContext, createDepositIx, checkDepositReadiness,
createRequestWithdrawIx, checkRequestWithdrawReadiness,
buildClaimWithdrawContext, createClaimWithdrawIx, checkClaimReadiness,
sendSmartTx,
} from "@elmntl/jlpd-sdk/p-stv-core";
import { findAta, createIdempotentAtaIx, resolveProgramAwareError } from "@elmntl/jlpd-sdk/common";
import "@elmntl/jlpd-sdk/onyc-strategy"; // registers onyc's error map + refresh_aum builder
const VAULT_ID = 16; // onyc / USDC
const [config] = findConfigPda();
const [stv] = findStvPda(VAULT_ID);
const [evMint] = findEvMintPda(VAULT_ID);
// ---- 1. VIEW: connect wallet, render the LP's current position ----
const dashboard = await fetchVaultDashboard(connection, { stv, user });
console.log(`NAV ${dashboard.navFormatted}, your evX ${dashboard.user?.evBalanceFormatted ?? "0"}`);
// ---- 2. DEPOSIT ----
const stvState = await fetchStv(connection, VAULT_ID);
const depositReadiness = await checkDepositReadiness(connection, stvState);
// Exhaustive, not a single `=== "stale"` check — see "Deposit base into a
// vault from a frontend" above for why every non-"ok" status needs handling.
switch (depositReadiness.status) {
case "ok":
break;
case "stale":
throw new Error(depositReadiness.hint); // or use selfHealStaleness: true — see "Deposit base into a vault" above
case "unsupportedAllocator":
throw new Error("this helper does not preflight allocator vaults");
case "unknownFreshnessCapability":
throw new Error(`no freshness capability registered for strategy ${depositReadiness.strategyProgramId.toBase58()}`);
}
const depositCtx = await buildDepositContext(connection, {
address: stv, baseMint: stvState.baseMint, lendProgram: stvState.lendProgram,
strategy: stvState.strategy, tokenProgram: TOKEN_PROGRAM_ID,
});
try {
const depositIx = createDepositIx({
user, config, stv, evMint, tokenProgram: TOKEN_PROGRAM_ID,
baseMint: stvState.baseMint,
vaultAta: findAta(stvState.baseMint, stv, TOKEN_PROGRAM_ID),
userBaseAta: findAta(stvState.baseMint, user, TOKEN_PROGRAM_ID),
userEvAta: findAta(evMint, user, TOKEN_PROGRAM_ID),
feeReceiverEvAta: findAta(evMint, stvState.feeReceiver, TOKEN_PROGRAM_ID),
amount: new BN(1_000_000_000), // 1,000 USDC
minShares: new BN(0), // set a real slippage floor in production
remainingAccounts: depositCtx.remainingAccounts,
protocolAumCount: depositCtx.protocolAumCount,
autoRouteCount: depositCtx.autoRouteCount,
});
await sendSmartTx(
connection,
[...depositCtx.preInstructions, createIdempotentAtaIx(user, evMint, user, TOKEN_PROGRAM_ID), depositIx],
user, signTransaction,
);
} catch (err) {
const { entry } = resolveProgramAwareError(err);
showToast(entry.message);
}
// ---- 3. REQUEST WITHDRAW (some time later, when the LP wants out) ----
// RE-FETCH stvState here — do not reuse step 2's copy. Core derives the
// WithdrawRequest PDA (and enforces every stv.* gate in
// checkRequestWithdrawReadiness) against the CURRENT epoch/flags at
// submission time; a cached stvState from step 2 can point at a stale
// stvState.currentEpochId if any time (even just a slow user, let alone a
// crank in between) has passed since it was fetched — a stale-epoch PDA
// derivation silently targets the WRONG WithdrawRequest account.
const requestStvState = await fetchStv(connection, VAULT_ID);
const shares = new BN(500_000);
const requestReadiness = await checkRequestWithdrawReadiness(connection, requestStvState, { user, shares });
if (requestReadiness.status === "ok") {
const [withdrawRequest, wrBump] = findWithdrawRequestPda(stv, user, requestStvState.currentEpochId);
const requestIx = createRequestWithdrawIx({
user, config, stv, withdrawRequest, evMint, tokenProgram: TOKEN_PROGRAM_ID, shares, wrBump,
userEvAta: findAta(evMint, user, TOKEN_PROGRAM_ID),
escrowEvAta: findAta(evMint, stv, TOKEN_PROGRAM_ID),
});
await sendSmartTx(connection, [requestIx], user, signTransaction);
}
// ---- 4. OPERATOR CRANK (OFF the LP's device — a keeper calls process_epoch;
// see "process_epoch's first call" above) — the LP just polls status: ----
// The crank ADVANCES stv.currentEpochId and re-prices requests — this is
// exactly why step 5 below re-fetches stvState yet again rather than reusing
// requestStvState from step 3.
const myRequests = await fetchUserWithdrawRequests(connection, { stv, user });
for (const item of myRequests) {
console.log(`epoch ${item.wr.epochId}: ${item.status}`); // "pending" until the crank prices it
}
// ---- 5. CLAIM (once an operator has cranked and the timelock has passed) ----
// RE-FETCH stvState here too, for the same reason as step 3 — the crank in
// step 4 may have changed stv.currentEpochId / lendProgram / pause flags
// since either earlier fetch. ALWAYS build through buildClaimWithdrawContext
// — never a bare createClaimWithdrawIx — even for a status of plain
// "claimable". Core unconditionally parses 4 fixed lend accounts before it
// ever checks idle liquidity (claim.rs:348), so a lend-active vault's claim
// fails outright without them; "claimable"/"claimableLendActive" mean
// ELIGIBLE TO ATTEMPT with a successfully built context, never "guaranteed
// to succeed with a bare instruction" (round-5 MED-1). Vault 16/onyc has no
// lend program, so this context is always empty for it — but the SAME code
// path here is what makes this snippet correct on a lend-active vault too,
// which is the point.
const claimStvState = await fetchStv(connection, VAULT_ID);
for (const item of myRequests) {
const readiness = await checkClaimReadiness(connection, claimStvState, item.wr);
if (readiness.status !== "claimable" && readiness.status !== "claimableLendActive") continue;
// 3rd arg `user` (payer) is required for the Kamino stale-vault refresh
// (round-6 MED-4) — WITHOUT it, a funded stale Kamino position's refresh
// is silently omitted and the claim reverts StaleProtocolData for a
// lend-active vault. Vault 16/onyc has no lend program, so this is a
// no-op for it, but the SAME call here is what makes this snippet correct
// on a lend-active vault too — never omit it, same discipline as the
// deposit context's own payer argument below.
const claimCtx = await buildClaimWithdrawContext(connection, {
address: stv, baseMint: claimStvState.baseMint, lendProgram: claimStvState.lendProgram,
strategy: claimStvState.strategy, tokenProgram: TOKEN_PROGRAM_ID,
}, user);
const claimIx = createClaimWithdrawIx({
user, config, stv, tokenProgram: TOKEN_PROGRAM_ID,
withdrawRequest: item.address, // fetchUserWithdrawRequests already gives you the WR's own PDA
vaultAta: findAta(claimStvState.baseMint, stv, TOKEN_PROGRAM_ID),
userBaseAta: findAta(claimStvState.baseMint, user, TOKEN_PROGRAM_ID),
feeReceiverBaseAta: findAta(claimStvState.baseMint, claimStvState.feeReceiver, TOKEN_PROGRAM_ID),
baseMint: claimStvState.baseMint,
remainingAccounts: claimCtx.remainingAccounts,
protocolAumCount: claimCtx.protocolAumCount,
autoUnrouteCount: claimCtx.autoUnrouteCount,
});
await sendSmartTx(connection, [...claimCtx.preInstructions, claimIx], user, signTransaction);
}(item.address/item.wr/item.status above are fetchUserWithdrawRequests's
per-request UserWithdrawRequest fields — see "Check withdrawal-request
status" above for the full shape. This snippet omits per-call error handling
after step 2 for brevity; wrap every sendSmartTx call in the same
try {} catch { resolveProgramAwareError(err) } pattern shown there. Steps 3
and 5 each re-fetch stvState rather than reusing an earlier copy — see the
comments inline; this exact bug class (a cached, stale-epoch stvState
producing a wrong WithdrawRequest PDA) is why src/p-stv-core/__tests__/readme-e2e-parity.test.ts
transcribes and byte-verifies this full sequence, not just its individual
steps.)
Turn a caught transaction error into a human-readable message
resolveProgramAwareError is the recommended entry point. A bare
Custom(n) code is ambiguous without knowing which program raised it:
p-stv-core CPIs into strategy programs as an UNMAPPED tail call (a strategy's
revert propagates unchanged, in ITS OWN numbering), and p-stv-core's
VaultError (0..=76) collides with onyc's OnycStrategyError (0..=29) on
every code from 0-29 with unrelated meanings (Custom(0) = MathOverflow
vs NotStvSigner; Custom(2) = Unauthorized vs StvStrategyMismatch).
resolveProgramAwareError reads the transaction's log trail to determine
which program actually threw — the FIRST Program <pid> failed: custom
program error: 0x<hex> line, with the program id AND the code ALWAYS read
from that SAME line (never a program id from one line paired with a code
found elsewhere — a failed: line with no code of its own establishes
nothing and is skipped; verified against real production log parsing, see
common/program-error.ts's module doc) — and resolves against THAT
program's registered map automatically, so a frontend calling this one
function cannot reach for the wrong map.
import { resolveProgramAwareError } from "@elmntl/jlpd-sdk/common";
import "@elmntl/jlpd-sdk/onyc-strategy"; // side-effect import — registers onyc's map (see note below)
try {
const sig = await sendSmartTx(connection, [ix], user, signTransaction);
} catch (err) {
const { status, entry, programLabel } = resolveProgramAwareError(err);
showToast(entry.message); // safe to render directly to the LP
console.error(`[${status}${programLabel ? ` via ${programLabel}` : ""}] ${entry.hint}`); // for your own logs
}Registration is a side effect of importing a program's module. Importing
anything from @elmntl/jlpd-sdk/p-stv-core (which you already do for
fetchVaultDashboard/deposit builders/etc.) registers VAULT_ERROR_MAP
automatically — no extra step. Onyc's map is registered the same way by
importing @elmntl/jlpd-sdk/onyc-strategy; if your app never otherwise
imports anything from it, add the bare side-effect import shown above so
resolveProgramAwareError can also recognize a revert that originated
inside onyc's CPI (e.g. deposit/instant_withdraw/process_epoch, all of
which CPI onyc's update_aum). getRegisteredProgramErrorMaps() lets you
confirm what's currently registered; registerProgramErrorMap(programId,
label, map) registers any additional program (jlpd-strategy,
elemental-lend, or a third-party strategy) the same way.
status tells you how confident the result is — always check it before
treating entry as definitive:
| status | Meaning |
| --- | --- |
| "resolved" | The origin program was identified from the logs and IS registered — entry/programId/programLabel are all populated. |
| "unregisteredProgram" | The origin program WAS identified, but no map is registered for it — entry.hint names the program id so you can register it. |
| "ambiguous" | A code was found but no genuine runtime Program <pid> failed: custom program error: 0x<hex> line was present to identify the origin (no logs at all, or logs that stop short of one — e.g. a truncated window showing only invoke frames). candidates lists every registered map's interpretation; the resolver never guesses from an unresolved invoke frame alone (an X invoke with no matching X failed/X success is NOT proof X is the one that failed — the omitted suffix could equally contain X success followed by a DIFFERENT program's failure). Fetch the transaction's full logs (connection.getTransaction) and re-resolve for a definitive answer. |
| "unparsed" | No Custom(n) code could be found at all — likely not a program-level revert (blockhash expiry, insufficient SOL for fees, a wallet/RPC error). |
Narrower alternatives — use these directly ONLY when you already know
for certain which single program raised the revert (e.g. request_withdraw,
which never CPIs a strategy at all): resolveVaultError (p-stv-core only,
from @elmntl/jlpd-sdk/p-stv-core) and resolveOnycStrategyError (onyc
only, from @elmntl/jlpd-sdk/onyc-strategy) skip program identification
entirely and resolve directly against one map. Both error maps carry a
drift-guard test (src/p-stv-core/__tests__/errors.test.ts,
src/onyc-strategy/__tests__/errors.test.ts) that re-parses each program's
error.rs at test time and fails if the map and the Rust source disagree on
even one code or name.
Parity tests (src/common/__tests__/readme-error-handling-example.test.ts,
src/common/__tests__/program-aware-error.test.ts) run this exact catch
block — and the collision case it exists to solve — against fixture errors
and assert the rendered message is both correct AND free of raw program
jargon (Custom(, 0x.., Rust type/enum names) an LP wouldn't understand.
Read a JLPD strategy state from a server job
import { fetchJlpStrategyState } from "@elmntl/jlpd-sdk/jlpd-strategy";
const state = await fetchJlpStrategyState(connection, baseMint);
console.log(`PPS: ${state.pps.toString()}`);
console.log(`base_loaned: ${state.baseLoaned.toString()}`);Build a manager-only swap_jlp rebalance from base → JLP
import { buildSwapJlpTransaction } from "@elmntl/jlpd-sdk/jlpd-strategy";
const result = await buildSwapJlpTransaction({
connection,
manager,
baseMint,
vaultId,
direction: "BaseToJlp",
amount: 1_000_000n,
slippageBps: 30,
});
const sig = await sendSmartTx(
connection,
[...result.preInstructions, ...result.instructions],
manager,
signTransaction,
{ additionalLuts: result.addressLookupTables },
);Quality bar
This SDK is the artifact submitted for external audit. The following invariants are enforced:
tscstrict mode withnoUnusedLocalsandnoUnusedParameters. Zero suppressions in source.- No
anyin source code. All wallet / connection seams use proper structural types. - No
@deprecatedexports. Deprecated aliases were removed during the audit cleanup; if you find one, it's a bug. - Buffer reads are bounds-checked. Every helper in
common/buffer.tsthrows on out-of-range offsets. - Discriminators are documented inline with their derivation (
// sha256("global:deposit")[..8]). - Each module follows the same file shape so the audit pattern is the same per program.
Project structure
sdk/
├── README.md (this file)
├── package.json
├── tsconfig.json strict + noUnusedLocals + noUnusedParameters
├── src/
│ ├── index.ts namespace re-exports
│ ├── common/
│ │ ├── index.ts
│ │ ├── constants.ts shared discriminators, PPS_DECIMALS, BPS, staleness
│ │ ├── connection.ts SolanaConnection structural type
│ │ ├── buffer.ts bounds-checked readers / writers
│ │ ├── ata.ts findAta()
│ │ ├── strategy-interface.ts StrategyStateHeader + StvPosition + decoders + generic strategy PDA finders
│ │ └── program-error.ts ProgramErrorEntry + extractCustomProgramErrorCode + resolveProgramError (generic, no program-specific data)
│ ├── p-stv-core/ vault management — Pinocchio program (13 instructions, 0x00–0x0C)
│ │ ├── constants.ts, pda.ts, types.ts, accounts.ts, instructions.ts, events.ts
│ │ ├── remaining-accounts.ts context builders for deposit/claim/epoch/strategy ops
│ │ ├── readiness.ts checkDepositReadiness / checkClaimReadiness preflight helpers
│ │ ├── withdraw-requests.ts fetchUserWithdrawRequests + per-request status derivation
│ │ ├── errors.ts VAULT_ERROR_MAP + resolveVaultError — VaultError -> LP message (drift-guard tested vs error.rs)
│ │ ├── dashboard.ts fetchVaultDashboard — live NAV/pps/evX-position reader for an LP dashboard
│ │ ├── send-tx.ts smart tx: simulate CU → priority fee → LUT → sign → confirm
│ │ ├── sol-wrap.ts native SOL wrap/unwrap for SOL-denominated vaults
│ │ └── prices.ts Jupiter Quote API USD price fetcher
│ ├── elemental-lend/ idle-base lending — Anchor 0.32.1 program
│ │ ├── constants.ts, pda.ts, types.ts, accounts.ts, instructions.ts
│ │ ├── kamino-vault.ts Kamino kVault account derivation + CPI + staleness check
│ │ ├── jupiter-lend.ts Jupiter Lend pool configs + auto-route account builders
│ │ └── protocol-actions.ts high-level protocol action tx builders
│ ├── jlpd-strategy/ JLP yield strategy — Anchor 0.32.1 program
│ │ ├── constants.ts, pda.ts, types.ts, accounts.ts, instructions.ts
│ │ ├── swap-jlp.ts Jupiter swap tx builders (BaseToJlp / JlpToBase)
│ │ ├── settle-yield.ts settle_yield instruction builder
│ │ ├── jlp-data.ts JLP pool data reader (custody weights, AUM, prices)
│ │ ├── jlp-borrow.ts Jupiter Lend Borrow constants + position reading
│ │ ├── jupusd-earn.ts Jupiter Lend Earn position + APR helpers
│ │ └── adapter.ts JLPD Lend Adapter instruction builders
│ └── onyc-strategy/ onyc/USDC strategy (STV 16) — errors only today, see "Module layout" above
│ └── errors.ts ONYC_STRATEGY_ERROR_MAP + resolveOnycStrategyError (drift-guard tested vs onyc's error.rs)
├── dist/ tsc output (published to npm; also committed for internal `file:` link consumers)
└── scripts/
└── create-luts.ts admin tool: create / extend address lookup tables