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
111 changes: 95 additions & 16 deletions mobile/packages/tx/transaction-builder/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,20 +247,6 @@ export function selectUtxosForAmounts(
const primaryTokenIdStr =
typeof primaryTokenId === 'string' ? primaryTokenId : primaryTokenId

const requiredAda =
(Object.keys(requiredAmounts) as Array<TokenId>).reduce((sum, tokenId) => {
const tokenIdStr = typeof tokenId === 'string' ? tokenId : tokenId
if (tokenIdStr === primaryTokenIdStr) {
const quantity = requiredAmounts[tokenId]
const qtyStr = typeof quantity === 'string' ? quantity : quantity
return sum + BigInt(qtyStr || '0')
}
return sum
}, BigInt(0)) +
BigInt(feeStr) +
minUtxoValue +
feeBuffer

// Get all required token IDs (excluding primary token)
const requiredTokenIds = new Set(
Object.keys(requiredAmounts).filter((id) => {
Expand Down Expand Up @@ -299,7 +285,6 @@ export function selectUtxosForAmounts(
}

// Check if we have enough of each token
let needsMoreAda = selectedAda < requiredAda
const needsMoreTokens: TokenId[] = []

for (const tokenId of requiredTokenIds) {
Expand Down Expand Up @@ -331,11 +316,105 @@ export function selectUtxosForAmounts(
return selected
}

// Calculate tokens that will remain in change (tokens in selected UTXOs minus tokens being sent)
const tokensInChange: Record<string, bigint> = {}
for (const [tokenId, inputAmount] of Object.entries(selectedAmounts)) {
if (tokenId === primaryTokenIdStr) continue
const outputAmount = BigInt(requiredAmounts[tokenId as TokenId] || '0')
const remaining = inputAmount - outputAmount
if (remaining > BigInt(0)) {
tokensInChange[tokenId] = remaining
}
}

// Estimate minimum UTXO for change output based on token count
// When tokens will be in change, the minimum UTXO requirement increases significantly
// Use a conservative estimate: base minimum + additional ADA per token
// This is a heuristic since exact calculation requires CSL
const tokenCountInChange = Object.keys(tokensInChange).length
let estimatedMinUtxoForChange = minUtxoValue
if (tokenCountInChange > 0) {
// Conservative estimate: base minimum + 0.1 ADA per token (scaled for safety)
// Actual minimum can be higher depending on token sizes, but this provides a buffer
const adaPerToken = BigInt('100000') // 0.1 ADA per token
const tokenMultiplier = BigInt(Math.max(tokenCountInChange, 1))
estimatedMinUtxoForChange =
minUtxoValue + adaPerToken * tokenMultiplier * BigInt(2) // 2x multiplier for safety
}

// Calculate base required ADA (outputs + fee + buffer) - min UTXO calculated separately
const baseRequiredAda =
(Object.keys(requiredAmounts) as Array<TokenId>).reduce((sum, tokenId) => {
const tokenIdStr = typeof tokenId === 'string' ? tokenId : tokenId
if (tokenIdStr === primaryTokenIdStr) {
const quantity = requiredAmounts[tokenId]
const qtyStr = typeof quantity === 'string' ? quantity : quantity
return sum + BigInt(qtyStr || '0')
}
return sum
}, BigInt(0)) +
BigInt(feeStr) +
feeBuffer

// Calculate required ADA including proper change output minimum
let requiredAda = baseRequiredAda + estimatedMinUtxoForChange

// If we need more ADA, select from remaining UTXOs
// Prefer pure ADA UTXOs to avoid adding unexpected tokens to change output
// This prevents underestimating minimum UTXO when non-required tokens end up in change
let needsMoreAda = selectedAda < requiredAda
if (needsMoreAda) {
const sortedRemaining = sortUtxosByAda(utxosWithoutTokens, primaryTokenId)
const pureAdaUtxos = filterPureAdaUtxos(utxosWithoutTokens, primaryTokenId)
const otherUtxos = utxosWithoutTokens.filter(
(u) => !pureAdaUtxos.includes(u),
)

// Always prefer pure ADA UTXOs first to avoid adding tokens to change
// This ensures minimum UTXO estimate remains accurate
const sortedRemaining = [
...sortUtxosByAda(pureAdaUtxos, primaryTokenId),
...sortUtxosByAda(otherUtxos, primaryTokenId),
]

// Track tokens being added and update minimum UTXO estimate dynamically
let currentTokensInChange = {...tokensInChange}
let currentEstimatedMinUtxo = estimatedMinUtxoForChange
const adaPerToken = BigInt('100000') // 0.1 ADA per token

for (const utxo of sortedRemaining) {
// Check if this UTXO adds any tokens to change
const utxoTokenIds = (Object.keys(utxo.balance) as Array<TokenId>).filter(
(id) => {
const idStr = typeof id === 'string' ? id : id
return idStr !== primaryTokenIdStr
},
)

// Update tokens in change if UTXO contains non-required tokens
if (utxoTokenIds.length > 0) {
for (const tokenId of utxoTokenIds) {
const tokenIdStr = typeof tokenId === 'string' ? tokenId : tokenId
const tokenAmount = BigInt(utxo.balance[tokenId] || '0')
if (tokenAmount > BigInt(0)) {
// This token will be added to change (not required, so goes to change)
if (!currentTokensInChange[tokenIdStr]) {
currentTokensInChange[tokenIdStr] = BigInt(0)
}
currentTokensInChange[tokenIdStr] += tokenAmount
}
}

// Recalculate minimum UTXO estimate based on updated token count
const newTokenCount = Object.keys(currentTokensInChange).length
if (newTokenCount > 0) {
const tokenMultiplier = BigInt(newTokenCount)
currentEstimatedMinUtxo =
minUtxoValue + adaPerToken * tokenMultiplier * BigInt(2)
// Update required ADA with new minimum UTXO estimate
requiredAda = baseRequiredAda + currentEstimatedMinUtxo
}
}

if (selectedAda >= requiredAda) break
Copy link

Choose a reason for hiding this comment

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

Bug: UTXO token tracking occurs before selection decision

In the UTXO selection loop, currentTokensInChange and requiredAda are updated based on the current UTXO's tokens before checking whether to select the UTXO. This means if the loop breaks at line 418 after updating these values, the tokens were counted as going to change even though the UTXO was never added to selected. Additionally, by increasing requiredAda before deciding to include the UTXO, the algorithm may select more UTXOs than necessary, since each UTXO with tokens increases the requirement before its ADA contribution is added to selectedAda. The token tracking and requiredAda recalculation (lines 394-416) should happen after adding the UTXO to selected (lines 419-420), not before checking the break condition.

Fix in Cursor Fix in Web

selected.push(utxo)
selectedAda += BigInt(utxo.balance[primaryTokenIdStr] || '0')
Expand Down
44 changes: 27 additions & 17 deletions mobile/src/features/Airdrop/ui/AirdropDetailsScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,15 @@ import {
} from '@react-navigation/native'
import {StackNavigationProp} from '@react-navigation/stack'
import {BigNumber} from 'bignumber.js'
import {LinearGradient} from 'expo-linear-gradient'
import * as React from 'react'
import {ScrollView, Text, TouchableOpacity, View} from 'react-native'
import {
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native'
import {SafeAreaView} from 'react-native-safe-area-context'

import {Address} from '~/common/Address/Address'
Expand Down Expand Up @@ -333,15 +340,25 @@ export const AirdropDetailsScreen = () => {
{/* Redeemable Now Card */}
<TouchableOpacity
activeOpacity={0.8}
style={[
a.p_lg,
a.rounded_sm,
{
backgroundColor:
currentlyRedeemableAmount > 0 ? p.bg_gradient_2[0] : p.gray_400,
},
]}
style={[a.p_lg, a.rounded_sm, a.overflow_hidden]}
>
{currentlyRedeemableAmount > 0 ? (
<LinearGradient
colors={p.bg_gradient_1}
start={{x: 1, y: 1}}
end={{x: 0, y: 0}}
style={[StyleSheet.absoluteFill]}
/>
) : (
<View
style={[
StyleSheet.absoluteFill,
a.border,
a.rounded_sm,
{borderColor: p.gray_100},
]}
/>
)}
<View style={[a.flex_row, a.justify_between, a.align_center]}>
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
<Text style={[a.body_1_lg_medium, ta.text_gray_max]}>
Expand Down Expand Up @@ -385,14 +402,7 @@ export const AirdropDetailsScreen = () => {
<TouchableOpacity
onPress={handleOpenThawSchedule}
activeOpacity={0.8}
style={[
a.p_lg,
a.rounded_sm,
{
borderWidth: 1,
borderColor: p.gray_200,
},
]}
style={[a.p_lg, a.rounded_sm, a.border, {borderColor: p.gray_200}]}
>
<View style={[a.flex_row, a.justify_between, a.align_center]}>
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
Expand Down
20 changes: 11 additions & 9 deletions mobile/src/features/Airdrop/ui/AirdropSelectionScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {useNavigation} from '@react-navigation/native'
import {StackNavigationProp} from '@react-navigation/stack'
import {useQueryClient} from '@tanstack/react-query'
import {BigNumber} from 'bignumber.js'
import {LinearGradient} from 'expo-linear-gradient'
import * as React from 'react'
import {useIntl} from 'react-intl'
import {
Expand All @@ -12,6 +13,7 @@ import {
Pressable,
RefreshControl,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View,
Expand Down Expand Up @@ -390,15 +392,15 @@ const AddressCard = ({
]}
>
{({pressed}) => (
<View
style={[
a.flex_row,
{
backgroundColor:
pressed || hasRedeemable ? p.bg_gradient_1[0] : 'transparent',
},
]}
>
<View style={[a.flex_row]}>
{(pressed || hasRedeemable) && (
<LinearGradient
colors={pressed ? p.bg_gradient_2 : p.bg_gradient_1}
start={{x: 1, y: 1}}
end={{x: 0, y: 0}}
style={[StyleSheet.absoluteFill]}
/>
)}
<View style={[a.flex_1, a.p_lg]}>
{/* Header row */}
<View style={[a.flex_row, a.justify_between, a.align_center]}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ import {
ChevronRightDarkIllustration,
ChevronRightGrayIllustration,
} from '~/features/SetupWallet/illustrations/ChevronRight'
import {features} from '~/kernel/features'
import {Icon} from '~/ui/Icon'
import {Loading} from '~/ui/Loading/Loading'
import {Space} from '~/ui/Space/Space'
Expand Down Expand Up @@ -166,17 +165,11 @@ export const WalletListItem = ({
</>
)}

{features.walletListFeedback && (
<>
{(syncWalletInfo?.status === 'syncing' || isLoading) && (
<Loading />
)}
{(syncWalletInfo?.status === 'syncing' || isLoading) && <Loading />}

<Space.Width.md />
<Space.Width.md />

{isSelected && <Icon.Check size={20} color={p.primary_600} />}
</>
)}
{isSelected && <Icon.Check size={20} color={p.primary_600} />}

<Space.Width.xl />

Expand Down
3 changes: 0 additions & 3 deletions mobile/src/kernel/features.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,12 @@ import {isDev} from './constants'
export const features = {
useTestnet: false,
prefillWalletInfo: false,
showProdPoolsInDev: isDev,
moderatingNftsEnabled: false,
poolTransition: true,
portfolioPerformance: false,
portfolioNews: false,
portfolioExport: false,
walletListFeedback: isDev,
walletListAggregatedBalance: false,
walletListSwipeableActions: isDev,
swapTokenLinks: true,
utxoConsolidation: isDev,
pushNotifications: true,
Expand Down
Loading