-
Notifications
You must be signed in to change notification settings - Fork 271
Sam/get tx info #5662
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Sam/get tx info #5662
Conversation
In preparation for swapData to be available on receive transactions, we may not have the source wallet to pass to this component.
There was a problem hiding this 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
edge-react-gui/src/components/scenes/TransactionDetailsScene.tsx
Lines 134 to 143 in 809d356
// Update the transaction object with saveAction data from reports server: | |
if ( | |
edgeTxActionSwapFromReports != null && | |
transaction.savedAction !== edgeTxActionSwapFromReports | |
) { | |
transaction.savedAction = edgeTxActionSwapFromReports | |
transaction.assetAction = { | |
assetActionType: 'swap' | |
} | |
} |
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
edge-react-gui/src/plugins/gui/providers/ioniaProvider.ts
Lines 20 to 421 in 809d356
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) |
Was this report helpful? Give feedback by reacting with 👍 or 👎
CHANGELOG
Does this branch warrant an entry to the CHANGELOG?
Dependencies
noneRequirements
If you have made any visual changes to the GUI. Make sure you have: