Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
3399c73
update numora version
Sharqiewicz May 27, 2026
3f30301
update placeholder
Sharqiewicz May 27, 2026
4c381b6
fix missing , in package.json
Sharqiewicz May 27, 2026
a9293a9
remove console.logs
Sharqiewicz May 27, 2026
f02d613
fix numeric input default value formatting
Sharqiewicz May 28, 2026
fa19fb4
hoist numeric input formatting options
Sharqiewicz May 28, 2026
1c728bd
simplify event handling numeric input
Sharqiewicz May 28, 2026
ad18552
update individuals hero section height
Sharqiewicz May 28, 2026
01784b1
prevent stale rampState payment details leaking into new quote
Sharqiewicz Jun 3, 2026
5087104
fix(frontend): support locale decimal separators in amount input
Sharqiewicz Jun 3, 2026
4d1250b
fix(frontend): hide submit spinner and quote error on empty input
Sharqiewicz Jun 3, 2026
f8b047f
feat(frontend): add countries and supported-crypto stats to PopularTo…
Sharqiewicz Jun 3, 2026
20c8a0d
fix(frontend): let hero section grow taller than the viewport
Sharqiewicz Jun 3, 2026
395b12b
refresh offramp quote right before use
gianfra-t Jun 4, 2026
bd18df7
Merge branch 'staging' into fix/wrong-localstorage-handling
ebma Jun 4, 2026
3071fe5
Amend merge
ebma Jun 4, 2026
f05f265
Merge pull request #1195 from pendulum-chain/fix/wrong-localstorage-h…
ebma Jun 4, 2026
472d411
fix phase routing for USDT on Polygon
gianfra-t Jun 4, 2026
27b8233
Stop backend substrate startup connections
ebma Jun 4, 2026
465c92a
Lazy load SDK substrate APIs
ebma Jun 4, 2026
5c7ea68
Lazy load frontend substrate nodes
ebma Jun 4, 2026
6197904
re-adjust phase flow
gianfra-t Jun 4, 2026
d0ffcc5
Address review comments
ebma Jun 4, 2026
7ac54bf
rollback unwanted flow routing changes
gianfra-t Jun 4, 2026
a224f02
Avoid eager frontend substrate connections
ebma Jun 4, 2026
c592086
Document lazy substrate RPC initialization
ebma Jun 4, 2026
153150c
Disable Assethub from frontend token selection
ebma Jun 4, 2026
5ce3d28
Merge branch 'staging' into chore/update-numora-version
ebma Jun 4, 2026
f271aff
Merge pull request #1167 from pendulum-chain/chore/update-numora-version
ebma Jun 4, 2026
ff11326
Potential fix for pull request finding
ebma Jun 4, 2026
acebd60
modify spec
gianfra-t Jun 4, 2026
ab44f3d
Merge pull request #1197 from pendulum-chain/improve-quote-refresh-al…
gianfra-t Jun 4, 2026
f733761
fix skip destinationTransfer condition
gianfra-t Jun 4, 2026
a0d5306
Reset Hydration SDK initialization on failure
ebma Jun 5, 2026
6e652e7
Make frontend substrate nodes opt-in
ebma Jun 5, 2026
ca650af
Merge pull request #1198 from pendulum-chain/remove-substrate-network…
ebma Jun 5, 2026
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
51 changes: 38 additions & 13 deletions apps/api/src/api/services/hydration/swap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,30 +11,40 @@ const CACHED_ASSET_IDS = [
];

export class HydrationRouter {
private sdk: Promise<SdkCtx>;
private sdk?: Promise<SdkCtx>;
private cachedXcmFees: Record<string, XcmFees>;
private xcmFeeRefreshInterval?: ReturnType<typeof setInterval>;

constructor() {
const apiManager = ApiManager.getInstance();
this.cachedXcmFees = {};
this.sdk = apiManager.getApi("hydration").then(async ({ api }) => {
return createSdkContext(api, {
router: { includeOnly: [PoolType.Omni, PoolType.Stable, PoolType.Aave] }
});
});

// Refresh transaction fees every hour
void this.refreshCachedXcmTransactionFeeToAssethub();
setInterval(() => this.refreshCachedXcmTransactionFeeToAssethub, 60 * 60 * 1000);
}

private getSdk(): Promise<SdkCtx> {
if (!this.sdk) {
const apiManager = ApiManager.getInstance();
this.sdk = apiManager
.getApi("hydration")
.then(async ({ api }) => {
return createSdkContext(api, {
router: { includeOnly: [PoolType.Omni, PoolType.Stable, PoolType.Aave] }
});
})
.catch(error => {
this.sdk = undefined;
throw error;
});
}

return this.sdk;
}

async getBestSellPriceFor(assetIn: string, assetOut: string, amountIn: string): Promise<Trade> {
const sdk = await this.sdk;
const sdk = await this.getSdk();
return sdk.api.router.getBestSell(assetIn, assetOut, amountIn);
}

async createTransactionForTrade(trade: Trade, beneficiaryAddress: string, slippage = 0.1): Promise<SubstrateTransaction> {
const sdk = await this.sdk;
const sdk = await this.getSdk();
const txBuilder = sdk.tx.trade(trade);
txBuilder.withBeneficiary(beneficiaryAddress);
txBuilder.withSlippage(slippage);
Expand All @@ -47,10 +57,25 @@ export class HydrationRouter {
return this.cachedXcmFees[assetId];
} else {
await this.refreshCachedXcmTransactionFeeToAssethub();
this.startXcmFeeRefreshInterval();
return this.cachedXcmFees[assetId];
}
}

private startXcmFeeRefreshInterval() {
if (!this.xcmFeeRefreshInterval) {
this.xcmFeeRefreshInterval = setInterval(
() => {
this.refreshCachedXcmTransactionFeeToAssethub().catch(error => {
const message = error instanceof Error ? error.message : String(error);
logger.error(`HydrationRouter: Error refreshing cached XCM transaction fees: ${message}`);
});
},
60 * 60 * 1000
);
}
}

private async refreshCachedXcmTransactionFeeToAssethub() {
logger.info("HydrationRouter: Refreshing cached XCM transaction fees..");
const placeholderSenderAddress = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,25 +66,29 @@ export class FinalSettlementSubsidyHandler extends BasePhaseHandler {
protected async executePhase(state: RampState): Promise<RampState> {
logger.debug(`FinalSettlementSubsidyHandler: Starting phase execution for ramp ${state.id}, type=${state.type}`);

if (state.state.isDirectTransfer === true) {
const quote = await QuoteTicket.findByPk(state.quoteId);
if (!quote) {
throw new Error("FinalSettlementSubsidyHandler: Quote not found for the given state");
}

if (
state.state.isDirectTransfer === true &&
!(state.type === RampDirection.SELL && isAlfredpayToken(quote.outputCurrency as FiatToken))
) {
logger.info(`FinalSettlementSubsidyHandler: Skipping subsidy for direct-transfer ramp ${state.id}`);
return this.transitionToNextPhase(state, "destinationTransfer");
return this.transitionToNextPhase(state, this.getNextPhase(state, quote));
}

const evmClientManager = EvmClientManager.getInstance();
const fundingAccount = getEvmFundingAccount(Networks.Moonbeam);

const quote = await QuoteTicket.findByPk(state.quoteId);
if (!quote) {
throw new Error("FinalSettlementSubsidyHandler: Quote not found for the given state");
}
logger.debug(
`FinalSettlementSubsidyHandler: Quote found. inputCurrency=${quote.inputCurrency}, outputCurrency=${quote.outputCurrency}, network=${quote.network}`
);

if (isFiatToOwnStablecoinBaseDirect(quote.inputCurrency, quote.outputCurrency, quote.network)) {
logger.info(`FinalSettlementSubsidyHandler: Skipping subsidy for Base direct-transfer route (ramp ${state.id})`);
return this.transitionToNextPhase(state, "destinationTransfer");
return this.transitionToNextPhase(state, this.getNextPhase(state, quote));
}

const isAlfredpaySell = state.type === RampDirection.SELL && isAlfredpayToken(quote.outputCurrency as FiatToken);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,8 @@ export class FundEphemeralPhaseHandler extends BasePhaseHandler {

protected nextPhaseSelector(state: RampState, quote: QuoteTicket): RampPhase {
if (
state.state.isDirectTransfer === true ||
(state.state.isDirectTransfer === true &&
!(state.type === RampDirection.SELL && isAlfredpayToken(quote.outputCurrency as FiatToken))) ||
(isOnramp(state) && isFiatToOwnStablecoinBaseDirect(quote.inputCurrency, quote.outputCurrency, quote.network))
) {
return "destinationTransfer";
Expand Down
13 changes: 0 additions & 13 deletions apps/api/src/api/services/priceFeed.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,19 +63,6 @@ export class PriceFeedService {

logger.info(`PriceFeedService initialized with CoinGecko API URL: ${this.coingeckoApiBaseUrl}`);
logger.info(`Cache TTLs configured - Crypto: ${this.cryptoCacheTtlMs}ms, Fiat: ${this.fiatCacheTtlMs}ms`);

// Start cron job to check onchain oracle prices
this.checkOnchainOraclePricesUpToDate().catch(error => {
logger.error(`Error checking onchain oracle prices: ${error.message}`);
});
setInterval(
() => {
this.checkOnchainOraclePricesUpToDate().catch(error => {
logger.error(`Error checking onchain oracle prices: ${error.message}`);
});
},
24 * 60 * 60 * 1000
); // Check every 24 hours
}

/**
Expand Down
67 changes: 66 additions & 1 deletion apps/api/src/api/services/ramp/ramp.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
AveniaPaymentMethod,
BrlaApiService,
BrlaCurrency,
CreateAlfredpayOfframpQuoteRequest,
CreateAlfredpayOnrampRequest,
EphemeralAccountType,
FiatToken,
Expand Down Expand Up @@ -207,6 +208,7 @@ export class RampService extends BaseRampService {
normalizedSigningAccounts,
additionalData,
signingAccounts,
transaction,
request.userId // will be undefined if not logged in. registerRamp is optional.
);

Expand Down Expand Up @@ -1028,8 +1030,15 @@ export class RampService extends BaseRampService {
quote: QuoteTicket,
normalizedSigningAccounts: AccountMeta[],
additionalData: RegisterRampRequest["additionalData"],
transaction: Transaction,
userId?: string
): Promise<{ unsignedTxs: UnsignedTx[]; stateMeta: Partial<StateMetadata> }> {
// We refresh the quote. It will be used in the transaction creation process, right after this.
if (isAlfredpayToken(quote.outputCurrency as FiatToken) && quote.metadata.alfredpayOfframp) {
const toCurrency = quote.outputCurrency as unknown as AlfredpayFiatCurrency;
await this.refreshAlfredpayOfframpQuoteIfMatching(quote, quote.metadata.alfredpayOfframp, toCurrency, transaction);
}

const { unsignedTxs, stateMeta } = await prepareOfframpTransactions({
destinationAddress: additionalData?.destinationAddress,
email: additionalData?.email,
Expand Down Expand Up @@ -1177,6 +1186,7 @@ export class RampService extends BaseRampService {
normalizedSigningAccounts: AccountMeta[],
additionalData: RegisterRampRequest["additionalData"],
signingAccounts: AccountMeta[],
transaction: Transaction,
userId?: string
): Promise<{
unsignedTxs: UnsignedTx[];
Expand All @@ -1190,7 +1200,7 @@ export class RampService extends BaseRampService {
return this.prepareOfframpBrlTransactions(quote, normalizedSigningAccounts, additionalData);

case RampTransactionPreparationKind.OfframpNonBrl:
return this.prepareOfframpNonBrlTransactions(quote, normalizedSigningAccounts, additionalData, userId);
return this.prepareOfframpNonBrlTransactions(quote, normalizedSigningAccounts, additionalData, transaction, userId);

case RampTransactionPreparationKind.OnrampMykobo:
return this.prepareMykoboOnrampTransactions(quote, normalizedSigningAccounts, additionalData, userId);
Expand Down Expand Up @@ -1460,6 +1470,61 @@ export class RampService extends BaseRampService {
}
}

private async refreshAlfredpayOfframpQuoteIfMatching(
quote: QuoteTicket,
originalAlfredpayOfframp: NonNullable<QuoteTicket["metadata"]["alfredpayOfframp"]>,
toCurrency: AlfredpayFiatCurrency,
transaction: Transaction
): Promise<string> {
const alfredpayService = AlfredpayApiService.getInstance();
const originalQuoteId = originalAlfredpayOfframp.quoteId;

const freshQuote = await alfredpayService.createOfframpQuote({
chain: AlfredpayChain.MATIC,
fromAmount: originalAlfredpayOfframp.inputAmountDecimal.toString(),
fromCurrency: ALFREDPAY_ONCHAIN_CURRENCY,
metadata: { businessId: "vortex", customerId: quote.userId || "unknown" },
paymentMethodType: AlfredpayPaymentMethodType.BANK,
toCurrency
} satisfies CreateAlfredpayOfframpQuoteRequest);

const originalToAmount = new Big(originalAlfredpayOfframp.outputAmountDecimal as unknown as string);
const freshToAmount = new Big(freshQuote.toAmount);

const originalFee = new Big(originalAlfredpayOfframp.fee as unknown as string);
const freshFee = AlfredpayApiService.sumFeesByCurrency(freshQuote.fees, toCurrency);

if (!freshToAmount.eq(originalToAmount) || !freshFee.eq(originalFee)) {
throw new APIError({
message:
`[refreshAlfredpayOfframpQuote] Quote ${quote.id}: refreshed Alfredpay offramp quote drifted. ` +
`toAmount original=${originalToAmount.toString()} fresh=${freshToAmount.toString()}, ` +
`fee original=${originalFee.toString()} fresh=${freshFee.toString()}. ` +
"Cannot proceed with offramp order.",
status: httpStatus.INTERNAL_SERVER_ERROR
});
}

await quote.update(
{
metadata: {
...quote.metadata,
alfredpayOfframp: {
...originalAlfredpayOfframp,
expirationDate: new Date(freshQuote.expiration),
quoteId: freshQuote.quoteId
}
}
},
{ transaction }
);

logger.info(
`[refreshAlfredpayOfframpQuote] Quote ${quote.id}: swapped Alfredpay offramp quote ${originalQuoteId} -> ${freshQuote.quoteId}.`
);
return freshQuote.quoteId;
}

private async processAlfredpayOfframpStart(
rampState: RampState,
quote: QuoteTicket,
Expand Down
5 changes: 1 addition & 4 deletions apps/api/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ApiManager, EvmClientManager, initializeEvmTokens, setLogger } from "@vortexfi/shared";
import { EvmClientManager, initializeEvmTokens, setLogger } from "@vortexfi/shared";
import dotenv from "dotenv";
import path from "path";
import cryptoService from "./config/crypto";
Expand Down Expand Up @@ -57,9 +57,6 @@ const initializeApp = async () => {
// Run database migrations
await runMigrations();

const apiManager = ApiManager.getInstance();
await apiManager.populateAllApis();

// Initialize EVM clients
const _evmClientManager = EvmClientManager.getInstance();

Expand Down
5 changes: 3 additions & 2 deletions apps/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,8 @@
"lottie-react": "^2.4.1",
"lucide-react": "^0.562.0",
"motion": "^12.0.3",
"numora": "^3.0.2",
"numora-react": "3.0.3",
"numora": "^4.0.0",
"numora-react": "^4.0.0",
"qrcode.react": "^4.2.0",
"radix-ui": "^1.4.3",
"react": "=19.2.0",
Expand All @@ -66,6 +66,7 @@
"stellar-sdk": "catalog:",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.0.3",
"torph": "^0.0.9",
"viem": "catalog:",
"wagmi": "catalog:",
"web3": "^4.16.0",
Expand Down
7 changes: 3 additions & 4 deletions apps/frontend/src/components/AssetNumericInput/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { EvmToken, Networks } from "@vortexfi/shared";
import type { ChangeEvent, FC } from "react";
import type { UseFormRegisterReturn } from "react-hook-form";
import { cn } from "../../helpers/cn";
import { QuoteFormValues } from "../../hooks/quote/schema";
import { AssetButton } from "../buttons/AssetButton";
Expand All @@ -23,7 +22,7 @@ interface AssetNumericInputProps {
tokenLoading?: boolean;
logoURI?: string;
fallbackLogoURI?: string;
registerInput: UseFormRegisterReturn<keyof QuoteFormValues>;
name: keyof QuoteFormValues;
id: string;
network?: Networks;
}
Expand All @@ -32,7 +31,7 @@ export const AssetNumericInput: FC<AssetNumericInputProps> = ({
assetIcon,
tokenSymbol,
onClick,
registerInput,
name,
loading,
tokenLoading,
...rest
Expand Down Expand Up @@ -62,7 +61,7 @@ export const AssetNumericInput: FC<AssetNumericInputProps> = ({
additionalStyle={cn("text-right text-lg", rest.readOnly && "text-xl", rest.disabled && "input-disabled opacity-50")}
loading={loading}
maxDecimals={getMaxDecimalsForToken(tokenSymbol)}
register={registerInput}
name={name}
{...rest}
/>
)}
Expand Down
Loading
Loading