diff --git a/frontend/index.html b/frontend/index.html
index ee5da2b..0b06a0f 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -865,6 +865,38 @@
Keyboard Shortcuts
+
+
+
+ 0 positions selected
+
+
+
+
+
+
+
+
+
Confirm Bulk Action
+
Review the positions affected by this action:
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/blend.ts b/frontend/src/blend.ts
index 3b22105..3ecaab5 100644
--- a/frontend/src/blend.ts
+++ b/frontend/src/blend.ts
@@ -1378,6 +1378,104 @@ export async function submitClassicXdr(signedXdr: string): Promise {
return (result as any).hash;
}
+export async function buildBulkCloseXdr(
+ poolPositions: { pool: PoolDef; positions: AssetPosition[] }[],
+ userAddress: string,
+): Promise {
+ const MAX_AMOUNT = 9_223_372_036_854_775_807n; // i64::MAX
+ const acc = await server.getAccount(userAddress);
+ const txBuilder = new TransactionBuilder(acc, {
+ fee: (BigInt(BASE_FEE) * 10n).toString(),
+ networkPassphrase: _cfg.passphrase,
+ });
+
+ const addrScVal = new Address(userAddress).toScVal();
+
+ for (const { pool, positions } of poolPositions) {
+ const reqItems: xdr.ScVal[] = [];
+ for (const pos of positions) {
+ if (pos.dTokens > 0n) {
+ reqItems.push(buildRequest(pos.asset.id, MAX_AMOUNT, REPAY));
+ }
+ reqItems.push(buildRequest(pos.asset.id, MAX_AMOUNT, WITHDRAW_COLLATERAL));
+ }
+ const requests = buildRequestsVec(reqItems);
+ const poolContract = new Contract(pool.id);
+ txBuilder.addOperation(poolContract.call("submit_with_allowance", addrScVal, addrScVal, addrScVal, requests));
+ }
+
+ const tx = txBuilder.setTimeout(60).build();
+ const sim = await server.simulateTransaction(tx);
+ if (!SorobanRpc.Api.isSimulationSuccess(sim))
+ throw new Error(`Bulk close simulation failed: ${(sim as SorobanRpc.Api.SimulateTransactionErrorResponse).error}`);
+ return SorobanRpc.assembleTransaction(tx, sim).build().toXDR();
+}
+
+export async function buildBulkAdjustXdr(
+ poolAdjustments: { pool: PoolDef; adjustments: { asset: AssetInfo; pos: AssetPosition; targetLev: number }[] }[],
+ userAddress: string,
+): Promise {
+ const acc = await server.getAccount(userAddress);
+ const txBuilder = new TransactionBuilder(acc, {
+ fee: (BigInt(BASE_FEE) * 10n).toString(),
+ networkPassphrase: _cfg.passphrase,
+ });
+
+ const addrScVal = new Address(userAddress).toScVal();
+
+ for (const { pool, adjustments } of poolAdjustments) {
+ const reqItems: xdr.ScVal[] = [];
+
+ for (const { asset, pos, targetLev } of adjustments) {
+ if (pos.equity <= 0) throw new Error(`No equity in ${asset.symbol} position`);
+
+ if (targetLev > pos.leverage) {
+ // Increase leverage
+ const targetCollateral = pos.equity * targetLev;
+ const additionalBorrow = targetCollateral - pos.collateral;
+ if (additionalBorrow <= 0) continue;
+
+ const additionalBorrowStroops = BigInt(Math.round(additionalBorrow * 1e7));
+ const cFactorBn = BigInt(Math.round(asset.cFactor * SCALAR_F));
+
+ let remaining = additionalBorrowStroops;
+ let balance = 0n;
+ while (remaining > 0n) {
+ const canBorrow = balance > 0n ? balance * cFactorBn / SCALAR : remaining;
+ const borrow = canBorrow < remaining ? canBorrow : remaining;
+ if (borrow <= 0n) break;
+ reqItems.push(buildRequest(asset.id, borrow, BORROW));
+ reqItems.push(buildRequest(asset.id, borrow, SUPPLY_COLLATERAL));
+ balance = borrow;
+ remaining -= borrow;
+ }
+ } else if (targetLev < pos.leverage) {
+ // Decrease leverage
+ const targetDebt = pos.equity * (targetLev - 1);
+ const debtReduction = pos.debt - targetDebt;
+ if (debtReduction <= 0) continue;
+
+ const debtReductionStroops = BigInt(Math.round(debtReduction * 1e7));
+ reqItems.push(buildRequest(asset.id, debtReductionStroops, WITHDRAW_COLLATERAL));
+ reqItems.push(buildRequest(asset.id, debtReductionStroops, REPAY));
+ }
+ }
+
+ if (reqItems.length === 0) continue;
+
+ const requests = buildRequestsVec(reqItems);
+ const poolContract = new Contract(pool.id);
+ txBuilder.addOperation(poolContract.call("submit_with_allowance", addrScVal, addrScVal, addrScVal, requests));
+ }
+
+ const tx = txBuilder.setTimeout(60).build();
+ const sim = await server.simulateTransaction(tx);
+ if (!SorobanRpc.Api.isSimulationSuccess(sim))
+ throw new Error(`Bulk adjust simulation failed: ${(sim as SorobanRpc.Api.SimulateTransactionErrorResponse).error}`);
+ return SorobanRpc.assembleTransaction(tx, sim).build().toXDR();
+}
+
+
// ── Position event timeline (A25) ─────────────────────────────────────────────
export type PositionEventKind = "open" | "rebalance" | "harvest" | "close";
diff --git a/frontend/src/main.ts b/frontend/src/main.ts
index 2c74593..9acbec2 100644
--- a/frontend/src/main.ts
+++ b/frontend/src/main.ts
@@ -43,6 +43,8 @@ import {
estimateBlndSwap,
submitSignedXdr,
submitClassicXdr,
+ buildBulkCloseXdr,
+ buildBulkAdjustXdr,
hfForLeverage,
maxLeverageFor,
getBlndPriceAssumption,
@@ -98,6 +100,10 @@ let selectedPool: PoolDef = getKnownPools()[0]; // default: Etherfuse
let assets: AssetInfo[] = getPoolAssets(selectedPool);
let selectedAsset: AssetInfo = assets[2]; // default: CETES (index 2 in Etherfuse)
+let selectedPositions = new Set(); // elements are poolId:assetId
+let _lastOverviewBlendPos: OverviewBlendPosition[] = [];
+
+
// ── Network switching ────────────────────────────────────────────────────────
async function switchNetwork(net: NetworkMode) {
@@ -127,6 +133,8 @@ async function switchNetwork(net: NetworkMode) {
// Reset state
reserves = [];
positions = { byAsset: new Map() };
+ selectedPositions.clear();
+ updateBulkMenuBar();
demoMode = false;
selectedPool = getKnownPools()[0];
assets = getPoolAssets(selectedPool);
@@ -2147,6 +2155,8 @@ async function disconnect() {
// ── View switching (Leverage / Swap) ─────────────────────────────────────
function switchView(view: AppView) {
+ selectedPositions.clear();
+ updateBulkMenuBar();
activeView = view;
// Top nav active states
const overviewBtn = $("proto-overview");
@@ -2681,6 +2691,7 @@ updatePreview();
renderTxHistory();
renderPoolFooter();
initTooltips();
+initBulkActionListeners();
// ── Overview (cross-protocol dashboard) ───────────────────────────────────────
@@ -2706,6 +2717,55 @@ async function loadOverview() {
const blendPositions: OverviewBlendPosition[] = [];
const vaultPositions: OverviewVaultPosition[] = [];
+ if (demoMode) {
+ const pool = getKnownPools()[0]; // Etherfuse
+ const poolAssets = getPoolAssets(pool);
+ const mockReserves = poolAssets.map(a => ({
+ asset: a, cFactor: a.cFactor, lFactor: 1, interestSupplyApr: 4.2, interestBorrowApr: 6.8,
+ blndSupplyApr: 2.1, blndBorrowApr: 1.5, netSupplyApr: 6.3, netBorrowCost: 5.3,
+ totalSupply: 1000000, totalBorrow: 650000, available: 350000, priceUsd: 1.0,
+ }));
+
+ // Add 3 mock positions for multi-select and shift-click testing
+ blendPositions.push({
+ pool,
+ asset: poolAssets[0], // XLM
+ pos: {
+ asset: poolAssets[0], collateral: 5000, debt: 3000, equity: 2000,
+ leverage: 2.5, hf: 1.15, bTokens: 50000000000n, dTokens: 30000000000n,
+ } as any,
+ reserves: mockReserves as any
+ });
+ blendPositions.push({
+ pool,
+ asset: poolAssets[1], // USDC
+ pos: {
+ asset: poolAssets[1], collateral: 10000, debt: 4000, equity: 6000,
+ leverage: 1.66, hf: 1.8, bTokens: 100000000000n, dTokens: 40000000000n,
+ } as any,
+ reserves: mockReserves as any
+ });
+ blendPositions.push({
+ pool,
+ asset: poolAssets[2], // CETES
+ pos: {
+ asset: poolAssets[2], collateral: 8000, debt: 6000, equity: 2000,
+ leverage: 4.0, hf: 1.05, bTokens: 80000000000n, dTokens: 60000000000n,
+ } as any,
+ reserves: mockReserves as any
+ });
+
+ vaultPositions.push({
+ vault: getVaults()[0],
+ userPos: { balanceShares: 5000n, underlyingValue: 5000 },
+ stats: { totalAssets: 1000000, totalSupply: 1000000, healthFactor: 1.25, strategyAddress: "", underlyingSymbol: "USDC" } as any
+ });
+
+ renderOverview(blendPositions, vaultPositions);
+ _overviewLoading = false;
+ return;
+ }
+
// Fetch all Blend pool positions in parallel
const poolFetches = getKnownPools().map(async (pool) => {
try {
@@ -2745,6 +2805,7 @@ async function loadOverview() {
}
function renderOverview(blendPos: OverviewBlendPosition[], vaultPos: OverviewVaultPosition[]) {
+ _lastOverviewBlendPos = blendPos;
const container = $("ov-protocols");
const emptyEl = $("ov-empty");
const totalPositions = blendPos.length + vaultPos.length;
@@ -2770,6 +2831,8 @@ function renderOverview(blendPos: OverviewBlendPosition[], vaultPos: OverviewVau
if (totalPositions === 0) {
emptyEl.classList.remove("hidden");
container.innerHTML = "";
+ selectedPositions.clear();
+ updateBulkMenuBar();
return;
}
emptyEl.classList.add("hidden");
@@ -2785,6 +2848,7 @@ function renderOverview(blendPos: OverviewBlendPosition[], vaultPos: OverviewVau
+ |
Asset | Pool | Equity |
Leverage | HF |
Net APY | Debt |
@@ -2798,8 +2862,11 @@ function renderOverview(blendPos: OverviewBlendPosition[], vaultPos: OverviewVau
const hfColor = bp.pos.hf > 1.1 ? "hf-ok" : bp.pos.hf > 1.03 ? "hf-warn" : "hf-bad";
const pool = getKnownPools().find(p => p.id === bp.pool.id)!;
const batchTip = `Approximate APY — Blend does not auto-compound. Actual net APR: ${fmt(batchNetApr, 2)}%`;
+ const posKey = `${bp.pool.id}:${bp.asset.id}`;
+ const isChecked = selectedPositions.has(posKey) ? "checked" : "";
html += `
+ |
${bp.asset.symbol} |
${pool.name} |
${fmt(bp.pos.equity, 2)} ${bp.asset.symbol} |
@@ -2845,9 +2912,84 @@ function renderOverview(blendPos: OverviewBlendPosition[], vaultPos: OverviewVau
container.innerHTML = html;
- // Wire up click navigation for Blend table rows
+ // Clean stale selections
+ const validKeys = new Set(blendPos.map(bp => `${bp.pool.id}:${bp.asset.id}`));
+ for (const key of selectedPositions) {
+ if (!validKeys.has(key)) {
+ selectedPositions.delete(key);
+ }
+ }
+
+ // Wire up select-all checkbox header logic
+ const selectAllCheckbox = $("select-all-positions") as HTMLInputElement | null;
+ const rowCheckboxes = Array.from(container.querySelectorAll(".position-select"));
+
+ if (selectAllCheckbox) {
+ selectAllCheckbox.checked = rowCheckboxes.length > 0 && rowCheckboxes.every(cb => cb.checked);
+ selectAllCheckbox.addEventListener("change", () => {
+ rowCheckboxes.forEach(cb => {
+ cb.checked = selectAllCheckbox.checked;
+ const key = `${cb.dataset.pool}:${cb.dataset.asset}`;
+ if (selectAllCheckbox.checked) {
+ selectedPositions.add(key);
+ } else {
+ selectedPositions.delete(key);
+ }
+ });
+ updateBulkMenuBar();
+ });
+ }
+
+ // Wire up checkbox logic including Shift-click ranges
+ let lastCheckedIndex = -1;
+ rowCheckboxes.forEach((cb, index) => {
+ cb.addEventListener("click", (e) => {
+ // Prevent row click navigation when clicking checkbox directly
+ e.stopPropagation();
+ });
+
+ cb.addEventListener("change", (e) => {
+ const key = `${cb.dataset.pool}:${cb.dataset.asset}`;
+ if (cb.checked) {
+ selectedPositions.add(key);
+ } else {
+ selectedPositions.delete(key);
+ }
+
+ // Multi-select with shift-click range selection
+ const mouseEvent = e as unknown as MouseEvent;
+ if (mouseEvent.shiftKey && lastCheckedIndex !== -1) {
+ const start = Math.min(index, lastCheckedIndex);
+ const end = Math.max(index, lastCheckedIndex);
+ for (let i = start; i <= end; i++) {
+ const currentCb = rowCheckboxes[i];
+ currentCb.checked = cb.checked;
+ const currentKey = `${currentCb.dataset.pool}:${currentCb.dataset.asset}`;
+ if (cb.checked) {
+ selectedPositions.add(currentKey);
+ } else {
+ selectedPositions.delete(currentKey);
+ }
+ }
+ }
+
+ lastCheckedIndex = index;
+
+ if (selectAllCheckbox) {
+ selectAllCheckbox.checked = rowCheckboxes.every(cb => cb.checked);
+ }
+
+ updateBulkMenuBar();
+ });
+ });
+
+ // Wire up click navigation for Blend table rows (ignoring click on select columns)
container.querySelectorAll("tr[data-nav-pool]").forEach(row => {
- row.addEventListener("click", () => {
+ row.addEventListener("click", (e) => {
+ const target = e.target as HTMLElement;
+ if (target.closest(".position-select-cell") || target.closest(".position-select")) {
+ return;
+ }
const poolId = row.dataset.navPool!;
const assetId = row.dataset.navAsset!;
const pool = getKnownPools().find(p => p.id === poolId);
@@ -2864,10 +3006,228 @@ function renderOverview(blendPos: OverviewBlendPosition[], vaultPos: OverviewVau
container.querySelectorAll(".overview-vault-card").forEach(card => {
card.addEventListener("click", () => switchView("vault"));
});
+
+ updateBulkMenuBar();
}
$("overview-refresh-btn").addEventListener("click", () => loadOverview());
+// ── Bulk Actions Management ──────────────────────────────────────────────────
+
+let _currentBulkAction: {
+ type: "close" | "adjust";
+ positions: OverviewBlendPosition[];
+ data?: any;
+} | null = null;
+
+function updateBulkMenuBar() {
+ const bar = $("bulk-actions-bar");
+ const countEl = $("bulk-selected-count");
+ if (!bar || !countEl) return;
+
+ const count = selectedPositions.size;
+ countEl.textContent = String(count);
+
+ if (count > 0 && activeView === "overview") {
+ bar.classList.remove("hidden");
+ } else {
+ bar.classList.add("hidden");
+ }
+}
+
+function initBulkActionListeners() {
+ $("bulk-close-btn").addEventListener("click", () => {
+ const targets = getSelectedOverviewPositions();
+ if (targets.length === 0) return;
+ showBulkConfirmModal("close", targets);
+ });
+
+ $("bulk-adjust-btn").addEventListener("click", () => {
+ const targets = getSelectedOverviewPositions();
+ if (targets.length === 0) return;
+ const inputVal = ($("bulk-adjust-input") as HTMLInputElement).value;
+ const percent = parseFloat(inputVal);
+ if (isNaN(percent) || percent === 0) {
+ toast("Please enter a valid percentage (e.g. 10 or -15)", "error");
+ return;
+ }
+ showBulkConfirmModal("adjust", targets, { percent });
+ });
+
+ $("bulk-confirm-close").addEventListener("click", hideBulkConfirmModal);
+ $("bulk-confirm-cancel").addEventListener("click", hideBulkConfirmModal);
+ $("bulk-confirm-submit").addEventListener("click", executeCurrentBulkAction);
+
+ // Close confirmation modal by clicking outside
+ $("bulk-confirm-overlay").addEventListener("click", (e) => {
+ if (e.target === $("bulk-confirm-overlay")) {
+ hideBulkConfirmModal();
+ }
+ });
+}
+
+function getSelectedOverviewPositions(): OverviewBlendPosition[] {
+ const list: OverviewBlendPosition[] = [];
+ selectedPositions.forEach(key => {
+ const bp = _lastOverviewBlendPos.find(x => `${x.pool.id}:${x.asset.id}` === key);
+ if (bp) list.push(bp);
+ });
+ return list;
+}
+
+function hideBulkConfirmModal() {
+ $("bulk-confirm-overlay").classList.add("hidden");
+ _currentBulkAction = null;
+}
+
+function showBulkConfirmModal(type: "close" | "adjust", positions: OverviewBlendPosition[], data?: any) {
+ _currentBulkAction = { type, positions, data };
+ const listEl = $("bulk-confirm-list");
+ listEl.innerHTML = "";
+
+ const titleEl = $("bulk-confirm-title");
+ titleEl.textContent = type === "close" ? "Confirm Bulk Close" : "Confirm Bulk Leverage Adjust";
+
+ positions.forEach(bp => {
+ const rs = bp.reserves.find(r => r.asset.id === bp.asset.id);
+ const pool = getKnownPools().find(p => p.id === bp.pool.id)!;
+ const item = document.createElement("div");
+ item.className = "bulk-confirm-item";
+
+ let detailsHtml = `
+
+ ${bp.asset.symbol}
+ ${pool.name}
+
`;
+
+ let actionHtml = "";
+ if (type === "close") {
+ actionHtml = `CLOSE (Equity: ${fmt(bp.pos.equity, 2)} ${bp.asset.symbol})
`;
+ } else {
+ const pct = data.percent;
+ const targetLev = bp.pos.leverage * (1 + pct / 100);
+ const c = rs ? rs.cFactor : bp.asset.cFactor;
+ const l = rs?.lFactor ?? 1;
+ const targetHf = hfForLeverage(targetLev, c, l);
+ const hfColor = targetHf > 1.1 ? "hf-ok" : targetHf > 1.03 ? "hf-warn" : "hf-bad";
+
+ actionHtml = `
+
+
${bp.pos.leverage.toFixed(1)}× → ${targetLev.toFixed(1)}×
+
Est. HF: ${isFinite(targetHf) ? targetHf.toFixed(3) : "\u221E"}
+
`;
+ }
+
+ item.innerHTML = detailsHtml + actionHtml;
+ listEl.appendChild(item);
+ });
+
+ $("bulk-confirm-overlay").classList.remove("hidden");
+}
+
+async function executeCurrentBulkAction() {
+ if (!_currentBulkAction || !userAddress) return;
+ const { type, positions: targetPositions, data } = _currentBulkAction;
+ hideBulkConfirmModal();
+
+ if (demoMode) {
+ toast("Demo mode \u2014 connect a real wallet to transact", "info");
+ return;
+ }
+
+ // 1. Group by pool
+ const poolMap = new Map();
+
+ for (const bp of targetPositions) {
+ let entry = poolMap.get(bp.pool.id);
+ if (!entry) {
+ entry = { pool: bp.pool, positions: [], adjustments: [] };
+ poolMap.set(bp.pool.id, entry);
+ }
+
+ if (type === "close") {
+ entry.positions.push(bp.pos);
+ } else {
+ const pct = data.percent;
+ const targetLev = bp.pos.leverage * (1 + pct / 100);
+
+ // Safety checks: check target leverage limits
+ const rs = bp.reserves.find(r => r.asset.id === bp.asset.id);
+ const maxLev = maxLeverageFor(rs ? rs.cFactor : bp.asset.cFactor, rs?.lFactor ?? 1, minHF());
+ if (targetLev > maxLev) {
+ toast(`Cannot adjust: target leverage ${targetLev.toFixed(1)}x for ${bp.asset.symbol} exceeds max leverage of ${maxLev.toFixed(1)}x.`, "error");
+ return;
+ }
+ if (targetLev < 1.0) {
+ toast(`Cannot adjust: target leverage for ${bp.asset.symbol} is below 1.0x.`, "error");
+ return;
+ }
+
+ entry.adjustments.push({ asset: bp.asset, pos: bp.pos, targetLev });
+ }
+ }
+
+ const poolGroups = Array.from(poolMap.values());
+ if (poolGroups.length === 0) return;
+
+ // Show stepper
+ const steps = poolGroups.map(pg => {
+ const actionLabel = type === "close" ? "Close" : "Adjust";
+ const assetsLabel = type === "close"
+ ? pg.positions.map(p => p.asset.symbol).join(", ")
+ : pg.adjustments.map(a => a.asset.symbol).join(", ");
+ return `${actionLabel} ${assetsLabel} on ${pg.pool.name}`;
+ });
+
+ showTxStepper(steps);
+
+ try {
+ let bulkXdr = "";
+ if (type === "close") {
+ const poolPositionsParam = poolGroups.map(pg => ({ pool: pg.pool, positions: pg.positions }));
+ bulkXdr = await buildBulkCloseXdr(poolPositionsParam, userAddress);
+ } else {
+ const poolAdjustmentsParam = poolGroups.map(pg => ({ pool: pg.pool, adjustments: pg.adjustments }));
+ bulkXdr = await buildBulkAdjustXdr(poolAdjustmentsParam, userAddress);
+ }
+
+ toast(`Signing transaction bundle...`, "info");
+ updateTxStep(0, "active");
+
+ const { signedTxXdr } = await StellarWalletsKit.signTransaction(bulkXdr, {
+ networkPassphrase: getNetworkPassphrase(),
+ address: userAddress,
+ });
+
+ toast(`Submitting transaction bundle...`, "info");
+ const hash = await submitSignedXdr(signedTxXdr);
+
+ // Mark all stepper steps as done since it's a single atomic transaction bundle
+ for (let i = 0; i < steps.length; i++) {
+ updateTxStep(i, "done");
+ }
+
+ toast(`Bulk action confirmed!`, "success", hash);
+ addTxToHistory(type === "close" ? "Bulk Close" : "Bulk Leverage Adjust", hash, "success");
+
+ if (type === "close") {
+ targetPositions.forEach(bp => {
+ removePnlEntry(bp.asset.id, bp.pool.id);
+ });
+ }
+
+ hideTxStepper();
+ selectedPositions.clear();
+ updateBulkMenuBar();
+ await loadOverview();
+ } catch (e: any) {
+ const msg = e?.message ?? String(e);
+ markStepperError(steps.length);
+ toast(`Bulk action failed: ${msg.slice(0, 200)}`, "error");
+ }
+}
+
+
// ── Vault view ───────────────────────────────────────────────────────────────
function getActiveVault(): VaultConfig {
diff --git a/frontend/src/style.css b/frontend/src/style.css
index 65725c3..9c2bafd 100644
--- a/frontend/src/style.css
+++ b/frontend/src/style.css
@@ -1291,6 +1291,131 @@ main { flex: 1; max-width: 1200px; width: 100%; margin: 0 auto; padding: 20px 24
.skeleton { animation: none; background: var(--metric-bg); }
}
+/* ── Bulk Actions & Checkboxes ───────────────────────────────────────────── */
+
+.position-select-cell {
+ width: 40px;
+ text-align: center;
+ padding: 8px 0;
+}
+
+.overview-table th.position-select-cell {
+ cursor: default;
+}
+
+.position-select-cell input[type="checkbox"] {
+ width: 16px;
+ height: 16px;
+ cursor: pointer;
+ accent-color: var(--primary);
+}
+
+.bulk-actions-bar {
+ position: fixed;
+ bottom: 24px;
+ left: 50%;
+ transform: translateX(-50%);
+ z-index: 240;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 24px;
+ padding: 12px 24px;
+ background: var(--surface-solid);
+ border: 1px solid var(--border);
+ border-radius: var(--r);
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
+ animation: slide-up .25s ease;
+ color: var(--text);
+ backdrop-filter: blur(8px);
+}
+
+.bulk-actions-info {
+ font-size: 13px;
+ font-weight: 700;
+ color: var(--text-2);
+}
+
+#bulk-selected-count {
+ color: var(--primary);
+ font-family: var(--mono);
+}
+
+.bulk-actions-buttons {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+}
+
+.bulk-adjust-group {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--text-2);
+}
+
+.bulk-confirm-item {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 10px 8px;
+ font-size: 13px;
+ border-bottom: 1px solid var(--border);
+}
+
+.bulk-confirm-item:last-child {
+ border-bottom: none;
+}
+
+.bulk-confirm-details {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ text-align: left;
+}
+
+.bulk-confirm-asset {
+ font-family: var(--mono);
+ font-weight: 700;
+ color: var(--text);
+}
+
+.bulk-confirm-pool {
+ font-size: 11px;
+ color: var(--text-3);
+}
+
+.bulk-confirm-action {
+ font-family: var(--mono);
+ font-weight: 600;
+ text-align: right;
+}
+
+@media (max-width: 600px) {
+ .bulk-actions-bar {
+ width: calc(100% - 24px);
+ flex-direction: column;
+ gap: 12px;
+ padding: 12px;
+ bottom: 12px;
+ }
+ .bulk-actions-buttons {
+ width: 100%;
+ flex-direction: column;
+ gap: 8px;
+ }
+ .bulk-actions-buttons button {
+ width: 100%;
+ }
+ .bulk-adjust-group {
+ width: 100%;
+ justify-content: space-between;
+ }
+}
+
+
/* ── Keyboard shortcut table (A12) ─────────────────────────────────────────── */
.shortcut-table { width: 100%; border-collapse: collapse; margin: 1rem 0; }
.shortcut-table td { padding: 0.4rem 0.6rem; }
diff --git a/frontend/test/bulk_tx.test.ts b/frontend/test/bulk_tx.test.ts
new file mode 100644
index 0000000..bac68cb
--- /dev/null
+++ b/frontend/test/bulk_tx.test.ts
@@ -0,0 +1,167 @@
+import { describe, it, expect, vi } from 'vitest';
+import { Account, Transaction } from '@stellar/stellar-sdk';
+
+// Mock @stellar/stellar-sdk
+vi.mock('@stellar/stellar-sdk', async (importActual) => {
+ const actual = await importActual();
+ return {
+ ...actual,
+ rpc: {
+ ...actual.rpc,
+ assembleTransaction: (tx: any, sim: any) => ({
+ build: () => tx
+ }),
+ Api: {
+ ...actual.rpc.Api,
+ isSimulationSuccess: () => true
+ }
+ }
+ };
+});
+
+import { buildBulkCloseXdr, buildBulkAdjustXdr, server } from '../src/blend';
+
+// Mock server methods
+vi.spyOn(server, 'getAccount').mockImplementation(async (address: string) => {
+ return new Account(address, '0');
+});
+
+vi.spyOn(server, 'simulateTransaction').mockImplementation(async (tx: Transaction) => {
+ return {
+ error: null,
+ results: [],
+ transactionData: null,
+ minResourceFee: '100',
+ events: [],
+ restorePreamble: null,
+ } as any;
+});
+
+const mockPool = {
+ id: "CDMAVJPFXPADND3YRL4BSM3AKZWCTFMX27GLLXCML3PD62HEQS5FPVAI",
+ name: "Etherfuse",
+ oracleId: "CAVRP26CWW6IUEXBRA3Q2T2SHBUVBC2DF43M4E23LEZGW5ZEIB62HALS",
+ oracleDec: 1e14,
+ backstopFP: 2000000,
+ status: 1,
+ assetIds: ["CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA", "CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75"]
+};
+
+const mockAssetXLM = {
+ id: "CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA",
+ symbol: "XLM",
+ name: "Stellar Lumens",
+ decimals: 7,
+ reserveIndex: 0,
+ supplyTokenId: 1,
+ borrowTokenId: 0,
+ cFactor: 0.75,
+ maxUtil: 0.70
+};
+
+const mockAssetUSDC = {
+ id: "CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75",
+ symbol: "USDC",
+ name: "USD Coin",
+ decimals: 7,
+ reserveIndex: 1,
+ supplyTokenId: 3,
+ borrowTokenId: 2,
+ cFactor: 0.95,
+ maxUtil: 0.95
+};
+
+describe('Bulk transaction building unit tests', () => {
+ const userAddress = 'GDJEHTBE6ZHUXSWFI642DCGLUOECLHPF3KSXHPXTSTJ7E3JF6MQ5EZYY';
+
+ it('buildBulkCloseXdr should successfully construct XDR for multiple positions in a pool', async () => {
+ const positions = [
+ {
+ asset: mockAssetXLM,
+ bTokens: 1000n,
+ dTokens: 500n,
+ bRate: 1n,
+ dRate: 1n,
+ collateral: 10,
+ debt: 5,
+ equity: 5,
+ leverage: 2,
+ hf: 1.5
+ },
+ {
+ asset: mockAssetUSDC,
+ bTokens: 2000n,
+ dTokens: 0n,
+ bRate: 1n,
+ dRate: 1n,
+ collateral: 20,
+ debt: 0,
+ equity: 20,
+ leverage: 1,
+ hf: 999
+ }
+ ];
+
+ const xdrResult = await buildBulkCloseXdr([{ pool: mockPool, positions }], userAddress);
+ expect(xdrResult).toBeDefined();
+
+ // Deserialize and check transaction structure
+ const tx = new Transaction(xdrResult, 'Test SDF Network ; September 2015');
+ expect(tx.operations.length).toBe(1); // One pooled contract call operation
+ expect(tx.operations[0].type).toBe('invokeHostFunction');
+ });
+
+ it('buildBulkAdjustXdr should build borrow/supply requests when leverage increases', async () => {
+ const adjustments = [
+ {
+ asset: mockAssetXLM,
+ pos: {
+ asset: mockAssetXLM,
+ bTokens: 1000n,
+ dTokens: 500n,
+ bRate: 1n,
+ dRate: 1n,
+ collateral: 10,
+ debt: 5,
+ equity: 5,
+ leverage: 2,
+ hf: 1.5
+ },
+ targetLev: 3 // Leverage increase
+ }
+ ];
+
+ const xdrResult = await buildBulkAdjustXdr([{ pool: mockPool, adjustments }], userAddress);
+ expect(xdrResult).toBeDefined();
+
+ const tx = new Transaction(xdrResult, 'Test SDF Network ; September 2015');
+ expect(tx.operations.length).toBe(1);
+ });
+
+ it('buildBulkAdjustXdr should build withdraw/repay requests when leverage decreases', async () => {
+ const adjustments = [
+ {
+ asset: mockAssetXLM,
+ pos: {
+ asset: mockAssetXLM,
+ bTokens: 1500n,
+ dTokens: 1000n,
+ bRate: 1n,
+ dRate: 1n,
+ collateral: 15,
+ debt: 10,
+ equity: 5,
+ leverage: 3,
+ hf: 1.2
+ },
+ targetLev: 2 // Leverage decrease
+ }
+ ];
+
+ const xdrResult = await buildBulkAdjustXdr([{ pool: mockPool, adjustments }], userAddress);
+ expect(xdrResult).toBeDefined();
+
+ const tx = new Transaction(xdrResult, 'Test SDF Network ; September 2015');
+ expect(tx.operations.length).toBe(1);
+ });
+});