perena-vault-sdk
v1.0.10
Published
Vault program helpers for Bankineco integrations.
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 idCreating 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);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,
};
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(),
},
};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 }, which corresponds to the
amount argument on the vault program's execute_deposit CPI.
Quoting conversions
Use client.quote.quote(...) to calculate expected output amounts before
building a transaction. Quotes fetch the current vault/tranche state, calculate
using the same fee formulas as the program, and require a signer address so
the SDK can check whether the signer is owned by a fee-exempt program.
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); // true when signer owner is whitelisted
// 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
// 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 are already
fee-free, but still support the same quote shape.
// 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);For withdrawals routed through external liquidity (CPI), 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,
assetDecimals: 6,
})
);
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: 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)
Some builders (e.g. executeWithdraw, protocolInteraction, jupiterSwap)
return lookupTables on the plan. sendTransaction does not compile v0
messages — use sendVersionedTransaction from common instead:
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 |
| 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 |
| 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 | User |
| managerWithdrawAsset | manager_withdraw_asset | HW manager |
| 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 |
| withdrawTrancheFees | withdraw_tranche_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 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.
