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
1 change: 0 additions & 1 deletion packages/kit-bg/src/services/ServiceCustomToken.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,7 +314,6 @@ class ServiceCustomToken extends ServiceBase {
}

@backgroundMethod()
@toastIfError()
async activateToken({
accountId,
networkId,
Expand Down
17 changes: 17 additions & 0 deletions packages/kit-bg/src/services/ServiceSend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
13 changes: 13 additions & 0 deletions packages/kit-bg/src/vaults/base/VaultBase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,19 @@ export abstract class VaultBaseChainOnly extends VaultContext {
params: IValidateGeneralInputParams,
): Promise<IGeneralInputValidation>;

/**
* 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<IPrivateKeyValidation> {
Expand Down
32 changes: 32 additions & 0 deletions packages/kit-bg/src/vaults/impls/stellar/Vault.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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<ClientStellar> => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,10 @@ export default class ClientStellar {
*/
async accountExists(address: string): Promise<boolean> {
try {
await this.getAccountInfo(address);
const accountInfo = await this.getAccountInfo(address);
if (!accountInfo) {
return false;
}
return true;
} catch (error) {
return false;
Expand Down
2 changes: 1 addition & 1 deletion packages/kit-bg/src/vaults/impls/stellar/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
19 changes: 15 additions & 4 deletions packages/kit-bg/src/vaults/impls/stellar/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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);
}
Expand Down
7 changes: 6 additions & 1 deletion packages/kit-bg/src/vaults/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -186,57 +186,86 @@ export function CreateOrEditContent({
);
}, [intl, media.gtMd, vaultSettings?.noteMaxLength, vaultSettings?.withNote]);

const validateMemoField = useCallback(
async (value: string): Promise<string | undefined> => {
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 (
<>
<Form.Field
label={intl.formatMessage({ id: ETranslations.send_tag })}
optional
name="memo"
rules={{
maxLength: {
value: maxLength,
message: intl.formatMessage(
{
id: ETranslations.dapp_connect_msg_description_can_be_up_to_int_characters,
},
{
number: maxLength,
},
),
},
validate: (value) => {
if (!value || !memoRegExp) return undefined;
const result = !memoRegExp.test(value);
return result ? validateErrMsg : undefined;
},
}}
>
<TextAreaInput
numberOfLines={2}
size={media.gtMd ? 'medium' : 'large'}
placeholder={intl.formatMessage({
id: ETranslations.send_tag_placeholder,
})}
/>
</Form.Field>
</>
<Form.Field
label={intl.formatMessage({ id: ETranslations.send_tag })}
optional
name="memo"
rules={{
maxLength: customValidate
? undefined
: {
value: maxLength,
message: intl.formatMessage(
{
id: ETranslations.dapp_connect_msg_description_can_be_up_to_int_characters,
},
{
number: maxLength,
},
),
},
validate: validateMemoField,
}}
>
<TextAreaInput
numberOfLines={2}
size={media.gtMd ? 'medium' : 'large'}
placeholder={intl.formatMessage({
id: ETranslations.send_tag_placeholder,
})}
/>
</Form.Field>
);
}, [
intl,
media.gtMd,
validateMemoField,
vaultSettings?.memoMaxLength,
vaultSettings?.numericOnlyMemo,
vaultSettings?.withMemo,
vaultSettings?.supportMemoValidation,
]);

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
}));

Expand Down Expand Up @@ -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<ISeriesApi<'Area'>, number>
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,14 @@ 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,
ITabEarnParamList,
} 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';
Expand Down
Loading