diff --git a/src/errors.ts b/src/errors.ts index 67cabaf..7b09f53 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -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; diff --git a/src/fees/trend.ts b/src/fees/trend.ts new file mode 100644 index 0000000..5013149 --- /dev/null +++ b/src/fees/trend.ts @@ -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; + private readonly ttlMs: number; + + constructor(options: FeeTrendOptions) { + const windowSize = toWindowCapacity(options.windowSize ?? DEFAULT_WINDOW_SIZE); + this.buffer = new CircularBuffer(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 { + 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); + } +} diff --git a/src/sep/sep12.ts b/src/sep/sep12.ts new file mode 100644 index 0000000..fa36588 --- /dev/null +++ b/src/sep/sep12.ts @@ -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: 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; + 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 { + 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> { + 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 { + 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 { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/src/soroban/deploy.ts b/src/soroban/deploy.ts new file mode 100644 index 0000000..c60575a --- /dev/null +++ b/src/soroban/deploy.ts @@ -0,0 +1,238 @@ +/** + * Soroban contract deployment pipeline for StellarSplit. + * + * Wraps the two-step "upload WASM, then instantiate a contract instance + * from the uploaded hash" flow behind a single {@link DeployPipeline} + * class, so callers no longer have to hand-construct the upload/create + * operations, simulate them for accurate resource fees, and chain the + * two transactions themselves. + */ + +import { + rpc as SorobanRpc, + Operation, + TransactionBuilder, + BASE_FEE, + Address, + Keypair, + scValToNative, + xdr, +} from "@stellar/stellar-sdk"; +import { + DeploySequenceError, + SimulationFailedError, + TransactionFailedError, + NoReturnValueError, +} from "../errors.js"; +import type { DeployOptions, DeployResult } from "../types.js"; + +/** Maximum number of resubmissions after a `tx_bad_seq` failure. */ +const MAX_SEQUENCE_RETRIES = 3; +/** Interval between `getTransaction` polls while awaiting confirmation. */ +const POLL_INTERVAL_MS = 1_500; +/** Maximum time to wait for a submitted transaction to confirm. */ +const POLL_TIMEOUT_MS = 30_000; + +/** Configuration for {@link DeployPipeline}. */ +export interface DeployPipelineOptions { + /** Soroban RPC endpoint URL. */ + rpcUrl: string; + /** Stellar network passphrase. */ + networkPassphrase: string; + /** Keypair used to sign and fund upload/instantiate transactions. */ + sourceKeypair: Keypair; + /** Force plaintext HTTP to the RPC endpoint. Defaults to inferring from the URL scheme. */ + allowHttp?: boolean; +} + +/** Internal signal thrown to trigger a sequence-number retry. */ +class BadSequenceSignal extends Error {} + +/** + * Deploys Soroban contracts by uploading WASM bytecode and instantiating + * a contract instance from the resulting hash. + * + * Both steps are simulated before submission so the assembled transaction + * carries accurate footprints and resource fees, and both are retried + * automatically on `tx_bad_seq` up to {@link MAX_SEQUENCE_RETRIES} times. + * + * @example + * ```typescript + * const pipeline = new DeployPipeline({ + * rpcUrl: "https://soroban-testnet.stellar.org", + * networkPassphrase: Networks.TESTNET, + * sourceKeypair: Keypair.fromSecret(secret), + * }); + * const wasmHash = await pipeline.upload(wasmBytes); + * const contractId = await pipeline.instantiate(wasmHash, salt); + * ``` + */ +export class DeployPipeline { + private readonly server: InstanceType; + private readonly networkPassphrase: string; + private readonly sourceKeypair: Keypair; + + constructor(options: DeployPipelineOptions) { + this.networkPassphrase = options.networkPassphrase; + this.sourceKeypair = options.sourceKeypair; + this.server = new SorobanRpc.Server(options.rpcUrl, { + allowHttp: options.allowHttp ?? options.rpcUrl.startsWith("http://"), + }); + } + + /** + * Uploads Soroban contract WASM bytecode to the network. + * + * @param wasmBytes - Raw contract bytecode. + * @returns The hex-encoded hash of the uploaded WASM. + */ + async upload(wasmBytes: Buffer): Promise { + const returnValue = await this.executeWithRetry( + () => Operation.uploadContractWasm({ wasm: wasmBytes }), + "uploadContractWasm" + ); + const hashBytes = scValToNative(returnValue) as Buffer; + return Buffer.from(hashBytes).toString("hex"); + } + + /** + * Instantiates a contract instance from a previously uploaded WASM hash. + * + * Wraps the `CreateContractV2` host function (via + * `Operation.createCustomContract`), deriving the contract address from + * the source account's identity and the supplied salt. + * + * @param wasmHash - Hex-encoded WASM hash returned by {@link upload}. + * @param salt - 32-byte salt used to derive the contract address. + * @returns The deployed contract's address (C...). + */ + async instantiate(wasmHash: string, salt: Buffer): Promise { + const returnValue = await this.executeWithRetry( + () => + Operation.createCustomContract({ + address: new Address(this.sourceKeypair.publicKey()), + wasmHash: Buffer.from(wasmHash, "hex"), + salt, + }), + "createContractV2" + ); + return scValToNative(returnValue) as string; + } + + /** + * Convenience wrapper that uploads WASM and instantiates a contract + * instance from it in one call. + */ + async deploy(options: DeployOptions): Promise { + const salt = options.salt ?? Buffer.from(Keypair.random().rawPublicKey()); + const wasmHash = await this.upload(options.wasmBytes); + const contractId = await this.instantiate(wasmHash, salt); + return { wasmHash, contractId }; + } + + // ------------------------------------------------------------------------- + // Internal: simulate, sign, submit, retry on bad sequence, poll for result + // ------------------------------------------------------------------------- + + private async executeWithRetry( + buildOperation: () => xdr.Operation, + stepName: string + ): Promise { + let attempt = 0; + for (;;) { + try { + return await this.executeOnce(buildOperation(), stepName); + } catch (err) { + if (!(err instanceof BadSequenceSignal)) { + throw err; + } + attempt += 1; + if (attempt > MAX_SEQUENCE_RETRIES) { + throw new DeploySequenceError(stepName, attempt - 1); + } + } + } + } + + private async executeOnce(operation: xdr.Operation, stepName: string): Promise { + const account = await this.server.getAccount(this.sourceKeypair.publicKey()); + + const tx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: this.networkPassphrase, + }) + .addOperation(operation) + .setTimeout(30) + .build(); + + const simulation = await this.server.simulateTransaction(tx); + if (SorobanRpc.Api.isSimulationError(simulation)) { + throw new SimulationFailedError( + `Simulation failed for ${stepName}: ${simulation.error}`, + stepName, + simulation.error + ); + } + + const prepared = SorobanRpc.assembleTransaction(tx, simulation).build(); + prepared.sign(this.sourceKeypair); + + const sendResponse = await this.server.sendTransaction(prepared); + + if (sendResponse.status === "ERROR") { + const errorDetail = JSON.stringify(sendResponse.errorResult); + if (looksLikeBadSequence(errorDetail)) { + throw new BadSequenceSignal(); + } + throw new TransactionFailedError( + `${stepName} submission failed: ${errorDetail}`, + sendResponse.hash, + errorDetail + ); + } + + return this.pollForResult(sendResponse.hash, stepName); + } + + private async pollForResult(hash: string, stepName: string): Promise { + const deadline = Date.now() + POLL_TIMEOUT_MS; + + for (;;) { + const response = await this.server.getTransaction(hash); + + if (response.status === SorobanRpc.Api.GetTransactionStatus.SUCCESS) { + const returnValue = (response as SorobanRpc.Api.GetSuccessfulTransactionResponse).returnValue; + if (!returnValue) { + throw new NoReturnValueError(stepName); + } + return returnValue; + } + + if (response.status === SorobanRpc.Api.GetTransactionStatus.FAILED) { + const errorDetail = JSON.stringify( + (response as SorobanRpc.Api.GetFailedTransactionResponse).resultXdr + ); + if (looksLikeBadSequence(errorDetail)) { + throw new BadSequenceSignal(); + } + throw new TransactionFailedError(`${stepName} transaction failed on-chain`, hash, errorDetail); + } + + if (Date.now() >= deadline) { + throw new TransactionFailedError(`${stepName} transaction confirmation timed out`, hash); + } + + await sleep(POLL_INTERVAL_MS); + } + } +} + +/** Type-guard-ish check for a `tx_bad_seq` result, mirroring `isSequenceTooOld` in sequenceCache.ts. */ +function looksLikeBadSequence(detail: string): boolean { + const lower = detail.toLowerCase(); + return lower.includes("tx_bad_seq") || lower.includes("bad sequence"); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/src/utils/circularBuffer.ts b/src/utils/circularBuffer.ts new file mode 100644 index 0000000..786cc91 --- /dev/null +++ b/src/utils/circularBuffer.ts @@ -0,0 +1,85 @@ +/** + * Fixed-capacity circular (ring) buffer. + * + * Used by {@link FeeTrendAnalyzer} (../fees/trend.js) to hold a rolling + * window of samples, evicting the oldest entry once capacity is reached. + */ + +export class CircularBuffer { + private readonly items: Array; + private readonly capacity: number; + private start = 0; + private count = 0; + + constructor(capacity: number) { + if (!Number.isInteger(capacity) || capacity < 1) { + throw new RangeError(`CircularBuffer capacity must be a positive integer, got ${capacity}`); + } + this.capacity = capacity; + this.items = new Array(capacity); + } + + /** + * Appends an item, evicting the oldest entry first when the buffer is + * already at capacity. + */ + push(item: T): void { + const index = (this.start + this.count) % this.capacity; + this.items[index] = item; + + if (this.count < this.capacity) { + this.count += 1; + } else { + this.start = (this.start + 1) % this.capacity; + } + } + + /** + * Evicts entries from the oldest end while `predicate` holds, stopping + * at the first entry that doesn't match. Suitable for TTL eviction, + * since samples are always pushed in increasing recency order. + * + * @returns The evicted items, oldest first. + */ + evictOldestWhile(predicate: (item: T) => boolean): T[] { + const evicted: T[] = []; + while (this.count > 0 && predicate(this.items[this.start] as T)) { + evicted.push(this.items[this.start] as T); + this.items[this.start] = undefined; + this.start = (this.start + 1) % this.capacity; + this.count -= 1; + } + return evicted; + } + + /** Returns all currently held items, oldest first. */ + toArray(): T[] { + const result: T[] = []; + for (let i = 0; i < this.count; i += 1) { + result.push(this.items[(this.start + i) % this.capacity] as T); + } + return result; + } + + /** Number of items currently held. */ + get size(): number { + return this.count; + } + + /** Maximum number of items this buffer can hold. */ + get maxSize(): number { + return this.capacity; + } + + /** Whether the buffer is holding as many items as its capacity allows. */ + get isFull(): boolean { + return this.count === this.capacity; + } + + /** Removes all items. */ + clear(): void { + this.items.fill(undefined); + this.start = 0; + this.count = 0; + } +} diff --git a/src/utils/stats.ts b/src/utils/stats.ts new file mode 100644 index 0000000..0389256 --- /dev/null +++ b/src/utils/stats.ts @@ -0,0 +1,43 @@ +/** + * Percentile computation helpers. + * + * Extracted from {@link FeeTrendAnalyzer} (../fees/trend.js) so the + * statistics logic can be exercised independently of Horizon/network + * concerns. + */ + +/** + * Computes the value at `percentileTarget` using linear interpolation + * between closest ranks (the same convention as numpy's default + * `"linear"` interpolation method). + * + * @param values - Unsorted sample values. + * @param percentileTarget - Target percentile in the range [0, 100]. + */ +export function percentile(values: readonly number[], percentileTarget: number): number { + if (values.length === 0) { + throw new RangeError("Cannot compute a percentile of an empty sample set"); + } + if (percentileTarget < 0 || percentileTarget > 100) { + throw new RangeError(`Percentile must be between 0 and 100, got ${percentileTarget}`); + } + + const sorted = [...values].sort((a, b) => a - b); + if (sorted.length === 1) { + return sorted[0] as number; + } + + const rank = (percentileTarget / 100) * (sorted.length - 1); + const lowerIndex = Math.floor(rank); + const upperIndex = Math.ceil(rank); + + if (lowerIndex === upperIndex) { + return sorted[lowerIndex] as number; + } + + const lowerValue = sorted[lowerIndex] as number; + const upperValue = sorted[upperIndex] as number; + const fraction = rank - lowerIndex; + + return lowerValue + (upperValue - lowerValue) * fraction; +} diff --git a/src/webhooks/delivery.ts b/src/webhooks/delivery.ts new file mode 100644 index 0000000..36f2ecc --- /dev/null +++ b/src/webhooks/delivery.ts @@ -0,0 +1,118 @@ +/** + * JWT/HMAC-signed webhook delivery agent for StellarSplit. + * + * Builds structured {@link WebhookPayload} envelopes for split-payment + * lifecycle events, signs them with a shared HMAC-SHA256 secret, and + * delivers them over HTTP with exponential-backoff retry. Pair with + * {@link verifyWebhookSignature} (./verify.js) on the receiving end. + */ + +import { createHmac, randomUUID } from "crypto"; +import { WebhookExhaustedError } from "../errors.js"; +import type { WebhookPayload } from "../types.js"; + +/** Header carrying the hex-encoded HMAC-SHA256 signature of the raw body. */ +export const WEBHOOK_SIGNATURE_HEADER = "X-Stellar-Split-Signature"; + +/** Configuration for {@link WebhookAgent}. */ +export interface WebhookAgentOptions { + /** Shared secret used to HMAC-sign delivered payloads. */ + secret: string; + /** Maximum number of retries after the initial attempt. Default: 5. */ + maxRetries?: number; + /** Backoff cap in milliseconds. Default: 60000 (60s). */ + maxBackoffMs?: number; + /** Override for `fetch`, primarily for testing. */ + fetchImpl?: typeof fetch; +} + +/** Input for a single webhook delivery; `event_id` and `timestamp` are generated by the agent. */ +export interface WebhookDeliveryInput { + /** Machine-readable event type, e.g. "invoice.payment.completed". */ + event_type: string; + /** Event-specific data. */ + data: T; +} + +/** + * Signs and delivers webhook payloads with retry and exponential backoff. + * + * @example + * ```typescript + * const agent = new WebhookAgent({ secret: process.env.WEBHOOK_SECRET! }); + * await agent.deliver("https://consumer.example.com/hooks", { + * event_type: "invoice.payment.completed", + * data: { invoiceId, amount }, + * }); + * ``` + */ +export class WebhookAgent { + private readonly secret: string; + private readonly maxRetries: number; + private readonly maxBackoffMs: number; + private readonly fetchImpl: typeof fetch; + + constructor(options: WebhookAgentOptions) { + this.secret = options.secret; + this.maxRetries = options.maxRetries ?? 5; + this.maxBackoffMs = options.maxBackoffMs ?? 60_000; + this.fetchImpl = options.fetchImpl ?? fetch; + } + + /** + * Delivers a signed webhook payload to `url`, retrying transient + * failures with exponential backoff (2^n seconds, capped). + * + * @throws {WebhookExhaustedError} once all retries are exhausted. + * @returns The envelope that was delivered, including its generated `event_id`. + */ + async deliver(url: string, payload: WebhookDeliveryInput): Promise> { + const envelope: WebhookPayload = { + event_id: randomUUID(), + event_type: payload.event_type, + timestamp: new Date().toISOString(), + data: payload.data, + }; + + const rawBody = JSON.stringify(envelope); + const signature = signBody(this.secret, rawBody); + + let lastError: string | undefined; + const totalAttempts = this.maxRetries + 1; + + for (let attempt = 0; attempt < totalAttempts; attempt++) { + try { + const response = await this.fetchImpl(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + [WEBHOOK_SIGNATURE_HEADER]: signature, + }, + body: rawBody, + }); + + if (response.ok) { + return envelope; + } + lastError = `HTTP ${response.status}`; + } catch (err) { + lastError = err instanceof Error ? err.message : String(err); + } + + const isLastAttempt = attempt === totalAttempts - 1; + if (!isLastAttempt) { + await sleep(Math.min(2 ** attempt * 1000, this.maxBackoffMs)); + } + } + + throw new WebhookExhaustedError(url, totalAttempts, lastError); + } +} + +function signBody(secret: string, rawBody: string): string { + return createHmac("sha256", secret).update(rawBody).digest("hex"); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/src/webhooks/verify.ts b/src/webhooks/verify.ts new file mode 100644 index 0000000..88cab67 --- /dev/null +++ b/src/webhooks/verify.ts @@ -0,0 +1,39 @@ +/** + * Server-side verification helper for StellarSplit webhook signatures. + * + * Mirrors the HMAC-SHA256 signing performed by {@link WebhookAgent} so + * webhook consumers can confirm a payload originated from the SDK and + * was not tampered with in transit. + */ + +import { createHmac, timingSafeEqual } from "crypto"; + +const HEX_PATTERN = /^[0-9a-f]+$/i; + +/** + * Verifies the `X-Stellar-Split-Signature` header against the raw request + * body using a timing-safe comparison. + * + * @param secret - The shared HMAC secret configured for the webhook. + * @param rawBody - The exact, unparsed request body bytes as received. + * @param signatureHeader - The hex-encoded signature from the request header. + * @returns `true` only when the computed digest matches the header value. + */ +export function verifyWebhookSignature( + secret: string, + rawBody: string, + signatureHeader: string +): boolean { + if (!HEX_PATTERN.test(signatureHeader) || signatureHeader.length % 2 !== 0) { + return false; + } + + const expected = createHmac("sha256", secret).update(rawBody).digest(); + const provided = Buffer.from(signatureHeader, "hex"); + + if (expected.length !== provided.length) { + return false; + } + + return timingSafeEqual(expected, provided); +}