From 3f95e59ec208f93d0ceb2847b905f4d4868884c3 Mon Sep 17 00:00:00 2001
From: gshaowei6 <47922975+gshaowei6@users.noreply.github.com>
Date: Sat, 30 May 2026 02:38:23 +0800
Subject: [PATCH 01/10] feat: add stellar mobile and ui bounty fixes
---
.../src/components/TransferNoteInput.tsx | 122 +++++------
.../useSendTransaction.schedule.test.ts | 5 +-
.../src/components/TransactionHistoryList.tsx | 7 +-
apps/mobile-wallet/src/index.ts | 1 +
.../src/linking/__tests__/paymentUri.test.ts | 25 +++
apps/mobile-wallet/src/linking/index.ts | 2 +
apps/mobile-wallet/src/linking/paymentUri.ts | 53 +++++
.../navigation/__tests__/onboarding.test.tsx | 3 +-
.../__tests__/TransactionHistoryList.test.tsx | 10 +
.../history/usePaginatedTransactionHistory.ts | 16 --
.../src/security/hooks/useBiometricUnlock.ts | 11 +-
packages/stellar/src/__tests__/client.test.ts | 73 +++++++
packages/stellar/src/client.ts | 49 +++--
packages/stellar/src/errors.ts | 57 +++++-
packages/stellar/src/fee-stats.ts | 5 +
packages/types/src/index.ts | 3 +-
packages/ui-kit/package.json | 1 +
.../ui-kit/src/__tests__/Form/Field.test.tsx | 55 +++++
.../ui-kit/src/__tests__/test-utils/a11y.ts | 42 ++++
.../src/components/Form/Field.stories.tsx | 35 ++++
packages/ui-kit/src/components/Form/Field.tsx | 81 ++++++++
packages/ui-kit/src/components/Form/index.ts | 3 +
.../components/Toast/NotificationProvider.tsx | 4 +-
.../ui-kit/src/components/Toast/Toast.tsx | 1 -
.../src/components/Toast/ToastContainer.tsx | 1 -
.../ui-kit/src/components/TransactionItem.tsx | 5 +-
.../ui-kit/src/components/ui/button.test.tsx | 7 +
.../ui-kit/src/components/ui/input.test.tsx | 12 ++
packages/ui-kit/src/index.ts | 3 +
packages/ui-kit/tsconfig.json | 2 +-
pnpm-lock.yaml | 193 +++++++++++-------
31 files changed, 696 insertions(+), 191 deletions(-)
create mode 100644 apps/mobile-wallet/src/linking/__tests__/paymentUri.test.ts
create mode 100644 apps/mobile-wallet/src/linking/index.ts
create mode 100644 apps/mobile-wallet/src/linking/paymentUri.ts
create mode 100644 packages/ui-kit/src/__tests__/Form/Field.test.tsx
create mode 100644 packages/ui-kit/src/__tests__/test-utils/a11y.ts
create mode 100644 packages/ui-kit/src/components/Form/Field.stories.tsx
create mode 100644 packages/ui-kit/src/components/Form/Field.tsx
diff --git a/apps/extension-wallet/src/components/TransferNoteInput.tsx b/apps/extension-wallet/src/components/TransferNoteInput.tsx
index ad5a14d9..7c9068ec 100644
--- a/apps/extension-wallet/src/components/TransferNoteInput.tsx
+++ b/apps/extension-wallet/src/components/TransferNoteInput.tsx
@@ -1,15 +1,13 @@
import React from 'react';
-import {
- validateTransferNote,
- getRemainingCharacters,
- MAX_NOTE_LENGTH,
-} from '@/utils/note-validation';
+import { getRemainingCharacters, MAX_NOTE_LENGTH } from '@/utils/note-validation';
+import { Field } from '@ancore/ui-kit';
interface TransferNoteInputProps {
value: string;
onChange: (value: string) => void;
error?: string;
className?: string;
+ label?: string;
placeholder?: string;
disabled?: boolean;
required?: boolean;
@@ -29,6 +27,7 @@ export function TransferNoteInput({
onChange,
error,
className = '',
+ label = 'Note',
placeholder = 'Add a note (optional)',
disabled = false,
required = false,
@@ -52,17 +51,40 @@ export function TransferNoteInput({
}
};
+ const warning = isOverLimit && !error && (
+
+
+ Note exceeds character limit and will be truncated
+
+ );
+
return (
-
- {/* Error message */}
- {error && (
-
+ {warning}
+ >
)}
-
- {/* Character limit warning */}
- {isOverLimit && !error && (
-
-
- Note exceeds character limit and will be truncated
-
- )}
-
+
);
}
diff --git a/apps/extension-wallet/src/hooks/__tests__/useSendTransaction.schedule.test.ts b/apps/extension-wallet/src/hooks/__tests__/useSendTransaction.schedule.test.ts
index ac540ce7..027d8ae0 100644
--- a/apps/extension-wallet/src/hooks/__tests__/useSendTransaction.schedule.test.ts
+++ b/apps/extension-wallet/src/hooks/__tests__/useSendTransaction.schedule.test.ts
@@ -13,7 +13,10 @@ describe('validateSchedule', () => {
});
it('accepts a valid schedule', () => {
- const startAt = new Date(Date.now() + 2 * 60 * 60 * 1000).toISOString().slice(0, 16);
+ const future = new Date(Date.now() + 2 * 60 * 60 * 1000);
+ const offsetMs = future.getTimezoneOffset() * 60 * 1000;
+ const startAt = new Date(future.getTime() - offsetMs).toISOString().slice(0, 16);
+
expect(
validateSchedule({
frequency: 'weekly',
diff --git a/apps/mobile-wallet/src/components/TransactionHistoryList.tsx b/apps/mobile-wallet/src/components/TransactionHistoryList.tsx
index a9817f3c..9d006c86 100644
--- a/apps/mobile-wallet/src/components/TransactionHistoryList.tsx
+++ b/apps/mobile-wallet/src/components/TransactionHistoryList.tsx
@@ -14,8 +14,12 @@ type Props = {
onRefresh: () => void;
onLoadMore: () => void;
onUnknownStatus?: (status: unknown) => void;
+ formatTimestamp?: (timestamp: string) => string;
};
+const defaultFormatTimestamp = (timestamp: string): string =>
+ new Date(timestamp).toLocaleString('en-US');
+
export const TransactionHistoryList = ({
transactions,
isLoadingInitial,
@@ -27,6 +31,7 @@ export const TransactionHistoryList = ({
onRefresh,
onLoadMore,
onUnknownStatus,
+ formatTimestamp = defaultFormatTimestamp,
}: Props) => {
if (isLoadingInitial) {
return Loading transactions…
;
@@ -75,7 +80,7 @@ export const TransactionHistoryList = ({
{tx.direction === 'in' ? 'Received' : 'Sent'} {tx.amount}
- {tx.asset ? ` ${tx.asset}` : ''} · {new Date(tx.timestamp).toLocaleString('en-US')}
+ {tx.asset ? ` ${tx.asset}` : ''} · {formatTimestamp(tx.timestamp)}
))}
diff --git a/apps/mobile-wallet/src/index.ts b/apps/mobile-wallet/src/index.ts
index f5d5b938..ebb08e82 100644
--- a/apps/mobile-wallet/src/index.ts
+++ b/apps/mobile-wallet/src/index.ts
@@ -1,6 +1,7 @@
export * from './accounts';
export * from './app';
export * from './config/environment';
+export * from './linking';
export * from './navigation';
export * from './sdk';
diff --git a/apps/mobile-wallet/src/linking/__tests__/paymentUri.test.ts b/apps/mobile-wallet/src/linking/__tests__/paymentUri.test.ts
new file mode 100644
index 00000000..1937c69a
--- /dev/null
+++ b/apps/mobile-wallet/src/linking/__tests__/paymentUri.test.ts
@@ -0,0 +1,25 @@
+import { parsePaymentUri } from '../paymentUri';
+
+const DESTINATION = 'GDQP2KPQGKIHYJGXNUIYOMHARUARCA7DJT5FO2FFOOKY3B2WSQHG4W37';
+
+describe('parsePaymentUri', () => {
+ it('parses stellar pay URIs with destination and amount', () => {
+ expect(parsePaymentUri(`stellar:pay?destination=${DESTINATION}&amount=12.5`)).toEqual({
+ dest: DESTINATION,
+ amount: '12.5',
+ });
+ });
+
+ it('parses web+stellar pay URIs without an amount', () => {
+ expect(parsePaymentUri(`web+stellar:pay?destination=${DESTINATION}`)).toEqual({
+ dest: DESTINATION,
+ });
+ });
+
+ it('returns null for unsupported actions, missing destination, and invalid amounts', () => {
+ expect(parsePaymentUri(`stellar:tx?destination=${DESTINATION}`)).toBeNull();
+ expect(parsePaymentUri('stellar:pay?amount=1')).toBeNull();
+ expect(parsePaymentUri(`stellar:pay?destination=${DESTINATION}&amount=0`)).toBeNull();
+ expect(parsePaymentUri('not a uri')).toBeNull();
+ });
+});
diff --git a/apps/mobile-wallet/src/linking/index.ts b/apps/mobile-wallet/src/linking/index.ts
new file mode 100644
index 00000000..976111af
--- /dev/null
+++ b/apps/mobile-wallet/src/linking/index.ts
@@ -0,0 +1,2 @@
+export { parsePaymentUri } from './paymentUri';
+export type { ParsedPaymentUri } from './paymentUri';
diff --git a/apps/mobile-wallet/src/linking/paymentUri.ts b/apps/mobile-wallet/src/linking/paymentUri.ts
new file mode 100644
index 00000000..047fafaa
--- /dev/null
+++ b/apps/mobile-wallet/src/linking/paymentUri.ts
@@ -0,0 +1,53 @@
+export interface ParsedPaymentUri {
+ dest: string;
+ amount?: string;
+}
+
+const SUPPORTED_SCHEMES = new Set(['stellar', 'web+stellar']);
+const DESTINATION_RE = /^G[A-Z0-9]{55}$/;
+
+function isValidAmount(amount: string): boolean {
+ if (!/^(?:0|[1-9]\d*)(?:\.\d{1,7})?$/.test(amount)) {
+ return false;
+ }
+
+ return Number(amount) > 0;
+}
+
+export function parsePaymentUri(uri: string): ParsedPaymentUri | null {
+ const trimmed = uri.trim();
+ const schemeSeparator = trimmed.indexOf(':');
+
+ if (schemeSeparator <= 0) {
+ return null;
+ }
+
+ const scheme = trimmed.slice(0, schemeSeparator).toLowerCase();
+ if (!SUPPORTED_SCHEMES.has(scheme)) {
+ return null;
+ }
+
+ const payload = trimmed.slice(schemeSeparator + 1);
+ const querySeparator = payload.indexOf('?');
+ const action = (querySeparator >= 0 ? payload.slice(0, querySeparator) : payload)
+ .replace(/^\/+/, '')
+ .toLowerCase();
+
+ if (action !== 'pay' || querySeparator < 0) {
+ return null;
+ }
+
+ const params = new URLSearchParams(payload.slice(querySeparator + 1));
+ const dest = params.get('destination') ?? params.get('dest');
+
+ if (!dest || !DESTINATION_RE.test(dest)) {
+ return null;
+ }
+
+ const amount = params.get('amount') ?? undefined;
+ if (amount !== undefined && !isValidAmount(amount)) {
+ return null;
+ }
+
+ return amount ? { dest, amount } : { dest };
+}
diff --git a/apps/mobile-wallet/src/navigation/__tests__/onboarding.test.tsx b/apps/mobile-wallet/src/navigation/__tests__/onboarding.test.tsx
index 2791dfaa..797c2da9 100644
--- a/apps/mobile-wallet/src/navigation/__tests__/onboarding.test.tsx
+++ b/apps/mobile-wallet/src/navigation/__tests__/onboarding.test.tsx
@@ -1,7 +1,6 @@
-import '@testing-library/jest-dom/vitest';
+import '@testing-library/jest-dom';
import { fireEvent, render, screen } from '@testing-library/react';
-import { describe, expect, it } from 'vitest';
import { OnboardingNavigatorTestHarness } from '..';
diff --git a/apps/mobile-wallet/src/screens/history/__tests__/TransactionHistoryList.test.tsx b/apps/mobile-wallet/src/screens/history/__tests__/TransactionHistoryList.test.tsx
index 263923f0..d90f2b44 100644
--- a/apps/mobile-wallet/src/screens/history/__tests__/TransactionHistoryList.test.tsx
+++ b/apps/mobile-wallet/src/screens/history/__tests__/TransactionHistoryList.test.tsx
@@ -3,6 +3,8 @@ import { render, screen } from '@testing-library/react';
import { TransactionHistoryList } from '../../../components/TransactionHistoryList';
import type { Transaction } from '../types';
+const formatSnapshotTimestamp = () => '5/29/2026, 11:00:00 AM';
+
const mockTransaction = (overrides?: Partial): Transaction => ({
id: 'tx-1',
amount: '100.00',
@@ -29,6 +31,7 @@ describe('TransactionHistoryList', () => {
onRetry={jest.fn()}
onRefresh={jest.fn()}
onLoadMore={jest.fn()}
+ formatTimestamp={formatSnapshotTimestamp}
/>
);
@@ -51,6 +54,7 @@ describe('TransactionHistoryList', () => {
onRetry={jest.fn()}
onRefresh={jest.fn()}
onLoadMore={jest.fn()}
+ formatTimestamp={formatSnapshotTimestamp}
/>
);
@@ -72,6 +76,7 @@ describe('TransactionHistoryList', () => {
onRetry={jest.fn()}
onRefresh={jest.fn()}
onLoadMore={jest.fn()}
+ formatTimestamp={formatSnapshotTimestamp}
/>
);
@@ -92,6 +97,7 @@ describe('TransactionHistoryList', () => {
onRetry={jest.fn()}
onRefresh={jest.fn()}
onLoadMore={jest.fn()}
+ formatTimestamp={formatSnapshotTimestamp}
/>
);
@@ -174,6 +180,7 @@ describe('TransactionHistoryList', () => {
onRetry={jest.fn()}
onRefresh={jest.fn()}
onLoadMore={jest.fn()}
+ formatTimestamp={formatSnapshotTimestamp}
/>
);
@@ -194,6 +201,7 @@ describe('TransactionHistoryList', () => {
onRetry={jest.fn()}
onRefresh={jest.fn()}
onLoadMore={jest.fn()}
+ formatTimestamp={formatSnapshotTimestamp}
/>
);
@@ -214,6 +222,7 @@ describe('TransactionHistoryList', () => {
onRetry={jest.fn()}
onRefresh={jest.fn()}
onLoadMore={jest.fn()}
+ formatTimestamp={formatSnapshotTimestamp}
/>
);
@@ -238,6 +247,7 @@ describe('TransactionHistoryList', () => {
onRetry={jest.fn()}
onRefresh={jest.fn()}
onLoadMore={jest.fn()}
+ formatTimestamp={formatSnapshotTimestamp}
/>
);
diff --git a/apps/mobile-wallet/src/screens/history/usePaginatedTransactionHistory.ts b/apps/mobile-wallet/src/screens/history/usePaginatedTransactionHistory.ts
index 179f9d5e..c0753de7 100644
--- a/apps/mobile-wallet/src/screens/history/usePaginatedTransactionHistory.ts
+++ b/apps/mobile-wallet/src/screens/history/usePaginatedTransactionHistory.ts
@@ -10,8 +10,6 @@ import { detectErrorKind, type HistoryError } from './errorTypes';
type Options = {
adapter: TransactionHistoryAdapter;
pageSize?: number;
- maxRetries?: number;
- initialBackoffMs?: number;
};
type State = {
@@ -25,8 +23,6 @@ type State = {
};
const DEFAULT_PAGE_SIZE = 20;
-const DEFAULT_MAX_RETRIES = 3;
-const DEFAULT_INITIAL_BACKOFF_MS = 1000;
const mergeUniqueTransactions = (
incoming: Transaction[],
@@ -48,8 +44,6 @@ const mergeUniqueTransactions = (
export const usePaginatedTransactionHistory = ({
adapter,
pageSize = DEFAULT_PAGE_SIZE,
- _maxRetries = DEFAULT_MAX_RETRIES,
- _initialBackoffMs = DEFAULT_INITIAL_BACKOFF_MS,
}: Options) => {
const [state, setState] = useState({
items: [],
@@ -62,7 +56,6 @@ export const usePaginatedTransactionHistory = ({
});
const requestIdRef = useRef(0);
- const backoffTimeoutRef = useRef | undefined>();
const fetchPage = useCallback(
async ({
@@ -160,15 +153,6 @@ export const usePaginatedTransactionHistory = ({
return fetchPage({ mode, cursor });
}, [fetchPage, state.items.length, state.nextCursor]);
- // Cleanup backoff timeout on unmount
- useEffect(() => {
- return () => {
- if (backoffTimeoutRef.current) {
- clearTimeout(backoffTimeoutRef.current);
- }
- };
- }, []);
-
return useMemo(
() => ({
...state,
diff --git a/apps/mobile-wallet/src/security/hooks/useBiometricUnlock.ts b/apps/mobile-wallet/src/security/hooks/useBiometricUnlock.ts
index c1cb5ecf..16295831 100644
--- a/apps/mobile-wallet/src/security/hooks/useBiometricUnlock.ts
+++ b/apps/mobile-wallet/src/security/hooks/useBiometricUnlock.ts
@@ -218,7 +218,14 @@ export function useBiometricUnlock({
isLoading: false,
feedbackMessage: buildFeedbackMessage(reason, newLockout, 0),
}));
- }, [biometricService, lockoutManager, onSuccess, onPermanentLockout, promptMessage]);
+ }, [
+ biometricService,
+ lockoutManager,
+ onPermanentLockout,
+ onSuccess,
+ promptMessage,
+ startCountdown,
+ ]);
// Password fallback
const switchToPasswordFallback = useCallback(() => {
@@ -263,7 +270,7 @@ export function useBiometricUnlock({
}));
}
},
- [lockoutManager, onSuccess, passwordService]
+ [lockoutManager, onSuccess, passwordService, stopCountdown]
);
const backToBiometric = useCallback(() => {
diff --git a/packages/stellar/src/__tests__/client.test.ts b/packages/stellar/src/__tests__/client.test.ts
index 6e3eb3dc..8b988d0d 100644
--- a/packages/stellar/src/__tests__/client.test.ts
+++ b/packages/stellar/src/__tests__/client.test.ts
@@ -36,6 +36,7 @@ const mockHorizonServerConstructor = jest.fn(() => ({
loadAccount: jest.fn(),
submitTransaction: jest.fn(),
}));
+const mockTransactionFromXDR = jest.fn();
jest.mock('@stellar/stellar-sdk', () => ({
rpc: {
@@ -44,6 +45,11 @@ jest.mock('@stellar/stellar-sdk', () => ({
Horizon: {
Server: jest.fn(() => mockHorizonServerConstructor()),
},
+ TransactionBuilder: {
+ fromXDR: jest.fn((xdr: string, networkPassphrase: string) =>
+ mockTransactionFromXDR(xdr, networkPassphrase)
+ ),
+ },
}));
const mockAccountResponse: Horizon.AccountResponse = {
@@ -111,6 +117,7 @@ describe('StellarClient', () => {
mockRpcServers.clear();
mockRpcServerConstructor.mockClear();
mockHorizonServerConstructor.mockClear();
+ mockTransactionFromXDR.mockReset();
});
afterEach(() => {
@@ -407,6 +414,29 @@ describe('StellarClient', () => {
await expect(client.getAccount('GABC123')).rejects.toBe(exhausted);
});
+
+ it('should retry Horizon 429 responses and preserve retry exhaustion context', async () => {
+ const client = new StellarClient({
+ network: 'testnet',
+ retryOptions: { maxRetries: 2, baseDelayMs: 0 },
+ });
+ const horizon = getHorizonMock(client);
+ horizon.loadAccount.mockRejectedValue(
+ Object.assign(new Error('rate limited'), {
+ response: { status: 429 },
+ })
+ );
+
+ await expect(client.getAccount('GABC123')).rejects.toMatchObject({
+ name: 'RetryExhaustedError',
+ attempts: 3,
+ lastError: expect.objectContaining({
+ name: 'NetworkError',
+ statusCode: 429,
+ }),
+ });
+ expect(horizon.loadAccount).toHaveBeenCalledTimes(3);
+ });
});
describe('submitTransaction', () => {
@@ -427,6 +457,22 @@ describe('StellarClient', () => {
expect(horizon.submitTransaction).toHaveBeenCalledWith(mockTransaction);
});
+ it('should decode signed transaction XDR before submission', async () => {
+ const client = new StellarClient({ network: 'testnet', retryOptions: fastRetryOptions });
+ const horizon = getHorizonMock(client);
+ mockTransactionFromXDR.mockReturnValue(mockTransaction);
+ horizon.submitTransaction.mockResolvedValue(mockSubmitResponse);
+
+ await expect(
+ client.submitTransaction('signed-xdr' as unknown as Transaction)
+ ).resolves.toEqual(mockSubmitResponse);
+ expect(mockTransactionFromXDR).toHaveBeenCalledWith(
+ 'signed-xdr',
+ 'Test SDF Network ; September 2015'
+ );
+ expect(horizon.submitTransaction).toHaveBeenCalledWith(mockTransaction);
+ });
+
it('should throw TransactionError when Horizon returns result codes', async () => {
const client = new StellarClient({ network: 'testnet', retryOptions: fastRetryOptions });
const horizon = getHorizonMock(client);
@@ -445,6 +491,33 @@ describe('StellarClient', () => {
await expect(client.submitTransaction(mockTransaction)).rejects.toThrow(TransactionError);
});
+ it('should preserve Horizon transaction result details on TransactionError', async () => {
+ const client = new StellarClient({ network: 'testnet', retryOptions: fastRetryOptions });
+ const horizon = getHorizonMock(client);
+ horizon.submitTransaction.mockRejectedValue({
+ response: {
+ status: 400,
+ data: {
+ extras: {
+ result_codes: {
+ transaction: 'tx_failed',
+ operations: ['op_underfunded', 'op_no_destination'],
+ },
+ result_xdr: 'result-xdr',
+ },
+ },
+ },
+ });
+
+ await expect(client.submitTransaction(mockTransaction)).rejects.toMatchObject({
+ name: 'TransactionError',
+ resultCode: 'tx_failed',
+ operationResultCodes: ['op_underfunded', 'op_no_destination'],
+ resultXdr: 'result-xdr',
+ statusCode: 400,
+ });
+ });
+
it('should wrap unexpected submission failures as NetworkError', async () => {
const client = new StellarClient({ network: 'testnet', retryOptions: fastRetryOptions });
const horizon = getHorizonMock(client);
diff --git a/packages/stellar/src/client.ts b/packages/stellar/src/client.ts
index e98a96d8..6a97c436 100644
--- a/packages/stellar/src/client.ts
+++ b/packages/stellar/src/client.ts
@@ -2,7 +2,7 @@
* StellarClient - Network client for Stellar blockchain interactions
*/
-import { rpc as StellarRpc, Horizon } from '@stellar/stellar-sdk';
+import { rpc as StellarRpc, Horizon, TransactionBuilder } from '@stellar/stellar-sdk';
import type { Transaction } from '@stellar/stellar-sdk';
import type { Network, NetworkConfig } from '@ancore/types';
import {
@@ -157,11 +157,15 @@ export class StellarClient {
return Math.max(this.rpcServers.length, maxRetries + 1);
}
- private getErrorStatusCode(error: Error): number | undefined {
+ private getErrorStatusCode(error: unknown): number | undefined {
if (error instanceof NetworkError) {
return error.statusCode;
}
+ if (!error || typeof error !== 'object') {
+ return undefined;
+ }
+
if ('statusCode' in error && typeof error.statusCode === 'number') {
return error.statusCode;
}
@@ -268,11 +272,16 @@ export class StellarClient {
const account = await this.horizonServer.loadAccount(publicKey);
return account;
} catch (error) {
- if (error instanceof Error && error.message.includes('Not Found')) {
+ const statusCode = this.getErrorStatusCode(error);
+ if (
+ statusCode === 404 ||
+ (error instanceof Error && error.message.includes('Not Found'))
+ ) {
throw new AccountNotFoundError(publicKey);
}
throw new NetworkError('Failed to load account', {
cause: error instanceof Error ? error : undefined,
+ statusCode,
});
}
},
@@ -286,7 +295,7 @@ export class StellarClient {
if (error instanceof RetryExhaustedError && error.lastError) {
if (
error.lastError instanceof AccountNotFoundError ||
- error.lastError instanceof NetworkError
+ (error.lastError instanceof NetworkError && error.lastError.statusCode !== 429)
) {
throw error.lastError;
}
@@ -436,31 +445,23 @@ export class StellarClient {
* @throws NetworkError if the network request fails
*/
async submitTransaction(
- transaction: Transaction
+ transaction: Transaction | string
): Promise {
try {
return await withRetry(async () => {
try {
- const response = await this.horizonServer.submitTransaction(transaction);
+ const response = await this.horizonServer.submitTransaction(
+ this.resolveSignedTransaction(transaction)
+ );
return response;
} catch (error) {
- if (
- error &&
- typeof error === 'object' &&
- 'response' in error &&
- error.response &&
- typeof error.response === 'object' &&
- 'data' in error.response
- ) {
- const data = error.response.data as {
- extras?: { result_codes?: { transaction?: string } };
- };
- throw new TransactionError('Transaction submission failed', {
- resultCode: data?.extras?.result_codes?.transaction,
- });
+ const transactionError = TransactionError.fromHorizonError(error);
+ if (transactionError) {
+ throw transactionError;
}
throw new NetworkError('Failed to submit transaction', {
cause: error instanceof Error ? error : undefined,
+ statusCode: this.getErrorStatusCode(error),
});
}
}, this.retryOptions);
@@ -477,6 +478,14 @@ export class StellarClient {
}
}
+ private resolveSignedTransaction(transaction: Transaction | string): Transaction {
+ if (typeof transaction !== 'string') {
+ return transaction;
+ }
+
+ return TransactionBuilder.fromXDR(transaction, this.networkPassphrase) as Transaction;
+ }
+
/**
* Fund an account using Friendbot (testnet only)
*
diff --git a/packages/stellar/src/errors.ts b/packages/stellar/src/errors.ts
index 013387d8..9a423153 100644
--- a/packages/stellar/src/errors.ts
+++ b/packages/stellar/src/errors.ts
@@ -44,15 +44,70 @@ export class AccountNotFoundError extends StellarError {
/**
* Error thrown when a transaction submission fails
*/
+type HorizonErrorPayload = {
+ response?: {
+ status?: number;
+ data?: {
+ message?: string;
+ extras?: {
+ result_codes?: {
+ transaction?: string;
+ operations?: unknown;
+ };
+ result_xdr?: string;
+ };
+ result_xdr?: string;
+ };
+ };
+};
+
+export interface TransactionErrorOptions {
+ resultCode?: string;
+ resultXdr?: string;
+ operationResultCodes?: string[];
+ statusCode?: number;
+}
+
export class TransactionError extends StellarError {
public readonly resultCode?: string;
public readonly resultXdr?: string;
+ public readonly operationResultCodes?: string[];
+ public readonly statusCode?: number;
- constructor(message: string, options?: { resultCode?: string; resultXdr?: string }) {
+ constructor(message: string, options?: TransactionErrorOptions) {
super(message);
this.name = 'TransactionError';
this.resultCode = options?.resultCode;
this.resultXdr = options?.resultXdr;
+ this.operationResultCodes = options?.operationResultCodes;
+ this.statusCode = options?.statusCode;
+ }
+
+ static fromHorizonError(error: unknown): TransactionError | null {
+ if (!error || typeof error !== 'object') {
+ return null;
+ }
+
+ const payload = error as HorizonErrorPayload;
+ const data = payload.response?.data;
+ const extras = data?.extras;
+ const resultCodes = extras?.result_codes;
+ const resultCode = resultCodes?.transaction;
+ const operationResultCodes = Array.isArray(resultCodes?.operations)
+ ? resultCodes.operations.filter((code): code is string => typeof code === 'string')
+ : undefined;
+ const resultXdr = extras?.result_xdr ?? data?.result_xdr;
+
+ if (!resultCode && !operationResultCodes?.length && !resultXdr) {
+ return null;
+ }
+
+ return new TransactionError(data?.message ?? 'Transaction submission failed', {
+ resultCode,
+ operationResultCodes,
+ resultXdr,
+ statusCode: payload.response?.status,
+ });
}
}
diff --git a/packages/stellar/src/fee-stats.ts b/packages/stellar/src/fee-stats.ts
index 9896c8c0..be3e4303 100644
--- a/packages/stellar/src/fee-stats.ts
+++ b/packages/stellar/src/fee-stats.ts
@@ -119,11 +119,16 @@ function normalize(raw: HorizonFeeStats): FeeStats {
*/
export async function fetchFeeStats(options: FeeStatsOptions): Promise {
const { horizonUrl, retryOptions = DEFAULT_RETRY, fallback = FALLBACK_FEE_STATS } = options;
+ const callerIsRetryable = retryOptions.isRetryable;
try {
const raw = await withRetry(() => fetchRaw(horizonUrl), {
...retryOptions,
isRetryable: (error) => {
+ if (callerIsRetryable && !callerIsRetryable(error)) {
+ return false;
+ }
+
if (error instanceof NetworkError && error.statusCode !== undefined) {
return isRetryableStatus(error.statusCode);
}
diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts
index 766984f1..016d697b 100644
--- a/packages/types/src/index.ts
+++ b/packages/types/src/index.ts
@@ -36,4 +36,5 @@ export * from './schemas';
export * from './payment-request';
export * from './contacts';
export * from './scheduled-transfer';
-export * from './statement';
\ No newline at end of file
+export * from './statement';
+export * from './handle-resolution';
diff --git a/packages/ui-kit/package.json b/packages/ui-kit/package.json
index 75dd0f12..24d2d1cd 100644
--- a/packages/ui-kit/package.json
+++ b/packages/ui-kit/package.json
@@ -68,6 +68,7 @@
"@typescript-eslint/parser": "^8.0.0",
"@vitejs/plugin-react": "^4.2.0",
"autoprefixer": "^10.4.0",
+ "axe-core": "^4.11.4",
"eslint": "^9.0.0",
"eslint-plugin-react": "^7.37.0",
"eslint-plugin-react-hooks": "^5.0.0",
diff --git a/packages/ui-kit/src/__tests__/Form/Field.test.tsx b/packages/ui-kit/src/__tests__/Form/Field.test.tsx
new file mode 100644
index 00000000..8ba2c8ea
--- /dev/null
+++ b/packages/ui-kit/src/__tests__/Form/Field.test.tsx
@@ -0,0 +1,55 @@
+import { render, screen } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+import { Field } from '@/components/Form/Field';
+import { Input } from '@/components/ui/input';
+
+describe('Field', () => {
+ it('links the label, description, and error to the wrapped control', () => {
+ render(
+
+
+
+ );
+
+ const input = screen.getByLabelText('Amount');
+
+ expect(input).toHaveAttribute('aria-invalid', 'true');
+ expect(input.getAttribute('aria-describedby')).toContain(
+ screen.getByText('Enter the transfer amount.').id
+ );
+ expect(input.getAttribute('aria-describedby')).toContain(
+ screen.getByText('Amount is required').id
+ );
+ });
+
+ it('uses the child id when one is provided', () => {
+ render(
+
+
+
+ );
+
+ expect(screen.getByLabelText('Recipient')).toHaveAttribute('id', 'recipient-address');
+ });
+
+ it('supports render-prop controls with custom markup', () => {
+ render(
+
+ {({ controlProps }) => (
+
+
+ 140
+
+ )}
+
+ );
+
+ const textarea = screen.getByLabelText('Note');
+
+ expect(textarea).toHaveAttribute(
+ 'aria-describedby',
+ screen.getByText('Visible only to you.').id
+ );
+ expect(screen.getByText('140')).toBeInTheDocument();
+ });
+});
diff --git a/packages/ui-kit/src/__tests__/test-utils/a11y.ts b/packages/ui-kit/src/__tests__/test-utils/a11y.ts
new file mode 100644
index 00000000..dc2977ea
--- /dev/null
+++ b/packages/ui-kit/src/__tests__/test-utils/a11y.ts
@@ -0,0 +1,42 @@
+import axe from 'axe-core';
+import type { AxeResults, RunOptions } from 'axe-core';
+import { expect } from 'vitest';
+
+const defaultOptions: RunOptions = {
+ rules: {
+ 'color-contrast': { enabled: false },
+ },
+};
+
+function mergeOptions(options?: RunOptions): RunOptions {
+ return {
+ ...defaultOptions,
+ ...options,
+ rules: {
+ ...defaultOptions.rules,
+ ...options?.rules,
+ },
+ };
+}
+
+export async function runA11yCheck(
+ container: Element | Document = document,
+ options?: RunOptions
+): Promise {
+ return axe.run(container, mergeOptions(options));
+}
+
+export async function expectNoA11yViolations(
+ container: Element | Document = document,
+ options?: RunOptions
+): Promise {
+ const results = await runA11yCheck(container, options);
+ const violations = results.violations.map((violation) => ({
+ id: violation.id,
+ impact: violation.impact,
+ help: violation.help,
+ nodes: violation.nodes.map((node) => node.target),
+ }));
+
+ expect(violations).toEqual([]);
+}
diff --git a/packages/ui-kit/src/components/Form/Field.stories.tsx b/packages/ui-kit/src/components/Form/Field.stories.tsx
new file mode 100644
index 00000000..3e4309f8
--- /dev/null
+++ b/packages/ui-kit/src/components/Form/Field.stories.tsx
@@ -0,0 +1,35 @@
+import type { Meta, StoryObj } from '@storybook/react';
+import { Field } from './Field';
+import { Input } from '@/components/ui/input';
+
+const meta = {
+ title: 'Form/Field',
+ component: Field,
+ parameters: {
+ layout: 'centered',
+ },
+ tags: ['autodocs'],
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ render: () => (
+
+
+
+
+
+ ),
+};
+
+export const WithError: Story = {
+ render: () => (
+
+
+
+
+
+ ),
+};
diff --git a/packages/ui-kit/src/components/Form/Field.tsx b/packages/ui-kit/src/components/Form/Field.tsx
new file mode 100644
index 00000000..7096ae32
--- /dev/null
+++ b/packages/ui-kit/src/components/Form/Field.tsx
@@ -0,0 +1,81 @@
+import * as React from 'react';
+import { cn } from '@/lib/utils';
+
+type FieldControlProps = {
+ id?: string;
+ 'aria-describedby'?: string;
+ 'aria-invalid'?: boolean | 'true' | 'false';
+};
+
+type FieldRenderProps = {
+ id: string;
+ describedBy?: string;
+ invalid: boolean;
+ controlProps: FieldControlProps & { id: string };
+};
+
+export interface FieldProps extends Omit, 'children'> {
+ label: React.ReactNode;
+ children: React.ReactElement | ((props: FieldRenderProps) => React.ReactNode);
+ description?: React.ReactNode;
+ error?: React.ReactNode;
+ required?: boolean;
+}
+
+function mergeIds(...ids: Array): string | undefined {
+ const merged = ids.filter(Boolean).join(' ');
+ return merged || undefined;
+}
+
+const Field = React.forwardRef(
+ ({ label, children, description, error, required = false, className, ...props }, ref) => {
+ const generatedId = React.useId();
+ const childProps = typeof children === 'function' ? undefined : children.props;
+ const inputId = childProps?.id ?? generatedId;
+ const descriptionId = description ? `${inputId}-description` : undefined;
+ const errorId = error ? `${inputId}-error` : undefined;
+ const describedBy = mergeIds(childProps?.['aria-describedby'], descriptionId, errorId);
+
+ const controlProps: FieldControlProps & { id: string } = {
+ id: inputId,
+ 'aria-describedby': describedBy,
+ 'aria-invalid': error ? true : childProps?.['aria-invalid'],
+ };
+
+ const control =
+ typeof children === 'function'
+ ? children({
+ id: inputId,
+ describedBy,
+ invalid: Boolean(error),
+ controlProps,
+ })
+ : React.cloneElement(children, controlProps);
+
+ return (
+
+
+ {control}
+ {description && (
+
+ {description}
+
+ )}
+ {error && (
+
+ {error}
+
+ )}
+
+ );
+ }
+);
+Field.displayName = 'Field';
+
+export { Field };
diff --git a/packages/ui-kit/src/components/Form/index.ts b/packages/ui-kit/src/components/Form/index.ts
index 317f6866..99188026 100644
--- a/packages/ui-kit/src/components/Form/index.ts
+++ b/packages/ui-kit/src/components/Form/index.ts
@@ -1,6 +1,9 @@
export { Form, FormSubmit, FormError } from './Form';
export type { FormProps, FormSubmitProps, FormErrorProps } from './Form';
+export { Field } from './Field';
+export type { FieldProps } from './Field';
+
export { AddressInput, AddressInputBase } from './AddressInput';
export type { AddressInputProps, AddressInputBaseProps } from './AddressInput';
diff --git a/packages/ui-kit/src/components/Toast/NotificationProvider.tsx b/packages/ui-kit/src/components/Toast/NotificationProvider.tsx
index 9cdc5305..6866529e 100644
--- a/packages/ui-kit/src/components/Toast/NotificationProvider.tsx
+++ b/packages/ui-kit/src/components/Toast/NotificationProvider.tsx
@@ -2,7 +2,7 @@ import * as React from 'react';
import * as ReactDOM from 'react-dom';
import { Toast as ToastComponent } from './Toast';
-export type ToastVariant = 'success' | 'error' | 'info';
+export type ToastVariant = 'success' | 'error' | 'warning' | 'info';
export interface Toast {
id: string;
@@ -10,6 +10,8 @@ export interface Toast {
variant: ToastVariant;
}
+export type ToastItem = Toast;
+
interface NotificationContextValue {
toast: (message: string, variant?: ToastVariant, duration?: number) => void;
}
diff --git a/packages/ui-kit/src/components/Toast/Toast.tsx b/packages/ui-kit/src/components/Toast/Toast.tsx
index 9735af9b..87e8c14a 100644
--- a/packages/ui-kit/src/components/Toast/Toast.tsx
+++ b/packages/ui-kit/src/components/Toast/Toast.tsx
@@ -1,4 +1,3 @@
-import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { CheckCircle, XCircle, Info, AlertTriangle, X } from 'lucide-react';
import { cn } from '@/lib/utils';
diff --git a/packages/ui-kit/src/components/Toast/ToastContainer.tsx b/packages/ui-kit/src/components/Toast/ToastContainer.tsx
index 62f43a57..ef25b61c 100644
--- a/packages/ui-kit/src/components/Toast/ToastContainer.tsx
+++ b/packages/ui-kit/src/components/Toast/ToastContainer.tsx
@@ -1,4 +1,3 @@
-import * as React from 'react';
import { Toast } from './Toast';
import type { ToastItem } from './NotificationProvider';
diff --git a/packages/ui-kit/src/components/TransactionItem.tsx b/packages/ui-kit/src/components/TransactionItem.tsx
index 21fb94d4..f094a506 100644
--- a/packages/ui-kit/src/components/TransactionItem.tsx
+++ b/packages/ui-kit/src/components/TransactionItem.tsx
@@ -32,7 +32,10 @@ const TYPE_ICONS: Record = {
withdrawal: ,
};
-export interface TransactionItemProps extends React.HTMLAttributes {
+export interface TransactionItemProps extends Omit<
+ React.HTMLAttributes,
+ 'onClick'
+> {
transaction: TransactionRecord;
onClick?: (transaction: TransactionRecord) => void;
}
diff --git a/packages/ui-kit/src/components/ui/button.test.tsx b/packages/ui-kit/src/components/ui/button.test.tsx
index 74cbc738..abc4d86c 100644
--- a/packages/ui-kit/src/components/ui/button.test.tsx
+++ b/packages/ui-kit/src/components/ui/button.test.tsx
@@ -1,6 +1,7 @@
import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { Button } from './button';
+import { expectNoA11yViolations } from '../../__tests__/test-utils/a11y';
describe('Button', () => {
it('renders with children text', () => {
@@ -39,4 +40,10 @@ describe('Button', () => {
rerender();
expect(screen.getByText('Large')).toHaveClass('h-11');
});
+
+ it('has no axe violations in the default state', async () => {
+ const { container } = render();
+
+ await expectNoA11yViolations(container);
+ });
});
diff --git a/packages/ui-kit/src/components/ui/input.test.tsx b/packages/ui-kit/src/components/ui/input.test.tsx
index 9322581c..c2a9d2a2 100644
--- a/packages/ui-kit/src/components/ui/input.test.tsx
+++ b/packages/ui-kit/src/components/ui/input.test.tsx
@@ -2,6 +2,7 @@ import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import userEvent from '@testing-library/user-event';
import { Input } from './input';
+import { expectNoA11yViolations } from '../../__tests__/test-utils/a11y';
describe('Input', () => {
it('renders with placeholder', () => {
@@ -36,4 +37,15 @@ describe('Input', () => {
rerender();
expect(screen.getByPlaceholderText('Password')).toHaveAttribute('type', 'password');
});
+
+ it('has no axe violations when labelled', async () => {
+ const { container } = render(
+
+ );
+
+ await expectNoA11yViolations(container);
+ });
});
diff --git a/packages/ui-kit/src/index.ts b/packages/ui-kit/src/index.ts
index 4986cc33..8e1f28f6 100644
--- a/packages/ui-kit/src/index.ts
+++ b/packages/ui-kit/src/index.ts
@@ -52,6 +52,9 @@ export type { IdenticonProps } from './components/Identicon';
export { Form, FormSubmit, FormError } from './components/Form/Form';
export type { FormProps, FormSubmitProps, FormErrorProps } from './components/Form/Form';
+export { Field } from './components/Form/Field';
+export type { FieldProps } from './components/Form/Field';
+
export { AddressInput, AddressInputBase } from './components/Form/AddressInput';
export type { AddressInputProps, AddressInputBaseProps } from './components/Form/AddressInput';
diff --git a/packages/ui-kit/tsconfig.json b/packages/ui-kit/tsconfig.json
index 93ac7da6..63592b2a 100644
--- a/packages/ui-kit/tsconfig.json
+++ b/packages/ui-kit/tsconfig.json
@@ -5,7 +5,7 @@
"jsx": "react-jsx",
"outDir": "./dist",
"rootDir": "./src",
- "composite": true,
+ "composite": false,
"tsBuildInfoFile": "./dist/.tsbuildinfo",
"baseUrl": ".",
"paths": {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f8b816e7..e6a39797 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -222,13 +222,13 @@ importers:
version: 5.0.0(eslint@9.39.4(jiti@1.21.7))
jest:
specifier: ^29.7.0
- version: 29.7.0(@types/node@25.9.1)
+ version: 29.7.0(@types/node@25.9.1)(ts-node@10.9.2(@types/node@25.9.1)(typescript@5.9.3))
jest-environment-jsdom:
specifier: ^30.3.0
version: 30.4.1
ts-jest:
specifier: ^29.4.0
- version: 29.4.11(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@30.4.1)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.21.5)(jest-util@30.4.1)(jest@29.7.0(@types/node@25.9.1))(typescript@5.9.3)
+ version: 29.4.11(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@30.4.1)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.21.5)(jest-util@30.4.1)(jest@29.7.0(@types/node@25.9.1)(ts-node@10.9.2(@types/node@25.9.1)(typescript@5.9.3)))(typescript@5.9.3)
tsup:
specifier: ^8.0.0
version: 8.5.1(jiti@1.21.7)(postcss@8.5.15)(typescript@5.9.3)(yaml@2.9.0)
@@ -356,10 +356,10 @@ importers:
version: 9.39.4(jiti@1.21.7)
jest:
specifier: ^29.7.0
- version: 29.7.0(@types/node@25.9.1)
+ version: 29.7.0(@types/node@25.9.1)(ts-node@10.9.2(@types/node@25.9.1)(typescript@5.9.3))
ts-jest:
specifier: ^29.2.0
- version: 29.4.11(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@30.4.1)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.21.5)(jest-util@30.4.1)(jest@29.7.0(@types/node@25.9.1))(typescript@5.9.3)
+ version: 29.4.11(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@30.4.1)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.21.5)(jest-util@30.4.1)(jest@29.7.0(@types/node@25.9.1)(ts-node@10.9.2(@types/node@25.9.1)(typescript@5.9.3)))(typescript@5.9.3)
tsup:
specifier: ^8.0.0
version: 8.5.1(jiti@1.21.7)(postcss@8.5.15)(typescript@5.9.3)(yaml@2.9.0)
@@ -405,10 +405,10 @@ importers:
version: 9.39.4(jiti@1.21.7)
jest:
specifier: ^29.7.0
- version: 29.7.0(@types/node@25.9.1)
+ version: 29.7.0(@types/node@25.9.1)(ts-node@10.9.2(@types/node@25.9.1)(typescript@5.9.3))
ts-jest:
specifier: ^29.4.0
- version: 29.4.11(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@30.4.1)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.21.5)(jest-util@30.4.1)(jest@29.7.0(@types/node@25.9.1))(typescript@5.9.3)
+ version: 29.4.11(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@30.4.1)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.21.5)(jest-util@30.4.1)(jest@29.7.0(@types/node@25.9.1)(ts-node@10.9.2(@types/node@25.9.1)(typescript@5.9.3)))(typescript@5.9.3)
tsup:
specifier: ^8.0.0
version: 8.5.1(jiti@1.21.7)(postcss@8.5.15)(typescript@5.9.3)(yaml@2.9.0)
@@ -463,10 +463,10 @@ importers:
version: 3.23.2
jest:
specifier: ^29.7.0
- version: 29.7.0(@types/node@20.19.41)
+ version: 29.7.0(@types/node@20.19.41)(ts-node@10.9.2(@types/node@20.19.41)(typescript@5.9.3))
ts-jest:
specifier: ^29.4.0
- version: 29.4.11(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@30.4.1)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@30.4.1)(jest@29.7.0(@types/node@20.19.41))(typescript@5.9.3)
+ version: 29.4.11(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@30.4.1)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.21.5)(jest-util@30.4.1)(jest@29.7.0(@types/node@20.19.41)(ts-node@10.9.2(@types/node@20.19.41)(typescript@5.9.3)))(typescript@5.9.3)
tsup:
specifier: ^8.0.0
version: 8.5.1(jiti@1.21.7)(postcss@8.5.15)(typescript@5.9.3)(yaml@2.9.0)
@@ -500,10 +500,10 @@ importers:
version: 9.39.4(jiti@1.21.7)
jest:
specifier: ^29.7.0
- version: 29.7.0(@types/node@25.9.1)
+ version: 29.7.0(@types/node@25.9.1)(ts-node@10.9.2(@types/node@25.9.1)(typescript@5.9.3))
ts-jest:
specifier: ^29.2.0
- version: 29.4.11(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@30.4.1)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.21.5)(jest-util@30.4.1)(jest@29.7.0(@types/node@25.9.1))(typescript@5.9.3)
+ version: 29.4.11(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@30.4.1)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.21.5)(jest-util@30.4.1)(jest@29.7.0(@types/node@25.9.1)(ts-node@10.9.2(@types/node@25.9.1)(typescript@5.9.3)))(typescript@5.9.3)
tsup:
specifier: ^8.0.0
version: 8.5.1(jiti@1.21.7)(postcss@8.5.15)(typescript@5.9.3)(yaml@2.9.0)
@@ -537,10 +537,10 @@ importers:
version: 9.39.4(jiti@1.21.7)
jest:
specifier: ^29.7.0
- version: 29.7.0(@types/node@25.9.1)
+ version: 29.7.0(@types/node@25.9.1)(ts-node@10.9.2(@types/node@25.9.1)(typescript@5.9.3))
ts-jest:
specifier: ^29.4.0
- version: 29.4.11(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@30.4.1)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.21.5)(jest-util@30.4.1)(jest@29.7.0(@types/node@25.9.1))(typescript@5.9.3)
+ version: 29.4.11(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@30.4.1)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.21.5)(jest-util@30.4.1)(jest@29.7.0(@types/node@25.9.1)(ts-node@10.9.2(@types/node@25.9.1)(typescript@5.9.3)))(typescript@5.9.3)
tsup:
specifier: ^8.0.0
version: 8.5.1(jiti@1.21.7)(postcss@8.5.15)(typescript@5.9.3)(yaml@2.9.0)
@@ -641,6 +641,9 @@ importers:
autoprefixer:
specifier: ^10.4.0
version: 10.5.0(postcss@8.5.15)
+ axe-core:
+ specifier: ^4.11.4
+ version: 4.11.4
eslint:
specifier: ^9.0.0
version: 9.39.4(jiti@1.21.7)
@@ -689,6 +692,9 @@ importers:
express:
specifier: ^4.18.2
version: 4.22.2
+ zod:
+ specifier: ^3.22.4
+ version: 3.25.76
devDependencies:
'@types/express':
specifier: ^4.17.21
@@ -710,7 +716,7 @@ importers:
version: 6.3.4
ts-jest:
specifier: ^29.1.0
- version: 29.4.11(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@30.4.1)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@30.4.1)(jest@29.7.0(@types/node@20.19.41)(ts-node@10.9.2(@types/node@20.19.41)(typescript@5.9.3)))(typescript@5.9.3)
+ version: 29.4.11(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@30.4.1)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.21.5)(jest-util@30.4.1)(jest@29.7.0(@types/node@20.19.41)(ts-node@10.9.2(@types/node@20.19.41)(typescript@5.9.3)))(typescript@5.9.3)
ts-node:
specifier: ^10.9.2
version: 10.9.2(@types/node@20.19.41)(typescript@5.9.3)
@@ -753,13 +759,13 @@ importers:
version: 6.0.3
jest:
specifier: ^29.7.0
- version: 29.7.0(@types/node@20.19.41)
+ version: 29.7.0(@types/node@20.19.41)(ts-node@10.9.2(@types/node@20.19.41)(typescript@5.9.3))
supertest:
specifier: ^6.3.4
version: 6.3.4
ts-jest:
specifier: ^29.1.0
- version: 29.4.11(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@30.4.1)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@30.4.1)(jest@29.7.0(@types/node@20.19.41))(typescript@5.9.3)
+ version: 29.4.11(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@30.4.1)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.21.5)(jest-util@30.4.1)(jest@29.7.0(@types/node@20.19.41)(ts-node@10.9.2(@types/node@20.19.41)(typescript@5.9.3)))(typescript@5.9.3)
typescript:
specifier: ^5.3.0
version: 5.9.3
@@ -3360,6 +3366,10 @@ packages:
resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
engines: {node: '>= 0.4'}
+ axe-core@4.11.4:
+ resolution: {integrity: sha512-KunSNx+TVpkAw/6ULfhnx+HWRecjqZGTOyquAoWHYLRSdK1tB5Ihce1ZW+UY3fj33bYAFWPu7W/GRSmmrCGuxA==}
+ engines: {node: '>=4'}
+
axios@1.16.1:
resolution: {integrity: sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==}
@@ -8379,6 +8389,41 @@ snapshots:
- supports-color
- ts-node
+ '@jest/core@29.7.0(ts-node@10.9.2(@types/node@25.9.1)(typescript@5.9.3))':
+ dependencies:
+ '@jest/console': 29.7.0
+ '@jest/reporters': 29.7.0
+ '@jest/test-result': 29.7.0
+ '@jest/transform': 29.7.0
+ '@jest/types': 29.6.3
+ '@types/node': 25.9.1
+ ansi-escapes: 4.3.2
+ chalk: 4.1.2
+ ci-info: 3.9.0
+ exit: 0.1.2
+ graceful-fs: 4.2.11
+ jest-changed-files: 29.7.0
+ jest-config: 29.7.0(@types/node@25.9.1)(ts-node@10.9.2(@types/node@25.9.1)(typescript@5.9.3))
+ jest-haste-map: 29.7.0
+ jest-message-util: 29.7.0
+ jest-regex-util: 29.6.3
+ jest-resolve: 29.7.0
+ jest-resolve-dependencies: 29.7.0
+ jest-runner: 29.7.0
+ jest-runtime: 29.7.0
+ jest-snapshot: 29.7.0
+ jest-util: 29.7.0
+ jest-validate: 29.7.0
+ jest-watcher: 29.7.0
+ micromatch: 4.0.8
+ pretty-format: 29.7.0
+ slash: 3.0.0
+ strip-ansi: 6.0.1
+ transitivePeerDependencies:
+ - babel-plugin-macros
+ - supports-color
+ - ts-node
+
'@jest/environment-jsdom-abstract@30.4.1(jsdom@26.1.0)':
dependencies:
'@jest/environment': 30.4.1
@@ -10389,6 +10434,8 @@ snapshots:
dependencies:
possible-typed-array-names: 1.1.0
+ axe-core@4.11.4: {}
+
axios@1.16.1:
dependencies:
follow-redirects: 1.16.0
@@ -10863,13 +10910,13 @@ snapshots:
- supports-color
- ts-node
- create-jest@29.7.0(@types/node@25.9.1):
+ create-jest@29.7.0(@types/node@25.9.1)(ts-node@10.9.2(@types/node@25.9.1)(typescript@5.9.3)):
dependencies:
'@jest/types': 29.6.3
chalk: 4.1.2
exit: 0.1.2
graceful-fs: 4.2.11
- jest-config: 29.7.0(@types/node@25.9.1)(ts-node@10.9.2(@types/node@20.19.41)(typescript@5.9.3))
+ jest-config: 29.7.0(@types/node@25.9.1)(ts-node@10.9.2(@types/node@25.9.1)(typescript@5.9.3))
jest-util: 29.7.0
prompts: 2.4.2
transitivePeerDependencies:
@@ -12210,7 +12257,7 @@ snapshots:
- babel-plugin-macros
- supports-color
- jest-cli@29.7.0(@types/node@20.19.41):
+ jest-cli@29.7.0(@types/node@20.19.41)(ts-node@10.9.2(@types/node@20.19.41)(typescript@5.9.3)):
dependencies:
'@jest/core': 29.7.0(ts-node@10.9.2(@types/node@20.19.41)(typescript@5.9.3))
'@jest/test-result': 29.7.0
@@ -12229,16 +12276,16 @@ snapshots:
- supports-color
- ts-node
- jest-cli@29.7.0(@types/node@20.19.41)(ts-node@10.9.2(@types/node@20.19.41)(typescript@5.9.3)):
+ jest-cli@29.7.0(@types/node@25.9.1)(ts-node@10.9.2(@types/node@25.9.1)(typescript@5.9.3)):
dependencies:
- '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@20.19.41)(typescript@5.9.3))
+ '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@25.9.1)(typescript@5.9.3))
'@jest/test-result': 29.7.0
'@jest/types': 29.6.3
chalk: 4.1.2
- create-jest: 29.7.0(@types/node@20.19.41)(ts-node@10.9.2(@types/node@20.19.41)(typescript@5.9.3))
+ create-jest: 29.7.0(@types/node@25.9.1)(ts-node@10.9.2(@types/node@25.9.1)(typescript@5.9.3))
exit: 0.1.2
import-local: 3.2.0
- jest-config: 29.7.0(@types/node@20.19.41)(ts-node@10.9.2(@types/node@20.19.41)(typescript@5.9.3))
+ jest-config: 29.7.0(@types/node@25.9.1)(ts-node@10.9.2(@types/node@25.9.1)(typescript@5.9.3))
jest-util: 29.7.0
jest-validate: 29.7.0
yargs: 17.7.2
@@ -12248,26 +12295,38 @@ snapshots:
- supports-color
- ts-node
- jest-cli@29.7.0(@types/node@25.9.1):
+ jest-config@29.7.0(@types/node@20.19.41)(ts-node@10.9.2(@types/node@20.19.41)(typescript@5.9.3)):
dependencies:
- '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@20.19.41)(typescript@5.9.3))
- '@jest/test-result': 29.7.0
+ '@babel/core': 7.29.7
+ '@jest/test-sequencer': 29.7.0
'@jest/types': 29.6.3
+ babel-jest: 29.7.0(@babel/core@7.29.7)
chalk: 4.1.2
- create-jest: 29.7.0(@types/node@25.9.1)
- exit: 0.1.2
- import-local: 3.2.0
- jest-config: 29.7.0(@types/node@25.9.1)(ts-node@10.9.2(@types/node@20.19.41)(typescript@5.9.3))
+ ci-info: 3.9.0
+ deepmerge: 4.3.1
+ glob: 7.2.3
+ graceful-fs: 4.2.11
+ jest-circus: 29.7.0
+ jest-environment-node: 29.7.0
+ jest-get-type: 29.6.3
+ jest-regex-util: 29.6.3
+ jest-resolve: 29.7.0
+ jest-runner: 29.7.0
jest-util: 29.7.0
jest-validate: 29.7.0
- yargs: 17.7.2
+ micromatch: 4.0.8
+ parse-json: 5.2.0
+ pretty-format: 29.7.0
+ slash: 3.0.0
+ strip-json-comments: 3.1.1
+ optionalDependencies:
+ '@types/node': 20.19.41
+ ts-node: 10.9.2(@types/node@20.19.41)(typescript@5.9.3)
transitivePeerDependencies:
- - '@types/node'
- babel-plugin-macros
- supports-color
- - ts-node
- jest-config@29.7.0(@types/node@20.19.41)(ts-node@10.9.2(@types/node@20.19.41)(typescript@5.9.3)):
+ jest-config@29.7.0(@types/node@25.9.1)(ts-node@10.9.2(@types/node@20.19.41)(typescript@5.9.3)):
dependencies:
'@babel/core': 7.29.7
'@jest/test-sequencer': 29.7.0
@@ -12292,13 +12351,13 @@ snapshots:
slash: 3.0.0
strip-json-comments: 3.1.1
optionalDependencies:
- '@types/node': 20.19.41
+ '@types/node': 25.9.1
ts-node: 10.9.2(@types/node@20.19.41)(typescript@5.9.3)
transitivePeerDependencies:
- babel-plugin-macros
- supports-color
- jest-config@29.7.0(@types/node@25.9.1)(ts-node@10.9.2(@types/node@20.19.41)(typescript@5.9.3)):
+ jest-config@29.7.0(@types/node@25.9.1)(ts-node@10.9.2(@types/node@25.9.1)(typescript@5.9.3)):
dependencies:
'@babel/core': 7.29.7
'@jest/test-sequencer': 29.7.0
@@ -12324,7 +12383,7 @@ snapshots:
strip-json-comments: 3.1.1
optionalDependencies:
'@types/node': 25.9.1
- ts-node: 10.9.2(@types/node@20.19.41)(typescript@5.9.3)
+ ts-node: 10.9.2(@types/node@25.9.1)(typescript@5.9.3)
transitivePeerDependencies:
- babel-plugin-macros
- supports-color
@@ -12589,18 +12648,6 @@ snapshots:
merge-stream: 2.0.0
supports-color: 8.1.1
- jest@29.7.0(@types/node@20.19.41):
- dependencies:
- '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@20.19.41)(typescript@5.9.3))
- '@jest/types': 29.6.3
- import-local: 3.2.0
- jest-cli: 29.7.0(@types/node@20.19.41)
- transitivePeerDependencies:
- - '@types/node'
- - babel-plugin-macros
- - supports-color
- - ts-node
-
jest@29.7.0(@types/node@20.19.41)(ts-node@10.9.2(@types/node@20.19.41)(typescript@5.9.3)):
dependencies:
'@jest/core': 29.7.0(ts-node@10.9.2(@types/node@20.19.41)(typescript@5.9.3))
@@ -12613,12 +12660,12 @@ snapshots:
- supports-color
- ts-node
- jest@29.7.0(@types/node@25.9.1):
+ jest@29.7.0(@types/node@25.9.1)(ts-node@10.9.2(@types/node@25.9.1)(typescript@5.9.3)):
dependencies:
- '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@20.19.41)(typescript@5.9.3))
+ '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@25.9.1)(typescript@5.9.3))
'@jest/types': 29.6.3
import-local: 3.2.0
- jest-cli: 29.7.0(@types/node@25.9.1)
+ jest-cli: 29.7.0(@types/node@25.9.1)(ts-node@10.9.2(@types/node@25.9.1)(typescript@5.9.3))
transitivePeerDependencies:
- '@types/node'
- babel-plugin-macros
@@ -14337,12 +14384,12 @@ snapshots:
ts-interface-checker@0.1.13: {}
- ts-jest@29.4.11(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@30.4.1)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.21.5)(jest-util@30.4.1)(jest@29.7.0(@types/node@25.9.1))(typescript@5.9.3):
+ ts-jest@29.4.11(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@30.4.1)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.21.5)(jest-util@30.4.1)(jest@29.7.0(@types/node@20.19.41)(ts-node@10.9.2(@types/node@20.19.41)(typescript@5.9.3)))(typescript@5.9.3):
dependencies:
bs-logger: 0.2.6
fast-json-stable-stringify: 2.1.0
handlebars: 4.7.9
- jest: 29.7.0(@types/node@25.9.1)
+ jest: 29.7.0(@types/node@20.19.41)(ts-node@10.9.2(@types/node@20.19.41)(typescript@5.9.3))
json5: 2.2.3
lodash.memoize: 4.1.2
make-error: 1.3.6
@@ -14358,12 +14405,12 @@ snapshots:
esbuild: 0.21.5
jest-util: 30.4.1
- ts-jest@29.4.11(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@30.4.1)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@30.4.1)(jest@29.7.0(@types/node@20.19.41)(ts-node@10.9.2(@types/node@20.19.41)(typescript@5.9.3)))(typescript@5.9.3):
+ ts-jest@29.4.11(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@30.4.1)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.21.5)(jest-util@30.4.1)(jest@29.7.0(@types/node@25.9.1)(ts-node@10.9.2(@types/node@25.9.1)(typescript@5.9.3)))(typescript@5.9.3):
dependencies:
bs-logger: 0.2.6
fast-json-stable-stringify: 2.1.0
handlebars: 4.7.9
- jest: 29.7.0(@types/node@20.19.41)(ts-node@10.9.2(@types/node@20.19.41)(typescript@5.9.3))
+ jest: 29.7.0(@types/node@25.9.1)(ts-node@10.9.2(@types/node@25.9.1)(typescript@5.9.3))
json5: 2.2.3
lodash.memoize: 4.1.2
make-error: 1.3.6
@@ -14376,36 +14423,35 @@ snapshots:
'@jest/transform': 29.7.0
'@jest/types': 30.4.1
babel-jest: 29.7.0(@babel/core@7.29.7)
+ esbuild: 0.21.5
jest-util: 30.4.1
- ts-jest@29.4.11(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@30.4.1)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@30.4.1)(jest@29.7.0(@types/node@20.19.41))(typescript@5.9.3):
+ ts-node@10.9.2(@types/node@20.19.41)(typescript@5.9.3):
dependencies:
- bs-logger: 0.2.6
- fast-json-stable-stringify: 2.1.0
- handlebars: 4.7.9
- jest: 29.7.0(@types/node@20.19.41)
- json5: 2.2.3
- lodash.memoize: 4.1.2
+ '@cspotcode/source-map-support': 0.8.1
+ '@tsconfig/node10': 1.0.12
+ '@tsconfig/node12': 1.0.11
+ '@tsconfig/node14': 1.0.3
+ '@tsconfig/node16': 1.0.4
+ '@types/node': 20.19.41
+ acorn: 8.16.0
+ acorn-walk: 8.3.5
+ arg: 4.1.3
+ create-require: 1.1.1
+ diff: 4.0.4
make-error: 1.3.6
- semver: 7.8.1
- type-fest: 4.41.0
typescript: 5.9.3
- yargs-parser: 21.1.1
- optionalDependencies:
- '@babel/core': 7.29.7
- '@jest/transform': 29.7.0
- '@jest/types': 30.4.1
- babel-jest: 29.7.0(@babel/core@7.29.7)
- jest-util: 30.4.1
+ v8-compile-cache-lib: 3.0.1
+ yn: 3.1.1
- ts-node@10.9.2(@types/node@20.19.41)(typescript@5.9.3):
+ ts-node@10.9.2(@types/node@25.9.1)(typescript@5.9.3):
dependencies:
'@cspotcode/source-map-support': 0.8.1
'@tsconfig/node10': 1.0.12
'@tsconfig/node12': 1.0.11
'@tsconfig/node14': 1.0.3
'@tsconfig/node16': 1.0.4
- '@types/node': 20.19.41
+ '@types/node': 25.9.1
acorn: 8.16.0
acorn-walk: 8.3.5
arg: 4.1.3
@@ -14415,6 +14461,7 @@ snapshots:
typescript: 5.9.3
v8-compile-cache-lib: 3.0.1
yn: 3.1.1
+ optional: true
tslib@1.14.1: {}
From 5bfebded23989abf8910851435b1683fd2b2811f Mon Sep 17 00:00:00 2001
From: gshaowei6 <47922975+gshaowei6@users.noreply.github.com>
Date: Sat, 30 May 2026 03:17:41 +0800
Subject: [PATCH 02/10] fix: address review feedback
---
.../useSendTransaction.schedule.test.ts | 1 +
.../src/linking/__tests__/paymentUri.test.ts | 6 ++++
apps/mobile-wallet/src/linking/paymentUri.ts | 2 +-
.../history/usePaginatedTransactionHistory.ts | 3 --
packages/stellar/src/__tests__/client.test.ts | 33 +++++++++++++++++++
packages/stellar/src/client.ts | 24 +++++++++++---
.../ui-kit/src/__tests__/Form/Field.test.tsx | 18 ++++++++--
packages/ui-kit/src/components/Form/Field.tsx | 4 +++
8 files changed, 81 insertions(+), 10 deletions(-)
diff --git a/apps/extension-wallet/src/hooks/__tests__/useSendTransaction.schedule.test.ts b/apps/extension-wallet/src/hooks/__tests__/useSendTransaction.schedule.test.ts
index 027d8ae0..a2ed6c82 100644
--- a/apps/extension-wallet/src/hooks/__tests__/useSendTransaction.schedule.test.ts
+++ b/apps/extension-wallet/src/hooks/__tests__/useSendTransaction.schedule.test.ts
@@ -14,6 +14,7 @@ describe('validateSchedule', () => {
it('accepts a valid schedule', () => {
const future = new Date(Date.now() + 2 * 60 * 60 * 1000);
+ // datetime-local expects local time without a timezone suffix; toISOString() emits UTC.
const offsetMs = future.getTimezoneOffset() * 60 * 1000;
const startAt = new Date(future.getTime() - offsetMs).toISOString().slice(0, 16);
diff --git a/apps/mobile-wallet/src/linking/__tests__/paymentUri.test.ts b/apps/mobile-wallet/src/linking/__tests__/paymentUri.test.ts
index 1937c69a..2b2f4f8c 100644
--- a/apps/mobile-wallet/src/linking/__tests__/paymentUri.test.ts
+++ b/apps/mobile-wallet/src/linking/__tests__/paymentUri.test.ts
@@ -20,6 +20,12 @@ describe('parsePaymentUri', () => {
expect(parsePaymentUri(`stellar:tx?destination=${DESTINATION}`)).toBeNull();
expect(parsePaymentUri('stellar:pay?amount=1')).toBeNull();
expect(parsePaymentUri(`stellar:pay?destination=${DESTINATION}&amount=0`)).toBeNull();
+ expect(parsePaymentUri(`stellar:pay?destination=${DESTINATION}&amount=1.123456789`)).toBeNull();
+ expect(
+ parsePaymentUri(
+ 'stellar:pay?destination=GDQP2KPQGKIHYJGXNUIYOMHARUARCA7DJT5FO2FFOOKY3B2WSQHG4W39'
+ )
+ ).toBeNull();
expect(parsePaymentUri('not a uri')).toBeNull();
});
});
diff --git a/apps/mobile-wallet/src/linking/paymentUri.ts b/apps/mobile-wallet/src/linking/paymentUri.ts
index 047fafaa..8fbf8456 100644
--- a/apps/mobile-wallet/src/linking/paymentUri.ts
+++ b/apps/mobile-wallet/src/linking/paymentUri.ts
@@ -4,7 +4,7 @@ export interface ParsedPaymentUri {
}
const SUPPORTED_SCHEMES = new Set(['stellar', 'web+stellar']);
-const DESTINATION_RE = /^G[A-Z0-9]{55}$/;
+const DESTINATION_RE = /^G[A-Z2-7]{55}$/;
function isValidAmount(amount: string): boolean {
if (!/^(?:0|[1-9]\d*)(?:\.\d{1,7})?$/.test(amount)) {
diff --git a/apps/mobile-wallet/src/screens/history/usePaginatedTransactionHistory.ts b/apps/mobile-wallet/src/screens/history/usePaginatedTransactionHistory.ts
index c0753de7..9a396947 100644
--- a/apps/mobile-wallet/src/screens/history/usePaginatedTransactionHistory.ts
+++ b/apps/mobile-wallet/src/screens/history/usePaginatedTransactionHistory.ts
@@ -19,7 +19,6 @@ type State = {
isLoadingMore: boolean;
isRefreshing: boolean;
error: HistoryError | null;
- retryCount: number;
};
const DEFAULT_PAGE_SIZE = 20;
@@ -52,7 +51,6 @@ export const usePaginatedTransactionHistory = ({
isLoadingMore: false,
isRefreshing: false,
error: null,
- retryCount: 0,
});
const requestIdRef = useRef(0);
@@ -111,7 +109,6 @@ export const usePaginatedTransactionHistory = ({
isLoadingMore: false,
isRefreshing: false,
error: historyError,
- retryCount: 0,
};
});
}
diff --git a/packages/stellar/src/__tests__/client.test.ts b/packages/stellar/src/__tests__/client.test.ts
index 8b988d0d..22f95da2 100644
--- a/packages/stellar/src/__tests__/client.test.ts
+++ b/packages/stellar/src/__tests__/client.test.ts
@@ -473,6 +473,17 @@ describe('StellarClient', () => {
expect(horizon.submitTransaction).toHaveBeenCalledWith(mockTransaction);
});
+ it('should surface XDR decode errors before submission', async () => {
+ const client = new StellarClient({ network: 'testnet', retryOptions: fastRetryOptions });
+ const horizon = getHorizonMock(client);
+ mockTransactionFromXDR.mockImplementationOnce(() => {
+ throw new Error('malformed XDR');
+ });
+
+ await expect(client.submitTransaction('bad-xdr')).rejects.toThrow('malformed XDR');
+ expect(horizon.submitTransaction).not.toHaveBeenCalled();
+ });
+
it('should throw TransactionError when Horizon returns result codes', async () => {
const client = new StellarClient({ network: 'testnet', retryOptions: fastRetryOptions });
const horizon = getHorizonMock(client);
@@ -491,6 +502,28 @@ describe('StellarClient', () => {
await expect(client.submitTransaction(mockTransaction)).rejects.toThrow(TransactionError);
});
+ it('should not retry permanent Horizon transaction errors', async () => {
+ const client = new StellarClient({
+ network: 'testnet',
+ retryOptions: { maxRetries: 2, baseDelayMs: 0 },
+ });
+ const horizon = getHorizonMock(client);
+ horizon.submitTransaction.mockRejectedValue({
+ response: {
+ data: {
+ extras: {
+ result_codes: {
+ transaction: 'tx_bad_seq',
+ },
+ },
+ },
+ },
+ });
+
+ await expect(client.submitTransaction(mockTransaction)).rejects.toThrow(TransactionError);
+ expect(horizon.submitTransaction).toHaveBeenCalledTimes(1);
+ });
+
it('should preserve Horizon transaction result details on TransactionError', async () => {
const client = new StellarClient({ network: 'testnet', retryOptions: fastRetryOptions });
const horizon = getHorizonMock(client);
diff --git a/packages/stellar/src/client.ts b/packages/stellar/src/client.ts
index 6a97c436..ab3a244e 100644
--- a/packages/stellar/src/client.ts
+++ b/packages/stellar/src/client.ts
@@ -447,12 +447,28 @@ export class StellarClient {
async submitTransaction(
transaction: Transaction | string
): Promise {
+ const signedTransaction = this.resolveSignedTransaction(transaction);
+ const callerIsRetryable = this.retryOptions.isRetryable;
+ const retryOptions: RetryOptions = {
+ ...this.retryOptions,
+ isRetryable: (error) => {
+ if (error instanceof TransactionError) {
+ return false;
+ }
+
+ if (callerIsRetryable) {
+ return callerIsRetryable(error);
+ }
+
+ const statusCode = this.getErrorStatusCode(error);
+ return statusCode === undefined || statusCode === 429 || statusCode >= 500;
+ },
+ };
+
try {
return await withRetry(async () => {
try {
- const response = await this.horizonServer.submitTransaction(
- this.resolveSignedTransaction(transaction)
- );
+ const response = await this.horizonServer.submitTransaction(signedTransaction);
return response;
} catch (error) {
const transactionError = TransactionError.fromHorizonError(error);
@@ -464,7 +480,7 @@ export class StellarClient {
statusCode: this.getErrorStatusCode(error),
});
}
- }, this.retryOptions);
+ }, retryOptions);
} catch (error: unknown) {
if (error instanceof RetryExhaustedError && error.lastError) {
if (
diff --git a/packages/ui-kit/src/__tests__/Form/Field.test.tsx b/packages/ui-kit/src/__tests__/Form/Field.test.tsx
index 8ba2c8ea..312bc687 100644
--- a/packages/ui-kit/src/__tests__/Form/Field.test.tsx
+++ b/packages/ui-kit/src/__tests__/Form/Field.test.tsx
@@ -32,9 +32,22 @@ describe('Field', () => {
expect(screen.getByLabelText('Recipient')).toHaveAttribute('id', 'recipient-address');
});
+ it('forwards required state to native controls', () => {
+ render(
+
+
+
+ );
+
+ const input = screen.getByLabelText(/Destination/);
+
+ expect(input).toBeRequired();
+ expect(input).toHaveAttribute('aria-required', 'true');
+ });
+
it('supports render-prop controls with custom markup', () => {
render(
-
+
{({ controlProps }) => (
@@ -44,12 +57,13 @@ describe('Field', () => {
);
- const textarea = screen.getByLabelText('Note');
+ const textarea = screen.getByLabelText(/Note/);
expect(textarea).toHaveAttribute(
'aria-describedby',
screen.getByText('Visible only to you.').id
);
+ expect(textarea).toHaveAttribute('aria-required', 'true');
expect(screen.getByText('140')).toBeInTheDocument();
});
});
diff --git a/packages/ui-kit/src/components/Form/Field.tsx b/packages/ui-kit/src/components/Form/Field.tsx
index 7096ae32..01b8bb16 100644
--- a/packages/ui-kit/src/components/Form/Field.tsx
+++ b/packages/ui-kit/src/components/Form/Field.tsx
@@ -5,6 +5,8 @@ type FieldControlProps = {
id?: string;
'aria-describedby'?: string;
'aria-invalid'?: boolean | 'true' | 'false';
+ 'aria-required'?: boolean | 'true' | 'false';
+ required?: boolean;
};
type FieldRenderProps = {
@@ -40,6 +42,8 @@ const Field = React.forwardRef
(
id: inputId,
'aria-describedby': describedBy,
'aria-invalid': error ? true : childProps?.['aria-invalid'],
+ 'aria-required': required ? true : childProps?.['aria-required'],
+ ...(required ? { required: true } : {}),
};
const control =
From 6aef6163933cfe852358b3d75f2213f02ae7b372 Mon Sep 17 00:00:00 2001
From: gshaowei6 <47922975+gshaowei6@users.noreply.github.com>
Date: Sat, 30 May 2026 03:28:56 +0800
Subject: [PATCH 03/10] fix: restore relayer build
---
services/relayer/src/handlers/executeRelay.ts | 25 +++++++++----
.../relayer/src/handlers/validateRelay.ts | 25 +++++++++----
services/relayer/src/logging/index.ts | 1 +
.../__tests__/requestLogger.test.ts | 9 +++--
.../relayer/src/middleware/requestLogger.ts | 37 +++++++------------
5 files changed, 56 insertions(+), 41 deletions(-)
diff --git a/services/relayer/src/handlers/executeRelay.ts b/services/relayer/src/handlers/executeRelay.ts
index 0b5b1295..165136c5 100644
--- a/services/relayer/src/handlers/executeRelay.ts
+++ b/services/relayer/src/handlers/executeRelay.ts
@@ -1,7 +1,16 @@
import { Request, Response } from 'express';
import type { RelayServiceContract } from '../types';
import type { RelayExecuteRequest } from '../types';
-import { redactSessionKey } from '../logging';
+import { redactSessionKey, type Logger } from '../logging';
+import type { LoggedRequest } from '../middleware/requestLogger';
+
+const noopLogger: Logger = {
+ debug: () => undefined,
+ info: () => undefined,
+ warn: () => undefined,
+ error: () => undefined,
+ child: () => noopLogger,
+};
/**
* Factory that returns the POST /relay/execute handler bound to a service instance.
@@ -11,15 +20,17 @@ import { redactSessionKey } from '../logging';
*/
export function createExecuteRelayHandler(relayService: RelayServiceContract) {
return async (req: Request, res: Response): Promise => {
+ const loggedReq = req as Partial;
const request = req.body as RelayExecuteRequest;
- const log = req.log?.child({
- route: 'POST /relay/execute',
- sessionKey: redactSessionKey(request.sessionKey),
- operation: request.operation,
- }) ?? { info: () => {}, warn: () => {}, error: () => {}, debug: () => {}, child: () => ({} as any) };
+ const log =
+ loggedReq.log?.child({
+ route: 'POST /relay/execute',
+ sessionKey: redactSessionKey(request.sessionKey),
+ operation: request.operation,
+ }) ?? noopLogger;
- const start = req.startTime ?? Date.now();
+ const start = loggedReq.startTime ?? Date.now();
const response = await relayService.executeRelay(request);
const durationMs = Date.now() - start;
diff --git a/services/relayer/src/handlers/validateRelay.ts b/services/relayer/src/handlers/validateRelay.ts
index 15f98e58..22752b99 100644
--- a/services/relayer/src/handlers/validateRelay.ts
+++ b/services/relayer/src/handlers/validateRelay.ts
@@ -1,7 +1,16 @@
import { Request, Response } from 'express';
import type { RelayServiceContract } from '../types';
import type { RelayValidateRequest } from '../types';
-import { redactSessionKey } from '../logging';
+import { redactSessionKey, type Logger } from '../logging';
+import type { LoggedRequest } from '../middleware/requestLogger';
+
+const noopLogger: Logger = {
+ debug: () => undefined,
+ info: () => undefined,
+ warn: () => undefined,
+ error: () => undefined,
+ child: () => noopLogger,
+};
/**
* Factory that returns the POST /relay/validate handler bound to a service instance.
@@ -11,15 +20,17 @@ import { redactSessionKey } from '../logging';
*/
export function createValidateRelayHandler(relayService: RelayServiceContract) {
return async (req: Request, res: Response): Promise => {
+ const loggedReq = req as Partial;
const request = req.body as RelayValidateRequest;
- const log = req.log?.child({
- route: 'POST /relay/validate',
- sessionKey: redactSessionKey(request.sessionKey),
- operation: request.operation,
- }) ?? { info: () => {}, warn: () => {}, error: () => {}, debug: () => {}, child: () => ({} as any) };
+ const log =
+ loggedReq.log?.child({
+ route: 'POST /relay/validate',
+ sessionKey: redactSessionKey(request.sessionKey),
+ operation: request.operation,
+ }) ?? noopLogger;
- const start = req.startTime ?? Date.now();
+ const start = loggedReq.startTime ?? Date.now();
const result = await relayService.validateRelay(request);
const durationMs = Date.now() - start;
diff --git a/services/relayer/src/logging/index.ts b/services/relayer/src/logging/index.ts
index 77f1e069..ac72a4fa 100644
--- a/services/relayer/src/logging/index.ts
+++ b/services/relayer/src/logging/index.ts
@@ -1,4 +1,5 @@
export {
+ rootLogger as logger,
rootLogger,
createRequestLogger,
redactAccountId,
diff --git a/services/relayer/src/middleware/__tests__/requestLogger.test.ts b/services/relayer/src/middleware/__tests__/requestLogger.test.ts
index 1139a06b..77cad9d1 100644
--- a/services/relayer/src/middleware/__tests__/requestLogger.test.ts
+++ b/services/relayer/src/middleware/__tests__/requestLogger.test.ts
@@ -6,7 +6,7 @@
*/
import { Request, Response, NextFunction } from 'express';
-import { createRequestLoggerMiddleware } from '../requestLogger';
+import { createRequestLoggerMiddleware, type LoggedRequest } from '../requestLogger';
// ---------------------------------------------------------------------------
// Helpers
@@ -80,9 +80,10 @@ describe('createRequestLoggerMiddleware', () => {
middleware(req, res as unknown as Response, next);
- expect(req.log).toBeDefined();
- expect(typeof req.log.info).toBe('function');
- expect(typeof req.log.child).toBe('function');
+ const loggedReq = req as LoggedRequest;
+ expect(loggedReq.log).toBeDefined();
+ expect(typeof loggedReq.log.info).toBe('function');
+ expect(typeof loggedReq.log.child).toBe('function');
});
it('emits request_start log on entry', () => {
diff --git a/services/relayer/src/middleware/requestLogger.ts b/services/relayer/src/middleware/requestLogger.ts
index baf94646..6a662bae 100644
--- a/services/relayer/src/middleware/requestLogger.ts
+++ b/services/relayer/src/middleware/requestLogger.ts
@@ -20,16 +20,11 @@
import { Request, Response, NextFunction, RequestHandler } from 'express';
import { createRequestLogger, redactAccountId, type Logger } from '../logging';
-// Augment Express Request to carry a typed logger
-declare global {
- namespace Express {
- interface Request {
- log: Logger;
- /** ISO timestamp when the request was received */
- startTime: number;
- }
- }
-}
+export type LoggedRequest = Request & {
+ log: Logger;
+ /** Epoch milliseconds when the request was received */
+ startTime: number;
+};
/**
* Creates the request logger middleware.
@@ -42,35 +37,31 @@ declare global {
*/
export function createRequestLoggerMiddleware(): RequestHandler {
return (req: Request, res: Response, next: NextFunction): void => {
- const requestId =
- (req.headers['x-request-id'] as string | undefined) ??
- generateRequestId();
+ const loggedReq = req as LoggedRequest;
+ const requestId = (req.headers['x-request-id'] as string | undefined) ?? generateRequestId();
- req.startTime = Date.now();
+ loggedReq.startTime = Date.now();
const route = `${req.method} ${req.path}`;
// Attach logger — accountId not yet available (set by auth middleware later)
- req.log = createRequestLogger({ requestId, route });
+ loggedReq.log = createRequestLogger({ requestId, route });
- req.log.info({ requestId }, 'request_start');
+ loggedReq.log.info({ requestId }, 'request_start');
// Emit completion log when the response finishes
res.on('finish', () => {
- const durationMs = Date.now() - req.startTime;
+ const durationMs = Date.now() - loggedReq.startTime;
const statusCode = res.statusCode;
const outcome = statusCode < 400 ? 'success' : 'error';
// Re-create logger with callerId if auth middleware populated it
const callerId = res.locals['callerId'] as string | undefined;
const completionLog = callerId
- ? req.log.child({ accountId: redactAccountId(callerId) })
- : req.log;
+ ? loggedReq.log.child({ accountId: redactAccountId(callerId) })
+ : loggedReq.log;
- completionLog.info(
- { durationMs, statusCode, outcome },
- 'request_complete'
- );
+ completionLog.info({ durationMs, statusCode, outcome }, 'request_complete');
});
next();
From 4c59f7e2092a297e7b75481706920f720bb0c08c Mon Sep 17 00:00:00 2001
From: gshaowei6 <47922975+gshaowei6@users.noreply.github.com>
Date: Sat, 30 May 2026 03:32:20 +0800
Subject: [PATCH 04/10] fix: re-export dashboard scheduler helper
---
apps/web-dashboard/src/services/scheduler-client.ts | 1 +
1 file changed, 1 insertion(+)
diff --git a/apps/web-dashboard/src/services/scheduler-client.ts b/apps/web-dashboard/src/services/scheduler-client.ts
index cbbcbbf2..ffd5e004 100644
--- a/apps/web-dashboard/src/services/scheduler-client.ts
+++ b/apps/web-dashboard/src/services/scheduler-client.ts
@@ -4,6 +4,7 @@ export {
defaultScheduleStartAt,
DEMO_ACCOUNT_ADDRESS,
getSchedulerClient,
+ resolveRelayerBaseUrl,
SCHEDULE_FREQUENCY_OPTIONS,
toIsoStartAt,
type SchedulerClient,
From e7338aa2c940f22464b090faa24b565a89ffb6d0 Mon Sep 17 00:00:00 2001
From: gshaowei6 <47922975+gshaowei6@users.noreply.github.com>
Date: Sat, 30 May 2026 03:37:35 +0800
Subject: [PATCH 05/10] fix: use shared dashboard test setup
---
apps/web-dashboard/src/test/setup.ts | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/apps/web-dashboard/src/test/setup.ts b/apps/web-dashboard/src/test/setup.ts
index bb02c60c..5e79cbc9 100644
--- a/apps/web-dashboard/src/test/setup.ts
+++ b/apps/web-dashboard/src/test/setup.ts
@@ -1 +1,5 @@
-import '@testing-library/jest-dom/vitest';
+/**
+ * Web dashboard test setup.
+ * Delegates to the shared Vitest setup (jest-dom, cleanup, localStorage shim).
+ */
+export * from '../../../../packages/vitest.setup';
From 3b96bd4334e32444bdce6920b398ed278465d5a7 Mon Sep 17 00:00:00 2001
From: gshaowei6 <47922975+gshaowei6@users.noreply.github.com>
Date: Sat, 30 May 2026 03:43:10 +0800
Subject: [PATCH 06/10] fix: stabilize fiat locale fallback
---
packages/core-sdk/src/fiat-formatter.ts | 17 ++++++++++++++++-
1 file changed, 16 insertions(+), 1 deletion(-)
diff --git a/packages/core-sdk/src/fiat-formatter.ts b/packages/core-sdk/src/fiat-formatter.ts
index fe742086..44b979d1 100644
--- a/packages/core-sdk/src/fiat-formatter.ts
+++ b/packages/core-sdk/src/fiat-formatter.ts
@@ -32,6 +32,21 @@ export interface FiatFormatOptions {
maximumFractionDigits?: number;
}
+function resolveSupportedLocale(locale: string | string[]): string | string[] {
+ try {
+ const requestedLocales = Array.isArray(locale) ? locale : [locale];
+ const supportedLocales = Intl.NumberFormat.supportedLocalesOf(requestedLocales);
+
+ if (supportedLocales.length > 0) {
+ return Array.isArray(locale) ? supportedLocales : supportedLocales[0];
+ }
+ } catch {
+ // Fall through to the stable fallback below.
+ }
+
+ return 'en-US';
+}
+
/**
* Formats a numeric amount as a fiat currency string.
*
@@ -55,7 +70,7 @@ export function formatFiatAmount(amount: number, options: FiatFormatOptions = {}
} = options;
try {
- return new Intl.NumberFormat(locale, {
+ return new Intl.NumberFormat(resolveSupportedLocale(locale), {
style: 'currency',
currency,
minimumFractionDigits,
From 6b357600bc1e53f2ef7210ad86d55df6cde95089 Mon Sep 17 00:00:00 2001
From: gshaowei6 <47922975+gshaowei6@users.noreply.github.com>
Date: Sat, 30 May 2026 03:48:00 +0800
Subject: [PATCH 07/10] fix: avoid dashboard state updates after unmount
---
.../src/hooks/useAccountState.ts | 24 ++++++++++++++++---
1 file changed, 21 insertions(+), 3 deletions(-)
diff --git a/apps/web-dashboard/src/hooks/useAccountState.ts b/apps/web-dashboard/src/hooks/useAccountState.ts
index 26d121a5..f3a3f729 100644
--- a/apps/web-dashboard/src/hooks/useAccountState.ts
+++ b/apps/web-dashboard/src/hooks/useAccountState.ts
@@ -1,4 +1,4 @@
-import { useState, useEffect, useCallback } from 'react';
+import { useState, useEffect, useCallback, useRef } from 'react';
import type { AccountData } from '../types/dashboard';
const STORAGE_KEY = 'ancore-dashboard-selected-account';
@@ -44,6 +44,7 @@ export function useAccountState(): UseAccountStateReturn {
const [currentAccount, setCurrentAccountState] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
+ const mountedRef = useRef(true);
const getStoredAccount = useCallback((): AccountData | null => {
try {
@@ -82,11 +83,19 @@ export function useAccountState(): UseAccountStateReturn {
}, []);
const fetchAccounts = useCallback(async () => {
+ if (!mountedRef.current) {
+ return;
+ }
+
setLoading(true);
setError(null);
try {
// Simulate API call
await new Promise((resolve) => setTimeout(resolve, 300));
+ if (!mountedRef.current) {
+ return;
+ }
+
setAccounts(MOCK_ACCOUNTS);
// Set current account from storage or default to first account
@@ -102,9 +111,13 @@ export function useAccountState(): UseAccountStateReturn {
setCurrentAccountState(MOCK_ACCOUNTS[0]);
}
} catch (err) {
- setError(err instanceof Error ? err : new Error('Failed to fetch accounts'));
+ if (mountedRef.current) {
+ setError(err instanceof Error ? err : new Error('Failed to fetch accounts'));
+ }
} finally {
- setLoading(false);
+ if (mountedRef.current) {
+ setLoading(false);
+ }
}
}, [getStoredAccount]);
@@ -121,7 +134,12 @@ export function useAccountState(): UseAccountStateReturn {
}, [fetchAccounts]);
useEffect(() => {
+ mountedRef.current = true;
fetchAccounts();
+
+ return () => {
+ mountedRef.current = false;
+ };
}, [fetchAccounts]);
return {
From 0d0338b00aba57f708a41a5b4e5da510b4997373 Mon Sep 17 00:00:00 2001
From: gshaowei6 <47922975+gshaowei6@users.noreply.github.com>
Date: Sat, 30 May 2026 03:52:31 +0800
Subject: [PATCH 08/10] style: apply repository formatting
---
README.md | 4 +--
.../src/config/__tests__/urls.test.ts | 5 +---
apps/extension-wallet/src/config/urls.ts | 7 ++---
apps/web-dashboard/src/hooks/useSplitBill.ts | 27 ++++++++++++++-----
apps/web-dashboard/src/pages/SplitBill.tsx | 19 ++++++-------
.../services/__tests__/bulk-payouts.test.ts | 17 ++++++------
.../src/services/bulk-payouts.ts | 13 ++++-----
contracts/validation-modules/README.md | 26 +++++++++---------
.../src/transaction-builder.ts | 6 ++++-
scripts/check-wasm-size.js | 19 ++++++-------
.../src/logging/__tests__/logger.test.ts | 7 +----
11 files changed, 80 insertions(+), 70 deletions(-)
diff --git a/README.md b/README.md
index 4463db02..99c06c42 100644
--- a/README.md
+++ b/README.md
@@ -129,15 +129,15 @@ pnpm contracts:test
### Updating WASM Size Budgets
-WASM contract sizes are monitored in CI to prevent regression. The budget for each contract is defined in `contracts/budgets/wasm-budgets.json`.
+WASM contract sizes are monitored in CI to prevent regression. The budget for each contract is defined in `contracts/budgets/wasm-budgets.json`.
If your changes intentionally increase the contract size beyond the current budget:
+
1. Ensure your contract builds locally: `pnpm contracts:build`
2. Check the new size of the optimized `.wasm` files in `contracts/target/wasm32-unknown-unknown/release/`.
3. You can run the local size check with: `node scripts/check-wasm-size.js`
4. Update `contracts/budgets/wasm-budgets.json` with the new size budget, and commit the changes.
-
## Contributing
We welcome contributions! Please see our [Contributing Guide](CONTRIBUTING.md) for details.
diff --git a/apps/extension-wallet/src/config/__tests__/urls.test.ts b/apps/extension-wallet/src/config/__tests__/urls.test.ts
index 141b1c02..9d02d5ba 100644
--- a/apps/extension-wallet/src/config/__tests__/urls.test.ts
+++ b/apps/extension-wallet/src/config/__tests__/urls.test.ts
@@ -168,10 +168,7 @@ describe('probeServiceHealth', () => {
vi.mocked(fetch).mockResolvedValueOnce(new Response('{}', { status: 200 }));
await probeServiceHealth('https://relayer.ancore.io/', 'relayer');
- expect(fetch).toHaveBeenCalledWith(
- 'https://relayer.ancore.io/health',
- expect.anything()
- );
+ expect(fetch).toHaveBeenCalledWith('https://relayer.ancore.io/health', expect.anything());
});
});
diff --git a/apps/extension-wallet/src/config/urls.ts b/apps/extension-wallet/src/config/urls.ts
index a56edb21..4051902d 100644
--- a/apps/extension-wallet/src/config/urls.ts
+++ b/apps/extension-wallet/src/config/urls.ts
@@ -43,9 +43,10 @@ export interface ConfigError {
// ---------------------------------------------------------------------------
const RELAYER_URLS: Record = {
- production: typeof import.meta !== 'undefined' && import.meta.env?.VITE_RELAYER_URL
- ? import.meta.env.VITE_RELAYER_URL
- : 'https://relayer.ancore.io',
+ production:
+ typeof import.meta !== 'undefined' && import.meta.env?.VITE_RELAYER_URL
+ ? import.meta.env.VITE_RELAYER_URL
+ : 'https://relayer.ancore.io',
staging: 'https://relayer-staging.ancore.io',
local: 'http://localhost:3000',
};
diff --git a/apps/web-dashboard/src/hooks/useSplitBill.ts b/apps/web-dashboard/src/hooks/useSplitBill.ts
index b163fe79..0252c5aa 100644
--- a/apps/web-dashboard/src/hooks/useSplitBill.ts
+++ b/apps/web-dashboard/src/hooks/useSplitBill.ts
@@ -13,7 +13,12 @@ export interface UseSplitBillReturn {
bills: SplitBill[];
isLoading: boolean;
createBill: (input: CreateSplitBillInput) => SplitBill;
- updateParticipant: (billId: string, participantId: string, status: ParticipantStatus, extra?: { failedReason?: string }) => void;
+ updateParticipant: (
+ billId: string,
+ participantId: string,
+ status: ParticipantStatus,
+ extra?: { failedReason?: string }
+ ) => void;
cancelBill: (billId: string) => void;
getBill: (billId: string) => SplitBill | undefined;
}
@@ -37,8 +42,19 @@ export function useSplitBill(): UseSplitBillReturn {
}, []);
const updateParticipant = useCallback(
- (billId: string, participantId: string, status: ParticipantStatus, extra?: { failedReason?: string }) => {
- const updated = storageUpdateParticipant(loadSplitBills(), billId, participantId, status, extra);
+ (
+ billId: string,
+ participantId: string,
+ status: ParticipantStatus,
+ extra?: { failedReason?: string }
+ ) => {
+ const updated = storageUpdateParticipant(
+ loadSplitBills(),
+ billId,
+ participantId,
+ status,
+ extra
+ );
saveSplitBills(updated);
setBills(updated);
},
@@ -51,10 +67,7 @@ export function useSplitBill(): UseSplitBillReturn {
setBills(updated);
}, []);
- const getBill = useCallback(
- (billId: string) => bills.find((b) => b.id === billId),
- [bills]
- );
+ const getBill = useCallback((billId: string) => bills.find((b) => b.id === billId), [bills]);
return { bills, isLoading, createBill, updateParticipant, cancelBill, getBill };
}
diff --git a/apps/web-dashboard/src/pages/SplitBill.tsx b/apps/web-dashboard/src/pages/SplitBill.tsx
index 8084e743..7e2b9370 100644
--- a/apps/web-dashboard/src/pages/SplitBill.tsx
+++ b/apps/web-dashboard/src/pages/SplitBill.tsx
@@ -182,13 +182,16 @@ function CreateBillForm({ onCreated }: { onCreated: (id: string) => void }) {
// ── Bill list ─────────────────────────────────────────────────────────────────
-const BILL_STATUS_CONFIG: Record<
- SplitBillStatus,
- { icon: ReactNode; className: string }
-> = {
+const BILL_STATUS_CONFIG: Record = {
open: { icon: , className: 'text-blue-600 bg-blue-50' },
- completed: { icon: , className: 'text-green-600 bg-green-50' },
- expired: { icon: , className: 'text-slate-500 bg-slate-100' },
+ completed: {
+ icon: ,
+ className: 'text-green-600 bg-green-50',
+ },
+ expired: {
+ icon: ,
+ className: 'text-slate-500 bg-slate-100',
+ },
cancelled: { icon: , className: 'text-red-600 bg-red-50' },
};
@@ -224,9 +227,7 @@ function BillCard({ bill, onClick }: { bill: SplitBill; onClick: () => void }) {
{paidCount}/{total} paid
-
- Expires {new Date(bill.expiresAt).toLocaleDateString()}
-
+ Expires {new Date(bill.expiresAt).toLocaleDateString()}
{/* Mini progress bar */}
diff --git a/apps/web-dashboard/src/services/__tests__/bulk-payouts.test.ts b/apps/web-dashboard/src/services/__tests__/bulk-payouts.test.ts
index ad1789be..60e237f8 100644
--- a/apps/web-dashboard/src/services/__tests__/bulk-payouts.test.ts
+++ b/apps/web-dashboard/src/services/__tests__/bulk-payouts.test.ts
@@ -86,14 +86,15 @@ describe('bulk payout execution queue', () => {
});
it('submits payouts to the relayer with idempotency and surfaces relay failures', async () => {
- const fetchImpl = vi.fn(async () =>
- new Response(
- JSON.stringify({
- success: false,
- error: { message: 'Missing required parameter: signedTransactionXdr' },
- }),
- { status: 422, headers: { 'Content-Type': 'application/json' } }
- )
+ const fetchImpl = vi.fn(
+ async () =>
+ new Response(
+ JSON.stringify({
+ success: false,
+ error: { message: 'Missing required parameter: signedTransactionXdr' },
+ }),
+ { status: 422, headers: { 'Content-Type': 'application/json' } }
+ )
);
const submitPayout = createRelayerPayoutSubmitter({
baseUrl: 'https://relayer.test/',
diff --git a/apps/web-dashboard/src/services/bulk-payouts.ts b/apps/web-dashboard/src/services/bulk-payouts.ts
index 2b51ab97..c2fd179f 100644
--- a/apps/web-dashboard/src/services/bulk-payouts.ts
+++ b/apps/web-dashboard/src/services/bulk-payouts.ts
@@ -100,13 +100,7 @@ export function parseBulkPayoutCsv(csv: string): BulkPayoutParseResult {
const signedTransactionXdr =
signedTransactionXdrIndex === -1 ? undefined : record[signedTransactionXdrIndex]?.trim();
accumulator.push(
- createRow(
- lineNumber,
- recipient,
- amount,
- signedTransactionXdr,
- validateRow(recipient, amount)
- )
+ createRow(lineNumber, recipient, amount, signedTransactionXdr, validateRow(recipient, amount))
);
return accumulator;
}, []);
@@ -271,7 +265,10 @@ function validateRow(recipient: string, amount: string): string[] {
}
function normalizeHeader(value: string): string {
- return value.trim().toLowerCase().replace(/[\s_-]+/g, '');
+ return value
+ .trim()
+ .toLowerCase()
+ .replace(/[\s_-]+/g, '');
}
function normalizeAmount(value: string): string {
diff --git a/contracts/validation-modules/README.md b/contracts/validation-modules/README.md
index 299342e8..b254ee1e 100644
--- a/contracts/validation-modules/README.md
+++ b/contracts/validation-modules/README.md
@@ -13,14 +13,14 @@ fn validate(env: Env, context: ValidationContext) -> Result<(), ValidationModule
`ValidationContext` is the account-to-module boundary:
-| Field | Meaning |
-| --- | --- |
-| `account` | Account contract requesting validation |
+| Field | Meaning |
+| ------------ | -------------------------------------------------------- |
+| `account` | Account contract requesting validation |
| `authorizer` | Owner or session-key authority represented as an address |
-| `target` | Contract address the account wants to invoke |
-| `function` | Function symbol the account wants to invoke |
-| `args_hash` | Fixed-size digest of the execution arguments |
-| `nonce` | Account nonce bound to the execution |
+| `target` | Contract address the account wants to invoke |
+| `function` | Function symbol the account wants to invoke |
+| `args_hash` | Fixed-size digest of the execution arguments |
+| `nonce` | Account nonce bound to the execution |
The interface keeps validation bounded by passing a fixed-size context instead
of an unbounded argument vector. Modules that need argument-aware rules should
@@ -53,13 +53,13 @@ Policy semantics:
Let `n` be the number of allowlisted targets.
-| Operation | Time | Space |
-| --- | --- | --- |
-| `initialize` | O(1) | O(1) |
-| `set_enabled` | O(1) | O(1) |
+| Operation | Time | Space |
+| -------------------- | ---- | --------------- |
+| `initialize` | O(1) | O(1) |
+| `set_enabled` | O(1) | O(1) |
| `set_allowed_target` | O(1) | O(1) per target |
-| `is_allowed_target` | O(1) | O(1) |
-| `validate` | O(1) | O(1) |
+| `is_allowed_target` | O(1) | O(1) |
+| `validate` | O(1) | O(1) |
Total module storage is O(n).
diff --git a/packages/account-abstraction/src/transaction-builder.ts b/packages/account-abstraction/src/transaction-builder.ts
index f108430a..12e4badf 100644
--- a/packages/account-abstraction/src/transaction-builder.ts
+++ b/packages/account-abstraction/src/transaction-builder.ts
@@ -143,7 +143,11 @@ export class TransactionBuilder {
return contract.call('revoke_session_key', nativeToScVal(op.sessionKey));
}
- private assertSessionKeyParams(sessionKey: string, permissions: number[], expiresAt: number): void {
+ private assertSessionKeyParams(
+ sessionKey: string,
+ permissions: number[],
+ expiresAt: number
+ ): void {
if (!sessionKey || typeof sessionKey !== 'string') {
throw new TypeError('Session key must be a non-empty string.');
}
diff --git a/scripts/check-wasm-size.js b/scripts/check-wasm-size.js
index 6ccfbc39..dbeba752 100644
--- a/scripts/check-wasm-size.js
+++ b/scripts/check-wasm-size.js
@@ -25,7 +25,7 @@ async function main() {
const size = fs.statSync(wasmArg).size;
const name = path.basename(wasmArg);
report[name] = { actual: size, budget: budgetArg, delta: size - budgetArg };
-
+
if (size > budgetArg) {
console.error(`❌ ${name} exceeded budget! (${size} > ${budgetArg})`);
hasError = true;
@@ -38,23 +38,23 @@ async function main() {
console.error(`Budget file not found at ${BUDGET_FILE}`);
process.exit(1);
}
-
+
const budgets = JSON.parse(fs.readFileSync(BUDGET_FILE, 'utf8'));
for (const [contractName, budget] of Object.entries(budgets)) {
// Find the wasm file
// assuming target dir is contracts/target/wasm32-unknown-unknown/release
const targetDir = path.join(__dirname, '../contracts/target/wasm32-unknown-unknown/release');
-
+
let wasmPath = path.join(targetDir, `${contractName}.optimized.wasm`);
if (!fs.existsSync(wasmPath)) {
wasmPath = path.join(targetDir, `${contractName}.wasm`);
}
-
+
if (!fs.existsSync(wasmPath)) {
console.warn(`⚠️ Wasm file for ${contractName} not found at ${wasmPath}`);
continue;
}
-
+
const size = fs.statSync(wasmPath).size;
report[contractName] = { actual: size, budget, delta: size - budget };
if (size > budget) {
@@ -72,17 +72,18 @@ async function main() {
console.log(`\nReport written to ${reportPath}`);
// Also write a markdown summary for GitHub step summary
- let md = '## WASM Size Report\n\n| Contract | Actual Size | Budget | Status |\n|---|---|---|---|\n';
+ let md =
+ '## WASM Size Report\n\n| Contract | Actual Size | Budget | Status |\n|---|---|---|---|\n';
for (const [name, stats] of Object.entries(report)) {
const status = stats.actual <= stats.budget ? '✅ Pass' : '❌ Fail';
md += `| ${name} | ${stats.actual} bytes | ${stats.budget} bytes | ${status} |\n`;
}
-
+
const summaryPath = process.env.GITHUB_STEP_SUMMARY;
if (summaryPath) {
fs.appendFileSync(summaryPath, md + '\n');
}
-
+
const mdReportPath = path.join(__dirname, '../wasm-size-report.md');
fs.writeFileSync(mdReportPath, md);
@@ -91,7 +92,7 @@ async function main() {
}
}
-main().catch(err => {
+main().catch((err) => {
console.error(err);
process.exit(1);
});
diff --git a/services/relayer/src/logging/__tests__/logger.test.ts b/services/relayer/src/logging/__tests__/logger.test.ts
index 3bbb9290..3f9ebd23 100644
--- a/services/relayer/src/logging/__tests__/logger.test.ts
+++ b/services/relayer/src/logging/__tests__/logger.test.ts
@@ -5,12 +5,7 @@
* Issue #474
*/
-import {
- rootLogger,
- createRequestLogger,
- redactAccountId,
- redactSessionKey,
-} from '../logger';
+import { rootLogger, createRequestLogger, redactAccountId, redactSessionKey } from '../logger';
// ---------------------------------------------------------------------------
// Helpers
From ccf6f690bc407a15511c420b3422791363c7e5d9 Mon Sep 17 00:00:00 2001
From: gshaowei6 <47922975+gshaowei6@users.noreply.github.com>
Date: Sat, 30 May 2026 04:12:02 +0800
Subject: [PATCH 09/10] fix: stabilize encoded UI separators
---
.../src/components/TransactionHistoryList.tsx | 8 ++++----
packages/ui-kit/src/components/TransactionItem.tsx | 2 +-
2 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/apps/mobile-wallet/src/components/TransactionHistoryList.tsx b/apps/mobile-wallet/src/components/TransactionHistoryList.tsx
index 9d006c86..bede3e1f 100644
--- a/apps/mobile-wallet/src/components/TransactionHistoryList.tsx
+++ b/apps/mobile-wallet/src/components/TransactionHistoryList.tsx
@@ -34,7 +34,7 @@ export const TransactionHistoryList = ({
formatTimestamp = defaultFormatTimestamp,
}: Props) => {
if (isLoadingInitial) {
- return Loading transactions…
;
+ return Loading transactions...
;
}
if (error && transactions.length === 0) {
@@ -61,7 +61,7 @@ export const TransactionHistoryList = ({
return (
{error ? (
@@ -80,7 +80,7 @@ export const TransactionHistoryList = ({
{tx.direction === 'in' ? 'Received' : 'Sent'} {tx.amount}
- {tx.asset ? ` ${tx.asset}` : ''} · {formatTimestamp(tx.timestamp)}
+ {tx.asset ? ` ${tx.asset}` : ''} · {formatTimestamp(tx.timestamp)}
))}
@@ -88,7 +88,7 @@ export const TransactionHistoryList = ({
{hasMore ? (
) : (
End of transaction history
diff --git a/packages/ui-kit/src/components/TransactionItem.tsx b/packages/ui-kit/src/components/TransactionItem.tsx
index f094a506..94e730ce 100644
--- a/packages/ui-kit/src/components/TransactionItem.tsx
+++ b/packages/ui-kit/src/components/TransactionItem.tsx
@@ -67,7 +67,7 @@ export function TransactionItem({
{transaction.type}
- {formatAddress(transaction.from)} → {formatAddress(transaction.to)}
+ {formatAddress(transaction.from)} → {formatAddress(transaction.to)}
From 0dd9284d643ae27eb9d1cbe26e59c2f0d8c32b87 Mon Sep 17 00:00:00 2001
From: gshaowei6 <47922975+gshaowei6@users.noreply.github.com>
Date: Sat, 30 May 2026 04:17:27 +0800
Subject: [PATCH 10/10] fix: address follow-up review feedback
---
packages/core-sdk/src/fiat-formatter.ts | 4 ++--
packages/stellar/src/__tests__/client.test.ts | 11 ++++++++---
packages/stellar/src/client.ts | 10 +++++++++-
.../relayer/src/logging/__tests__/logger.test.ts | 8 ++++----
services/relayer/src/logging/logger.ts | 13 +++++++------
5 files changed, 30 insertions(+), 16 deletions(-)
diff --git a/packages/core-sdk/src/fiat-formatter.ts b/packages/core-sdk/src/fiat-formatter.ts
index 44b979d1..dc4a8d1b 100644
--- a/packages/core-sdk/src/fiat-formatter.ts
+++ b/packages/core-sdk/src/fiat-formatter.ts
@@ -11,7 +11,7 @@ export interface FiatFormatOptions {
/**
* Locale string for formatting
* Fallback behavior: If the provided locale is invalid or unsupported,
- * it falls back to the system default locale, and ultimately to 'en-US'.
+ * it falls back directly to 'en-US'.
* @default 'en-US'
*/
locale?: string | string[];
@@ -54,7 +54,7 @@ function resolveSupportedLocale(locale: string | string[]): string | string[] {
* Uses standard Intl.NumberFormat rounding (half-expand).
*
* Fallback behavior:
- * - If locale is invalid, falls back to standard Intl fallback ('en-US' usually)
+ * - If locale is invalid or unsupported, falls back to 'en-US'
* - If Intl is not available or parameters are severely malformed, falls back to basic string formatting
*
* @param amount The numerical amount to format
diff --git a/packages/stellar/src/__tests__/client.test.ts b/packages/stellar/src/__tests__/client.test.ts
index 22f95da2..c7c45fc4 100644
--- a/packages/stellar/src/__tests__/client.test.ts
+++ b/packages/stellar/src/__tests__/client.test.ts
@@ -473,14 +473,19 @@ describe('StellarClient', () => {
expect(horizon.submitTransaction).toHaveBeenCalledWith(mockTransaction);
});
- it('should surface XDR decode errors before submission', async () => {
+ it('should normalize XDR decode errors before submission', async () => {
const client = new StellarClient({ network: 'testnet', retryOptions: fastRetryOptions });
const horizon = getHorizonMock(client);
+ const malformed = new Error('malformed XDR');
mockTransactionFromXDR.mockImplementationOnce(() => {
- throw new Error('malformed XDR');
+ throw malformed;
});
- await expect(client.submitTransaction('bad-xdr')).rejects.toThrow('malformed XDR');
+ await expect(client.submitTransaction('bad-xdr')).rejects.toMatchObject({
+ name: 'NetworkError',
+ message: 'Invalid signed transaction XDR',
+ cause: malformed,
+ });
expect(horizon.submitTransaction).not.toHaveBeenCalled();
});
diff --git a/packages/stellar/src/client.ts b/packages/stellar/src/client.ts
index ab3a244e..36475261 100644
--- a/packages/stellar/src/client.ts
+++ b/packages/stellar/src/client.ts
@@ -447,7 +447,15 @@ export class StellarClient {
async submitTransaction(
transaction: Transaction | string
): Promise {
- const signedTransaction = this.resolveSignedTransaction(transaction);
+ let signedTransaction: Transaction;
+ try {
+ signedTransaction = this.resolveSignedTransaction(transaction);
+ } catch (error) {
+ throw new NetworkError('Invalid signed transaction XDR', {
+ cause: error instanceof Error ? error : undefined,
+ });
+ }
+
const callerIsRetryable = this.retryOptions.isRetryable;
const retryOptions: RetryOptions = {
...this.retryOptions,
diff --git a/services/relayer/src/logging/__tests__/logger.test.ts b/services/relayer/src/logging/__tests__/logger.test.ts
index 3f9ebd23..1fd875a0 100644
--- a/services/relayer/src/logging/__tests__/logger.test.ts
+++ b/services/relayer/src/logging/__tests__/logger.test.ts
@@ -34,7 +34,7 @@ describe('redactAccountId', () => {
});
it('truncates to first 8 chars + ellipsis for longer values', () => {
- expect(redactAccountId('GBXXX123YYYY')).toBe('GBXXX123…');
+ expect(redactAccountId('GBXXX123YYYY')).toBe('GBXXX123\u2026');
});
});
@@ -54,7 +54,7 @@ describe('redactSessionKey', () => {
it('truncates to first 8 chars + ellipsis for longer values', () => {
const key = 'a'.repeat(64);
- expect(redactSessionKey(key)).toBe('aaaaaaaa…');
+ expect(redactSessionKey(key)).toBe('aaaaaaaa\u2026');
});
});
@@ -194,7 +194,7 @@ describe('createRequestLogger', () => {
log.info({}, 'test');
}) as Record;
- expect(entry['accountId']).toBe('GBXXX123…');
+ expect(entry['accountId']).toBe('GBXXX123\u2026');
});
it('redacts sessionKey to first 8 chars', () => {
@@ -207,7 +207,7 @@ describe('createRequestLogger', () => {
log.info({}, 'test');
}) as Record;
- expect(entry['sessionKey']).toBe('abcdef12…');
+ expect(entry['sessionKey']).toBe('abcdef12\u2026');
});
it('omits accountId when not provided', () => {
diff --git a/services/relayer/src/logging/logger.ts b/services/relayer/src/logging/logger.ts
index 58d56118..4733655c 100644
--- a/services/relayer/src/logging/logger.ts
+++ b/services/relayer/src/logging/logger.ts
@@ -11,10 +11,10 @@
* service — always "relayer"
* requestId — UUID propagated from X-Request-Id header (#572)
* route — "METHOD /path" string
- * accountId — caller identity, redacted to first 8 chars + "…"
+ * accountId — caller identity, redacted to first 8 chars + "\u2026"
* durationMs — elapsed time from request start to log emission
* outcome — "success" | "error" | "validation_failed"
- * sessionKey — first 8 chars of the hex session key + "…" (never full)
+ * sessionKey — first 8 chars of the hex session key + "\u2026" (never full)
* operation — relay operation type
* statusCode — HTTP response status
*
@@ -69,6 +69,7 @@ const SENSITIVE_KEYS = new Set([
'signedXdr',
'signed_xdr',
]);
+const TRUNCATION_MARKER = '\u2026';
/**
* Redacts known sensitive keys from a log fields object.
@@ -87,23 +88,23 @@ function redactFields(fields: LogFields): LogFields {
}
/**
- * Redacts an accountId to the first 8 characters followed by "…".
+ * Redacts an accountId to the first 8 characters followed by "\u2026".
* Returns undefined if the input is falsy.
*/
export function redactAccountId(accountId: string | undefined): string | undefined {
if (!accountId) return undefined;
if (accountId.length <= 8) return accountId;
- return `${accountId.slice(0, 8)}…`;
+ return `${accountId.slice(0, 8)}${TRUNCATION_MARKER}`;
}
/**
- * Redacts a session key to the first 8 hex chars followed by "…".
+ * Redacts a session key to the first 8 hex chars followed by "\u2026".
* Returns undefined if the input is falsy.
*/
export function redactSessionKey(sessionKey: string | undefined): string | undefined {
if (!sessionKey) return undefined;
if (sessionKey.length <= 8) return sessionKey;
- return `${sessionKey.slice(0, 8)}…`;
+ return `${sessionKey.slice(0, 8)}${TRUNCATION_MARKER}`;
}
// ---------------------------------------------------------------------------