Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""Add amendment_count and amendment_log to swap_orders."""

from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql

revision: str = "20260601_0005"
down_revision: Union[str, None] = "20260530_0004"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
op.add_column(
"swap_orders",
sa.Column(
"amendment_count",
sa.Integer(),
nullable=False,
server_default="0",
),
)
op.add_column(
"swap_orders",
sa.Column(
"amendment_log",
postgresql.JSONB(astext_type=sa.Text()),
nullable=False,
server_default="[]",
),
)


def downgrade() -> None:
op.drop_column("swap_orders", "amendment_log")
op.drop_column("swap_orders", "amendment_count")
4 changes: 3 additions & 1 deletion backend/app/models/order.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import uuid
from sqlalchemy import Column, String, BigInteger, Integer, ForeignKey
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.dialects.postgresql import UUID, JSONB
from .base import Base, TimestampMixin


Expand All @@ -27,3 +27,5 @@ class SwapOrder(Base, TimestampMixin):
expiry = Column(BigInteger, nullable=False, index=True)
status = Column(String, nullable=False, default="open", index=True)
counterparty = Column(String, nullable=True)
amendment_count = Column(Integer, nullable=False, default=0)
amendment_log = Column(JSONB, nullable=False, default=list)
62 changes: 60 additions & 2 deletions backend/app/routes/orders.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Order book endpoints: create, list, match, cancel (#26, #59)."""
"""Order book endpoints: create, list, match, cancel, amend (#26, #59, #512)."""

from datetime import datetime, timezone
from typing import Annotated, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select
Expand All @@ -8,7 +9,7 @@
from app.config.database import get_db
from app.config.redis import get_redis, CacheService
from app.models.order import SwapOrder
from app.schemas.order import OrderCreate, OrderResponse, OrderMatch
from app.schemas.order import OrderAmend, OrderCreate, OrderResponse, OrderMatch
from app.middleware.auth import require_api_key
from app.services.order_matching import OrderMatchingService
from app.ws.events import emit_order_event, EventType
Expand Down Expand Up @@ -148,6 +149,63 @@ async def match_order(
return response


@router.patch("/{order_id}/amend", response_model=OrderResponse)
async def amend_order(
order_id: str,
data: OrderAmend,
db: AsyncSession = Depends(get_db),
_=Depends(require_api_key),
):
result = await db.execute(select(SwapOrder).where(SwapOrder.id == order_id))
order = result.scalar_one_or_none()
if not order:
raise HTTPException(status_code=404, detail="Order not found")
if order.status != "open":
raise HTTPException(status_code=400, detail="Only open orders can be amended")

changes: dict = {}
if data.from_amount is not None and data.from_amount != order.from_amount:
changes["from_amount"] = {"before": int(order.from_amount), "after": data.from_amount}
order.from_amount = data.from_amount
if data.to_amount is not None and data.to_amount != order.to_amount:
changes["to_amount"] = {"before": int(order.to_amount), "after": data.to_amount}
order.to_amount = data.to_amount
if data.min_fill_amount is not None and data.min_fill_amount != order.min_fill_amount:
changes["min_fill_amount"] = {
"before": int(order.min_fill_amount) if order.min_fill_amount is not None else None,
"after": data.min_fill_amount,
}
order.min_fill_amount = data.min_fill_amount
if data.expiry is not None and data.expiry != order.expiry:
changes["expiry"] = {"before": int(order.expiry), "after": data.expiry}
order.expiry = data.expiry

if not changes:
raise HTTPException(status_code=400, detail="No fields changed")

entry = {
"sequence": int(order.amendment_count or 0) + 1,
"amended_at": datetime.now(timezone.utc).isoformat(),
"changes": changes,
}
if data.note:
entry["note"] = data.note

order.amendment_count = int(order.amendment_count or 0) + 1
order.amendment_log = list(order.amendment_log or []) + [entry]

await db.commit()
await db.refresh(order)

redis = get_redis()
cache = CacheService(redis)
await cache.invalidate_pattern("orders:*")

response = OrderResponse.model_validate(order)
await emit_order_event(redis, EventType.ORDER_UPDATED, response.model_dump())
return response


@router.post("/{order_id}/cancel", response_model=OrderResponse)
async def cancel_order(
order_id: str, db: AsyncSession = Depends(get_db), _=Depends(require_api_key)
Expand Down
28 changes: 27 additions & 1 deletion backend/app/schemas/order.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from pydantic import BaseModel, Field, field_validator, model_validator
from typing import Optional
from typing import Any, Optional
from datetime import datetime

from app.utils.address_validation import (
Expand Down Expand Up @@ -40,6 +40,30 @@ def validate_creator_address(self):
return self


class OrderAmend(BaseModel):
from_amount: Optional[int] = Field(default=None, gt=0)
to_amount: Optional[int] = Field(default=None, gt=0)
min_fill_amount: Optional[int] = Field(default=None, gt=0)
expiry: Optional[int] = Field(default=None, gt=0)
note: Optional[str] = None

@model_validator(mode="after")
def at_least_one_field(self):
if all(
v is None
for v in (self.from_amount, self.to_amount, self.min_fill_amount, self.expiry)
):
raise ValueError("At least one amendable field must be provided")
return self


class OrderAmendmentEntry(BaseModel):
sequence: int
amended_at: str
changes: dict[str, Any]
note: Optional[str] = None


class OrderMatch(BaseModel):
counterparty: str
fill_amount: Optional[int] = None
Expand Down Expand Up @@ -69,6 +93,8 @@ class OrderResponse(BaseModel):
status: str
counterparty: Optional[str] = None
created_at: Optional[datetime] = None
amendment_count: int = 0
amendment_log: list[dict[str, Any]] = []

class Config:
from_attributes = True
1 change: 1 addition & 0 deletions backend/app/ws/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ class EventType(str, Enum):
ORDER_MATCHED = "order.matched"
ORDER_CANCELLED = "order.cancelled"
ORDER_FILLED = "order.filled"
ORDER_UPDATED = "order.updated"


def _build_event(event_type: EventType, channel: str, data: Any) -> str:
Expand Down
102 changes: 101 additions & 1 deletion backend/tests/test_orders.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from unittest.mock import AsyncMock, MagicMock

from app.models.order import SwapOrder
from app.schemas.order import OrderResponse
from app.schemas.order import OrderAmend, OrderResponse
from app.services.order_matching import OrderMatchingService


Expand All @@ -28,6 +28,8 @@ def make_order(**overrides):
"status": "open",
"counterparty": None,
"created_at": now,
"amendment_count": 0,
"amendment_log": [],
}
values.update(overrides)
order = SwapOrder()
Expand All @@ -54,6 +56,104 @@ def test_order_response_from_dict(self):
resp = OrderResponse(**data)
assert resp.id == "order-001"
assert resp.status == "open"
assert resp.amendment_count == 0
assert resp.amendment_log == []

def test_order_response_includes_amendment_fields(self):
log_entry = {
"sequence": 1,
"amended_at": "2026-06-01T10:00:00+00:00",
"changes": {"from_amount": {"before": 50, "after": 100}},
}
data = {
"id": "order-002",
"creator": "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN7",
"from_chain": "stellar",
"to_chain": "ethereum",
"from_asset": "XLM",
"to_asset": "USDC",
"from_amount": 100,
"to_amount": 200,
"filled_amount": 0,
"expiry": 9999999999,
"status": "open",
"amendment_count": 1,
"amendment_log": [log_entry],
}
resp = OrderResponse(**data)
assert resp.amendment_count == 1
assert len(resp.amendment_log) == 1
assert resp.amendment_log[0]["sequence"] == 1


class TestOrderAmendSchema:
def test_valid_amend_single_field(self):
data = OrderAmend(from_amount=500)
assert data.from_amount == 500
assert data.to_amount is None

def test_valid_amend_multiple_fields(self):
data = OrderAmend(from_amount=300, to_amount=600, note="repriced")
assert data.from_amount == 300
assert data.to_amount == 600
assert data.note == "repriced"

def test_rejects_zero_fields_changed(self):
with pytest.raises(ValueError, match="At least one amendable field must be provided"):
OrderAmend()

def test_rejects_zero_amounts(self):
with pytest.raises(ValueError):
OrderAmend(from_amount=0)

def test_expiry_amend(self):
data = OrderAmend(expiry=9999999999)
assert data.expiry == 9999999999


class TestAmendOrderLogic:
def test_amendment_increments_count(self):
order = make_order()
assert order.amendment_count == 0

order.from_amount = 150
order.amendment_count = order.amendment_count + 1
entry = {
"sequence": 1,
"amended_at": datetime.now(timezone.utc).isoformat(),
"changes": {"from_amount": {"before": 100, "after": 150}},
}
order.amendment_log = list(order.amendment_log) + [entry]

assert order.amendment_count == 1
assert len(order.amendment_log) == 1
assert order.amendment_log[0]["sequence"] == 1

def test_multiple_amendments_append_in_order(self):
order = make_order()
now = datetime.now(timezone.utc)

for i in range(1, 4):
order.amendment_count = i
entry = {
"sequence": i,
"amended_at": now.isoformat(),
"changes": {"from_amount": {"before": i * 10, "after": (i + 1) * 10}},
}
order.amendment_log = list(order.amendment_log) + [entry]

assert order.amendment_count == 3
assert [e["sequence"] for e in order.amendment_log] == [1, 2, 3]

def test_no_changes_does_not_amend(self):
order = make_order(from_amount=100)
amend = OrderAmend(from_amount=100, to_amount=200)
changes = {}
if amend.from_amount is not None and amend.from_amount != order.from_amount:
changes["from_amount"] = {"before": order.from_amount, "after": amend.from_amount}
if amend.to_amount is not None and amend.to_amount != order.to_amount:
changes["to_amount"] = {"before": order.to_amount, "after": amend.to_amount}
assert changes == {}


class TestOrderMatchingService:
Expand Down
Loading