npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@perena/vault-sdk

v1.0.72

Published

Vault program helpers for Bankineco integrations.

Downloads

3,663

Readme

Vault SDK

TypeScript helpers for the Bankineco vault program. The SDK wraps PDA derivation, account fetching, and instruction building behind a single VaultClient. Every on-chain instruction has a transaction builder on client.tx that returns a VaultTransactionPlan you can sign and send.

For runnable CLI examples, see scripts/README.md.

Install

From the monorepo (workspace package name is vault):

pnpm install
pnpm --filter vault run build

In another workspace package:

{
  "dependencies": {
    "vault": "workspace:*"
  }
}

Quick start

Option A — createVaultClient (scripts and backend services)

Use this when targeting deployed test or prod networks. It wires RPC, keypair, and program id from env vars (see .env.example).

import { createVaultClient } from "vault";

const { client, payer, signer } = createVaultClient("test", {
  role: "vault", // default keypair: ~/.config/solana/vault-test.json
});

const vault = "..." as Address; // vault PDA
const vaultAccount = await client.account.fetchVault(vault);

const plan = await client.tx.executeDeposit.getTx({
  user: signer,
  vault,
  assetMint: vaultAccount.config.baseAssetMint,
  shareMint: vaultAccount.config.shareMint,
  amount: 1_000_000n, // base units (6 decimals → 1.0 UI)
});

const signature = await client.sendTransaction(payer, plan);
console.log(signature);

Option B — manual VaultClient (tests, custom integrations)

Use this when you already have a Connection and signer:

import { AnchorProvider } from "@anchor-lang/core";
import { Connection, Keypair } from "@solana/web3.js";
import { fromWeb3Pk } from "common";
import { VaultClient, makeProvider } from "vault";

const connection = new Connection("http://127.0.0.1:8899", "confirmed");
const payer = Keypair.generate();
const client = new VaultClient(makeProvider(connection, payer));

const [vault] = await client.pda.deriveVaultPda(0); // vault id

NAV-priced yield-tracker accounts

The SDK supports both legacy APR accounts and NAV accounts whose share price is read by the yield tracker from a configured JSON API. Cashflow amounts are USDC base units; for NAV accounts the tracker records the exact shares purchased or redeemed at the fetched price.

import {
  addCashflowUpdate,
  createAccount,
  getNavValue,
  getTrackedValue,
} from "@perena/vault-sdk";

await createAccount({
  name: "piku-basis-trade",
  type: "stockmarkettrbasistrade",
  valuationModel: "nav",
  navConfig: {
    url: "https://public-api.piku.co/v1/tokens/stockmarkettrbasistrade",
    price_path: "price",
  },
  tag: vault.toString(),
  password: accountPassword,
  adminSecret,
});

const deposit = await addCashflowUpdate({
  accountName: "piku-basis-trade",
  amount: 1_000_000, // 1 USDC at six decimals
  cashflowType: "deposit",
  valuationModel: "nav",
  password: accountPassword,
});
console.log(deposit.share_amount, deposit.nav_price);

const nav = await getNavValue("piku-basis-trade");
console.log(nav.shares, nav.nav_price, nav.total_nav);

// Works for either APR or NAV accounts.
const tracked = await getTrackedValue("piku-basis-trade");
console.log(tracked.totalValue, tracked.valuationModel);

Use getAprYield(...) when an APR-only response is required. The general getYield(...) and getBalance(...) methods return discriminated APR/NAV unions, with isNavAccountValue(...) and isNavAccountBalance(...) available as type guards. Historical start, end, or as_of valuation is unavailable for NAV accounts backed only by a current-price API. NAV cashflows have no date or fundingNextDay parameter: they execute immediately at the price the tracker fetches during submission.

Incentive campaigns

All seven incentive instructions have exported getTx / getIx builders on client.tx: createIncentive, addIncentiveRecipient, setIncentiveLimits, submitIncentive, cancelIncentiveProposal, distributeIncentive, and claimIncentive. Their argument types are exported from @perena/vault-sdk alongside the builders. Creation and enrollment take oracle, authorized against either of the first two active reporting slots; getTx derives the vault oracle PDA. Submission also resolves any tranche accounts. Distribution resolves registered recipients, creates canonical share ATAs for both token programs, and requires every paid recipient to co-sign.

import {
  getIncentiveRecipients,
  getIncentiveReportRecipients,
  getIncentiveTotals,
  getIncentiveRecipientShareAtas,
  MAX_INCENTIVE_RECIPIENTS,
} from "@perena/vault-sdk";

const [incentive] = await client.pda.deriveIncentivePda(vault, 1n);
const create = await client.tx.createIncentive.getTx({
  oracle, vault, id: 1n, recipients: [recipient], maxTotalShares: 1_000_000n,
});
// Send with the oracle, then fetch the created account.
const data = await client.account.fetchIncentiveForVault(vault, 1n);
const recipients = getIncentiveRecipients(data); // registration order, bigint amounts
const reportRecipients = getIncentiveReportRecipients(data); // frozen prefix while pending
const totals = getIncentiveTotals(data); // settledShares, distributedShares, outstandingShares

// Discover IDs instead of maintaining an off-chain list.
const campaigns = await client.account.fetchAllIncentivesForVault(vault);
// Each result: { publicKey: Address, account: VaultIncentiveAccountData }.
const maybeCampaign = await client.account.fetchIncentiveNullable(incentive);
const batch = await client.account.fetchIncentives([incentive]);
// Batch preserves input order, duplicates, and nulls for missing accounts.

// Needed only for custom instruction composition; getTx derives these itself.
const recipientAtas = getIncentiveRecipientShareAtas(data, shareMint, shareTokenProgram);

// Prepare this payload once and share the SAME values with both oracle signers.
const proposal = {
  vault,
  incentive,
  roundId: BigInt(data.roundId.toString()),
  totals: reportRecipients.map(recipient => recipient.settledTotal + 100n),
  expiresAt: (await client.account.fetchClockUnixTimestamp()) + 600n,
  maxSurplusValue: 0n,
};
const report = await client.tx.submitIncentive.getTx({ ...proposal, oracle });
// After both reports agree and funding is reserved, the user signs when ready:
const claim = await client.tx.claimIncentive.getTx({ recipient, vault, incentive });
// Optional batch: collect signatures from every funded recipient as well as the cranker.
const payout = await client.tx.distributeIncentive.getTx({ cranker, vault, incentive });

claimIncentive reads the signing recipient's existing batch entry and creates no per-wallet program account. Reporting remains batched. Its payout updates the same lifetime counter as distributeIncentive, so claims and batch cranks can interleave without duplicate payment. The SDK creates only the claiming recipient's missing ATA. Five append-only recipients share each 408-byte batch account. Oracle enrollment preserves pending reports: new wallets join the following round. When pending, getIncentiveReportRecipients excludes those new slots; when idle, it returns all registered recipients. Persist and share the original payload for both signers.

distributeIncentive.getTx adds required signer metas for each outstanding recipient; it does not collect signatures. Supply them when sending the transaction. A curator or oracle signature cannot replace recipient consent. For custom composition, getIx accepts optional recipientSigners (default: all recipients); append these after the full ordered ATA list. The program rejects any nonzero payout without that wallet's signature, including attempts with the old unsigned batch layout. A PDA recipient must consent through a signed CPI from its owning program.

Creation/enrollment getIx calls now require vaultOracle; their signer argument is oracle, not curator. Upgrade the SDK with the program. Existing funded accounts remain valid: proposalRecipientCount uses former padding and the report helper handles pending version-1 accounts. Curator cap/floor changes and cancellation permissions are unchanged.

Incentive fetches always read fresh state. Raw decoded integers use Anchor BN; the recipient and total helpers expose bigint without precision loss. Claims are cumulative lifetime atomic share amounts: settledTotal - distributedTotal is currently payable, and unfinished oracle reports are excluded. Up to MAX_INCENTIVE_RECIPIENTS (five) wallets may be registered. maxSurplusValue and minSurplusReserve use vault accounting units, rather than share units.

Use the live clock helper for deadlines; historical block timestamps can lag execution time after validator time travel. Keep the round, expiry, surplus debit limit and totals identical across the two oracle submissions. The program still enforces authorization, campaign caps and available funding when agreement occurs.

For instruction semantics and the full accounting model, see docs/incentives.md.

Creating transactions

The builder pattern

Every instruction lives on client.tx.<builderName>. Builders follow the same contract:

| Method | Returns | When to use | | --------------- | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------- | | getTx(txArgs) | VaultTransactionPlan | Preferred. Derives PDAs/ATAs, prepends setup ixs (e.g. idempotent ATA creates), and attaches cache invalidation metadata. | | getIx(ixArgs) | Instruction | Low-level. You supply every account address yourself. |

TxArgs types are the high-level inputs (curator, vault, amounts as bigint, optional PDAs). IxArgs types are the fully-resolved account sets the program expects. Type definitions live in src/client/builders/args.ts.

A typical flow:

// 1. Build a plan
const plan = await client.tx.executeDeposit.getTx({
  user: signer,
  vault,
  assetMint,
  shareMint,
  amount: 500_000n,
});

// 2. Send it (signs, confirms, clears relevant account cache)
const sig = await client.sendTransaction(payer, plan);

Squads proposal routing

Browser integrations can map a connected Squads member wallet to a multisig and vault index, then use the derived vault PDA while building the instruction:

import {
  prepareVaultTransaction,
  vaultAuthorityForWallet,
  type SquadsWalletRouteConfig,
} from "@perena/vault-sdk";

const routes: SquadsWalletRouteConfig[] = [
  { wallet: "MEMBER_WALLET", multisigPda: "MULTISIG_PDA", vaultIndex: 0 },
];
const authority = vaultAuthorityForWallet(connectedWallet, routes);
const plan = await client.tx.setVaultConfig.getTx({
  curator: address(authority.toBase58()),
  // ...
});
const prepared = await prepareVaultTransaction({
  connection,
  proposer: connectedWallet,
  instructions: plan.instructions.map(fromKitInstruction),
  squadsRoutes: routes,
});

When the wallet is configured, prepared.transaction creates a Squads vault transaction and proposal. The member signs and submits that outer transaction; the multisig must still approve and execute the proposal. When no route matches, the helper returns the original instructions as a normal direct transaction. The configured member must have Squads' Initiate permission, and the vault program role must be assigned to the derived Squads vault PDA. Before returning a Squads transaction, prepareVaultTransaction simulates the inner instructions with signature verification disabled so the derived vault PDA has the signer privileges it will receive during execution. A failed simulation throws SquadsProposalExecutionSimulationError, including the RPC error and program logs. This dry run validates current program and account state, but not proposal approvals or the Squads execution wrapper.

VaultTransactionPlan contains:

  • instructions — ordered @solana/kit instructions (preamble + program ix)
  • lookupTables? — optional address lookup tables for v0 transactions
  • postSuccessCacheInvalidations? — vault cache keys to clear after success

Direct Rust CPI integrations

For integrations that build a Rust CPI into the vault program, some builders also expose the resolved Anchor account map and method arguments separately:

  • getIxAccounts(ixArgs) — named accounts passed to Anchor's accountsPartial
  • getIxData(ixArgs) — instruction method arguments, such as amounts and tranche kind

These helpers are currently available on:

  • client.tx.executeDeposit
  • client.tx.executeWithdraw
  • client.tx.executeTrancheDeposit
  • client.tx.executeTrancheWithdraw

Use these when a TypeScript integration wants the SDK to stay the source of truth for the account layout and argument shape, but your on-chain Rust program performs the final CPI. They accept fully-resolved IxArgs, so derive PDAs/ATAs with client.pda, account fetches, or getAtaAddress(...) before calling them. The returned data is not serialized instruction bytes; it is the same account map and argument values that getIx(...) uses internally.

import { BN } from "@anchor-lang/core";

const ixArgs = {
  user,
  vault,
  vaultOracle,
  vaultTrancheState: null,
  assetMint,
  shareMint,
  amount: new BN("1000000"),
  assetTokenProgram,
  shareTokenProgram,
  userAssetAta,
  vaultAssetAta,
  feeVault,
  feeVaultAta,
  userShareAta,
  // Optional: selects execute_deposit_fee_exempt and is included in ix data.
  // Include every invoke_signed seed, with the bump as the final seed.
  addressSeeds,
};

const accounts = client.tx.executeDeposit.getIxAccounts(ixArgs);
const data = client.tx.executeDeposit.getIxData(ixArgs);

// Example payload to hand to your Rust integration layer.
const cpiPayload = {
  accounts: Object.fromEntries(
    Object.entries(accounts).map(([name, pubkey]) => [
      name,
      pubkey?.toString() ?? null,
    ])
  ),
  data: {
    amount: data.amount.toString(),
    addressSeeds: data.addressSeeds,
  },
};

In Rust, map those account addresses into the matching Anchor CPI account struct and pass the data fields into the generated CPI method. For example, executeDeposit.getIxData(...) returns { amount, addressSeeds }. Without addressSeeds, it corresponds to execute_deposit; with seeds, the builder selects execute_deposit_fee_exempt.

For regular and instant tranche withdrawals, use getCpiPlan(txArgs) to resolve liquidity and split the requested shares across external positions:

const cpis = await client.tx.executeWithdraw.getCpiPlan({
  user: programAuthority,
  vault,
  assetMint,
  shareMint,
  shareAmount: 500_000n,
  addressSeeds,
});
// Invoke each entry in order within the same transaction.
for (const { accounts, data, remainingAccounts, lookupTables } of cpis) {
  // Pass accounts, data, and remainingAccounts to your Rust CPI integration.
  // Include lookupTables in the outer transaction and allow enough compute.
}

executeTrancheWithdraw.getCpiPlan(...) has the same behavior for junior and senior redemptions. getIxArgs(txArgs) returns the resolved arguments for each leg; getIxs(txArgs) returns serialized vault instructions without ATA creation. getTx(txArgs) adds ATA creation instructions and combines lookup tables. No builder requests a compute-unit limit; the submitting transaction handler must simulate and set the limit from units consumed with a 10% margin. The synchronous getIxData(ixArgs) and getIxAccounts(ixArgs) helpers describe a single resolved leg; they do not fetch balances or split it.

Fee-exempt program integrations

The deposit, withdrawal, tranche deposit, tranche withdrawal, and queued junior fulfillment builders accept optional addressSeeds in both their TxArgs and resolved IxArgs. Providing this field selects the corresponding *_fee_exempt instruction; omitting it always selects the standard fee-bearing instruction. External-liquidity withdrawals select execute_withdraw_from_external_fee_exempt when both external withdrawal data and addressSeeds are present.

Pass the exact seed slices used by the integrating program's invoke_signed, including the one-byte bump as the final seed:

import { PublicKey } from "@solana/web3.js";

const baseSeeds = [Buffer.from("authority"), new PublicKey(vault).toBuffer()];
const [authority, bump] = PublicKey.findProgramAddressSync(
  baseSeeds,
  integrationProgramId
);
const addressSeeds = [...baseSeeds, Buffer.from([bump])];

const ixArgs = await client.tx.executeDeposit.getIxData({
  ...resolvedDepositIxArgs,
  user: authority.toBase58() as Address,
  addressSeeds,
});

// ixArgs.addressSeeds is forwarded to execute_deposit_fee_exempt.

On-chain exemption succeeds only when the authority is a signer, its owner is the vault program or an allowlisted integration program, and create_program_address(addressSeeds, authority.owner) equals the authority address. The integrating program must invoke the vault instruction using invoke_signed with those same seeds. A PDA cannot call the fee-exempt route as a directly submitted top-level transaction.

For fulfillJuniorTrancheWithdraw, the fee-exempt signer must also be the queue owner; a separate fulfiller cannot waive that owner's fee.

Quoting conversions

Use client.quote.quote(...) to calculate expected output amounts before building a transaction. Quotes fetch the current vault/tranche state and use the same fee formulas as the program. To quote a fee-exempt route, provide the same addressSeeds; owner allowlisting by itself does not make a quote fee-exempt. Allowlisted wallets receive their exemption automatically without PDA seeds.

All amounts are base units. expectedAmountOut is the net amount after fees; grossAmountOut, feeAmount, feeBps, and feeExempt explain the quote.

Regular share mint

// Asset -> regular vault shares
const depositQuote = await client.quote.quote({
  shareClass: "regular",
  direction: "deposit",
  signer,
  vault,
  assetMint,
  amount: 1_000_000n,
});

console.log(depositQuote.expectedAmountOut); // regular shares to receive
console.log(depositQuote.feeExempt); // false for a non-allowlisted wallet

const exemptDepositQuote = await client.quote.quote({
  shareClass: "regular",
  direction: "deposit",
  signer,
  vault,
  assetMint,
  amount: 1_000_000n,
  addressSeeds,
});

// Regular vault shares -> asset
const withdrawQuote = await client.quote.quote({
  shareClass: "regular",
  direction: "withdraw",
  signer,
  vault,
  assetMint,
  amount: 500_000n, // share amount to burn
});

console.log(withdrawQuote.expectedAmountOut); // asset tokens to receive

Junior and senior tranches

Both tranche deposit types use the same vault mintFeeBps as regular deposits, with fees accruing as pending curator shares. Quotes follow each path's integer rounding: regular deposits deduct the fee from gross shares, while tranche deposits deduct it from the deposit's accounting value before minting shares.

// Asset -> junior tranche shares
const juniorDepositQuote = await client.quote.quote({
  shareClass: "junior",
  direction: "deposit",
  signer,
  vault,
  assetMint,
  amount: 1_000_000n,
});

// Senior works the same way.
const seniorDepositQuote = await client.quote.quote({
  shareClass: "senior",
  direction: "deposit",
  signer,
  vault,
  assetMint,
  amount: 1_000_000n,
});

For junior withdrawals, pass withdrawalMode: "instant" to quote executeTrancheWithdraw, or withdrawalMode: "queued" to quote fulfillJuniorTrancheWithdraw after lockup. Senior withdrawals use the vault's burnFeeBps, like regular withdrawals, and accrue the fee as pending curator shares. The existing wallet and approved-PDA fee exemptions still apply.

// Junior tranche shares -> asset through instant withdrawal.
const instantJuniorWithdrawQuote = await client.quote.quote({
  shareClass: "junior",
  direction: "withdraw",
  withdrawalMode: "instant",
  signer,
  vault,
  assetMint,
  amount: 100_000n, // junior shares to burn
});

// Junior tranche shares -> asset through queued fulfillment.
const queuedJuniorWithdrawQuote = await client.quote.quote({
  shareClass: "junior",
  direction: "withdraw",
  withdrawalMode: "queued",
  signer,
  vault,
  assetMint,
  amount: 100_000n,
});

// Senior tranche shares -> asset.
const seniorWithdrawQuote = await client.quote.quote({
  shareClass: "senior",
  direction: "withdraw",
  signer,
  vault,
  assetMint,
  amount: 100_000n,
});

If you already derived the tranche state PDA, pass vaultTrancheState to avoid an extra PDA derivation/fetch. Pass fresh: true when quoting immediately after another transaction and you want to bypass the SDK account cache.

Example: deposit

const plan = await client.tx.executeDeposit.getTx({
  user: signer,
  vault,
  assetMint,
  shareMint,
  amount: 1_000_000n,
});
await client.sendTransaction(payer, plan);

The builder automatically:

  • Derives vaultOracle from the vault PDA
  • Computes user/vault ATAs
  • Prepends idempotent ATA-create instructions where needed

Example: withdraw

const plan = await client.tx.executeWithdraw.getTx({
  user: signer,
  vault,
  assetMint,
  shareMint,
  shareAmount: 500_000n,
});
await client.sendTransaction(payer, plan);

Regular and instant tranche withdrawal builders first count the live vault ATA balance, then visit external liquidity slots in index order. Capacity is bounded by the vault's deposit and the bank/reserve's available cash, so borrowed liquidity is not counted. Kamino capacity also respects withdrawal caps, queued redemptions, and cToken rounding. Automatic routing supports supply-only Marginfi and Kamino positions.

If local cash plus slot 0 cannot fund the redemption, the builder splits the requested shares into sequential withdrawal instructions, continuing with slot 1 and later slots until funded. It accounts for fees and integer rounding after each burn, skips empty positions, and rejects the whole plan if aggregate liquidity is insufficient. Splitting can slightly change rounding compared with a single burn. The instructions belong in one atomic transaction.

The builder adds protocol lookup tables. Compute-unit limits belong to the submitting transaction handler, which should simulate and request Math.ceil(unitsConsumed * 1.1), capped at the transaction maximum. Additional lookupTables may be needed for large routes. Solana transaction size and compute limits still apply. Capacity is a build-time snapshot; simulate before sending and rebuild if balances change. skipExternalLiquidity: true requires enough local cash, while externalLiquiditySource: 1 pins routing to slot 1 instead of scanning all positions.

To supply the external route explicitly instead of relying on auto-detection, pass externalWithdrawIxRefs, externalWithdrawAccounts, and optionally lookupTables — see ExecuteWithdrawTxArgs in args.ts.

Example: vault setup (multi-step)

Deploy flows compose several builders in sequence:

const curator = fromWeb3Pk(payer.publicKey);

await client.sendTransaction(
  payer,
  await client.tx.createVault.getTx({
    curator,
    shareMint,
    strictAssetMint: assetMint,
  })
);

await client.sendTransaction(
  payer,
  await client.tx.createTrancheState.getTx({
    curator,
    vault,
    juniorShareMint,
    seniorShareMint,
    seniorFixedApyBps: 1_000,
  })
);

await client.sendTransaction(
  payer,
  await client.tx.updateConsensusSigners.getTx({
    curator,
    vault,
    signers: [curator],
  })
);

Example: tranche deposit

const plan = await client.tx.executeTrancheDeposit.getTx({
  user: signer,
  vault,
  assetMint,
  trancheShareMint: juniorMint,
  kind: { junior: {} }, // or { senior: {} }
  amount: 1_000_000n,
});
await client.sendTransaction(payer, plan);

Example: async regular-share redemption

New vaults default to hybrid redemption for regular shares. The curator can choose a redemptionMode through setVaultConfig:

| Mode | Immediate withdrawals and regular-to-tranche share swaps | New async requests | | --- | --- | --- | | { synchronous: {} } | Allowed | Rejected | | { asyncOnly: {} } | Rejected | Allowed | | { hybrid: {} } (default) | Allowed | Allowed |

Hybrid mode lets users choose immediate withdrawal or queue a request on the same vault without switching modes. Immediate withdrawals still require enough available liquidity; they do not automatically create an async request on failure. Existing vaults retain their configured mode until the curator changes it. Configure fulfiller using the existing role timelock before processing requests. Async-only mode also blocks fee-exempt and external regular withdrawals. Junior and senior tranche redemption policies are unchanged. Upgrade the program and SDK before setting hybrid mode; older SDKs cannot decode its new enum variant.

await client.sendTransaction(curator, await client.tx.setVaultConfig.getTx({
  curator: fromWeb3Pk(curator.publicKey),
  vault,
  redemptionMode: { hybrid: {} },
  redemptionPeriodSecs: 3_600n, // one hour for new async requests
}));

await client.sendTransaction(user, await client.tx.requestAsyncWithdraw.getTx({
  user: fromWeb3Pk(user.publicKey),
  vault,
  assetMint,
  queueId: 0,
  shareAmount: 100_000n,
}));

const pending = await client.account.fetchPendingRedemptionQueues(vault);
// Optional second argument restricts the results to one owner.

// Optional: the HW manager locks the current net output-token payout once.
await client.sendTransaction(hwManager, await client.tx.priceAsyncWithdraw.getTx({
  hwManager: fromWeb3Pk(hwManager.publicKey),
  vault,
  withdrawalQueue: pending[0].publicKey,
}));

await client.sendTransaction(fulfiller, await client.tx.fulfillAsyncWithdraw.getTx({
  signer: fromWeb3Pk(fulfiller.publicKey),
  owner: fromWeb3Pk(user.publicKey),
  vault,
  assetMint,
  queueId: 0,
}));

Requests escrow shares without changing supply or NAV. Only the configured fulfiller can settle regular requests after their stored expiryTs.

The curator sets redemptionPeriodSecs through setVaultConfig (or the CLI's --redemption-period-secs flag). It is a non-negative integer number of seconds; 0 is the default and permits immediate settlement. Each new async request stores expiryTs = requestTs + redemptionPeriodSecs. Later config changes affect only new requests. Synchronous withdrawals in Hybrid mode remain immediate, and the separate 30-day cancellation rule is unchanged. The field uses reserved account space, so existing vaults retain their size and default to zero delay. Upgrade the program before using this setting; older programs ignore the appended option. Unpriced requests use current NAV, asset prices, and burn fees. The HW manager can price a request once, recording request.outputAmount in output-token atomic units, pricedTs, and feeShareAmount. A positive pricedTs marks a locked quote, even when its payout is zero. Pricing uses fresh oracle state and regular-share backing (excluding tranches), applies the owner's fee exemption, and does not require liquidity, burn shares, reserve assets, or change NAV. Repricing is rejected.

Priced requests settle the recorded net tokens and fee shares. NAV is debited by the locked tokens' value at the current output asset price, so settlement still requires fresh oracle state. Current withdrawal limits apply to both paths. Settlement is all-or-nothing, burns the requested shares, pays the owner's asset ATA, returns surplus escrow shares, and refunds queue/escrow rent to the original payer. The builder creates missing owner ATAs and resolves the recorded rent payer. Settlement uses the vault's local token balance first and pulls only the net payout's shortfall from external liquidity. The builder reads the queue fresh and compares its locked payout (or a current quote when unpriced) against the live local balance. When local funds cover the payout, it omits external CPI accounts and lookups. Otherwise it prepares the same single-source external resolution as junior queue fulfillment. Explicit CPI refs remain caller-controlled. Insufficient combined liquidity leaves the request pending. If liquidity or pricing changes after building a local-only transaction, rebuild it to resolve any newly required external accounts; the fulfiller does this on its next run.

Both share classes share the existing VaultWithdrawalQueue layout and the 256 queue IDs per owner/vault. fetchAllWithdrawalQueues supports vault, owner, and input-mint filters; fetchPendingRedemptionQueues selects regular shares. WithdrawalQueueService routes by input mint, so previously accepted requests remain fulfillable after switching back to synchronous mode. Service snapshots include nullable outputAmount and pricedTs; fulfillment always refreshes the queue before building its instruction.

The keeper processes regular-share requests oldest-first by requestTs within each vault, using PDA address to break same-second ties deterministically. The queueId remains a reusable owner slot (0–255). An unfunded or not-yet-ready request blocks later regular requests in that keeper run; the keeper logs skipped-no-liquidity and continues with other vaults and junior queues. It uses on-chain time for readiness. Ordering is a keeper policy: the program allows an authorized fulfiller to settle any ready request directly.

Only the request owner can cancel, at any time before the HW manager locks the payout (pricedTs == 0). A locked zero payout also cannot be cancelled. There is no cancellation waiting period. cancelAsyncWithdraw.getTx({ user, vault, queueId }) recreates a missing owner share ATA, returns all escrow shares (including surplus), and refunds queue/escrow rent to the recorded payer without charging redemption fees. The legacy junior cancellation route enforces the same payout lock for regular shares; junior cancellation keeps its existing behavior. Requests are independent and need no neighbouring queue accounts.

The curator console's Redemptions tab shows pending regular-share requests, their locked payouts and pricing timestamps, and owner cancellation eligibility. Price all unpriced packs instructions up to the transaction size limit and uses the HW-manager wallet's signAllTransactions where supported. Each request is priced when its transaction executes; separate transactions are not a single atomic snapshot. Failed batches remain pending and can be refreshed and retried.

The redemption mode and redemption period reuse reserved vault space; pricing fields reuse queue padding. Existing vault account sizes and prior field offsets are unchanged, and queue accounts remain 296 bytes including the discriminator. No FIFO state or account migration is needed.

Deploy the updated program and publish SDK 1.0.70, then update all consumers before enabling regular-share async requests. Existing config payloads omitting the trailing options remain valid.

Example: junior withdrawal queue

// User queues a locked junior withdraw
await client.sendTransaction(
  payer,
  await client.tx.requestJuniorTrancheWithdraw.getTx({
    owner: signer,
    vault,
    juniorMint,
    queueId: 0,
    shareAmount: 100_000n,
  })
);

// Fulfiller pays out after lockup (see WithdrawalQueueService for batch logic)
await client.sendTransaction(
  fulfiller,
  await client.tx.fulfillJuniorTrancheWithdraw.getTx({
    fulfiller: fromWeb3Pk(fulfiller.publicKey),
    owner,
    vault,
    queueId: 0,
  })
);

Sending transactions

Built-in helper

VaultClient.sendTransaction builds a legacy Transaction, signs with the fee payer, sends, confirms, and applies cache invalidations from the plan:

const signature = await client.sendTransaction(payer, plan);

Versioned transactions (lookup tables)

All vault transaction builders include the vault-specific LUT from VAULT_LOOKUP_TABLES when configured, alongside protocol/caller lookup tables. Multi-vault operations include both vaults' tables. sendTransaction and simulateTransaction use v0 messages when the plan contains lookup tables; they also apply vault mappings to manually assembled vault instructions.

Create or update a vault's table from the repository root:

pnpm vault-lut prod --vault-id 0 --dry-run
pnpm vault-lut prod --vault-id 0
pnpm build:vault-sdk

The script defaults to ~/.config/solana/hw-manager-prod.json as LUT authority and rent payer; --keypair PATH selects another signer. This signer owns the LUT, independently of vault roles. It discovers an existing table for this authority/vault or uses the saved mapping, creates a table if absent, and adds only missing addresses in batches of 20. It gathers vault PDAs, holding/share mints and ATAs, Marginfi withdrawal/health accounts, and Kamino obligation/reserve/oracle/farm accounts, including an empty SOL/BTC position. --user KEY adds user ATAs; --accounts A,B,... adds explicit addresses.

The mapping is saved in src/vaultLookupTables.json as soon as the table is created, so an interrupted extension resumes on the next run. Commit this file and rebuild/publish the SDK for downstream consumers. Tables are limited to 256 addresses; setup fails before extension if the table lacks room. Newly added entries must be active in a later slot; the CLI waits for that slot.

Applications can supply a mapping immediately without waiting for a new SDK release:

const { client } = createVaultClient("prod", {
  keypair: payer,
  vaultLookupTables: { [vault]: vaultLutAddress },
});
const plan = await client.tx.executeWithdraw.getTx(withdrawArgs);
await client.sendTransaction(payer, plan);

Withdrawal getCpiPlan(...) and getIxArgs(...) also return the vault LUT. Single raw getIx(...) instructions have no LUT field: use getVaultLookupTables(vault) or withVaultLookupTables(plan, programId) when composing outside VaultClient. For a custom sender:

import { fromKitInstruction, sendVersionedTransaction } from "common";

const plan = await client.tx.protocolInteraction.getTx({
  /* ... */
});

const signature = await sendVersionedTransaction({
  connection: client.provider.connection,
  payer,
  instructions: plan.instructions.map(fromKitInstruction),
  lookupTables: plan.lookupTables ?? [],
});

client.applyCacheInvalidations(plan);

Composing your own transaction

You can merge instructions from multiple builders or mix in custom ixs:

const depositPlan = await client.tx.executeDeposit.getTx({ /* ... */ });
const customIx = /* your instruction */;

const combined: VaultTransactionPlan = {
  instructions: [...depositPlan.instructions, customIx],
  postSuccessCacheInvalidations: depositPlan.postSuccessCacheInvalidations,
};

Or call getIx directly when you only need the program instruction:

const ix = await client.tx.executeDeposit.getIx({
  user: signer,
  vault,
  vaultOracle,
  vaultTrancheState: null,
  assetMint,
  shareMint,
  amount: new BN("1000000"),
  assetTokenProgram: TOKEN_PROGRAM_ID,
  shareTokenProgram: TOKEN_PROGRAM_ID,
  userAssetAta,
  vaultAssetAta,
  userShareAta,
});

Reading on-chain state

Use client.account to fetch decoded vault accounts (cached by default, TTL 60s):

const vaultAccount = await client.account.fetchVault(vault);
const oracle = await client.account.fetchVaultOracle(vaultOraclePda);
const trancheState = await client.account.fetchVaultTrancheState(tranchePda);

// Force a fresh read after an external tx
const fresh = await client.account.fetchVault(vault, { fresh: true });
client.clearAllCache();

Derive PDAs with client.pda:

const [vault] = await client.pda.deriveVaultPda(vaultId);
const [vaultOracle] = await client.pda.deriveVaultOraclePda(vault);
const [trancheState] = await client.pda.deriveVaultTrancheStatePda(vault);
const [queue] = await client.pda.deriveWithdrawalQueuePda(
  vault,
  owner,
  queueId
);

Available transaction builders

All builders are on client.tx:

| Builder | Program instruction | Typical signer | | ------------------------------- | ----------------------------------------------------- | --------------------- | | createVault | create_vault | Curator | | claimIncentive | claim_incentive | Recipient | | createIncentive | create_incentive | Either reporting oracle | | addIncentiveRecipient | add_incentive_recipient | Either reporting oracle | | setIncentiveLimits | set_incentive_limits | Curator | | submitIncentive | submit_incentive | First two oracle slots | | cancelIncentiveProposal | cancel_incentive_proposal | Curator, or anyone after expiry | | distributeIncentive | distribute_incentive | Cranker + every paid recipient | | createAssetHolding | create_asset_holding | HW manager | | removeAssetHolding | remove_asset_holding | HW manager | | updateConsensusSigners | update_consensus_signers | Curator | | createTrancheState | create_tranche_state | Curator | | setVaultConfig | set_vault_config | Curator | | updateTrancheConfig | update_tranche_config | Curator | | activateCircuitBreaker | activate_circuit_breaker | CB trigger or curator | | disableCircuitBreaker | disable_circuit_breaker | Curator | | updateConsensusOracle | update_consensus_oracle | Consensus signer | | executeDeposit | execute_deposit | User | | executeTrancheDeposit | execute_tranche_deposit | User | | executeTrancheWithdraw | execute_tranche_withdraw | User | | requestJuniorTrancheWithdraw | request_junior_tranche_withdraw | Owner | | cancelJuniorTrancheWithdraw | cancel_junior_tranche_withdraw | Owner | | fulfillJuniorTrancheWithdraw | fulfill_junior_tranche_withdraw | Fulfiller | | executeWithdraw | execute_withdraw / execute_withdraw_from_external | User | | managerWithdrawAsset | manager_withdraw_asset | HW manager | | setManagerWithdrawDestination | set_manager_withdraw_destination | Curator | | managerRedepositAsset | manager_redeposit_asset | HW manager | | protocolInteraction | protocol_interaction | HW manager | | jupiterSwap | jupiter_swap | HW manager | | setExternalLiquidity | set_external_liquidity | HW manager | | setAssetPriceOracle | set_asset_price_oracle | Curator | | updateAssetPrice | update_asset_price | Oracle keeper | | vaultReallocation | vault_reallocation | HW manager | | withdrawProtocolFees | withdraw_protocol_fees | Curator |

Argument shapes for each builder: src/client/builders/args.ts.

Higher-level services

For recurring backend work, prefer the services layer over reimplementing discovery logic in your app:

  • WithdrawalQueueService — scan and fulfill regular and eligible junior withdrawal queues (src/services/withdrawalQueueService.ts)
  • OracleService — consensus oracle / asset price updates (src/services/oracleService.ts)

These services call the same client.tx.*.getTx builders internally.

Environment and RPC

Copy .env.example to .env. RPC resolution order:

  1. VAULT_RPC_URL_TEST / VAULT_RPC_URL_PROD
  2. RPC_URL
  3. HELIUS_API_KEY (auto-built Helius URL)

Program ids per environment: getVaultProgramId("test" | "prod") in src/env.ts.

CLI scripts

Thin wrappers around the SDK live in scripts/. From the repo root:

pnpm deposit test <VAULT_PDA> 100
pnpm inspect prod <VAULT_PDA>

See scripts/README.md for the full command list.

Types and addresses

  • Public keys use @solana/kit Address strings in builder args.
  • Convert from web3.js: fromWeb3Pk(keypair.publicKey) (from common).
  • Amounts in TxArgs are bigint base units unless noted otherwise.

External liquidity allocation (1.0.57)

ExternalLiquidityIntegrityService powers the backend-services/vault-external-liquidity Lambda. For USDC with both protocols registered, it targets 35% Marginfi / 65% Kamino SOL/BTC, excluding the local vault buffer. An external rebalance fires outside 30–40% Marginfi / 60–70% Kamino and restores 35/65. Exactly on a boundary remains inside the band. marginfiWeightBps (default 3500) and externalWeightBandBps (default 500) configure the target and absolute percentage-point tolerance.

The existing buffer remains: local USDC worth 1.5% of total vault TVL, or 0.5% of each other supported holding, with a 20%-of-target buffer band. Buffer replenishment takes priority. A buffer adjustment also distributes remaining external funds toward the target weights. Single-protocol holdings continue to use that protocol; both initialized, registered positions are required for the USDC split. Other configured stablecoins continue to use Marginfi.

Allocation uses each live position's supplied balance, including accrued value, and the actual vault token balance. Marginfi withdrawal capacity is capped by both bank cash and total assets minus total liabilities, retaining 0.1% of that capacity for interest accrual and rounding. This cap does not reduce the position value used for allocation weights. Kamino capacity checks reserve cash, withdrawal caps and queued redemptions. Utilization can limit a rebalance to a partial transfer; subsequent runs retry if weights remain outside the band. Each mint's withdrawals and deposits share one atomic transaction and use the vault's configured LUT. If a destination rejects a deposit, the whole transaction fails and is reported for retry. No transfer is left half-completed.

Kamino deposits use buildKaminoDepositInteraction and the appended KLEND_DEPOSIT_AND_COLLATERALIZE_V2 CPI. The vault validates the registered obligation, vault-owned source ATA, reserve, market, token programs, farms and absence of borrowing. Only the hardware manager can deposit. Upgrade the vault program with this CPI support before deploying the Lambda with SDK 1.0.57. Publish the SDK and then rebuild/deploy the backend consumer; changing the local package version alone does not update a running Lambda.

# Simulate all eligible vaults; no transactions are submitted.
pnpm run-external-liquidity-service prod --dry-run

# Explicitly supply the default allocation policy.
pnpm run-external-liquidity-service prod --dry-run \
  --marginfi-weight-bps 3500 --external-weight-band-bps 500