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
9 changes: 2 additions & 7 deletions ignition-pay-frontend/components/transaction-row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,16 +105,11 @@ export function TransactionRow({ transaction }: TransactionRowProps) {
})

const isOptimistic = isOptimisticTransaction(transaction)

// Determine standard fee
const networkFee = '0.00001 XLM'
const explorerLink = transaction.txHash
? `https://stellar.expert/explorer/public/tx/${transaction.txHash}`
: '#'
const txId = isOptimistic ? transaction.optimisticId : transaction.id

return (
<Link
href={`/transactions/${transaction.id}`}
href={isOptimistic ? '#' : `/transactions/${txId}`}
className={`flex items-center justify-between py-4 px-4 rounded-lg transition-colors border ${
isOptimistic
? 'bg-yellow-500/5 border-yellow-500/30 hover:bg-yellow-500/10'
Expand Down
66 changes: 60 additions & 6 deletions ignition-pay-frontend/features/send/models/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,63 @@ export interface SendableAsset {
reserved?: number
}

/** Fee charged per operation, in XLM. */
export const NETWORK_FEE_XLM = 0.00001
/** Stellar base fee per operation in stroops (1 XLM = 10,000,000 stroops). */
export const BASE_FEE_STROOPS = 100
export const STROOPS_PER_XLM = 10_000_000

/** Default fee charged per single-operation transaction, in XLM (100 stroops). */
export const DEFAULT_NETWORK_FEE_XLM = 0.00001

/** Fee charged per operation, in XLM (default fallback). */
export const NETWORK_FEE_XLM = DEFAULT_NETWORK_FEE_XLM

export interface NetworkFeeEstimate {
feeInXlm: number
feeInStroops: number
formattedFee: string
baseFeeInStroops: number
operationCount: number
isDynamic: boolean
source: 'horizon' | 'fallback'
}

/**
* Calculates the transaction fee in Stroops, XLM, and human-readable string
* based on base fee and operation count.
*/
export function calculateTransactionFee(
baseFeeStroops: number = BASE_FEE_STROOPS,
operationCount: number = 1,
source: 'horizon' | 'fallback' = 'fallback',
): NetworkFeeEstimate {
const safeBaseFee = Math.max(
BASE_FEE_STROOPS,
Number.isFinite(baseFeeStroops) ? baseFeeStroops : BASE_FEE_STROOPS,
)
const safeOpCount = Math.max(
1,
Number.isFinite(operationCount) ? Math.floor(operationCount) : 1,
)
const feeInStroops = safeBaseFee * safeOpCount
const feeInXlm = feeInStroops / STROOPS_PER_XLM
const formattedFee = `${formatAmount(feeInXlm)} XLM`

return {
feeInXlm,
feeInStroops,
formattedFee,
baseFeeInStroops: safeBaseFee,
operationCount: safeOpCount,
isDynamic: source === 'horizon',
source,
}
}

/** Amount of `asset` the sender can actually send, after reserves and fees. */
export function spendableBalance(asset: SendableAsset): number {
export function spendableBalance(asset: SendableAsset, customFee?: number): number {
const reserved = asset.reserved ?? 0
const feeAllowance = asset.issuer === 'native' ? NETWORK_FEE_XLM : 0
const fee = customFee ?? (asset.issuer === 'native' ? NETWORK_FEE_XLM : 0)
const feeAllowance = asset.issuer === 'native' ? fee : 0

return Math.max(0, asset.balance - reserved - feeAllowance)
}
Expand All @@ -30,7 +80,11 @@ export interface AmountValidationResult {
/** Stellar amounts carry at most 7 decimal places. */
const MAX_DECIMAL_PLACES = 7

export function validateAmount(value: string, asset: SendableAsset): AmountValidationResult {
export function validateAmount(
value: string,
asset: SendableAsset,
customFee?: number,
): AmountValidationResult {
const trimmed = value.trim()

if (trimmed.length === 0) {
Expand All @@ -54,7 +108,7 @@ export function validateAmount(value: string, asset: SendableAsset): AmountValid
}
}

const spendable = spendableBalance(asset)
const spendable = spendableBalance(asset, customFee)
if (amount > spendable) {
return {
isValid: false,
Expand Down
104 changes: 104 additions & 0 deletions ignition-pay-frontend/features/send/services/fee.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { BASE_FEE_STROOPS, calculateTransactionFee, type NetworkFeeEstimate } from '../models'
import { TIMEOUT } from '@/lib/constants'

export interface HorizonFeeStatsResponse {
last_ledger?: string
last_ledger_base_fee?: string
fee_charged?: {
min?: string
mode?: string
p50?: string
p90?: string
p95?: string
p99?: string
}
}

export interface NetworkFeeStats {
baseFeeStroops: number
p50FeeStroops: number
source: 'horizon' | 'fallback'
}

function horizonBaseUrl(): string {
const configured = process.env.NEXT_PUBLIC_STELLAR_HORIZON_URL
if (configured) return configured.replace(/\/$/, '')

return 'https://horizon.stellar.org'
}

/**
* Fetches dynamic network fee stats from the Stellar Horizon `/fee_stats` endpoint.
* Falls back to the protocol standard minimum base fee (100 stroops) on failure.
*/
export async function fetchNetworkFeeStats(signal?: AbortSignal): Promise<NetworkFeeStats> {
const url = `${horizonBaseUrl()}/fee_stats`
const timeout = AbortSignal.timeout(TIMEOUT.default)
const composed = signal ? AbortSignal.any([signal, timeout]) : timeout

try {
const response = await fetch(url, {
signal: composed,
headers: { Accept: 'application/json' },
})

if (!response.ok) {
return {
baseFeeStroops: BASE_FEE_STROOPS,
p50FeeStroops: BASE_FEE_STROOPS,
source: 'fallback',
}
}

const payload = (await response.json()) as HorizonFeeStatsResponse
const lastLedgerBase = payload.last_ledger_base_fee
? parseInt(payload.last_ledger_base_fee, 10)
: BASE_FEE_STROOPS
const p50 = payload.fee_charged?.p50
? parseInt(payload.fee_charged.p50, 10)
: lastLedgerBase

const baseFeeStroops =
Number.isFinite(lastLedgerBase) && lastLedgerBase >= BASE_FEE_STROOPS
? lastLedgerBase
: BASE_FEE_STROOPS

const p50FeeStroops =
Number.isFinite(p50) && p50 >= BASE_FEE_STROOPS ? p50 : baseFeeStroops

return {
baseFeeStroops,
p50FeeStroops,
source: 'horizon',
}
} catch {
return {
baseFeeStroops: BASE_FEE_STROOPS,
p50FeeStroops: BASE_FEE_STROOPS,
source: 'fallback',
}
}
}

export interface EstimateTransactionFeeOptions {
operationCount?: number
baseFeeStroops?: number
signal?: AbortSignal
}

/**
* Estimates transaction fee based on current Stellar network conditions (via Horizon)
* and transaction complexity (operation count).
*/
export async function estimateTransactionFee(
options: EstimateTransactionFeeOptions = {},
): Promise<NetworkFeeEstimate> {
const { operationCount = 1, baseFeeStroops, signal } = options

if (baseFeeStroops !== undefined && Number.isFinite(baseFeeStroops)) {
return calculateTransactionFee(baseFeeStroops, operationCount, 'fallback')
}

const stats = await fetchNetworkFeeStats(signal)
return calculateTransactionFee(stats.p50FeeStroops, operationCount, stats.source)
}
1 change: 1 addition & 0 deletions ignition-pay-frontend/features/send/services/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './fee'
import { API_BASE_URLS, API_ENDPOINTS, API_PREFIX, TIMEOUT } from '@/lib/constants'

export type TrustlineStatus =
Expand Down
74 changes: 47 additions & 27 deletions ignition-pay-frontend/features/send/widgets/SendPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ import {
type MemoType,
} from '@/lib/stellar/memo'
import { AssetAmountPicker } from '@/components/asset-amount-picker'
import { validateAmount, type SendableAsset } from '@/features/send/models'
import { checkTrustline, type TrustlineCheck } from '@/features/send/services'
import { validateAmount, type SendableAsset, type NetworkFeeEstimate } from '@/features/send/models'
import { checkTrustline, estimateTransactionFee, type TrustlineCheck } from '@/features/send/services'
import { useOptimisticTransactions } from '@/features/history/state'
import { useToast } from '@/components/ui/toast'
import { API_BASE_URLS, API_PREFIX } from '@/lib/constants/api'
Expand Down Expand Up @@ -76,14 +76,16 @@ export function SendPage({ address: addressProp }: SendPageProps = {}) {
})
const [trustline, setTrustline] = useState<TrustlineCheck | null>(null)
const [isCheckingTrustline, setIsCheckingTrustline] = useState(false)
const [estimatedFee, setEstimatedFee] = useState<NetworkFeeEstimate | null>(null)
const [isEstimatingFee, setIsEstimatingFee] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const [optimisticId, setOptimisticId] = useState<string | null>(null)

const selectedAsset =
sendableAssets.find((asset) => asset.code === formData.asset) ?? sendableAssets[0]
const amountCheck = useMemo(
() => validateAmount(formData.amount, selectedAsset),
[formData.amount, selectedAsset],
() => validateAmount(formData.amount, selectedAsset, estimatedFee?.feeInXlm),
[formData.amount, selectedAsset, estimatedFee?.feeInXlm],
)

// Verify the recipient can hold the asset once the review step is reached, so
Expand All @@ -106,6 +108,29 @@ export function SendPage({ address: addressProp }: SendPageProps = {}) {
return () => controller.abort()
}, [step, formData.recipient, selectedAsset.code, selectedAsset.issuer])

// Dynamically estimate network fee based on network conditions and transaction complexity
useEffect(() => {
if (step !== 'review') return

const controller = new AbortController()
setIsEstimatingFee(true)

const operationCount = 1

estimateTransactionFee({ operationCount, signal: controller.signal })
.then((fee) => {
if (!controller.signal.aborted) setEstimatedFee(fee)
})
.catch(() => {
// Fallback is handled inside estimateTransactionFee
})
.finally(() => {
if (!controller.signal.aborted) setIsEstimatingFee(false)
})

return () => controller.abort()
}, [step, formData.asset, formData.recipient])

const recipientCheck = useMemo(
() => validateStellarAddress(formData.recipient),
[formData.recipient],
Expand Down Expand Up @@ -241,7 +266,9 @@ export function SendPage({ address: addressProp }: SendPageProps = {}) {
</div>
<div>
<p className="text-muted-foreground">Network Fee</p>
<p className="text-foreground font-semibold">0.00001 XLM</p>
<p className="text-foreground font-semibold">
{estimatedFee?.formattedFee ?? '0.00001 XLM'}
</p>
</div>
</div>
<div className="flex gap-3">
Expand Down Expand Up @@ -287,41 +314,25 @@ export function SendPage({ address: addressProp }: SendPageProps = {}) {
<div className="max-w-2xl mx-auto px-6 py-8">
{/* Progress Steps */}
<div className="flex items-center justify-center gap-4 mb-12">
<div
className={`flex items-center justify-center w-10 h-10 rounded-full font-semibold transition-all ${
step === 'form' || step === 'review' || step === 'confirmed'
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground'
}`}
>
<div className="flex items-center justify-center w-10 h-10 rounded-full font-semibold transition-all bg-primary text-primary-foreground">
1
</div>
<div
className={`flex-1 h-1 transition-all ${
step === 'review' || step === 'confirmed' ? 'bg-primary' : 'bg-muted'
step === 'review' ? 'bg-primary' : 'bg-muted'
}`}
/>
<div
className={`flex items-center justify-center w-10 h-10 rounded-full font-semibold transition-all ${
step === 'review' || step === 'confirmed'
step === 'review'
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground'
}`}
>
2
</div>
<div
className={`flex-1 h-1 transition-all ${
step === 'confirmed' ? 'bg-primary' : 'bg-muted'
}`}
/>
<div
className={`flex items-center justify-center w-10 h-10 rounded-full font-semibold transition-all ${
step === 'confirmed'
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground'
}`}
>
<div className="flex-1 h-1 transition-all bg-muted" />
<div className="flex items-center justify-center w-10 h-10 rounded-full font-semibold transition-all bg-muted text-muted-foreground">
3
</div>
</div>
Expand Down Expand Up @@ -530,7 +541,16 @@ export function SendPage({ address: addressProp }: SendPageProps = {}) {
</div>
<div className="border-t border-border pt-4 flex items-center justify-between">
<span className="text-muted-foreground">Network Fee</span>
<span className="font-semibold text-foreground">0.00001 XLM</span>
{isEstimatingFee ? (
<span className="flex items-center gap-1.5 text-sm text-muted-foreground">
<Loader2 size={14} className="animate-spin" />
Estimating…
</span>
) : (
<span className="font-semibold text-foreground">
{estimatedFee?.formattedFee ?? '0.00001 XLM'}
</span>
)}
</div>
<div className="border-t border-border pt-4 flex items-center justify-between">
<span className="text-muted-foreground">Recipient trustline</span>
Expand Down
Loading