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
151 changes: 151 additions & 0 deletions src/accountDataManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
/**
* Typed CRUD manager for Stellar account data entries.
*
* Wraps `Operation.manageData()` with validation for the protocol's 64-byte
* key/value limits and 64-entry-per-account cap, so callers can store custom
* metadata alongside SDK state without hand-rolling raw manageData calls.
*/

import {
Account,
Horizon,
Keypair,
Operation,
TransactionBuilder,
BASE_FEE,
} from "@stellar/stellar-sdk";
import type { AccountDataMap } from "./types.js";
import { DataEntryValidationError } from "./errors.js";

/** Stellar protocol limit for both data entry keys and values, in bytes. */
const MAX_DATA_ENTRY_BYTES = 64;

/** Stellar protocol limit on the number of data entries per account. */
const MAX_DATA_ENTRIES = 64;

/** Result of submitting a manageData transaction. */
export interface TransactionResult {
txHash: string;
}

/** Configuration for {@link AccountDataManager}. */
export interface AccountDataManagerConfig {
/** Horizon server URL. */
horizonUrl: string;
/** Stellar network passphrase. */
networkPassphrase: string;
}

function byteLength(value: string): number {
return Buffer.byteLength(value, "utf8");
}

/**
* Typed CRUD manager for account data entries, built on top of
* `Operation.manageData()` and `Server.loadAccount().data_attr`.
*/
export class AccountDataManager {
private readonly server: Horizon.Server;
private readonly networkPassphrase: string;

constructor(config: AccountDataManagerConfig) {
this.server = new Horizon.Server(config.horizonUrl);
this.networkPassphrase = config.networkPassphrase;
}

/**
* Set (create or update) a data entry on `accountId`.
*
* @throws DataEntryValidationError if the key/value exceed 64 bytes, or if
* the account already has 64 entries and `key` is new.
*/
async set(
accountId: string,
key: string,
value: string,
signerSecret: string,
): Promise<TransactionResult> {
await this.validateEntry(accountId, key, value);
return this.submitManageData(accountId, key, value, signerSecret);
}

/**
* Fetch the current value of `key` on `accountId`, or `null` if absent.
*/
async get(accountId: string, key: string): Promise<string | null> {
const entries = await this.list(accountId);
return Object.prototype.hasOwnProperty.call(entries, key) ? entries[key]! : null;
}

/**
* Delete a data entry by submitting `manageData` with a `null` value.
*/
async delete(
accountId: string,
key: string,
signerSecret: string,
): Promise<TransactionResult> {
return this.submitManageData(accountId, key, null, signerSecret);
}

/**
* Return all data entries currently stored on `accountId`, decoded from
* base64 to UTF-8 strings.
*/
async list(accountId: string): Promise<AccountDataMap> {
const account = await this.server.loadAccount(accountId);
const raw = account.data_attr as Record<string, string> | undefined;
const result: AccountDataMap = {};
for (const [key, base64Value] of Object.entries(raw ?? {})) {
result[key] = Buffer.from(base64Value, "base64").toString("utf8");
}
return result;
}

private async validateEntry(accountId: string, key: string, value: string): Promise<void> {
if (byteLength(key) > MAX_DATA_ENTRY_BYTES) {
throw new DataEntryValidationError(
`key "${key}" exceeds ${MAX_DATA_ENTRY_BYTES} bytes`,
{ key },
);
}
if (byteLength(value) > MAX_DATA_ENTRY_BYTES) {
throw new DataEntryValidationError(
`value for key "${key}" exceeds ${MAX_DATA_ENTRY_BYTES} bytes`,
{ key },
);
}

const existing = await this.list(accountId);
const isNewKey = !Object.prototype.hasOwnProperty.call(existing, key);
if (isNewKey && Object.keys(existing).length >= MAX_DATA_ENTRIES) {
throw new DataEntryValidationError(
`account ${accountId} already has ${MAX_DATA_ENTRIES} data entries`,
{ accountId },
);
}
}

private async submitManageData(
accountId: string,
key: string,
value: string | null,
signerSecret: string,
): Promise<TransactionResult> {
const keypair = Keypair.fromSecret(signerSecret);
const loaded = await this.server.loadAccount(accountId);
const sourceAccount = new Account(loaded.accountId(), loaded.sequenceNumber());

const tx = new TransactionBuilder(sourceAccount, {
fee: BASE_FEE,
networkPassphrase: this.networkPassphrase,
})
.addOperation(Operation.manageData({ name: key, value: value ?? null }))
.setTimeout(30)
.build();

tx.sign(keypair);
const result = await this.server.submitTransaction(tx);
return { txHash: result.hash };
}
}
39 changes: 38 additions & 1 deletion src/auditLogger.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { truncateAddress } from "./utils.js";
import { decodeXDR } from "./xdrDecoder.js";
import type { XDRType, DecodedXDR } from "./types.js";
import type { XDRType, DecodedXDR, SplitAuditEntry } from "./types.js";

export interface AuditEntry {
timestamp: number;
Expand All @@ -22,6 +22,7 @@ const MIN_XDR_LENGTH = 40;

export class AuditLogger {
private readonly sink: (entry: AuditEntry) => void;
private readonly splitAuditTrails = new Map<string, SplitAuditEntry[]>();

constructor(sink: (entry: AuditEntry) => void) {
this.sink = sink;
Expand Down Expand Up @@ -101,4 +102,40 @@ export class AuditLogger {

this.log(entry);
}

/**
* Record a `SplitAuditEntry` for a single settled leg of a multi-recipient
* split payment. Writes immediately to the configured sink (as an
* `AuditEntry`) and to the in-memory per-invoice trail returned by
* {@link exportSplitAuditTrail}.
*/
recordSplitLeg(entry: SplitAuditEntry): void {
const trail = this.splitAuditTrails.get(entry.invoiceId) ?? [];
trail.push(entry);
this.splitAuditTrails.set(entry.invoiceId, trail);

this.log({
timestamp: entry.settledAt,
method: "split_leg_settled",
params: this.sanitize({
invoiceId: entry.invoiceId,
legIndex: entry.legIndex,
recipientId: entry.recipientId,
assetCode: entry.assetCode,
amount: entry.amount.toString(),
operationId: entry.operationId,
ledgerSequence: entry.ledgerSequence,
}),
success: true,
durationMs: 0,
});
}

/**
* Return all recorded `SplitAuditEntry` records for `invoiceId`, in the
* order they were settled.
*/
async exportSplitAuditTrail(invoiceId: string): Promise<SplitAuditEntry[]> {
return [...(this.splitAuditTrails.get(invoiceId) ?? [])];
}
}
20 changes: 20 additions & 0 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ import { PluginRegistry } from "./plugin.js";
import type { SdkPlugin } from "./plugin.js";
import { checkRPCHealth } from "./health.js";
import { Deduplicator } from "./dedup.js";
import { SorobanFeatureDetector } from "./sorobanFeatureDetector.js";
import type { SorobanFeatureFlags } from "./types.js";
import { verifyBatchPayments } from "./batchVerifier.js";
import { type HealthCheckResult, HealthCheckTimeoutError } from "./types.js";
import type {
Expand Down Expand Up @@ -652,6 +654,7 @@ export class StellarSplitClient extends TypedEventEmitter<SplitClientEventMap> {
private _advancedCircuitBreaker: AdvancedCircuitBreaker | null = null;
/** Optimistic UI cache for Invoice reads during a pending pay() call. */
private _optimisticCache: OptimisticCache<Invoice> | null = null;
private _sorobanFeatureDetector: SorobanFeatureDetector;
private _shutdownInProgress = false;
private _pluginsDestroyed = false;
private _runtimeShutdownPromise: Promise<void> | null = null;
Expand Down Expand Up @@ -806,6 +809,13 @@ export class StellarSplitClient extends TypedEventEmitter<SplitClientEventMap> {

this._stateMachine = new InvoiceStateMachine(config.stateMachine);

// Soroban protocol feature detection (Issue #529): probe once at startup;
// the detector caches internally and re-probes after its staleness window.
this._sorobanFeatureDetector = new SorobanFeatureDetector({ rpcUrl: primaryUrl });
this._sorobanFeatureDetector.detect().catch(() => {
// Best-effort startup probe; getSorobanFeatures() will retry on demand.
});

if (
!this._rpcClient &&
Array.isArray(config.rpcUrl) &&
Expand Down Expand Up @@ -1213,6 +1223,16 @@ export class StellarSplitClient extends TypedEventEmitter<SplitClientEventMap> {
* Performs a health check of the client's RPC connection and contract.
* Resolves with status information or throws HealthCheckTimeoutError if taking > 5000ms.
*/
/**
* Return the current Soroban protocol feature flags, detected once at
* startup and re-probed automatically after the detector's staleness
* window (default 1 hour). Emits `protocolUpgradeDetected` on the
* underlying detector when a re-probe observes a version change.
*/
async getSorobanFeatures(): Promise<SorobanFeatureFlags> {
return this._sorobanFeatureDetector.detect();
}

async healthCheck(): Promise<HealthCheckResult> {
const start = Date.now();
try {
Expand Down
39 changes: 38 additions & 1 deletion src/complianceExporter.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Invoice } from "./types.js";
import type { Invoice, SplitAuditEntry } from "./types.js";

/** A single row in the compliance audit export. */
export interface ComplianceExportRecord {
Expand Down Expand Up @@ -142,3 +142,40 @@ function csvEscape(value: string): string {
}
return value;
}

// ---------------------------------------------------------------------------
// Per-split audit trail export (Issue #531)
// ---------------------------------------------------------------------------

/** Stable, ordered list of CSV column names for the split audit trail export. */
export const SPLIT_AUDIT_CSV_COLUMNS = [
"invoiceId",
"legIndex",
"recipientId",
"assetCode",
"amount",
"operationId",
"ledgerSequence",
"settledAt",
] as const;

/**
* Produces a CSV export of per-split-leg audit entries (as recorded by
* `AuditLogger.recordSplitLeg`), suitable for inclusion in compliance reports.
*/
export function exportSplitAuditTrail(entries: SplitAuditEntry[]): string {
const header = SPLIT_AUDIT_CSV_COLUMNS.join(",");
const rows = entries.map((e) =>
[
e.invoiceId,
String(e.legIndex),
e.recipientId,
e.assetCode,
String(e.amount),
e.operationId,
String(e.ledgerSequence),
String(e.settledAt),
].join(","),
);
return [header, ...rows].join("\n");
}
23 changes: 23 additions & 0 deletions src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1699,3 +1699,26 @@ export class ClassifiedHorizonError extends StellarSplitError {
export function isClassifiedHorizonError(err: unknown): err is ClassifiedHorizonError {
return err instanceof ClassifiedHorizonError;
}

// ---------------------------------------------------------------------------
// Account Data Entry errors
// ---------------------------------------------------------------------------

/**
* Thrown when an account data entry key/value exceeds the 64-byte Stellar
* protocol limit, or when adding a new key would exceed the 64-entry cap.
*/
export class DataEntryValidationError extends StellarSplitError {
readonly reason: string;

constructor(reason: string, context?: Record<string, unknown>) {
super(`Account data entry validation failed: ${reason}`, "DATA_ENTRY_VALIDATION_ERROR", context);
this.name = "DataEntryValidationError";
this.reason = reason;
Object.setPrototypeOf(this, new.target.prototype);
}
}

export function isDataEntryValidationError(err: unknown): err is DataEntryValidationError {
return err instanceof DataEntryValidationError;
}
55 changes: 55 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1112,3 +1112,58 @@ export type {
ScValPrimitive,
ContractStorageExporterOptions,
} from "./diagnostics/ContractStorageExporter.js";

// ---------------------------------------------------------------------------
// #528 — AccountDataManager: typed CRUD for account data entries
// ---------------------------------------------------------------------------

export { AccountDataManager } from "./accountDataManager.js";
export type {
AccountDataManagerConfig,
TransactionResult as AccountDataTransactionResult,
} from "./accountDataManager.js";
export type { AccountDataEntry, AccountDataMap } from "./types.js";
export {
DataEntryValidationError,
isDataEntryValidationError,
} from "./errors.js";

// ---------------------------------------------------------------------------
// #529 — SorobanFeatureDetector: protocol upgrade / feature flag detection
// ---------------------------------------------------------------------------

export { SorobanFeatureDetector } from "./sorobanFeatureDetector.js";
export type {
SorobanFeatureDetectorConfig,
SorobanFeatureDetectorEventMap,
} from "./sorobanFeatureDetector.js";
export type { SorobanFeatureFlags } from "./types.js";

// ---------------------------------------------------------------------------
// #530 — StreamDeduplicator: paging-token-based stream event dedup
// ---------------------------------------------------------------------------

export { StreamDeduplicator } from "./streamDeduplicator.js";
export type {
StreamDeduplicatorOptions,
StreamDeduplicatorEventMap,
} from "./streamDeduplicator.js";
export {
InMemoryDedupTokenStore,
setDefaultDedupTokenStore,
saveDedupTokens,
loadDedupTokens,
} from "./snapshot.js";
export type { DedupTokenStore } from "./snapshot.js";

// ---------------------------------------------------------------------------
// #531 — Per-Split Audit Log Emitter
// ---------------------------------------------------------------------------

export { AuditLogger } from "./auditLogger.js";
export type { AuditEntry } from "./auditLogger.js";
export type { SplitAuditEntry } from "./types.js";
export {
exportSplitAuditTrail,
SPLIT_AUDIT_CSV_COLUMNS,
} from "./complianceExporter.js";
Loading