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
42 changes: 40 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 2 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,6 @@
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"test": "vitest run test/client.test.ts test/retryPolicy.test.ts test/property-roundtrip.test.ts test/property-address.test.ts test/property-deadline.test.ts test/property-invoice-params.test.ts test/property-client-invariants.test.ts",
"test": "vitest run test/client.test.ts test/retryPolicy.test.ts test/resilience.test.ts",
"test": "vitest run test/client.test.ts test/multiTenant.test.ts",
"test": "vitest run test/client.test.ts test/profiler.test.ts",
"test": "vitest run test/client.test.ts test/retryPolicy.test.ts",
"test:ui": "vitest run test/ui/",
"test:all": "vitest run",
Expand All @@ -57,7 +53,8 @@
"@noble/curves": "^2.2.0",
"@stellar/freighter-api": "^3.1.0",
"@stellar/stellar-sdk": "^13.3.0",
"@walletconnect/sign-client": "^2.23.9"
"@walletconnect/sign-client": "^2.23.9",
"ajv": "^8.20.0"
},
"devDependencies": {
"@opentelemetry/api": "^1.9.0",
Expand Down
113 changes: 113 additions & 0 deletions src/accountStateDetector.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/**
* Account freeze / lock state detector.
*
* An issuer can revoke authorization on a trustline (freezing it) or an
* account can be left in a state where authorization can never be granted
* again (`AUTH_IMMUTABLE`). Sending payments to or from such accounts fails
* on submission in ways that are hard to distinguish from other errors.
* `detectLockState` inspects the recipient's trustline and the issuer's
* account flags up front so callers can surface a clear error before
* building a transaction.
*/

import { Asset, Horizon } from "@stellar/stellar-sdk";
import { AccountFrozenError, AccountLockedError } from "./errors.js";
import type { AccountLockState } from "./types.js";

/**
* Inspect an account's trustline for `asset` and the issuer's account flags
* to determine whether the account is frozen or permanently locked out of
* authorization.
*
* @param server - Horizon server instance.
* @param accountId - Stellar address to inspect.
* @param asset - Asset whose trustline is being checked. Native XLM is
* never frozen or locked, so it always returns a fully
* authorized state.
* @returns The account's lock state.
*/
export async function detectLockState(
server: Horizon.Server,
accountId: string,
asset: Asset,
): Promise<AccountLockState> {
if (asset.isNative()) {
return {
isFrozen: false,
isLocked: false,
trustlineAuthorized: true,
revocableByIssuer: false,
};
}

const account = await server.loadAccount(accountId);
const balances = account.balances as Array<
Horizon.HorizonApi.BalanceLineAsset | Horizon.HorizonApi.BalanceLineNative | Horizon.HorizonApi.BalanceLineLiquidityPool
>;

const trustline = balances.find(
(b): b is Horizon.HorizonApi.BalanceLineAsset =>
(b.asset_type === "credit_alphanum4" || b.asset_type === "credit_alphanum12") &&
b.asset_code === asset.getCode() &&
b.asset_issuer === asset.getIssuer(),
);

if (!trustline) {
return {
isFrozen: false,
isLocked: false,
trustlineAuthorized: false,
reason: "no_trustline",
revocableByIssuer: false,
};
}

const trustlineAuthorized = trustline.is_authorized;
const isFrozen = !trustline.is_authorized && !trustline.is_authorized_to_maintain_liabilities;

let issuerFlags: Horizon.HorizonApi.Flags | undefined;
try {
const issuerAccount = await server.loadAccount(asset.getIssuer());
issuerFlags = issuerAccount.flags;
} catch {
issuerFlags = undefined;
}

const revocableByIssuer = issuerFlags?.auth_revocable ?? false;
const isLocked = !trustlineAuthorized && issuerFlags?.auth_immutable === true;

const result: AccountLockState = {
isFrozen,
isLocked,
trustlineAuthorized,
revocableByIssuer,
};
if (isFrozen) {
result.reason = "frozen";
} else if (isLocked) {
result.reason = "immutable";
}
return result;
}

/**
* Convenience wrapper around {@link detectLockState} that throws when the
* account is frozen or locked, instead of returning a report.
*
* @throws {AccountFrozenError} When the trustline has been frozen by the issuer.
* @throws {AccountLockedError} When the account can never be authorized again.
*/
export async function assertAccountUnlocked(
server: Horizon.Server,
accountId: string,
asset: Asset,
): Promise<AccountLockState> {
const state = await detectLockState(server, accountId, asset);
if (state.isFrozen) {
throw new AccountFrozenError(accountId, asset.getCode());
}
if (state.isLocked) {
throw new AccountLockedError(accountId, asset.getCode());
}
return state;
}
8 changes: 8 additions & 0 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,7 @@ import { IdempotencyManager } from "./idempotency.js";
import type { IdempotencyConfig } from "./idempotency.js";
import { RollbackCoordinator } from "./splitRollbackCoordinator.js";
import { validateInvoicePayload } from "./payloadGuard.js";
import { InvoiceMetadataValidator } from "./validators/invoiceMetadataValidator.js";
import { validateSplitRatiosOrThrow } from "./validators/splitRatioValidator.js";
import type { SplitConfig } from "./types.js";
import { checkTrustlines } from "./trustlineChecker.js";
Expand Down Expand Up @@ -620,6 +621,7 @@ export class StellarSplitClient extends TypedEventEmitter<SplitClientEventMap> {
private _plugins = new Set<string>();
private _pluginInstances: StellarSplitPlugin[] = [];
private _pluginRegistry = new PluginRegistry();
private _metadataValidator: InvoiceMetadataValidator;
private _dedup = new Deduplicator<Invoice>();
private _cache: SimpleCache<any> | ICacheStore<any> | null = null;
private _auditLogger: AuditLogger | null = null;
Expand Down Expand Up @@ -768,6 +770,10 @@ export class StellarSplitClient extends TypedEventEmitter<SplitClientEventMap> {
super();
validateOrThrow(config);
this.config = config;
this._metadataValidator = new InvoiceMetadataValidator(
config.metadataSchema,
config.metadataThrowOnInvalid ?? true,
);
const primaryUrl = Array.isArray(config.rpcUrl)
? config.rpcUrl[0]!
: config.rpcUrl;
Expand Down Expand Up @@ -1834,6 +1840,8 @@ export class StellarSplitClient extends TypedEventEmitter<SplitClientEventMap> {
validateInvoicePayload(params, this.config.payloadGuard);
}

this._metadataValidator.validate(params.metadata);

// Pre-submission split ratio validation: catch malformed ratio arrays
// early (ratio-sum violations, negative shares, duplicates, zeros).
if (params.recipients.length > 1) {
Expand Down
8 changes: 7 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,10 @@ export {
isApprovalTimeoutError,
} from "./errors.js";

// Invoice metadata JSON Schema validator (issue #533)
export { InvoiceMetadataValidator } from "./validators/invoiceMetadataValidator.js";
export type { MetadataValidationResult } from "./validators/invoiceMetadataValidator.js";

// ---------------------------------------------------------------------------
// Lifecycle management (graceful shutdown)
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -763,12 +767,14 @@ export type { FilterCriteria, CompiledFilter } from "./invoiceFilter.js";
export { diffInvoices, hasDiff } from "./diff.js";
export type { InvoiceDiff, InvoiceDiffEntry } from "./diff.js";

export { diffSimulations } from "./simulationDiff.js";
export { diffSimulations, compareSimulations, formatDiffSummary } from "./simulationDiff.js";
export type {
SimulationDiff,
SimulationDiffSuccess,
SimulationDiffNotComparable,
ResourceDelta,
SimulationComparison,
SimulationComparisonNotComparable,
} from "./simulationDiff.js";

// Payment velocity tracking
Expand Down
46 changes: 46 additions & 0 deletions src/preflightChecker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,3 +259,49 @@ export async function checkPayerReadiness(

return { ready: true };
}

// ---------------------------------------------------------------------------
// Account Freeze / Lock Preflight Check
// ---------------------------------------------------------------------------

/** Per-recipient lock state report, keyed by recipient address. */
export interface RecipientLockCheckResult {
allUnlocked: boolean;
states: Record<string, AccountLockState>;
}

/**
* Pre-submission check: verify none of the payment recipients are frozen or
* permanently locked out of authorization for `asset`.
*
* Calls {@link detectLockState} for each recipient. Throws on the first
* frozen or locked account found so callers fail fast before building a
* transaction that would otherwise be rejected on-chain.
*
* @param server - Horizon server instance.
* @param recipients - Recipient addresses to check.
* @param asset - Asset being sent.
*
* @throws {AccountFrozenError} When a recipient's trustline has been frozen by the issuer.
* @throws {AccountLockedError} When a recipient can never be authorized again for the asset.
*/
export async function checkRecipientsUnlocked(
server: Horizon.Server,
recipients: string[],
asset: Asset,
): Promise<RecipientLockCheckResult> {
const states: Record<string, AccountLockState> = {};

for (const recipient of recipients) {
const state = await detectLockState(server, recipient, asset);
states[recipient] = state;
if (state.isFrozen) {
throw new AccountFrozenError(recipient, asset.getCode());
}
if (state.isLocked) {
throw new AccountLockedError(recipient, asset.getCode());
}
}

return { allUnlocked: true, states };
}
Loading