diff --git a/src/lib/target-tick.ts b/src/lib/target-tick.ts new file mode 100644 index 0000000..df84c9f --- /dev/null +++ b/src/lib/target-tick.ts @@ -0,0 +1,35 @@ +import { fetchTickInfo } from '@/lib/network-stats' + +const isRequestedTargetTickExpired = ( + requestedTargetTick: bigint | number | undefined, + currentTick: number | undefined, +): boolean => { + if ( + requestedTargetTick === undefined || + typeof currentTick !== 'number' || + !Number.isInteger(currentTick) + ) { + return false + } + + if (typeof requestedTargetTick === 'number' && !Number.isInteger(requestedTargetTick)) { + return false + } + + const targetTick = + typeof requestedTargetTick === 'bigint' ? requestedTargetTick : BigInt(requestedTargetTick) + + return targetTick < BigInt(currentTick) +} + +export const isRequestedTargetTickExpiredNow = async ( + requestedTargetTick: bigint | number | undefined, +): Promise => { + if (requestedTargetTick === undefined) return false + try { + const latestTickInfo = await fetchTickInfo() + return isRequestedTargetTickExpired(requestedTargetTick, latestTickInfo.tickInfo?.tick) + } catch { + return false + } +} diff --git a/src/lib/transaction-submission-errors.ts b/src/lib/transaction-submission-errors.ts new file mode 100644 index 0000000..41672f0 --- /dev/null +++ b/src/lib/transaction-submission-errors.ts @@ -0,0 +1,49 @@ +import { isRequestedTargetTickExpiredNow } from '@/lib/target-tick' + +type RequestedTargetTick = bigint | number | undefined +const TARGET_TICK_EXPIRED_ERROR_CODE = 'tx_target_tick_expired' + +type TransactionSubmissionErrorMessages = { + generic: string + targetTickExpired: string + networkError: string + broadcastFailed: string +} + +const getErrorCode = (error: unknown): string | undefined => { + if (!error || typeof error !== 'object' || !('code' in error)) return undefined + const { code } = error as { code?: unknown } + return typeof code === 'string' ? code : undefined +} + +export const resolveTransactionSubmissionErrorMessage = async ( + error: unknown, + requestedTargetTick: RequestedTargetTick, + messages: TransactionSubmissionErrorMessages, + options?: { allowTickExpiryHeuristic?: boolean }, +): Promise => { + if (getErrorCode(error) === TARGET_TICK_EXPIRED_ERROR_CODE) { + return messages.targetTickExpired + } + + if (!(error instanceof Error)) return messages.generic + + const errorMessage = error.message.toLowerCase() + + if (errorMessage.includes('network') || errorMessage.includes('fetch')) { + return messages.networkError + } + + if (errorMessage.includes('broadcast')) { + return messages.broadcastFailed + } + + if ( + options?.allowTickExpiryHeuristic && + (await isRequestedTargetTickExpiredNow(requestedTargetTick)) + ) { + return messages.targetTickExpired + } + + return error.message +} diff --git a/src/locales/de.json b/src/locales/de.json index bfcc55a..46e61db 100644 --- a/src/locales/de.json +++ b/src/locales/de.json @@ -364,6 +364,7 @@ }, "errors": { "networkError": "Netzwerkfehler. Bitte erneut versuchen.", + "targetTickExpired": "Der Ziel-Tick ist bereits abgelaufen. Bitte einen höheren Ziel-Tick verwenden.", "broadcastFailed": "Transaktion konnte nicht gesendet werden.", "watchOnly": "Watch-Only-Konten können keine Überweisungen senden.", "generic": "Überweisung fehlgeschlagen. Bitte erneut versuchen." @@ -413,6 +414,7 @@ }, "errors": { "networkError": "Netzwerkfehler. Bitte erneut versuchen.", + "targetTickExpired": "Der Ziel-Tick ist bereits abgelaufen. Bitte einen höheren Ziel-Tick verwenden.", "broadcastFailed": "Transaktion konnte nicht gesendet werden.", "watchOnly": "Watch-Only-Konten können keine Rechte übertragen.", "generic": "Rechteübertragung fehlgeschlagen. Bitte erneut versuchen." diff --git a/src/locales/en.json b/src/locales/en.json index 5dd40b8..4c1dcce 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -364,6 +364,7 @@ }, "errors": { "networkError": "Network error. Please retry.", + "targetTickExpired": "Target tick has already passed. Please use a higher target tick.", "broadcastFailed": "Failed to broadcast transaction.", "watchOnly": "Watch-only accounts cannot send transfers.", "generic": "Transfer failed. Please retry." @@ -413,6 +414,7 @@ }, "errors": { "networkError": "Network error. Please retry.", + "targetTickExpired": "Target tick has already passed. Please use a higher target tick.", "broadcastFailed": "Failed to broadcast transaction.", "watchOnly": "Watch-only accounts cannot transfer rights.", "generic": "Transfer rights failed. Please retry." diff --git a/src/locales/es.json b/src/locales/es.json index c70f24b..3705cdd 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -364,6 +364,7 @@ }, "errors": { "networkError": "Error de red. Intenta de nuevo.", + "targetTickExpired": "El tick objetivo ya pasó. Usa un tick objetivo más alto.", "broadcastFailed": "Fallo al transmitir la transacción.", "watchOnly": "Las cuentas de solo lectura no pueden enviar transferencias.", "generic": "Transferencia fallida. Intenta de nuevo." @@ -413,6 +414,7 @@ }, "errors": { "networkError": "Error de red. Intenta de nuevo.", + "targetTickExpired": "El tick objetivo ya pasó. Usa un tick objetivo más alto.", "broadcastFailed": "Fallo al transmitir la transacción.", "watchOnly": "Las cuentas de solo lectura no pueden transferir derechos.", "generic": "Transferencia de derechos fallida. Intenta de nuevo." diff --git a/src/locales/fr.json b/src/locales/fr.json index 16ae7d3..d99a0ab 100644 --- a/src/locales/fr.json +++ b/src/locales/fr.json @@ -364,6 +364,7 @@ }, "errors": { "networkError": "Erreur réseau. Veuillez réessayer.", + "targetTickExpired": "Le tick cible est déjà passé. Veuillez utiliser un tick cible plus élevé.", "broadcastFailed": "Échec de la diffusion de la transaction.", "watchOnly": "Les comptes en lecture seule ne peuvent pas envoyer de transferts.", "generic": "Échec du transfert. Veuillez réessayer." @@ -413,6 +414,7 @@ }, "errors": { "networkError": "Erreur réseau. Veuillez réessayer.", + "targetTickExpired": "Le tick cible est déjà passé. Veuillez utiliser un tick cible plus élevé.", "broadcastFailed": "Échec de la diffusion de la transaction.", "watchOnly": "Les comptes en lecture seule ne peuvent pas transférer de droits.", "generic": "Échec du transfert de droits. Veuillez réessayer." diff --git a/src/locales/ru.json b/src/locales/ru.json index 9e489e1..4bd8cc4 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -364,6 +364,7 @@ }, "errors": { "networkError": "Ошибка сети. Попробуйте снова.", + "targetTickExpired": "Целевой тик уже прошел. Укажите более высокий целевой тик.", "broadcastFailed": "Не удалось отправить транзакцию в сеть.", "watchOnly": "Аккаунты только для просмотра не могут отправлять переводы.", "generic": "Перевод не удался. Попробуйте снова." @@ -413,6 +414,7 @@ }, "errors": { "networkError": "Ошибка сети. Попробуйте снова.", + "targetTickExpired": "Целевой тик уже прошел. Укажите более высокий целевой тик.", "broadcastFailed": "Не удалось отправить транзакцию в сеть.", "watchOnly": "Аккаунты только для просмотра не могут передавать права.", "generic": "Передача прав не удалась. Попробуйте снова." diff --git a/src/locales/tr.json b/src/locales/tr.json index 0130ae3..7f3fac3 100644 --- a/src/locales/tr.json +++ b/src/locales/tr.json @@ -364,6 +364,7 @@ }, "errors": { "networkError": "Ağ hatası. Lütfen tekrar deneyin.", + "targetTickExpired": "Hedef tick zaten geçti. Lütfen daha yüksek bir hedef tick kullanın.", "broadcastFailed": "İşlem yayınlanamadı.", "watchOnly": "Yalnızca izleme hesapları transfer gönderemez.", "generic": "Transfer başarısız. Lütfen tekrar deneyin." @@ -413,6 +414,7 @@ }, "errors": { "networkError": "Ağ hatası. Lütfen tekrar deneyin.", + "targetTickExpired": "Hedef tick zaten geçti. Lütfen daha yüksek bir hedef tick kullanın.", "broadcastFailed": "İşlem yayınlanamadı.", "watchOnly": "Yalnızca izleme hesapları hak transferi yapamaz.", "generic": "Hak transferi başarısız. Lütfen tekrar deneyin." diff --git a/src/locales/vi.json b/src/locales/vi.json index 6f86835..634bf26 100644 --- a/src/locales/vi.json +++ b/src/locales/vi.json @@ -364,6 +364,7 @@ }, "errors": { "networkError": "Lỗi mạng. Vui lòng thử lại.", + "targetTickExpired": "Tick mục tiêu đã qua. Vui lòng dùng tick mục tiêu cao hơn.", "broadcastFailed": "Không thể truyền giao dịch.", "watchOnly": "Tài khoản chỉ xem không thể gửi chuyển khoản.", "generic": "Chuyển khoản thất bại. Vui lòng thử lại." @@ -413,6 +414,7 @@ }, "errors": { "networkError": "Lỗi mạng. Vui lòng thử lại.", + "targetTickExpired": "Tick mục tiêu đã qua. Vui lòng dùng tick mục tiêu cao hơn.", "broadcastFailed": "Không thể truyền giao dịch.", "watchOnly": "Tài khoản chỉ xem không thể chuyển quyền.", "generic": "Chuyển quyền thất bại. Vui lòng thử lại." diff --git a/src/locales/zh.json b/src/locales/zh.json index ee1e2ae..66b61cd 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -364,6 +364,7 @@ }, "errors": { "networkError": "网络错误,请重试。", + "targetTickExpired": "目标 Tick 已经过期。请使用更高的目标 Tick。", "broadcastFailed": "交易广播失败。", "watchOnly": "仅观察账户无法发送转账。", "generic": "转账失败,请重试。" @@ -413,6 +414,7 @@ }, "errors": { "networkError": "网络错误,请重试。", + "targetTickExpired": "目标 Tick 已经过期。请使用更高的目标 Tick。", "broadcastFailed": "交易广播失败。", "watchOnly": "仅观察账户无法转移权限。", "generic": "权限转移失败,请重试。" diff --git a/src/pages/transfer-rights.tsx b/src/pages/transfer-rights.tsx index 9710a4b..5e6eb52 100644 --- a/src/pages/transfer-rights.tsx +++ b/src/pages/transfer-rights.tsx @@ -42,6 +42,7 @@ import { import { addPendingTransaction, PENDING_SETTLED_EVENT } from '@/lib/pending-transactions' import { isWalletLocked } from '@/lib/lock' import { useTickInfo, fetchTickInfo } from '@/lib/network-stats' +import { resolveTransactionSubmissionErrorMessage } from '@/lib/transaction-submission-errors' import { compareBigIntDesc, formatBalance, @@ -324,6 +325,8 @@ const TransferRights = () => { setSending(true) setErrorMessage('') + let requestedTargetTick: bigint | number | undefined + let reachedSubmitStage = false try { const parsedShares = parseAmount(shares) @@ -331,8 +334,6 @@ const TransferRights = () => { throw new Error(t('transferRights.validation.sharesInvalid')) } - let requestedTargetTick: bigint | number | undefined - const freshTickInfo = await fetchTickInfo() const sendCurrentTick = freshTickInfo.tickInfo?.tick @@ -381,6 +382,8 @@ const TransferRights = () => { ) } + reachedSubmitStage = true + const result = await sdk.transactions.send({ fromSeed: seed, toIdentity: sourceContract.contractAddress, @@ -415,17 +418,17 @@ const TransferRights = () => { navigate('/') } catch (error) { - let message = t('transferRights.errors.generic') - - if (error instanceof Error) { - if (error.message.includes('network') || error.message.includes('fetch')) { - message = t('transferRights.errors.networkError') - } else if (error.message.includes('broadcast')) { - message = t('transferRights.errors.broadcastFailed') - } else { - message = error.message - } - } + const message = await resolveTransactionSubmissionErrorMessage( + error, + requestedTargetTick, + { + generic: t('transferRights.errors.generic'), + targetTickExpired: t('transferRights.errors.targetTickExpired'), + networkError: t('transferRights.errors.networkError'), + broadcastFailed: t('transferRights.errors.broadcastFailed'), + }, + { allowTickExpiryHeuristic: reachedSubmitStage }, + ) seedRef.current = null setErrorMessage(message) diff --git a/src/pages/transfer.tsx b/src/pages/transfer.tsx index fd0675c..961a4c5 100644 --- a/src/pages/transfer.tsx +++ b/src/pages/transfer.tsx @@ -24,6 +24,7 @@ import { import { addPendingTransaction, PENDING_SETTLED_EVENT } from '@/lib/pending-transactions' import { isWalletLocked } from '@/lib/lock' import { useLatestStats, useTickInfo, fetchTickInfo } from '@/lib/network-stats' +import { resolveTransactionSubmissionErrorMessage } from '@/lib/transaction-submission-errors' import ConfirmationDrawer from '@/components/pages/transfer/confirmation-drawer' import TransferForm from '@/components/pages/transfer/transfer-form' import type { FormErrors } from '@/components/pages/transfer/types' @@ -204,6 +205,8 @@ const Transfer = () => { setSending(true) setErrorMessage('') + let requestedTargetTick: bigint | number | undefined + let reachedSubmitStage = false try { const parsedAmount = parseAmount(amount) @@ -212,9 +215,7 @@ const Transfer = () => { } let result: { txId: string; targetTick: bigint } - let requestedTargetTick: bigint | number | undefined - // Fetch fresh tick info at send time const freshTickInfo = await fetchTickInfo() const sendCurrentTick = freshTickInfo.tickInfo?.tick @@ -247,6 +248,8 @@ const Transfer = () => { throw new Error(t('transfer.errors.networkError')) } + reachedSubmitStage = true + if (selectedAsset) { const payload = buildAssetTransferPayload( selectedAsset.issuerIdentity, @@ -298,17 +301,17 @@ const Transfer = () => { navigate('/') } catch (error) { - let message = t('transfer.errors.generic') - - if (error instanceof Error) { - if (error.message.includes('network') || error.message.includes('fetch')) { - message = t('transfer.errors.networkError') - } else if (error.message.includes('broadcast')) { - message = t('transfer.errors.broadcastFailed') - } else { - message = error.message - } - } + const message = await resolveTransactionSubmissionErrorMessage( + error, + requestedTargetTick, + { + generic: t('transfer.errors.generic'), + targetTickExpired: t('transfer.errors.targetTickExpired'), + networkError: t('transfer.errors.networkError'), + broadcastFailed: t('transfer.errors.broadcastFailed'), + }, + { allowTickExpiryHeuristic: reachedSubmitStage }, + ) seedRef.current = null setErrorMessage(message)