From 1a3af7618d9efb66d6efd10f16a25ccc21a96a19 Mon Sep 17 00:00:00 2001 From: Oluwatimileyin Oyelabi Date: Sun, 31 May 2026 16:37:56 +0100 Subject: [PATCH] feat: Add bulk position management and batch transaction building --- frontend/index.html | 32 +++ frontend/src/blend.ts | 98 +++++++++ frontend/src/main.ts | 364 +++++++++++++++++++++++++++++++++- frontend/src/style.css | 125 ++++++++++++ frontend/test/bulk_tx.test.ts | 167 ++++++++++++++++ 5 files changed, 784 insertions(+), 2 deletions(-) create mode 100644 frontend/test/bulk_tx.test.ts diff --git a/frontend/index.html b/frontend/index.html index f904f23..390538d 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -795,6 +795,38 @@

APY Alerts

+ + + + + + diff --git a/frontend/src/blend.ts b/frontend/src/blend.ts index 1c4ad6c..9cfaf4f 100644 --- a/frontend/src/blend.ts +++ b/frontend/src/blend.ts @@ -1340,3 +1340,101 @@ export async function submitClassicXdr(signedXdr: string): Promise { const result = await horizon.submitTransaction(tx); 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(); +} + diff --git a/frontend/src/main.ts b/frontend/src/main.ts index fcc1ceb..30f0f85 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -42,6 +42,8 @@ import { estimateBlndSwap, submitSignedXdr, submitClassicXdr, + buildBulkCloseXdr, + buildBulkAdjustXdr, hfForLeverage, maxLeverageFor, type NetworkMode, @@ -90,6 +92,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) { @@ -119,6 +125,8 @@ async function switchNetwork(net: NetworkMode) { // Reset state reserves = []; positions = { byAsset: new Map() }; + selectedPositions.clear(); + updateBulkMenuBar(); demoMode = false; selectedPool = getKnownPools()[0]; assets = getPoolAssets(selectedPool); @@ -1762,6 +1770,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"); @@ -2260,6 +2270,7 @@ updatePreview(); renderTxHistory(); renderPoolFooter(); initTooltips(); +initBulkActionListeners(); // ── Overview (cross-protocol dashboard) ─────────────────────────────────────── @@ -2285,6 +2296,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 { @@ -2324,6 +2384,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; @@ -2349,6 +2410,8 @@ function renderOverview(blendPos: OverviewBlendPosition[], vaultPos: OverviewVau if (totalPositions === 0) { emptyEl.classList.remove("hidden"); container.innerHTML = ""; + selectedPositions.clear(); + updateBulkMenuBar(); return; } emptyEl.classList.add("hidden"); @@ -2364,6 +2427,7 @@ function renderOverview(blendPos: OverviewBlendPosition[], vaultPos: OverviewVau + @@ -2377,8 +2441,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 += ` + @@ -2424,9 +2491,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); @@ -2443,10 +2585,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 0d4348f..951d551 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -1210,3 +1210,128 @@ main { flex: 1; max-width: 1200px; width: 100%; margin: 0 auto; padding: 20px 24 *, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; } .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; + } +} + 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); + }); +});
AssetPoolEquity LeverageHF Net APYDebt
${bp.asset.symbol} ${pool.name} ${fmt(bp.pos.equity, 2)} ${bp.asset.symbol}