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
54 changes: 54 additions & 0 deletions src/accountFlagsInspector.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/**
* Account Flags State Inspector — decodes the AUTH_* flags on a Stellar
* account's Horizon `AccountRecord` into a typed `AccountFlagSet`, so callers
* don't need to inspect Horizon's raw `flags` object themselves.
*/

import { Horizon } from "@stellar/stellar-sdk";
import type { AccountFlagSet } from "./types.js";

/** Operations that AUTH_REQUIRED blocks until the holder has been explicitly authorized. */
const AUTH_REQUIRED_BLOCKS = new Set(["trustline", "create_trustline", "payment"]);

function buildFlagSet(flags: Horizon.HorizonApi.Flags): AccountFlagSet {
const flagSet: AccountFlagSet = {
authRequired: flags.auth_required,
authRevocable: flags.auth_revocable,
authImmutable: flags.auth_immutable,
authClawbackEnabled: flags.auth_clawback_enabled,
isCompatibleWith(operation: string): boolean {
if (flagSet.authRequired && AUTH_REQUIRED_BLOCKS.has(operation.toLowerCase())) {
return false;
}
return true;
},
};
return flagSet;
}

/**
* Fetch and decode the AUTH_* flags for a Stellar account.
*
* @param accountId - Stellar address to inspect.
* @param horizonUrl - Horizon API base URL used to load the account.
*/
export async function inspectFlags(
accountId: string,
horizonUrl: string,
): Promise<AccountFlagSet> {
const server = new Horizon.Server(horizonUrl);
const account = await server.loadAccount(accountId);
return buildFlagSet(account.flags);
}

/**
* Convenience quick-fail check: `true` when any flag that can restrict a
* counterparty's ability to hold or transact in this account's asset is set
* (`authRequired`, `authRevocable`, or `authClawbackEnabled`).
*
* `authImmutable` is excluded — it only affects whether the issuer's own
* flags can change in the future, not a counterparty's current operations.
*/
export function hasAnyRestrictiveFlag(flags: AccountFlagSet): boolean {
return flags.authRequired || flags.authRevocable || flags.authClawbackEnabled;
}
11 changes: 8 additions & 3 deletions src/claimableBalanceFallback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import type { StellarSplitClientConfig } from "./client.js";
import { ValidationError, ClaimableBalanceLifecycleError } from "./errors.js";
import { TypedEventEmitter, type Unsubscribe } from "./events/TypedEventEmitter.js";
import type { ClaimableBalanceRecord, ClaimableBalanceStatus } from "./types.js";
import { PredicateBuilder, type ClaimPredicate } from "./predicateBuilder.js";

// ---------------------------------------------------------------------------
// Error-pattern detection
Expand Down Expand Up @@ -97,7 +98,9 @@ export interface ClaimableRefundEntry {
* Build and submit a `createClaimableBalance` operation so that `payer` can
* claim the refund once their account / trustline is ready.
*
* The claimable balance is unconditional — the payer may claim it at any time.
* The claimable balance is unconditional by default — the payer may claim it
* at any time. Pass `predicate` (see {@link PredicateBuilder}) to restrict
* when the balance can be claimed.
*
* Requires `config.horizonUrl` to be set.
*
Expand All @@ -107,6 +110,7 @@ export interface ClaimableRefundEntry {
* @param sourceAddress - Stellar address funding / submitting the transaction.
* This account must hold sufficient `asset` balance.
* @param config - StellarSplit client config. `horizonUrl` must be set.
* @param predicate - Optional claim predicate. Defaults to unconditional.
*
* @throws If `config.horizonUrl` is not configured.
*/
Expand All @@ -115,7 +119,8 @@ export async function createClaimableRefund(
amount: bigint,
asset: Asset,
sourceAddress: string,
config: StellarSplitClientConfig
config: StellarSplitClientConfig,
predicate: ClaimPredicate = PredicateBuilder.unconditional()
): Promise<ClaimableRefundResult> {
if (!config.horizonUrl) {
throw new ValidationError(
Expand Down Expand Up @@ -143,7 +148,7 @@ export async function createClaimableRefund(
Operation.createClaimableBalance({
asset,
amount: amountStr,
claimants: [new Claimant(payer, Claimant.predicateUnconditional())],
claimants: [new Claimant(payer, predicate)],
})
)
.setTimeout(30)
Expand Down
47 changes: 26 additions & 21 deletions src/currencyConverter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
scValToNative,
} from "@stellar/stellar-sdk";
import { OraclePriceError, NoReturnValueError } from "./errors.js";
import { RateCache } from "./rateCache.js";

export interface ConvertedAmount {
original: bigint;
Expand All @@ -23,21 +24,34 @@ export interface ConvertedAmount {
toDisplayCurrency: string;
}

interface CacheEntry {
rate: bigint;
fetchedAt: number;
}

const priceCache = new Map<string, CacheEntry>();

const DEFAULT_CACHE_TTL_MS = 10_000;

function cacheKey(fromToken: string, toDisplayCurrency: string, oracleAddress: string): string {
return `${fromToken}:${toDisplayCurrency}:${oracleAddress}`;
/** One `RateCache<bigint>` per oracle address, so entries survive across calls. */
const rateCachesByOracle = new Map<string, RateCache<bigint>>();

function getRateCache(
oracleAddress: string,
server: SorobanRpc.Server,
networkPassphrase: string,
ttlMs: number,
): RateCache<bigint> {
let cache = rateCachesByOracle.get(oracleAddress);
if (!cache) {
cache = new RateCache<bigint>(
(from, to) => fetchOraclePrice(from, to, oracleAddress, server, networkPassphrase),
{ ttlMs },
);
rateCachesByOracle.set(oracleAddress, cache);
}
return cache;
}

export function clearPriceCache(): void {
priceCache.clear();
for (const cache of rateCachesByOracle.values()) {
cache.stop();
cache.invalidateAll();
}
rateCachesByOracle.clear();
}

async function fetchOraclePrice(
Expand Down Expand Up @@ -88,17 +102,8 @@ export async function convertAmount(
networkPassphrase: string,
priceCacheTtlMs: number = DEFAULT_CACHE_TTL_MS,
): Promise<ConvertedAmount> {
const key = cacheKey(fromToken, toDisplayCurrency, oracleAddress);
const now = Date.now();
const cached = priceCache.get(key);

let rate: bigint;
if (cached && now - cached.fetchedAt < priceCacheTtlMs) {
rate = cached.rate;
} else {
rate = await fetchOraclePrice(fromToken, toDisplayCurrency, oracleAddress, server, networkPassphrase);
priceCache.set(key, { rate, fetchedAt: now });
}
const cache = getRateCache(oracleAddress, server, networkPassphrase, priceCacheTtlMs);
const rate = await cache.getRate(fromToken, toDisplayCurrency);

// rate is assumed to be in fixed-point with 18 decimals (1e18 = 1.0)
const converted = (amount * rate) / 1_000_000_000_000_000_000n;
Expand Down
16 changes: 14 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,7 @@ export type {
DecodedXDR,
DecodedTransactionEnvelope,
DecodedTransactionResult,
DecodedOperationResult,
DecodedTransactionMeta,
DecodedLedgerEntry,
DecodedOperation,
Expand Down Expand Up @@ -413,8 +414,11 @@ export type { RpcClient } from "./rpcClient.js";
export { negotiateVersion, SDK_CONTRACT_VERSION } from "./version.js";
export type { VersionInfo } from "./types.js";

export { checkPayerReadiness, checkInvoiceExpiry, checkSponsorReserve } from "./preflightChecker.js";
export type { PayerReadinessResult, PayerReadinessReason, InvoiceExpiryResult, InvoiceExpiryReason, SponsorReserveCheck } from "./preflightChecker.js";
export { checkPayerReadiness, checkInvoiceExpiry, checkSponsorReserve, checkRecipientFlags } from "./preflightChecker.js";
export type { PayerReadinessResult, PayerReadinessReason, InvoiceExpiryResult, InvoiceExpiryReason, SponsorReserveCheck, RecipientFlagsCheck } from "./preflightChecker.js";

export { inspectFlags, hasAnyRestrictiveFlag } from "./accountFlagsInspector.js";
export type { AccountFlagSet } from "./types.js";

export { getSuggestion } from "./errorSuggestions.js";

Expand All @@ -423,6 +427,7 @@ export { getSuggestion } from "./errorSuggestions.js";
// ---------------------------------------------------------------------------

export { decodeXDR } from "./xdrDecoder.js";
export { decodeTransactionResult } from "./txResultDecoder.js";

// ---------------------------------------------------------------------------
// SSE Cursor Tracker — persistent cursor for stream resumption
Expand Down Expand Up @@ -772,6 +777,13 @@ export type {
ClaimableBalanceLifecycleEventMap,
} from "./claimableBalanceFallback.js";

export { PredicateBuilder } from "./predicateBuilder.js";
export type { ClaimPredicate } from "./predicateBuilder.js";
export type { PredicateConfig } from "./types.js";

export { RateCache } from "./rateCache.js";
export type { RateCacheEntry, RateOracleFn, RateCacheConfig } from "./rateCache.js";

export { subscribeToInvoice } from "./sse.js";
export type {
SSEInvoiceEventType,
Expand Down
77 changes: 77 additions & 0 deletions src/predicateBuilder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/**
* Claimable Balance Predicate Builder — a fluent, type-safe wrapper around
* `@stellar/stellar-sdk`'s `Claimant` predicate helpers, which otherwise
* require directly composing `xdr.ClaimPredicate` unions by hand.
*/

import { Claimant, xdr } from "@stellar/stellar-sdk";
import type { PredicateConfig } from "./types.js";

export type ClaimPredicate = xdr.ClaimPredicate;

/** Fluent builder for Stellar claimable-balance claim predicates. */
export class PredicateBuilder {
/** An always-claimable predicate. */
static unconditional(): ClaimPredicate {
return Claimant.predicateUnconditional();
}

/**
* A predicate claimable only within `[startUnixSeconds, endUnixSeconds]`.
*
* @param startUnixSeconds - Unix timestamp (seconds) the balance becomes claimable.
* @param endUnixSeconds - Unix timestamp (seconds) after which the balance can no longer be claimed.
*/
static absoluteWindow(
startUnixSeconds: number,
endUnixSeconds: number,
): ClaimPredicate {
const notBeforeStart = Claimant.predicateNot(
Claimant.predicateBeforeAbsoluteTime(startUnixSeconds.toString()),
);
const beforeEnd = Claimant.predicateBeforeAbsoluteTime(
endUnixSeconds.toString(),
);
return Claimant.predicateAnd(notBeforeStart, beforeEnd);
}

/**
* A predicate claimable for the next `secondsFromNow` seconds (relative to
* the ledger close time the claim transaction is applied in).
*/
static relativeWindow(secondsFromNow: number): ClaimPredicate {
return Claimant.predicateBeforeRelativeTime(secondsFromNow.toString());
}

/** Logical AND of two predicates — both must hold for the claim to succeed. */
static and(a: ClaimPredicate, b: ClaimPredicate): ClaimPredicate {
return Claimant.predicateAnd(a, b);
}

/** Logical OR of two predicates — either may hold for the claim to succeed. */
static or(a: ClaimPredicate, b: ClaimPredicate): ClaimPredicate {
return Claimant.predicateOr(a, b);
}

/** Build a `ClaimPredicate` from a declarative, JSON-serializable `PredicateConfig`. */
static build(config: PredicateConfig): ClaimPredicate {
switch (config.type) {
case "unconditional":
return PredicateBuilder.unconditional();
case "absoluteWindow":
return PredicateBuilder.absoluteWindow(config.start, config.end);
case "relativeWindow":
return PredicateBuilder.relativeWindow(config.secondsFromNow);
case "and":
return PredicateBuilder.and(
PredicateBuilder.build(config.predicates[0]),
PredicateBuilder.build(config.predicates[1]),
);
case "or":
return PredicateBuilder.or(
PredicateBuilder.build(config.predicates[0]),
PredicateBuilder.build(config.predicates[1]),
);
}
}
}
32 changes: 32 additions & 0 deletions src/preflightChecker.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { rpc as SorobanRpc, Horizon } from "@stellar/stellar-sdk";
import { inspectFlags } from "./accountFlagsInspector.js";
import type { AccountFlagSet } from "./types.js";

export type PayerReadinessReason =
| "account_not_found"
Expand Down Expand Up @@ -157,6 +159,36 @@ export async function checkSponsorReserve(
};
}

// ---------------------------------------------------------------------------
// Recipient Flags Preflight Check
// ---------------------------------------------------------------------------

/** Result of checking a recipient's account flags against an intended operation. */
export interface RecipientFlagsCheck {
/** `false` when the recipient's flags make `operation` impossible without prior authorization. */
compatible: boolean;
/** The recipient's decoded AUTH_* flags. */
flags: AccountFlagSet;
}

/**
* Preflight check: inspect a recipient's AUTH_* flags and flag when they are
* incompatible with the intended operation (e.g. AUTH_REQUIRED blocking a
* trustline creation or payment without prior issuer authorization).
*
* @param accountId - Stellar address of the recipient to inspect.
* @param horizonUrl - Horizon API base URL used to load the account.
* @param operation - The operation the caller intends to perform (e.g. "payment").
*/
export async function checkRecipientFlags(
accountId: string,
horizonUrl: string,
operation: string,
): Promise<RecipientFlagsCheck> {
const flags = await inspectFlags(accountId, horizonUrl);
return { compatible: flags.isCompatibleWith(operation), flags };
}

// ---------------------------------------------------------------------------
// Payer Readiness Check (existing)
// ---------------------------------------------------------------------------
Expand Down
Loading