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
60 changes: 8 additions & 52 deletions app/api/swap/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,11 @@ class SwapQuoteRequest(SwapSupportRequest):

class SwapStatusRequest(SwapBaseModel):
tx_hash: str = Field(description="Transaction hash of the swap")
source_coin: Coin = Field(description="Source coin of the swap")
source_chain_id: str = Field(description="Source chain ID of the swap")
destination_coin: Coin = Field(description="Destination coin of the swap")
destination_chain_id: str = Field(description="Destination chain ID of the swap")

deposit_address: str = Field(description="Deposit address of the swap")
deposit_memo: str | None = Field(
default=None,
Expand Down Expand Up @@ -457,61 +462,12 @@ class SwapQuote(SwapBaseModel):
routes: list[SwapRoute] = Field(description="Available swap routes")


class SwapTransactionDetails(SwapBaseModel):
coin: Coin = Field(description="Coin identifier")
chain_id: str = Field(description="Chain identifier")
hash: str = Field(description="Transaction hash")
explorer_url: str | None = Field(
default=None,
description="Block explorer URL for this transaction",
)

@property
def chain(self) -> Chain | None:
return Chain.get(self.coin, self.chain_id)


class SwapDetails(SwapBaseModel):
amount_in: str | None = Field(
default=None,
description="Actual input amount in smallest unit",
)
amount_in_formatted: str | None = Field(
default=None,
description="Actual input amount in readable format",
)

amount_out: str | None = Field(
default=None,
description="Actual output amount in smallest unit",
)
amount_out_formatted: str | None = Field(
default=None,
description="Actual output amount in readable format",
)

refunded_amount: str | None = Field(
default=None,
description="Refunded amount in smallest unit (if any)",
)
refunded_amount_formatted: str | None = Field(
default=None,
description="Refunded amount in readable format",
)

transactions: list[SwapTransactionDetails] = Field(
default_factory=list,
description="All transactions involved in the swap",
)


class SwapStatusResponse(SwapSupportRequest):
class SwapStatusResponse(SwapBaseModel):
status: SwapStatus = Field(description="Current status of the swap")
swap_details: SwapDetails | None = Field(
internal_status: str | None = Field(
default=None,
description="Detailed swap information",
description="Provider-specific status of the swap",
)
provider: SwapProviderEnum = Field(description="Provider handling this swap")
explorer_url: str | None = Field(
default=None,
description="Block explorer URL for the swap transaction",
Expand Down
3 changes: 1 addition & 2 deletions app/api/swap/providers/near_intents/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,6 @@ async def get_status(self, request: SwapStatusRequest) -> SwapStatusResponse:
data = response.json()
near_response = NearIntentsStatusResponse.model_validate(data)

supported_tokens = await self.get_supported_tokens()
return from_near_intents_status(near_response, supported_tokens)
return from_near_intents_status(near_response, request)

self._handle_error_response(response)
35 changes: 0 additions & 35 deletions app/api/swap/providers/near_intents/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,43 +75,8 @@ class NearIntentsQuoteResponse(BaseModel):
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)


class NearIntentsTransactionDetails(BaseModel):
hash: str
explorer_url: str | None = Field(default=None)

model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)


class NearIntentsSwapDetails(BaseModel):
intent_hashes: list[str] = Field(default_factory=list)
near_tx_hashes: list[str] = Field(default_factory=list)

amount_in: str | None = Field(default=None)
amount_in_formatted: str | None = Field(default=None)
amount_in_usd: str | None = Field(default=None)

amount_out: str | None = Field(default=None)
amount_out_formatted: str | None = Field(default=None)
amount_out_usd: str | None = Field(default=None)

refunded_amount: str | None = Field(default=None)
refunded_amount_formatted: str | None = Field(default=None)

origin_chain_tx_hashes: list[NearIntentsTransactionDetails] = Field(
default_factory=list
)
destination_chain_tx_hashes: list[NearIntentsTransactionDetails] = Field(
default_factory=list
)

model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)


class NearIntentsStatusResponse(BaseModel):
quote_response: NearIntentsQuoteResponse
status: str
updated_at: datetime
swap_details: NearIntentsSwapDetails

model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)

Expand Down
49 changes: 41 additions & 8 deletions app/api/swap/providers/near_intents/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
SwapErrorKind,
SwapProviderEnum,
SwapQuoteRequest,
SwapStatus,
SwapStatusRequest,
SwapSupportRequest,
SwapType,
Expand Down Expand Up @@ -998,6 +999,10 @@ async def test_post_submit_hook_success(client, mock_httpx_client):

request = SwapStatusRequest(
tx_hash="4jLC9UPQJUyEK9dbgTywQQHeJTngX54FjJ6ZLPb1BUspGX4ZZGrg3u4P5tjHGqzpuq1c73rD2QwhyFQETvPgWdm5",
source_coin=Chain.SOLANA.coin,
source_chain_id=Chain.SOLANA.chain_id,
destination_coin=Chain.BITCOIN.coin,
destination_chain_id=Chain.BITCOIN.chain_id,
deposit_address="4Rqnz7SPU4EqSUravxbKTSBti4RNf1XGaqvBmnLfvH83",
deposit_memo=None,
provider=SwapProviderEnum.NEAR_INTENTS,
Expand Down Expand Up @@ -1032,6 +1037,10 @@ async def test_post_submit_hook_with_memo(client, mock_httpx_client):

request = SwapStatusRequest(
tx_hash="test_hash",
source_coin=Chain.SOLANA.coin,
source_chain_id=Chain.SOLANA.chain_id,
destination_coin=Chain.BITCOIN.coin,
destination_chain_id=Chain.BITCOIN.chain_id,
deposit_address="test_address",
deposit_memo="test_memo",
provider=SwapProviderEnum.NEAR_INTENTS,
Expand All @@ -1054,6 +1063,10 @@ async def test_post_submit_hook_error(client, mock_httpx_client):

request = SwapStatusRequest(
tx_hash="invalid_hash",
source_coin=Chain.SOLANA.coin,
source_chain_id=Chain.SOLANA.chain_id,
destination_coin=Chain.BITCOIN.coin,
destination_chain_id=Chain.BITCOIN.chain_id,
deposit_address="test_address",
deposit_memo=None,
provider=SwapProviderEnum.NEAR_INTENTS,
Expand Down Expand Up @@ -1085,7 +1098,10 @@ async def test_get_status_success(
mock_response.json.return_value = {
"quoteResponse": {
"quoteRequest": MOCK_QUOTE_REQUEST,
"quote": MOCK_FIRM_QUOTE,
"quote": {
**MOCK_FIRM_QUOTE,
"depositAddress": "4Rqnz7SPU4EqSUravxbKTSBti4RNf1XGaqvBmnLfvH83",
},
},
"status": "SUCCESS",
"updatedAt": "2025-12-11T13:48:50.883000Z",
Comment on lines 1098 to 1107
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The mock response includes fields that are no longer part of the NearIntentsStatusResponse model. The model now only expects a 'status' field. The extra fields 'quoteResponse', 'updatedAt', and 'swapDetails' should be removed from the mock to keep the test data aligned with the actual model structure and improve test maintainability.

Copilot uses AI. Check for mistakes.
Expand All @@ -1110,6 +1126,10 @@ async def test_get_status_success(

request = SwapStatusRequest(
tx_hash="4jLC9UPQJUyEK9dbgTywQQHeJTngX54FjJ6ZLPb1BUspGX4ZZGrg3u4P5tjHGqzpuq1c73rD2QwhyFQETvPgWdm5",
source_coin=Chain.SOLANA.coin,
source_chain_id=Chain.SOLANA.chain_id,
destination_coin=Chain.BITCOIN.coin,
destination_chain_id=Chain.BITCOIN.chain_id,
deposit_address="4Rqnz7SPU4EqSUravxbKTSBti4RNf1XGaqvBmnLfvH83",
deposit_memo=None,
provider=SwapProviderEnum.NEAR_INTENTS,
Expand All @@ -1124,11 +1144,12 @@ async def test_get_status_success(
assert call_args[1]["params"]["depositAddress"] == request.deposit_address

# Verify response
assert result.status.value == "SUCCESS"
assert result.provider == SwapProviderEnum.NEAR_INTENTS
assert result.swap_details is not None
assert result.swap_details.amount_in == "2005138"
assert result.swap_details.amount_out == "711"
assert result.status == SwapStatus.SUCCESS
assert result.internal_status == "SUCCESS"
assert (
result.explorer_url
== "https://explorer.near-intents.org/transactions/4Rqnz7SPU4EqSUravxbKTSBti4RNf1XGaqvBmnLfvH83"
)


@pytest.mark.asyncio
Expand All @@ -1152,25 +1173,33 @@ async def test_get_status_with_memo(
"quoteRequest": MOCK_QUOTE_REQUEST,
"quote": MOCK_INDICATIVE_QUOTE,
},
"status": "PENDING",
"status": "PENDING_DEPOSIT",
"updatedAt": "2025-12-11T13:48:50.883000Z",
"swapDetails": {},
}
Comment on lines 1173 to 1179
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The mock response includes fields that are no longer part of the NearIntentsStatusResponse model. The model now only expects a 'status' field. The extra fields 'quoteResponse', 'updatedAt', and 'swapDetails' should be removed from the mock to keep the test data aligned with the actual model structure and improve test maintainability.

Copilot uses AI. Check for mistakes.
mock_httpx_client.get.return_value = mock_response

request = SwapStatusRequest(
tx_hash="test_hash",
source_coin=Chain.SOLANA.coin,
source_chain_id=Chain.SOLANA.chain_id,
destination_coin=Chain.BITCOIN.coin,
destination_chain_id=Chain.BITCOIN.chain_id,
deposit_address="test_address",
deposit_memo="test_memo",
provider=SwapProviderEnum.NEAR_INTENTS,
)

await client.get_status(request)
result = await client.get_status(request)

# Verify memo was included in params
call_args = mock_httpx_client.get.call_args
assert call_args[1]["params"]["depositMemo"] == "test_memo"

# Verify status and internal_status
assert result.status == SwapStatus.PENDING
assert result.internal_status == "PENDING_DEPOSIT"


@pytest.mark.asyncio
async def test_route_price_impact_negative(
Expand Down Expand Up @@ -1236,6 +1265,10 @@ async def test_get_status_error(client, mock_httpx_client, mock_supported_tokens

request = SwapStatusRequest(
tx_hash="invalid_hash",
source_coin=Chain.SOLANA.coin,
source_chain_id=Chain.SOLANA.chain_id,
destination_coin=Chain.BITCOIN.coin,
destination_chain_id=Chain.BITCOIN.chain_id,
deposit_address="invalid_address",
deposit_memo=None,
provider=SwapProviderEnum.NEAR_INTENTS,
Expand Down
80 changes: 4 additions & 76 deletions app/api/swap/providers/near_intents/transformations.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,14 @@
CardanoTransactionParams,
EvmTransactionParams,
SolanaTransactionParams,
SwapDetails,
SwapProviderEnum,
SwapQuoteRequest,
SwapRoute,
SwapRouteStep,
SwapStatus,
SwapStatusRequest,
SwapStatusResponse,
SwapStepToken,
SwapTransactionDetails,
SwapType,
TransactionParams,
ZcashTransactionParams,
Expand Down Expand Up @@ -326,91 +325,20 @@ def normalize_near_intents_status(status: str) -> SwapStatus:
return status_mapping.get(status, SwapStatus.PENDING)


def _find_token_by_asset_id(
asset_id: str,
supported_tokens: list[TokenInfo],
) -> TokenInfo | None:
"""Find a token by its NEAR Intents asset ID."""
return next(
(t for t in supported_tokens if t.near_intents_asset_id == asset_id),
None,
)


def from_near_intents_status(
response: NearIntentsStatusResponse,
supported_tokens: list[TokenInfo],
request: SwapStatusRequest,
) -> SwapStatusResponse:
origin_asset = _find_token_by_asset_id(
response.quote_response.quote_request.origin_asset_id,
supported_tokens,
)
destination_asset = _find_token_by_asset_id(
response.quote_response.quote_request.destination_asset_id,
supported_tokens,
)

if not origin_asset or not destination_asset:
raise ValueError("Invalid origin or destination asset")

swap_details_data = response.swap_details

# Collect all transactions
transactions = []

# Add origin chain transactions
for tx in swap_details_data.origin_chain_tx_hashes:
transactions.append(
SwapTransactionDetails(
coin=origin_asset.coin,
chain_id=origin_asset.chain_id,
hash=tx.hash,
explorer_url=tx.explorer_url,
),
)

# Add destination chain transactions
for tx in swap_details_data.destination_chain_tx_hashes:
transactions.append(
SwapTransactionDetails(
coin=destination_asset.coin,
chain_id=destination_asset.chain_id,
hash=tx.hash,
explorer_url=tx.explorer_url,
),
)

swap_details = SwapDetails(
amount_in=swap_details_data.amount_in,
amount_in_formatted=swap_details_data.amount_in_formatted,
amount_out=swap_details_data.amount_out,
amount_out_formatted=swap_details_data.amount_out_formatted,
refunded_amount=swap_details_data.refunded_amount,
refunded_amount_formatted=swap_details_data.refunded_amount_formatted,
transactions=transactions,
)

# Generate explorer URL for NEAR Intents using deposit address
explorer_url = None
deposit_address = response.quote_response.quote.deposit_address
deposit_address = request.deposit_address
if deposit_address:
explorer_url = (
f"https://explorer.near-intents.org/transactions/{deposit_address}"
)

return SwapStatusResponse(
# source
source_coin=origin_asset.coin,
source_chain_id=origin_asset.chain_id,
source_token_address=origin_asset.address,
# destination
destination_coin=destination_asset.coin,
destination_chain_id=destination_asset.chain_id,
destination_token_address=destination_asset.address,
recipient=response.quote_response.quote_request.recipient,
# status fields
status=normalize_near_intents_status(response.status),
swap_details=swap_details,
provider=SwapProviderEnum.NEAR_INTENTS,
internal_status=response.status,
explorer_url=explorer_url,
)
Loading