diff --git a/package-lock.json b/package-lock.json index 15452ac..9d39748 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5456,6 +5456,7 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, diff --git a/src/components/providers/WalletProvider.tsx b/src/components/providers/WalletProvider.tsx index e567460..39bb798 100644 --- a/src/components/providers/WalletProvider.tsx +++ b/src/components/providers/WalletProvider.tsx @@ -11,6 +11,7 @@ import { import type { Keypair } from "@stellar/stellar-sdk"; import { abortAllRequests } from "@/services/api"; import { cacheClearByPrefix } from "@/services/cache"; +import { nonceManager } from "@/services/nonceManager"; export interface WalletAccount { address: string; @@ -64,6 +65,7 @@ export function WalletProvider({ children }: { children: React.ReactNode }) { if (oldAddress) { await cacheClearByPrefix(`utility:${oldAddress}`); + nonceManager.flushAccount(oldAddress); } }, []); @@ -86,6 +88,7 @@ export function WalletProvider({ children }: { children: React.ReactNode }) { ); setAccounts([newAccount]); setAccount(newAccount); + nonceManager.initialize(newAccount.address, newAccount.network).catch(console.error); } finally { setIsConnecting(false); } @@ -109,6 +112,7 @@ export function WalletProvider({ children }: { children: React.ReactNode }) { "utility-wallet-session", JSON.stringify({ address, network: target.network, cacheVersion: cacheVersion.current }) ); + nonceManager.initialize(target.address, target.network).catch(console.error); }); await switchGuard; }, diff --git a/src/hooks/useNonceManager.ts b/src/hooks/useNonceManager.ts new file mode 100644 index 0000000..d49164f --- /dev/null +++ b/src/hooks/useNonceManager.ts @@ -0,0 +1,40 @@ +import { useSyncExternalStore, useCallback } from "react"; +import { nonceStore } from "@/store/slices/nonceSlice"; +import { nonceManager } from "@/services/nonceManager"; +import { useWallet } from "@/components/providers/WalletProvider"; + +export function useNonceManager() { + const { account } = useWallet(); + + const state = useSyncExternalStore( + (listener) => nonceStore.subscribe(listener), + () => nonceStore.getState(), + () => nonceStore.getState() + ); + + const accountId = account?.address; + const accountState = accountId ? state[accountId] : null; + + const acquireNonce = useCallback(async () => { + if (!accountId) throw new Error("Wallet not connected"); + return await nonceManager.acquireLease(accountId, account.network); + }, [accountId, account?.network]); + + const releaseNonce = useCallback( + (leaseId: string, nonce: string) => { + if (!accountId) return; + nonceManager.releaseLease(accountId, leaseId, nonce); + }, + [accountId] + ); + + return { + status: accountState?.lastError ? "error" : "ok", + pendingCount: accountState?.pendingCount || 0, + poolSize: accountState?.poolSize || 0, + baseSequence: accountState?.baseSequence || "0", + lastError: accountState?.lastError || null, + acquireNonce, + releaseNonce, + }; +} diff --git a/src/services/nonceManager.ts b/src/services/nonceManager.ts new file mode 100644 index 0000000..8cba93f --- /dev/null +++ b/src/services/nonceManager.ts @@ -0,0 +1,209 @@ +import { rpc } from "@stellar/stellar-sdk"; +import { nonceStore } from "@/store/slices/nonceSlice"; +import { getRpcUrl, submitWithNonce as sorobanSubmit } from "@/services/soroban"; +import type { NonceLease, SequenceNumber } from "@/types/soroban"; + +// BigInt max +const MAX_PENDING = 50; + +class NonceManager { + private pools: Map = new Map(); + private leases: Map = new Map(); + + /** + * Fetches current sequence and pre-fetches 20 nonces into the pool. + */ + async initialize(accountId: string, network: string = "testnet"): Promise { + const server = new rpc.Server(getRpcUrl(network)); + let baseSequence: string; + + try { + const account = await server.getAccount(accountId); + baseSequence = account.sequenceNumber(); + } catch { + // If account doesn't exist or other error, fallback to 0 + baseSequence = "0"; + } + + const newPool: SequenceNumber[] = []; + let currentSeq = BigInt(baseSequence); + + for (let i = 0; i < 20; i++) { + currentSeq += BigInt(1); + newPool.push(currentSeq.toString()); + } + + this.pools.set(accountId, newPool); + + nonceStore.dispatch({ + type: "NONCE_POOL_UPDATED", + payload: { accountId, baseSequence, poolSize: newPool.length }, + }); + } + + /** + * Pops the next available nonce from the pool and creates a lease. + */ + async acquireLease(accountId: string, network: string = "testnet"): Promise { + let pool = this.pools.get(accountId); + + if (!pool || pool.length === 0) { + await this.initialize(accountId, network); + pool = this.pools.get(accountId); + } + + if (!pool || pool.length === 0) { + throw new Error("Failed to initialize nonce pool"); + } + + const state = nonceStore.getAccountState(accountId); + if (state.pendingCount >= MAX_PENDING) { + throw new Error("Maximum pending transactions reached for account"); + } + + const nonce = pool.shift()!; + const leaseId = `${accountId}-${nonce}-${Date.now()}`; + const lease: NonceLease = { + id: leaseId, + nonce, + acquiredAt: Date.now(), + expiresAt: Date.now() + 120000, // 120 seconds + }; + + // Set timeout to return nonce to pool + const timeout = setTimeout(() => { + this.releaseLease(accountId, leaseId, nonce); + }, 120000); + + this.leases.set(leaseId, timeout); + + nonceStore.dispatch({ + type: "NONCE_LEASE_ACQUIRED", + payload: { accountId }, + }); + + return lease; + } + + /** + * Returns the nonce to the pool if unused. + */ + releaseLease(accountId: string, leaseId: string, nonce: SequenceNumber) { + const timeout = this.leases.get(leaseId); + if (timeout) { + clearTimeout(timeout); + this.leases.delete(leaseId); + } + + const pool = this.pools.get(accountId) || []; + // Insert back in sorted order + pool.push(nonce); + pool.sort((a, b) => (BigInt(a) < BigInt(b) ? -1 : 1)); + this.pools.set(accountId, pool); + + nonceStore.dispatch({ + type: "NONCE_LEASE_RELEASED", + payload: { accountId }, + }); + } + + /** + * Wraps soroban.submitTransaction, catches tx_bad_seq errors, and triggers re-fetch + retry cycle. + */ + async submitWithNonce( + args: { + xdr: string; + lease: NonceLease; + accountId: string; + network?: string; + buildXdr?: (lease: NonceLease) => Promise | string; // callback for rebuild if retrying + } + ) { + const { lease, accountId, network = "testnet", buildXdr } = args; + let currentXdr = args.xdr; + let currentLease = lease; + let attempts = 0; + const maxAttempts = 3; + + while (attempts < maxAttempts) { + try { + attempts++; + const response = await sorobanSubmit(currentXdr, currentLease, network); + + // Success: clear lease timeout so it doesn't return to pool + const timeout = this.leases.get(currentLease.id); + if (timeout) { + clearTimeout(timeout); + this.leases.delete(currentLease.id); + } + + // We do not release back to pool because it's consumed! + // But we dispatch released to decrement pending count, as it's no longer pending. + nonceStore.dispatch({ + type: "NONCE_LEASE_RELEASED", + payload: { accountId }, + }); + + // If pool is running low, eagerly fetch more (pre-fetching in background) + const pool = this.pools.get(accountId); + if (pool && pool.length < 5) { + this.initialize(accountId, network).catch(console.error); + } + + return response; + } catch (err: unknown) { + const errorObj = err as { code?: string }; + if (errorObj?.code === "tx_bad_seq") { + nonceStore.dispatch({ + type: "NONCE_POOL_EXHAUSTED", + payload: { accountId, error: "tx_bad_seq" }, + }); + + if (attempts >= maxAttempts) { + throw new Error("Max retries reached for tx_bad_seq"); + } + + // Release all pre-fetched nonces (flush queue) + this.flushAccount(accountId); + + // Re-fetch sequence + await this.initialize(accountId, network); + + if (buildXdr) { + // Get new lease and rebuild + currentLease = await this.acquireLease(accountId, network); + currentXdr = await buildXdr(currentLease); + } else { + // If no buildXdr provided, we can't legitimately retry with a new seq. + // We just wait a second and retry the exact same XDR in case the node was lagging. + await new Promise((resolve) => setTimeout(resolve, 2000)); + } + } else { + throw err; + } + } + } + } + + /** + * On account change or error, cancel all leases and flush. + */ + flushAccount(accountId: string) { + // Cancel all leases for this account (inefficient loop but safe for small sizes) + for (const [leaseId, timeout] of this.leases.entries()) { + if (leaseId.startsWith(`${accountId}-`)) { + clearTimeout(timeout); + this.leases.delete(leaseId); + } + } + + this.pools.delete(accountId); + + nonceStore.dispatch({ + type: "NONCE_FLUSHED", + payload: { accountId }, + }); + } +} + +export const nonceManager = new NonceManager(); diff --git a/src/services/soroban.ts b/src/services/soroban.ts new file mode 100644 index 0000000..2fd0b34 --- /dev/null +++ b/src/services/soroban.ts @@ -0,0 +1,73 @@ +import { rpc, TransactionBuilder } from "@stellar/stellar-sdk"; +import type { NonceLease } from "@/types/soroban"; + +export const getRpcUrl = (network: string) => { + switch (network) { + case "mainnet": + return "https://mainnet.sorobanrpc.com"; + case "futurenet": + return "https://rpc-futurenet.stellar.org"; + default: + return "https://soroban-testnet.stellar.org"; + } +}; + +const getNetworkPassphrase = (network: string) => { + switch (network) { + case "mainnet": + return "Public Global Stellar Network ; September 2015"; + case "futurenet": + return "Test SDF Future Network ; October 2022"; + default: + return "Test SDF Network ; September 2015"; + } +}; + +export const submitTransaction = async ( + xdr: string, + network: string = "testnet" +) => { + const server = new rpc.Server(getRpcUrl(network)); + const tx = TransactionBuilder.fromXDR(xdr, getNetworkPassphrase(network)); + + try { + const response = await server.sendTransaction(tx as unknown as import("@stellar/stellar-sdk").Transaction); + + if (response.status === "ERROR") { + const errorStr = JSON.stringify(response); + if ( + errorStr.includes("tx_bad_seq") || + errorStr.includes("txBadSeq") || + errorStr.includes("bad_seq") + ) { + const err = new Error("tx_bad_seq") as Error & { code?: string }; + err.code = "tx_bad_seq"; + throw err; + } + throw new Error(`Transaction failed: ${errorStr}`); + } + + return response; + } catch (err: unknown) { + const errorObj = err as { message?: string }; + const msg = errorObj?.message || ""; + if (msg.includes("tx_bad_seq") || msg.includes("bad_seq")) { + const formattedErr = new Error("tx_bad_seq") as Error & { code?: string }; + formattedErr.code = "tx_bad_seq"; + throw formattedErr; + } + throw err; + } +}; + +export const submitWithNonce = async ( + xdr: string, + lease: NonceLease, + network: string = "testnet" +) => { + // In a full implementation, if the transaction succeeds, the lease is consumed. + // It relies on the manager popping it out. If an exception is thrown, the manager handles retries. + const response = await submitTransaction(xdr, network); + // Successfully consumed. We return the response. + return response; +}; diff --git a/src/store/slices/nonceSlice.ts b/src/store/slices/nonceSlice.ts new file mode 100644 index 0000000..a58b3df --- /dev/null +++ b/src/store/slices/nonceSlice.ts @@ -0,0 +1,111 @@ +import type { AccountNonceState } from "@/types/soroban"; + +// --------------------------------------------------------------------------- +// Actions & State +// --------------------------------------------------------------------------- + +export type NonceState = Record; + +export type NonceAction = + | { type: "NONCE_POOL_UPDATED"; payload: { accountId: string; baseSequence: string; poolSize: number } } + | { type: "NONCE_LEASE_ACQUIRED"; payload: { accountId: string } } + | { type: "NONCE_LEASE_RELEASED"; payload: { accountId: string } } + | { type: "NONCE_POOL_EXHAUSTED"; payload: { accountId: string; error: string } } + | { type: "NONCE_FLUSHED"; payload: { accountId: string } }; + +type Listener = (state: NonceState) => void; + +// --------------------------------------------------------------------------- +// Store +// --------------------------------------------------------------------------- + +const initialState: AccountNonceState = { + baseSequence: "0", + poolSize: 0, + pendingCount: 0, + lastError: null, +}; + +class NonceStore { + private state: NonceState = {}; + private listeners = new Set(); + + getState(): Readonly { + return this.state; + } + + getAccountState(accountId: string): Readonly { + return this.state[accountId] || { ...initialState }; + } + + subscribe(listener: Listener): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + dispatch(action: NonceAction): void { + this.state = this.reducer(this.state, action); + this.notify(); + } + + private reducer(state: NonceState, action: NonceAction): NonceState { + const accountId = "accountId" in action.payload ? action.payload.accountId : undefined; + if (!accountId) return state; + + const currentAccountState = state[accountId] || { ...initialState }; + + switch (action.type) { + case "NONCE_POOL_UPDATED": + return { + ...state, + [accountId]: { + ...currentAccountState, + baseSequence: action.payload.baseSequence, + poolSize: action.payload.poolSize, + lastError: null, + }, + }; + case "NONCE_LEASE_ACQUIRED": + return { + ...state, + [accountId]: { + ...currentAccountState, + poolSize: Math.max(0, currentAccountState.poolSize - 1), + pendingCount: currentAccountState.pendingCount + 1, + }, + }; + case "NONCE_LEASE_RELEASED": + return { + ...state, + [accountId]: { + ...currentAccountState, + poolSize: currentAccountState.poolSize + 1, + pendingCount: Math.max(0, currentAccountState.pendingCount - 1), + }, + }; + case "NONCE_POOL_EXHAUSTED": + return { + ...state, + [accountId]: { + ...currentAccountState, + lastError: action.payload.error, + }, + }; + case "NONCE_FLUSHED": + const newState = { ...state }; + delete newState[accountId]; + return newState; + default: + return state; + } + } + + private notify(): void { + for (const listener of this.listeners) { + listener(this.state); + } + } +} + +/** Singleton nonce store instance. */ +export const nonceStore = new NonceStore(); diff --git a/src/tests/nonceManager.test.ts b/src/tests/nonceManager.test.ts new file mode 100644 index 0000000..062f146 --- /dev/null +++ b/src/tests/nonceManager.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { nonceManager } from "@/services/nonceManager"; +import { nonceStore } from "@/store/slices/nonceSlice"; +import * as sorobanService from "@/services/soroban"; +import type { NonceLease } from "@/types/soroban"; + +// Mock the Soroban service +vi.mock("@/services/soroban", () => ({ + getRpcUrl: vi.fn(), + submitWithNonce: vi.fn(), +})); + +// Mock the stellar-sdk rpc +let mockSequenceNumber = BigInt(100); +vi.mock("@stellar/stellar-sdk", () => ({ + rpc: { + Server: vi.fn().mockImplementation(() => ({ + getAccount: vi.fn().mockImplementation(() => ({ + sequenceNumber: () => mockSequenceNumber.toString(), + })), + })), + }, + TransactionBuilder: { + fromXDR: vi.fn(), + }, +})); + +describe("NonceManager", () => { + const accountId = "GABCDEFGHIJKLMNOPQRSTUVWXYZ"; + + beforeEach(() => { + vi.clearAllMocks(); + nonceManager.flushAccount(accountId); + mockSequenceNumber = BigInt(100); + }); + + it("should pre-fetch nonces and acquire leases concurrently", async () => { + // Acquire 5 leases concurrently + const promises = Array.from({ length: 5 }).map(() => + nonceManager.acquireLease(accountId) + ); + const leases = await Promise.all(promises); + + // Each lease should have a unique nonce starting from baseSequence + 1 + const nonces = leases.map((l) => l.nonce); + expect(new Set(nonces).size).toBe(5); + expect(nonces).toContain("101"); + expect(nonces).toContain("102"); + expect(nonces).toContain("103"); + expect(nonces).toContain("104"); + expect(nonces).toContain("105"); + + const state = nonceStore.getAccountState(accountId); + expect(state.pendingCount).toBe(5); + // 20 pre-fetched initially, 5 popped + expect(state.poolSize).toBe(15); + }); + + it("should handle tx_bad_seq error, flush pool, re-fetch, and retry", async () => { + const buildXdr = vi.fn().mockImplementation(async (lease: NonceLease) => { + return `XDR-WITH-NONCE-${lease.nonce}`; + }); + + const submitMock = sorobanService.submitWithNonce as import("vitest").Mock; + + // Fail first time with tx_bad_seq, succeed second time + submitMock.mockImplementationOnce(() => { + const err = new Error("tx_bad_seq") as Error & { code?: string }; + err.code = "tx_bad_seq"; + throw err; + }).mockImplementationOnce(() => { + return { status: "SUCCESS" }; + }); + + const initialLease = await nonceManager.acquireLease(accountId); + expect(initialLease.nonce).toBe("101"); + + // The blockchain sequence moved forward out-of-band! + mockSequenceNumber = BigInt(200); + + const response = await nonceManager.submitWithNonce({ + xdr: "INITIAL_XDR", + lease: initialLease, + accountId, + buildXdr, + }); + + expect(response).toEqual({ status: "SUCCESS" }); + + // submitWithNonce should have been called twice + expect(submitMock).toHaveBeenCalledTimes(2); + + // buildXdr should have been called to reconstruct the XDR after the flush + expect(buildXdr).toHaveBeenCalledTimes(1); + + // After re-fetch, base sequence is 200, so new lease should be 201 + // And submitMock should have received the rebuilt XDR + const secondCallArgs = submitMock.mock.calls[1]; + expect(secondCallArgs[0]).toBe("XDR-WITH-NONCE-201"); + expect(secondCallArgs[1].nonce).toBe("201"); + + const state = nonceStore.getAccountState(accountId); + // After retry and success, pendingCount should go down + // We acquired 1 initially (pending=1). It failed, queue flushed (pending=0). + // Re-acquired 1 (pending=1). Succeeded, released (pending=0). + expect(state.pendingCount).toBe(0); + // Pool size should be 19 because it pre-fetched 20 after flush and popped 1 + expect(state.poolSize).toBe(19); + }); +}); diff --git a/src/types/soroban.ts b/src/types/soroban.ts new file mode 100644 index 0000000..146a975 --- /dev/null +++ b/src/types/soroban.ts @@ -0,0 +1,19 @@ +export type SequenceNumber = string; + +export interface NonceLease { + id: string; + nonce: SequenceNumber; + acquiredAt: number; + expiresAt: number; +} + +export interface NoncePool { + nonces: SequenceNumber[]; +} + +export interface AccountNonceState { + baseSequence: SequenceNumber; + poolSize: number; + pendingCount: number; + lastError: string | null; +}