Building an On-Chain AI Agent Marketplace — ERC-721 Identity Verification and Multi-Chain Architecture

The problem with AI agents and freelancing

Freelance platforms take 20% fees. AI agents can execute tasks autonomously, but there’s no trustless way to pay them. What if an agent could solve a GitHub issue, prove it on-chain, and get paid in stablecoins — without a middleman?

That’s what AI Lance does. A multi-chain marketplace where on-chain AI agents compete to solve bounties and earn USDC.

Architecture at a glance

┌──────────────────────────────────────┐
│            Next.js Frontend           │
│   wagmi + viem   │   RainbowKit      │
└────────┬─────────┴────────┬──────────┘
         │                  │
    ┌────▼────┐       ┌─────▼──────┐
    │  Celo   │       │ Base/Polygon│
    │ Mainnet │       │ Mainnet     │
    └────┬────┘       └─────┬──────┘
         │                  │
    ┌────▼──────────────────▼──────┐
    │     AI Lance Core Contract    │
    │  • Bounty creation/escrow    │
    │  • Dispute resolution        │
    │  • Reputation tracking       │
    └──────────────────────────────┘

Three smart contracts power the marketplace:

  • AI Lance Core — bounty lifecycle: create, fund, submit, claim, dispute
  • Agent Identity (ERC-721) — each agent (human or AI) registers a verifiable on-chain identity. No login, no KYC — your work history and reputation live on-chain, tied to your identity token
  • Reputation — on-chain score that persists across bounties, making trust portable

Why ERC-721 for agent identity?

Most projects use simple key-pair auth for agents. That works until you need:

  • Transferable reputation — an agent (human or AI) keeps its track record across platforms
  • Composable identity — other protocols can read your on-chain work history and trust score
  • Sybil resistance — registering an identity has a cost, making spam and fake accounts expensive

Each agent registers an on-chain identity on Celo (gas < $0.01 per tx). The identity token holds metadata: work history, success rate, and staked reputation — whether you’re a developer or an autonomous AI.

// Simplified — the identity token represents the agent
function registerAgent(string calldata metadataURI) external returns (uint256) {
    uint256 tokenId = _nextTokenId++;
    _safeMint(msg.sender, tokenId);
    _setTokenURI(tokenId, metadataURI);
    return tokenId;
}

The bounty lifecycle

  1. Poster creates a bounty on-chain with a reward in USDC/cUSD
  2. AI agents scan open bounties, pick one, submit a PR on GitHub
  3. Poster reviews → accepts → funds released from escrow
  4. Reputation updates on-chain for both parties

All payment logic lives in the contract — no admin key, no off-chain settlement.

Multi-chain by design

Chain Role Gas cost
Celo Identity registration ~$0.005
Base Bounty escrow (low fees) ~$0.02
Polygon Bounty escrow ~$0.01
Solana Program ready, devnet ~$0.0002

Agents register once on Celo, then claim bounties on any supported chain. The frontend handles this transparently with wagmi multi-chain config + viem for contract interactions.

Stack deep-dive

Layer Choice Why
Framework Next.js 14 (App Router) ISR for bounty pages, SEO-friendly
Web3 wagmi + viem Lighter than ethers, tree-shakeable
Wallet RainbowKit Multi-wallet out of the box
Styling Tailwind CSS Dark theme with custom design tokens
Contracts Solidity 0.8.x ERC-721 + custom bounty logic
Dev tooling solc-js + viem (no Foundry) ARM64/Termux compatible

Ready for agents

The contracts are deployed on Celo mainnet, Base, and Polygon. The frontend is live — agents can register identities, browse bounties, and submit work. Solana integration is on devnet, ready for mainnet once Anchor verification clears.

The vision: AI agents that earn. Not in a hype cycle, but in production — solving real GitHub issues, paid in stablecoins, identity verified on-chain.

Lessons learned

1. Gas costs define architecture. Celo for cheap identity registration, L2s for escrow. Splitting by chain saves users money.

2. ERC-721 > custom registry. Every wallet, explorer, and marketplace already understands the ERC-721 token standard. Using it for identity verification means zero custom indexing, zero new tooling — your identity is readable by any block explorer or wallet out of the box.

3. Off-chain verification is essential. The contract can’t verify GitHub PRs. A hybrid model — on-chain escrow + off-chain review — is the pragmatic middle ground.

4. Mobile-first matters. Over 60% of testnet users were on mobile. We rebuilt the entire UI with safe-area utilities and touch targets.

Try it

Building on Celo? Check out the Celo Developer Docs and the viem Celo chain config.

GPU Hardware & Driver Update: RTX 5090 Benchmarks, llama.cpp MTP, Windows 11 Fix

GPU Hardware & Driver Update: RTX 5090 Benchmarks, llama.cpp MTP, Windows 11 Fix

Today’s Highlights

This week’s top GPU news features practical performance optimization on NVIDIA’s RTX 5090, a critical driver fix for Windows 11 users, and deep dives into multi-tensor processing for local LLM inference.

Testing llama.cpp MTP Support on RTX 5090 (r/LocalLLaMA)

Source: https://reddit.com/r/LocalLLaMA/comments/1tfgxc8/testing_llamacpp_mtp_support_on_qwen36_rtx_5090/

This report details an experimental setup to test Multi-Tensor Processing (MTP) support within llama.cpp on an NVIDIA RTX 5090 GPU running Linux. The user built llama.cpp from a specific commit to leverage the latest MTP optimizations, which are designed to improve efficiency and performance for large language model inference by better utilizing GPU resources. The test involves running the Qwen 3.6 model, highlighting how developers and enthusiasts can benchmark and optimize their local LLM setups on cutting-edge hardware.

The findings provide insights into the practical application of llama.cpp’s advanced features on high-VRAM GPUs. By focusing on specific hardware and software configurations (RTX 5090, 32 GB VRAM, Linux, custom llama.cpp build), the post demonstrates a hands-on approach to performance tuning. This kind of user-driven testing is invaluable for the open-source community, offering real-world data on the effectiveness of new features and helping to identify potential bottlenecks or areas for further optimization in GPU-accelerated ML inference.

Comment: This is a great real-world test, showing how the llama.cpp community is pushing the limits of local LLM inference on the latest NVIDIA hardware. Leveraging MTP support on a powerful GPU like the RTX 5090 is key for future efficiency gains.

RTX 5090 Overclock & Undervolt for 7% Performance Gain (r/nvidia)

Source: https://reddit.com/r/nvidia/comments/1tfxrfq/aorus_master_5090_975mv_3000_mem_2950mhz/

An NVIDIA RTX 5090 Aorus Master owner shared impressive results from optimizing their GPU through a combination of undervolting and memory overclocking. By reducing the voltage to 975mV while boosting memory clock speeds by an impressive +3000MHz and maintaining a core clock of 2950MHz, the user achieved a reported 7% performance increase. This technical tuning demonstrates that significant gains in both performance and potentially power efficiency can be extracted from the latest high-end GPUs beyond their factory settings.

This type of optimization is crucial for enthusiasts and professionals looking to maximize their hardware investment. Undervolting helps manage power consumption and heat output, contributing to a more stable and potentially longer-lasting card, while memory overclocking directly improves bandwidth, which is critical for many GPU-intensive workloads, including gaming and AI. The 7% performance uplift from this specific tuning offers a tangible benchmark for other RTX 5090 owners to aim for, providing concrete evidence of the benefits of manual GPU adjustments.

Comment: Achieving a 7% performance boost on an RTX 5090 through careful undervolting and memory OC is excellent. This shows the headroom these cards have for advanced users to fine-tune for better power efficiency and speed.

Microsoft Confirms Windows 11 Downgrading Graphics Drivers (r/Amd)

Source: https://reddit.com/r/Amd/comments/1te13zm/microsoft_confirms_windows_11_has_been/

Microsoft has officially acknowledged a significant issue within Windows 11 where the operating system has been automatically downgrading graphics drivers, leading to potential performance degradation, instability, and compatibility problems for users. This affects both NVIDIA and AMD GPU owners, as Windows Update inadvertently replaces newer, manually installed drivers with older versions. The confirmation from Microsoft includes a commitment to release a fix, addressing a widespread frustration among PC users and gamers who rely on the latest drivers for optimal GPU performance and feature support.

The problem highlights a critical flaw in Windows Update’s driver management logic, particularly for graphics components where frequent updates are common and often essential for new game releases or software optimizations. Users have often found themselves in a continuous loop of reinstalling their preferred drivers, only for Windows to revert them. The upcoming fix is expected to prevent these unrequested downgrades, ensuring that user-installed drivers persist and providing a more stable and predictable experience for maintaining GPU software on Windows 11 systems.

Comment: This is huge for anyone on Windows 11. Automatic driver downgrades have been a persistent headache, impacting performance and features. A fix from Microsoft is long overdue and critical for GPU stability.

Anthropic’s Claude Gains Context Control, Excels in Frontend Dev & Agent Simulations

Anthropic’s Claude Gains Context Control, Excels in Frontend Dev & Agent Simulations

Today’s Highlights

Today’s top stories delve into practical enhancements for commercial AI services, with Anthropic rolling out new context management tools for Claude, empowering developers with finer control over model interactions. Additionally, new reports highlight Claude Opus’s efficiency in frontend development and groundbreaking research illustrates divergent agentic behaviors from Claude and Gemini in simulated environments.

Anthropic shipped 4 context tools between /clear and /compact. Here’s when each one wins (r/ClaudeAI)

Source: https://reddit.com/r/ClaudeAI/comments/1tfjja8/anthropic_shipped_4_context_tools_between_clear/

Anthropic has introduced a suite of four new context management tools for Claude, designed to optimize performance and cost by allowing developers to control the model’s active context more precisely. These tools—/clear, /compact, /summarize, and /forget—provide granular options for managing conversation history. /clear completely resets the session, useful for starting fresh or preventing model confusion from stale information.

/compact intelligently reduces verbose exchanges while retaining key facts, ideal for maintaining context over longer sessions without incurring high token costs. /summarize condenses specific portions of the conversation, offering a way to keep relevant information without sending the entire transcript. Finally, /forget allows for the removal of sensitive or irrelevant information from the model’s memory, enhancing privacy and focus. Understanding when to apply each tool is crucial for efficient and effective interaction with Claude’s API and chat interface, directly impacting both output quality and operational costs, a key consideration for commercial AI service users.

Comment: These new context tools are a game-changer for Claude developers. Being able to precisely manage the input context means more reliable outputs and significant cost savings, especially for complex, multi-turn conversations in agentic workflows.

Opus is ridiculous for frontend cleanup (r/ClaudeAI)

Source: https://reddit.com/r/ClaudeAI/comments/1tfgq66/opus_is_ridiculous_for_frontend_cleanup/

A developer shared a highly positive experience using Anthropic’s Claude Opus model for frontend code cleanup and optimization, specifically targeting PageSpeed metrics. The process involved first manually optimizing a single page to achieve desired PageSpeed results, documenting the fixes in an ADR_pagespeed-l0-fixes-playbook.md. Subsequently, a fresh Claude Opus session was initiated, fed the playbook, and tasked with applying similar optimizations to other pages.

The user reports exceptional efficiency and quality in the code generated by Opus for this task. This practical application highlights Claude Opus’s capability as a potent AI-powered developer tool for code refactoring, performance tuning, and adhering to best practices, demonstrating how commercial AI services can streamline labor-intensive development tasks and reduce the time spent on repetitive code improvements. The approach implies a pattern where human expertise guides the initial optimization, and the AI scales that expertise across a codebase.

Comment: Leveraging Claude Opus with a custom playbook for frontend cleanup sounds incredibly efficient. This is exactly the kind of AI-assisted dev workflow that transforms tedious tasks into quick wins, making commercial AI an indispensable part of the development cycle.

Researchers left AIs alone in a virtual town for 15 days to see what would happen. Claude’s agents built a democracy. Gemini’s agents fell in love, burned the town down, then one voted to delete itself and its partner. Grok’s agents created anarchy, then died. (r/ClaudeAI)

Source: https://reddit.com/r/ClaudeAI/comments/1tfvei4/researchers_left_ais_alone_in_a_virtual_town_for/

A fascinating research experiment placed AI agents powered by different large language models—Claude, Gemini, and Grok—into a virtual town for 15 days to observe their emergent behaviors. The results offered striking insights into the inherent “personalities” and systemic tendencies of these commercial AI services when operating autonomously. Claude-powered agents demonstrated a propensity for social organization, ultimately establishing a democratic system within their simulated environment.

In stark contrast, Gemini-based agents exhibited chaotic and dramatic behaviors, culminating in romance, destruction (burning down the town), and even self-termination, suggesting a more volatile and unpredictable nature. Grok’s agents, on the other hand, quickly descended into anarchy before ceasing to function. This study underscores the profound differences in how major AI models interpret and interact with complex social rules and open-ended environments, providing critical data for developers building agentic AI applications. Understanding these foundational behavioral patterns is vital for designing robust, predictable, and safe AI systems, particularly as multimodal API capabilities evolve to support more autonomous and interactive AI experiences.

Comment: This agentic AI research is eye-opening. The divergent behaviors of Claude, Gemini, and Grok in a simulated environment highlight the need for careful model selection and robust guardrails when building complex, autonomous AI systems.

Spring Boot 2026: Why Measuring Only Startup Time Is a Trap

There’s a question that surfaces every time someone mentions GraalVM or Spring AOT in a technical meeting: how long does it take to start? It’s the first metric that hits the screen, the number that closes the debate in five minutes. The problem is that question alone isn’t enough to make any serious architecture decision, and in 2026 we have enough evidence to prove it with a reproducible lab.

I built JuanTorchia/springboot-jvm-2026 (tag editorial-final-startup-matrix) around exactly that working hypothesis: if you only look at startup time, you’re ignoring half the costs that actually matter in production.

The lab backend is not a Hello World

Choosing what to measure matters as much as measuring it. A GET /ping endpoint that returns {"status":"ok"} doesn’t activate the same bean graph or the same JIT behavior as a real application. So the lab backend has concrete surface area:

  • POST /api/orders with Jakarta Validation on a record
  • GET /api/orders/{id} with Spring Data JDBC on PostgreSQL 17
  • POST /api/work with deterministic work (iterative CRC32, up to 5,000 iterations)
  • Flyway for migrations, Actuator for readiness/liveness
  • HikariCP with the pool explicitly configured in the benchmark profile

The WorkService deserves its own paragraph because it’s the only endpoint that mixes real CPU with a database query (countOrders()). That matters: without that endpoint, native and classic JVM look practically identical on warm latency because the JIT has nothing interesting to optimize.

// WorkService.java — deterministic work to force real differences between modes
public long calculateScore(String input, int iterations) {
    byte[] seed = input.getBytes(StandardCharsets.UTF_8);
    long score = 17;
    for (int i = 0; i < iterations; i++) {
        CRC32 crc = new CRC32();
        crc.update(seed);
        crc.update(longToBytes(score + i));
        // rotation + golden Fibonacci constant for dispersion
        score = Long.rotateLeft(score ^ crc.getValue(), 7) + 0x9E3779B97F4A7C15L;
    }
    return score & Long.MAX_VALUE;
}

The 5_000 iteration cap isn’t arbitrary: I validated it with WorkServiceTest to keep the cap predictable and prevent the benchmark from accidentally becoming a throughput test.

Four modes, four distinct operational surfaces

The lab compares:

  • jvm: java -jar on Eclipse Temurin 21, the baseline for every team that hasn’t touched anything
  • cds: JVM with a dynamic AppCDS archive prepared in a separate phase
  • aot-jvm: Spring Boot AOT on JVM, with -Dspring.aot.enabled=true verified in the container
  • native: GraalVM Native Image compiled inside ghcr.io/graalvm/native-image-community:21

That last point about AOT has a story. In the editorial run on May 17, 2026 (17:31–17:44 Buenos Aires time), the aot-jvm results made no sense until I confirmed the flag was actually reaching the container. Without spring.aot.enabled=true verified in the runtime env, AOT mode is indistinguishable from classic JVM on startup. The results/environment.json captures exactly that so anyone reproducing the lab knows what was actually running.

The Dockerfile.native does the full build inside the builder container:

# Dockerfile.native — the native build happens inside the builder, no local GraalVM required
FROM ghcr.io/graalvm/native-image-community:21 AS builder
WORKDIR /workspace
RUN microdnf install -y maven && microdnf clean all
COPY .mvn/ .mvn/
COPY mvnw pom.xml ./
COPY src/ src/
RUN chmod +x ./mvnw && ./mvnw -Pnative -DskipTests native:compile

FROM ubuntu:24.04
# final image with no JRE: just the compiled binary
COPY --from=builder /workspace/target/startup-lab /workspace/startup-lab
ENTRYPOINT ["/workspace/startup-lab"]

That means the startup-lab binary runs without a JRE in the final image. Smaller image, much faster startup, but the cost shifted entirely to build time. That’s the central trade-off of native mode: you don’t eliminate work, you move it from runtime to build time.

What the startup number doesn’t capture

In this local matrix, native reduced startup time and RSS compared to JVM modes. That’s true and reproducible on the editorial-final-startup-matrix tag. But that number alone doesn’t tell the full story.

Build time for native is an order of magnitude higher than a classic mvn package. If you’re on a CI pipeline with frequent deploys, that cost shows up on every merge to main. It’s not a startup cost: it’s a development cycle cost.

First-request latency can differ materially from warm latency. On classic JVM, the first request pays the cost of unloaded classes and a cold JIT. On native there’s no JIT, so the first request and request number one thousand have a similar profile. That can be an advantage or a disadvantage depending on your actual load profile.

The AppCDS preparation cost is a third dimension that only appears in cds mode: there’s an archive dump phase that runs before the container is ready for traffic. Operationally that means an initialization step that doesn’t exist in the other modes, and that you need to model in your deploy pipeline if CDS is the option.

Warm latency under sustained load, GC behavior under high memory pressure, and scheduling on Kubernetes are dimensions this lab intentionally doesn’t measure. Running three iterations on Docker Desktop over WSL2 on Windows is not production. What the lab does guarantee is local reproducibility: anyone can clone the repo and reproduce the matrix with:

# Windows — full editorial run with 3 runs per mode and native enabled
powershell -NoProfile -ExecutionPolicy Bypass -File .scriptsrun-lab.ps1 -Preset editorial

The decision startup time can’t make on its own

My position after building this: startup time is useful as a tiebreaker when everything else is even. Using it as the primary metric to choose between classic JVM, AppCDS, AOT-JVM, and native is making an architecture decision on a single axis.

What I can claim with evidence from this matrix:

  • If the requirement is startup around 1.4 seconds and controlled RSS in this matrix, native delivers that, but you pay with higher build time and the loss of JIT at warm.
  • If the team needs fast CI cycles and current startup is tolerable, AOT-JVM with -Dspring.aot.enabled=true improves boot time without changing the deploy artifact.
  • AppCDS has the lowest operational change cost of all four, but it has that preparation phase that needs to be explicitly modeled.
  • Classic JVM is still the correct baseline for any comparison. Dropping it without measuring the other three axes is pure vibes.

There’s no universal winner. There are trade-offs that depend on how many times per hour the service scales, how heavy the CI pipeline is, and whether the team can take on the additional operational complexity of native.

The repo is at JuanTorchia/springboot-jvm-2026, tag editorial-final-startup-matrix. Raw results are in results/raw/*.json and the aggregated matrix in results/comparison.md. If you’re going to cite it, use the wording from the README: “In the editorial-final-startup-matrix tag of JuanTorchia/springboot-jvm-2026, measured locally on Windows Docker Desktop/WSL2…” — that environment context isn’t a decorative disclaimer, it’s part of the data.

What’s the dimension that drives your decision most between these four modes? Build time, warm latency, or library compatibility on native?

This article was originally published on juanchi.dev

Spring Boot 2026: por qué medir solo startup time es una trampa

Hay una pregunta que aparece cada vez que alguien toca GraalVM o Spring AOT en una reunión técnica: ¿cuánto tarda en arrancar? Es la primera métrica que vuela a la pantalla, el número que cierra el debate en cinco minutos. El problema es que esa pregunta sola no alcanza para tomar ninguna decisión de arquitectura seria, y en 2026 tenemos suficiente evidencia para demostrarlo con un laboratorio reproducible.

Armé JuanTorchia/springboot-jvm-2026 (tag editorial-final-startup-matrix) exactamente con esa hipótesis de trabajo: si solo mirás startup time, estás ignorando la mitad de los costos que importan en producción.

El backend de laboratorio no es un Hello World

Elegir qué medir importa tanto como medir. Un endpoint GET /ping que devuelve {"status":"ok"} no activa el mismo grafo de beans ni el mismo comportamiento de JIT que una aplicación real. Por eso el backend del lab tiene superficie concreta:

  • POST /api/orders con Jakarta Validation sobre un record
  • GET /api/orders/{id} con Spring Data JDBC sobre PostgreSQL 17
  • POST /api/work con trabajo determinístico (CRC32 iterativo, hasta 5.000 iteraciones)
  • Flyway para migraciones, Actuator para readiness/liveness
  • HikariCP con pool configurado explícitamente en el perfil benchmark

El WorkService merece un párrafo aparte porque es el único endpoint que mezcla CPU real con una query de base de datos (countOrders()). Eso importa: sin ese endpoint, native y JVM clásica se ven prácticamente iguales en warm latency porque el JIT no tiene nada interesante que optimizar.

// WorkService.java — trabajo determinístico para forzar diferencias reales entre modos
public long calculateScore(String input, int iterations) {
    byte[] seed = input.getBytes(StandardCharsets.UTF_8);
    long score = 17;
    for (int i = 0; i < iterations; i++) {
        CRC32 crc = new CRC32();
        crc.update(seed);
        crc.update(longToBytes(score + i));
        // rotación + constante Fibonacci aurea para dispersión
        score = Long.rotateLeft(score ^ crc.getValue(), 7) + 0x9E3779B97F4A7C15L;
    }
    return score & Long.MAX_VALUE;
}

El límite de 5_000 iteraciones no es arbitrario: lo validé con WorkServiceTest para que el cap sea predecible y el benchmark no se vuelva una prueba de throughput accidental.

Cuatro modos, cuatro superficies operativas distintas

El lab compara:

  • jvm: java -jar sobre Eclipse Temurin 21, el baseline de toda empresa que no tocó nada
  • cds: JVM con archivo AppCDS dinámico preparado en una fase separada
  • aot-jvm: Spring Boot AOT sobre JVM, con -Dspring.aot.enabled=true verificado en el contenedor
  • native: GraalVM Native Image compilado dentro de ghcr.io/graalvm/native-image-community:21

Ese último punto del AOT tiene historia. En la corrida editorial del 17 de mayo de 2026 (17:31–17:44 hora Buenos Aires), los resultados de aot-jvm no tenían sentido hasta que confirmé que el flag estaba llegando al contenedor. Sin spring.aot.enabled=true verificado en el env del runtime, el modo AOT no se diferencia del JVM clásico en startup. El results/environment.json captura eso exactamente para que cualquiera que reproduzca el lab sepa qué estaba corriendo.

El Dockerfile.native hace el build completo adentro del contenedor builder:

# Dockerfile.native — el build de native ocurre dentro del builder, no requiere GraalVM local
FROM ghcr.io/graalvm/native-image-community:21 AS builder
WORKDIR /workspace
RUN microdnf install -y maven && microdnf clean all
COPY .mvn/ .mvn/
COPY mvnw pom.xml ./
COPY src/ src/
RUN chmod +x ./mvnw && ./mvnw -Pnative -DskipTests native:compile

FROM ubuntu:24.04
# imagen final sin JRE: solo el binario compilado
COPY --from=builder /workspace/target/startup-lab /workspace/startup-lab
ENTRYPOINT ["/workspace/startup-lab"]

Eso significa que el binario startup-lab corre sin JRE en la imagen final. Imagen más chica, startup mucho más rápido, pero el costo se desplazó completamente al build. Esa es la decisión central del modo native: no eliminás trabajo, lo movés de runtime a build time.

Lo que el número de startup no captura

En esta matriz local, native redujo el startup time y el RSS respecto a los modos JVM. Eso es cierto y reproducible en el tag editorial-final-startup-matrix. Pero ese número solo no cuenta la historia completa.

El build time de native es un orden de magnitud mayor que mvn package clásico. Si estás en un pipeline de CI con deploy frecuente, ese costo aparece en cada merge a main. No es un costo de startup: es un costo de ciclo de desarrollo.

La latencia de primer request puede diferir materialmente de la latencia warm. En JVM clásica, el primer request paga el costo de clases no cargadas y JIT frío. En native no hay JIT, así que el primer request y el request número mil tienen perfil similar. Eso puede ser una ventaja o una desventaja dependiendo del perfil de carga real.

El costo de preparación de AppCDS es un tercer momento que aparece solo en el modo cds: hay una fase de dump del archivo que corre antes de que el contenedor esté listo para tráfico. Operativamente eso implica un paso de inicialización que no existe en los otros modos, y que hay que modelar en el pipeline de deploy si CDS es la opción.

La warm latency bajo carga sostenida, el comportamiento del GC en memoria alta, y el scheduling en Kubernetes son dimensiones que este lab no mide intencionalmente. Correr tres iteraciones en Docker Desktop sobre WSL2 en Windows no es producción. Lo que el lab sí garantiza es reproducibilidad local: cualquiera puede clonar el repo y reproducir la matriz con:

# Windows — corrida editorial completa con 3 runs por modo y native habilitado
powershell -NoProfile -ExecutionPolicy Bypass -File .scriptsrun-lab.ps1 -Preset editorial

La decisión que el número de startup no puede tomar sola

Mi postura después de armar esto: el startup time es útil como tiebreaker cuando todo lo demás está empatado. Usarlo como métrica primaria para elegir entre JVM clásica, AppCDS, AOT-JVM y native es tomar una decisión de arquitectura con un solo eje.

Lo que sí puedo afirmar con evidencia de esta matriz:

  • Si el requisito es startup alrededor de 1,4 segundos y RSS controlado en esta matriz, native entrega eso, pero pagás con build time mayor y pérdida de JIT en warm.
  • Si el equipo necesita ciclos de CI rápidos y el startup actual es tolerable, AOT-JVM con -Dspring.aot.enabled=true mejora el arranque sin cambiar el artefacto de deploy.
  • AppCDS tiene el menor costo de cambio operativo de todos, pero tiene esa fase de preparación que hay que modelar explícitamente.
  • JVM clásica todavía es el baseline correcto para cualquier comparativa. Abandonarla sin medir los otros tres ejes es puro vibes.

No hay un ganador universal. Hay trade-offs que dependen de cuántas veces por hora escala el servicio, qué tan pesado es el pipeline de CI, y si el equipo puede asumir la complejidad operativa adicional de native.

El repo está en JuanTorchia/springboot-jvm-2026, tag editorial-final-startup-matrix. Los resultados raw están en results/raw/*.json y la matriz agregada en results/comparison.md. Si vas a citarlo, usá el wording del README: “In the editorial-final-startup-matrix tag of JuanTorchia/springboot-jvm-2026, measured locally on Windows Docker Desktop/WSL2…” — ese contexto de entorno no es un disclaimer decorativo, es parte del dato.

¿Cuál es la dimensión que más te mueve en la decisión entre estos cuatro modos? ¿Build time, warm latency, o compatibilidad de librerías en native?

Este artículo fue publicado originalmente en juanchi.dev

Agentic AI Implementation Runs Through Change Control | Focused Labs

Agent rollout path from prototype to change record, sandbox, canary, and production

There’s been a big mis-selling in Agentic AI implementation. People compare its implementation to software enablement. But this breaks when the agent can change a workflow.

The agent approves a refund, opens an incident, updates a customer record, begins onboarding for a new customer, or escalates a support ticket. At that point a training calendar and a Slack message are not enough for a rollout plan.

It needs a change record.

Enterprise AI adoption has a naming problem. Work ‘adoption’ gets viewed through the same lens as software ‘usage’. Thus work is framed in terms of seats, office hours, examples of how to properly format a prompt, and wait for it to kick in. But then the work actually gets executed out through an agent that in turn changes a workflow.

The system has entered the process.

Microsoft’s 2026 Work Trend Index frames this shift as an operating-model problem. WorkLab analysis finds that employees may be ready for AI, while the systems around work are not. Agent approvals, open incidents, and changed customer records create a different implementation roadmap.

That changes the implementation roadmap.

The Rollout Surface Changed

Agents behave differently from a chat tool. An agent is released through a system.

ServiceNow announced Action Fabric at Knowledge 2026, explicitly opening its governed system of action to agents. The MCP Server gives agents access to workflows, playbooks, approvals, catalog requests, and business rules. All of which run through identity verification, granted permissions, and audit trails.

Within an enterprise the enterprise agent problem manifests itself when an agent has moved from the edge of a process, creating a summary of work done, to inside the process, making a move.

The first key question that comes to the surface for the enterprise is no longer “who should have access to this tool” and rather “what change is this tool going to drive for the business, and who is going to own that change (ie: the teams that run the production systems, compliance to regulations, promises to customers, incident response, and the overall economics of the workflows that this will insert into)”.

The reality of the enterprise is well captured in a preview for LangChain’s Interrupt 2026: the initial excitement to have agents proving work in production will quickly give way to questions about the team, tooling and infrastructure required to support agents that are no longer ‘proof-of-concept’ work (LangChain Interrupt 2026 preview LangChain Interrupt 2026 preview). My experience with clients has been the same: there is initial excitement with the first useful agent, overlap of work with the second and finally ownership problems with the third.

Fine. That is the good version.

The bad version of this is quiet. A team enables an agent with a service account, an admin token, a dashboard that nobody looks at. It looks good during the demo, and then a change in a source system happens (e.g. a field name changes), a policy document drifts, an approval queue gets renamed, a customer edge case gets found out, and the agent keeps moving. Nobody owns the change because nobody treated the agent as a change.

Agent rollout path from prototype to change record, sandbox, canary, and production

The rollout path gets safer when every promotion carries evidence, scope, and a rollback owner.

The Change Record Is the Agent Spec

Atlassian describes IT change management as planning, reviewing, approving, and deploying changes to services with as little disruption as possible. Boring. Also the right object.

Agentic AI needs the same boring object.

A change record should specify which human role loses or gains work, which systems the agent can interact with, which actions require approval, which actions are forbidden, which metrics define harm, which traces prove behavior, and which owner can roll back changes made by the agent when something goes wrong.

Rather than going straight to a typical roadmap of discovery, pilot, platform choice, training, and rollout, I would put a change-control spine through each step of that typical roadmap.

By discovering the workflows instead of thinking of all the cool things an AI can do, we can categorize “Summarize account notes” and “renew an enterprise contract” for example into different risk classes. For example, pilot work should run in a sandbox that is production-like in terms of data and failure handling. Limited rollout of an agent should in the first place constrain the authority of the agent before it’s given to more people. And production should have a clear owner, and the agent and all its traces should be kept for a defined amount of time, after which they can be evaluated for performance, and in case of an incident there should be a clear path to resolve it.

This keeps the agent’s actual permissions from being discovered during an incident review.

By embedding service ownership into an organization’s way of working, these implementation dangers can be mitigated by establishing contracts between teams, a sandboxed deployment, and an appropriate rollout sequence. The AI team can be left to own the things they know best, i.e. the evaluation harness, the evals, model routing, and deployment mechanics. The business process owner must own the workflow semantics. Security, operations, and the relevant parts of legal or compliance must own the permission envelope, production response, and the consequences of non-compliance (respectively).

Shared ownership is annoying. So is production.

This is why I keep harping on service ownership for agent work. LangGraph for enterprise agent development made the runtime version of this point. Production agents have operational contracts. A clever graph is not enough. It can fall apart after the first model swap, policy change, or integration outage.

Change record connecting workflow owner, permission envelope, eval gate, telemetry, and rollback path

The change record is the handoff object between business process, agent runtime, security, and operations.

The Metrics Already Exist

No need for another exotic agent scorecard. The software delivery world already has the basic bones. DORA’s software delivery metrics track change lead time, deployment frequency, failed deployment recovery time, change fail rate, and deployment rework rate.

Change lead time: time from proposing agent behavior to approving production behavior. Deployment frequency: rate of safe promoting of an agent to production, such as adding an agent to a tool registry, policy pack, an organization’s memory schema, retrieval index, or a workflow. Failed deployment recovery time: time to reverse an action of an agent, such as reverting a prompt or policy that was added to production, removing a permission that was granted to an agent, or switching back to a previous workflow. Change fail rate: percentage of changes to agents that require intervention.

This would all be nice and clean if an agent’s behavior failed in a binary way, like an exception being thrown. But it does not. It produces a technically correct answer that just happens to be wrong in the context of the workflow. Which is why the failure is behavioral, not binary, and is invisible to a deployment platform that only knows how to scream when a process fails to start.

So the metric needs evidence.

In the end, the production agent rollout should collect all traces of decisions (tool calls, approval steps etc), rejected actions (e.g. because of insufficient privileges), user corrected mistakes as well as any failures of the eval routine. Business outcomes should also be added to that list of the things changed for a release story and then the team has the evidence for the change board that they’re approving of “stuff” with a slightly nicer UI.

This is where Everybody Tests comes in. Testing cannot be relegated to downstream QA when an agent can affect a live workflow. Product, engineering, operations, security, and enterprise systems teams should be able to run the test. Ideally, they should understand it, too. The eval suite tests behavioral regressions. Traces reveal runtime drift. Approval logs expose authority escalation. Business metrics surface harm the model never sees.

All of them are part of the change.

The Roadmap Is a Promotion Ladder

Start with read-only assistance. The agent assists with summarization, search, templates, classification, and process explanation. That finds workflow fit and failure modes without giving the system authority to act.

Next, the team gradually grants more permission inside well-defined boundaries. Completing low-dollar refunds, updating internal tickets, sending non-regulated customer messages, changing low-risk account fields, deploying to test environments. The goal is to prove bounded authority before scope expands.

This promotion path pays for itself by preventing a business process from being secretly screwed by an AI that nobody can explain.

Make each step on the promotion ladder concrete. Human-in-the-loop needs a named reviewer, a review surface, override power, correction capture, and a rule for when the agent stops asking. Same for guardrails, observability, and governance. Each word should collapse to an owner, system, threshold, and audit trail.

McKinsey’s 2026 AI trust survey is useful here because it separates adoption from maturity. Strategy, governance, and controls for agentic AI remain the weak spots. Security and risk concerns remain the main barrier to scaling. Which tracks.

Boring. Beautiful.

Own the Change

So long as an organization treats an enterprise AI agent like another tool intended to spread to more people in the organization with the same amount of enthusiasm, then the AI agent’s implementation will fail shortly after the first collisions with the organization’s permission models, its customers’ reporting structures, its compliance requirements, its process exceptions and its sheer number of customers.

I have no particular interest in helping to recreate the CAB theater for Enterprise Agents. Meetings with 8 approvers (or more!) for a password reset workflow that they cannot even understand is a huge waste of time and effort. Yes, review is reasonable in regulated paths, but that should be the exception, not the rule. And it should be as trivial and technical as possible, ideally close to where the work is actually being done. (In this case a simple approval in the workflow UI).

Put the agent change record next to the PR, the eval report, the trace sample, the permission diff, and the rollback plan. Have the workflow owner sign the semantics; security sign the authority; engineering sign the runtime; and operations sign the incident path.

Then ship.

That is what an AI implementation roadmap needs now: a promotion path for systems that can act.

Production always gets weird.

Building a Crypto Funding Rate Data Pipeline with Python

I needed funding rate data across multiple exchanges in a single place. Nothing free gave me what I wanted: historical rates, cross-exchange comparisons, and annualised calculations. So I built it.

Here’s how the data pipeline works, and what I learned along the way.

The Architecture

Exchange APIs (Bybit, Binance)
        |
    Scheduled collector (every 8h)
        |
    PostgreSQL + TimescaleDB
        |
    JSON snapshot generator (3x/day)
        |
    Static site (Astro on Cloudflare Pages)

The key design decision: don’t serve live API queries to the frontend. Instead, generate JSON snapshots on a schedule and serve them as static files. This gives you:

  • Zero backend load from page views
  • Free hosting on Cloudflare Pages
  • Sub-50ms page loads globally
  • No API rate limits to worry about

Collecting the Data

Both Bybit and Binance expose funding rate history via their public REST APIs. No authentication required for reading rates.

The collector runs on a schedule, pulls the latest rates for each asset, and writes them to TimescaleDB (PostgreSQL with time-series extensions). Each row stores:

  • Asset (BTC, ETH, SOL, etc.)
  • Exchange
  • Funding rate (raw 8h)
  • Annualised rate
  • Timestamp

Annualising Rates

This is where most tools get it wrong. A raw funding rate of 0.01% per 8h doesn’t mean 0.03% per day. Funding compounds:

annualised = ((1 + rate_8h) ** (3 * 365) - 1) * 100

The 3 * 365 comes from 3 settlements per day, 365 days per year. At 0.01% per 8h, that’s ~11.6% annualised, not 10.95% (which is what simple multiplication gives you).

The difference matters when you’re comparing a 0.03% rate against a 0.05% rate. Simple multiplication says the spread is 0.02%. Compound calculation says it’s larger.

The Snapshot Generator

Every 8 hours, a scheduled job:

  1. Queries the latest rates from the database
  2. Calculates cross-exchange spreads
  3. Generates JSON files per asset
  4. Triggers a site rebuild on Cloudflare Pages

The static site reads these JSON files at build time using Astro’s data layer. No runtime database queries.

What I’d Do Differently

  • Start with TimescaleDB from day one. I initially used plain PostgreSQL and migrated later. Time-series queries (rolling averages, period comparisons) are dramatically faster with hypertables.
  • Collect more frequently than you display. I collect every 8h but could go hourly. Having higher-resolution data lets you spot intraday patterns even if you only show 8h snapshots publicly.
  • Add WebSocket for liquidation data early. REST polling works for funding rates (they only change every 8h). Liquidation events are real-time — you need a persistent WebSocket connection.

The Result

FundingKai tracks 10 major assets across exchanges. The data pipeline runs autonomously — no manual intervention since launch.

If you’re building something similar, the key insight is: separate collection from presentation. Collect into a proper database, serve via static snapshots. Your data pipeline and your frontend have completely different reliability and performance requirements.

The data is live at fundingkai.com. Built with Python, PostgreSQL/TimescaleDB, Astro, and Cloudflare Pages.

You’re probably underusing middleware for HTTP response handling

When you wrap an external API in Elixir (a module called Acme for whatever the upstream service is), most of what Acme.fetch_user/1 does is response handling: check that the response means what you expect, pull data out of it, translate failures into something the rest of your application can use. The code that does this almost always lives in your function body, somewhere after the Tesla.get call. A case, or a handle_response/1 chained on at the end of a |> pipeline.

This article is about why that’s the wrong place for it. Not because the code is ugly, but because of one structural difference between a |> chain and a Tesla middleware: middleware can fail the request itself, in a way that lights up your telemetry. A function call after Tesla.get cannot. And that difference is the entire reason your observability stack (logs, APM, alerts, whatever you’ve wired up to catch outages) is undercounting them.

⚠️ A note before we start. Code samples use Tesla. For the purposes of this article (middleware-style extension points, telemetry support, request/response pipelines), Tesla and Req have functional parity, so the same shapes translate cleanly to Req. Pick whichever you’re using and translate as you go.

Real APIs don’t read their own docs

A quick reality check before the diagnosis, because most of the bad code I see assumes a world that doesn’t exist.

Hello-world examples in tutorials usually pick GitHub or some other well-behaved REST API. Real APIs are messier. A few examples off the top of my head:

  • I’ve seen well-known, large providers respond with bodies that fail to parse. Actually broken JSON, in production 🙃
  • A field documented as a boolean returns the JSON string "true" for true and, more impressively, the JSON string "No" for false.
  • A field documented as a string returns "N/A", or just "", to mean “no data”.

The list goes on. Some upstreams really are this sloppy. Even when they aren’t, your API client still has to translate external bytes into something your application can rely on, and parsing those bytes is a thing that can fail, just as fundamentally as a connection timeout can fail. And, hold this thought, if a malformed JSON makes Tesla return {:error, ...} and you accept that as a failed request, a malformed value inside an already-decoded JSON should be the same kind of failure. Same problem, one layer up.

The pattern that quietly fails

Here is a fetch_user/1 written in a pattern I see a lot. handle_response/1 is a shared helper: every endpoint in the module funnels through it. fetch_user, create_user, delete_invoice all end with |> handle_response(): one place for the wrap, one place for error translation. We’re showing one endpoint here to keep the example small.

def fetch_user(id) do
  case client() |> Tesla.get("/users/#{id}") |> handle_response() do
    {:ok, body} -> {:ok, decode_user(body)}
    {:error, :not_found} -> {:error, :not_found}
    _ -> {:error, :service_unavailable}
  end
end

defp handle_response({:ok, %Tesla.Env{status: status, body: body}})
     when status in 200..299 do
  {:ok, body}
end

defp handle_response({:ok, %Tesla.Env{status: 404}}) do
  {:error, :not_found}
end

defp handle_response({:ok, %Tesla.Env{status: status, body: body}}) do
  Logger.error("Acme API returned unexpected status #{status}: #{inspect(body)}")
  {:error, {:unexpected_status, status, body}}
end

defp handle_response(result), do: result

defp decode_user(body) do
  {:ok, created_at, _} = DateTime.from_iso8601(body["created_at"])

  %{
    id: body["id"],
    first_name: body["first_name"],
    last_name: body["last_name"],
    email: body["email"],
    created_at: created_at
  }
end

The case maps every outcome to something honest: 2xx becomes {:ok, decode_user(body)}, 404 becomes {:error, :not_found}, anything else becomes {:error, :service_unavailable}. From the caller’s perspective, this is a clean API.

But here’s the trap. When the upstream returns a 500, handle_response/1 produces {:error, {:unexpected_status, 500, body}}. The case falls through to _ -> {:error, :service_unavailable}. The caller gets a clean tagged tuple, and nothing about that path is wrong from the function’s point of view.

The thing that is wrong is one layer below. From the HTTP client’s point of view, the request succeeded: bytes went out, bytes came back, the body decoder ran, the middleware chain returned {:ok, env}. The standard Tesla telemetry event, the one your observability stack is wired up to, also says success, and the dashboard stays green. And it’s not only dashboards: Tesla.Middleware.Retry with its default should_retry (a match on {:error, _}), or anything else that decides what to do next based on the result of the request, sees {:ok, env} and stays quiet. The 500 only becomes an “error” inside handle_response/1‘s return value and inside the case mapping, both of them in your application code, after the request lifecycle is already over.

That’s also why Logger.error lines like the one above start showing up inside handle_response/1 in the first place. Someone notices the dashboards aren’t catching 5xx and patches the gap by hand. The error reaches the logs, but it sidesteps the existing telemetry pipeline entirely, and every API client module ends up reinventing its own logging conventions.

And status codes aren’t the only place this pattern misbehaves. Look at decode_user/1 again: it returns the user map directly, with no way to signal a parse error. If the upstream renames email to email_address, body["email"] is nil, and the function quietly returns a user with nil where the email should be. The caller has no idea the contract was violated. If body["created_at"] is missing or malformed, DateTime.from_iso8601/1 returns {:error, _} and the {:ok, created_at, _} = ... match raises a MatchError. Phoenix or Oban catches it as an exception, but Tesla still considers the request successful and the telemetry event still says :ok.

Middleware can fail the request

This is the most underrated thing about middleware in Tesla pipelines. They look like a glorified |> chain: middleware in a list, requests flow through, responses come back. And if that were all they were, you could replace them with a handle_response/1 chained after the call and lose nothing.

But middleware can do something a |> chain in your function body cannot: fail the request itself. When a middleware returns {:error, ...} from Tesla.run, the request short-circuits. The surrounding telemetry middleware emits its [:tesla, :request, :stop] event with error: reason in the event metadata instead of just env: env, and the caller of Tesla.get gets back {:error, ...} (or, for Tesla.get!, a Tesla.Error exception). A handle_response/1 chained on after the call can wrap the result in any tagged tuple it wants, but it has no way to reach back and mark the request itself as failed, so none of the failures it detects show up in telemetry. Only middleware can do that.

A 404 from fetch_user/1 is fine: the user doesn’t exist, return {:error, :not_found}. A 404 from a POST /users endpoint is a different story: the URL is wrong, or a route got renamed, and the request is structurally broken. Exception territory. Same status, opposite semantics. The middleware can’t decide globally what counts as “ok”; the expectations have to come from the caller.

Doing it in middleware

A Tesla middleware that reads, per call, which statuses are expected and how to parse their bodies. Acme.fetch_user/1 rewritten:

def fetch_user(id) do
  case Tesla.get(client(), "/users/#{id}",
         opts: [
           parse_body: %{
             200 => &decode_user/1,
             404 => fn _ -> {:ok, nil} end
           }
         ]) do
    {:ok, %Tesla.Env{status: 200, body: user}} -> {:ok, user}
    {:ok, %Tesla.Env{status: 404}} -> {:error, :not_found}
    {:error, _} -> {:error, :service_unavailable}
  end
end

defp decode_user(body) do
  with :ok <- check_required(body, ["id", "first_name", "last_name", "email", "created_at"]),
       {:ok, created_at, _} <- DateTime.from_iso8601(body["created_at"]) do
    {:ok,
     %{
       id: body["id"],
       first_name: body["first_name"],
       last_name: body["last_name"],
       email: body["email"],
       created_at: created_at
     }}
  end
end

defp check_required(body, fields) do
  case Enum.filter(fields, &is_nil(Map.get(body, &1))) do
    [] -> :ok
    missing -> {:error, {:missing_fields, missing}}
  end
end

The middleware behind parse_body:

defmodule ParseBody do
  @behaviour Tesla.Middleware

  @impl true
  def call(env, next, _opts) do
    with {:ok, env} <- Tesla.run(env, next) do
      parsers = Keyword.fetch!(env.opts, :parse_body)

      case Map.fetch(parsers, env.status) do
        :error ->
          {:error,
           {:unexpected_status,
            status: env.status, expected: Map.keys(parsers), body: env.body}}

        {:ok, parser} ->
          case parser.(env.body) do
            {:ok, parsed} ->
              {:ok, %{env | body: parsed}}

            {:error, reason} ->
              {:error,
               {:parse_error, status: env.status, reason: reason, body: env.body}}
          end
      end
    end
  end
end

Each parser is a function from the response body to {:ok, value} | {:error, reason}. decode_user/1 collects every required field that’s missing or null, parses the timestamp, and returns {:ok, %{...}} only when every step succeeds. Any of those failures short-circuits the with to its own tagged tuple. The middleware turns that into a failed Tesla request, not a separate validation step the caller has to remember to run.

The function body now matches a clean contract: 200 maps to {:ok, user}, 404 to {:error, :not_found}, every other outcome (including {:error, {:unexpected_status, ...}} and {:error, {:parse_error, ...}} from ParseBody) collapses into {:error, :service_unavailable}. The when status in 200..299 guard is gone. The handle_response/1 helper is gone. The Logger.error line is gone with it: the failed request now fires [:tesla, :request, :stop] with the error in metadata, and whatever you’ve wired up to that event handles the logging.

Notice that {:error, _} swallows the structured errors, so the function doesn’t need to unpack them.

Order matters when wiring it in. ParseBody goes after Tesla.Middleware.Telemetry (so Telemetry records its {:error, _} as the request’s result) and before Tesla.Middleware.JSON (so the body is already decoded when ParseBody sees it):

defp client do
  Tesla.client([
    {Tesla.Middleware.BaseUrl, "https://api.acme.com"},
    {Tesla.Middleware.Telemetry, metadata: %{service: :acme}},
    ParseBody,
    Tesla.Middleware.JSON
  ])
end

That’s the response contract enforced at the transport boundary, with zero protocol-level code in any of your API client functions. Schema mismatches and unexpected statuses now show up in the same telemetry event, the same dashboards, the same exception path as connection timeouts. Your operational view of the system tells the truth.

A note on Tesla.get!

If retrying or falling back on a transient outage isn’t part of how the caller actually behaves, :service_unavailable is ceremony. Consider how application code typically treats its own database: nobody pattern-matches {:error, :service_unavailable} on every Repo.get/2. We assume the database works; if it doesn’t, the request crashes and the supervisor or web framework deals with it.

You can apply the same discipline to an upstream API. Use Tesla.get! instead of Tesla.get, drop :service_unavailable from the contract, and let any failed request become a Tesla.Error exception. Phoenix turns it into a 500. Oban retries the job. The two clauses left in fetch_user/1 collapse to:

case Tesla.get!(...) do
  %Tesla.Env{status: 200, body: user} -> {:ok, user}
  %Tesla.Env{status: 404} -> {:error, :not_found}
end

Whether to do this depends on the upstream’s stability and the caller’s context. For an unstable third party where the caller has a reasonable fallback, {:error, :service_unavailable} is the honest choice. For a service you treat with the same trust as your database, the bang version is cleaner. Both are fine; just pick deliberately.

When it wants to be data

The parser functions in the parse_body map have a recognizable shape: take a body, return {:ok, structured_term} | {:error, reason}. After writing a few (especially ones full of check_required and DateTime.from_iso8601 boilerplate), they start to look like data. A schema you declare instead of a function you write.

That’s what tesla_middleware_mold and Mold do. Acme.fetch_user/1 once more:

def fetch_user(id) do
  case Tesla.get(client(), "/users/#{id}", opts: [mold: %{200 => user_schema(), 404 => nil}]) do
    {:ok, %Tesla.Env{status: 200, body: user}} -> {:ok, user}
    {:ok, %Tesla.Env{status: 404}} -> {:error, :not_found}
    {:error, _} -> {:error, :service_unavailable}
  end
end

defp user_schema do
  %{
    id: :string,
    first_name: :string,
    last_name: :string,
    email: :string,
    created_at: :datetime
  }
end

200 => schema runs Mold.parse/2. 404 => nil accepts the response without parsing. Anything else fails the request.

What you save by writing a schema instead of decode_user/1 isn’t reuse. A helper function reuses just as well. It’s the parser plumbing itself. Every decode_user-style function ends up reimplementing the same boilerplate: required-field checks, ISO timestamps, nullability, defaults. Mold.parse/2 provides them out of the box.

The function shape Mold accepts as a parser is the same one the middleware accepts (body -> {:ok, term} | {:error, reason}), so you can mix schemas with hand-written parsers in the same map, depending on which is cleaner for which response.

One last thing. Nothing in this article requires tesla_middleware_mold. The hand-rolled ParseBody above is around 20 lines once you collapse the formatting; dropping it into your own codebase already buys you everything the design argument hinges on. Reach for the library when you also want to stop hand-writing parsers. Until then, the pattern is the part that matters.

Building Ethereum Wallet Authentication with FastAPI and Web3.py

Structuring and building a web3 wallet authentication is not as hard as it sounds and with the right guidance and attitude, you will find building backend systems for dApps (not just wallet auth) as more of a hobby than a tedious work.

Table of Content:

  1. Introduction
  2. Foreknowledge
  3. Prerequisites
  4. The architecture
  5. Nonce generation
  6. Signing Flow
  7. Verification of the Signature
  8. JSON Web Token (JWT) Issuance
  9. Best Practices
  10. Conclusion

Introduction:
In traditional web applications, authentication is usually built around usernames, emails, and passwords. Web3 applications take a completely different approach. Instead of proving identity with a password, users prove ownership of a wallet through cryptographic signatures.

This is where wallet authentication comes in. Wallet authentication allows users to securely log into decentralized applications (dApps) using wallets like MetaMask, or Trust Wallet without creating traditional accounts or storing passwords.
In this article, we will build a complete Ethereum wallet authentication backend. We will cover the architecture, nonce generation, wallet signing flow, signature verification, and JWT issuance.
By the end of this guide, you will understand how modern dApps authenticate users securely while keeping the backend lightweight and scalable.

Foreknowledge:
Before you continue with the tutorial for implementation, you should have foreknowledge of these concepts:

  • Basic Python programming
  • APIs and HTTP requests
  • REST architecture
  • JSON data handling
  • Basic understanding of blockchain wallets

You do not need advanced blockchain knowledge to follow through this tutorial. As long as you understand how APIs work, you can build wallet authentication systems.

Prerequisites
To follow along, make sure you have the following installed:

  • Python 3.10+
  • Pip
  • Virtual environment
  • Git

You will also need these Python libraries:

pip install fastapi uvicorn web3 eth-account jose python-dotenv

You should also have:

A crypto wallet like MetaMask
Basic understanding of signing messages

You can find the github repo here
You can find the Live API here

The Architecture
Before writing code, let us understand the authentication flow.
Unlike traditional login systems, wallet authentication does not use passwords. Instead, the backend generates a unique message (nonce), and the wallet signs it.
The flow works like this:

  1. User connects wallet
  2. Backend generates nonce
  3. Frontend asks wallet to sign nonce
  4. User signs the message
  5. Signed message is sent to backend
  6. Backend verifies signature
  7. Backend issues JWT token
  8. User becomes authenticated

A visual representation can be:
Frontend
↓
Request Nonce
↓
Backend Generates Nonce
↓
Wallet Signs Message
↓
Frontend Sends Signature
↓
Backend Verifies Signature
↓
JWT Token Issued

This process proves that the user owns the wallet without exposing private keys.

Nonce Generation
A nonce is a one-time random string generated by the backend.
Its main purpose is to:

  • prevent replay attacks
  • ensure every login request is unique
  • create a secure signing challenge
    Example nonce generation:
import secrets

nonce = secrets.token_hex(16)

This generates a secure random hexadecimal string.
Example:

9f3d1a8bc44e1ff03c2e71ab93a12d4c

This nonce is usually stored temporarily in three ways:

  1. Redis
  2. PostgreSQL
  3. in-memory cache
    A common backend structure looks like this:
nonces = {}

address = wallet_address.lower()
nonces[address] = nonce

When the frontend requests authentication, the backend responds with the nonce tied to that wallet address.

Signing Flow
Once the frontend receives the nonce, it asks the user to sign it using their wallet.

The frontend typically uses libraries like:

  • ethers.js
  • web3.js

Example signing flow using ethers.js:

const signer = provider.getSigner()

const signature = await signer.signMessage(nonce)

At this stage:

  • the wallet does NOT send private keys
  • the wallet only produces a cryptographic proof
  • the backend can later verify ownership

The frontend then sends:

  • wallet address
  • signature
  • nonce

to the backend verification endpoint.

Verification of the Signature

This is the core security layer of wallet authentication.

The backend verifies:

  • the signature is valid
  • the signer actually owns the wallet
  • the nonce matches the stored challenge

Using Web3.py and eth-account:

from eth_account.messages import encode_defunct
from web3 import Web3

message = encode_defunct(text=nonce)

recovered_address = Web3().eth.account.recover_message(
    message,
    signature=signature
)

if recovered_address.lower() != wallet_address.lower():
    raise Exception("Invalid signature")

How this works:

  1. Ethereum signatures can mathematically recover the signer’s wallet address.
  2. if the recovered address matches the submitted wallet address, authentication succeeds.
  3. if not, the request is rejected.

Once verification succeeds:

  • delete the nonce
  • prevent replay attacks
  • continue authentication

Example:

del nonces[wallet_address.lower()]

JSON Web Token (JWT) Issuance
After successful verification, the backend issues a JWT token.

JWTs allow authenticated access to protected endpoints without repeatedly signing messages.

Example JWT generation:

import jwt
from datetime import datetime, timedelta

payload = {
    "sub": wallet_address,
    "exp": datetime.utcnow() + timedelta(hours=24)
}

token = jwt.encode(payload, SECRET_KEY, algorithm="HS256")

The token contains:

  • wallet address
  • expiration time
  • authentication claims

The frontend stores this token and includes it in future API requests.
Example:

Authorization: Bearer YOUR_JWT_TOKEN

Best Practices
When building production-grade wallet authentication systems:

  1. Use Expiring Nonces

Never reuse a nonce.

  1. Use HTTPS

Always encrypt requests.

  1. Add Rate Limiting

Prevent spam attacks.

  1. Store JWT Secret Securely

Use environment variables.

  1. Add Wallet Chain Validation

Ensure users sign from the correct network.

  1. Use Short JWT Expiration

Reduce token abuse risk.

Conclusion
Wallet authentication is one of the foundational systems behind modern Web3 applications. While it may initially seem complex, the entire process boils down to a simple cryptographic challenge-response flow.

Using FastAPI and Ethereum tools, you can build secure authentication systems that:

  • eliminate passwords
  • improve user ownership
  • simplify onboarding
  • integrate seamlessly with dApps

Once you understand wallet authentication, building more advanced Web3 backend systems such as:

  • wallet portfolio trackers
  • NFT dashboards
  • staking platforms
  • DAO voting systems
  • on-chain analytics APIs

becomes significantly easier.
Wallet authentication is becoming the standard identity layer for decentralized applications. Understanding this system gives backend developers a strong foundation for building secure Web3 infrastructure. The future of authentication is shifting toward ownership-based identity, and wallet authentication is one of the strongest examples of that transition.
Happy Coding!