From 6db3a7148f7615eb09c5d5f478974e5668363d72 Mon Sep 17 00:00:00 2001 From: TYDev01 Date: Mon, 1 Jun 2026 14:29:49 +0100 Subject: [PATCH 1/2] feat(orders): add amendment history visibility (#512) --- ...260601_0005_add_order_amendment_columns.py | 38 +++ backend/app/models/order.py | 4 +- backend/app/routes/orders.py | 62 +++- backend/app/schemas/order.py | 28 +- backend/app/ws/events.py | 1 + backend/tests/test_orders.py | 102 ++++++- frontend/src/app/(orders)/orders/page.tsx | 277 +++++++++++++----- frontend/src/hooks/useOrderBook.ts | 65 +++- frontend/src/lib/api/orders.ts | 10 + frontend/src/lib/api/schemas.ts | 14 + frontend/src/types/api.ts | 22 ++ frontend/src/types/index.ts | 15 + 12 files changed, 553 insertions(+), 85 deletions(-) create mode 100644 backend/alembic/versions/20260601_0005_add_order_amendment_columns.py diff --git a/backend/alembic/versions/20260601_0005_add_order_amendment_columns.py b/backend/alembic/versions/20260601_0005_add_order_amendment_columns.py new file mode 100644 index 00000000..de646329 --- /dev/null +++ b/backend/alembic/versions/20260601_0005_add_order_amendment_columns.py @@ -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") diff --git a/backend/app/models/order.py b/backend/app/models/order.py index 8631ad4b..766af46e 100644 --- a/backend/app/models/order.py +++ b/backend/app/models/order.py @@ -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 @@ -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) diff --git a/backend/app/routes/orders.py b/backend/app/routes/orders.py index a38858b7..d3c502d2 100644 --- a/backend/app/routes/orders.py +++ b/backend/app/routes/orders.py @@ -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 @@ -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 @@ -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) diff --git a/backend/app/schemas/order.py b/backend/app/schemas/order.py index e725e52e..0005d62f 100644 --- a/backend/app/schemas/order.py +++ b/backend/app/schemas/order.py @@ -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 ( @@ -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 @@ -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 diff --git a/backend/app/ws/events.py b/backend/app/ws/events.py index 178d7042..7c105c8a 100644 --- a/backend/app/ws/events.py +++ b/backend/app/ws/events.py @@ -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: diff --git a/backend/tests/test_orders.py b/backend/tests/test_orders.py index 751b6dc5..779d9e34 100644 --- a/backend/tests/test_orders.py +++ b/backend/tests/test_orders.py @@ -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 @@ -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() @@ -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: diff --git a/frontend/src/app/(orders)/orders/page.tsx b/frontend/src/app/(orders)/orders/page.tsx index b7567232..68c49ab1 100644 --- a/frontend/src/app/(orders)/orders/page.tsx +++ b/frontend/src/app/(orders)/orders/page.tsx @@ -5,8 +5,11 @@ import Link from "next/link"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { BookmarkPlus, + ChevronDown, + ChevronUp, Clock3, Filter, + History, LayoutGrid, Rows3, RefreshCw, @@ -20,7 +23,7 @@ import { import { Button, Card, EmptyState, Input, Spinner, ToastContainer } from "@/components/ui"; import { WalletConnect } from "@/components/swap/WalletConnect"; import { DEMO_ORDER_OWNER, useMockOrders, useOrderBookStore } from "@/hooks/useOrderBook"; -import { Order, OrderStatus } from "@/types"; +import { Order, OrderAmendmentEntry, OrderStatus } from "@/types"; import { cn } from "@/lib/utils"; import { shortenHash } from "@/lib/format"; import { AdvancedFilterDrawer } from "@/components/filters/AdvancedFilterDrawer"; @@ -99,6 +102,7 @@ export default function OrdersPage() { const [pendingCancelId, setPendingCancelId] = useState(null); const [orderToCancel, setOrderToCancel] = useState(null); const [toasts, setToasts] = useState([]); + const [expandedAmendmentId, setExpandedAmendmentId] = useState(null); useEffect(() => { seedMockOrders(ownerAddress); @@ -459,89 +463,134 @@ export default function OrdersPage() { } /> ) : ( - visibleOrders.map((order) => ( - -
-
-
- {order.pair} - { + const isAmendmentExpanded = expandedAmendmentId === order.id; + const hasAmendments = (order.amendmentCount ?? 0) > 0; + return ( + +
+
+
+ {order.pair} + + {order.derivedStatus} + + {hasAmendments && ( + + + {order.amendmentCount} {order.amendmentCount === 1 ? "amendment" : "amendments"} + )} - > - {order.derivedStatus} - - {pendingCancelId === order.id && ( -
- - Cancelling -
- )} -
-
-

Maker: {shortAddress(order.maker)}

-

- Chain Route: {order.chainIn} to {order.chainOut} -

-

- Size: {order.amount} {order.tokenIn} -

-

- Total: {order.total} {order.tokenOut} -

-

- Expires:{" "} - {order.expiresAt ? new Date(order.expiresAt).toLocaleString() : "Not set"} -

-

Created: {new Date(order.timestamp).toLocaleString()}

+ {pendingCancelId === order.id && ( +
+ + Cancelling +
+ )} +
+
+

Maker: {shortAddress(order.maker)}

+

+ Chain Route: {order.chainIn} to {order.chainOut} +

+

+ Size: {order.amount} {order.tokenIn} +

+

+ Total: {order.total} {order.tokenOut} +

+

+ Expires:{" "} + {order.expiresAt ? new Date(order.expiresAt).toLocaleString() : "Not set"} +

+

Created: {new Date(order.timestamp).toLocaleString()}

+
-
-
-
-

- Order Summary -

-

- Type: {order.orderType ?? "limit"} -

-

- Partial fills:{" "} - - {order.allowPartialFills ? "Enabled" : "Disabled"} - -

-
+
+
+

+ Order Summary +

+

+ Type: {order.orderType ?? "limit"} +

+

+ Partial fills:{" "} + + {order.allowPartialFills ? "Enabled" : "Disabled"} + +

+ {hasAmendments && ( +

+ Last amended:{" "} + + {new Date( + order.amendmentLog![order.amendmentLog!.length - 1].amended_at + ).toLocaleString()} + +

+ )} +
+ + {hasAmendments && ( + + )} - + +
-
- - )) + + {isAmendmentExpanded && order.amendmentLog && order.amendmentLog.length > 0 && ( + + )} + + ); + }) )}
@@ -716,6 +765,76 @@ export default function OrdersPage() { ); } +const FIELD_LABELS: Record = { + from_amount: "Send amount", + to_amount: "Receive amount", + min_fill_amount: "Min fill", + expiry: "Expiry", +}; + +function AmendmentHistory({ entries }: { entries: OrderAmendmentEntry[] }) { + const sorted = [...entries].sort((a, b) => b.sequence - a.sequence); + return ( +
+

+ + Amendment History +

+
    + {sorted.map((entry, index) => ( +
  1. +
    + + {entry.sequence} + + {index < sorted.length - 1 && ( +
    + )} +
    +
    +
    + + {new Date(entry.amended_at).toLocaleString()} + + {index === 0 && ( + + Latest + + )} +
    +
      + {Object.entries(entry.changes).map(([field, change]) => ( +
    • + + {FIELD_LABELS[field] ?? field} + + {": "} + + {change.before ?? "—"} + {" "} + →{" "} + {change.after} +
    • + ))} +
    + {entry.note && ( +

    “{entry.note}”

    + )} +
    +
  2. + ))} +
+
+ ); +} + function StatCard({ label, value }: { label: string; value: string }) { return (
diff --git a/frontend/src/hooks/useOrderBook.ts b/frontend/src/hooks/useOrderBook.ts index d81317ae..884d4054 100644 --- a/frontend/src/hooks/useOrderBook.ts +++ b/frontend/src/hooks/useOrderBook.ts @@ -1,6 +1,13 @@ import { create } from "zustand"; import { persist } from "zustand/middleware"; -import { AdvancedOrderType, Order, OrderBookStore, OrderSide, OrderStatus } from "@/types"; +import { + AdvancedOrderType, + Order, + OrderAmendmentEntry, + OrderBookStore, + OrderSide, + OrderStatus, +} from "@/types"; import { useCallback } from "react"; export const DEMO_ORDER_OWNER = "cb-local-trader"; @@ -62,6 +69,16 @@ export const useMockOrders = () => { orderType: AdvancedOrderType.LIMIT, allowPartialFills: true, amendmentCount: 1, + amendmentLog: [ + { + sequence: 1, + amended_at: new Date(Date.now() - 1000 * 60 * 20).toISOString(), + changes: { + from_amount: { before: 8000, after: 10000 }, + }, + note: "Increased order size", + }, + ] as OrderAmendmentEntry[], minFillAmount: "2,500", takerFeeEstimate: "~0.0004 ETH", }, @@ -103,6 +120,24 @@ export const useMockOrders = () => { orderType: AdvancedOrderType.TWAP, allowPartialFills: true, amendmentCount: 2, + amendmentLog: [ + { + sequence: 1, + amended_at: new Date(Date.now() - 1000 * 60 * 25).toISOString(), + changes: { + to_amount: { before: 400000, after: 450000 }, + }, + }, + { + sequence: 2, + amended_at: new Date(Date.now() - 1000 * 60 * 10).toISOString(), + changes: { + min_fill_amount: { before: null, after: 1000 }, + expiry: { before: 1800000, after: 2400000 }, + }, + note: "Set min fill and extended expiry", + }, + ] as OrderAmendmentEntry[], minFillAmount: "0.01", takerFeeEstimate: "~35 XLM", }, @@ -144,6 +179,16 @@ export const useMockOrders = () => { orderType: AdvancedOrderType.LIMIT, allowPartialFills: true, amendmentCount: 1, + amendmentLog: [ + { + sequence: 1, + amended_at: new Date(Date.now() - 1000 * 60 * 7).toISOString(), + changes: { + to_amount: { before: 170000, after: 184000 }, + }, + note: "Adjusted rate", + }, + ] as OrderAmendmentEntry[], minFillAmount: "1,000", makerFeeEstimate: "~3 XLM", }, @@ -165,6 +210,24 @@ export const useMockOrders = () => { orderType: AdvancedOrderType.STOP_LOSS, allowPartialFills: false, amendmentCount: 3, + amendmentLog: [ + { + sequence: 1, + amended_at: new Date(Date.now() - 1000 * 60 * 80).toISOString(), + changes: { from_amount: { before: 1000, after: 2000 } }, + }, + { + sequence: 2, + amended_at: new Date(Date.now() - 1000 * 60 * 60).toISOString(), + changes: { to_amount: { before: 400000, after: 468000 } }, + note: "Updated target price", + }, + { + sequence: 3, + amended_at: new Date(Date.now() - 1000 * 60 * 30).toISOString(), + changes: { expiry: { before: 3600, after: 5400 } }, + }, + ] as OrderAmendmentEntry[], makerFeeEstimate: "~0.00003 BTC", }, ]; diff --git a/frontend/src/lib/api/orders.ts b/frontend/src/lib/api/orders.ts index d2134142..6bd241d5 100644 --- a/frontend/src/lib/api/orders.ts +++ b/frontend/src/lib/api/orders.ts @@ -1,6 +1,7 @@ import { createApiClient, getUserApiHeaders } from "@/lib/api/client"; import { ApiOrderRecordSchema, ApiOrderListSchema } from "@/lib/api/schemas"; import type { + AmendOrderPayload, ApiOrderRecord, CreateOrderPayload, ListOrdersParams, @@ -41,3 +42,12 @@ export function cancelOrder(orderId: string) { ApiOrderRecordSchema ); } + +export function amendOrder(orderId: string, payload: AmendOrderPayload) { + return ordersClient.patch( + `/${orderId}/amend`, + payload, + undefined, + ApiOrderRecordSchema + ); +} diff --git a/frontend/src/lib/api/schemas.ts b/frontend/src/lib/api/schemas.ts index 33c4cb9b..e3489701 100644 --- a/frontend/src/lib/api/schemas.ts +++ b/frontend/src/lib/api/schemas.ts @@ -6,6 +6,18 @@ import { z } from "zod"; // ── Order Schemas ────────────────────────────────────────────────────────────── +export const ApiOrderAmendmentChangeSchema = z.object({ + before: z.number().nullable(), + after: z.number(), +}); + +export const ApiOrderAmendmentEntrySchema = z.object({ + sequence: z.number(), + amended_at: z.string(), + changes: z.record(z.string(), ApiOrderAmendmentChangeSchema), + note: z.string().optional(), +}); + export const ApiOrderRecordSchema = z.object({ id: z.string(), onchain_id: z.number().nullable(), @@ -22,6 +34,8 @@ export const ApiOrderRecordSchema = z.object({ status: z.string(), counterparty: z.string().nullable(), created_at: z.string().nullable(), + amendment_count: z.number().default(0), + amendment_log: z.array(ApiOrderAmendmentEntrySchema).default([]), }); export const ApiOrderListSchema = z.array(ApiOrderRecordSchema); diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 2ad6b5c7..02d39b79 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -5,6 +5,18 @@ export interface ApiErrorShape { details?: unknown; } +export interface ApiOrderAmendmentChange { + before: number | null; + after: number; +} + +export interface ApiOrderAmendmentEntry { + sequence: number; + amended_at: string; + changes: Record; + note?: string; +} + export interface ApiOrderRecord { id: string; onchain_id: number | null; @@ -21,6 +33,16 @@ export interface ApiOrderRecord { status: string; counterparty: string | null; created_at: string | null; + amendment_count: number; + amendment_log: ApiOrderAmendmentEntry[]; +} + +export interface AmendOrderPayload { + from_amount?: number; + to_amount?: number; + min_fill_amount?: number; + expiry?: number; + note?: string; } export interface CreateOrderPayload { diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index b064968d..5c719042 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -102,6 +102,18 @@ export enum OrderStatus { EXPIRED = "expired", } +export interface OrderAmendmentChange { + before: number | null; + after: number; +} + +export interface OrderAmendmentEntry { + sequence: number; + amended_at: string; + changes: Record; + note?: string; +} + export interface Order { id: string; maker: string; @@ -121,6 +133,7 @@ export interface Order { expiresAt?: string; allowPartialFills?: boolean; amendmentCount?: number; + amendmentLog?: OrderAmendmentEntry[]; minFillAmount?: string; makerFeeEstimate?: string; takerFeeEstimate?: string; @@ -283,9 +296,11 @@ export interface ProtocolStats { } export type { + AmendOrderPayload, ApiErrorShape, ApiHTLCBaseRecord, ApiHTLCRecord, + ApiOrderAmendmentEntry, ApiOrderRecord, ApiSwapRecord, ClaimHTLCPayload, From 922230574c978b91803c624a872288a8a266f2cd Mon Sep 17 00:00:00 2001 From: TYDev01 Date: Mon, 1 Jun 2026 14:47:52 +0100 Subject: [PATCH 2/2] feat(orders): add trigger_price and valid_from execution conditions (#511) --- ...0601_0006_add_advanced_order_conditions.py | 31 ++ backend/app/models/order.py | 4 +- backend/app/routes/orders.py | 19 ++ backend/app/schemas/order.py | 22 +- backend/app/services/order_matching.py | 23 +- backend/tests/test_orders.py | 303 +++++++++++++++++- 6 files changed, 397 insertions(+), 5 deletions(-) create mode 100644 backend/alembic/versions/20260601_0006_add_advanced_order_conditions.py diff --git a/backend/alembic/versions/20260601_0006_add_advanced_order_conditions.py b/backend/alembic/versions/20260601_0006_add_advanced_order_conditions.py new file mode 100644 index 00000000..1c8dadca --- /dev/null +++ b/backend/alembic/versions/20260601_0006_add_advanced_order_conditions.py @@ -0,0 +1,31 @@ +"""Add trigger_price and valid_from to swap_orders for advanced order conditions.""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +revision: str = "20260601_0006" +down_revision: Union[str, None] = "20260601_0005" +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("trigger_price", sa.Numeric(precision=36, scale=18), nullable=True), + ) + op.add_column( + "swap_orders", + sa.Column("valid_from", sa.BigInteger(), nullable=True), + ) + op.create_index( + op.f("ix_swap_orders_valid_from"), "swap_orders", ["valid_from"], unique=False + ) + + +def downgrade() -> None: + op.drop_index(op.f("ix_swap_orders_valid_from"), table_name="swap_orders") + op.drop_column("swap_orders", "valid_from") + op.drop_column("swap_orders", "trigger_price") diff --git a/backend/app/models/order.py b/backend/app/models/order.py index 766af46e..3e76331d 100644 --- a/backend/app/models/order.py +++ b/backend/app/models/order.py @@ -1,5 +1,5 @@ import uuid -from sqlalchemy import Column, String, BigInteger, Integer, ForeignKey +from sqlalchemy import Column, String, BigInteger, Integer, Numeric, ForeignKey from sqlalchemy.dialects.postgresql import UUID, JSONB from .base import Base, TimestampMixin @@ -29,3 +29,5 @@ class SwapOrder(Base, TimestampMixin): counterparty = Column(String, nullable=True) amendment_count = Column(Integer, nullable=False, default=0) amendment_log = Column(JSONB, nullable=False, default=list) + trigger_price = Column(Numeric(36, 18), nullable=True) + valid_from = Column(BigInteger, nullable=True, index=True) diff --git a/backend/app/routes/orders.py b/backend/app/routes/orders.py index d3c502d2..c31da3c2 100644 --- a/backend/app/routes/orders.py +++ b/backend/app/routes/orders.py @@ -47,6 +47,8 @@ async def create_order( to_amount=data.to_amount, min_fill_amount=data.min_fill_amount, expiry=data.expiry, + trigger_price=data.trigger_price, + valid_from=data.valid_from, status="open", ) db.add(order) @@ -179,6 +181,23 @@ async def amend_order( 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 data.trigger_price is not None and data.trigger_price != order.trigger_price: + changes["trigger_price"] = { + "before": float(order.trigger_price) if order.trigger_price is not None else None, + "after": float(data.trigger_price), + } + order.trigger_price = data.trigger_price + if data.valid_from is not None and data.valid_from != order.valid_from: + new_expiry = data.expiry if data.expiry is not None else int(order.expiry) + if data.valid_from >= new_expiry: + raise HTTPException( + status_code=400, detail="valid_from must be earlier than expiry" + ) + changes["valid_from"] = { + "before": int(order.valid_from) if order.valid_from is not None else None, + "after": data.valid_from, + } + order.valid_from = data.valid_from if not changes: raise HTTPException(status_code=400, detail="No fields changed") diff --git a/backend/app/schemas/order.py b/backend/app/schemas/order.py index 0005d62f..891bdda2 100644 --- a/backend/app/schemas/order.py +++ b/backend/app/schemas/order.py @@ -1,3 +1,4 @@ +from decimal import Decimal from pydantic import BaseModel, Field, field_validator, model_validator from typing import Any, Optional from datetime import datetime @@ -19,6 +20,8 @@ class OrderCreate(BaseModel): to_amount: int = Field(gt=0) min_fill_amount: Optional[int] = None expiry: int = Field(gt=0) + trigger_price: Optional[Decimal] = Field(default=None, gt=0) + valid_from: Optional[int] = Field(default=None, gt=0) @field_validator("from_chain", "to_chain") @classmethod @@ -39,19 +42,34 @@ def validate_creator_address(self): ) return self + @model_validator(mode="after") + def validate_time_window(self): + if self.valid_from is not None and self.valid_from >= self.expiry: + raise ValueError("valid_from must be earlier than expiry") + 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) + trigger_price: Optional[Decimal] = Field(default=None, gt=0) + valid_from: 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) + for v in ( + self.from_amount, + self.to_amount, + self.min_fill_amount, + self.expiry, + self.trigger_price, + self.valid_from, + ) ): raise ValueError("At least one amendable field must be provided") return self @@ -95,6 +113,8 @@ class OrderResponse(BaseModel): created_at: Optional[datetime] = None amendment_count: int = 0 amendment_log: list[dict[str, Any]] = [] + trigger_price: Optional[Decimal] = None + valid_from: Optional[int] = None class Config: from_attributes = True diff --git a/backend/app/services/order_matching.py b/backend/app/services/order_matching.py index 8dc52fc9..a31b4349 100644 --- a/backend/app/services/order_matching.py +++ b/backend/app/services/order_matching.py @@ -20,6 +20,18 @@ def _is_expired(order: SwapOrder) -> bool: return int(order.expiry) <= int(datetime.now(timezone.utc).timestamp()) +def _is_not_yet_active(order: SwapOrder) -> bool: + if order.valid_from is None: + return False + return int(datetime.now(timezone.utc).timestamp()) < int(order.valid_from) + + +def _meets_trigger_price(order: SwapOrder, execution_price: Fraction) -> bool: + if order.trigger_price is None: + return True + return execution_price >= Fraction(order.trigger_price) + + def _counterparty_label(existing: str | None, new_value: str) -> str: if not existing or existing == new_value: return new_value @@ -72,7 +84,7 @@ class OrderMatchingService: """Price-time-priority matcher for reciprocal open orders.""" async def match_order(self, db: AsyncSession, order: SwapOrder) -> MatchingSummary: - if order.status not in {"open", "matched"} or _is_expired(order): + if order.status not in {"open", "matched"} or _is_expired(order) or _is_not_yet_active(order): return MatchingSummary( total_matches=0, filled_amount=int(order.filled_amount or 0), @@ -111,7 +123,7 @@ def candidate_price_key(candidate: SwapOrder) -> Fraction: ): if _remaining(order) <= 0: break - if _remaining(candidate) <= 0 or _is_expired(candidate): + if _remaining(candidate) <= 0 or _is_expired(candidate) or _is_not_yet_active(candidate): continue if not self._is_price_compatible(order, candidate): continue @@ -133,6 +145,13 @@ def candidate_price_key(candidate: SwapOrder) -> Fraction: ): continue + taker_execution_price = Fraction(counterparty_fill, max_fill) + if not _meets_trigger_price(order, taker_execution_price): + continue + maker_execution_price = Fraction(max_fill, counterparty_fill) + if not _meets_trigger_price(candidate, maker_execution_price): + continue + order.filled_amount = int(order.filled_amount or 0) + max_fill candidate.filled_amount = ( int(candidate.filled_amount or 0) + counterparty_fill diff --git a/backend/tests/test_orders.py b/backend/tests/test_orders.py index 779d9e34..23daa157 100644 --- a/backend/tests/test_orders.py +++ b/backend/tests/test_orders.py @@ -1,13 +1,14 @@ """Tests for order schemas and matching.""" from datetime import datetime, timedelta, timezone +from decimal import Decimal from uuid import uuid4 import pytest from unittest.mock import AsyncMock, MagicMock from app.models.order import SwapOrder -from app.schemas.order import OrderAmend, OrderResponse +from app.schemas.order import OrderAmend, OrderCreate, OrderResponse from app.services.order_matching import OrderMatchingService @@ -30,6 +31,8 @@ def make_order(**overrides): "created_at": now, "amendment_count": 0, "amendment_log": [], + "trigger_price": None, + "valid_from": None, } values.update(overrides) order = SwapOrder() @@ -218,3 +221,301 @@ async def test_skips_incompatible_price(self): assert summary.total_matches == 0 assert taker.status == "open" + + +class TestAdvancedOrderConditions: + """Tests for trigger_price and valid_from execution conditions.""" + + # --- Schema validation --- + + def test_order_create_accepts_trigger_price_and_valid_from(self): + now = int(datetime.now(timezone.utc).timestamp()) + data = OrderCreate( + creator="GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN7", + from_chain="stellar", + to_chain="ethereum", + from_asset="XLM", + to_asset="USDC", + from_amount=100, + to_amount=200, + expiry=now + 3600, + trigger_price=Decimal("1.5"), + valid_from=now + 60, + ) + assert data.trigger_price == Decimal("1.5") + assert data.valid_from == now + 60 + + def test_order_create_rejects_valid_from_after_expiry(self): + now = int(datetime.now(timezone.utc).timestamp()) + with pytest.raises(ValueError, match="valid_from must be earlier than expiry"): + OrderCreate( + creator="GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN7", + from_chain="stellar", + to_chain="ethereum", + from_asset="XLM", + to_asset="USDC", + from_amount=100, + to_amount=200, + expiry=now + 3600, + valid_from=now + 7200, + ) + + def test_order_create_rejects_valid_from_equal_to_expiry(self): + now = int(datetime.now(timezone.utc).timestamp()) + expiry = now + 3600 + with pytest.raises(ValueError, match="valid_from must be earlier than expiry"): + OrderCreate( + creator="GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN7", + from_chain="stellar", + to_chain="ethereum", + from_asset="XLM", + to_asset="USDC", + from_amount=100, + to_amount=200, + expiry=expiry, + valid_from=expiry, + ) + + def test_order_create_rejects_zero_trigger_price(self): + now = int(datetime.now(timezone.utc).timestamp()) + with pytest.raises(ValueError): + OrderCreate( + creator="GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN7", + from_chain="stellar", + to_chain="ethereum", + from_asset="XLM", + to_asset="USDC", + from_amount=100, + to_amount=200, + expiry=now + 3600, + trigger_price=Decimal("0"), + ) + + def test_order_amend_accepts_trigger_price_and_valid_from(self): + now = int(datetime.now(timezone.utc).timestamp()) + data = OrderAmend(trigger_price=Decimal("2.5"), valid_from=now + 120) + assert data.trigger_price == Decimal("2.5") + assert data.valid_from == now + 120 + + def test_order_amend_rejects_all_none_including_new_fields(self): + with pytest.raises(ValueError, match="At least one amendable field must be provided"): + OrderAmend() + + def test_order_response_exposes_new_fields(self): + resp = OrderResponse( + id="order-adv-001", + 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", + trigger_price=Decimal("1.8"), + valid_from=1700000000, + ) + assert resp.trigger_price == Decimal("1.8") + assert resp.valid_from == 1700000000 + + # --- Matching: valid_from window --- + + @pytest.mark.anyio + async def test_taker_not_yet_active_skips_matching(self): + """Taker with future valid_from should not be matched.""" + service = OrderMatchingService() + future_ts = int((datetime.now(timezone.utc) + timedelta(hours=1)).timestamp()) + taker = make_order(from_amount=100, to_amount=200, valid_from=future_ts) + candidate = make_order( + creator="0xde0b295669a9fd93d5f28d9ec85e40f4cb697bae", + from_chain="ethereum", + to_chain="stellar", + from_asset="USDC", + to_asset="XLM", + from_amount=200, + to_amount=100, + ) + + result = MagicMock() + result.scalars.return_value.all.return_value = [candidate] + db = AsyncMock() + db.execute = AsyncMock(return_value=result) + + summary = await service.match_order(db, taker) + + assert summary.total_matches == 0 + assert taker.status == "open" + + @pytest.mark.anyio + async def test_candidate_not_yet_active_is_skipped(self): + """Candidate with future valid_from should be skipped.""" + service = OrderMatchingService() + future_ts = int((datetime.now(timezone.utc) + timedelta(hours=1)).timestamp()) + taker = make_order(from_amount=100, to_amount=200) + candidate = make_order( + creator="0xde0b295669a9fd93d5f28d9ec85e40f4cb697bae", + from_chain="ethereum", + to_chain="stellar", + from_asset="USDC", + to_asset="XLM", + from_amount=200, + to_amount=100, + valid_from=future_ts, + ) + + result = MagicMock() + result.scalars.return_value.all.return_value = [candidate] + db = AsyncMock() + db.execute = AsyncMock(return_value=result) + + summary = await service.match_order(db, taker) + + assert summary.total_matches == 0 + assert taker.status == "open" + + @pytest.mark.anyio + async def test_order_within_valid_window_matches(self): + """Orders with valid_from in the past should match normally.""" + service = OrderMatchingService() + past_ts = int((datetime.now(timezone.utc) - timedelta(minutes=5)).timestamp()) + taker = make_order(from_amount=100, to_amount=200, valid_from=past_ts) + candidate = make_order( + creator="0xde0b295669a9fd93d5f28d9ec85e40f4cb697bae", + from_chain="ethereum", + to_chain="stellar", + from_asset="USDC", + to_asset="XLM", + from_amount=200, + to_amount=100, + valid_from=past_ts, + ) + + result = MagicMock() + result.scalars.return_value.all.return_value = [candidate] + db = AsyncMock() + db.execute = AsyncMock(return_value=result) + + summary = await service.match_order(db, taker) + + assert summary.total_matches == 1 + assert taker.status == "filled" + + # --- Matching: trigger_price --- + + @pytest.mark.anyio + async def test_taker_trigger_price_met_allows_match(self): + """Match proceeds when execution price meets taker's trigger_price.""" + service = OrderMatchingService() + # taker sends 100 XLM, wants 200 USDC => execution price 2.0 USDC/XLM + # trigger_price = 2.0 — exactly met + taker = make_order(from_amount=100, to_amount=200, trigger_price=Decimal("2.0")) + candidate = make_order( + creator="0xde0b295669a9fd93d5f28d9ec85e40f4cb697bae", + from_chain="ethereum", + to_chain="stellar", + from_asset="USDC", + to_asset="XLM", + from_amount=200, + to_amount=100, + ) + + result = MagicMock() + result.scalars.return_value.all.return_value = [candidate] + db = AsyncMock() + db.execute = AsyncMock(return_value=result) + + summary = await service.match_order(db, taker) + + assert summary.total_matches == 1 + assert taker.status == "filled" + + @pytest.mark.anyio + async def test_taker_trigger_price_not_met_skips_match(self): + """Match is skipped when execution price is below taker's trigger_price.""" + service = OrderMatchingService() + # taker sends 100 XLM, wants 200 USDC => execution price 2.0 USDC/XLM + # trigger_price = 2.5 — not met + taker = make_order(from_amount=100, to_amount=200, trigger_price=Decimal("2.5")) + candidate = make_order( + creator="0xde0b295669a9fd93d5f28d9ec85e40f4cb697bae", + from_chain="ethereum", + to_chain="stellar", + from_asset="USDC", + to_asset="XLM", + from_amount=200, + to_amount=100, + ) + + result = MagicMock() + result.scalars.return_value.all.return_value = [candidate] + db = AsyncMock() + db.execute = AsyncMock(return_value=result) + + summary = await service.match_order(db, taker) + + assert summary.total_matches == 0 + assert taker.status == "open" + + @pytest.mark.anyio + async def test_candidate_trigger_price_not_met_skips_match(self): + """Match is skipped when execution price is below the candidate's trigger_price.""" + service = OrderMatchingService() + # candidate sends 200 USDC, wants 100 XLM => maker exec price = 100/200 = 0.5 XLM/USDC + # candidate's trigger_price = 1.0 — not met + taker = make_order(from_amount=100, to_amount=200) + candidate = make_order( + creator="0xde0b295669a9fd93d5f28d9ec85e40f4cb697bae", + from_chain="ethereum", + to_chain="stellar", + from_asset="USDC", + to_asset="XLM", + from_amount=200, + to_amount=100, + trigger_price=Decimal("1.0"), + ) + + result = MagicMock() + result.scalars.return_value.all.return_value = [candidate] + db = AsyncMock() + db.execute = AsyncMock(return_value=result) + + summary = await service.match_order(db, taker) + + assert summary.total_matches == 0 + assert taker.status == "open" + + # --- Partial-fill preservation --- + + @pytest.mark.anyio + async def test_min_fill_amount_preserved_alongside_trigger_price(self): + """min_fill_amount enforcement still applies when trigger_price is set.""" + service = OrderMatchingService() + # taker wants 200 USDC for 100 XLM with trigger_price=2.0, min_fill=80 + # candidate only fills 50 XLM, counterparty_fill = 100 USDC < min_fill 80 of taker + taker = make_order( + from_amount=100, + to_amount=200, + min_fill_amount=80, + trigger_price=Decimal("2.0"), + ) + candidate = make_order( + creator="0xde0b295669a9fd93d5f28d9ec85e40f4cb697bae", + from_chain="ethereum", + to_chain="stellar", + from_asset="USDC", + to_asset="XLM", + from_amount=100, + to_amount=50, + ) + + result = MagicMock() + result.scalars.return_value.all.return_value = [candidate] + db = AsyncMock() + db.execute = AsyncMock(return_value=result) + + summary = await service.match_order(db, taker) + + assert summary.total_matches == 0 + assert taker.status == "open"