Workspace IndexDev Notes › OpenZeppelin Relayer & Monitor

#5PoC

OpenZeppelin Relayer & Monitor

The managed service shut down; the tools were opened. Self-hosted transaction plumbing and on-chain alerting.

A thought experiment against verex's ChainJob worker: what a Relayer deletes, and what has to stay.

Why

Two questions this catalogue has not asked yet. First, the operational one: every agent demo here hand-rolls the dullest and most failure-prone part of on-chain work — nonce management, gas strategy, retries — and OpenZeppelin Relayer is that exact layer, extracted and hardened. Second, and larger: Defender was a managed SaaS that stopped taking sign-ups in June 2025 and shut down on 2026-07-01, handing its functionality to open source on the way out. That makes it a case study in a criterion missing from most infrastructure decisions — not "what does it do" or "what does it cost", but "what remains when the vendor leaves". Defender left well: a year's notice, a migration guide, a production-ready open-source successor. Most vendors will not.

How it works

Relayer keeps the plumbing: it accepts a transaction over a REST API, signs it, and owns nonce sequencing, gas pricing, and retry — EVM multi-chain plus Solana and Stellar, with keys in HashiCorp Vault or AWS KMS rather than an env var. Monitor watches the other direction: declarative JSON rules over events, function calls, and transaction patterns, firing Slack or webhook alerts. The concrete scenario is verex, which already wrote this by hand. Its ChainJob worker executes strictly serially, and its own header explains why: “all txs are sent by the operator or a server-held demo key, so a single lane doubles as nonce management.” Around that sit exponential backoff (5s → 25s → 125s), an atomic PENDING→RUNNING claim, and stuck-job recovery after two minutes — a small relayer, built to make settlement work at all. Adopting the real one deletes the nonce lane, the gas strategy, and the retry ladder, but not onFailed: reversing DB fills after a terminal failure is business logic wearing plumbing's clothes, and no relayer can know that a failed SETTLE_MATCH means two users' balances must be un-credited. The interesting question is the third one. The single lane was serializing business logic as a side effect, not just nonces; widen it and you find out whether that mattered — and this codebase has already produced one bug of exactly that family, a ladder sized from a pre-settlement balanceOf. Monitor addresses the mirror image: that bug was invisible off-chain until it produced a wrong quote, while on-chain it was observable the entire time.

3 diagram(s) on the live page.

Related code

"""OpenZeppelin Relayer PoC -- nonce-managed transaction queue.
Illustrates the core mechanism: the relayer assigns strictly increasing nonces, retries
failed sends with backoff, and never re-uses a nonce even across retries/failures.
"""

from dataclasses import dataclass, field


@dataclass
class TxRequest:
    id: str
    payload: str
    should_fail_times: int = 0  # simulate transient failures before success


@dataclass
class Relayer:
    next_nonce: int = 0
    sent: list[tuple[int, str]] = field(default_factory=list)  # (nonce, tx id)

    def submit(self, req: TxRequest) -> None:
        nonce = self.next_nonce
        self.next_nonce += 1  # nonce is consumed here, permanently -- never reused
        attempts = 0
        backoff = [5, 25, 125]
        while True:
            attempts += 1
            ok = attempts > req.should_fail_times
            print(f"  nonce={nonce} tx={req.id} attempt={attempts} -> {'CONFIRMED' if ok else 'fails, retry'}")
            if ok:
                self.sent.append((nonce, req.id))
                return
            if attempts > len(backoff):
                print(f"  nonce={nonce} tx={req.id} -> exhausted retries, giving up (nonce still not reused)")
                self.sent.append((nonce, f"{req.id} (FAILED)"))
                return
            print(f"    backing off {backoff[attempts - 1]}s before retry")


if __name__ == "__main__":
    relayer = Relayer()
    queue = [
        TxRequest("settle-match-1", "transfer(A,B,10)", should_fail_times=0),
        TxRequest("settle-match-2", "transfer(C,D,5)", should_fail_times=2),
        TxRequest("settle-match-3", "transfer(E,F,7)", should_fail_times=0),
    ]

    print("processing queue, one lane, strictly increasing nonces:")
    for req in queue:
        relayer.submit(req)

    print("\nfinal nonce -> tx mapping (no nonce ever reused):")
    for nonce, tx_id in relayer.sent:
        print(f"  nonce {nonce}: {tx_id}")

← All Dev Notes · Workspace Index · Top ↑ · Open on jaylabs.xyz →

OpenZeppelin Relayer · Monitor

서비스는 죽고, 도구는 열렸다 — 셀프호스팅 트랜잭션 배관과 온체인 감시.

verex의 ChainJob 워커를 대상으로 한 사고 실험 — Relayer가 지우는 것과, 남아야 하는 것.

이 카탈로그가 아직 묻지 않은 질문 둘. 첫째는 운영의 문제입니다 — 여기 있는 모든 에이전트 데모가 온체인 작업에서 가장 지루하고 가장 자주 터지는 부분(논스 관리, 가스 전략, 재시도)을 손으로 다시 짜고 있고, OpenZeppelin Relayer는 정확히 그 층을 뽑아내 굳혀놓은 것입니다. 둘째는 더 큰 문제입니다: Defender는 2025년 6월 신규 가입을 닫고 2026-07-01에 완전히 종료된 관리형 SaaS였고, 나가면서 기능을 오픈소스로 넘겼습니다. 그래서 이 항목은 대부분의 인프라 결정에 빠져 있는 기준 하나에 대한 사례 연구가 됩니다 — "무엇을 하는가"도 "얼마인가"도 아닌, "벤더가 떠날 때 무엇이 남는가". Defender는 잘 떠났습니다: 1년 예고, 마이그레이션 가이드, 프로덕션 레디 오픈소스 후계자. 대부분의 벤더는 그렇게 떠나지 않습니다.

동작 방식

Relayer는 배관을 맡습니다: REST API로 트랜잭션을 받아 서명하고, 논스 순서·가스 가격·재시도를 직접 관리합니다 — EVM 멀티체인에 Solana·Stellar까지, 키는 env 변수가 아니라 HashiCorp Vault나 AWS KMS에 둡니다. Monitor는 반대 방향을 봅니다: 이벤트·함수 호출·트랜잭션 패턴을 선언적 JSON 룰로 감시하고 Slack·웹훅으로 알립니다. 구체적인 시나리오는 verex입니다 — 이미 이걸 손으로 짜 놨거든요. ChainJob 워커는 엄격히 직렬로 실행되고, 그 이유가 파일 헤더에 그대로 적혀 있습니다: "모든 tx를 오퍼레이터나 서버 보관 키가 보내므로, 단일 레인이 곧 논스 관리다." 그 주위에 지수 백오프(5s → 25s → 125s), 원자적 PENDING→RUNNING 클레임, 2분 뒤 멈춘 잡 복구가 붙어 있습니다 — 정산을 굴러가게 만들려고 지은 작은 릴레이어입니다. 진짜 Relayer를 도입하면 논스 레인·가스 전략·재시도 사다리는 지워지지만, onFailed는 아닙니다: 종료 실패 후 DB 체결을 되감는 건 배관의 옷을 입은 비즈니스 로직이고, 실패한 SETTLE_MATCH가 곧 두 사용자의 잔고를 취소해야 한다는 뜻임을 아는 릴레이어는 없습니다. 흥미로운 건 세 번째 질문입니다. 단일 레인은 논스만이 아니라 비즈니스 로직까지 부수적으로 직렬화하고 있었고, 레인을 넓히면 그게 중요했는지 아닌지가 드러납니다 — 그리고 이 코드베이스는 이미 정확히 그 계열의 버그를 하나 냈습니다(정산 전 balanceOf로 사다리를 산정한 건). Monitor는 그 거울상을 맡습니다: 그 버그는 잘못된 호가를 낼 때까지 오프체인에서 보이지 않았지만, 온체인에서는 처음부터 관측 가능했습니다.

3 diagram(s) on the live page.

관련 코드

"""OpenZeppelin Relayer PoC -- nonce-managed transaction queue.
Illustrates the core mechanism: the relayer assigns strictly increasing nonces, retries
failed sends with backoff, and never re-uses a nonce even across retries/failures.
"""

from dataclasses import dataclass, field


@dataclass
class TxRequest:
    id: str
    payload: str
    should_fail_times: int = 0  # simulate transient failures before success


@dataclass
class Relayer:
    next_nonce: int = 0
    sent: list[tuple[int, str]] = field(default_factory=list)  # (nonce, tx id)

    def submit(self, req: TxRequest) -> None:
        nonce = self.next_nonce
        self.next_nonce += 1  # nonce is consumed here, permanently -- never reused
        attempts = 0
        backoff = [5, 25, 125]
        while True:
            attempts += 1
            ok = attempts > req.should_fail_times
            print(f"  nonce={nonce} tx={req.id} attempt={attempts} -> {'CONFIRMED' if ok else 'fails, retry'}")
            if ok:
                self.sent.append((nonce, req.id))
                return
            if attempts > len(backoff):
                print(f"  nonce={nonce} tx={req.id} -> exhausted retries, giving up (nonce still not reused)")
                self.sent.append((nonce, f"{req.id} (FAILED)"))
                return
            print(f"    backing off {backoff[attempts - 1]}s before retry")


if __name__ == "__main__":
    relayer = Relayer()
    queue = [
        TxRequest("settle-match-1", "transfer(A,B,10)", should_fail_times=0),
        TxRequest("settle-match-2", "transfer(C,D,5)", should_fail_times=2),
        TxRequest("settle-match-3", "transfer(E,F,7)", should_fail_times=0),
    ]

    print("processing queue, one lane, strictly increasing nonces:")
    for req in queue:
        relayer.submit(req)

    print("\nfinal nonce -> tx mapping (no nonce ever reused):")
    for nonce, tx_id in relayer.sent:
        print(f"  nonce {nonce}: {tx_id}")

← 전체 개발 노트 · 워크스페이스 인덱스 · 맨 위 ↑ · Open on jaylabs.xyz →