Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions src/components/providers/WalletProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -64,6 +65,7 @@ export function WalletProvider({ children }: { children: React.ReactNode }) {

if (oldAddress) {
await cacheClearByPrefix(`utility:${oldAddress}`);
nonceManager.flushAccount(oldAddress);
}
}, []);

Expand All @@ -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);
}
Expand All @@ -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;
},
Expand Down
40 changes: 40 additions & 0 deletions src/hooks/useNonceManager.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
209 changes: 209 additions & 0 deletions src/services/nonceManager.ts
Original file line number Diff line number Diff line change
@@ -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<string, SequenceNumber[]> = new Map();
private leases: Map<string, NodeJS.Timeout> = new Map();

/**
* Fetches current sequence and pre-fetches 20 nonces into the pool.
*/
async initialize(accountId: string, network: string = "testnet"): Promise<void> {
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<NonceLease> {
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> | 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();
73 changes: 73 additions & 0 deletions src/services/soroban.ts
Original file line number Diff line number Diff line change
@@ -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;
};
Loading
Loading