diff --git a/src/soroban/resultDecoder.test.ts b/src/soroban/resultDecoder.test.ts index 4fe1ad1..de8a6c6 100644 --- a/src/soroban/resultDecoder.test.ts +++ b/src/soroban/resultDecoder.test.ts @@ -38,6 +38,72 @@ describe("contract result decoder", () => { if (enumResult.status === "ok") expect(enumResult.data).toEqual({ type: "Some", value: 7n }); }); + it("decodes the no-payload variant of an Option (#857)", () => { + const noneValue = xdr.ScVal.scvVec([xdr.ScVal.scvSymbol("None")]); + const result = decodeContractResult(noneValue, { + type: "enum", + variants: { Some: "i128", None: undefined }, + }); + + expect(result.status).toBe("ok"); + if (result.status === "ok") expect(result.data).toEqual({ type: "None" }); + }); + + it("decodes both sides of a Result union with the discriminator intact (#857)", () => { + const schema = { + type: "enum" as const, + variants: { + Ok: { type: "struct" as const, fields: { amount: "i128" as const } }, + Err: "u32" as const, + }, + }; + + const okValue = xdr.ScVal.scvVec([ + xdr.ScVal.scvSymbol("Ok"), + xdr.ScVal.scvMap([ + new xdr.ScMapEntry({ + key: xdr.ScVal.scvSymbol("amount"), + val: nativeToScVal(100n, { type: "i128" }), + }), + ]), + ]); + const okResult = decodeContractResult(okValue, schema); + expect(okResult.status).toBe("ok"); + if (okResult.status === "ok") { + expect(okResult.data).toEqual({ type: "Ok", value: { amount: 100n } }); + } + + const errValue = xdr.ScVal.scvVec([ + xdr.ScVal.scvSymbol("Err"), + xdr.ScVal.scvU32(4), + ]); + const errResult = decodeContractResult(errValue, schema); + expect(errResult.status).toBe("ok"); + if (errResult.status === "ok") { + expect(errResult.data).toEqual({ type: "Err", value: 4 }); + } + + // The two sides of the union decode to genuinely distinguishable shapes, + // not a flattened/ambiguous result that loses which arm was returned. + expect(okResult.status === "ok" && errResult.status === "ok").toBe(true); + }); + + it("rejects an unknown enum variant and a malformed enum vector rather than silently misdecoding (#857)", () => { + const unknownVariant = xdr.ScVal.scvVec([xdr.ScVal.scvSymbol("Pending")]); + const unknownResult = decodeContractResult(unknownVariant, { + type: "enum", + variants: { Ok: undefined, Err: "u32" }, + }); + expect(unknownResult.status).toBe("error"); + + const notAVec = nativeToScVal(5, { type: "u32" }); + const malformedResult = decodeContractResult(notAVec, { + type: "enum", + variants: { Ok: undefined, Err: "u32" }, + }); + expect(malformedResult.status).toBe("error"); + }); + it("creates reusable decoders and returns errors for mismatches", () => { const decoder = createDecoder("u32"); expect(decoder(xdr.ScVal.scvU32(9)).status).toBe("ok"); diff --git a/src/tests/endpointFailover.test.ts b/src/tests/endpointFailover.test.ts index 6866578..a14a82d 100644 --- a/src/tests/endpointFailover.test.ts +++ b/src/tests/endpointFailover.test.ts @@ -6,6 +6,14 @@ import { import { mapHorizonError } from "../shared/horizonErrorMapper"; import { SorokitErrorCode } from "../shared/response"; +function withFakeNow(startMs: number) { + const spy = vi.spyOn(Date, "now").mockReturnValue(startMs); + return { + advanceTo: (ms: number) => spy.mockReturnValue(ms), + restore: () => spy.mockRestore(), + }; +} + describe("endpoint failover", () => { it("retries a transient primary failure on the backup", async () => { const pool = new EndpointPool([ @@ -24,6 +32,64 @@ describe("endpoint failover", () => { expect(pool.getHealth()[0]?.healthy).toBe(false); }); + it("skips an endpoint that exceeded its failure threshold and prefers the healthy one (#855)", () => { + const pool = new EndpointPool( + ["https://primary.example", "https://backup.example"], + { failureThreshold: 2, recoveryIntervalMs: 60_000 }, + ); + + pool.markFailure("https://primary.example"); + pool.markFailure("https://primary.example"); + + const primaryHealth = pool + .getHealth() + .find((h) => h.endpoint === "https://primary.example"); + expect(primaryHealth?.healthy).toBe(false); + expect(primaryHealth?.consecutiveFailures).toBe(2); + + // With the primary unhealthy and inside its recovery window, `current` + // must select the still-healthy backup rather than cycling back to it. + expect(pool.current).toBe("https://backup.example"); + expect(pool.current).toBe("https://backup.example"); + }); + + it("recovers an unhealthy endpoint once its recovery window elapses (#855)", () => { + const clock = withFakeNow(1_000_000); + try { + const pool = new EndpointPool( + ["https://primary.example", "https://backup.example"], + { failureThreshold: 1, recoveryIntervalMs: 30_000 }, + ); + + pool.markFailure("https://primary.example"); + expect( + pool.getHealth().find((h) => h.endpoint === "https://primary.example") + ?.healthy, + ).toBe(false); + + // Still inside the recovery window: the primary stays deprioritised. + clock.advanceTo(1_000_000 + 10_000); + expect(pool.current).toBe("https://backup.example"); + + // Recovery window has elapsed: the primary is eligible to be selected + // again even though its `healthy` flag hasn't been reset yet (recovery + // is a time-based retry opportunity, confirmed by a real request's + // markSuccess call, not an automatic flip). + clock.advanceTo(1_000_000 + 30_000); + const candidates = new Set(); + for (let i = 0; i < pool.endpoints.length; i += 1) candidates.add(pool.current); + expect(candidates.has("https://primary.example")).toBe(true); + + pool.markSuccess("https://primary.example"); + expect( + pool.getHealth().find((h) => h.endpoint === "https://primary.example") + ?.healthy, + ).toBe(true); + } finally { + clock.restore(); + } + }); + it("maps common Horizon response classes to stable SDK codes", () => { expect(mapHorizonError({ status: 400, message: "tx_bad_auth" }).code) .toBe(SorokitErrorCode.INVALID_TRANSACTION); diff --git a/src/tests/simulateTransactionCache.test.ts b/src/tests/simulateTransactionCache.test.ts new file mode 100644 index 0000000..dedca0a --- /dev/null +++ b/src/tests/simulateTransactionCache.test.ts @@ -0,0 +1,181 @@ +/** + * Tests for simulateTransaction's result caching (Issue #856). + * + * Verifies: + * - A repeated simulation of the same contract call (same contractId, + * method, and args) is served from cache with no second RPC call. + * - The cache key created by createSimulationCacheKey is scoped to the + * contract call identity, so a different method/args produces a miss. + * - The default TTL (5 minutes) and a custom ttlMs are both honored. + * - A simulation without a cache passed does not throw and does not cache. + */ + +import { + Account, + BASE_FEE, + Contract, + Keypair, + StrKey, + TransactionBuilder, + nativeToScVal, +} from "@stellar/stellar-sdk"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createInMemoryCache } from "../shared/cache"; +import { simulateTransaction } from "../soroban/simulateTransaction"; + +const { mockSimulateTransaction, mockIsSimulationSuccess, mockIsSimulationError } = + vi.hoisted(() => ({ + mockSimulateTransaction: vi.fn(), + mockIsSimulationSuccess: vi.fn(), + mockIsSimulationError: vi.fn(), + })); + +vi.mock("@stellar/stellar-sdk", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + rpc: { + ...actual.rpc, + Server: vi.fn().mockImplementation(() => ({ + simulateTransaction: mockSimulateTransaction, + })), + Api: { + ...actual.rpc.Api, + isSimulationError: mockIsSimulationError, + isSimulationSuccess: mockIsSimulationSuccess, + }, + }, + }; +}); + +function randomContractId(): string { + return StrKey.encodeContract(Keypair.random().rawPublicKey()); +} + +const NETWORK_PASSPHRASE = "Test SDF Network ; September 2015"; +const RPC_URL = "https://soroban-testnet.stellar.org"; + +function buildInvocationXdr(contractId: string, method: string, args: string[] = []) { + const source = Keypair.random(); + const contract = new Contract(contractId); + const scArgs = args.map((a) => nativeToScVal(a, { type: "string" })); + const op = contract.call(method, ...scArgs); + const account = new Account(source.publicKey(), "1"); + return new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: NETWORK_PASSPHRASE, + }) + .addOperation(op) + .setTimeout(30) + .build() + .toXDR(); +} + +const MOCK_SUCCESS_RESULT = { + result: { retval: {} }, + minResourceFee: "100", + transactionData: { build: () => ({ toXDR: () => "mock-resource-data" }) }, +}; + +function setupMocks() { + mockSimulateTransaction.mockReset(); + mockSimulateTransaction.mockResolvedValue(MOCK_SUCCESS_RESULT); + mockIsSimulationError.mockReset(); + mockIsSimulationError.mockReturnValue(false); + mockIsSimulationSuccess.mockReset(); + mockIsSimulationSuccess.mockReturnValue(true); +} + +describe("simulateTransaction — cache behaviour (#856)", () => { + beforeEach(() => { + setupMocks(); + }); + + it("caches a successful simulation — a repeated identical call is a cache hit with no second RPC call", async () => { + const cache = createInMemoryCache(); + const contractId = randomContractId(); + const xdr = buildInvocationXdr(contractId, "balance", ["GABC"]); + + const first = await simulateTransaction(RPC_URL, NETWORK_PASSPHRASE, xdr, { cache }); + expect(first.ok).toBe(true); + expect(mockSimulateTransaction).toHaveBeenCalledTimes(1); + + const second = await simulateTransaction(RPC_URL, NETWORK_PASSPHRASE, xdr, { cache }); + expect(second.ok).toBe(true); + expect(mockSimulateTransaction).toHaveBeenCalledTimes(1); + if (second.ok) { + expect(second.value).toEqual(first.ok ? first.value : undefined); + } + }); + + it("misses the cache for a different method on the same contract", async () => { + const cache = createInMemoryCache(); + const contractId = randomContractId(); + const balanceXdr = buildInvocationXdr(contractId, "balance", ["GABC"]); + const transferXdr = buildInvocationXdr(contractId, "transfer", ["GABC", "GXYZ", "10"]); + + await simulateTransaction(RPC_URL, NETWORK_PASSPHRASE, balanceXdr, { cache }); + expect(mockSimulateTransaction).toHaveBeenCalledTimes(1); + + await simulateTransaction(RPC_URL, NETWORK_PASSPHRASE, transferXdr, { cache }); + expect(mockSimulateTransaction).toHaveBeenCalledTimes(2); + }); + + it("misses the cache for the same method on a different contract", async () => { + const cache = createInMemoryCache(); + const xdrA = buildInvocationXdr(randomContractId(), "balance", ["GABC"]); + const xdrB = buildInvocationXdr(randomContractId(), "balance", ["GABC"]); + + await simulateTransaction(RPC_URL, NETWORK_PASSPHRASE, xdrA, { cache }); + expect(mockSimulateTransaction).toHaveBeenCalledTimes(1); + + await simulateTransaction(RPC_URL, NETWORK_PASSPHRASE, xdrB, { cache }); + expect(mockSimulateTransaction).toHaveBeenCalledTimes(2); + }); + + it("uses the default TTL of 5 minutes when none is passed", async () => { + const cache = createInMemoryCache(); + const setSpy = vi.spyOn(cache, "set"); + const xdr = buildInvocationXdr(randomContractId(), "balance", ["GABC"]); + + await simulateTransaction(RPC_URL, NETWORK_PASSPHRASE, xdr, { cache }); + + expect(setSpy).toHaveBeenCalledWith(expect.any(String), expect.anything(), 5 * 60 * 1000); + }); + + it("respects a custom ttlMs", async () => { + const cache = createInMemoryCache(); + const setSpy = vi.spyOn(cache, "set"); + const xdr = buildInvocationXdr(randomContractId(), "balance", ["GABC"]); + + await simulateTransaction(RPC_URL, NETWORK_PASSPHRASE, xdr, { cache, ttlMs: 1000 }); + + expect(setSpy).toHaveBeenCalledWith(expect.any(String), expect.anything(), 1000); + }); + + it("does not throw and does not cache when no cache is passed", async () => { + const xdr = buildInvocationXdr(randomContractId(), "balance", ["GABC"]); + + const result = await simulateTransaction(RPC_URL, NETWORK_PASSPHRASE, xdr); + expect(result.ok).toBe(true); + + const second = await simulateTransaction(RPC_URL, NETWORK_PASSPHRASE, xdr); + expect(second.ok).toBe(true); + // No cache supplied, so both calls must hit the RPC. + expect(mockSimulateTransaction).toHaveBeenCalledTimes(2); + }); + + it("invalidate() on the cache forces the next call to hit the RPC again", async () => { + const cache = createInMemoryCache(); + const contractId = randomContractId(); + const xdr = buildInvocationXdr(contractId, "balance", ["GABC"]); + + await simulateTransaction(RPC_URL, NETWORK_PASSPHRASE, xdr, { cache }); + expect(mockSimulateTransaction).toHaveBeenCalledTimes(1); + + cache.clear(); + + await simulateTransaction(RPC_URL, NETWORK_PASSPHRASE, xdr, { cache }); + expect(mockSimulateTransaction).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/transaction/index.ts b/src/transaction/index.ts index 5a320e8..a87bc63 100644 --- a/src/transaction/index.ts +++ b/src/transaction/index.ts @@ -232,6 +232,19 @@ export { formatTransactionsToJson, } from "./exportTransactionHistory"; export { validateTransaction, validateTransactionBatch } from "./validateTransaction"; +// ─── Transaction size tracking and early-warning (#858) ─────────────────────── +export { + TransactionSizeTracker, + createTransactionSizeTracker, + MAX_OPERATIONS, + RECOMMENDED_MAX_OPERATIONS, + RECOMMENDED_MAX_BYTES, +} from "./transactionSizeTracker"; +export type { + TransactionSizeWarning, + TransactionSizeSnapshot, + TransactionSizeTrackerOptions, +} from "./transactionSizeTracker"; export { validateTransactionOffline } from "./validateTransactionOffline"; export type { OfflineValidationIssue, OfflineValidationReport, OfflineValidationOptions } from "./validateTransactionOffline"; export { diff --git a/src/transaction/transactionSizeTracker.test.ts b/src/transaction/transactionSizeTracker.test.ts new file mode 100644 index 0000000..d5b593a --- /dev/null +++ b/src/transaction/transactionSizeTracker.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it, vi } from "vitest"; +import { + MAX_OPERATIONS, + RECOMMENDED_MAX_OPERATIONS, + RECOMMENDED_MAX_BYTES, + TransactionSizeTracker, +} from "./transactionSizeTracker"; + +function makeLogger() { + return { warn: vi.fn() }; +} + +describe("TransactionSizeTracker (#858)", () => { + it("tracks operation count as operations are added", () => { + const tracker = new TransactionSizeTracker(); + expect(tracker.snapshot().operationCount).toBe(0); + + tracker.addOperation("payment", { amount: "100" }); + tracker.addOperation("payment", { amount: "200" }); + + expect(tracker.snapshot().operationCount).toBe(2); + }); + + it("increases the estimated byte size as operations with larger payloads are added", () => { + const tracker = new TransactionSizeTracker(); + const before = tracker.snapshot().estimatedBytes; + + const after = tracker.addOperation("manageData", { + name: "a-fairly-long-data-entry-name", + value: "x".repeat(200), + }); + + expect(after.estimatedBytes).toBeGreaterThan(before); + }); + + it("warns once the recommended operation count is exceeded, logging via the configured logger", () => { + const logger = makeLogger(); + const tracker = new TransactionSizeTracker({ logger, recommendedMaxOperations: 3 }); + + tracker.addOperation("payment"); + tracker.addOperation("payment"); + const atLimit = tracker.addOperation("payment"); + expect(atLimit.warnings).toHaveLength(0); + expect(logger.warn).not.toHaveBeenCalled(); + + const overLimit = tracker.addOperation("payment"); + expect(overLimit.warnings.some((w) => w.code === "OPERATION_COUNT_ABOVE_RECOMMENDED")).toBe(true); + expect(logger.warn).toHaveBeenCalledTimes(1); + }); + + it("warns when the protocol hard limit of operations is exceeded, with a distinct warning code", () => { + const logger = makeLogger(); + const tracker = new TransactionSizeTracker({ logger, recommendedMaxOperations: MAX_OPERATIONS }); + + let last; + for (let i = 0; i < MAX_OPERATIONS + 1; i += 1) { + last = tracker.addOperation("payment"); + } + + expect(last?.operationCount).toBe(MAX_OPERATIONS + 1); + expect(last?.warnings.some((w) => w.code === "OPERATION_COUNT_EXCEEDS_LIMIT")).toBe(true); + }); + + it("warns when the estimated size crosses the recommended byte threshold", () => { + const logger = makeLogger(); + const tracker = new TransactionSizeTracker({ logger, recommendedMaxBytes: 500 }); + + const first = tracker.addOperation("manageData", { value: "x".repeat(100) }); + expect(first.warnings).toHaveLength(0); + + const second = tracker.addOperation("manageData", { value: "x".repeat(600) }); + expect(second.warnings.some((w) => w.code === "ESTIMATED_SIZE_ABOVE_RECOMMENDED")).toBe(true); + }); + + it("does not repeat the same warning code once it has already fired", () => { + const logger = makeLogger(); + const tracker = new TransactionSizeTracker({ logger, recommendedMaxOperations: 1 }); + + tracker.addOperation("payment"); + tracker.addOperation("payment"); + tracker.addOperation("payment"); + + // The warning code is present every time the condition still holds, but + // the underlying logger should only have been called once per code. + expect(logger.warn).toHaveBeenCalledTimes(1); + }); + + it("reset() clears both the operation count and any previously emitted warning state", () => { + const logger = makeLogger(); + const tracker = new TransactionSizeTracker({ logger, recommendedMaxOperations: 1 }); + + tracker.addOperation("payment"); + tracker.addOperation("payment"); + expect(logger.warn).toHaveBeenCalledTimes(1); + + tracker.reset(); + expect(tracker.snapshot().operationCount).toBe(0); + + tracker.addOperation("payment"); + tracker.addOperation("payment"); + // The warning fires again after reset, since warnedCodes was cleared too. + expect(logger.warn).toHaveBeenCalledTimes(2); + }); + + it("uses console.warn by default when no logger is passed", () => { + const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + const tracker = new TransactionSizeTracker({ recommendedMaxOperations: 1 }); + tracker.addOperation("payment"); + tracker.addOperation("payment"); + expect(consoleSpy).toHaveBeenCalledTimes(1); + } finally { + consoleSpy.mockRestore(); + } + }); + + it("exposes the documented default thresholds", () => { + expect(MAX_OPERATIONS).toBe(100); + expect(RECOMMENDED_MAX_OPERATIONS).toBeLessThan(MAX_OPERATIONS); + expect(RECOMMENDED_MAX_BYTES).toBeGreaterThan(0); + }); +}); diff --git a/src/transaction/transactionSizeTracker.ts b/src/transaction/transactionSizeTracker.ts new file mode 100644 index 0000000..3419fd3 --- /dev/null +++ b/src/transaction/transactionSizeTracker.ts @@ -0,0 +1,175 @@ +/** + * Transaction size tracking and early-warning utility (#858). + * + * `TransactionBuilder.addOperation()` has no visibility into how large the + * resulting transaction envelope is getting until the caller actually calls + * `.build().toXDR()` and submits it, at which point an oversized transaction + * is rejected by Horizon/the network with no earlier warning. This module + * tracks operation count and estimated XDR byte size as operations are + * added, so callers can be warned before they finish building. + * + * Thresholds used here: + * - `MAX_OPERATIONS` (100): Stellar's protocol-level hard limit on + * operations per transaction. Exceeding it always fails at submission. + * - `RECOMMENDED_MAX_OPERATIONS` (50): a conservative soft limit, well + * below the hard cap, left as headroom for fee-bump/signature overhead + * and to keep individual transactions easy to reason about. + * - `RECOMMENDED_MAX_BYTES` (5 * 1024): a conservative soft limit on + * estimated envelope size. Horizon's hard limit is higher, but staying + * well under it avoids edge cases around varint-length XDR padding for + * operations with large parameter payloads (e.g. manage_data, path + * payments with many hops). + */ + +export const MAX_OPERATIONS = 100; +export const RECOMMENDED_MAX_OPERATIONS = 50; +export const RECOMMENDED_MAX_BYTES = 5 * 1024; + +/** Rough per-operation XDR overhead used for estimation before a real build. */ +const BASE_OPERATION_BYTES = 36; +/** Rough fixed overhead for the transaction envelope itself (source account, seq num, fee, memo header, signature slots). */ +const BASE_ENVELOPE_BYTES = 96; + +export interface TransactionSizeWarning { + code: "OPERATION_COUNT_EXCEEDS_LIMIT" | "OPERATION_COUNT_ABOVE_RECOMMENDED" | "ESTIMATED_SIZE_ABOVE_RECOMMENDED"; + message: string; +} + +export interface TransactionSizeSnapshot { + operationCount: number; + /** Rough estimated XDR byte size of the envelope at this point. Not exact — a real `.build().toXDR()` is authoritative. */ + estimatedBytes: number; + warnings: TransactionSizeWarning[]; +} + +export interface TransactionSizeTrackerOptions { + /** Operation count above which a soft warning is emitted. @default RECOMMENDED_MAX_OPERATIONS */ + recommendedMaxOperations?: number; + /** Estimated byte size above which a soft warning is emitted. @default RECOMMENDED_MAX_BYTES */ + recommendedMaxBytes?: number; + /** Optional custom logger to receive warnings as they're emitted. Defaults to `console.warn`. */ + logger?: { + warn: (message: string, ...args: unknown[]) => void; + }; +} + +/** + * Rough per-operation byte estimate based on the number of top-level keys in + * its params payload. This is intentionally conservative (an underestimate + * for operations with deeply nested or large array params) — it exists to + * give an early, directional warning, not to replace a real `toXDR()` size. + */ +function estimateOperationBytes(params: Record | undefined): number { + if (!params) return BASE_OPERATION_BYTES; + let total = BASE_OPERATION_BYTES; + for (const value of Object.values(params)) { + if (typeof value === "string") total += value.length; + else if (value instanceof Uint8Array || Buffer.isBuffer(value)) total += value.length; + else total += 8; + } + return total; +} + +/** + * Tracks operation count and estimated size as operations are added to a + * transaction, and emits a warning (via the configured logger, defaulting + * to `console.warn`) the first time a threshold is crossed. + * + * @example + * const tracker = new TransactionSizeTracker(); + * for (const op of plannedOperations) { + * tracker.addOperation(op.type, op.params); + * } + * if (tracker.snapshot().warnings.length > 0) { + * // surface to the caller before calling builder.build() + * } + */ +export class TransactionSizeTracker { + private operationCount = 0; + private estimatedBytes: number; + private readonly recommendedMaxOperations: number; + private readonly recommendedMaxBytes: number; + private readonly logger: { warn: (message: string, ...args: unknown[]) => void }; + private readonly warnedCodes = new Set(); + + constructor(options: TransactionSizeTrackerOptions = {}) { + this.recommendedMaxOperations = options.recommendedMaxOperations ?? RECOMMENDED_MAX_OPERATIONS; + this.recommendedMaxBytes = options.recommendedMaxBytes ?? RECOMMENDED_MAX_BYTES; + this.logger = options.logger ?? { + warn: (message: string, ...args: unknown[]) => { + if (typeof console !== "undefined" && typeof console.warn === "function") { + console.warn(message, ...args); + } + }, + }; + this.estimatedBytes = BASE_ENVELOPE_BYTES; + } + + /** Record that an operation was added, re-evaluating thresholds and warning on first crossing. */ + addOperation(type: string, params?: Record): TransactionSizeSnapshot { + this.operationCount += 1; + this.estimatedBytes += estimateOperationBytes(params); + return this.evaluate(type); + } + + /** Current snapshot without recording a new operation. */ + snapshot(): TransactionSizeSnapshot { + return { + operationCount: this.operationCount, + estimatedBytes: this.estimatedBytes, + warnings: [], + }; + } + + /** Reset the tracker to track a fresh transaction. */ + reset(): void { + this.operationCount = 0; + this.estimatedBytes = BASE_ENVELOPE_BYTES; + this.warnedCodes.clear(); + } + + private evaluate(lastOperationType: string): TransactionSizeSnapshot { + const warnings: TransactionSizeWarning[] = []; + + if (this.operationCount > MAX_OPERATIONS) { + warnings.push(this.emitOnce( + "OPERATION_COUNT_EXCEEDS_LIMIT", + `Transaction has ${this.operationCount} operations, exceeding the protocol limit of ${MAX_OPERATIONS}. ` + + `This transaction will be rejected at submission.`, + )); + } else if (this.operationCount > this.recommendedMaxOperations) { + warnings.push(this.emitOnce( + "OPERATION_COUNT_ABOVE_RECOMMENDED", + `Transaction has ${this.operationCount} operations (recommended max ${this.recommendedMaxOperations}) ` + + `after adding "${lastOperationType}". Consider splitting into multiple transactions.`, + )); + } + + if (this.estimatedBytes > this.recommendedMaxBytes) { + warnings.push(this.emitOnce( + "ESTIMATED_SIZE_ABOVE_RECOMMENDED", + `Transaction's estimated envelope size is ~${this.estimatedBytes} bytes ` + + `(recommended max ${this.recommendedMaxBytes}) after adding "${lastOperationType}".`, + )); + } + + return { + operationCount: this.operationCount, + estimatedBytes: this.estimatedBytes, + warnings, + }; + } + + /** Emit a warning via the logger the first time this code is seen; return it every time regardless so `snapshot()`/`addOperation()` stay queryable. */ + private emitOnce(code: TransactionSizeWarning["code"], message: string): TransactionSizeWarning { + if (!this.warnedCodes.has(code)) { + this.warnedCodes.add(code); + this.logger.warn(`[sorokit] TransactionSizeTracker: ${message}`); + } + return { code, message }; + } +} + +export function createTransactionSizeTracker(options?: TransactionSizeTrackerOptions): TransactionSizeTracker { + return new TransactionSizeTracker(options); +}