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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 88 additions & 12 deletions src/account/dataEntries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,94 @@ import type { SorokitResult } from "../shared/response";
import { isNotFoundError, toMessage } from "../shared";

export type AccountDataEntries = Record<string, string>;
const MAX_BYTES = 64;
function bytes(value: string): number { return new TextEncoder().encode(value).byteLength; }
function validateKey(key: string): SorokitResult<string> { 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<string | null> { 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<string> {
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<DataEntryValue | null> {
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<SorokitResult<AccountDataEntries>> {
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<any> {
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<any> {
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,
};

104 changes: 104 additions & 0 deletions src/client/createSorokitClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -837,6 +841,32 @@ export interface SorokitClient {
params: SetOptionsParams,
timeoutMs?: number,
): Promise<SorokitResult<string>>;
buildSetDataEntry(
sourcePublicKey: string,
key: string,
value: DataEntryValue,
options?: DataEntryTransactionOptions,
timeoutMs?: number,
): Promise<SorokitResult<string>>;
setData(
sourcePublicKey: string,
key: string,
value: DataEntryValue,
options?: DataEntryTransactionOptions,
timeoutMs?: number,
): Promise<SorokitResult<string>>;
buildDeleteDataEntry(
sourcePublicKey: string,
key: string,
options?: DataEntryTransactionOptions,
timeoutMs?: number,
): Promise<SorokitResult<string>>;
deleteData(
sourcePublicKey: string,
key: string,
options?: DataEntryTransactionOptions,
timeoutMs?: number,
): Promise<SorokitResult<string>>;
/**
* Start a fluent multi-operation transaction builder bound to this client's
* network config. Call `.build()` on the returned builder for the XDR (#542).
Expand Down Expand Up @@ -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);
Expand Down
11 changes: 11 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
10 changes: 5 additions & 5 deletions src/shared/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<LogLevel, "off">, message: string, meta?: StructuredLogMeta): void => {
const sanitized = sanitizeLogMeta(meta);
const record: StructuredLogRecord = {
Expand All @@ -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);
}
};
Expand Down Expand Up @@ -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);
}
Expand Down
Loading
Loading