PoCs

OpenZeppelin Relayer · Monitor

서비스는 죽고, 도구는 열렸다 — 관리형 SaaS가 종료되며 오픈소스로 넘긴 트랜잭션 배관과 온체인 감시.

목업 — 아직 아무것도 실제로 돌지 않습니다

Relayer도 Monitor도 배포되어 있지 않습니다. 아래 큐 화면의 숫자는 전부 손으로 적은 각본이고, 「다음 블록」 버튼이 그것을 한 칸씩 보여줄 뿐입니다. 도입 여부를 정하기 전에, 무엇을 바꾸게 되는지를 눈앞에 두고 이야기하기 위한 페이지입니다.

먼저, 이 항목이 여기 있는 이유

Defender는 컨트랙트 운영의 3종 세트(트랜잭션 릴레이·온체인 모니터링·운영 자동화)를 팔던 관리형 SaaS였고, 2025-06-30 신규 가입 중단 → 2026-07-01 완전 종료됐습니다. OpenZeppelin은 같은 기능을 프로덕션 레디 오픈소스로 다시 내놨습니다. 그래서 이 항목의 질문은 「이 서비스를 쓸까」가 아닙니다 — 대부분의 인프라 결정에 빠져 있는 기준 하나에 대한 사례 연구입니다: 벤더가 떠날 때 무엇이 남는가.

Defender는 잘 떠났습니다 — 1년 예고, 마이그레이션 가이드, 오픈소스 후계자. 그래서 오히려 좋은 기준점이 됩니다. 대부분의 벤더는 이렇게 나가주지 않고, 그때 남는 건 「우리가 그 위에 무엇을 얹었는가」뿐입니다.

사고 실험의 대상 — verex는 이미 릴레이어를 손으로 짰다

verex의 ChainJob 워커(packages/api/src/worker.ts)는 엄격히 직렬로 실행되고, 그 이유가 파일 헤더에 그대로 적혀 있습니다 — 「모든 tx를 오퍼레이터나 서버 보관 키가 보내므로, 단일 레인이 곧 논스 관리다.」 그 주위에 지수 백오프(5s → 25s → 125s), 원자적 PENDING→RUNNING 클레임, 2분 뒤 멈춘 잡 복구, 종료 실패 시 보상(onFailed)이 붙어 있습니다. 이름만 안 붙었을 뿐 릴레이어입니다.

  1. 무엇을 지울 수 있나? 논스 레인, 가스 전략, 재시도 사다리. 셋 다 배관이고, 셋 다 이미 남이 더 잘 만들어 뒀습니다.
  2. 무엇이 남아야 하나? onFailed. 종료 실패 후 DB 체결을 되감는 건 배관의 옷을 입은 비즈니스 로직입니다 — 릴레이어는 트랜잭션이 실패했다는 건 알지만, 실패한 SETTLE_MATCH가 곧 두 사용자의 잔고를 취소해야 한다는 뜻임은 알 수 없습니다.
  3. 무엇이 깨지나? 이게 진짜 질문입니다. 단일 레인은 논스만이 아니라 정산 순서까지 부수적으로 직렬화하고 있었습니다. 레인을 넓히면 그 보장이 사라지고, 그게 중요했는지 아닌지가 그때 드러납니다 — 그리고 이 코드베이스는 이미 정확히 그 계열의 버그를 하나 냈습니다(정산 전 balanceOf로 사다리를 산정한 건, 2026-08-07).
구체적으로 — 무엇을 세우고, verex의 어디가 바뀌나

① Relayer 세우기 — 작은 VM 1대

git clone https://github.com/OpenZeppelin/openzeppelin-relayer
cd openzeppelin-relayer && cp .env.example .env
docker compose up -d          # REST API 노출

핵심은 설정 파일이다 — 네트워크, 그리고 키를 어디서 가져오는가. 오퍼레이터 키가 서버 env를 떠나는 지점이라, 도입의 부수 효과가 아니라 주된 이유일 수 있다.

{
  "networks": [{ "network": "sepolia", "rpc_urls": ["${SEPOLIA_RPC}"] }],
  "signers": [{
    "id": "verex-operator",
    "type": "aws_kms",            // ← env 변수가 아니라 KMS
    "config": { "key_id": "${KMS_KEY_ID}", "region": "ap-northeast-2" }
  }],
  "relayers": [{
    "id": "verex-settle",
    "network": "sepolia",
    "signer_id": "verex-operator",
    "policies": { "gas_price_cap": "50000000000" }
  }]
}

② verex의 호출부 — 무엇이 지워지나

지금은 워커가 viem으로 직접 쏘고, 논스는 「레인이 하나라서」 맞고, 실패하면 runAfter를 밀어 재시도한다. Relayer를 넣으면 워커는 「무엇을 보낼지」만 정하고 나머지를 넘긴다.

// before — worker.ts 안, 직렬 레인이 논스를 지켜준다
const hash = await exchange.matchOrders(taker, makers, fills);
await publicClient.waitForTransactionReceipt({ hash });

// after — 논스·가스·재시도는 Relayer 소관
const res = await fetch(`${RELAYER}/api/v1/relayers/verex-settle/transactions`, {
  method: "POST",
  headers: { Authorization: `Bearer ${RELAYER_KEY}` },
  body: JSON.stringify({ to: EXCHANGE, data: encodeMatchOrders(...), value: "0" }),
});
const { id } = await res.json();   // 이 id를 ChainJob.result 에 저장하고 폴링

지워지는 것: 논스 레인(= 직렬 제약), 가스 가격 선택, 백오프 사다리 5s/25s/125s. 남는 것: ChainJob 행 자체(어떤 비즈니스 작업인지 아는 건 여기뿐), 그리고 onFailed.

③ Monitor 룰 — 이미 근거가 있는 첫 룰

08-07의 사다리 버그는 잘못된 호가를 낼 때까지 오프체인에서 보이지 않았지만, 온체인에서는 처음부터 관측 가능했다. 오퍼레이터의 실제 보유량과 호가 규모가 어긋나는 순간이 곧 룰이다.

{
  "name": "verex-operator-oversold",
  "networks": ["sepolia"],
  "addresses": ["0xCTF"],
  "match_conditions": {
    "events": [{
      "signature": "TransferSingle(address,address,address,uint256,uint256)",
      "expression": "from == OPERATOR && value > 500000000"
    }]
  },
  "triggers": ["slack_settlement"]
}

그 다음 룰 셋: 정산 실패(FAILED로 떨어진 ChainJob에 대응하는 revert), 만료 임박한 마켓의 미확정 상태, 그리고 Relayer 계정의 가스 잔량 — 마지막 것이 「누가 데몬을 지켜보는가」에 대한 유일한 실질적 답이다.

정산 큐 — 같은 6건, 레인만 다르게

한 틱 = 블록 하나(Sepolia ~12초). 체인 지연·가스·재시도 횟수는 양쪽을 같게 두었다 — 다른 건 동시에 띄우는 트랜잭션 수뿐이다. 3번 잡은 일부러 한 번 실패한다.

오늘 — 단일 직렬 레인
1/6 확정

worker.ts가 그렇게 말한다: 레인이 하나라 논스 경합이 없다. 처리량은 블록당 1건에 묶인다.

체결 — BTC 10만? · 240 USDC블록 1
· 체결 — 연준 인하? · 55 USDC블록 2
· 체결 — 월드컵 · 1,200 USDC (1회 실패)블록 4
· 체결 — ETH ETF? · 18 USDC블록 5
· 확정 — 연준 인하? → 아니오블록 6
· 상환 — 0x9f3d, 55 USDC블록 7
Relayer — 동시 3건
2/6 확정

논스를 릴레이어가 관리하므로 레인을 넓힐 수 있다. 공짜는 아니다 — 아래를 보라.

체결 — BTC 10만? · 240 USDC블록 1
체결 — 연준 인하? · 55 USDC블록 1
· 체결 — 월드컵 · 1,200 USDC (1회 실패)블록 2
· 체결 — ETH ETF? · 18 USDC블록 2
· 확정 — 연준 인하? → 아니오블록 2
· 상환 — 0x9f3d, 55 USDC블록 3
현재 블록 1 / 7
Monitor — 반대 방향

Relayer가 내보내는 쪽이라면 Monitor는 지켜보는 쪽입니다. 이벤트·함수 호출·트랜잭션 패턴을 선언적 JSON 룰로 감시하고 Slack이나 웹훅으로 알립니다. verex에 붙일 첫 룰이 정해져 있는데, 근거가 이미 있기 때문입니다 — 08-07의 사다리 버그는 잘못된 호가를 낼 때까지 오프체인에서 보이지 않았지만, 온체인에서는 처음부터 관측 가능했습니다. 오퍼레이터의 실제 보유량과 호가 규모가 어긋나는 순간이 곧 룰입니다.

{
  "name": "verex-operator-oversold",
  "networks": ["sepolia"],
  "addresses": ["0xCTF"],
  "match_conditions": {
    "events": [{
      "signature": "TransferSingle(address,address,address,uint256,uint256)",
      "expression": "from == OPERATOR && value > 500000000"
    }]
  },
  "triggers": ["slack_alert"]
}

위 룰은 예시일 뿐 검증된 스키마가 아닙니다 — 실제 필드명은 openzeppelin-monitor 저장소에서 확인해야 합니다.

이 목업을 두고 정해야 할 것
이 페이지가 주장하지 않는 것

기술 노트

각 항목이 실제로 무엇을 보여주고 어떻게 동작하는지 — 위 카드보다 자세한 기술 설명입니다.

OpenZeppelin Relayer · Monitor완료

목적: 이 카탈로그가 아직 묻지 않은 질문 둘. 첫째는 운영의 문제입니다 — 여기 있는 모든 에이전트 데모가 온체인 작업에서 가장 지루하고 가장 자주 터지는 부분(논스 관리, 가스 전략, 재시도)을 손으로 다시 짜고 있고, 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는 그 거울상을 맡습니다: 그 버그는 잘못된 호가를 낼 때까지 오프체인에서 보이지 않았지만, 온체인에서는 처음부터 관측 가능했습니다.

오늘의 verex — 레인이 곧 논스 관리자라서, 레인이 하나
Relayer를 넣으면 — 무엇이 떠나고 무엇이 남아야 하나
에이전트의 틱에서 Relayer가 앉을 자리
관련 코드:
"""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}")

docs/code/pocs/oz-relayer.py