diff --git a/packages/kit-bg/src/services/ServiceCustomToken.ts b/packages/kit-bg/src/services/ServiceCustomToken.ts index 4410a305a7eb..f19bce1e07f5 100644 --- a/packages/kit-bg/src/services/ServiceCustomToken.ts +++ b/packages/kit-bg/src/services/ServiceCustomToken.ts @@ -314,7 +314,6 @@ class ServiceCustomToken extends ServiceBase { } @backgroundMethod() - @toastIfError() async activateToken({ accountId, networkId, diff --git a/packages/kit-bg/src/services/ServiceSend.ts b/packages/kit-bg/src/services/ServiceSend.ts index c33e124fe958..146a5ba7cd67 100644 --- a/packages/kit-bg/src/services/ServiceSend.ts +++ b/packages/kit-bg/src/services/ServiceSend.ts @@ -939,6 +939,23 @@ class ServiceSend extends ServiceBase { ); return resp.data.data; } + + @backgroundMethod() + async validateMemo(params: { + networkId: string; + accountId?: string; + memo: string; + }) { + const { networkId, accountId, memo } = params; + if (accountId) { + const vault = await vaultFactory.getVault({ networkId, accountId }); + return vault.validateMemo(memo); + } + + return (await vaultFactory.getChainOnlyVault({ networkId })).validateMemo( + memo, + ); + } } export default ServiceSend; diff --git a/packages/kit-bg/src/vaults/base/VaultBase.ts b/packages/kit-bg/src/vaults/base/VaultBase.ts index e5bc2fbba256..88b77d7b48ee 100644 --- a/packages/kit-bg/src/vaults/base/VaultBase.ts +++ b/packages/kit-bg/src/vaults/base/VaultBase.ts @@ -192,6 +192,19 @@ export abstract class VaultBaseChainOnly extends VaultContext { params: IValidateGeneralInputParams, ): Promise; + /** + * Validate memo/tag field (optional, chain-specific implementation) + * @param memo - The memo string to validate + * @returns Validation result with error message if invalid + */ + async validateMemo(memo: string): Promise<{ + isValid: boolean; + errorMessage?: string; + }> { + // Default implementation: always valid (chains can override) + return { isValid: true }; + } + async baseValidatePrivateKey( privateKey: string, ): Promise { diff --git a/packages/kit-bg/src/vaults/impls/stellar/Vault.ts b/packages/kit-bg/src/vaults/impls/stellar/Vault.ts index 54e01a360dc3..d0d96e8b034a 100644 --- a/packages/kit-bg/src/vaults/impls/stellar/Vault.ts +++ b/packages/kit-bg/src/vaults/impls/stellar/Vault.ts @@ -23,6 +23,7 @@ import { OneKeyInternalError, } from '@onekeyhq/shared/src/errors'; import { ETranslations } from '@onekeyhq/shared/src/locale'; +import { appLocale } from '@onekeyhq/shared/src/locale/appLocale'; import bufferUtils from '@onekeyhq/shared/src/utils/bufferUtils'; import { memoizee } from '@onekeyhq/shared/src/utils/cacheUtils'; import timerUtils from '@onekeyhq/shared/src/utils/timerUtils'; @@ -89,12 +90,14 @@ import { EStellarAssetType } from './types'; import { BASE_FEE, ENTRY_RESERVE, + MEMO_TEXT_MAX_BYTES, SAC_TOKEN_ASSET_TYPES, SAC_TOKEN_DECIMALS, buildMemoFromString, calculateAvailableBalance, calculateFrozenBalance, getNetworkPassphrase, + getUtf8ByteLength, isValidAccountCreationAmount, parseTokenAddress, } from './utils'; @@ -1074,6 +1077,35 @@ export default class Vault extends VaultBase { return result; } + override async validateMemo(memo: string): Promise<{ + isValid: boolean; + errorMessage?: string; + }> { + if (!memo || !memo.trim()) { + return { isValid: true }; // Empty memo is valid + } + + const trimmed = memo.trim(); + + // Text memo: check byte length + const byteLength = getUtf8ByteLength(trimmed); + if (byteLength > MEMO_TEXT_MAX_BYTES) { + return { + isValid: false, + errorMessage: appLocale.intl.formatMessage( + { id: ETranslations.send_memo_size_exceeded }, + { + limit: MEMO_TEXT_MAX_BYTES, + current: byteLength, + type: 'Bytes', + }, + ), + }; + } + + return { isValid: true }; + } + // ========== LOCAL DEVELOPMENT RPC SUPPORT ========== private _getCustomClientCache = memoizee( async (url: string): Promise => { diff --git a/packages/kit-bg/src/vaults/impls/stellar/sdkStellar/ClientStellar.ts b/packages/kit-bg/src/vaults/impls/stellar/sdkStellar/ClientStellar.ts index 837df0c0abd5..0631ef9db34f 100644 --- a/packages/kit-bg/src/vaults/impls/stellar/sdkStellar/ClientStellar.ts +++ b/packages/kit-bg/src/vaults/impls/stellar/sdkStellar/ClientStellar.ts @@ -68,7 +68,10 @@ export default class ClientStellar { */ async accountExists(address: string): Promise { try { - await this.getAccountInfo(address); + const accountInfo = await this.getAccountInfo(address); + if (!accountInfo) { + return false; + } return true; } catch (error) { return false; diff --git a/packages/kit-bg/src/vaults/impls/stellar/settings.ts b/packages/kit-bg/src/vaults/impls/stellar/settings.ts index abd846443db2..fe1c4c74e027 100644 --- a/packages/kit-bg/src/vaults/impls/stellar/settings.ts +++ b/packages/kit-bg/src/vaults/impls/stellar/settings.ts @@ -45,7 +45,7 @@ const settings: IVaultSettings = { hasFrozenBalance: true, // trustline count * 0.5XLM is frozen balance withMemo: true, - memoMaxLength: 28, + supportMemoValidation: true, // Use Vault.validateMemo() for precise validation accountDeriveInfo, customRpcEnabled: true, diff --git a/packages/kit-bg/src/vaults/impls/stellar/utils.ts b/packages/kit-bg/src/vaults/impls/stellar/utils.ts index dd7497923642..2bc857a8f72a 100644 --- a/packages/kit-bg/src/vaults/impls/stellar/utils.ts +++ b/packages/kit-bg/src/vaults/impls/stellar/utils.ts @@ -21,10 +21,19 @@ export const SAC_TOKEN_DECIMALS = 7; export const SAC_TOKEN_ASSET_TYPES = ['credit_alphanum4', 'credit_alphanum12']; -const MEMO_TEXT_MAX_BYTES = 28; +export const MEMO_TEXT_MAX_BYTES = 28; const MEMO_ID_MAX = new BigNumber('18446744073709551615'); +/** + * Calculate the byte length of a UTF-8 string + * @param text - The text to measure + * @returns The byte length + */ +export function getUtf8ByteLength(text: string): number { + return Buffer.from(text, 'utf8').length; +} + export function getNetworkPassphrase(networkId: string): string { return networkId.includes('testnet') ? Networks.TESTNET : Networks.PUBLIC; } @@ -74,9 +83,11 @@ export function buildMemoFromString(memo?: string) { if (isUint64Memo(trimmed)) { return Memo.id(trimmed); } - const memoBytes = Buffer.from(trimmed, 'utf8'); - if (memoBytes.length > MEMO_TEXT_MAX_BYTES) { - throw new OneKeyInternalError('Memo text exceeds 28 bytes limit'); + const byteLength = getUtf8ByteLength(trimmed); + if (byteLength > MEMO_TEXT_MAX_BYTES) { + throw new OneKeyInternalError( + `Memo text exceeds ${MEMO_TEXT_MAX_BYTES} bytes limit (current: ${byteLength} bytes)`, + ); } return Memo.text(trimmed); } diff --git a/packages/kit-bg/src/vaults/types.ts b/packages/kit-bg/src/vaults/types.ts index c5c525de347f..68364d7b3110 100644 --- a/packages/kit-bg/src/vaults/types.ts +++ b/packages/kit-bg/src/vaults/types.ts @@ -212,8 +212,13 @@ export type IVaultSettings = { * https://support.ledger.com/hc/en-us/articles/4409603715217-What-is-a-Memo-Tag-?support=true */ withMemo?: boolean; - memoMaxLength?: number; + memoMaxLength?: number; // Fallback: character-based limit (legacy) numericOnlyMemo?: boolean; + /** + * If true, Vault has implemented validateMemo() for precise validation + * Form validation will call vault.validateMemo() instead of using memoMaxLength + */ + supportMemoValidation?: boolean; // dnx withPaymentId?: boolean; diff --git a/packages/kit/src/views/AddressBook/components/CreateOrEditContent.tsx b/packages/kit/src/views/AddressBook/components/CreateOrEditContent.tsx index 298e4dd8e0e4..4600f8c568b1 100644 --- a/packages/kit/src/views/AddressBook/components/CreateOrEditContent.tsx +++ b/packages/kit/src/views/AddressBook/components/CreateOrEditContent.tsx @@ -186,57 +186,86 @@ export function CreateOrEditContent({ ); }, [intl, media.gtMd, vaultSettings?.noteMaxLength, vaultSettings?.withNote]); + const validateMemoField = useCallback( + async (value: string): Promise => { + if (!value) return undefined; + + try { + const validationResult = + await backgroundApiProxy.serviceSend.validateMemo({ + networkId, + memo: value, + }); + if (!validationResult.isValid) { + return validationResult.errorMessage; + } + return undefined; + } catch (error) { + // Fallback to client-side validation if Vault validation fails + console.warn('Vault validateMemo failed, using fallback:', error); + } + + // Fallback: use original logic + const validateErrMsg = vaultSettings?.numericOnlyMemo + ? intl.formatMessage({ + id: ETranslations.send_field_only_integer, + }) + : undefined; + const memoRegExp = vaultSettings?.numericOnlyMemo + ? /^[0-9]+$/ + : undefined; + + if (!value || !memoRegExp) return undefined; + const result = !memoRegExp.test(value); + return result ? validateErrMsg : undefined; + }, + [intl, networkId, vaultSettings?.numericOnlyMemo], + ); + const renderMemoForm = useCallback(() => { if (!vaultSettings?.withMemo) return null; + const maxLength = vaultSettings?.memoMaxLength || 256; - const validateErrMsg = vaultSettings?.numericOnlyMemo - ? intl.formatMessage({ - id: ETranslations.send_field_only_integer, - }) - : undefined; - const memoRegExp = vaultSettings?.numericOnlyMemo ? /^[0-9]+$/ : undefined; + const customValidate = vaultSettings?.supportMemoValidation; return ( - <> - { - if (!value || !memoRegExp) return undefined; - const result = !memoRegExp.test(value); - return result ? validateErrMsg : undefined; - }, - }} - > - - - + + + ); }, [ intl, media.gtMd, + validateMemoField, vaultSettings?.memoMaxLength, - vaultSettings?.numericOnlyMemo, vaultSettings?.withMemo, + vaultSettings?.supportMemoValidation, ]); return ( diff --git a/packages/kit/src/views/Borrow/pages/ReserveDetails/components/InterestRateModelChart.native.tsx b/packages/kit/src/views/Borrow/pages/ReserveDetails/components/InterestRateModelChart.native.tsx index e7e1e63ebd63..822132967a59 100644 --- a/packages/kit/src/views/Borrow/pages/ReserveDetails/components/InterestRateModelChart.native.tsx +++ b/packages/kit/src/views/Borrow/pages/ReserveDetails/components/InterestRateModelChart.native.tsx @@ -359,7 +359,9 @@ export function InterestRateModelChart({ }, [chartConfig, webViewReady]); const utilizationPercentage = utilizationRatio - ? `${(normalizeUtilization(parseFloat(utilizationRatio)) * 100).toFixed(2)}%` + ? `${(normalizeUtilization(parseFloat(utilizationRatio)) * 100).toFixed( + 2, + )}%` : '0.00%'; if (isLoading) { diff --git a/packages/kit/src/views/Borrow/pages/ReserveDetails/components/InterestRateModelChart.tsx b/packages/kit/src/views/Borrow/pages/ReserveDetails/components/InterestRateModelChart.tsx index a66072975003..4b5afeb3d1f5 100644 --- a/packages/kit/src/views/Borrow/pages/ReserveDetails/components/InterestRateModelChart.tsx +++ b/packages/kit/src/views/Borrow/pages/ReserveDetails/components/InterestRateModelChart.tsx @@ -150,12 +150,16 @@ export function InterestRateModelChart({ } const supplyData = supplyCurve.map(([util, apy]) => ({ - time: convertUtilizationToTime(normalizeUtilization(util)) as UTCTimestamp, + time: convertUtilizationToTime( + normalizeUtilization(util), + ) as UTCTimestamp, value: normalizeApyToPercent(parseFloat(apy)), })); const borrowData = borrowCurve.map(([util, apy]) => ({ - time: convertUtilizationToTime(normalizeUtilization(util)) as UTCTimestamp, + time: convertUtilizationToTime( + normalizeUtilization(util), + ) as UTCTimestamp, value: normalizeApyToPercent(parseFloat(apy)), })); @@ -220,7 +224,7 @@ export function InterestRateModelChart({ // Subscribe to crosshair move for tooltip chart.subscribeCrosshairMove((param) => { handleCrosshairMove({ - time: param.time as UTCTimestamp | BusinessDay | undefined, + time: param.time, point: param.point, seriesPrices: param.seriesPrices as | Map, number> @@ -302,7 +306,9 @@ export function InterestRateModelChart({ ]); const utilizationPercentage = utilizationRatio - ? `${(normalizeUtilization(parseFloat(utilizationRatio)) * 100).toFixed(2)}%` + ? `${(normalizeUtilization(parseFloat(utilizationRatio)) * 100).toFixed( + 2, + )}%` : '0.00%'; if (isLoading) { diff --git a/packages/kit/src/views/Borrow/pages/ReserveDetails/index.tsx b/packages/kit/src/views/Borrow/pages/ReserveDetails/index.tsx index 1d0424f265ed..1a253a8d19aa 100644 --- a/packages/kit/src/views/Borrow/pages/ReserveDetails/index.tsx +++ b/packages/kit/src/views/Borrow/pages/ReserveDetails/index.tsx @@ -15,7 +15,6 @@ import { AccountSelectorProviderMirror } from '@onekeyhq/kit/src/components/Acco import { Token } from '@onekeyhq/kit/src/components/Token'; import { useAppRoute } from '@onekeyhq/kit/src/hooks/useAppRoute'; import { EarnText } from '@onekeyhq/kit/src/views/Staking/components/ProtocolDetails/EarnText'; -import type { IBorrowReserveDetail } from '@onekeyhq/shared/types/staking'; import { useDevSettingsPersistAtom } from '@onekeyhq/kit-bg/src/states/jotai/atoms'; import type { ETabEarnRoutes, @@ -23,6 +22,7 @@ import type { } from '@onekeyhq/shared/src/routes'; import { ETabRoutes } from '@onekeyhq/shared/src/routes'; import { EAccountSelectorSceneName } from '@onekeyhq/shared/types'; +import type { IBorrowReserveDetail } from '@onekeyhq/shared/types/staking'; import { EarnPageContainer } from '../../../Earn/components/EarnPageContainer'; import { BorrowNavigation } from '../../borrowUtils'; diff --git a/packages/kit/src/views/Send/pages/SendDataInput/SendDataInputContainer.tsx b/packages/kit/src/views/Send/pages/SendDataInput/SendDataInputContainer.tsx index 8bcc1c29b595..1db3f5b9eaed 100644 --- a/packages/kit/src/views/Send/pages/SendDataInput/SendDataInputContainer.tsx +++ b/packages/kit/src/views/Send/pages/SendDataInput/SendDataInputContainer.tsx @@ -239,6 +239,7 @@ function SendDataInputContainer() { numericOnlyMemo, displayNoteForm, noteMaxLength, + supportsMemoValidation, displayTxMessageForm, ] = useMemo(() => { return [ @@ -248,6 +249,7 @@ function SendDataInputContainer() { vaultSettings?.numericOnlyMemo, vaultSettings?.withNote, vaultSettings?.noteMaxLength, + vaultSettings?.supportMemoValidation, vaultSettings?.withTxMessage, ]; }, [vaultSettings]); @@ -1354,15 +1356,47 @@ function SendDataInputContainer() { return null; }, [form, intl, isLoadingAssets, nft?.collectionType, nftDetails?.amount]); + const validateMemoField = useCallback( + async (value: string): Promise => { + if (vaultSettings?.supportMemoValidation) { + try { + const result = await backgroundApiProxy.serviceSend.validateMemo({ + networkId: currentAccount.networkId, + accountId: currentAccount.accountId, + memo: value, + }); + if (!result.isValid) { + return result.errorMessage; + } + return undefined; + } catch (error) { + console.error('Vault memo validation failed:', error); + } + } + + const validateErrMsg = numericOnlyMemo + ? intl.formatMessage({ + id: ETranslations.send_field_only_integer, + }) + : undefined; + const memoRegExp = numericOnlyMemo ? /^[0-9]+$/ : undefined; + + if (!value || !memoRegExp) return undefined; + const result = !memoRegExp.test(value); + return result ? validateErrMsg : undefined; + }, + [ + currentAccount.accountId, + currentAccount.networkId, + intl, + numericOnlyMemo, + vaultSettings?.supportMemoValidation, + ], + ); + const renderMemoForm = useCallback(() => { if (!displayMemoForm) return null; const maxLength = memoMaxLength || 256; - const validateErrMsg = numericOnlyMemo - ? intl.formatMessage({ - id: ETranslations.send_field_only_integer, - }) - : undefined; - const memoRegExp = numericOnlyMemo ? /^[0-9]+$/ : undefined; return ( <> @@ -1371,22 +1405,20 @@ function SendDataInputContainer() { optional name="memo" rules={{ - maxLength: { - value: maxLength, - message: intl.formatMessage( - { - id: ETranslations.dapp_connect_msg_description_can_be_up_to_int_characters, + maxLength: supportsMemoValidation + ? undefined + : { + value: maxLength, + message: intl.formatMessage( + { + id: ETranslations.dapp_connect_msg_description_can_be_up_to_int_characters, + }, + { + number: maxLength, + }, + ), }, - { - number: maxLength, - }, - ), - }, - validate: (value) => { - if (!value || !memoRegExp) return undefined; - const result = !memoRegExp.test(value); - return result ? validateErrMsg : undefined; - }, + validate: validateMemoField, }} >