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
45 changes: 45 additions & 0 deletions src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,51 @@ export class SimulationFailedError extends StellarSplitError {
}
}

/**
* Thrown by {@link DeployPipeline} when a WASM upload or contract
* instantiation step keeps failing with `tx_bad_seq` after retrying with
* a freshly-fetched sequence number.
*/
export class DeploySequenceError extends StellarSplitError {
readonly step: string;
readonly attempts: number;

constructor(step: string, attempts: number) {
super(
`Deploy step "${step}" failed after ${attempts} sequence retries due to tx_bad_seq`,
"DEPLOY_SEQUENCE_ERROR",
{ step, attempts }
);
this.name = "DeploySequenceError";
this.step = step;
this.attempts = attempts;
Object.setPrototypeOf(this, new.target.prototype);
}
}

/**
* Thrown by {@link WebhookAgent.deliver} when a webhook delivery has
* exhausted its configured retry budget without a successful response.
*/
export class WebhookExhaustedError extends StellarSplitError {
readonly url: string;
readonly attempts: number;
readonly lastError?: string;

constructor(url: string, attempts: number, lastError?: string) {
super(
`Webhook delivery to ${url} failed after ${attempts} attempts${lastError ? `: ${lastError}` : ""}`,
"WEBHOOK_EXHAUSTED",
{ url, attempts, lastError }
);
this.name = "WebhookExhaustedError";
this.url = url;
this.attempts = attempts;
this.lastError = lastError;
Object.setPrototypeOf(this, new.target.prototype);
}
}

/** Thrown when no return value is received from a contract call. */
export class NoReturnValueError extends StellarSplitError {
readonly method: string;
Expand Down
105 changes: 105 additions & 0 deletions src/fees/trend.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/**
* Transaction fee history trend analyzer for StellarSplit.
*
* Polls Horizon's `/fee_stats` endpoint on a rolling basis and computes
* percentile estimates over a sliding window, so callers can request a
* recommended base fee for a chosen acceptance percentile instead of
* guessing during network congestion.
*/

import { Horizon } from "@stellar/stellar-sdk";
import { CircularBuffer } from "../utils/circularBuffer.js";
import { percentile } from "../utils/stats.js";
import type { FeeTrendOptions } from "../types.js";

/** Acceptance-percentile targets supported by {@link FeeTrendAnalyzer.recommendedFee}. */
export type FeePercentile = 50 | 75 | 95 | 99;

const MIN_WINDOW_SIZE = 5;
const MAX_WINDOW_SIZE = 100;
const DEFAULT_WINDOW_SIZE = 20;
const DEFAULT_TTL_MS = 5 * 60 * 1000;

const brand: unique symbol = Symbol("WindowCapacity");

/** Branded integer type: a window size validated to `[5, 100]` at construction time. */
export type WindowCapacity = number & { readonly [brand]: true };

/** Validates and brands a raw window size. */
function toWindowCapacity(size: number): WindowCapacity {
if (!Number.isInteger(size) || size < MIN_WINDOW_SIZE || size > MAX_WINDOW_SIZE) {
throw new RangeError(
`windowSize must be an integer between ${MIN_WINDOW_SIZE} and ${MAX_WINDOW_SIZE}, got ${size}`
);
}
return size as WindowCapacity;
}

/** A single fee snapshot captured from Horizon's `/fee_stats` endpoint. */
interface FeeSample {
/** Representative fee charged (stroops) for the most recently closed ledger. */
value: number;
/** Time the sample was captured, used for TTL-based eviction. */
capturedAt: number;
}

/**
* Tracks a rolling window of Horizon fee_stats snapshots and recommends a
* base fee at a caller-specified acceptance percentile.
*
* @example
* ```typescript
* const analyzer = new FeeTrendAnalyzer({ horizonUrl: "https://horizon.stellar.org" });
* await analyzer.sample();
* const fee = analyzer.recommendedFee(95); // stroops
* ```
*/
export class FeeTrendAnalyzer {
private readonly server: Horizon.Server;
private readonly buffer: CircularBuffer<FeeSample>;
private readonly ttlMs: number;

constructor(options: FeeTrendOptions) {
const windowSize = toWindowCapacity(options.windowSize ?? DEFAULT_WINDOW_SIZE);
this.buffer = new CircularBuffer<FeeSample>(windowSize);
this.ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
this.server = new Horizon.Server(options.horizonUrl);
}

/**
* Fetches the current fee stats snapshot from Horizon and appends it to
* the rolling window, evicting the oldest entry once at capacity.
*/
async sample(): Promise<void> {
const stats = await this.server.feeStats();
const value = Number(stats.fee_charged.mode);
this.buffer.push({ value, capturedAt: Date.now() });
}

/**
* Returns the recommended fee, in stroops, at `percentileTarget` across
* all samples currently in the window. Samples older than the
* configured TTL are evicted before the computation runs.
*/
recommendedFee(percentileTarget: FeePercentile): number {
this.evictExpired();

const values = this.buffer.toArray().map((sample) => sample.value);
if (values.length === 0) {
throw new RangeError("No fee samples available; call sample() before recommendedFee()");
}

return Math.ceil(percentile(values, percentileTarget));
}

/** Number of non-expired samples currently held in the window. */
get sampleCount(): number {
this.evictExpired();
return this.buffer.size;
}

private evictExpired(): void {
const cutoff = Date.now() - this.ttlMs;
this.buffer.evictOldestWhile((sample) => sample.capturedAt < cutoff);
}
}
223 changes: 223 additions & 0 deletions src/sep/sep12.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
/**
* SEP-12 KYC field submission handler for StellarSplit.
*
* Wraps the anchor `PUT /customer` and `GET /customer` endpoints used by
* SEP-31 cross-border payment flows: derives the `KYC_SERVER` URL from
* the anchor's stellar.toml, uploads text fields and binary documents in
* a single multipart request, attaches the SEP-10 bearer token
* automatically, and polls for approval status.
*
* @see https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0012.md
*/

import { StellarTomlResolver } from "@stellar/stellar-sdk";
import { KycNeedsInfoError } from "../types.js";
import type { KycFields, KycDocument, KycStatus } from "../types.js";

/** Configuration for {@link Sep12Client}. */
export interface Sep12ClientOptions {
/** Anchor home domain used to resolve stellar.toml, e.g. "anchor.example.com". */
homeDomain: string;
/** SEP-10 JWT, attached automatically as `Authorization: Bearer <jwt>`. */
jwt: string;
/** Stellar account being KYC'd. */
account: string;
/** Optional memo identifying the account (for shared/omnibus accounts). */
memo?: string;
/** Memo type, required alongside `memo` when set. */
memoType?: string;
}

/** Options for {@link Sep12Client.pollUntilResolved}. */
export interface Sep12PollOptions {
/** Interval between status checks in milliseconds. Default: 3000. */
intervalMs?: number;
/** Maximum time to poll before giving up in milliseconds. Default: 120000. */
timeoutMs?: number;
}

interface Sep12PutCustomerResponse {
id: string;
}

interface Sep12GetCustomerResponse {
id?: string;
status: string;
fields?: Record<string, unknown>;
message?: string;
}

/**
* Client for the SEP-12 KYC submission and status-check flow.
*
* @example
* ```typescript
* const kyc = new Sep12Client({ homeDomain: "anchor.example.com", jwt, account });
* const { id } = await kyc.putCustomer({ first_name: "Ada", last_name: "Lovelace" });
* const accepted = await kyc.pollUntilResolved(id);
* ```
*/
export class Sep12Client {
private readonly homeDomain: string;
private readonly jwt: string;
private readonly account: string;
private readonly memo?: string;
private readonly memoType?: string;
private kycServerUrl: string | null = null;

constructor(options: Sep12ClientOptions) {
this.homeDomain = options.homeDomain;
this.jwt = options.jwt;
this.account = options.account;
this.memo = options.memo;
this.memoType = options.memoType;
}

/**
* Submits KYC text fields and optional binary documents to the anchor
* in a single `multipart/form-data` PUT request.
*
* @param fields - SEP-9 text fields, e.g. `{ first_name, last_name, email_address }`.
* @param docs - Optional binary attachments, e.g. photo ID scans.
* @param customerId - Existing customer id, when updating a prior submission.
* @returns The anchor-assigned customer id.
*/
async putCustomer(
fields: KycFields,
docs: KycDocument[] = [],
customerId?: string
): Promise<{ id: string }> {
const base = await this.resolveKycServer();
const form = new FormData();

form.append("account", this.account);
if (this.memo) form.append("memo", this.memo);
if (this.memoType) form.append("memo_type", this.memoType);
if (customerId) form.append("id", customerId);

for (const [key, value] of Object.entries(fields)) {
form.append(key, value);
}

for (const doc of docs) {
const blob =
doc.content instanceof Blob ? doc.content : new Blob([doc.content], { type: doc.contentType });
form.append(doc.field, blob, doc.filename);
}

const response = await fetch(`${base}/customer`, {
method: "PUT",
headers: {
Authorization: `Bearer ${this.jwt}`,
},
body: form,
});

if (!response.ok) {
const errorText = await response.text().catch(() => "Unknown error");
throw new Error(`SEP-12 putCustomer failed (${response.status}): ${errorText}`);
}

return (await response.json()) as Sep12PutCustomerResponse;
}

/**
* Fetches the current KYC status for a customer.
*
* Always resolves to a typed {@link KycStatus}; use
* {@link pollUntilResolved} if you want `NEEDS_INFO` / `REJECTED` to be
* raised as a {@link KycNeedsInfoError} instead.
*/
async getCustomer(id: string): Promise<KycStatus> {
const base = await this.resolveKycServer();
const url = new URL(`${base}/customer`);
url.searchParams.set("id", id);
url.searchParams.set("account", this.account);
if (this.memo) url.searchParams.set("memo", this.memo);
if (this.memoType) url.searchParams.set("memo_type", this.memoType);

const response = await fetch(url.toString(), {
headers: {
Authorization: `Bearer ${this.jwt}`,
},
});

if (!response.ok) {
const errorText = await response.text().catch(() => "Unknown error");
throw new Error(`SEP-12 getCustomer failed (${response.status}): ${errorText}`);
}

const data = (await response.json()) as Sep12GetCustomerResponse;
return toKycStatus(id, data);
}

/**
* Polls {@link getCustomer} until the status is terminal.
*
* @throws {KycNeedsInfoError} when the anchor reports `NEEDS_INFO` or `REJECTED`.
* @returns The accepted status once approved.
*/
async pollUntilResolved(
id: string,
options: Sep12PollOptions = {}
): Promise<Extract<KycStatus, { status: "ACCEPTED" }>> {
const intervalMs = options.intervalMs ?? 3000;
const deadline = Date.now() + (options.timeoutMs ?? 120_000);

for (;;) {
const status = await this.getCustomer(id);

if (status.status === "ACCEPTED") {
return status;
}
if (status.status === "NEEDS_INFO" || status.status === "REJECTED") {
throw new KycNeedsInfoError(status);
}

if (Date.now() >= deadline) {
throw new Error(`SEP-12 customer ${id} did not resolve within the polling timeout`);
}

await sleep(intervalMs);
}
}

private async resolveKycServer(): Promise<string> {
if (this.kycServerUrl) return this.kycServerUrl;

const toml = await StellarTomlResolver.resolve(this.homeDomain);
const kycServer = (toml.KYC_SERVER as string | undefined) ?? (toml.TRANSFER_SERVER_SEP0024 as string | undefined);
if (!kycServer) {
throw new Error(`No KYC_SERVER found in stellar.toml for ${this.homeDomain}`);
}

this.kycServerUrl = kycServer.replace(/\/$/, "");
return this.kycServerUrl;
}
}

/** Maps a raw anchor response body to the typed {@link KycStatus} union. */
function toKycStatus(id: string, data: Sep12GetCustomerResponse): KycStatus {
const status = data.status.toUpperCase().trim();

switch (status) {
case "ACCEPTED":
return { status: "ACCEPTED", id: data.id ?? id };
case "NEEDS_INFO":
return {
status: "NEEDS_INFO",
id: data.id ?? id,
missingFields: data.fields ? Object.keys(data.fields) : [],
message: data.message,
};
case "REJECTED":
return { status: "REJECTED", id: data.id ?? id, message: data.message };
case "PROCESSING":
default:
return { status: "PROCESSING", id: data.id ?? id, message: data.message };
}
}

function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
Loading