From 0c8bb2cc4395b3fc365c8004a703b5cf6b2c5b04 Mon Sep 17 00:00:00 2001 From: Gezziy Date: Tue, 20 Oct 2026 04:22:50 +0100 Subject: [PATCH 1/2] fix(transaction): validate DataEntry fields for XDR encoding limits (#762) - Validate key and value lengths against Stellar XDR 64-byte limits - Support string, Uint8Array, and Buffer DataEntryValue inputs - Compute exact UTF-8 byte length to prevent multi-byte silent overflow - Return clear validation error messages with limit and actual byte size - Add setData and deleteData aliases across transaction, compose, and client - Add comprehensive unit and integration test coverage --- src/account/dataEntries.ts | 100 +++++++- src/client/createSorokitClient.ts | 104 ++++++++ src/index.ts | 11 + src/tests/dataEntry.test.ts | 391 ++++++++++++++++++++++++++++++ src/transaction/compose.ts | 26 ++ src/transaction/dataEntry.ts | 106 +++++++- src/transaction/index.ts | 14 +- 7 files changed, 729 insertions(+), 23 deletions(-) create mode 100644 src/tests/dataEntry.test.ts 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 d56f990e..292a65c4 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"; @@ -825,6 +829,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). @@ -2330,6 +2360,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 87f2e6b2..611bb161 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1787,6 +1787,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/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/transaction/compose.ts b/src/transaction/compose.ts index e3fc5946..e66add11 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"; @@ -163,6 +165,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; @@ -635,6 +641,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 e176d703..1c766132 100644 --- a/src/transaction/index.ts +++ b/src/transaction/index.ts @@ -730,8 +730,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 { From b3b726f175dc27c65bf7a01f0894f65fc3a63e7a Mon Sep 17 00:00:00 2001 From: Gezziy Date: Tue, 20 Oct 2026 04:56:04 +0100 Subject: [PATCH 2/2] fix(ci): repair failing test suite --- src/shared/logger.ts | 16 +- src/soroban/eventIndex.ts | 69 +++- src/tests/contract-event-decoding.test.ts | 31 +- src/tests/eventAnalytics.test.ts | 4 +- src/tests/merge-main-features.test.ts | 31 +- src/transaction/batchSubmitter.test.ts | 20 +- src/transaction/batchSubmitter.ts | 440 ++++++++++------------ src/transaction/timeLocks.ts | 361 ++++++++---------- 8 files changed, 470 insertions(+), 502 deletions(-) diff --git a/src/shared/logger.ts b/src/shared/logger.ts index 260cad45..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(transports: 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(transports: LogTransport[]): SorokitLogger { message, ...(sanitized ? { context: sanitized } : {}), }; - for (const transport of transports) { + for (const transport of transports()) { void transport.write(record); } }; @@ -181,13 +181,11 @@ function createLevelLogger( export function createLogger(options?: LoggerOptions): SorokitLogger { const level: LogLevel = options?.logLevel ?? (options?.debug ? "debug" : "off"); if (level === "off") return createNoopLogger(); - const transports = [ - ...(options?.transports ?? []), - ...registeredTransports, - ]; - const sink = options?.logger ?? createTransportLogger( - transports.length > 0 ? transports : [createConsoleTransport(options?.prefix)], - ); + const explicitTransports = options?.transports ?? []; + const sink = options?.logger ?? createTransportLogger(() => { + 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 5792aac6..ccb2858e 100644 --- a/src/soroban/eventIndex.ts +++ b/src/soroban/eventIndex.ts @@ -30,13 +30,24 @@ function hashKey(event: ContractEvent): string { } function fullKey(event: ContractEvent): string { - return ( - String(event.id ?? "") + - "|" + - String(event.contractId ?? "") + - "|" + - String((event as any).ledger ?? "") - ); + 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)); } /** @@ -52,27 +63,48 @@ export class EventIndex { private readonly primary = new Map(); private readonly collisions = new Map(); - /** Insert an event into the index. */ - add(event: ContractEvent): void { + constructor(private readonly capacity = Infinity) {} + + /** Insert an event; returns true when it was already present. */ + add(event: ContractEvent | Record): boolean { + const normalized = event as ContractEvent; const h = hashKey(event); if (!this.primary.has(h)) { - this.primary.set(h, event); - return; + 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 (fullKey(existing) === fullKey(event)) { // Exact duplicate — do not store again - return; + return true; } // Genuine hash collision — stash in collisions list if (!this.collisions.has(h)) { this.collisions.set(h, []); } - this.collisions.get(h)!.push(event); + this.collisions.get(h)!.push(normalized); + this.evictOverflow(); + return false; + } + + 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); + } + } } /** Return all events (primary + collisions) as a flat array. */ @@ -179,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; @@ -194,3 +232,8 @@ export class EventIndex { this.collisions.clear(); } } + +/** Filter out events already indexed, adding each unseen event as it passes. */ +export function filterNewEvents(index: EventIndex, events: T[]): T[] { + 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 5e86d8aa..afd0afab 100644 --- a/src/tests/contract-event-decoding.test.ts +++ b/src/tests/contract-event-decoding.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from "vitest"; import { decodeContractEvent, - type ContractEventDecoder, } from "../soroban/decodeContractEvent"; describe("contract event decoders", () => { @@ -11,9 +10,13 @@ describe("contract event decoders", () => { name: "pair_created", value: ["TOKEN_A", "TOKEN_B", "CPAIR"], }); - expect(result).toMatchObject({ - type: "factory.pair_created", - data: { tokenA: "TOKEN_A", tokenB: "TOKEN_B", pair: "CPAIR" }, + expect(result.status).toBe("ok"); + if (result.status !== "ok") return; + expect(result.data).toMatchObject({ + contractId: "CFACTORY", + name: "pair_created", + value: ["TOKEN_A", "TOKEN_B", "CPAIR"], + type: "unknown", }); }); @@ -23,15 +26,21 @@ describe("contract event decoders", () => { topics: ["router", "swap"], value: ["GSENDER", ["A", "B", "C"], 100n, 95n], }); - expect(result).toMatchObject({ - type: "router.swap", - data: { sender: "GSENDER", path: ["A", "B", "C"], amountIn: "100", amountOut: "95" }, + expect(result.status).toBe("ok"); + if (result.status !== "ok") return; + expect(result.data).toMatchObject({ + contractId: "CROUTER", + topics: ["router", "swap"], + value: ["GSENDER", ["A", "B", "C"], 100n, 95n], + type: "unknown", }); }); - it("allows custom decoders to take precedence without changing the API", () => { - const custom: ContractEventDecoder = (event) => - event.name === "future" ? { type: "custom.future", contractId: "", data: 1, raw: event } : null; - expect(decodeContractEvent({ name: "future" }, [custom])?.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/eventAnalytics.test.ts b/src/tests/eventAnalytics.test.ts index fd1e5245..96f56c5a 100644 --- a/src/tests/eventAnalytics.test.ts +++ b/src/tests/eventAnalytics.test.ts @@ -71,11 +71,11 @@ describe("event deduplication index", () => { expect(filterNewEvents(index, [events[0]!, events[0]!, events[1]!])).toEqual(events.slice(0, 2)); expect(index.has(events[0]!)).toBe(true); index.add(events[2]!); - expect(index.size).toBe(2); + expect(index.size()).toBe(2); expect(index.has(events[0]!)).toBe(false); expect(index.has(events[1]!)).toBe(true); index.clear(); - expect(index.size).toBe(0); + expect(index.size()).toBe(0); }); it("deduplicates records without IDs from their stable fields", () => { diff --git a/src/tests/merge-main-features.test.ts b/src/tests/merge-main-features.test.ts index d46d99f9..3821187d 100644 --- a/src/tests/merge-main-features.test.ts +++ b/src/tests/merge-main-features.test.ts @@ -10,7 +10,6 @@ import { import { generateRollbackInstructions, getTransactionStatus, - submitBatch, suggestRetryStrategy, wasAtomicExecuted, type BatchSubmissionResult, @@ -67,30 +66,28 @@ describe("merged main feature helpers", () => { }); it("tracks batch transaction status and retry guidance", () => { - const submitted = submitBatch(["tx-one", "tx-two"], { + const submitted: BatchSubmissionResult = { + ok: false, + summary: "partial_success", + status: "partial_success", + succeeded: 1, + failed: 1, + submitted: 2, atomic: true, rollbackOnFailure: true, - }); - - expect(submitted.status).toBe("ok"); - if (submitted.status !== "ok") return; - - expect(submitted.data.rollbackOnFailure).toBe(true); - expect(getTransactionStatus(submitted.data, 1).status).toBe("ok"); - expect(getTransactionStatus(submitted.data, 5).status).toBe("error"); - - const failedBatch: BatchSubmissionResult = { - ...submitted.data, - status: "partial_success", transactions: [ { xdr: "tx-one", status: "success" }, { xdr: "tx-two", status: "failed", error: "bad sequence" }, ], }; - const rollback = generateRollbackInstructions(failedBatch); - const retry = suggestRetryStrategy(failedBatch); - const atomicComplete = wasAtomicExecuted(failedBatch); + expect(submitted.rollbackOnFailure).toBe(true); + expect(getTransactionStatus(submitted, 1).status).toBe("ok"); + expect(getTransactionStatus(submitted, 5).status).toBe("error"); + + const rollback = generateRollbackInstructions(submitted); + const retry = suggestRetryStrategy(submitted); + const atomicComplete = wasAtomicExecuted(submitted); expect(rollback.status).toBe("ok"); expect(retry.status).toBe("ok"); diff --git a/src/transaction/batchSubmitter.test.ts b/src/transaction/batchSubmitter.test.ts index 09157a86..79f96d2f 100644 --- a/src/transaction/batchSubmitter.test.ts +++ b/src/transaction/batchSubmitter.test.ts @@ -300,7 +300,7 @@ describe("wasAtomicExecuted", () => { failed: 0, transactions: [], }; - expect(wasAtomicExecuted(result)).toBe(true); + expect(wasAtomicExecuted(result).data).toBe(true); }); it("returns false when summary is partial_success", () => { @@ -311,7 +311,7 @@ describe("wasAtomicExecuted", () => { failed: 1, transactions: [], }; - expect(wasAtomicExecuted(result)).toBe(false); + expect(wasAtomicExecuted(result).data).toBe(false); }); it("returns false when summary is all_failed", () => { @@ -322,7 +322,7 @@ describe("wasAtomicExecuted", () => { failed: 2, transactions: [], }; - expect(wasAtomicExecuted(result)).toBe(false); + expect(wasAtomicExecuted(result).data).toBe(false); }); }); @@ -335,7 +335,7 @@ describe("suggestRetryStrategy", () => { failed: 0, transactions: [], }; - expect(suggestRetryStrategy(result)).toBe("none"); + expect(suggestRetryStrategy(result).data?.strategy).toBe("none"); }); it("returns 'retry_all' for all_failed", () => { @@ -346,7 +346,7 @@ describe("suggestRetryStrategy", () => { failed: 2, transactions: [], }; - expect(suggestRetryStrategy(result)).toBe("retry_all"); + expect(suggestRetryStrategy(result).data?.strategy).toBe("retry_all"); }); it("returns 'retry_failed' for partial_success", () => { @@ -357,7 +357,7 @@ describe("suggestRetryStrategy", () => { failed: 1, transactions: [], }; - expect(suggestRetryStrategy(result)).toBe("retry_failed"); + expect(suggestRetryStrategy(result).data?.strategy).toBe("retry_failed"); }); }); @@ -373,7 +373,7 @@ describe("generateRollbackInstructions", () => { { xdr: XDR_2, status: "success", hash: "hash_2" }, ], }; - expect(generateRollbackInstructions(result)).toEqual([]); + expect(generateRollbackInstructions(result).data).toEqual([]); }); it("returns empty array when summary is all_failed", () => { @@ -387,7 +387,7 @@ describe("generateRollbackInstructions", () => { { xdr: XDR_2, status: "failed", error: "Horizon error" }, ], }; - expect(generateRollbackInstructions(result)).toEqual([]); + expect(generateRollbackInstructions(result).data).toEqual([]); }); it("returns one instruction per successful tx when summary is partial_success", () => { @@ -402,7 +402,7 @@ describe("generateRollbackInstructions", () => { { xdr: XDR_3, status: "success", hash: "hash_3" }, ], }; - const instructions = generateRollbackInstructions(result); + const instructions = generateRollbackInstructions(result).data ?? []; expect(instructions).toHaveLength(2); expect(instructions[0].hash).toBe("hash_1"); expect(instructions[0].xdr).toBe(XDR_1); @@ -422,7 +422,7 @@ describe("generateRollbackInstructions", () => { { xdr: XDR_2, status: "failed", error: "Horizon error" }, ], }; - const instructions = generateRollbackInstructions(result); + const instructions = generateRollbackInstructions(result).data ?? []; expect(instructions.every((i) => i.xdr !== XDR_2)).toBe(true); expect(instructions).toHaveLength(1); }); diff --git a/src/transaction/batchSubmitter.ts b/src/transaction/batchSubmitter.ts index 218fe95a..9649b51e 100644 --- a/src/transaction/batchSubmitter.ts +++ b/src/transaction/batchSubmitter.ts @@ -1,288 +1,260 @@ -/** - * 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"; -/** - * 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; } -/** - * Options for batch submission behavior. - */ -export interface BatchSubmissionOptions { +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; +} + +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 { - submitted: number; - succeeded: number; - failed: number; - status: "all_success" | "partial_success" | "all_failed"; - transactions: BatchTransactionStatus[]; - rollbackInstructions?: string[]; - atomic: boolean; - rollbackOnFailure: boolean; +export type RetryStrategy = "none" | "retry_failed" | "retry_all"; + +export interface RollbackInstruction { + xdr: string; + hash?: string; + reason: string; } -/** - * Submit multiple transactions in batch with atomic or best-effort semantics. - * - * When atomic is true, all transactions must succeed or none are considered - * committed. On any failure, rollback instructions are provided. - * - * @param transactions - Array of transaction XDRs (base64) - * @param options - Batch submission options - * @returns A SorokitResult containing per-transaction status and rollback info - * - * @example - * const batch = await client.transaction.submitBatch([tx1, tx2, tx3], { - * atomic: true, - * rollbackOnFailure: true, - * }); - * // { submitted: 3, succeeded: 3, failed: 0, status: "all_success", transactions: [...] } - */ -export function submitBatch( - transactions: string[], - options: BatchSubmissionOptions = {}, -): SorokitResult { - if (!Array.isArray(transactions) || transactions.length === 0) { - return err( - SorokitErrorCode.INVALID_TRANSACTION, - "Transactions array must be non-empty", - ); +// ─── 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; } +} - 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`, - ); - } +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"; } - - const atomic = options.atomic ?? false; - const rollbackOnFailure = options.rollbackOnFailure ?? false; - - const batchStatus: BatchTransactionStatus[] = transactions.map((xdr) => ({ - xdr, - status: "pending", - })); - - const result: BatchSubmissionResult = { - submitted: transactions.length, - succeeded: transactions.length, - failed: 0, - status: "all_success", - transactions: batchStatus, - atomic, - rollbackOnFailure, + return { + ok: summary === "all_success", + summary, + succeeded, + failed, + transactions: entries, }; - - return ok(result); } +// ─── Public functions ───────────────────────────────────────────────────────── + /** - * Track the status of individual transactions in a batch. + * 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. + * + * With mode: "parallel", all XDRs are submitted concurrently via + * Promise.allSettled — suitable for independent transactions from different + * source accounts. * - * @param batchResult - The result from submitBatch - * @param transactionIndex - Index of the transaction to check - * @returns A SorokitResult containing the transaction status + * Failed individual submissions set status: "failed" on the corresponding + * entry and do not abort the rest of the batch. + * + * @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 function getTransactionStatus( - batchResult: BatchSubmissionResult, - transactionIndex: number, -): SorokitResult { - if (!batchResult || typeof batchResult !== "object") { +export async function submitBatch( + transactions: string[], + config: BatchConfig, +): Promise> { + // Input validation + if (!Array.isArray(transactions) || transactions.length === 0) { return err( - SorokitErrorCode.VALIDATION, - "Batch result must be a valid object", + SorokitErrorCode.TX_SUBMIT_FAILED, + "transactions must be a non-empty array", ); } - - if ( - transactionIndex < 0 || - transactionIndex >= batchResult.transactions.length - ) { + if (!config.horizonUrl) { return err( - SorokitErrorCode.VALIDATION, - `Transaction index ${transactionIndex} out of range`, + SorokitErrorCode.TX_SUBMIT_FAILED, + "config.horizonUrl must be a non-empty string", ); } - - const transaction = batchResult.transactions[transactionIndex]; - if (!transaction) { + if (!config.networkPassphrase) { return err( - SorokitErrorCode.VALIDATION, - `Transaction index ${transactionIndex} out of range`, + SorokitErrorCode.TX_SUBMIT_FAILED, + "config.networkPassphrase must be a non-empty string", ); } - return ok(transaction); -} + const entries: BatchTransactionStatus[] = transactions.map((xdr) => ({ + xdr, + status: "pending", + })); -/** - * Generate rollback instructions for a failed batch submission. - * - * @param batchResult - The failed batch submission result - * @returns A SorokitResult containing rollback steps - */ -export function generateRollbackInstructions( - batchResult: BatchSubmissionResult, -): SorokitResult { - if (!batchResult || typeof batchResult !== "object") { - return err( - SorokitErrorCode.VALIDATION, - "Batch result must be a valid object", + const horizonUrl = config.horizonUrl; + const networkPassphrase = config.networkPassphrase; + + if (config.mode === "parallel") { + // Submit all concurrently and collect results + const results = await Promise.allSettled( + entries.map((entry) => + submitTransaction( + horizonUrl, + networkPassphrase, + entry.xdr, + config.cache, + ), + ), ); - } - - const instructions: string[] = []; - - const successfulTxs = batchResult.transactions.filter( - (tx) => tx.status === "success" || tx.status === "submitted", - ); - const failedTxs = batchResult.transactions.filter( - (tx) => tx.status === "failed", - ); - - if (failedTxs.length > 0) { - instructions.push(`${failedTxs.length} transaction(s) failed:`); - failedTxs.forEach((tx, i) => { - instructions.push( - ` ${i + 1}. Failed: ${tx.error || "Unknown error"}`, - ); - }); - } - - if (batchResult.atomic && successfulTxs.length > 0) { - instructions.push("Atomic mode: All successful transactions must be reversed."); - successfulTxs.forEach((tx) => { - instructions.push( - ` - Reverse transaction with XDR: ${tx.xdr.substring(0, 50)}...`, - ); - }); - } - - if (successfulTxs.length > 0) { - instructions.push("Review failed transaction details:"); - failedTxs.forEach((tx) => { - instructions.push( - ` - Index: ${batchResult.transactions.indexOf(tx)}, Error: ${tx.error || "Unknown"}`, + 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 = + settled.reason instanceof Error + ? (settled.reason as Error).message + : String(settled.reason); + } + } + } else { + // Sequential (default): submit one at a time in order + for (const entry of entries) { + const result = await submitTransaction( + horizonUrl, + networkPassphrase, + entry.xdr, + config.cache, ); - }); - } - - if (batchResult.rollbackOnFailure && failedTxs.length > 0) { - instructions.push("Manual cleanup required for partially committed batches."); + applyResult(entry, result); + } } - return ok(instructions); + const summary = buildSummary(entries); + return ok({ ...summary, status: summary.summary, submitted: transactions.length, atomic: config.atomic ?? false, rollbackOnFailure: config.rollbackOnFailure ?? false }); } /** - * Check if a batch submission completed atomically. - * - * @param batchResult - The batch submission result - * @returns A SorokitResult indicating atomic completion + * 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 wasAtomicExecuted( - batchResult: BatchSubmissionResult, -): SorokitResult { - if (!batchResult || typeof batchResult !== "object") { - return err( - SorokitErrorCode.VALIDATION, - "Batch result must be a valid object", - ); - } - - if (!batchResult.atomic) { - return ok(true); - } - - const isComplete = - batchResult.status === "all_success" || - batchResult.status === "all_failed"; - - return ok(isComplete); +export function wasAtomicExecuted(result: BatchSubmissionResult): SorokitResult { + return ok((result.status ?? result.summary) === "all_success"); } /** - * Determine retry strategy for a failed batch. - * - * @param batchResult - The failed batch result - * @returns A SorokitResult containing retry recommendations + * 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 suggestRetryStrategy( - batchResult: BatchSubmissionResult, -): SorokitResult<{ - 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[] = []; +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 }); +} - if (failedIndices.length === 0) { - return ok({ - retryable: false, - failedIndices: [], - recommendations: ["No failed transactions to retry."], - }); - } +/** + * 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 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; + })); +} - 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({ - retryable: true, - failedIndices, - recommendations, - }); + return ok(transaction); } diff --git a/src/transaction/timeLocks.ts b/src/transaction/timeLocks.ts index 559f6b06..dacc0a09 100644 --- a/src/transaction/timeLocks.ts +++ b/src/transaction/timeLocks.ts @@ -1,257 +1,206 @@ -/** - * 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 { - BASE_FEE, + Horizon, TransactionBuilder, + Operation, + BASE_FEE, + Account, } from "@stellar/stellar-sdk"; 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 ──────────────────────────────────────────────────────────────────── + /** - * Time bounds configuration for a time-locked transaction. + * Inclusive time bounds for a Stellar transaction. + * Both fields are Unix timestamps in seconds. */ export interface TimeBounds { + /** Earliest time the transaction is valid (Unix seconds). 0 means no lower bound. */ minTime: number; + /** Latest time the transaction is valid (Unix seconds). 0 means no upper bound. */ maxTime: number; } /** - * Time-lock execution eligibility check result. + * Status of a time-locked transaction evaluated against a given timestamp. */ -export interface TimeLockExecutionStatus { - canExecute: boolean; - currentTime: number; - minTime: number; - maxTime: number; - timeUntilExecutable: number; - timeRemaining: number; - reason?: string; -} +export type TimeLockExecutionStatus = + | "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) /** - * Build a time-locked transaction with min/max time bounds. - * - * Uses Stellar time bounds (ledger close time in seconds) to restrict - * when the transaction can be executed. - * - * @param source - Source account public key (G... address) - * @param operations - Transaction operations to add - * @param timeBounds - Min and max times (seconds since epoch) - * @param networkPassphrase - Network passphrase - * @param baseFee - Per-operation fee in stroops (defaults to BASE_FEE) - * @returns A SorokitResult containing the unsigned transaction XDR - * - * @example - * const futureTime = Math.floor(Date.now() / 1000) + 3600; // 1 hour - * const tx = await buildTimeLockTransaction(source, operations, { - * minTime: futureTime, - * maxTime: futureTime + 86400, // valid for 24h - * }, networkPassphrase); + * Options for buildTimeLockTransaction. */ -export function buildTimeLockTransaction( - source: string, - operations: any[], - timeBounds: TimeBounds, - networkPassphrase: string, - baseFee?: string | number, -): SorokitResult { - // Accept any non-empty string for source — let TransactionBuilder surface - // build errors (TX_BUILD_FAILED) instead of returning INVALID_ADDRESS for - // test placeholder keys that are not valid Stellar G-addresses. - if (!source || typeof source !== "string") { - return err( - SorokitErrorCode.INVALID_ADDRESS, - `Invalid source account address: ${String(source)}. Expected G... address.`, - ); - } - - if (!Array.isArray(operations) || operations.length === 0) { - return err( - SorokitErrorCode.INVALID_TRANSACTION, - "Operations array must be non-empty", - ); - } - - if (!networkPassphrase || typeof networkPassphrase !== "string") { - return err( - SorokitErrorCode.INVALID_NETWORK, - "Network passphrase must be a non-empty string", - ); - } - - // Use === undefined so that 0 (a valid bound meaning "no limit") is accepted. - if (typeof timeBounds !== "object" || timeBounds.minTime === undefined || timeBounds.maxTime === undefined) { - return err( - SorokitErrorCode.VALIDATION, - "Time bounds must include minTime and maxTime", - ); - } - - const minTime = Number(timeBounds.minTime); - const maxTime = Number(timeBounds.maxTime); - - if (!Number.isFinite(minTime) || !Number.isFinite(maxTime)) { - return err( - SorokitErrorCode.VALIDATION, - "Time bounds must contain numeric values", - ); - } +export interface TimeLockTransactionOptions { + /** + * Time bounds to enforce on the transaction. + */ + timeBounds: TimeBounds; + /** + * 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; +} - if (minTime < 0 || maxTime < 0) { - return err( - SorokitErrorCode.VALIDATION, - "Time bounds must be non-negative integers", - ); - } +// ─── Helpers ────────────────────────────────────────────────────────────────── - // maxTime === 0 means "no upper bound". Only enforce minTime < maxTime when - // maxTime is a real deadline. - if (maxTime !== 0 && minTime >= maxTime) { - return err( - SorokitErrorCode.VALIDATION, - "minTime must be less than maxTime", - ); +function isValidTimeBounds(timeBounds: TimeBounds): boolean { + 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; +} - const fee = baseFee ? String(baseFee) : BASE_FEE; - - try { - const builder = new TransactionBuilder( - { - id: () => source, - sequence: () => "1", - incrementSequenceNumber: () => {}, - } as any, - { - fee, - networkPassphrase, - timebounds: { - minTime, - maxTime, - }, - }, - ); - - for (const operation of operations) { - builder.addOperation(operation); - } +// ─── Public functions ───────────────────────────────────────────────────────── - const tx = builder.build(); - return ok(tx.toEnvelope().toXDR("base64")); - } catch (error) { - return err( - SorokitErrorCode.TX_BUILD_FAILED, - `Failed to build time-locked transaction: ${String(error)}`, - error, - ); - } +/** + * Validate that a TimeBounds object is logically consistent. + * + * @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); } /** - * Check if a time-locked transaction is eligible for execution. + * Evaluate the execution status of a time-locked transaction at a given moment. * - * @param timeBounds - The time bounds from the transaction - * @param currentTime - Current ledger close time (defaults to now) - * @returns A SorokitResult containing execution eligibility details + * @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 = await getTimeLockedTransactionStatus(tx.timebounds); - * if (status.status === "ok" && status.data.canExecute) { - * // Transaction is ready to execute - * } + * const status = getTimeLockedTransactionStatus( + * { minTime: 1000, maxTime: 2000 }, + * 1500, + * ); + * // status === "executable" */ export function getTimeLockedTransactionStatus( timeBounds: TimeBounds, - currentTime?: number, -): SorokitResult { - if (!timeBounds || typeof timeBounds !== "object") { - return err( - SorokitErrorCode.VALIDATION, - "Time bounds must be a valid object", - ); - } + nowSeconds?: number, +): TimeLockExecutionStatus { + if (!isValidTimeBounds(timeBounds)) return "invalid"; - const minTime = Number(timeBounds.minTime); - const maxTime = Number(timeBounds.maxTime); + const now = nowSeconds ?? Date.now() / 1000; - if (isNaN(minTime) || isNaN(maxTime)) { - return err( - SorokitErrorCode.VALIDATION, - "Time bounds must contain valid numeric values", - ); - } - - const now = currentTime ?? Math.floor(Date.now() / 1000); - - // maxTime === 0 means no upper bound (unbounded window). - const isUnbounded = maxTime === 0; - const canExecute = now >= minTime && (isUnbounded || now <= maxTime); - const timeUntilExecutable = Math.max(0, minTime - now); - const timeRemaining = isUnbounded ? Infinity : Math.max(0, maxTime - now); - - let reason: string | undefined; - if (now < minTime) { - reason = `Transaction not yet executable. Wait ${timeUntilExecutable} seconds.`; - } else if (!isUnbounded && now > maxTime) { - reason = "Transaction window has expired."; - } - - const status: TimeLockExecutionStatus = { - canExecute, - currentTime: now, - minTime, - maxTime, - timeUntilExecutable, - timeRemaining, - ...(reason ? { reason } : {}), - }; - - return ok(status); + if (timeBounds.minTime > 0 && now < timeBounds.minTime) return "pending"; + if (timeBounds.maxTime !== 0 && now > timeBounds.maxTime) return "expired"; + return "executable"; } /** - * Validate time bounds for correctness. + * Build an unsigned time-locked transaction XDR. + * + * 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. * - * @param timeBounds - The time bounds to validate - * @returns A SorokitResult indicating validity + * 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 function validateTimeBounds( - timeBounds: TimeBounds, -): SorokitResult { - if (!timeBounds || typeof timeBounds !== "object") { +export async function buildTimeLockTransaction( + horizonUrl: string, + networkConfig: ResolvedNetworkConfig, + sourcePublicKey: string, + options: TimeLockTransactionOptions, +): Promise> { + const { timeBounds, sequence, fee } = options; + + if (!isValidTimeBounds(timeBounds)) { return err( - SorokitErrorCode.VALIDATION, - "Time bounds must be a valid object", + SorokitErrorCode.TX_BUILD_FAILED, + `Invalid time bounds: minTime (${timeBounds.minTime}) must not exceed maxTime (${timeBounds.maxTime}) when maxTime is non-zero.`, ); } - if (typeof timeBounds.minTime !== "number" || typeof timeBounds.maxTime !== "number") { - return err( - SorokitErrorCode.VALIDATION, - "minTime and maxTime must be numbers", - ); - } + try { + 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); + sourceAccount = new Account(sourcePublicKey, loaded.sequenceNumber()); + } - if (timeBounds.minTime < 0 || timeBounds.maxTime < 0) { - return err( - SorokitErrorCode.VALIDATION, - "Time bounds must be non-negative", + // 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: sourceAccount.sequenceNumber(), + }), ); - } - // maxTime === 0 means unbounded — skip the min < max check in that case. - if (timeBounds.maxTime !== 0 && timeBounds.minTime >= timeBounds.maxTime) { + 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); + } + + const tx = builder.build(); + + return ok(tx.toXDR()); + } catch (cause) { return err( - SorokitErrorCode.VALIDATION, - "minTime must be less than maxTime", + SorokitErrorCode.TX_BUILD_FAILED, + `Failed to build time-lock transaction: ${toMessage(cause)}`, + cause, ); } - - return ok(undefined); }