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
79 changes: 69 additions & 10 deletions src/network/NetworkPassphraseValidator.ts
Original file line number Diff line number Diff line change
@@ -1,38 +1,97 @@
import { rpc as SorobanRpc } from "@stellar/stellar-sdk";

/** Map of known network passphrases to their labels. */
export const KNOWN_NETWORK_PASSPHRASES = {
"Public Global Stellar Network ; September 2015": "mainnet",
"Test SDF Network ; September 2015": "testnet",
"Future Network ; September 2015": "futurenet",
} as const;

export type KnownNetworkLabel = typeof KNOWN_NETWORK_PASSPHRASES[keyof typeof KNOWN_NETWORK_PASSPHRASES];

export interface ValidationResult {
valid: boolean;
configured: string;
reported: string;
mismatch: boolean;
/** If the passphrase is a known network, the network label. */
networkLabel?: KnownNetworkLabel;
}

export interface PassphraseValidationOptions {
/** Allow unknown (non-standard) passphrases. Defaults to false. */
allowUnknown?: boolean;
}

export class NetworkPassphraseValidator {
/**
* Validates a passphrase against the known networks list.
* Rejects unknown passphrases unless allowUnknown is set to true.
*
* @param passphrase - The passphrase to validate
* @param options - Validation options
* @returns Validation result with network label if it's a known network
*/
static validatePassphrase(
passphrase: string,
options: PassphraseValidationOptions = {},
): ValidationResult {
const networkLabel = Object.entries(KNOWN_NETWORK_PASSPHRASES).find(
([p]) => p === passphrase,
)?.[1];

const isKnown = networkLabel !== undefined;
const isValid = isKnown || (options.allowUnknown ?? false);

return {
valid: isValid,
configured: passphrase,
reported: passphrase,
mismatch: false,
networkLabel,
};
}

/**
* Validates that the local passphrase matches the remote Soroban RPC node's network.
*/
static async validate(configured: string, rpcUrl: string): Promise<ValidationResult> {
static async validate(
configured: string,
rpcUrl: string,
options: PassphraseValidationOptions = {},
): Promise<ValidationResult> {
try {
const server = new SorobanRpc.Server(rpcUrl);
const networkInfo = await server.getNetwork();
const reported = networkInfo.passphrase;

const isValid = configured === reported;
const isMatch = configured === reported;
const configuredLabel = Object.entries(KNOWN_NETWORK_PASSPHRASES).find(
([p]) => p === configured,
)?.[1];
const reportedLabel = Object.entries(KNOWN_NETWORK_PASSPHRASES).find(
([p]) => p === reported,
)?.[1];

const isConfiguredKnown = configuredLabel !== undefined;
const isReportedKnown = reportedLabel !== undefined;

// Valid if: they match, OR (configured is known and reported is known and they don't match)
const isValid =
isMatch ||
(isConfiguredKnown && isReportedKnown && !isMatch) ||
(options.allowUnknown ?? false);

return {
valid: isValid,
configured,
reported,
mismatch: !isValid
mismatch: !isMatch,
networkLabel: configuredLabel,
};
} catch (error) {
// If RPC fails, we return invalid but mismatch false (since we don't know the reported value)
return {
valid: false,
configured,
reported: "unknown",
mismatch: false
};
// If RPC fails, validate passphrase only
return this.validatePassphrase(configured, options);
}
}
}
53 changes: 51 additions & 2 deletions src/network/NetworkSwitcher.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,54 @@
import { NetworkPassphraseValidator } from "./NetworkPassphraseValidator";
import { PassphraseMismatchError } from "../errors";

/** Function to flush pending operations before network switch. */
export type FlushPendingFn = () => Promise<void>;

/** Options for network switching behavior. */
export interface NetworkSwitcherOptions {
/** Timeout in ms for flushing pending operations. Defaults to 5000. */
flushTimeoutMs?: number;
}

export class NetworkSwitcher {
/**
* Switches the network and clears all SDK state to prevent data leakage between networks.
* Awaits pending operations to complete before switching the network endpoint.
*
* @param network - Target network ('mainnet', 'testnet', or 'futurenet')
* @param client - The SplitClient instance
* @param flushPending - Injected function to flush pending operations
* @param options - Configuration options
*/
static async switchTo(
static async switchNetwork(
network: 'mainnet' | 'testnet' | 'futurenet',
client: any // Using any here to avoid circular dependency with SplitClient
client: any, // Using any here to avoid circular dependency with SplitClient
flushPending: FlushPendingFn,
options: NetworkSwitcherOptions = {},
): Promise<void> {
const flushTimeoutMs = options.flushTimeoutMs ?? 5000;

try {
client.emit('network:switching', { network });

// 0. Flush pending operations before switching
try {
const flushPromise = flushPending();
await Promise.race([
flushPromise,
new Promise((_, reject) =>
setTimeout(
() => reject(new Error('Flush pending timeout')),
flushTimeoutMs,
),
),
]);
} catch (error) {
console.warn(
`[NetworkSwitcher] Flush pending operations timed out or failed: ${error instanceof Error ? error.message : String(error)}. Proceeding with network switch.`,
);
}

// 1. Drain subscriptions
if (client.subscriptionManager) {
client.subscriptionManager.stopAll();
Expand Down Expand Up @@ -49,4 +86,16 @@ export class NetworkSwitcher {
throw error;
}
}

/**
* @deprecated Use switchNetwork instead
*/
static async switchTo(
network: 'mainnet' | 'testnet' | 'futurenet',
client: any,
): Promise<void> {
return this.switchNetwork(network, client, async () => {
// Default no-op flush for backwards compatibility
});
}
}
43 changes: 32 additions & 11 deletions src/preflight/RecipientBalancePreCheck.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,13 @@ export interface RecipientPreCheckOptions {
* Override in tests / for testnet usage.
*/
horizonUrl?: string;
/**
* XLM amount threshold above the minimum reserve.
* When native balance exceeds minimumReserveXlm + skipThresholdXlm,
* the minimum_reserve check passes immediately without detailed validation.
* Defaults to 10 XLM.
*/
skipThresholdXlm?: number;
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -187,24 +194,38 @@ async function checkRecipient(
if (nativeBalance) {
const balanceXlm = parseFloat(nativeBalance.balance);
const reserveXlm = minimumReserveXlm(account.subentry_count);
const shortfallXlm = reserveXlm - balanceXlm;
const skipThreshold = options.skipThresholdXlm ?? 10;

if (shortfallXlm <= 0) {
// Fast-path: if balance is well above reserve, skip detailed validation
if (balanceXlm >= reserveXlm + skipThreshold) {
console.debug(
`[RecipientBalancePreCheck] Fast-path skip for ${recipient}: balance ${balanceXlm.toFixed(7)} XLM >= reserve ${reserveXlm.toFixed(7)} XLM + threshold ${skipThreshold} XLM`,
);
checks.push({
name: "minimum_reserve",
passed: true,
detail: `XLM balance (${balanceXlm.toFixed(7)} XLM) satisfies minimum reserve (${reserveXlm.toFixed(7)} XLM).`,
});
} else {
const shortfallStroops = BigInt(Math.ceil(shortfallXlm * Number(XLM_STROOPS)));
checks.push({
name: "minimum_reserve",
passed: false,
detail: `XLM balance (${balanceXlm.toFixed(7)} XLM) is below the minimum reserve (${reserveXlm.toFixed(7)} XLM). Shortfall: ${shortfallXlm.toFixed(7)} XLM (${shortfallStroops} stroops).`,
});
remediations.push(
`Send at least ${shortfallXlm.toFixed(7)} XLM (${shortfallStroops} stroops) to account ${recipient} to satisfy the minimum reserve requirement.`,
);
const shortfallXlm = reserveXlm - balanceXlm;

if (shortfallXlm <= 0) {
checks.push({
name: "minimum_reserve",
passed: true,
detail: `XLM balance (${balanceXlm.toFixed(7)} XLM) satisfies minimum reserve (${reserveXlm.toFixed(7)} XLM).`,
});
} else {
const shortfallStroops = BigInt(Math.ceil(shortfallXlm * Number(XLM_STROOPS)));
checks.push({
name: "minimum_reserve",
passed: false,
detail: `XLM balance (${balanceXlm.toFixed(7)} XLM) is below the minimum reserve (${reserveXlm.toFixed(7)} XLM). Shortfall: ${shortfallXlm.toFixed(7)} XLM (${shortfallStroops} stroops).`,
});
remediations.push(
`Send at least ${shortfallXlm.toFixed(7)} XLM (${shortfallStroops} stroops) to account ${recipient} to satisfy the minimum reserve requirement.`,
);
}
}
} else {
// Should not happen for a funded account, but guard defensively.
Expand Down
72 changes: 72 additions & 0 deletions test/recipientBalancePreCheck.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,78 @@ describe("RecipientBalancePreCheck — minimum_reserve", () => {
expect(hint).toBeDefined();
expect(hint).toMatch(/1\.5/);
});

it("uses default skipThresholdXlm of 10 when not specified", async () => {
// reserve = 2 XLM; balance = 12 XLM (exactly reserve + 10 XLM threshold)
// Should trigger fast-path
loadAccountSpy.mockResolvedValue(
makeAccount({ subentryCount: 2, xlmBalance: "12.0000000" }) as any,
);

const debugSpy = vi.spyOn(console, "debug");
const checker = new RecipientBalancePreCheck();
const [result] = await checker.run([ADDR_VALID]);

const reserveCheck = result!.checks.find((c) => c.name === "minimum_reserve");
expect(reserveCheck!.passed).toBe(true);
expect(debugSpy).toHaveBeenCalledWith(
expect.stringContaining("Fast-path skip"),
);
debugSpy.mockRestore();
});

it("skips detailed validation when balance exceeds reserve + skipThreshold", async () => {
// reserve = 2 XLM; balance = 15 XLM; threshold = 5 XLM
// 15 >= 2 + 5, so should trigger fast-path
loadAccountSpy.mockResolvedValue(
makeAccount({ subentryCount: 2, xlmBalance: "15.0000000" }) as any,
);

const debugSpy = vi.spyOn(console, "debug");
const checker = new RecipientBalancePreCheck({ skipThresholdXlm: 5 });
const [result] = await checker.run([ADDR_VALID]);

const reserveCheck = result!.checks.find((c) => c.name === "minimum_reserve");
expect(reserveCheck!.passed).toBe(true);
expect(debugSpy).toHaveBeenCalledWith(
expect.stringContaining("threshold 5"),
);
debugSpy.mockRestore();
});

it("respects custom skipThresholdXlm option", async () => {
// reserve = 2 XLM; balance = 11 XLM; threshold = 10 XLM
// 11 < 2 + 10, so should NOT trigger fast-path
loadAccountSpy.mockResolvedValue(
makeAccount({ subentryCount: 2, xlmBalance: "11.0000000" }) as any,
);

const debugSpy = vi.spyOn(console, "debug");
const checker = new RecipientBalancePreCheck({ skipThresholdXlm: 10 });
const [result] = await checker.run([ADDR_VALID]);

const reserveCheck = result!.checks.find((c) => c.name === "minimum_reserve");
expect(reserveCheck!.passed).toBe(true);
expect(debugSpy).not.toHaveBeenCalled();
debugSpy.mockRestore();
});

it("does not skip when balance is only slightly above reserve", async () => {
// reserve = 2 XLM; balance = 2.5 XLM; default threshold = 10 XLM
// 2.5 < 2 + 10, so should NOT trigger fast-path
loadAccountSpy.mockResolvedValue(
makeAccount({ subentryCount: 2, xlmBalance: "2.5000000" }) as any,
);

const debugSpy = vi.spyOn(console, "debug");
const checker = new RecipientBalancePreCheck();
const [result] = await checker.run([ADDR_VALID]);

const reserveCheck = result!.checks.find((c) => c.name === "minimum_reserve");
expect(reserveCheck!.passed).toBe(true);
expect(debugSpy).not.toHaveBeenCalled();
debugSpy.mockRestore();
});
});

describe("RecipientBalancePreCheck — fully valid recipient", () => {
Expand Down