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
95 changes: 93 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,15 @@ The `MetricsRegistry` (defined in `src/metrics.rs`) is the central instrumentati
| `config_validation_failures_total` | Counter | Total configuration validation failures |
| `config_reload_total` | Counter | Total configuration reloads attempted |

#### Webhook Delivery Metrics

| Metric | Type | Labels | Description |
|---|---|---|---|
| `webhook_deliveries_total` | CounterVec | `status` (success/dead_lettered) | Total webhook delivery outcomes |
| `webhook_delivery_latency_seconds` | HistogramVec | `status` | End-to-end delivery latency including all retries |
| `webhook_dlq_depth` | Gauge | — | Current number of entries in the dead-letter queue |
| `webhook_retries_total` | Counter | — | Total webhook retry attempts |

#### Recommended Alerting Thresholds

| Alert | Condition | Severity |
Expand All @@ -362,6 +371,8 @@ The `MetricsRegistry` (defined in `src/metrics.rs`) is the central instrumentati
| Event backlog growing | `event_backlog_size > 1000` | Warning |
| Config validation failures | `increase(config_validation_failures_total[5m]) > 0` | Critical |
| High Horizon latency | `histogram_quantile(0.95, rate(horizon_latency_seconds_bucket[5m])) > 5` | Warning |
| Webhook DLQ growing | `webhook_dlq_depth > 0` | Warning |
| High webhook failure rate | `rate(webhook_deliveries_total{status="dead_lettered"}[5m]) > 0` | Critical |

#### Running with Metrics

Expand Down Expand Up @@ -400,8 +411,13 @@ scrape_configs:
| `STELLAR_CIRCUIT_BREAKER_OPEN_DURATION_MS` | `30000` | Milliseconds the circuit remains open before allowing a half-open probe |
| `STELLAR_CIRCUIT_BREAKER_HALF_OPEN_MAX_CALLS` | `1` | Concurrent half-open probes allowed before recovery or reopening |
| `LOG_LEVEL` | `info` | Log verbosity string |
| `WEBHOOK_URLS` | empty | Comma-separated list of valid URLs |
| `WEBHOOK_SECRET` | unset | Optional webhook signing secret |
| `WEBHOOK_URLS` | empty | Comma-separated list of valid URLs to receive webhook events |
| `WEBHOOK_SECRET` | unset | Optional shared secret sent as `X-Webhook-Secret` header |
| `WEBHOOK_MAX_RETRIES` | `5` | Retry attempts after the initial webhook delivery fails |
| `WEBHOOK_RETRY_BASE_DELAY_MS` | `200` | Initial exponential backoff delay in milliseconds; must be greater than `0` |
| `WEBHOOK_RETRY_MAX_DELAY_MS` | `30000` | Maximum backoff delay in milliseconds; must be ≥ base delay |
| `WEBHOOK_REQUEST_TIMEOUT_MS` | `10000` | Per-request webhook HTTP timeout in milliseconds; must be greater than `0` |
| `WEBHOOK_JITTER_ENABLED` | `true` | Boolean; adds random jitter of up to 25 % of the capped delay |
| `CACHE_VERIFICATION_TTL` | `3600` | Seconds before a cached verification result expires |

Set `REDIS_URL` to a real Redis instance in production. The in-memory backend is suitable for local development and testing only.
Expand All @@ -419,6 +435,81 @@ Audit records should be retained for as long as the operator needs replay and fo

---

## 🔔 Webhook Delivery

After an event is finalized, the service dispatches it asynchronously to every URL listed in `WEBHOOK_URLS`. External systems subscribe to these events to maintain up-to-date replicas of document state.

### Event payload schema

```json
{
"event_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"event_type": "DocumentRegistered",
"idempotency_key": "contract:tx123:42:3:doc-1:DocumentRegistered",
"sequence": 42003,
"timestamp": "2026-06-28T12:00:00Z",
"aggregate_id": "doc-1",
"actor": "GDEX...",
"data": { "issuer": "GDEX...", "owner": "GBBB..." },
"metadata": {
"transaction_hash": "tx123",
"ledger_sequence": 42,
"event_index": 3,
"document_hash": "e3b0c4...",
"source": "contract"
}
}
```

| Field | Description |
|---|---|
| `event_id` | UUID v4 unique to this event record |
| `event_type` | One of `DocumentRegistered`, `DocumentRevoked`, `DocumentVerified`, `DocumentAuthorizationFailed`, `DocumentOwnerChanged` |
| `idempotency_key` | Stable token derived from transaction hash + event index. Use this to deduplicate retried deliveries. |
| `sequence` | Monotonically increasing within an aggregate. For contract events: `ledger_sequence * 1000 + event_index`. |
| `timestamp` | ISO-8601 UTC timestamp when the event was recorded |
| `metadata` | Present for contract-origin events; contains `transaction_hash`, `ledger_sequence`, `event_index`, `document_hash` |

### Request headers

Each webhook HTTP POST includes the following headers:

| Header | Value |
|---|---|
| `Content-Type` | `application/json` |
| `X-Idempotency-Key` | The event's `idempotency_key` |
| `X-Event-Id` | The event's `event_id` |
| `X-Event-Type` | The event's `event_type` |
| `X-Webhook-Secret` | Value of `WEBHOOK_SECRET` if configured |

### Retry semantics

Deliveries use exponential backoff with jitter:

```
delay(attempt) = min(base * 2^attempt, max) + random_jitter(0, delay/4)
```

- `base` is `WEBHOOK_RETRY_BASE_DELAY_MS` (default `200` ms)
- `max` is `WEBHOOK_RETRY_MAX_DELAY_MS` (default `30 000` ms)
- Jitter is drawn uniformly from `[0, capped_delay / 4)` using wall-clock sub-millisecond noise
- Total attempts = `WEBHOOK_MAX_RETRIES + 1` (default 6 total)

### Ordering guarantees

URLs are contacted **sequentially in registration order**. An event is attempted against every URL regardless of individual failures — a URL that exhausts retries is dead-lettered without blocking delivery to subsequent URLs.

### Dead-letter queue

Failed deliveries (all retries exhausted) are moved to an in-memory bounded queue (max 10 000 entries). The queue is accessible via:

- `GET /webhooks/dlq` — returns `{"dlq_depth": N}`
- `POST /webhooks/dlq/drain` — drains and returns all entries: `{"drained": N, "entries": [...]}`

Each dead-letter entry contains the original payload, target URL, attempt count, last error, and failure timestamp. Replaying drained entries is the operator's responsibility.

---

## 🧪 Future Improvements

* Issuer registry system
Expand Down
108 changes: 108 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@ pub struct AppConfig {
pub log_level: String,
pub webhook_urls: Vec<String>,
pub webhook_secret: Option<String>,
pub webhook_max_retries: u32,
pub webhook_retry_base_delay_ms: u64,
pub webhook_retry_max_delay_ms: u64,
pub webhook_request_timeout_ms: u64,
pub webhook_jitter_enabled: bool,
pub cache_verification_ttl: u64,
}

Expand Down Expand Up @@ -98,6 +103,11 @@ impl fmt::Debug for AppConfig {
"webhook_secret",
&self.webhook_secret.as_deref().map(|_| "<redacted>"),
)
.field("webhook_max_retries", &self.webhook_max_retries)
.field("webhook_retry_base_delay_ms", &self.webhook_retry_base_delay_ms)
.field("webhook_retry_max_delay_ms", &self.webhook_retry_max_delay_ms)
.field("webhook_request_timeout_ms", &self.webhook_request_timeout_ms)
.field("webhook_jitter_enabled", &self.webhook_jitter_enabled)
.field("cache_verification_ttl", &self.cache_verification_ttl)
.finish()
}
Expand Down Expand Up @@ -153,6 +163,14 @@ impl AppConfig {
}
};
let webhook_secret = env::var("WEBHOOK_SECRET").ok();
let webhook_max_retries_raw = get_env_or_default("WEBHOOK_MAX_RETRIES", "5");
let webhook_retry_base_delay_ms_raw =
get_env_or_default("WEBHOOK_RETRY_BASE_DELAY_MS", "200");
let webhook_retry_max_delay_ms_raw =
get_env_or_default("WEBHOOK_RETRY_MAX_DELAY_MS", "30000");
let webhook_request_timeout_ms_raw =
get_env_or_default("WEBHOOK_REQUEST_TIMEOUT_MS", "10000");
let webhook_jitter_raw = get_env_or_default("WEBHOOK_JITTER_ENABLED", "true");

let rate_limit_per_second_raw = get_env_or_default("RATE_LIMIT_PER_SECOND", "100");
let rate_limit_burst_raw =
Expand Down Expand Up @@ -465,6 +483,81 @@ impl AppConfig {
);
}

let webhook_max_retries: u32 = match webhook_max_retries_raw.parse() {
Ok(v) => v,
Err(_) => {
errors.push(format!(
"WEBHOOK_MAX_RETRIES must be a valid u32, got '{}'",
webhook_max_retries_raw
));
5
}
};

let webhook_retry_base_delay_ms: u64 = match webhook_retry_base_delay_ms_raw.parse() {
Ok(v) if v > 0 => v,
Ok(_) => {
errors.push("WEBHOOK_RETRY_BASE_DELAY_MS must be greater than 0".to_string());
200
}
Err(_) => {
errors.push(format!(
"WEBHOOK_RETRY_BASE_DELAY_MS must be a valid u64, got '{}'",
webhook_retry_base_delay_ms_raw
));
200
}
};

let webhook_retry_max_delay_ms: u64 = match webhook_retry_max_delay_ms_raw.parse() {
Ok(v) if v > 0 => v,
Ok(_) => {
errors.push("WEBHOOK_RETRY_MAX_DELAY_MS must be greater than 0".to_string());
30_000
}
Err(_) => {
errors.push(format!(
"WEBHOOK_RETRY_MAX_DELAY_MS must be a valid u64, got '{}'",
webhook_retry_max_delay_ms_raw
));
30_000
}
};

let webhook_request_timeout_ms: u64 = match webhook_request_timeout_ms_raw.parse() {
Ok(v) if v > 0 => v,
Ok(_) => {
errors.push("WEBHOOK_REQUEST_TIMEOUT_MS must be greater than 0".to_string());
10_000
}
Err(_) => {
errors.push(format!(
"WEBHOOK_REQUEST_TIMEOUT_MS must be a valid u64, got '{}'",
webhook_request_timeout_ms_raw
));
10_000
}
};

let webhook_jitter_enabled = match webhook_jitter_raw.to_lowercase().as_str() {
"1" | "true" | "yes" | "y" => true,
"0" | "false" | "no" | "n" => false,
other => {
errors.push(format!(
"WEBHOOK_JITTER_ENABLED must be a boolean, got '{}'",
other
));
true
}
};

if webhook_retry_max_delay_ms < webhook_retry_base_delay_ms {
errors.push(
"WEBHOOK_RETRY_MAX_DELAY_MS must be greater than or equal to WEBHOOK_RETRY_BASE_DELAY_MS"
.to_string(),
);
}

let webhook_urls: Vec<String> = webhook_urls_raw
.split(',')
.map(str::trim)
Expand Down Expand Up @@ -511,6 +604,11 @@ impl AppConfig {
log_level,
webhook_urls,
webhook_secret,
webhook_max_retries,
webhook_retry_base_delay_ms,
webhook_retry_max_delay_ms,
webhook_request_timeout_ms,
webhook_jitter_enabled,
cache_verification_ttl,
})
}
Expand Down Expand Up @@ -545,6 +643,11 @@ mod tests {
"LOG_LEVEL",
"WEBHOOK_URLS",
"WEBHOOK_SECRET",
"WEBHOOK_MAX_RETRIES",
"WEBHOOK_RETRY_BASE_DELAY_MS",
"WEBHOOK_RETRY_MAX_DELAY_MS",
"WEBHOOK_REQUEST_TIMEOUT_MS",
"WEBHOOK_JITTER_ENABLED",
"CACHE_VERIFICATION_TTL",
];
for key in keys {
Expand Down Expand Up @@ -722,6 +825,11 @@ mod tests {
log_level: "info".to_string(),
webhook_urls: vec!["https://webhook.example.com".to_string()],
webhook_secret: Some("another-secret".to_string()),
webhook_max_retries: 5,
webhook_retry_base_delay_ms: 200,
webhook_retry_max_delay_ms: 30_000,
webhook_request_timeout_ms: 10_000,
webhook_jitter_enabled: true,
cache_verification_ttl: 3600,
};

Expand Down
2 changes: 2 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ pub mod metrics;
pub mod rate_limit;
#[cfg(not(target_arch = "wasm32"))]
pub mod stellar;
#[cfg(not(target_arch = "wasm32"))]
pub mod webhook;
use soroban_sdk::{
contract, contracterror, contractevent, contractimpl, contracttype, Address, BytesN, Env,
Symbol, Vec,
Expand Down
30 changes: 29 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,24 +38,28 @@ mod native {

use axum::extract::State;
use axum::response::IntoResponse;
use axum::routing::get;
use axum::routing::{get, post};
use axum::{Json, Router};
use serde_json::json;

use proofstell_contract::config::AppConfig;
use proofstell_contract::metrics::MetricsRegistry;
use proofstell_contract::webhook::WebhookDispatcher;

/// Shared application state, accessible by all axum handlers.
#[derive(Clone)]
struct AppState {
metrics: Arc<MetricsRegistry>,
webhook: Arc<WebhookDispatcher>,
}

/// Build the axum router with all application routes.
fn build_router(state: AppState) -> Router {
Router::new()
.route("/health", get(health_handler))
.route("/metrics", get(metrics_handler))
.route("/webhooks/dlq", get(dlq_status_handler))
.route("/webhooks/dlq/drain", post(dlq_drain_handler))
.with_state(state)
}

Expand All @@ -69,6 +73,18 @@ mod native {
state.metrics.render()
}

/// `GET /webhooks/dlq` — returns the current DLQ depth.
async fn dlq_status_handler(State(state): State<AppState>) -> impl IntoResponse {
let depth = state.webhook.dlq_depth().await;
Json(json!({ "dlq_depth": depth }))
}

/// `POST /webhooks/dlq/drain` — drains and returns all DLQ entries for manual replay.
async fn dlq_drain_handler(State(state): State<AppState>) -> impl IntoResponse {
let entries = state.webhook.drain_dlq().await;
Json(json!({ "drained": entries.len(), "entries": entries }))
}

/// Bootstrap: load config, wire up services, and start the server.
pub async fn run() -> anyhow::Result<()> {
// ── Metrics ─────────────────────────────────────────────────
Expand All @@ -89,10 +105,22 @@ mod native {
"[proofstell] rate_limit: {}/s (burst {})",
config.rate_limit_per_second, config.rate_limit_burst
);
eprintln!(
"[proofstell] webhooks: {} url(s) configured (max_retries={})",
config.webhook_urls.len(),
config.webhook_max_retries,
);

// ── Webhook dispatcher ───────────────────────────────────────
let webhook = Arc::new(WebhookDispatcher::from_app_config(
&config,
Some(Arc::clone(&metrics)),
));

// ── Router ──────────────────────────────────────────────────
let state = AppState {
metrics: Arc::clone(&metrics),
webhook,
};
let app = build_router(state);

Expand Down
Loading
Loading