Skip to content

Conversation

@samholmes
Copy link
Contributor

@samholmes samholmes commented Jul 15, 2025

CHANGELOG

Does this branch warrant an entry to the CHANGELOG?

  • Yes
  • No

Dependencies

none

Requirements

If you have made any visual changes to the GUI. Make sure you have:

  • Tested on iOS device
  • Tested on Android device
  • Tested on small-screen device (iPod Touch)
  • Tested on large-screen device (tablet)


Note

Integrates reports server data into transaction details

  • Adds util/reportsServer to query order status and amounts, map to EdgeTxSwap/EdgeTxActionSwap, and merge into receive tx’s without swap data; config via ENV.REPORTS_SERVERS.
  • TransactionDetailsScene: uses react-query to fetch reports tx info, updates transaction.savedAction, and passes status/loading to SwapDetailsCard.
  • SwapDetailsCard: refactored to support optional source wallet, shows quote type and fetched exchange status, opens status page, and renders details via new DataSheetModal; adjusts support email body serialization.
  • New UI components: DataSheetModal (sectioned data display) and ShimmerText (loading state); EdgeRow gains shimmer prop. Snapshots updated for accessibility state and loading.
  • Adds locale strings (send/receive amount, exchange status, quote type, unknown) and minor type/cleanup changes.

Written by Cursor Bugbot for commit 21db217. This will update automatically on new commits. Configure here.

cursor[bot]

This comment was marked as outdated.

cursor[bot]

This comment was marked as outdated.

Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

Bug: Render Phase Mutation Causes React Reconciliation Issues

Direct mutation of the transaction prop object occurs within the component's render phase, specifically modifying transaction.savedAction and transaction.assetAction. This violates React's immutability principles and render purity, causing unexpected behavior, potential performance issues, and reconciliation problems. Compounding this, the mutation condition uses reference equality for edgeTxActionSwapFromReports (a useMemo result), potentially causing unnecessary mutations on every render even when data is identical.

src/components/scenes/TransactionDetailsScene.tsx#L134-L143

// Update the transaction object with saveAction data from reports server:
if (
edgeTxActionSwapFromReports != null &&
transaction.savedAction !== edgeTxActionSwapFromReports
) {
transaction.savedAction = edgeTxActionSwapFromReports
transaction.assetAction = {
assetActionType: 'swap'
}
}

Fix in CursorFix in Web


Bug: URL Handling Mismatch Causes Runtime Errors

The code exhibits inconsistent URL handling, mixing URLParse objects (from the url-parse library) with native URL constructor calls. This creates potential type mismatches, as functions like cleanFetch expect native URL objects. A critical instance of this is when new URL() is called with paymentRequestUrl, which is derived from a URL query parameter. Since query parameters can be relative paths, passing them to the native URL constructor (which requires an absolute URL) will cause a TypeError at runtime.

src/plugins/gui/providers/ioniaProvider.ts#L20-L421

import { sprintf } from 'sprintf-js'
import URLParse from 'url-parse'
import { lstrings } from '../../../locales/strings'
import { wasBase64 } from '../../../util/cleaners/asBase64'
import { cleanFetch, fetcherWithOptions } from '../../../util/cleanFetch'
import { getCurrencyCodeMultiplier } from '../../../util/CurrencyInfoHelpers'
import { logActivity } from '../../../util/logger'
import {
FiatProvider,
FiatProviderAssetMap,
FiatProviderFactory,
FiatProviderGetQuoteParams,
FiatProviderQuote
} from '../fiatProviderTypes'
import { RewardsCardItem, UserRewardsCards } from '../RewardsCardPlugin'
const providerId = 'ionia'
// JWT 24 hour access token for Edge
let ACCESS_TOKEN: string
const ONE_MINUTE = 1000 * 60
const RATE_QUOTE_CARD_AMOUNT = 500
const HARD_CURRENCY_PRECISION = 8
const MAX_FIAT_CARD_PURCHASE_AMOUNT = 1000
const MIN_FIAT_CARD_PURCHASE_AMOUNT = 10
const ioniaBaseRequestOptions = {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
}
const asIoniaPluginApiKeys = asObject({
clientId: asString,
clientSecret: asString,
ioniaBaseUrl: asString,
merchantId: asNumber,
scope: asString
})
export const asRewardsCard = asCodec<RewardsCardItem>(
raw => {
const ioniaCard = asObject({
Id: asNumber,
ActualAmount: asOptional(asNumber),
CardNumber: asString,
CreatedDate: asDate,
Currency: asOptional(asString)
})(raw)
const purchaseAsset = ioniaCard.Currency
const amount = ioniaCard.ActualAmount
// Expires 6 calendar months from the creation date
const expirationDate = new Date(ioniaCard.CreatedDate.valueOf())
expirationDate.setMonth(ioniaCard.CreatedDate.getMonth() + 6)
return {
id: ioniaCard.Id,
creationDate: ioniaCard.CreatedDate,
expirationDate,
amount,
purchaseAsset,
url: ioniaCard.CardNumber
}
},
rewardCard => ({
Id: rewardCard.id,
ActualAmount: rewardCard.amount,
CardNumber: rewardCard.url,
CreatedDate: rewardCard.creationDate,
Currency: rewardCard.purchaseAsset
})
)
export type IoniaPurchaseCard = ReturnType<typeof asIoniaPurchaseCard>
export const asIoniaPurchaseCard = asObject({
paymentId: asString,
order_id: asString,
uri: asString,
currency: asString,
amount: asNumber,
status: asValue('PENDING'),
success: asBoolean,
userId: asNumber
})
const asIoniaResponse = <Data extends any>(asData: Cleaner<Data>) =>
asObject({
Data: asData,
Successful: asBoolean,
ErrorMessage: asString
})
const asStoreHiddenCards = asOptional(asJSON(asArray(asNumber)), [])
const wasStoreHiddenCards = uncleaner(asStoreHiddenCards)
export interface IoniaMethods {
authenticate: (shouldCreate?: boolean) => Promise<boolean>
getRewardsCards: () => Promise<UserRewardsCards>
hideCard: (cardId: number) => Promise<void>
}
export const makeIoniaProvider: FiatProviderFactory<IoniaMethods> = {
providerId: 'ionia',
storeId: 'ionia',
async makeProvider(params) {
const { makeUuid, store } = params.io
const pluginKeys = asIoniaPluginApiKeys(params.apiKeys)
const STORE_USERNAME_KEY = `${pluginKeys.scope}:userName`
const STORE_EMAIL_KEY = `${pluginKeys.scope}:uuidEmail`
const STORE_HIDDEN_CARDS_KEY = `${pluginKeys.scope}:hiddenCards`
//
// Fetch API
//
// OAuth Access Token Request:
const fetchAccessToken = cleanFetch({
resource: new URL(`https://auth.craypay.com/connect/token`),
options: {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
},
asRequest: asOptional(
asString,
`grant_type=client_credentials&scope=${pluginKeys.scope}`
),
asResponse: asJSON(
asObject({
access_token: asString,
expires_in: asNumber,
token_type: asString,
scope: asString
})
)
})
// Ionia Create User:
const fetchCreateUserBase = cleanFetch({
resource: new URL(`${pluginKeys.ioniaBaseUrl}/CreateUser`),
options: ioniaBaseRequestOptions,
asRequest: asJSON(
asObject({
requestedUUID: asString,
Email: asString
})
),
asResponse: asJSON(
asIoniaResponse(
asEither(
asNull,
asObject({
UserName: asString,
ErrorMessage: asEither(asNull, asString)
})
)
)
)
})
// Ionia Get Gift Cards:
const fetchGetGiftCardsBase = cleanFetch({
resource: new URL(`${pluginKeys.ioniaBaseUrl}/GetGiftCards`),
options: ioniaBaseRequestOptions,
asRequest: asJSON(
asOptional(
asEither(
asObject({}),
asObject({
Id: asNumber
})
),
{}
)
),
asResponse: asJSON(asIoniaResponse(asArray(asRewardsCard)))
})
// Ionia Purchase Card Request:
const fetchPurchaseGiftCardBase = cleanFetch({
resource: new URL(`${pluginKeys.ioniaBaseUrl}/PurchaseGiftCard`),
options: ioniaBaseRequestOptions,
asRequest: asJSON(
asObject({
MerchantId: asNumber,
Amount: asNumber,
Currency: asString
})
),
asResponse: asJSON(asIoniaResponse(asMaybe(asIoniaPurchaseCard)))
})
// Payment Protocol Request Payment Options:
const fetchPaymentOptions = cleanFetch({
resource: input => input.endpoint,
asResponse: asJSON(
asObject({
time: asString,
expires: asString,
memo: asString,
paymentUrl: asString,
paymentId: asString,
paymentOptions: asArray(
asObject({
currency: asString,
chain: asString,
network: asString,
estimatedAmount: asNumber,
requiredFeeRate: asNumber,
minerFee: asNumber,
decimals: asNumber,
selected: asBoolean
})
)
})
),
options: {
headers: {
Accept: 'application/payment-options'
}
}
})
// Fetch Access Token From OAuth Protocol:
if (ACCESS_TOKEN == null) {
const credentialsString = `${pluginKeys.clientId}:${pluginKeys.clientSecret}`
const credentialsBytes = Uint8Array.from(
credentialsString.split('').map(char => char.charCodeAt(0))
)
const base64Credentials = wasBase64(credentialsBytes)
const accessTokenResponse = await fetchAccessToken({
headers: {
Authorization: `Basic ${base64Credentials}`
}
})
ACCESS_TOKEN = accessTokenResponse.access_token
}
const authorizedFetchOptions: RequestInit = {
headers: {
Authorization: `Bearer ${ACCESS_TOKEN}`,
client_id: pluginKeys.clientId
}
}
const userAuthenticatedFetchOptions = {
headers: {
Authorization: `Bearer ${ACCESS_TOKEN}`,
client_id: pluginKeys.clientId,
UserName: '',
requestedUUID: params.deviceId
}
}
const fetchCreateUser = fetcherWithOptions(
fetchCreateUserBase,
authorizedFetchOptions
)
const fetchGetGiftCards = fetcherWithOptions(
fetchGetGiftCardsBase,
authorizedFetchOptions
)
const fetchPurchaseGiftCard = fetcherWithOptions(
fetchPurchaseGiftCardBase,
userAuthenticatedFetchOptions
)
//
// State:
//
let hiddenCardIds: number[] = asStoreHiddenCards(
await store.getItem(STORE_HIDDEN_CARDS_KEY).catch(_ => undefined)
)
let purchaseCardTimeoutId: NodeJS.Timeout
const ratesCache: {
[currencyCode: string]: {
expiry: number
rateQueryPromise: Promise<number>
}
} = {}
//
// Private methods:
//
async function getPurchaseCard(
currencyCode: string,
cardAmount: number
): Promise<IoniaPurchaseCard | null> {
return await new Promise<IoniaPurchaseCard | null>((resolve, reject) => {
// Hastily invoke the task promise with a debounce:
const newPurchaseCardTimeoutId = setTimeout(() => {
if (purchaseCardTimeoutId === newPurchaseCardTimeoutId) {
queryPurchaseCard(currencyCode, cardAmount).then(resolve, reject)
} else {
// Aborted
resolve(null)
}
}, 1000)
// Set the new task to the provider state
purchaseCardTimeoutId = newPurchaseCardTimeoutId
})
}
/**
* Get the purchase rate for a card in units of crypto amount per fiat unit
* (e.g. 3700 sats per 1 USD).
*/
async function getCardPurchaseRateAmount(
currencyCode: string,
cardAmount: number
): Promise<number> {
// Return cached value:
if (ratesCache[currencyCode] != null) {
const { expiry, rateQueryPromise } = ratesCache[currencyCode]
if (expiry > Date.now()) return await rateQueryPromise
}
// Update cache value with new query:
const ratePromise = queryCardPurchaseRateAmount(currencyCode, cardAmount)
ratesCache[currencyCode] = {
expiry: Date.now() + ONE_MINUTE,
rateQueryPromise: ratePromise
}
const rate = await ratePromise
logActivity(
`Ionia rates a $${cardAmount} card at ${rate} ${currencyCode}`
)
return rate
}
function checkAmountMinMax(fiatAmount: number) {
if (fiatAmount > MAX_FIAT_CARD_PURCHASE_AMOUNT) {
throw new Error(
sprintf(
lstrings.card_amount_max_error_message_s,
MAX_FIAT_CARD_PURCHASE_AMOUNT
)
)
}
if (fiatAmount < MIN_FIAT_CARD_PURCHASE_AMOUNT) {
throw new Error(
sprintf(
lstrings.card_amount_min_error_message_s,
MIN_FIAT_CARD_PURCHASE_AMOUNT
)
)
}
}
async function createUser(): Promise<string> {
const uuid = await makeUuid()
const uuidEmail = `${uuid}@edge.app`
logActivity(
`Creating Ionia User: requestedUUID=${params.deviceId} Email=${uuidEmail}`
)
const createUserResponse = await fetchCreateUser({
payload: {
requestedUUID: params.deviceId,
Email: uuidEmail
}
})
const ErrorMessage =
createUserResponse.ErrorMessage ?? createUserResponse.Data?.ErrorMessage
if (!createUserResponse.Successful || createUserResponse.Data == null) {
throw new Error(`Failed to create user: ${ErrorMessage}`)
}
logActivity(`Ionia user created successfully.`)
const userName = createUserResponse.Data.UserName
await store.setItem(STORE_USERNAME_KEY, userName)
await store.setItem(STORE_EMAIL_KEY, uuidEmail)
logActivity(`Ionia user info saved to store.`)
return userName
}
async function queryCardPurchaseRateAmount(
currencyCode: string,
cardAmount: number
): Promise<number> {
const cardPurchase = await queryPurchaseCard(currencyCode, cardAmount)
const paymentUrl = new URLParse(cardPurchase.uri, true)
const paymentRequestUrl = paymentUrl.query.r
if (paymentRequestUrl == null)
throw new Error(
`Missing or invalid payment URI from purchase gift card API`
)
const paymentProtocolResponse = await fetchPaymentOptions({
endpoint: new URL(paymentRequestUrl)

Fix in CursorFix in Web


Was this report helpful? Give feedback by reacting with 👍 or 👎

<EdgeText
ellipsizeMode="tail"
style={error ? styles.textHeaderError : styles.textHeader}
style={error != null ? styles.textHeaderError : styles.textHeader}
Copy link

Choose a reason for hiding this comment

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

Incorrect conditional check breaks error styling logic

Medium Severity

The condition changed from error to error != null, which breaks the styling logic. Since error is a boolean prop, when error={false} is explicitly passed, false != null evaluates to true, causing the error style (styles.textHeaderError) to be incorrectly applied. The original truthy check error ? correctly handles all cases where error is true, false, or undefined.

Fix in Cursor Fix in Web

.map(
(target, index) =>
`${target.publicAddress}${
index + 1 !== spendTargets.length ? newline : ''
Copy link

Choose a reason for hiding this comment

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

Accessing undefined wallet causes crash for tokens

Medium Severity

When computing destinationAssetName, the code accesses destinationWallet.currencyConfig without checking if destinationWallet is defined. The comment at line 155 acknowledges "The wallet may have been deleted" but this check is missing here. If swapData.payoutTokenId is not null (meaning a token swap) and the destination wallet was deleted, this will crash.

Fix in Cursor Fix in Web

transaction,
sourceWallet,
reportsTxInfo,
isReportsTxInfoLoading = false
Copy link

Choose a reason for hiding this comment

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

Default value prevents intended status row hiding

Low Severity

The default value isReportsTxInfoLoading = false on line 76 contradicts the conditional check isReportsTxInfoLoading == null on line 342. The comment on lines 40-44 states "If not provided, the card will not show the status" but the default value ensures this condition is never met, as false == null is always false.

Additional Locations (1)

Fix in Cursor Fix in Web

plugin,
refundAddress
} = upgradeSwapData(wallet, swapData)
const swapData = upgradeSwapData(sourceWallet, props.swapData)
Copy link

Choose a reason for hiding this comment

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

Wrong wallet passed to upgradeSwapData function

Medium Severity

The upgradeSwapData function expects a destinationWallet parameter (line 52) to resolve payoutTokenId from payoutCurrencyCode using the destination wallet's currency configuration. However, line 80 passes sourceWallet instead. This causes the function to look up the payout token in the wrong wallet's config, which will fail to find the correct token ID for token swaps.

Fix in Cursor Fix in Web

// Address information:
payoutAddress: txInfo.payout.address,
payoutCurrencyCode,
payoutNativeAmount: txInfo.payout.amount.toString(),
Copy link

Choose a reason for hiding this comment

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

Inconsistent amount conversion between query and assignment

High Severity

The amount field from the API is handled inconsistently. At line 145, queryReportsTxInfo converts the amount to native units using mul(tx.payout.amount, denom.multiplier) for comparison. However, toEdgeTxSwap at line 183 and toEdgeTxActionSwap at lines 207/212 use the raw amount.toString() directly as nativeAmount/payoutNativeAmount without conversion. This results in incorrect swap amounts being stored.

Additional Locations (2)

Fix in Cursor Fix in Web

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants