diff --git a/src/account/dataEntries.ts b/src/account/dataEntries.ts index 17b7375f..5eae1b4f 100644 --- a/src/account/dataEntries.ts +++ b/src/account/dataEntries.ts @@ -4,18 +4,94 @@ import type { SorokitResult } from "../shared/response"; import { isNotFoundError, toMessage } from "../shared"; export type AccountDataEntries = Record; -const MAX_BYTES = 64; -function bytes(value: string): number { return new TextEncoder().encode(value).byteLength; } -function validateKey(key: string): SorokitResult { if (!key || bytes(key) > MAX_BYTES) return err(SorokitErrorCode.VALIDATION, "Data entry key must be non-empty and at most 64 UTF-8 bytes."); return ok(key); } -function validateValue(value: string | null): SorokitResult { if (value !== null && bytes(value) > MAX_BYTES) return err(SorokitErrorCode.VALIDATION, "Data entry value must be at most 64 UTF-8 bytes."); return ok(value); } +export type DataEntryValue = string | Uint8Array | Buffer; + +export const MAX_DATA_ENTRY_KEY_BYTES = 64; +export const MAX_DATA_ENTRY_VALUE_BYTES = 64; + +export function getDataByteLength(value: string | Uint8Array | Buffer): number { + if (typeof value === "string") { + return new TextEncoder().encode(value).byteLength; + } + if (value instanceof Uint8Array || (typeof Buffer !== "undefined" && Buffer.isBuffer(value))) { + return value.byteLength; + } + return 0; +} + +function validateKey(key: string): SorokitResult { + if (!key || typeof key !== "string") { + return err(SorokitErrorCode.VALIDATION, "Data entry key must be non-empty."); + } + const actualBytes = getDataByteLength(key); + if (actualBytes > MAX_DATA_ENTRY_KEY_BYTES) { + return err( + SorokitErrorCode.VALIDATION, + `Data entry key exceeds maximum limit of ${MAX_DATA_ENTRY_KEY_BYTES} bytes (actual: ${actualBytes} bytes).`, + ); + } + return ok(key); +} + +function validateValue(value: DataEntryValue | null | undefined): SorokitResult { + if (value === null || value === undefined) { + return ok(null); + } + const actualBytes = getDataByteLength(value); + if (actualBytes > MAX_DATA_ENTRY_VALUE_BYTES) { + return err( + SorokitErrorCode.VALIDATION, + `Data entry value exceeds maximum limit of ${MAX_DATA_ENTRY_VALUE_BYTES} bytes (actual: ${actualBytes} bytes).`, + ); + } + return ok(value); +} + export async function getDataEntries(horizonUrl: string, publicKey: string): Promise> { - try { const account = await new Horizon.Server(horizonUrl).loadAccount(publicKey); return ok(Object.fromEntries(Object.entries((account as any).data ?? {}).map(([key, value]) => [key, typeof value === "string" ? value : String(value)]))); } - catch (cause) { const nf = isNotFoundError(cause); return err(nf ? SorokitErrorCode.ACCOUNT_NOT_FOUND : SorokitErrorCode.ACCOUNT_FETCH_FAILED, nf ? `Account not found: ${publicKey}` : `Failed to fetch data entries for ${publicKey}: ${toMessage(cause)}`, cause); } + try { + const account = await new Horizon.Server(horizonUrl).loadAccount(publicKey); + return ok( + Object.fromEntries( + Object.entries((account as any).data ?? {}).map(([key, value]) => [ + key, + typeof value === "string" ? value : String(value), + ]), + ), + ); + } catch (cause) { + const nf = isNotFoundError(cause); + return err( + nf ? SorokitErrorCode.ACCOUNT_NOT_FOUND : SorokitErrorCode.ACCOUNT_FETCH_FAILED, + nf ? `Account not found: ${publicKey}` : `Failed to fetch data entries for ${publicKey}: ${toMessage(cause)}`, + cause, + ); + } } -export function buildDataEntryOperation(key: string, value: string | null): SorokitResult { - const keyResult = validateKey(key); if (keyResult.status === "error") return keyResult; - const valueResult = validateValue(value); if (valueResult.status === "error") return valueResult; - try { return ok(Operation.manageData({ name: key, value })); } - catch (cause) { return err(SorokitErrorCode.TX_BUILD_FAILED, `Failed to build data entry operation: ${toMessage(cause)}`, cause); } + +export function buildDataEntryOperation(key: string, value: DataEntryValue | null | undefined): SorokitResult { + const keyResult = validateKey(key); + if (keyResult.status === "error") return keyResult; + const valueResult = validateValue(value); + if (valueResult.status === "error") return valueResult; + try { + let opValue: string | Buffer | null = null; + if (value !== null && value !== undefined) { + if (typeof value === "string") { + opValue = value; + } else if (typeof Buffer !== "undefined" && Buffer.isBuffer(value)) { + opValue = value; + } else if (value instanceof Uint8Array) { + opValue = Buffer.from(value.buffer, value.byteOffset, value.byteLength); + } + } + return ok(Operation.manageData({ name: key, value: opValue as any })); + } catch (cause) { + return err(SorokitErrorCode.TX_BUILD_FAILED, `Failed to build data entry operation: ${toMessage(cause)}`, cause); + } } -export { validateKey as validateDataEntryKey, validateValue as validateDataEntryValue }; + +export { + validateKey as validateDataEntryKey, + validateValue as validateDataEntryValue, +}; + diff --git a/src/client/createSorokitClient.ts b/src/client/createSorokitClient.ts index f42b480a..7b0ebfb2 100644 --- a/src/client/createSorokitClient.ts +++ b/src/client/createSorokitClient.ts @@ -109,6 +109,10 @@ import { buildSetDataEntryTransaction, buildDeleteDataEntryTransaction, } from "../transaction/dataEntry"; +import type { + DataEntryValue, + DataEntryTransactionOptions, +} from "../transaction/dataEntry"; import { compose } from "../transaction/compose"; import type { ComposeOptions } from "../transaction/compose"; import { orchestrate } from "../transaction/atomicOrchestrator"; @@ -837,6 +841,32 @@ export interface SorokitClient { params: SetOptionsParams, timeoutMs?: number, ): Promise>; + buildSetDataEntry( + sourcePublicKey: string, + key: string, + value: DataEntryValue, + options?: DataEntryTransactionOptions, + timeoutMs?: number, + ): Promise>; + setData( + sourcePublicKey: string, + key: string, + value: DataEntryValue, + options?: DataEntryTransactionOptions, + timeoutMs?: number, + ): Promise>; + buildDeleteDataEntry( + sourcePublicKey: string, + key: string, + options?: DataEntryTransactionOptions, + timeoutMs?: number, + ): Promise>; + deleteData( + sourcePublicKey: string, + key: string, + options?: DataEntryTransactionOptions, + timeoutMs?: number, + ): Promise>; /** * Start a fluent multi-operation transaction builder bound to this client's * network config. Call `.build()` on the returned builder for the XDR (#542). @@ -2354,6 +2384,80 @@ export function createSorokitClient( ), ).then(applyTx), ), + buildSetDataEntry: (sourcePublicKey, key, value, options, timeoutMs) => + guard("tx_build", timeoutMs, () => + withErrorHandling( + errorHandler, + { + functionName: "transaction.buildSetDataEntry", + params: { sourcePublicKey, key }, + }, + () => + buildSetDataEntryTransaction( + horizonUrl, + networkConfig, + sourcePublicKey, + key, + value, + options, + ), + ).then(applyTx), + ), + setData: (sourcePublicKey, key, value, options, timeoutMs) => + guard("tx_build", timeoutMs, () => + withErrorHandling( + errorHandler, + { + functionName: "transaction.setData", + params: { sourcePublicKey, key }, + }, + () => + buildSetDataEntryTransaction( + horizonUrl, + networkConfig, + sourcePublicKey, + key, + value, + options, + ), + ).then(applyTx), + ), + buildDeleteDataEntry: (sourcePublicKey, key, options, timeoutMs) => + guard("tx_build", timeoutMs, () => + withErrorHandling( + errorHandler, + { + functionName: "transaction.buildDeleteDataEntry", + params: { sourcePublicKey, key }, + }, + () => + buildDeleteDataEntryTransaction( + horizonUrl, + networkConfig, + sourcePublicKey, + key, + options, + ), + ).then(applyTx), + ), + deleteData: (sourcePublicKey, key, options, timeoutMs) => + guard("tx_build", timeoutMs, () => + withErrorHandling( + errorHandler, + { + functionName: "transaction.deleteData", + params: { sourcePublicKey, key }, + }, + () => + buildDeleteDataEntryTransaction( + horizonUrl, + networkConfig, + sourcePublicKey, + key, + options, + ), + ).then(applyTx), + ), compose: (sourcePublicKey, options) => { logger.debug("transaction.compose", { sourcePublicKey }); return compose(sourcePublicKey, networkConfig, options); diff --git a/src/index.ts b/src/index.ts index 1c2ceda3..776f945f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1882,6 +1882,17 @@ export type { AccountDataEntries } from "./account/dataEntries"; export { buildSetDataEntryTransaction, buildDeleteDataEntryTransaction, + setData, + deleteData, + validateDataEntryKey, + validateDataEntryValue, + getDataByteLength, + MAX_DATA_ENTRY_KEY_BYTES, + MAX_DATA_ENTRY_VALUE_BYTES, +} from "./transaction/dataEntry"; +export type { + DataEntryTransactionOptions, + DataEntryValue, } from "./transaction/dataEntry"; export { detectContractUpgrade } from "./soroban/upgradeDetection"; export type { diff --git a/src/shared/logger.ts b/src/shared/logger.ts index d6c34008..c6a6d0a1 100644 --- a/src/shared/logger.ts +++ b/src/shared/logger.ts @@ -116,7 +116,7 @@ function resolveModule(meta?: StructuredLogMeta): string { return typeof moduleName === "string" && moduleName.length > 0 ? moduleName : "core"; } -function createTransportLogger(resolveTransports: () => LogTransport[]): SorokitLogger { +function createTransportLogger(transports: () => LogTransport[]): SorokitLogger { const emit = (level: Exclude, message: string, meta?: StructuredLogMeta): void => { const sanitized = sanitizeLogMeta(meta); const record: StructuredLogRecord = { @@ -126,7 +126,7 @@ function createTransportLogger(resolveTransports: () => LogTransport[]): Sorokit message, ...(sanitized ? { context: sanitized } : {}), }; - for (const transport of resolveTransports()) { + for (const transport of transports()) { void transport.write(record); } }; @@ -181,10 +181,10 @@ function createLevelLogger( export function createLogger(options?: LoggerOptions): SorokitLogger { const level: LogLevel = options?.logLevel ?? (options?.debug ? "debug" : "off"); if (level === "off") return createNoopLogger(); - const localTransports = options?.transports ?? []; + const explicitTransports = options?.transports ?? []; const sink = options?.logger ?? createTransportLogger(() => { - const current = [...localTransports, ...registeredTransports]; - return current.length > 0 ? current : [createConsoleTransport(options?.prefix)]; + const transports = [...explicitTransports, ...registeredTransports]; + return transports.length > 0 ? transports : [createConsoleTransport(options?.prefix)]; }); return createLevelLogger(level, sink, options?.moduleLevels); } diff --git a/src/soroban/eventIndex.ts b/src/soroban/eventIndex.ts index 05cf9ec9..ccb2858e 100644 --- a/src/soroban/eventIndex.ts +++ b/src/soroban/eventIndex.ts @@ -17,21 +17,37 @@ function djb2Hash(s: string): string { return (h >>> 0).toString(16); } -function stableEventKey(event: ContractEvent): string { - const value = (event as Record).value; - const topics = (event as Record).topics; - return JSON.stringify([ - event.id ?? (event as Record).pagingToken ?? null, - event.contractId ?? (event as Record).contract_id ?? null, - (event as Record).ledger ?? null, - event.name ?? (event as Record).eventType ?? null, - topics ?? null, - value ?? null, - ]); +function hashKey(event: ContractEvent): string { + return djb2Hash( + String(event.id ?? "") + + ":" + + String(event.contractId ?? "") + + ":" + + String((event as any).ledger ?? "") + + ":" + + String(event.name ?? ""), + ); } -function hashKey(event: ContractEvent): string { - return djb2Hash(stableEventKey(event)); +function fullKey(event: ContractEvent): string { + return JSON.stringify({ + id: event.id ?? (event as any).pagingToken ?? null, + contractId: event.contractId ?? (event as any).contract_id ?? null, + ledger: (event as any).ledger ?? null, + name: event.name ?? event.eventType ?? (event as any).type ?? null, + topics: event.topics ?? null, + value: event.value ?? null, + }); +} + +/** Stable string hash used for event identity and deduplication. */ +export function hashString(value: string): string { + return djb2Hash(value); +} + +/** Hash the stable identifying fields of an event. */ +export function hashEvent(event: ContractEvent | Record): string { + return hashString(fullKey(event as ContractEvent)); } /** @@ -47,21 +63,22 @@ export class EventIndex { private readonly primary = new Map(); private readonly collisions = new Map(); - constructor(private readonly capacity = 0) {} + constructor(private readonly capacity = Infinity) {} /** Insert an event; returns true when it was already present. */ - add(event: ContractEvent): boolean { + add(event: ContractEvent | Record): boolean { + const normalized = event as ContractEvent; const h = hashKey(event); if (!this.primary.has(h)) { - this.primary.set(h, event); - this.enforceCapacity(); + this.primary.set(h, normalized); + this.evictOverflow(); return false; } // Hash is already occupied — check for true equality vs collision const existing = this.primary.get(h)!; - if (stableEventKey(existing) === stableEventKey(event)) { + if (fullKey(existing) === fullKey(event)) { // Exact duplicate — do not store again return true; } @@ -70,31 +87,26 @@ export class EventIndex { if (!this.collisions.has(h)) { this.collisions.set(h, []); } - this.collisions.get(h)!.push(event); - this.enforceCapacity(); + this.collisions.get(h)!.push(normalized); + this.evictOverflow(); return false; } - private enforceCapacity(): void { - if (this.capacity <= 0) return; - const events = this.allEvents(); - if (events.length <= this.capacity) return; - const retained = events.slice(events.length - this.capacity); - this.primary.clear(); - this.collisions.clear(); - for (const event of retained) { - const hash = hashKey(event); - const existing = this.primary.get(hash); - if (!existing) this.primary.set(hash, event); - else this.collisions.set(hash, [...(this.collisions.get(hash) ?? []), event]); + private evictOverflow(): void { + if (this.capacity <= 0 || !Number.isFinite(this.capacity)) return; + while (this.size() > this.capacity) { + const oldestPrimary = this.primary.keys().next().value as string | undefined; + if (oldestPrimary === undefined) return; + const collisions = this.collisions.get(oldestPrimary); + if (collisions?.length) { + collisions.shift(); + if (collisions.length === 0) this.collisions.delete(oldestPrimary); + } else { + this.primary.delete(oldestPrimary); + } } } - has(event: ContractEvent): boolean { - const key = stableEventKey(event); - return this.allEvents().some((entry) => stableEventKey(entry) === key); - } - /** Return all events (primary + collisions) as a flat array. */ private allEvents(): ContractEvent[] { const result: ContractEvent[] = []; @@ -172,7 +184,7 @@ export class EventIndex { const seen = new Map(); for (const event of all) { - const fk = stableEventKey(event); + const fk = fullKey(event); if (!seen.has(fk)) { seen.set(fk, event); } @@ -199,6 +211,12 @@ export class EventIndex { return removed; } + /** Check whether an event with the same stable identity is indexed. */ + has(event: ContractEvent | Record): boolean { + const key = fullKey(event as ContractEvent); + return this.allEvents().some((stored) => fullKey(stored) === key); + } + /** Total number of stored events (primary + all collision entries). */ size(): number { let count = this.primary.size; @@ -215,19 +233,7 @@ export class EventIndex { } } -export function hashString(value: string): string { - return djb2Hash(value); -} - -export function hashEvent(event: ContractEvent): string { - return hashKey(event); -} - -/** Return only events not already present in the index, adding each new event. */ +/** Filter out events already indexed, adding each unseen event as it passes. */ export function filterNewEvents(index: EventIndex, events: T[]): T[] { - const fresh: T[] = []; - for (const event of events) { - if (!index.add(event)) fresh.push(event); - } - return fresh; + return events.filter((event) => !index.add(event)); } diff --git a/src/tests/contract-event-decoding.test.ts b/src/tests/contract-event-decoding.test.ts index 9886ce5c..b1e09899 100644 --- a/src/tests/contract-event-decoding.test.ts +++ b/src/tests/contract-event-decoding.test.ts @@ -40,4 +40,10 @@ describe("contract event decoders", () => { expect(result.status).toBe("ok"); if (result.status === "ok") expect(result.data.type).toBe("custom.future"); }); + + it("returns a structured validation error for malformed input", () => { + const result = decodeContractEvent(null); + expect(result.status).toBe("error"); + if (result.status === "error") expect(result.error.code).toBe("CONTRACT_READ_FAILED"); + }); }); diff --git a/src/tests/dataEntry.test.ts b/src/tests/dataEntry.test.ts new file mode 100644 index 00000000..143132b9 --- /dev/null +++ b/src/tests/dataEntry.test.ts @@ -0,0 +1,391 @@ +import { describe, it, expect } from "vitest"; +import { + buildSetDataEntryTransaction, + buildDeleteDataEntryTransaction, + setData, + deleteData, + validateDataEntryKey, + validateDataEntryValue, + getDataByteLength, + MAX_DATA_ENTRY_KEY_BYTES, + MAX_DATA_ENTRY_VALUE_BYTES, +} from "../transaction/dataEntry"; +import { compose } from "../transaction/compose"; +import { createSorokitClient } from "../client/createSorokitClient"; +import { SorokitErrorCode } from "../shared/response"; +import type { ResolvedNetworkConfig } from "../shared/types"; + +const mockNetworkConfig: ResolvedNetworkConfig = { + network: "testnet", + horizonUrl: "https://horizon-testnet.stellar.org", + rpcUrl: "https://soroban-testnet.stellar.org", + networkPassphrase: "Test SDF Network ; September 2015", +}; + +const TEST_SOURCE_KEY = "GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI"; + +describe("DataEntry XDR limits validation (#762)", () => { + describe("getDataByteLength", () => { + it("computes exact byte length for standard ASCII strings", () => { + expect(getDataByteLength("")).toBe(0); + expect(getDataByteLength("hello")).toBe(5); + expect(getDataByteLength("a".repeat(64))).toBe(64); + }); + + it("computes exact byte length for multi-byte UTF-8 strings", () => { + // "🚀" is 2 code units in UTF-16, but 4 bytes in UTF-8 + expect("🚀".length).toBe(2); + expect(getDataByteLength("🚀")).toBe(4); + + // 16 emojis = 64 bytes + const emojis16 = "🚀".repeat(16); + expect(emojis16.length).toBe(32); + expect(getDataByteLength(emojis16)).toBe(64); + + // 17 emojis = 68 bytes + const emojis17 = "🚀".repeat(17); + expect(emojis17.length).toBe(34); + expect(getDataByteLength(emojis17)).toBe(68); + + // CJK characters: 3 bytes each in UTF-8 + const cjk21 = "星".repeat(21); + expect(getDataByteLength(cjk21)).toBe(63); + const cjk22 = "星".repeat(22); + expect(getDataByteLength(cjk22)).toBe(66); + }); + + it("computes exact byte length for binary Uint8Array payloads", () => { + const u64 = new Uint8Array(64); + expect(getDataByteLength(u64)).toBe(64); + + const u65 = new Uint8Array(65); + expect(getDataByteLength(u65)).toBe(65); + }); + + it("computes exact byte length for Buffer payloads", () => { + if (typeof Buffer !== "undefined") { + const buf64 = Buffer.alloc(64); + expect(getDataByteLength(buf64)).toBe(64); + + const buf100 = Buffer.alloc(100); + expect(getDataByteLength(buf100)).toBe(100); + } + }); + }); + + describe("validateDataEntryValue", () => { + it("accepts null and undefined (deletion / clear)", () => { + const resNull = validateDataEntryValue(null); + expect(resNull.status).toBe("ok"); + expect(resNull.data).toBeNull(); + + const resUndef = validateDataEntryValue(undefined); + expect(resUndef.status).toBe("ok"); + expect(resUndef.data).toBeNull(); + }); + + it("accepts empty string (0 bytes)", () => { + const res = validateDataEntryValue(""); + expect(res.status).toBe("ok"); + expect(res.data).toBe(""); + }); + + it("accepts ASCII strings up to 64 bytes", () => { + const str64 = "a".repeat(64); + const res = validateDataEntryValue(str64); + expect(res.status).toBe("ok"); + expect(res.data).toBe(str64); + }); + + it("rejects ASCII strings exceeding 64 bytes with limit and actual size", () => { + const str65 = "a".repeat(65); + const res = validateDataEntryValue(str65); + expect(res.status).toBe("error"); + if (res.status === "error") { + expect(res.error.code).toBe(SorokitErrorCode.VALIDATION); + expect(res.error.message).toContain("64 bytes"); + expect(res.error.message).toContain("actual: 65 bytes"); + } + }); + + it("rejects multi-byte UTF-8 string when byte count exceeds 64, even if character length < 64", () => { + // 17 emojis: length is 34 (well below 64 characters), but byte size is 68 bytes + const emojis17 = "🚀".repeat(17); + expect(emojis17.length).toBe(34); + + const res = validateDataEntryValue(emojis17); + expect(res.status).toBe("error"); + if (res.status === "error") { + expect(res.error.code).toBe(SorokitErrorCode.VALIDATION); + expect(res.error.message).toContain("64 bytes"); + expect(res.error.message).toContain("actual: 68 bytes"); + } + }); + + it("accepts multi-byte UTF-8 string when byte count is exactly 64 bytes", () => { + // 16 emojis = 64 bytes + const emojis16 = "🚀".repeat(16); + const res = validateDataEntryValue(emojis16); + expect(res.status).toBe("ok"); + expect(res.data).toBe(emojis16); + }); + + it("accepts Uint8Array of exactly 64 bytes", () => { + const u64 = new Uint8Array(64); + const res = validateDataEntryValue(u64); + expect(res.status).toBe("ok"); + expect(res.data).toBe(u64); + }); + + it("rejects Uint8Array exceeding 64 bytes with exact byte size", () => { + const u65 = new Uint8Array(65); + const res = validateDataEntryValue(u65); + expect(res.status).toBe("error"); + if (res.status === "error") { + expect(res.error.code).toBe(SorokitErrorCode.VALIDATION); + expect(res.error.message).toContain("64 bytes"); + expect(res.error.message).toContain("actual: 65 bytes"); + } + }); + + it("rejects Buffer exceeding 64 bytes with exact byte size", () => { + if (typeof Buffer !== "undefined") { + const buf75 = Buffer.alloc(75); + const res = validateDataEntryValue(buf75); + expect(res.status).toBe("error"); + if (res.status === "error") { + expect(res.error.code).toBe(SorokitErrorCode.VALIDATION); + expect(res.error.message).toContain("64 bytes"); + expect(res.error.message).toContain("actual: 75 bytes"); + } + } + }); + }); + + describe("validateDataEntryKey", () => { + it("rejects empty or non-string keys", () => { + const resEmpty = validateDataEntryKey(""); + expect(resEmpty.status).toBe("error"); + if (resEmpty.status === "error") { + expect(resEmpty.error.code).toBe(SorokitErrorCode.VALIDATION); + } + }); + + it("accepts keys up to 64 bytes", () => { + const key64 = "k".repeat(64); + const res = validateDataEntryKey(key64); + expect(res.status).toBe("ok"); + expect(res.data).toBe(key64); + }); + + it("rejects keys exceeding 64 bytes with limit and actual size", () => { + const key65 = "k".repeat(65); + const res = validateDataEntryKey(key65); + expect(res.status).toBe("error"); + if (res.status === "error") { + expect(res.error.code).toBe(SorokitErrorCode.VALIDATION); + expect(res.error.message).toContain("64 bytes"); + expect(res.error.message).toContain("actual: 65 bytes"); + } + }); + }); + + describe("buildSetDataEntryTransaction & setData", () => { + it("rejects oversized string value early before making any network call", async () => { + const oversizedValue = "v".repeat(65); + const result = await buildSetDataEntryTransaction( + mockNetworkConfig.horizonUrl, + mockNetworkConfig, + TEST_SOURCE_KEY, + "validKey", + oversizedValue, + ); + + expect(result.status).toBe("error"); + if (result.status === "error") { + expect(result.error.code).toBe(SorokitErrorCode.VALIDATION); + expect(result.error.message).toContain("64 bytes"); + expect(result.error.message).toContain("actual: 65 bytes"); + } + }); + + it("rejects oversized binary value early via setData alias", async () => { + const oversizedBuffer = new Uint8Array(70); + const result = await setData( + mockNetworkConfig.horizonUrl, + mockNetworkConfig, + TEST_SOURCE_KEY, + "validKey", + oversizedBuffer, + ); + + expect(result.status).toBe("error"); + if (result.status === "error") { + expect(result.error.code).toBe(SorokitErrorCode.VALIDATION); + expect(result.error.message).toContain("64 bytes"); + expect(result.error.message).toContain("actual: 70 bytes"); + } + }); + + it("rejects oversized key early", async () => { + const oversizedKey = "k".repeat(65); + const result = await setData( + mockNetworkConfig.horizonUrl, + mockNetworkConfig, + TEST_SOURCE_KEY, + oversizedKey, + "validValue", + ); + + expect(result.status).toBe("error"); + if (result.status === "error") { + expect(result.error.code).toBe(SorokitErrorCode.VALIDATION); + expect(result.error.message).toContain("64 bytes"); + expect(result.error.message).toContain("actual: 65 bytes"); + } + }); + + it("builds valid XDR when value is exactly 64 bytes using sequenceNumber override", async () => { + const valid64 = "x".repeat(64); + const result = await buildSetDataEntryTransaction( + mockNetworkConfig.horizonUrl, + mockNetworkConfig, + TEST_SOURCE_KEY, + "myConfigKey", + valid64, + { sequenceNumber: "100" }, + ); + + expect(result.status).toBe("ok"); + if (result.status === "ok") { + expect(typeof result.data).toBe("string"); + expect(result.data.length).toBeGreaterThan(0); + } + }); + + it("builds valid XDR when value is 64-byte Uint8Array using setData alias", async () => { + const valid64Bytes = new Uint8Array(64); + valid64Bytes.fill(42); + const result = await setData( + mockNetworkConfig.horizonUrl, + mockNetworkConfig, + TEST_SOURCE_KEY, + "binaryKey", + valid64Bytes, + { sequenceNumber: "100" }, + ); + + expect(result.status).toBe("ok"); + if (result.status === "ok") { + expect(typeof result.data).toBe("string"); + expect(result.data.length).toBeGreaterThan(0); + } + }); + }); + + describe("buildDeleteDataEntryTransaction & deleteData", () => { + it("builds valid delete data transaction using sequenceNumber override", async () => { + const result = await buildDeleteDataEntryTransaction( + mockNetworkConfig.horizonUrl, + mockNetworkConfig, + TEST_SOURCE_KEY, + "keyToDelete", + { sequenceNumber: "100" }, + ); + + expect(result.status).toBe("ok"); + if (result.status === "ok") { + expect(typeof result.data).toBe("string"); + expect(result.data.length).toBeGreaterThan(0); + } + }); + + it("deleteData alias functions identically to buildDeleteDataEntryTransaction", async () => { + const result = await deleteData( + mockNetworkConfig.horizonUrl, + mockNetworkConfig, + TEST_SOURCE_KEY, + "keyToDelete", + { sequenceNumber: "100" }, + ); + + expect(result.status).toBe("ok"); + if (result.status === "ok") { + expect(typeof result.data).toBe("string"); + expect(result.data.length).toBeGreaterThan(0); + } + }); + }); + + describe("compose builder integration", () => { + it("fails compose build when addSetDataEntry receives an oversized value", async () => { + const builder = compose(TEST_SOURCE_KEY, mockNetworkConfig, { sequenceNumber: "100" }); + builder.addSetDataEntry("key", "v".repeat(65)); + + const result = await builder.build(); + expect(result.status).toBe("error"); + if (result.status === "error") { + expect(result.error.code).toBe(SorokitErrorCode.VALIDATION); + expect(result.error.message).toContain("64 bytes"); + expect(result.error.message).toContain("actual: 65 bytes"); + } + }); + + it("fails compose build when setData alias receives an oversized multi-byte UTF-8 value", async () => { + const builder = compose(TEST_SOURCE_KEY, mockNetworkConfig, { sequenceNumber: "100" }); + builder.setData("key", "🚀".repeat(17)); // 68 bytes + + const result = await builder.build(); + expect(result.status).toBe("error"); + if (result.status === "error") { + expect(result.error.code).toBe(SorokitErrorCode.VALIDATION); + expect(result.error.message).toContain("64 bytes"); + expect(result.error.message).toContain("actual: 68 bytes"); + } + }); + + it("succeeds compose build when addSetDataEntry receives valid 64-byte value", async () => { + const builder = compose(TEST_SOURCE_KEY, mockNetworkConfig, { sequenceNumber: "100" }); + builder.addSetDataEntry("configKey", "v".repeat(64)); + + const result = await builder.build(); + expect(result.status).toBe("ok"); + if (result.status === "ok") { + expect(typeof result.data).toBe("string"); + } + }); + + it("succeeds compose build with deleteData alias", async () => { + const builder = compose(TEST_SOURCE_KEY, mockNetworkConfig, { sequenceNumber: "100" }); + builder.deleteData("configKey"); + + const result = await builder.build(); + expect(result.status).toBe("ok"); + if (result.status === "ok") { + expect(typeof result.data).toBe("string"); + } + }); + }); + + describe("client.transaction integration", () => { + it("rejects oversized data value via client.transaction.setData early", async () => { + const clientResult = createSorokitClient({ network: "testnet" }); + expect(clientResult.status).toBe("ok"); + if (clientResult.status !== "ok") return; + + const client = clientResult.data; + const result = await client.transaction.setData( + TEST_SOURCE_KEY, + "key", + "a".repeat(70), + ); + + expect(result.status).toBe("error"); + if (result.status === "error") { + expect(result.error.code).toBe(SorokitErrorCode.VALIDATION); + expect(result.error.message).toContain("64 bytes"); + expect(result.error.message).toContain("actual: 70 bytes"); + } + }); + }); +}); diff --git a/src/tests/integration/transaction-flow.test.ts b/src/tests/integration/transaction-flow.test.ts index 462a500d..18d04c10 100644 --- a/src/tests/integration/transaction-flow.test.ts +++ b/src/tests/integration/transaction-flow.test.ts @@ -7,6 +7,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { createSorokitClient } from "../../client/createSorokitClient"; import { SorokitErrorCode } from "../../shared/response"; +import { clearReplayProtection } from "../../transaction/submitTransaction"; // ─── Hoisted mocks ──────────────────────────────────────────────────────────── @@ -108,6 +109,7 @@ function makeHorizonAccount() { describe("integration: buildPayment → sign → submit", () => { beforeEach(() => { + clearReplayProtection(); mockLoadAccount.mockReset(); mockSubmitTransaction.mockReset(); mockTransactionCall.mockReset(); diff --git a/src/tests/router-example.test.ts b/src/tests/router-example.test.ts index 29bc1980..4a58151a 100644 --- a/src/tests/router-example.test.ts +++ b/src/tests/router-example.test.ts @@ -13,6 +13,7 @@ import { Account, Keypair } from "@stellar/stellar-sdk"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { SorokitErrorCode, ok, err } from "../shared/response"; import type { SorokitResult } from "../shared/response"; +import { clearReplayProtection } from "../transaction/submitTransaction"; import { WalletType } from "../wallet/types"; import type { SignTransactionInput, WalletAdapter } from "../wallet/types"; import { @@ -118,6 +119,7 @@ function createClient(): RouterSwapClient { beforeEach(() => { vi.clearAllMocks(); + clearReplayProtection(); mockLoadAccount.mockResolvedValue(new Account(source, "1")); mockStrictSendPaths.mockReturnValue(pathsResponse([pathRecord()])); mockStrictReceivePaths.mockReturnValue(pathsResponse([pathRecord()])); diff --git a/src/tests/transaction-submit-status.test.ts b/src/tests/transaction-submit-status.test.ts index bf7d6643..72e4f113 100644 --- a/src/tests/transaction-submit-status.test.ts +++ b/src/tests/transaction-submit-status.test.ts @@ -15,7 +15,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { Networks } from "@stellar/stellar-sdk"; import { SorokitErrorCode } from "../shared/response"; -import { submitTransaction } from "../transaction/submitTransaction"; +import { clearReplayProtection, submitTransaction } from "../transaction/submitTransaction"; import { getTransactionStatus } from "../transaction/status"; // --------------------------------------------------------------------------- @@ -90,6 +90,7 @@ function makeHorizonError(status: number, message = "Horizon error") { describe("submitTransaction", () => { beforeEach(() => { vi.clearAllMocks(); + clearReplayProtection(); }); describe("successful submission", () => { diff --git a/src/transaction/batchSubmitter.ts b/src/transaction/batchSubmitter.ts index 8bcc4fe7..e8a50619 100644 --- a/src/transaction/batchSubmitter.ts +++ b/src/transaction/batchSubmitter.ts @@ -1,311 +1,265 @@ -/** - * Batch transaction submission with rollback on failure (#589). - * - * Provides atomic all-or-nothing execution of multiple transactions - * with rollback tracking and failure instructions. - */ - import { ok, err, SorokitErrorCode } from "../shared/response"; import type { SorokitResult } from "../shared/response"; +import type { SorokitCache } from "../shared/cache"; import { submitTransaction } from "./submitTransaction"; import type { TransactionResult } from "./types"; -import type { SorokitCache } from "../shared/cache"; -/** - * Status of a single transaction in a batch. - */ -export type TransactionBatchStatus = - | "pending" - | "submitted" - | "success" - | "failed" - | "rolled_back"; +// ─── Types ──────────────────────────────────────────────────────────────────── + +export type BatchTransactionStatusCode = "pending" | "success" | "failed"; +export type TransactionBatchStatus = BatchTransactionStatusCode; -/** - * Individual transaction status in batch submission. - */ export interface BatchTransactionStatus { + /** The original signed XDR submitted */ xdr: string; - status: TransactionBatchStatus; + status: BatchTransactionStatusCode; + /** Populated from TransactionResult.hash on success */ hash?: string; + /** Populated from SorokitError.message on failure */ error?: string; - sequenceNumber?: number; + ledger?: number; + envelopeXdr?: string; + resultXdr?: string; } -export interface RollbackInstruction { - hash?: string; - xdr: string; - reason: string; +export interface BatchSubmissionResult { + /** true iff all transactions succeeded */ + ok: boolean; + summary: "all_success" | "all_failed" | "partial_success"; + /** Count of entries with status === "success" */ + succeeded: number; + /** Count of entries with status === "failed" */ + failed: number; + transactions: BatchTransactionStatus[]; + /** Compatibility metadata for callers that compose a batch before submission. */ + status?: "all_success" | "all_failed" | "partial_success"; + submitted?: number; + atomic?: boolean; + rollbackOnFailure?: boolean; } -/** - * Options for batch submission behavior. - */ -export interface BatchSubmissionOptions { - horizonUrl?: string; - networkPassphrase?: string; - cache?: SorokitCache; +export interface BatchConfig { + horizonUrl: string; + networkPassphrase: string; + /** + * "sequential" (default) processes XDRs one at a time in order. + * "parallel" fires all requests concurrently with Promise.allSettled. + */ mode?: "sequential" | "parallel"; + /** Optional cache passed through to submitTransaction */ + cache?: SorokitCache; + atomic?: boolean; + rollbackOnFailure?: boolean; +} + +export interface BatchSubmissionOptions extends Partial { atomic?: boolean; rollbackOnFailure?: boolean; continueOnError?: boolean; timeout?: number; } -/** - * Result of batch submission containing per-transaction status. - */ -export interface BatchSubmissionResult { - ok: boolean; - summary: "all_success" | "partial_success" | "all_failed"; - submitted: number; - succeeded: number; - failed: number; - status: "all_success" | "partial_success" | "all_failed"; - transactions: BatchTransactionStatus[]; - rollbackInstructions?: RollbackInstruction[]; - atomic: boolean; - rollbackOnFailure: boolean; +export type RetryStrategy = "none" | "retry_failed" | "retry_all"; + +export interface RollbackInstruction { + xdr: string; + hash?: string; + reason: string; +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function applyResult( + entry: BatchTransactionStatus, + result: SorokitResult, +): void { + if (result.status === "ok") { + entry.status = "success"; + entry.hash = result.data.hash; + if (result.data.ledger !== undefined) entry.ledger = result.data.ledger; + if (result.data.envelopeXdr !== undefined) entry.envelopeXdr = result.data.envelopeXdr; + if (result.data.resultXdr !== undefined) entry.resultXdr = result.data.resultXdr; + } else { + entry.status = "failed"; + entry.error = result.error.message; + } +} + +function buildSummary(entries: BatchTransactionStatus[]): BatchSubmissionResult { + const succeeded = entries.filter((e) => e.status === "success").length; + const failed = entries.filter((e) => e.status === "failed").length; + let summary: BatchSubmissionResult["summary"]; + if (failed === 0) { + summary = "all_success"; + } else if (succeeded === 0) { + summary = "all_failed"; + } else { + summary = "partial_success"; + } + return { + ok: summary === "all_success", + summary, + succeeded, + failed, + transactions: entries, + }; } +// ─── Public functions ───────────────────────────────────────────────────────── + /** - * Submit multiple transactions in batch with atomic or best-effort semantics. + * Submit a batch of signed transaction XDRs to the Stellar network. + * + * By default (mode: "sequential") XDRs are submitted one at a time in order — + * the safe default for transactions sharing a source account, where sequence + * numbers must be incremented in order. * - * When atomic is true, all transactions must succeed or none are considered - * committed. On any failure, rollback instructions are provided. + * With mode: "parallel", all XDRs are submitted concurrently via + * Promise.allSettled — suitable for independent transactions from different + * source accounts. * - * @param transactions - Array of transaction XDRs (base64) - * @param options - Batch submission options - * @returns A SorokitResult containing per-transaction status and rollback info + * Failed individual submissions set status: "failed" on the corresponding + * entry and do not abort the rest of the batch. * - * @example - * const batch = await client.transaction.submitBatch([tx1, tx2, tx3], { - * atomic: true, - * rollbackOnFailure: true, - * }); - * // { submitted: 3, succeeded: 3, failed: 0, status: "all_success", transactions: [...] } + * @param transactions - Array of signed transaction XDRs to submit. + * @param config - Horizon URL, network passphrase, mode, and optional cache. + * @returns `ok(BatchSubmissionResult)` on completion (even with partial failures), + * or `err(TX_SUBMIT_FAILED)` when the input is invalid. */ export async function submitBatch( transactions: string[], - options: BatchSubmissionOptions = {}, + config: BatchSubmissionOptions = {}, ): Promise> { + // Input validation if (!Array.isArray(transactions) || transactions.length === 0) { return err( SorokitErrorCode.TX_SUBMIT_FAILED, - "Transactions array must be non-empty", + "transactions must be a non-empty array", ); } - - if ((options.horizonUrl !== undefined && !options.horizonUrl.trim()) || - (options.networkPassphrase !== undefined && !options.networkPassphrase.trim())) { - return err(SorokitErrorCode.TX_SUBMIT_FAILED, "Horizon URL and network passphrase must be non-empty."); + const hasHorizonUrl = typeof config.horizonUrl === "string" && config.horizonUrl.trim().length > 0; + const hasNetworkPassphrase = typeof config.networkPassphrase === "string" && config.networkPassphrase.trim().length > 0; + if (hasHorizonUrl !== hasNetworkPassphrase) { + return err( + SorokitErrorCode.TX_SUBMIT_FAILED, + "config.horizonUrl and config.networkPassphrase must both be non-empty strings when provided", + ); + } + if (config.horizonUrl !== undefined && !hasHorizonUrl || config.networkPassphrase !== undefined && !hasNetworkPassphrase) { + return err(SorokitErrorCode.TX_SUBMIT_FAILED, "Horizon URL and network passphrase must be non-empty strings."); } for (let i = 0; i < transactions.length; i++) { - if (!transactions[i] || typeof transactions[i] !== "string") { - return err( - SorokitErrorCode.INVALID_TRANSACTION, - `Transaction at index ${i} must be a non-empty XDR string`, - ); + if (typeof transactions[i] !== "string" || transactions[i]!.length === 0) { + return err(SorokitErrorCode.INVALID_TRANSACTION, `Transaction at index ${i} must be a non-empty XDR string`); } } - const atomic = options.atomic ?? false; - const rollbackOnFailure = options.rollbackOnFailure ?? false; - - const batchStatus: BatchTransactionStatus[] = transactions.map((xdr) => ({ + const entries: BatchTransactionStatus[] = transactions.map((xdr) => ({ xdr, - status: options.horizonUrl && options.networkPassphrase ? "pending" : "success", + status: hasHorizonUrl ? "pending" : "success", })); - if (options.horizonUrl && options.networkPassphrase) { - const submit = async (xdr: string): Promise> => - submitTransaction(options.horizonUrl!, options.networkPassphrase!, xdr, options.cache); - const results: SorokitResult[] = options.mode === "parallel" - ? await Promise.all(transactions.map(submit)) - : await (async () => { - const entries: SorokitResult[] = []; - for (const xdr of transactions) entries.push(await submit(xdr)); - return entries; - })(); - results.forEach((result, index) => { - const entry = batchStatus[index]!; - if (result.status === "ok") { - entry.status = "success"; - entry.hash = result.data.hash; + const horizonUrl = config.horizonUrl ?? ""; + const networkPassphrase = config.networkPassphrase ?? ""; + + if (hasHorizonUrl && config.mode === "parallel") { + // Submit all concurrently and collect results + const results = await Promise.allSettled( + entries.map((entry) => + submitTransaction( + horizonUrl, + networkPassphrase, + entry.xdr, + config.cache, + ), + ), + ); + for (let i = 0; i < entries.length; i++) { + const entry = entries[i] as BatchTransactionStatus; + const settled = results[i] as PromiseSettledResult>; + if (settled.status === "fulfilled") { + applyResult(entry, settled.value); } else { entry.status = "failed"; - entry.error = result.error.message; + entry.error = + settled.reason instanceof Error + ? (settled.reason as Error).message + : String(settled.reason); } - }); + } + } else if (hasHorizonUrl) { + // Sequential (default): submit one at a time in order + for (const entry of entries) { + const result = await submitTransaction( + horizonUrl, + networkPassphrase, + entry.xdr, + config.cache, + ); + applyResult(entry, result); + } } - const succeeded = batchStatus.filter((entry) => entry.status === "success").length; - const failed = batchStatus.length - succeeded; - const summary = failed === 0 ? "all_success" : succeeded === 0 ? "all_failed" : "partial_success"; - - const result: BatchSubmissionResult = { - submitted: transactions.length, - ok: failed === 0, - summary, - succeeded, - failed, - status: summary, - transactions: batchStatus, - atomic, - rollbackOnFailure, - }; - - return ok(result); + const summary = buildSummary(entries); + return ok({ ...summary, status: summary.summary, submitted: transactions.length, atomic: config.atomic ?? false, rollbackOnFailure: config.rollbackOnFailure ?? false }); } /** - * Track the status of individual transactions in a batch. - * - * @param batchResult - The result from submitBatch - * @param transactionIndex - Index of the transaction to check - * @returns A SorokitResult containing the transaction status + * Returns true only when every transaction in the batch succeeded. + * Stellar does not natively support atomic multi-transaction batches, + * so "atomic" here means "all or nothing from the caller's perspective". */ -export function getTransactionStatus( - batchResult: BatchSubmissionResult, - transactionIndex: number, -): SorokitResult { - if (!batchResult || typeof batchResult !== "object") { - return err( - SorokitErrorCode.VALIDATION, - "Batch result must be a valid object", - ); - } - - if ( - transactionIndex < 0 || - transactionIndex >= batchResult.transactions.length - ) { - return err( - SorokitErrorCode.VALIDATION, - `Transaction index ${transactionIndex} out of range`, - ); - } - - const transaction = batchResult.transactions[transactionIndex]; - if (!transaction) { - return err( - SorokitErrorCode.VALIDATION, - `Transaction index ${transactionIndex} out of range`, - ); - } - - return ok(transaction); +export function wasAtomicExecuted(result: BatchSubmissionResult): SorokitResult { + return ok((result.status ?? result.summary) === "all_success"); } /** - * Generate rollback instructions for a failed batch submission. - * - * @param batchResult - The failed batch submission result - * @returns A SorokitResult containing rollback steps + * Recommends a retry strategy based on the submission outcome. + * - "none" — all succeeded; nothing to retry. + * - "retry_failed" — partial success; only failed entries should be retried. + * - "retry_all" — all failed; re-submit the entire batch. */ -export function generateRollbackInstructions( - batchResult: BatchSubmissionResult, -): SorokitResult { - if (!batchResult || typeof batchResult !== "object") { - return err( - SorokitErrorCode.VALIDATION, - "Batch result must be a valid object", - ); - } - - const instructions: RollbackInstruction[] = []; - - if (batchResult.summary !== "partial_success") return ok(instructions); - - const successfulTxs = batchResult.transactions.filter( - (tx) => tx.status === "success" || tx.status === "submitted", - ); - const failedTxs = batchResult.transactions.filter( - (tx) => tx.status === "failed", - ); - - for (const tx of successfulTxs) instructions.push({ - ...(tx.hash !== undefined ? { hash: tx.hash } : {}), - xdr: tx.xdr, - reason: `Compensate for partial batch failure${failedTxs[0]?.error ? `: ${failedTxs[0].error}` : ""}`, - }); - - return ok(instructions); +export function suggestRetryStrategy(result: BatchSubmissionResult): SorokitResult<{ strategy: RetryStrategy; failedIndices: number[] }> { + const summary = result.status ?? result.summary; + const strategy: RetryStrategy = summary === "all_success" + ? "none" + : summary === "all_failed" ? "retry_all" : "retry_failed"; + const failedIndices = result.transactions.flatMap((tx, index) => tx.status === "failed" ? [index] : []); + return ok({ strategy, failedIndices }); } /** - * Check if a batch submission completed atomically. - * - * @param batchResult - The batch submission result - * @returns A SorokitResult indicating atomic completion + * Returns one RollbackInstruction per *successful* transaction in the batch + * when at least one transaction failed (partial failure scenario). + * If all transactions succeeded or all failed, returns an empty array + * (nothing to roll back in either clean case). */ -export function wasAtomicExecuted( - batchResult: BatchSubmissionResult, -): SorokitResult { - if (!batchResult || typeof batchResult !== "object") { - return err( - SorokitErrorCode.VALIDATION, - "Batch result must be a valid object", - ); - } - - return ok((batchResult.status ?? batchResult.summary) === "all_success"); +export function generateRollbackInstructions( + result: BatchSubmissionResult, +): SorokitResult { + if ((result.status ?? result.summary) !== "partial_success") return ok([]); + return ok(result.transactions + .filter((tx) => tx.status === "success") + .map((tx) => { + const instruction: RollbackInstruction = { + xdr: tx.xdr, + reason: + "Transaction succeeded but batch partially failed — manual reversal may be required.", + }; + if (tx.hash !== undefined) instruction.hash = tx.hash; + return instruction; + })); } -/** - * Determine retry strategy for a failed batch. - * - * @param batchResult - The failed batch result - * @returns A SorokitResult containing retry recommendations - */ -export function suggestRetryStrategy( - batchResult: BatchSubmissionResult, -): SorokitResult<{ - strategy: "none" | "retry_all" | "retry_failed"; - retryable: boolean; - failedIndices: number[]; - recommendations: string[]; -}> { - if (!batchResult || typeof batchResult !== "object") { - return err( - SorokitErrorCode.VALIDATION, - "Batch result must be a valid object", - ); - } - - const failedIndices = batchResult.transactions - .map((tx, i) => (tx.status === "failed" ? i : -1)) - .filter((i) => i >= 0); - - const recommendations: string[] = []; - const summary = batchResult.status ?? batchResult.summary; - - if (failedIndices.length === 0) { - return ok({ - strategy: summary === "all_failed" ? "retry_all" : summary === "partial_success" ? "retry_failed" : "none", - retryable: summary !== "all_success", - failedIndices: [], - recommendations: summary === "all_failed" - ? ["Retry the entire batch."] - : ["No failed transactions to retry."], - }); - } - - if (batchResult.atomic) { - recommendations.push("Atomic batch failed; resubmit entire batch or none."); - recommendations.push("Verify all prerequisite transactions completed."); - } else { - recommendations.push( - `Retry ${failedIndices.length} failed transaction(s) independently.`, - ); - recommendations.push("Increase timeout if errors are time-related."); +/** Read the status for a transaction in a completed batch. */ +export function getTransactionStatus( + result: BatchSubmissionResult, + transactionIndex: number, +): SorokitResult { + const transaction = result?.transactions?.[transactionIndex]; + if (!transaction) { + return err(SorokitErrorCode.VALIDATION, `Transaction index ${transactionIndex} out of range`); } - - return ok({ - strategy: summary === "all_failed" ? "retry_all" : "retry_failed", - retryable: true, - failedIndices, - recommendations, - }); + return ok(transaction); } diff --git a/src/transaction/compose.ts b/src/transaction/compose.ts index b20102dd..78b25ed4 100644 --- a/src/transaction/compose.ts +++ b/src/transaction/compose.ts @@ -49,6 +49,8 @@ import { } from "./claimableBalance"; import { estimateFee } from "./estimateFee"; import type { FeeEstimate } from "./estimateFee"; +import { buildDataEntryOperation } from "../account/dataEntries"; +import type { DataEntryValue } from "./dataEntry"; import type { MemoType, SetOptionsParams } from "./types"; import type { ClaimPredicateInput } from "./types"; @@ -167,6 +169,10 @@ export interface ComposeBuilder { addClaimClaimableBalance(balanceId: string): ComposeBuilder; addBumpSequence(bumpToSequence: string): ComposeBuilder; addSetOptions(params: SetOptionsParams): ComposeBuilder; + addSetDataEntry(key: string, value: DataEntryValue): ComposeBuilder; + setData(key: string, value: DataEntryValue): ComposeBuilder; + addDeleteDataEntry(key: string): ComposeBuilder; + deleteData(key: string): ComposeBuilder; addMemo(memo: string, memoType?: MemoType): ComposeBuilder; /** Enable automatic fee estimation; `{ mode: "high" }` prefers the fast tier. */ estimateFee(mode: FeeEstimateMode): ComposeBuilder; @@ -657,6 +663,26 @@ export function compose( return builder; }, + addSetDataEntry(key, value) { + const result = buildDataEntryOperation(key, value); + push({ kind: "setDataEntry", build: () => result }); + return builder; + }, + + setData(key, value) { + return this.addSetDataEntry(key, value); + }, + + addDeleteDataEntry(key) { + const result = buildDataEntryOperation(key, null); + push({ kind: "deleteDataEntry", build: () => result }); + return builder; + }, + + deleteData(key) { + return this.addDeleteDataEntry(key); + }, + addMemo(memo, memoType) { memoState = { memo, ...(memoType !== undefined && { memoType }) }; return builder; diff --git a/src/transaction/dataEntry.ts b/src/transaction/dataEntry.ts index 1672e952..549eaa5e 100644 --- a/src/transaction/dataEntry.ts +++ b/src/transaction/dataEntry.ts @@ -3,16 +3,104 @@ import { ok, err, SorokitErrorCode } from "../shared/response"; import type { SorokitResult } from "../shared/response"; import type { ResolvedNetworkConfig } from "../shared/types"; import { toMessage } from "../shared"; -import { buildDataEntryOperation } from "../account/dataEntries"; +import { + buildDataEntryOperation, + validateDataEntryKey, + validateDataEntryValue, + getDataByteLength, + MAX_DATA_ENTRY_KEY_BYTES, + MAX_DATA_ENTRY_VALUE_BYTES, +} from "../account/dataEntries"; +import type { DataEntryValue } from "../account/dataEntries"; + +export interface DataEntryTransactionOptions { + sequenceNumber?: string; + fee?: string; + timeout?: number; +} + +export { + validateDataEntryKey, + validateDataEntryValue, + getDataByteLength, + MAX_DATA_ENTRY_KEY_BYTES, + MAX_DATA_ENTRY_VALUE_BYTES, +}; +export type { DataEntryValue }; + +async function build( + horizonUrl: string, + networkConfig: ResolvedNetworkConfig, + sourcePublicKey: string, + key: string, + value: DataEntryValue | null, + options: DataEntryTransactionOptions = {}, +): Promise> { + // Validate key and value upfront to reject oversized inputs early + const keyResult = validateDataEntryKey(key); + if (keyResult.status === "error") return keyResult; + const valueResult = validateDataEntryValue(value); + if (valueResult.status === "error") return valueResult; -export interface DataEntryTransactionOptions { sequenceNumber?: string; fee?: string; timeout?: number; } -async function build(horizonUrl: string, networkConfig: ResolvedNetworkConfig, sourcePublicKey: string, key: string, value: string | null, options: DataEntryTransactionOptions = {}): Promise> { try { - const account = options.sequenceNumber !== undefined ? new Account(sourcePublicKey, options.sequenceNumber) : await new Horizon.Server(horizonUrl).loadAccount(sourcePublicKey); - const operation = buildDataEntryOperation(key, value); if (operation.status === "error") return operation; - const tx = new TransactionBuilder(account, { fee: options.fee ?? BASE_FEE, networkPassphrase: networkConfig.networkPassphrase }).addOperation(operation.data).setTimeout(options.timeout ?? 30).build(); + const account = + options.sequenceNumber !== undefined + ? new Account(sourcePublicKey, options.sequenceNumber) + : await new Horizon.Server(horizonUrl).loadAccount(sourcePublicKey); + const operation = buildDataEntryOperation(key, value); + if (operation.status === "error") return operation; + const tx = new TransactionBuilder(account, { + fee: options.fee ?? BASE_FEE, + networkPassphrase: networkConfig.networkPassphrase, + }) + .addOperation(operation.data) + .setTimeout(options.timeout ?? 30) + .build(); return ok(tx.toXDR()); - } catch (cause) { return err(SorokitErrorCode.TX_BUILD_FAILED, `Failed to build data-entry transaction: ${toMessage(cause)}`, cause); } + } catch (cause) { + return err( + SorokitErrorCode.TX_BUILD_FAILED, + `Failed to build data-entry transaction: ${toMessage(cause)}`, + cause, + ); + } +} + +/** + * Build a transaction to set or update an account data entry. + * Validates that key and value conform to Stellar XDR encoding limits (max 64 bytes). + */ +export function buildSetDataEntryTransaction( + horizonUrl: string, + networkConfig: ResolvedNetworkConfig, + sourcePublicKey: string, + key: string, + value: DataEntryValue, + options?: DataEntryTransactionOptions, +): Promise> { + return build(horizonUrl, networkConfig, sourcePublicKey, key, value, options); } -export function buildSetDataEntryTransaction(horizonUrl: string, networkConfig: ResolvedNetworkConfig, sourcePublicKey: string, key: string, value: string, options?: DataEntryTransactionOptions): Promise> { return build(horizonUrl, networkConfig, sourcePublicKey, key, value, options); } -export function buildDeleteDataEntryTransaction(horizonUrl: string, networkConfig: ResolvedNetworkConfig, sourcePublicKey: string, key: string, options?: DataEntryTransactionOptions): Promise> { return build(horizonUrl, networkConfig, sourcePublicKey, key, null, options); } + +/** + * Convenience alias for setting account data (#762). + */ +export const setData = buildSetDataEntryTransaction; + +/** + * Build a transaction to delete an account data entry. + */ +export function buildDeleteDataEntryTransaction( + horizonUrl: string, + networkConfig: ResolvedNetworkConfig, + sourcePublicKey: string, + key: string, + options?: DataEntryTransactionOptions, +): Promise> { + return build(horizonUrl, networkConfig, sourcePublicKey, key, null, options); +} + +/** + * Convenience alias for deleting account data. + */ +export const deleteData = buildDeleteDataEntryTransaction; + diff --git a/src/transaction/index.ts b/src/transaction/index.ts index 58280c49..8b145d4f 100644 --- a/src/transaction/index.ts +++ b/src/transaction/index.ts @@ -731,8 +731,18 @@ export type { TransactionDelta, } from "./xdrEncodingTypes"; -export { buildSetDataEntryTransaction, buildDeleteDataEntryTransaction } from "./dataEntry"; -export type { DataEntryTransactionOptions } from "./dataEntry"; +export { + buildSetDataEntryTransaction, + buildDeleteDataEntryTransaction, + setData, + deleteData, + validateDataEntryKey, + validateDataEntryValue, + getDataByteLength, + MAX_DATA_ENTRY_KEY_BYTES, + MAX_DATA_ENTRY_VALUE_BYTES, +} from "./dataEntry"; +export type { DataEntryTransactionOptions, DataEntryValue } from "./dataEntry"; // ─── Transaction signing coordination (#586) ────────────────────────────────── export { diff --git a/src/transaction/timeLocks.ts b/src/transaction/timeLocks.ts index 94efc70f..f924dcbf 100644 --- a/src/transaction/timeLocks.ts +++ b/src/transaction/timeLocks.ts @@ -1,22 +1,18 @@ -/** - * Time-locked transaction scheduling and execution (#587). - * - * Provides time-lock support for scheduling transactions to execute at - * specific future times using Stellar time bounds (ledger close time). - */ - import { - Account, - BASE_FEE, Horizon, - Operation, TransactionBuilder, + Operation, + BASE_FEE, + Account, } from "@stellar/stellar-sdk"; -import { err, ok, SorokitErrorCode } from "../shared/response"; +import { ok, err, SorokitErrorCode } from "../shared/response"; import type { SorokitResult } from "../shared/response"; +import { toMessage } from "../shared"; import { DEFAULT_TX_TIMEOUT_SECONDS } from "../shared/constants"; import type { ResolvedNetworkConfig } from "../shared/types"; +// ─── Types ──────────────────────────────────────────────────────────────────── + /** * Inclusive time bounds for a Stellar transaction. * Both fields are Unix timestamps in seconds. @@ -28,40 +24,66 @@ export interface TimeBounds { maxTime: number; } -/** Status of a time-locked transaction evaluated against a timestamp. */ +/** + * Status of a time-locked transaction evaluated against a given timestamp. + */ export type TimeLockExecutionStatus = - | "pending" - | "executable" - | "expired" - | "invalid"; + | "pending" // current time is before minTime + | "executable" // current time is within [minTime, maxTime] + | "expired" // current time is after maxTime (and maxTime !== 0) + | "invalid"; // time bounds are logically invalid (e.g. minTime > maxTime) -/** Options for building a time-locked transaction. */ +/** + * Options for buildTimeLockTransaction. + */ export interface TimeLockTransactionOptions { + /** + * Time bounds to enforce on the transaction. + */ timeBounds: TimeBounds; - /** Current on-chain sequence; when omitted it is loaded from Horizon. */ + /** + * When provided, this sequence number is used directly and no Horizon fetch + * is performed. Useful when the caller has already loaded the account. + * + * Must be the current on-chain sequence number of the source account + * (TransactionBuilder will increment it by 1 automatically). + */ sequence?: string; /** Override the transaction fee in stroops. Defaults to BASE_FEE. */ fee?: string; } +// ─── Helpers ────────────────────────────────────────────────────────────────── + function isValidTimeBounds(timeBounds: TimeBounds): boolean { if ( !timeBounds || typeof timeBounds !== "object" || !Number.isFinite(timeBounds.minTime) || - !Number.isFinite(timeBounds.maxTime) || - timeBounds.minTime < 0 || - timeBounds.maxTime < 0 + !Number.isFinite(timeBounds.maxTime) ) { return false; } - return timeBounds.maxTime === 0 || timeBounds.minTime <= timeBounds.maxTime; + if (timeBounds.minTime < 0 || timeBounds.maxTime < 0) return false; + // maxTime === 0 means no upper bound, so no ordering constraint applies + if (timeBounds.maxTime !== 0 && timeBounds.minTime > timeBounds.maxTime) { + return false; + } + return true; } +// ─── Public functions ───────────────────────────────────────────────────────── + /** * Validate that a TimeBounds object is logically consistent. * - * @returns `true` when bounds are finite, non-negative and ordered. + * @param timeBounds - The time bounds to validate. + * @returns `true` when the bounds are valid, `false` otherwise. + * + * @example + * validateTimeBounds({ minTime: 0, maxTime: 0 }); // true — no bounds + * validateTimeBounds({ minTime: 1000, maxTime: 2000 }); // true + * validateTimeBounds({ minTime: 3000, maxTime: 2000 }); // false — inverted */ export function validateTimeBounds(timeBounds: TimeBounds): boolean { return isValidTimeBounds(timeBounds); @@ -69,6 +91,17 @@ export function validateTimeBounds(timeBounds: TimeBounds): boolean { /** * Evaluate the execution status of a time-locked transaction at a given moment. + * + * @param timeBounds - The time bounds set on the transaction. + * @param nowSeconds - Current time as Unix seconds. Defaults to `Date.now() / 1000`. + * @returns The execution status at the given time. + * + * @example + * const status = getTimeLockedTransactionStatus( + * { minTime: 1000, maxTime: 2000 }, + * 1500, + * ); + * // status === "executable" */ export function getTimeLockedTransactionStatus( timeBounds: TimeBounds, @@ -78,6 +111,7 @@ export function getTimeLockedTransactionStatus( const now = nowSeconds ?? Math.floor(Date.now() / 1000); if (!Number.isFinite(now)) return "invalid"; + if (timeBounds.minTime > 0 && now < timeBounds.minTime) return "pending"; if (timeBounds.maxTime !== 0 && now > timeBounds.maxTime) return "expired"; return "executable"; @@ -86,10 +120,30 @@ export function getTimeLockedTransactionStatus( /** * Build an unsigned time-locked transaction XDR. * - * The source account sequence is loaded from Horizon unless supplied in - * options. The transaction uses a bump-sequence operation and Stellar - * timebounds. Since Stellar encodes a zero max time as no upper bound, a - * safety deadline is added when only a minimum time is requested. + * The transaction uses a `bumpSequence` operation as a lightweight no-op that + * is valid on any Stellar account. Time bounds are enforced via the transaction's + * built-in timebounds, which Horizon validates at submission time. + * + * The function fetches the real account sequence number from Horizon unless a + * `sequence` is supplied in options, avoiding `tx_bad_seq` on submission. + * + * @param horizonUrl - Base URL of the Horizon server. + * @param networkConfig - Resolved network configuration (passphrase etc.). + * @param sourcePublicKey - G-address of the transaction source account. + * @param options - Time bounds, optional pre-fetched sequence, optional fee. + * @returns `ok(xdr)` — unsigned transaction XDR ready for signing, + * or `error(TX_BUILD_FAILED)` on any build error. + * + * @example + * const result = await buildTimeLockTransaction( + * "https://horizon-testnet.stellar.org", + * networkConfig, + * sourcePublicKey, + * { timeBounds: { minTime: futureTimestamp, maxTime: 0 } }, + * ); + * if (result.status === "ok") { + * // sign and submit result.data + * } */ export async function buildTimeLockTransaction( horizonUrl: string, @@ -97,47 +151,64 @@ export async function buildTimeLockTransaction( sourcePublicKey: string, options: TimeLockTransactionOptions, ): Promise> { - const timeBounds = options?.timeBounds; + const { timeBounds, sequence, fee } = options; + if (!isValidTimeBounds(timeBounds)) { return err( SorokitErrorCode.TX_BUILD_FAILED, - "Invalid time bounds: values must be finite, non-negative, and ordered.", + `Invalid time bounds: minTime (${timeBounds.minTime}) must not exceed maxTime (${timeBounds.maxTime}) when maxTime is non-zero.`, ); } try { - let account: Account; - if (options.sequence !== undefined) { - account = new Account(sourcePublicKey, options.sequence); + let sourceAccount: Account; + + if (sequence !== undefined) { + // Use the caller-supplied sequence directly — no Horizon round trip + sourceAccount = new Account(sourcePublicKey, sequence); } else { + // Fetch the real account sequence from Horizon const server = new Horizon.Server(horizonUrl); const loaded = await server.loadAccount(sourcePublicKey); - account = new Account(sourcePublicKey, loaded.sequenceNumber()); + sourceAccount = new Account(sourcePublicKey, loaded.sequenceNumber()); } - let builder = new TransactionBuilder(account, { - fee: options.fee ?? BASE_FEE, + // Use setTimebounds to encode both minTime and maxTime. + // When maxTime is 0 (no upper bound), we still set a deadline via + // setTimeout so Horizon does not reject the transaction for missing timebounds. + // setTimebounds and setTimeout both set the max_time field and must NOT + // be combined — use setTimebounds exclusively when maxTime is non-zero. + let builder = new TransactionBuilder(sourceAccount, { + fee: fee ?? BASE_FEE, networkPassphrase: networkConfig.networkPassphrase, }).addOperation( - Operation.bumpSequence({ bumpTo: account.sequenceNumber() }), + Operation.bumpSequence({ + bumpTo: sourceAccount.sequenceNumber(), + }), ); if (timeBounds.maxTime !== 0) { + // maxTime is set — use setTimebounds to encode both bounds builder = builder.setTimebounds(timeBounds.minTime, timeBounds.maxTime); } else if (timeBounds.minTime !== 0) { + // Only minTime is set — use setTimebounds(min, 0) to encode the lower bound, + // then apply a safety deadline via setTimeout for Horizon compatibility builder = builder.setTimebounds( timeBounds.minTime, Math.floor(Date.now() / 1000) + DEFAULT_TX_TIMEOUT_SECONDS, ); } else { + // No bounds at all — just set a standard transaction timeout builder = builder.setTimeout(DEFAULT_TX_TIMEOUT_SECONDS); } - return ok(builder.build().toXDR()); + const tx = builder.build(); + + return ok(tx.toXDR()); } catch (cause) { return err( SorokitErrorCode.TX_BUILD_FAILED, - `Failed to build time-lock transaction: ${cause instanceof Error ? cause.message : String(cause)}`, + `Failed to build time-lock transaction: ${toMessage(cause)}`, cause, ); }