@perena/vault-sdk
v1.0.72
Published
Vault program helpers for Bankineco integrations.
Downloads
3,663
Keywords
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 buildIn 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 idNAV-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/kitinstructions (preamble + program ix)lookupTables?— optional address lookup tables for v0 transactionspostSuccessCacheInvalidations?— 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'saccountsPartialgetIxData(ixArgs)— instruction method arguments, such as amounts and tranche kind
These helpers are currently available on:
client.tx.executeDepositclient.tx.executeWithdrawclient.tx.executeTrancheDepositclient.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 receiveJunior 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
vaultOraclefrom 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-sdkThe 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:
VAULT_RPC_URL_TEST/VAULT_RPC_URL_PRODRPC_URLHELIUS_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/kitAddressstrings in builder args. - Convert from web3.js:
fromWeb3Pk(keypair.publicKey)(fromcommon). - Amounts in
TxArgsarebigintbase 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