Your documentation is broken, DynoDocs is here to fix it

The way the software industry handles product documentation is broken, and the reason is simple: documentation is completely disconnected from the codebase.

Your code is the source of truth for what your product does. Not your help center, not your onboarding guide, and not the screenshots someone captured three months ago. The code defines what’s real – everything else is just a description that starts going stale the moment it’s written.

In practice, this means a developer ships a PR that changes the onboarding flow, and there’s nothing connecting that change to the help article that describes it. Nobody finds out the docs are wrong until a customer tells them.

This isn’t a discipline problem. No number of Slack reminders or “update the docs” checkboxes on your PR template can close the gap. The fix has to be structural: documentation generation should be automated and live inside your development pipeline.

Introducing DynoDocs

We built DynoDocs to do exactly this. AI agents handle the heavy lifting – mapping your codebase to discover user flows, analyzing PRs to flag which flows are affected, and generating documentation from real browser automation against your actual product.

The result: when a developer ships a change, DynoDocs already knows which docs need updating, re-runs the affected flows, and produces current screenshots and step-by-step guides automatically.

Because flows are defined as plain-language steps, anyone on your team can own them. A CS lead can create flows for the onboarding paths generating the most tickets. A compliance officer can verify that required disclosures are visible. A PM can make sure their feature is documented the day it ships. The people closest to the problem own the documentation, without waiting on engineering.

Why this matters right now

Software development is getting faster. AI coding tools mean more PRs, more changes, more features shipping – all of which can leave your docs out of date overnight.

That cost compounds quickly. Stale help articles become support tickets. Outdated onboarding guides lead to churned users. Last month’s screenshots in compliance docs become real risk.

Documentation tied to your code isn’t a nice-to-have: it’s the only kind that actually works.

DynoDocs is a documentation integrity platform that connects your product docs to your codebase. When your code changes, your documentation changes with it – automatically, inside the same pipeline that runs your tests. Learn more at dyno-docs.com

Determinism Series: Siliconizing Decision-Making (Index)

The goal of this series is simple:

If a decision can be turned into rules, we should “siliconize” it.
That is: move decisions from “interpretation-driven” to deterministic, replayable judgments.

“Determinism” here is not about “the correct time” or “powerful magic.”
What we really want is:

  • Ordering: don’t break which came first
  • Replay: later, with the same input + grounds + procedure, return to the same conclusion

Shared principles across the series

0) Label the target first (RML: which world is this decision in?)

Whether a decision can be “siliconized” is often determined before implementation—by the world (responsibility scope) the decision belongs to.

This series assumes a “world label” up front:

  • RML-1 (Closed World): not outside yet / safe to fail (simulation, dry-run)
  • RML-2 (Dialog World): counterparties exist / recover via dialog + compensation (sagas, retries, reconcile)
  • RML-3 (History World): irreversible history / correct-forward with refunds, explanations, audits (ledger)

Once you fix:

“Which world does this decision live in?”

…it becomes much harder for the design to drift:

  • how far you may automate
  • where human approval or correction events are needed
  • what must be kept as “non-erasable logs”

1) Split proposing from deciding

Whether it’s an LLM or a human, proposals can wobble—and that’s fine.
What must be stable is the side that decides accept/reject:

  • Propose (probabilistic): LLM/humans produce a plan
  • Verify (deterministic): rules return ACCEPT / REJECT / DEGRADE
  • Execute (authority + audit): only verified, typed operations may pass

2) Make “insufficient information” first-class (DEGRADE)

In real operations, the common failure is not “rule violations,” but missing grounds.

So the first branch to design is not REJECT, but:

DEGRADE (defer / hold / request missing)

DEGRADE is not “stop.” It’s stop in a re-enterable way:
enumerate missing fields mechanically, and make it operable under SLOs.

3) Pin the grounds into logs (make incidents replayable)

A “plausible explanation” is not auditable grounds.
What must be pinned is:

  • input schema
  • policy/version
  • reason codes
  • evidence IDs
  • an authority for ordering

Reading order (recommended)

From top to bottom, it naturally flows: agents → operations → distributed foundations.

1) The Real Reason AI Agents “Work” in Software (series intro)

Why “agents succeed” in software is not model intelligence, but deterministic guardrails (verifiers) already baked into the environment.

2) Don’t “Execute” the LLM: Typed Actions + Verifiers for Safe Business Agents

Never execute free text. Use Typed Actions + a deterministic verifier to prevent operational accidents.

3) Design DEGRADE (Defer) and Your Agent Becomes “Operations”

Once you can handle “missing,” you eliminate interpretation hell and turn workflows into re-enterable operations.

4) Grow the Verifier, Not the Prompt: Run Production with 10 Golden Cases

What must be stabilized is not the LLM output, but the verifier output:
verdict / reason / missing / normalized_plan. Includes how to “grow” it under CI.

Domain application (turn determinism into business reality)

5) Billing That Won’t Break: Deterministic Design to Kill Double Charges (API Contract × DB Boundary × Execution Patterns)

In “fatal if broken” billing domains, push design toward ordering, idempotency, auditability:
API contracts × DB boundaries × execution patterns.

6) Make A/B Tests Operable: Goal Surface × Deterministic Logs × Safe Automation (dry-run / shadow / canary / rollout)

Turn experiments from “one-off analyses” into operations:
connect multi-objective + constraints (goal surface) with DEGRADE, watermarking, and pinned logs.

Foundations (don’t break ordering and replay)

7) Deterministic Time: Don’t Break Ordering and Replay in Distributed Systems

Even without TrueTime, you can build “ordering authority” via seq / watermark / HLC.
Also covers late data, trace closure, and dual writes (outbox, event sourcing).

Series checklist (when you’re stuck, come back here)

  • If discussions don’t align: Which world is it (RML-1/2/3)?
  • If decisions wobble: did you separate proposal and verification?
  • If you keep getting blocked by missing info: are DEGRADE and missing first-class?
  • If it turns into meetings: are reason codes typed?
  • If you can’t explain it later: are inputs / policy / snapshots / ordering pinned to logs?
  • If long-running jobs break: are they state-machine based + re-enterable? (Fix dual writes via outbox / event sourcing.)
  • If ordering breaks in distributed systems: which authority did you choose—seq / watermark / HLC?

Related series

The Worlds of Distributed Systems — Align Your Team’s Mental Model:

This determinism series is a parts kit for verification, evidence, and operations—but in practice, you often need to label the target world first.

  • RML-1: grow verifiers safely inside a room
  • RML-2: make workflows re-enterable via dialog + compensation (DEGRADE / action hints / retries)
  • RML-3: keep history, reconcile with correction events (ledgers / audits / explanations)

Once the “world” is aligned, it becomes much easier to see where you need:
deterministic logs, DEGRADE, watermarking, outbox/event sourcing, and more.

Closing

This series is not about “perfecting everything.”
It’s a parts kit for siliconizing the most fragile decisions first.

Start with just:

  • a minimal verifier, and
  • minimal pinned logging

Then grow it using SLOs and reason codes.

The long-term endgame is a system that can make decisions autonomously—and when it hits a wall, it uses the failure as training data:

  • unexpected events or missing inputs are recorded in deterministic logs,
  • the next step becomes cheaper to decide,
  • and if the decision is rule-able, you update the domain compiler and golden cases so the next run becomes automated.

Pushing full automation that far isn’t easy—but the operational load can be managed as engineering:
SLOs, reason codes, and staged adoption, not heroic effort.

At first, “Verifier + pinned logs” already pays off.
When you want more, add in order: domain compiler → golden case updates → proposal generation (LLM).

I Built a Free WCAG Accessibility Audit CLI for Government Teams

Every government website in the US is required to meet Section 508 accessibility standards. Most commercial tools cost hundreds per month. So I built an open source alternative.
**

The Problem **

If you’re a developer working on a .gov site, you need to verify WCAG compliance before every deploy. Your options

are:

  • Manual testing — slow, inconsistent, doesn’t scale
  • Commercial tools (Siteimprove, Level Access) — $500+/month
  • Browser extensions (axe DevTools) — great for one page, but can’t scan a whole site or run in CI

I wanted something that:

  1. Runs from the terminal
  2. Scans entire sites via sitemap
  3. Outputs JSON/CSV for CI pipelines
  4. Costs nothing

wcag-audit

npx wcag-audit scan https://your-site.gov

That’s it. No API keys, no account, no config files.

It launches a headless browser, injects https://github.com/dequelabs/axe-core (the same engine Google and Microsoft

use), and returns a report with every WCAG violation, the affected elements, and how to fix them.

What the output looks like

════════════════════════════════════════════════════
WCAG ACCESSIBILITY AUDIT REPORT

════════════════════════════════════════════════════

URL:      https://example.gov                                                                                     
Title:    Example Government Site
Level:    WCAG AA

Critical: 2  Serious: 5  Moderate: 8  Minor: 3                                                                      

[critical] image-alt: Images must have alternate text                                                               
  WCAG: wcag2a, wcag111                                                                                           
  Elements affected: 4
    → img.hero-banner
      Fix: Element does not have an alt attribute

[serious] color-contrast: Elements must meet minimum
  color contrast ratio                                                                                              
  WCAG: wcag2aa, wcag143                                                                                          
  Elements affected: 12
    → .nav-link
      Fix: Element has insufficient color contrast

════════════════════════════════════════════════════

Every violation tells you:

  • The rule that failed
  • The severity (critical, serious, moderate, minor)
  • Which WCAG criterion it violates
  • The CSS selector of the affected element
  • How to fix it

Scan an Entire Site

wcag-audit crawl https://example.gov/sitemap.xml –max-pages 50

Reads the sitemap, scans each page, and produces a consolidated report.

Drop it into CI

The CLI exits with code 1 when violations are found:

# GitHub Actions

  • name: Accessibility Audit
    run: npx wcag-audit scan ${{ env.DEPLOY_URL }} –level AA

Now accessibility is enforced on every deploy. No violations, no merge.

JSON and CSV for Reporting

# JSON for programmatic use

wcag-audit scan https://example.gov –format json –output report.json

# CSV for spreadsheets and compliance reports
wcag-audit scan https://example.gov –format csv –output report.csv

The CSV is designed for compliance teams who need to track violations in spreadsheets and produce audit reports for

management.

WCAG Levels

# Level A (minimum)
wcag-audit scan https://example.gov –level A

# Level AA (required for US federal, most common)

wcag-audit scan https://example.gov –level AA

# Level AAA (strictest)

wcag-audit scan https://example.gov –level AAA

Most government sites need AA. The tool defaults to AA.

Use it as a Library

const { scanUrl, formatTextReport } = require(‘wcag-audit’);

const results = await scanUrl(‘https://example.gov‘, {
level: ‘AA’,
viewport: { width: 1280, height: 720 },
});

if (results.summary.impactBreakdown.critical > 0) {

console.error(‘Critical accessibility violations found!’);
process.exit(1);

}

Why I Built This

I’ve been contributing to accessibility-related projects across multiple government agencies — the US Web Design

System (USWDS), the UK’s GOV.UK Frontend, Singapore’s GovTech accessibility tool (oobee), and Grafana’s
colorblind-safe palette. Every one of these projects deals with the same problem: making sure websites are accessible
to everyone.

The tooling gap was obvious. Developers who care about accessibility shouldn’t need to pay for the privilege of

testing it.

Tech Stack

Install

npm install -g wcag-audit

Or try without installing:

npx wcag-audit scan https://example.com

Links

The tool is MIT licensed. PRs welcome. If you work on a government site and this saves you time, I’d love to hear
about it.

Your Debounce Is Lying to You

Screenshot

Debounce is one of those patterns every frontend developer learns early and keeps using forever.

At its core, debouncing does one thing well: it coalesces a burst of calls into one invocation after a quiet window. That is a great fit for noisy UI signals.

Its most familiar use case is autocomplete, but the same pattern applies to resize handlers, scroll listeners, live validation, filter controls, and telemetry hooks.

A typical implementation looks like this:

function debounce(fn, delay) {
    let timer;
    return (...args) => {
        clearTimeout(timer);
        timer = setTimeout(() => fn(...args), delay);
    };
}

const search = debounce(async (q) => {
    const res = await fetch(`/api/search?q=${q}`);
    const data = await res.json();
    render(data);
}, 300);

It looks disciplined. It feels efficient. It ships fast.

And this is where the title comes from.

The issue is not debounce itself. The issue is this vanilla debounce + fetch pattern once real network behavior enters the picture.

It gives the feeling that requests are “under control,” but it does not control request lifecycle: response ordering, cancellation of stale work, or failure behavior.

That is why it feels like debounce is “lying” in production: the UI looks smoothed, while the network layer is still fragile.

In this article, we will keep debounce for what it is good at (UI smoothing), then harden the request path with cancellation, retries, and better error handling.

The Illusion of “Fixed” Behavior

Debounce is convincing: you type quickly, the UI triggers fewer calls, and the network tab looks quieter. It feels like the system is now stable. But in production, under real network conditions, many things can go wrong. You will experience stale data, wasted requests, silent failures and other unexpected behaviors.

This is true for any network request, but debounce adds another layer of complexity: it makes the UI look smooth while the network can still be unpredictable. This mismatch can create a false sense of security.

Debounce itself only guarantees one thing:

“I won’t call this function too often.”

Debounce smooths input frequency, not request lifecycle. It does not guarantee:

  • Responses arrive in order.
  • Stale requests stop running.
  • Failures are handled consistently.

In other words: it is a UI pattern, not a network pattern. So you have to make sure the underlying network layer is robust enough to handle real-world conditions. Before we examine what can go wrong, let’s set the stage!

The Companion Code

To make these problems visible, we have a companion demo app with a single text input where every keystroke triggers a debounced request to /api/echo?q=<input>. The backend is an Express server that returns { query, timestamp }, and the frontend appends each response to a div as query@timestamp. The stack is minimal: Node.js + Express on the backend, and plain HTML/CSS/JavaScript in the browser.

Clone the repo and install dependencies:

git clone https://github.com/gkoos/article-debouncing.git
cd article-debouncing
npm install

Then start the app:

npm start

And navigate to http://localhost:3000 in your browser. You will see something like this:

Screenshot

Now, as you type in the input field, you will see the responses coming back in the list below:

Screenshot

The UI also shows toast notifications for request success and failure, which will become relevant in later sections.

This is our baseline setup, it demonstrates the basic pattern. There is a 300ms debounce on the input, and the backend immediately responds with the query and a timestamp. The UI appends each response to the list.

Problem 1: Race Conditions (aka Stale UI)

On your local machine, everything is fast and smooth. But in production, network conditions are unpredictable. Requests can take varying amounts of time to complete, so there is no guarantee that responses will arrive in the same order they were sent. Let’s see what happens if we add random delays to the server response to simulate real network conditions.

Check out the 01-stale-requests branch and restart the server:

git checkout 01-stale-requests
npm start

We added a middleware that introduces a random delay of 0–1000ms for each request. Now, when you type quickly, you might see responses arriving out of order:

Screenshot

We typed 12345678, but the UI shows 1234567! The response for 7 came back after 8, so the UI is now stale. This is a classic race condition, and debounce itself does not prevent it. The UI is showing results for an older query, which can lead to confusion and errors in a real application.

How to fix this? We need to ensure that only the latest request’s response is processed, and any previous requests are either cancelled or ignored. We could implement a simple version of this by keeping track of the latest query and ignoring responses that don’t match it. But that would still allow all requests to run, which is inefficient. A better approach is to use the AbortController API to cancel stale requests, so they don’t consume resources or trigger side effects when they complete.

AbortController is a browser-native API. You create a controller, pass its signal to fetch, and call abort() whenever you want to cancel the request. The fetch will throw an AbortError, which you can catch and ignore since it’s expected.

Here is the updated debounce callback with cancellation:

let controller;

const debouncedFetch = debounce(async (q) => {
  if (!q) return;

  if (controller) controller.abort();
  controller = new AbortController();

  try {
    const response = await fetch(`/api/echo?q=${encodeURIComponent(q)}`, {
      signal: controller.signal
    });
    const data = await response.json();
    // render data...
  } catch (err) {
    if (err.name === 'AbortError') return;
    // handle real errors...
  }
}, 300);

Two things changed:

  • Before each request, we abort any in-flight request from the previous call and create a fresh controller.
  • After catching an error, we check if it’s an AbortError and return early: these are expected and not real failures.

The result: in normal flow, only the last request in a typing burst ever completes. Previous ones are cancelled at the network level, not just ignored after the fact. (For absolute safety, you can also guard the render step with a request ID check. This covers the tiny edge window where a response resolves right before a newer request aborts the previous one.)

Problem 2: Network Failures

The network is not only unpredictable in terms of latency, but also in terms of reliability. Sometimes a request can fail that would have succeeded if retried. This can be due to transient server issues like network congestion, temporary spikes in load, or database timeouts. If we want a more robust user experience, we need to handle these failures gracefully.

Let’s simulate random failures in our backend. Check out the 02-failures branch and restart the server:

git checkout 02-failures
npm start

This version of the server adds a random failure mechanism: each request has a 40% chance to fail with a 500 error. Now this may be too aggressive, but it will help us see the problem clearly. When you type in the input field, you will see some requests fail:

Screenshot

Well, that’s not good. First, our UI shows undefined@undefined when a request fails. That happens because the server returns { error: 'Internal Server Error' } on a 500, so data.query and data.timestamp are both undefined. Vanilla fetch doesn’t throw on HTTP error status codes: it only rejects on network failures. So we need to check response.ok ourselves:

const response = await fetch(`/api/echo?q=${encodeURIComponent(q)}`, {
  signal: controller.signal
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();

Now a 500 throws before we ever touch the body, the catch handles it, and the error toast shows instead of broken output.

But that’s just the start. In a real app, you would want to implement some retry logic for transient failures. For example, you could automatically retry a failed request up to 3 times with exponential backoff. This way, if a request fails due to a temporary issue, it has a chance to succeed without the user having to do anything.

To implement this manually you’d need to write retry loops, track attempt counts, implement backoff timing, and make sure none of it fires after a cancellation. That’s not trivial, and it’s not the interesting part of your app, so let’s use a library for that.

@fetchkit/ffetch is a thin wrapper around fetch that handles exactly this. We’ll use it for the retry behavior in our demo.

There are good alternatives in this space (for example ky, axios, or a custom wrapper). I chose ffetch here because it keeps a fetch-compatible API surface and handles abort-aware retries cleanly.

Since this is a minimal demo with no build step, we load it directly from a CDN rather than installing it. In a real project you’d npm install @fetchkit/ffetch and import it normally, but here a single import line is enough:

import { createClient } from 'https://esm.sh/@fetchkit/ffetch';

const api = createClient({
  retries: 3, // retry up to 3 times on failure
  shouldRetry: (ctx) => ctx.response?.status >= 500, // only retry on 5xx errors
});

api has the exact same call signature as fetch: same arguments, same return type. You drop it in as a replacement:

const response = await api(`/api/echo?q=${encodeURIComponent(q)}`, {
  signal: controller.signal
});

What this buys us in this specific scenario:

  • retries: 3 — if the server returns a 500, ffetch retries up to 3 more times before giving up
  • shouldRetry — we only retry on 5xx; anything else (network error, abort) propagates immediately
  • abort-aware backoff — if controller.abort() fires during the delay between retries, the wait exits immediately and the abort propagates; no stale work keeps running in the background

That last point is quite convenient here. Without it, aborting a request mid-retry would cut the active fetch but leave the backoff timer running, which would then fire another fetch attempt that instantly aborts. Now we handle this correctly.

There’s one more thing we can clean up. Because the native fetch does not throw on HTTP error status codes (one of the pain points of the API), we had to add a manual check:

if (!response.ok) throw new Error(`HTTP ${response.status}`);

ffetch can handle this too. With the throwOnHttpError: true config option, any HTTP error response throws automatically, no manual check needed.

const api = createClient({
  retries: 3,
  shouldRetry: (ctx) => ctx.response?.status >= 500,
  throwOnHttpError: true,
});

Now the fetch call is just:

const response = await api(`/api/echo?q=${encodeURIComponent(q)}`, {
  signal: controller.signal
});
const data = await response.json();

The catch block still handles everything – HTTP errors, network failures, real errors – without any extra branching in the happy path.

The final implementation can be found in the 03-fixed branch:

git checkout 03-fixed
npm start

For reference, the full code for the debounce callback with ffetch can be found here.

Conclusion

Debounce is not the problem. The problem is treating it as a complete solution for network control when it only handles one dimension of it: call frequency. It is a very useful pattern for smoothing out noisy UI signals, but it does not handle the complexities of real network behavior. To build a robust application, you need to complement debounce with proper request lifecycle management: cancellation of stale requests, retries with backoff for transient failures, and consistent error handling. This way, you can ensure that your UI remains not only responsive but also accurate even under unpredictable network conditions.

The YieldBlox $10M Oracle Poisoning: How One Trade in a Dead Market Drained an Entire Lending Pool — And the 5-Defense Pattern Every Protocol Needs

On February 22, 2026, a single trade worth barely $5 on the Stellar DEX inflated a token’s price by 100×, allowing an attacker to borrow $10.97 million against worthless collateral. The YieldBlox exploit wasn’t a smart contract bug — it was an economic attack that every DeFi protocol using on-chain oracles must study.

The Setup: A Perfect Storm of Illiquidity

YieldBlox operated as a Blend V2 lending pool on Stellar, accepting USTRY (a U.S. Treasury-backed token worth ~$1.05) as collateral. It sourced prices from Reflector, a volume-weighted average price (VWAP) oracle that sampled trades on the Stellar Decentralized Exchange (SDEX).

Here’s the problem: the USTRY/USDC market on SDEX was essentially dead. Less than $1 in hourly volume. Virtually no order book depth. And just before the attack, the sole market maker had withdrawn all liquidity.

The Reflector oracle was designed correctly — it faithfully reported volume-weighted prices from the market. But a VWAP oracle in a market with zero liquidity is like a thermometer in a vacuum: technically accurate, practically meaningless.

The Attack: Five Steps to $10.97M

Step 1 — Poison the Oracle

The attacker placed a sell offer for USTRY at ~501 USDC per USTRY — roughly 500× its real value. Using a second account, they bought 0.05 USTRY at this price (~$5 total spend).

With zero competing trades in the VWAP window, this single transaction dominated the oracle’s calculation. Reflector dutifully updated: USTRY = ~$106.

Step 2 — Deposit Overvalued Collateral

Armed with the poisoned oracle, the attacker deposited 13,003 USTRY as collateral into YieldBlox. The protocol valued this at 13,003 × $106 = ~$1.38M instead of its real worth of ~$13,650.

Step 3 — Borrow USDC

Against this phantom collateral, the attacker borrowed 1,000,196 USDC — 73× the actual value of their deposit.

Step 4 — Double Down

The attacker deposited an additional 140,000 USTRY and borrowed approximately 61 million XLM (Stellar’s native token).

Step 5 — Bridge and Run

The stolen assets were swapped to USDC, bridged from Stellar → Base via Allbridge, then moved Stellar → Ethereum via Across and Relay. Total take: ~$10.97M in bad debt.

Root Cause Analysis

Factor Details
Oracle Design VWAP oracle without minimum-volume thresholds or liquidity guards
Market Conditions Sub-$1 hourly volume; zero market makers at time of attack
Protocol Guards No price deviation circuit breakers, no sanity bounds on collateral valuation
Smart Contract Code No bugs — Blend V2 contracts functioned as intended

This is what makes the YieldBlox exploit instructive: every component worked correctly in isolation. The oracle reported real trades. The lending protocol accepted valid oracle prices. The collateral system followed its rules. The failure was systemic — a gap between individual correctness and economic safety.

The Broader Pattern: Oracle-as-Attack-Surface

YieldBlox joins a growing list of oracle manipulation exploits in 2026:

  • Venus Protocol ($3.7M) — Flash-loan-assisted collateral inflation bypassed supply caps via getCashPrior() manipulation
  • Moonwell ($1.78M) — AI-generated exploit manipulated an oracle dependency
  • Truebit ($26.2M) — Classic oracle overflow attack on price feed arithmetic

The common thread: protocols that treat oracle output as ground truth without validating the conditions under which that truth was produced.

Defense Patterns

1. Minimum Liquidity Thresholds

Never accept oracle prices from markets below a volume/depth floor:

# Pseudocode for oracle consumer
MIN_24H_VOLUME = 100_000  # USD
MIN_DEPTH_BOTH_SIDES = 50_000  # USD

def get_safe_price(asset):
    market = get_market_stats(asset)
    if market.volume_24h < MIN_24H_VOLUME:
        raise OracleUnsafe("Insufficient volume")
    if market.bid_depth < MIN_DEPTH_BOTH_SIDES:
        raise OracleUnsafe("Insufficient depth")
    return oracle.get_price(asset)

2. Price Deviation Circuit Breakers

Reject prices that deviate more than X% from a time-weighted baseline:

// Solidity — price sanity check
uint256 constant MAX_DEVIATION_BPS = 1000; // 10%

function validatePrice(
    uint256 newPrice,
    uint256 twapPrice
) internal pure returns (bool) {
    uint256 deviation = newPrice > twapPrice
        ? ((newPrice - twapPrice) * 10000) / twapPrice
        : ((twapPrice - newPrice) * 10000) / twapPrice;
    return deviation <= MAX_DEVIATION_BPS;
}

3. Multi-Oracle Consensus

Require agreement from ≥2 independent price sources before accepting a valuation:

function getConsensusPrice(address asset) internal view returns (uint256) {
    uint256 priceA = oracleA.getPrice(asset);
    uint256 priceB = oracleB.getPrice(asset);

    uint256 diff = priceA > priceB
        ? priceA - priceB
        : priceB - priceA;
    uint256 avg = (priceA + priceB) / 2;

    // Reject if oracles diverge more than 5%
    require(diff * 10000 / avg <= 500, "Oracle divergence");
    return avg;
}

4. Collateral Valuation Caps

Set hard dollar ceilings per collateral type regardless of oracle price:

mapping(address => uint256) public maxCollateralValue;

function depositCollateral(address token, uint256 amount) external {
    uint256 value = oracle.getPrice(token) * amount / 1e18;
    uint256 cap = maxCollateralValue[token];
    require(cap > 0, "Unsupported collateral");

    // Cap effective value
    uint256 effectiveValue = value > cap ? cap : value;
    userCollateral[msg.sender] += effectiveValue;
}

5. Time-Delay Borrowing for New Collateral

Force a waiting period between collateral deposit and borrowing, giving oracles time to reflect any manipulation:

uint256 constant BORROW_DELAY = 2 hours;

mapping(address => uint256) public lastDepositTime;

function borrow(uint256 amount) external {
    require(
        block.timestamp >= lastDepositTime[msg.sender] + BORROW_DELAY,
        "Borrow delay not met"
    );
    // ... borrow logic
}

Audit Checklist: Oracle Integration Safety

Before integrating any price oracle, ask these 7 questions:

  1. What happens if market liquidity drops to zero? — Can a single trade move the oracle?
  2. Is there a minimum sample size? — VWAP with 1 trade ≠ VWAP with 1,000 trades
  3. Are there price deviation bounds? — What’s the maximum acceptable single-period price change?
  4. Do you use multiple oracle sources? — Single-source oracles are single points of failure
  5. Is there a freshness check? — How old can oracle data be before it’s rejected?
  6. Are there collateral caps? — Can any single asset dominate the collateral pool?
  7. Is there a borrow delay? — Can an attacker deposit + borrow in the same transaction?

Recovery & Aftermath

Stellar Tier-1 validators froze approximately $7.2M of the stolen XLM, enabling partial recovery. The attacker’s bridging path (Stellar → Base → Ethereum) was traced via Allbridge and Across, demonstrating that cross-chain movement doesn’t guarantee anonymity when validators act quickly.

YieldBlox’s incident response — while ultimately recovering ~65% of funds — exposed a critical gap: no monitoring detected the oracle price moving 100× in a single update. Real-time anomaly detection on oracle feeds should be table stakes for any lending protocol in 2026.

Key Takeaways

For protocol developers: Oracles are only as strong as the markets they sample. A correctly-functioning oracle in an illiquid market is an attack vector, not a safety mechanism.

For auditors: Add “market conditions” to your threat model. Auditing contract logic without stress-testing oracle behavior under adversarial market conditions misses an entire class of economic attacks.

For DeFi users: Before depositing into a lending protocol, check what oracle it uses, what markets feed that oracle, and whether those markets have meaningful liquidity. If the oracle source is a DEX with $50 in daily volume, your funds are at risk.

The YieldBlox exploit is a masterclass in economic attack design — no bugs exploited, no code broken, just a $5 trade that turned into $10.97M. In DeFi, the oracle isn’t just infrastructure. It’s the attack surface.

PCIe Overview

PCIe is widely used across modern computing systems, powering devices such as SSDs, GPUs, and network interfaces.

This article provides a structured overview of PCIe with an emphasis on practical understanding. It covers key concepts including topology, protocol layers, BARs, DMA, and interrupt mechanisms

What is PCIe

PCIe (Peripheral Component Interconnect Express) is a high-speed serial bus standard used to connect peripheral devices to the main processor.

The older PCI bus was parallel—it had 32 or 64 data lines all switching simultaneously. However, parallel buses have a fundamental limitation at high frequencies: signals on different wires arrive at slightly different times (called skew), making it difficult to scale to higher speeds.

PCIe uses a high-speed serialized bitstream over differential pairs of wires (called lanes). Each lane consists of one differential pair for transmit and one for receive. Because there are only two wires per direction per lane, PCIe can operate at much higher frequencies without skew-related issues.

Feature PCI PCIe
Signal Type Parallel (32/64 wires) Serial (differential pair per lane)
Topology Shared bus Point-to-point (switched fabric)
Max Bandwidth ~533 MB/s Up to ~128 GB/s (Gen5 x16)
Interrupt IRQ Lines MSI / MSI-X

PCIe uses a point-to-point topology. Each device connects through a dedicated link to the Root Complex or via switches. There is no shared electrical bus; instead, PCIe forms a hierarchical switched interconnect.

Basic PCIe Topology

+-----------------------------------------------+
|                   SoC                         |
|                                               |
|  +-------+            +-------------------+   |
|  |  CPU  |<---------->| Root Complex (RC) |   |
|  +-------+  AXI bus   |                   |   |
|                       |                   |   |
|  +-------+            |                   |   |
|  | DRAM  |<---------->|                   |   |
|  +-------+  AXI bus   +---------^---------+   |
|                                 |             |
+---------------------------------|-------------+
                                  |
                        =====================
                              PCIe Link
                        =====================
                                  |
                        +---------v---------+
                        |   Endpoint (EP)   |
                        +-------------------+

Key Components:

Root Complex (RC):
The Root Complex is the PCIe host controller inside the SoC. It acts as a bridge between the CPU’s memory bus (AXI/AHB) and the PCIe fabric. It:

  • Initiates configuration space reads/writes during enumeration
  • Translates CPU memory accesses into PCIe TLPs (Transaction Layer Packets)
  • Receives TLPs from endpoints and converts them into memory transactions

Endpoint (EP):

  • Exposes registers and memory regions via BARs (Base Address Registers)
  • Can initiate DMA transfers to/from host memory
  • Signals the CPU using MSI/MSI-X interrupts

PCIe Switch (optional):
A PCIe switch expands a single upstream port into multiple downstream ports, allowing multiple endpoints to connect. This creates a hierarchical topology rather than a simple bus.

PCIe Link Layers

PCIe uses a layered architecture:

+------------------------+
| Transaction Layer (TL) |
+------------------------+
| Data Link Layer (DLL)  |
+------------------------+
| Physical Layer (PL)    |
+------------------------+

Physical Layer (PL):

  • Transmits serialized data over differential pairs (TX+/TX- and RX+/RX-)
  • Uses encoding schemes (8b/10b for Gen1/2, 128b/130b for Gen3+) to embed clock information
  • Performs link training using LTSSM (Link Training and Status State Machine), negotiating lane width, speed, and equalization parameters

Data Link Layer (DLL):

  • Adds sequence numbers and LCRC (Link CRC) to ensure data integrity
  • Implements ACK/NAK-based retransmission for reliable delivery

Transaction Layer (TL):

  • Creates and processes Transaction Layer Packets (TLPs)
  • Supports different types of transactions:

    • Memory Read (Non-posted): Request is sent, and a Completion TLP returns data
    • Memory Write (Posted): Sent without requiring a completion
    • Configuration Read/Write: Used during enumeration
    • Message TLPs: Used for MSI/MSI-X interrupts

PCIe Lanes and Link Speed

A PCIe lane consists of one differential pair for transmit and one for receive, enabling full-duplex communication.

Link Width Notation Typical Use Case
1 lane x1 Low-bandwidth peripherals
4 lanes x4 SSDs, NICs
8 lanes x8 High-performance NICs
16 lanes x16 Graphics cards (GPUs)

PCIe generations define per-lane throughput:

Generation Raw Rate Effective Bandwidth per Lane
Gen1 2.5 GT/s 250 MB/s
Gen2 5 GT/s 500 MB/s
Gen3 8 GT/s ~985 MB/s
Gen4 16 GT/s ~1969 MB/s

Gen1/Gen2 use 8b/10b encoding, while Gen3 and above use 128b/130b encoding, which improves efficiency.

Example:
A PCIe Gen3 x2 link provides ~2 GB/s bandwidth in each direction.

PCIe Configuration Space

PCIe devices expose a configuration space used by the host (Root Complex) to discover devices, read capabilities, and configure them.

The first 256 bytes follow the legacy PCI configuration format for backward compatibility. PCIe extends this to a total of 4 KB.

Standard 256-byte PCI Configuration Space:

00: VendorID  DeviceID
04: Command   Status
08: RevID ProgIF SubClass ClassCode
0C: CacheLine LatTimer HeaderType BIST

10: BAR0
14: BAR1
18: BAR2
1C: BAR3
20: BAR4
24: BAR5

28: CardBus CIS
2C: SubVendor  SubDevice
30: Expansion ROM
34: CapPtr
38: Reserved
3C: IRQLine IRQPin MinGnt MaxLat

40-FF: Capabilities / Device specific registers

Header Types:

HeaderType Device Type
0x00 Endpoint
0x01 PCI-to-PCI Bridge

Bridges include bus routing registers assigned during enumeration:

0x10  BAR0
0x14  BAR1

0x18  Primary Bus Number
0x19  Secondary Bus Number
0x1A  Subordinate Bus Number

BAR – Base Address Register

A BAR (Base Address Register) allows a PCIe endpoint to expose memory or register regions to the host.

Each endpoint can have up to 6 BARs. Each BAR defines:

  • Type: Memory (MMIO) or legacy I/O
  • Size: Power-of-two region size
  • Prefetchable: Indicates reads have no side effects and can be cached/prefetched
  • Address Width: 32-bit or 64-bit

BAR sizing mechanism:

During enumeration, the OS writes 0xFFFFFFFF to a BAR and reads it back. The device returns a mask indicating the size.

Example:

  • Returned value: 0xFFFF0000
  • Size: 64 KB

After sizing, the OS assigns a physical address. The driver maps it into virtual space using functions like pci_iomap().

MSI / MSI-X

Traditional PCI used dedicated interrupt lines (physical wires). PCIe replaces these with message-based interrupts.

How MSI works:

During setup, the device is programmed with:

  • A target address in host memory (interrupt controller region)
  • A data value

When the device generates an interrupt:

  • It sends a Memory Write TLP to that address with the data
  • The CPU’s interrupt controller interprets this as an interrupt

DMA

DMA allows devices to directly access system memory without CPU involvement.

Types of DMA:

  • Inbound DMA (Device → Host):
    Device sends Memory Write TLPs to host memory

  • Outbound DMA (Device ← Host):
    Device sends Memory Read TLPs and receives Completion data

Addressing:

  • CPU uses virtual addresses (via MMU)
  • Devices use:

    • Physical addresses, or
    • IOMMU-translated IO virtual addresses (IOVA)

Tuz Gölü fayının yılda 1,3 milimetre dikey hareket ettiği, İç Anadolu'nun da genişlediği ortaya çıktı

Konya Teknik Üniversitesi’nden Dr. Gülin Gençoğlu Korkmaz’ın baş araştırmacısı olduğu çalışmada, Tuz Gölü fayının yılda 1,3 milimetre dikey hareket ettiği ve İç Anadolu’nun doğu-batı yönünde genişlemesine katkı sağladığı belirlendi. ‘Zircon double dating’ tekniğiyle yapılan araştırmada, fayın normal bir fay olduğu kanıtlandı.