Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions backend/app/services/fee_estimation.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@

from __future__ import annotations

import logging
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Optional

logger = logging.getLogger(__name__)


@dataclass
class FeeComponent:
Expand All @@ -22,6 +25,7 @@ class FeeEstimate:
asset: str
components: list[FeeComponent]
estimated_at: str
warning: Optional[str] = None

def to_dict(self) -> dict:
return {
Expand All @@ -38,6 +42,7 @@ def to_dict(self) -> dict:
for c in self.components
],
"estimated_at": self.estimated_at,
"warning": self.warning,
}


Expand Down Expand Up @@ -123,12 +128,15 @@ def estimate_chain_fee(self, chain: str) -> FeeEstimate:
chain_lower = chain.lower()
config = CHAIN_FEE_CONFIG.get(chain_lower)
if not config:
msg = f"Chain '{chain_lower}' is not supported for fee estimation."
logger.warning(msg)
return FeeEstimate(
chain=chain_lower,
total_fee=0.0,
asset="UNKNOWN",
components=[],
estimated_at=datetime.now(timezone.utc).isoformat(),
warning=msg,
)

network_fee = FeeComponent(
Expand Down
9 changes: 9 additions & 0 deletions backend/app/services/price_oracle.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
PRICE_CACHE_TTL = 60
# Maximum acceptable deviation between sources (10%)
MAX_PRICE_DEVIATION = 0.10
# Maximum price history entries retained per asset
MAX_HISTORY_PER_ASSET = 500


@dataclass
Expand Down Expand Up @@ -260,5 +262,12 @@ def _record_history(self, price_data: PriceData) -> None:
timestamp=price_data.timestamp,
)
)
# Per-asset cap: remove oldest entries for this asset when over limit
asset_indices = [i for i, e in enumerate(self._history) if e.asset == price_data.asset]
if len(asset_indices) > MAX_HISTORY_PER_ASSET:
to_remove = len(asset_indices) - MAX_HISTORY_PER_ASSET
for i in reversed(asset_indices[:to_remove]):
self._history.pop(i)
# Global cap
if len(self._history) > 5000:
self._history = self._history[-5000:]
2 changes: 2 additions & 0 deletions backend/tests/test_fee_estimation.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ def test_estimate_unknown_chain(self, service):
assert estimate.chain == "unknown"
assert estimate.total_fee == 0.0
assert len(estimate.components) == 0
assert estimate.warning is not None
assert "not supported" in estimate.warning

def test_fee_components_structure(self, service):
estimate = service.estimate_chain_fee("stellar")
Expand Down
17 changes: 17 additions & 0 deletions backend/tests/test_price_oracle.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,23 @@ async def test_history_filter_by_asset(self, service):
history = service.get_price_history(asset="XLM")
assert all(r["asset"] == "XLM" for r in history)

@pytest.mark.anyio
async def test_per_asset_history_is_bounded(self, service):
from app.services.price_oracle import MAX_HISTORY_PER_ASSET, PriceHistoryEntry

for _ in range(MAX_HISTORY_PER_ASSET + 10):
service._history.append(
PriceHistoryEntry(
asset="XLM", price_usd=0.15, source="test", timestamp="t"
)
)

with patch.object(service, "_fetch_coingecko", return_value=0.15):
await service.get_price("XLM")

xlm_count = sum(1 for e in service._history if e.asset == "XLM")
assert xlm_count <= MAX_HISTORY_PER_ASSET


class TestAlerts:
@pytest.mark.anyio
Expand Down
56 changes: 56 additions & 0 deletions frontend/e2e/swap-smoke.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { test, expect } from "@playwright/test";

/**
* Happy-path smoke test for the swap form at /swap.
* No real wallet required — verifies UI state without wallet dependency.
* Issue #427
*/

const SWAP_PAGE = "/swap";

test.describe("Swap form — happy-path smoke (no wallet)", () => {
test.beforeEach(async ({ page }) => {
await page.goto(SWAP_PAGE);
});

test("page renders form heading", async ({ page }) => {
await expect(page.getByText("Create Swap")).toBeVisible();
});

test("From and To chain-asset selectors are visible", async ({ page }) => {
await expect(page.getByText("From").first()).toBeVisible();
await expect(page.getByText("To").first()).toBeVisible();
});

test("default route shows stellar XLM as source and bitcoin BTC as destination", async ({
page,
}) => {
// ChainAssetSelector renders the selected asset symbol as button text
await expect(page.getByRole("button", { name: /XLM/i }).first()).toBeVisible();
await expect(page.getByRole("button", { name: /BTC/i }).first()).toBeVisible();
});

test("amount input accepts a numeric value", async ({ page }) => {
const amountInput = page.locator('input[type="number"]').first();
await expect(amountInput).toBeVisible();
await amountInput.fill("10");
await expect(amountInput).toHaveValue("10");
});

test("submit button is disabled when no wallet is connected", async ({ page }) => {
const submit = page.getByRole("button", { name: /Connect Wallet to Swap/i });
await expect(submit).toBeVisible();
await expect(submit).toBeDisabled();
});

test("wallet-connect hint is visible when no wallet connected", async ({ page }) => {
await expect(page.getByText("Connect a wallet to continue.")).toBeVisible();
});

test("submit remains disabled after entering a valid amount with no wallet", async ({ page }) => {
await page.locator('input[type="number"]').first().fill("5");
// isConnected is always false in a no-wallet test — canSubmit stays false
const submit = page.getByRole("button", { name: /Connect Wallet to Swap/i });
await expect(submit).toBeDisabled();
});
});
13 changes: 13 additions & 0 deletions scripts/verify.sh
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SMARTCONTRACT_DIR="${SCRIPT_DIR}/../smartcontract"
RELAYER_DIR="${SCRIPT_DIR}/../relayer"

# ---------------------------------------------------------------------------
# Static analysis: run cargo clippy on the smart contract
Expand All @@ -33,6 +34,18 @@ fi
(cd "${SMARTCONTRACT_DIR}" && cargo clippy -- -D warnings)
echo " PASS cargo clippy passed"
echo ""

# ---------------------------------------------------------------------------
# Static analysis: run cargo clippy on the relayer
# ---------------------------------------------------------------------------
echo "============================================"
echo "Running cargo clippy on relayer"
echo "============================================"

(cd "${RELAYER_DIR}" && cargo clippy -- -D warnings)
echo " PASS cargo clippy passed (relayer)"
echo ""

CONFIG_FILE="${SCRIPT_DIR}/config/testnet.env"
CONTRACT_ID=""

Expand Down
Loading