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
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { afterAll, describe, expect, it, mock } from "bun:test";
import * as sharedNamespace from "@vortexfi/shared";
import { EphemeralAccountType, type EvmNetworks, EvmToken, evmTokenConfig, Networks } from "@vortexfi/shared";
import {
EphemeralAccountType,
type EvmNetworks,
EvmToken,
evmTokenConfig,
NATIVE_TOKEN_ADDRESS,
Networks
} from "@vortexfi/shared";
import type { PrepareCtx } from "../core/types";
import type { EvmOfframpSourceRegistrationFacts } from "../phases/evm-offramp-source/registration";
import type { EvmOfframpSourceMetadata } from "../phases/evm-offramp-source/simulation";
Expand Down Expand Up @@ -83,4 +90,18 @@ describe("EVM offramp source transaction variants", () => {
toNetwork: Networks.Base
});
});

it("uses only the Squid swap for a native source token", async () => {
const prepared = await prepareEvmOfframpSourceTxs(context(Networks.Ethereum, EvmToken.ETH));
expect(prepared.intents.map(intent => intent.phase)).toEqual(["squidRouterSwap"]);
expect(prepared.intents[0]?.signer).toBe(USER);
expect(prepared.intents[0]?.network).toBe(Networks.Ethereum);
expect(routeRequests.at(-1)).toMatchObject({
destinationAddress: EPHEMERAL,
fromAddress: USER,
fromNetwork: Networks.Ethereum,
toNetwork: Networks.Base
});
expect(String(routeRequests.at(-1)?.fromToken).toLowerCase()).toBe(NATIVE_TOKEN_ADDRESS.toLowerCase());
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { describe, expect, it } from "bun:test";
import { EvmToken, FiatToken, mapFiatToDestination, Networks, RampDirection } from "@vortexfi/shared";
import { resolveBlockFlow } from "../flows/catalog";

function sellRequest(inputCurrency: string, outputCurrency: FiatToken) {
return {
from: Networks.Ethereum,
inputAmount: "1",
inputCurrency: inputCurrency as EvmToken,
network: Networks.Ethereum,
outputCurrency,
rampType: RampDirection.SELL,
to: mapFiatToDestination(outputCurrency)
};
}

describe("SELL flow catalog with routed (Squid-discovered) source tokens", () => {
// PAXG is absent from the static token config, and no dynamic token list is loaded in tests, so a
// match here proves the catalog does not consult live token discovery. Persisted flows are
// re-resolved at startup, when discovery may have fallen back to the static config.
it("maps a routed EVM source token to the BRL, EUR, and Alfredpay offramp flows without live token discovery", () => {
expect(resolveBlockFlow(sellRequest("PAXG", FiatToken.BRL)).name).toBe("BrlOfframpBase");
expect(resolveBlockFlow(sellRequest("PAXG", FiatToken.EURC)).name).toBe("EurOfframpBase");
expect(resolveBlockFlow(sellRequest("PAXG", FiatToken.USD)).name).toBe("AlfredpayOfframp");
});

it("still rejects a fiat symbol as an on-chain SELL source", () => {
expect(() => resolveBlockFlow(sellRequest(FiatToken.BRL, FiatToken.BRL))).toThrow(/No block flow mapped/);
});
});
17 changes: 17 additions & 0 deletions apps/api/src/api/services/phases/blocks/core/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,23 @@ export function validateChainSupport(rampType: RampDirection, from: DestinationT
}
}

/**
* The AssetHub BRL corridors are retired at runtime: their flows stay cataloged only so persisted
* records can be decoded, and new quotes for them are rejected.
*/
export function isRetiredAssetHubCorridor(
request: Pick<CreateQuoteRequest, "rampType" | "from" | "to" | "inputCurrency" | "outputCurrency">
): boolean {
return (
(request.rampType === RampDirection.BUY &&
request.inputCurrency === FiatToken.BRL &&
getNetworkFromDestination(request.to) === Networks.AssetHub) ||
(request.rampType === RampDirection.SELL &&
getNetworkFromDestination(request.from) === Networks.AssetHub &&
request.outputCurrency === FiatToken.BRL)
);
}

/**
* Corridors whose partner-markup component is collected via EVM fee transfers: the
* BRL Base routes (USDC on Base) and every Alfredpay corridor (USDT on Polygon). A
Expand Down
4 changes: 3 additions & 1 deletion apps/api/src/api/services/phases/blocks/core/io.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {
type OnChainToken
} from "@vortexfi/shared";
import Big from "big.js";
import httpStatus from "http-status";
import { APIError } from "../../../../errors/api-error";
import type { ChainBrand, FlowInputResolver, PhaseCtx, PhaseIO, TokenBrand } from "./types";

export function fiatRequestIO<Token extends FiatToken>(...tokens: Token[]): FlowInputResolver<PhaseIO<Token, "fiat">> {
Expand Down Expand Up @@ -38,7 +40,7 @@ function onChainRequestIO<Token extends OnChainToken, Chain extends Networks>(
}
const tokenDetails = getOnChainTokenDetails(chain, token);
if (!tokenDetails) {
throw new Error(`Token ${token} is not configured on ${chain}`);
throw new APIError({ message: `Token ${token} is not configured on ${chain}`, status: httpStatus.BAD_REQUEST });
}
const amount = new Big(ctx.request.inputAmount);
return {
Expand Down
50 changes: 41 additions & 9 deletions apps/api/src/api/services/phases/blocks/core/squidrouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@ import {
DestinationType,
EvmToken,
EvmTokenDetails,
getEvmTokensForNetwork,
getNetworkFromDestination,
getOnChainTokenDetails,
getRoute,
isEvmTokenDetails,
isNetworkEVM,
NATIVE_TOKEN_ADDRESS,
Networks,
OnChainToken,
parseContractBalanceResponse,
Expand Down Expand Up @@ -51,6 +54,12 @@ export interface EvmBridgeResult {
outputTokenDecimals: number;
}

const STATIC_NATIVE_TOKEN_PRICE_FALLBACKS_USD: Readonly<Partial<Record<string, number>>> = {
ethereum: 2500,
moonbeam: 0.08,
"polygon-ecosystem-token": 0.5
};

/**
* Helper to get token details for final output currency on EVM destination
*/
Expand Down Expand Up @@ -107,15 +116,33 @@ function getNativeTokenCoingeckoId(network: Networks): string {
case Networks.Moonbeam:
return "moonbeam";
default:
return "moonbeam";
throw new Error(`Unsupported Squid Router source network: ${network}`);
}
}

function getDiscoveredNativeTokenPriceUSD(network: Networks): number | undefined {
if (!isNetworkEVM(network)) {
return undefined;
}
const nativeToken = getEvmTokensForNetwork(network).find(
token => token.isNative || token.erc20AddressSourceChain.toLowerCase() === NATIVE_TOKEN_ADDRESS.toLowerCase()
);
const priceUSD = nativeToken?.usdPrice;
return priceUSD !== undefined && Number.isFinite(priceUSD) && priceUSD > 0 ? priceUSD : undefined;
}

async function calculateSquidrouterNetworkFee(
route: SquidrouterRoute | SquidrouterCachedRoute,
fromNetwork: Networks
fromNetwork: Networks,
routeParams: RouteParams
): Promise<string> {
const squidRouterSwapValue = multiplyByPowerOfTen(Big(route.transactionRequest.value), -18);
// A native source token (ETH, POL, ...) is sent as msg.value, so the route's value carries the
// swapped principal on top of the router fee. Only the fee part is a network cost.
const isNativeSource = routeParams.fromToken.toLowerCase() === NATIVE_TOKEN_ADDRESS.toLowerCase();
const nativeFeeWei = isNativeSource
? Big(route.transactionRequest.value).minus(routeParams.fromAmount)
: Big(route.transactionRequest.value);
const squidRouterSwapValue = multiplyByPowerOfTen(nativeFeeWei.lt(0) ? Big(0) : nativeFeeWei, -18);
const nativeTokenId = getNativeTokenCoingeckoId(fromNetwork);

try {
Expand All @@ -124,11 +151,16 @@ async function calculateSquidrouterNetworkFee(
logger.debug(`Network fee calculated using ${nativeTokenId} price: $${nativePriceUSD}, fee: $${squidFeeUSD}`);
return squidFeeUSD;
} catch (error) {
logger.error(
`Failed to get ${nativeTokenId} price, using fallback: ${error instanceof Error ? error.message : "Unknown error"}`
);
// Conservative per-chain fallback so we never silently report ~$0 for ETH-priced chains.
const fallbackPriceUSD = nativeTokenId === "ethereum" ? 2500 : nativeTokenId === "polygon-ecosystem-token" ? 0.5 : 0.08;
logger.error(`Failed to get ${nativeTokenId} price: ${error instanceof Error ? error.message : "Unknown error"}`);
const discoveredPriceUSD = getDiscoveredNativeTokenPriceUSD(fromNetwork);
// Static fallbacks cover the established chains when token discovery is unavailable. Newly
// discovered chains must bring their own validated native-token price or fail the quote.
const staticFallbackPriceUSD = STATIC_NATIVE_TOKEN_PRICE_FALLBACKS_USD[nativeTokenId];
const fallbackPriceUSD = discoveredPriceUSD ?? staticFallbackPriceUSD;
if (fallbackPriceUSD === undefined) {
logger.error(`No validated ${nativeTokenId} fallback price is available`);
throw error;
}
const squidFeeUSD = squidRouterSwapValue.mul(fallbackPriceUSD).toFixed(6);
logger.warn(`Using fallback ${nativeTokenId} price: $${fallbackPriceUSD}, fee: $${squidFeeUSD}`);
return squidFeeUSD;
Expand Down Expand Up @@ -198,7 +230,7 @@ async function getSquidrouterRouteData(routeParams: RouteParams, fromNetwork: Ne
} catch {
outputAmountUsd = null;
}
const networkFeeUSD = await calculateSquidrouterNetworkFee(routeData.route, fromNetwork);
const networkFeeUSD = await calculateSquidrouterNetworkFee(routeData.route, fromNetwork, routeParams);

return {
fromToken: routeParams.fromToken,
Expand Down
26 changes: 16 additions & 10 deletions apps/api/src/api/services/phases/blocks/flows/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ import {
FiatToken,
getNetworkFromDestination,
isDomesticToken,
isEvmToken,
isNetworkEVM,
isOnChainToken,
mapFiatToDestination,
Networks,
RampDirection
Expand Down Expand Up @@ -84,10 +84,10 @@ const flowDefinitions: FlowDefinition[] = [
{
create(request) {
const network = getNetworkFromDestination(request.from);
if (!network || !isNetworkEVM(network) || !isEvmToken(request.inputCurrency)) {
if (!network || !isNetworkEVM(network)) {
throw new APIError({ message: "Unsupported EVM source for EUR offramp", status: httpStatus.BAD_REQUEST });
}
return makeEurOfframpBaseFlow(request.inputCurrency, network);
return makeEurOfframpBaseFlow(request.inputCurrency as EvmToken, network);
},
executorFlow: eurOfframpBaseFlow,
matches(request) {
Expand All @@ -98,18 +98,21 @@ const flowDefinitions: FlowDefinition[] = [
request.to === EPaymentMethod.SEPA &&
network !== undefined &&
isNetworkEVM(network) &&
isEvmToken(request.inputCurrency) &&
evmTokenConfig[network][request.inputCurrency] !== undefined
// Structural only: the flow input resolver rejects symbols unknown to the merged token
// catalog at quote time. Matching must not depend on live token discovery, because
// persisted flows are re-resolved at startup, when discovery may have fallen back to
// the static config.
isOnChainToken(request.inputCurrency)
);
}
},
{
create(request) {
const network = getNetworkFromDestination(request.from);
if (!network || !isNetworkEVM(network) || !isEvmToken(request.inputCurrency)) {
if (!network || !isNetworkEVM(network)) {
throw new APIError({ message: "Unsupported EVM source for BRL offramp", status: httpStatus.BAD_REQUEST });
}
return makeBrlOfframpBaseFlow(request.inputCurrency, network);
return makeBrlOfframpBaseFlow(request.inputCurrency as EvmToken, network);
},
executorFlow: brlOfframpBaseFlow,
matches(request) {
Expand All @@ -119,8 +122,11 @@ const flowDefinitions: FlowDefinition[] = [
request.outputCurrency === FiatToken.BRL &&
network !== undefined &&
isNetworkEVM(network) &&
isEvmToken(request.inputCurrency) &&
evmTokenConfig[network][request.inputCurrency] !== undefined
// Structural only: the flow input resolver rejects symbols unknown to the merged token
// catalog at quote time. Matching must not depend on live token discovery, because
// persisted flows are re-resolved at startup, when discovery may have fallen back to
// the static config.
isOnChainToken(request.inputCurrency)
);
}
},
Expand All @@ -141,7 +147,7 @@ const flowDefinitions: FlowDefinition[] = [
request.to === mapFiatToDestination(request.outputCurrency as FiatToken) &&
network !== undefined &&
isNetworkEVM(network) &&
evmTokenConfig[network][request.inputCurrency as EvmToken] !== undefined
isOnChainToken(request.inputCurrency)
);
}
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@ import {
EvmToken,
EvmTransactionData,
evmTokenConfig,
NATIVE_TOKEN_ADDRESS,
Networks
} from "@vortexfi/shared";
import { encodeFunctionData, erc20Abi } from "viem";
import { requireAccount } from "../../core/accounts";
import { encodeEvmTransactionData } from "../../core/evm-transactions";
import type { PrepareCtx, PreparedPhaseTxs } from "../../core/types";
import type { PrepareCtx, PreparedPhaseTxs, TxIntent } from "../../core/types";
import type { EvmOfframpSourceRegistrationFacts } from "./registration";
import type { EvmOfframpSourceMetadata } from "./simulation";

Expand Down Expand Up @@ -58,23 +59,25 @@ export async function prepareEvmOfframpSourceTxs(
toNetwork: Networks.Base,
toToken: baseUsdc
});
const swapIntent: TxIntent = {
lane: "main",
network: metadata.fromNetwork,
phase: "squidRouterSwap",
signer: facts.userAddress,
txData: encodeEvmTransactionData(swapData) as EvmTransactionData
};
const intents: TxIntent[] = [swapIntent];
if (metadata.fromToken.toLowerCase() !== NATIVE_TOKEN_ADDRESS.toLowerCase()) {
intents.unshift({
lane: "main",
network: metadata.fromNetwork,
phase: "squidRouterApprove",
signer: facts.userAddress,
txData: encodeEvmTransactionData(approveData) as EvmTransactionData
});
}
return {
intents: [
{
lane: "main",
network: metadata.fromNetwork,
phase: "squidRouterApprove",
signer: facts.userAddress,
txData: encodeEvmTransactionData(approveData) as EvmTransactionData
},
{
lane: "main",
network: metadata.fromNetwork,
phase: "squidRouterSwap",
signer: facts.userAddress,
txData: encodeEvmTransactionData(swapData) as EvmTransactionData
}
],
intents,
state: { userAddress: facts.userAddress }
};
}
16 changes: 7 additions & 9 deletions apps/api/src/api/services/quote/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,12 @@ import pLimit from "p-limit";
import logger from "../../../config/logger";
import { config } from "../../../config/vars";
import { APIError } from "../../errors/api-error";
import { getTargetFiatCurrency, SUPPORTED_CHAINS, validateChainSupport } from "../phases/blocks/core/helpers";
import {
getTargetFiatCurrency,
isRetiredAssetHubCorridor,
SUPPORTED_CHAINS,
validateChainSupport
} from "../phases/blocks/core/helpers";
import { MykoboFeeUnavailableError } from "../phases/blocks/core/mykobo-fee";
import { runBlockQuoteFlow } from "../phases/blocks/core/quote";
import { buildBlockQuoteResponse } from "../phases/blocks/core/quote-response";
Expand Down Expand Up @@ -188,14 +193,7 @@ export class QuoteService extends BaseRampService {
): Promise<QuoteResponse> {
validateChainSupport(request.rampType, request.from, request.to);

if (
(request.rampType === RampDirection.BUY &&
request.inputCurrency === FiatToken.BRL &&
getNetworkFromDestination(request.to) === Networks.AssetHub) ||
(request.rampType === RampDirection.SELL &&
getNetworkFromDestination(request.from) === Networks.AssetHub &&
request.outputCurrency === FiatToken.BRL)
) {
if (isRetiredAssetHubCorridor(request)) {
throw new APIError({ message: QuoteError.FailedToCalculateQuote, status: httpStatus.BAD_REQUEST });
}

Expand Down
Loading
Loading