OpenZeppelin Relayer · Monitor
서비스는 죽고, 도구는 열렸다 — 관리형 SaaS가 종료되며 오픈소스로 넘긴 트랜잭션 배관과 온체인 감시.
Relayer도 Monitor도 배포되어 있지 않습니다. 아래 큐 화면의 숫자는 전부 손으로 적은 각본이고, 「다음 블록」 버튼이 그것을 한 칸씩 보여줄 뿐입니다. 도입 여부를 정하기 전에, 무엇을 바꾸게 되는지를 눈앞에 두고 이야기하기 위한 페이지입니다.
Defender는 컨트랙트 운영의 3종 세트(트랜잭션 릴레이·온체인 모니터링·운영 자동화)를 팔던 관리형 SaaS였고, 2025-06-30 신규 가입 중단 → 2026-07-01 완전 종료됐습니다. OpenZeppelin은 같은 기능을 프로덕션 레디 오픈소스로 다시 내놨습니다. 그래서 이 항목의 질문은 「이 서비스를 쓸까」가 아닙니다 — 대부분의 인프라 결정에 빠져 있는 기준 하나에 대한 사례 연구입니다: 벤더가 떠날 때 무엇이 남는가.
Defender는 잘 떠났습니다 — 1년 예고, 마이그레이션 가이드, 오픈소스 후계자. 그래서 오히려 좋은 기준점이 됩니다. 대부분의 벤더는 이렇게 나가주지 않고, 그때 남는 건 「우리가 그 위에 무엇을 얹었는가」뿐입니다.
verex의 ChainJob 워커(packages/api/src/worker.ts)는 엄격히 직렬로 실행되고, 그 이유가 파일 헤더에 그대로 적혀 있습니다 — 「모든 tx를 오퍼레이터나 서버 보관 키가 보내므로, 단일 레인이 곧 논스 관리다.」 그 주위에 지수 백오프(5s → 25s → 125s), 원자적 PENDING→RUNNING 클레임, 2분 뒤 멈춘 잡 복구, 종료 실패 시 보상(onFailed)이 붙어 있습니다. 이름만 안 붙었을 뿐 릴레이어입니다.
- 무엇을 지울 수 있나? 논스 레인, 가스 전략, 재시도 사다리. 셋 다 배관이고, 셋 다 이미 남이 더 잘 만들어 뒀습니다.
- 무엇이 남아야 하나? onFailed. 종료 실패 후 DB 체결을 되감는 건 배관의 옷을 입은 비즈니스 로직입니다 — 릴레이어는 트랜잭션이 실패했다는 건 알지만, 실패한 SETTLE_MATCH가 곧 두 사용자의 잔고를 취소해야 한다는 뜻임은 알 수 없습니다.
- 무엇이 깨지나? 이게 진짜 질문입니다. 단일 레인은 논스만이 아니라 정산 순서까지 부수적으로 직렬화하고 있었습니다. 레인을 넓히면 그 보장이 사라지고, 그게 중요했는지 아닌지가 그때 드러납니다 — 그리고 이 코드베이스는 이미 정확히 그 계열의 버그를 하나 냈습니다(정산 전 balanceOf로 사다리를 산정한 건, 2026-08-07).
① 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 계정의 가스 잔량 — 마지막 것이 「누가 데몬을 지켜보는가」에 대한 유일한 실질적 답이다.
한 틱 = 블록 하나(Sepolia ~12초). 체인 지연·가스·재시도 횟수는 양쪽을 같게 두었다 — 다른 건 동시에 띄우는 트랜잭션 수뿐이다. 3번 잡은 일부러 한 번 실패한다.
worker.ts가 그렇게 말한다: 레인이 하나라 논스 경합이 없다. 처리량은 블록당 1건에 묶인다.
논스를 릴레이어가 관리하므로 레인을 넓힐 수 있다. 공짜는 아니다 — 아래를 보라.
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 저장소에서 확인해야 합니다.
- 레인을 넓히는 게 verex에 실제로 필요한가 — 지금 정산량이 블록당 1건에 눌릴 만큼인가, 아니면 아직 아무 문제도 아닌가? 필요 없는데 도입하면 운영 부담만 새로 생긴다.
- 순서 보장이 필요한 범위는 어디까지인가 — 마켓 단위인가, 전역인가? 마켓 단위면 마켓별로 레인을 나눠도 되고, 그러면 릴레이어 없이도 넓힐 수 있다.
- 키를 어디 두는가 — 지금은 서버 env다. Vault/KMS 연동은 릴레이어 도입의 부수 효과가 아니라, 어쩌면 도입의 주된 이유일 수도 있다.
- 누가 이 데몬을 지켜보는가 — Docker 이미지가 self-contained인 것과, 새벽 3시에 멈춘 걸 누가 알아채는가는 다른 문제다. 에이전트 카드의 「지켜지지 않는 가스통」과 같은 모양의 구멍이다.
- Relayer를 도입해야 한다는 주장이 아니다 — 위 세 질문 중 ①만 답하고 ②③을 건너뛰면 정산이 조용히 망가진다.
- 큐 화면의 숫자는 측정값이 아니라 각본이다. 체인 지연·가스·재시도를 양쪽 같게 두고 동시성만 다르게 계산했을 뿐이며, 실제 처리량은 블록 가스와 릴레이어 설정에 달려 있다.
- 오픈소스로 넘어갔다는 것이 곧 안전한 선택이라는 뜻은 아니다. 호스팅·가용성·대시보드·Autotasks는 관리형이었기에 공짜로 얻던 것이고, 그 책임이 전부 사용자에게 돌아온다.
기술 노트
각 항목이 실제로 무엇을 보여주고 어떻게 동작하는지 — 위 카드보다 자세한 기술 설명입니다.
목적: 이 카탈로그가 아직 묻지 않은 질문 둘. 첫째는 운영의 문제입니다 — 여기 있는 모든 에이전트 데모가 온체인 작업에서 가장 지루하고 가장 자주 터지는 부분(논스 관리, 가스 전략, 재시도)을 손으로 다시 짜고 있고, 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는 그 거울상을 맡습니다: 그 버그는 잘못된 호가를 낼 때까지 오프체인에서 보이지 않았지만, 온체인에서는 처음부터 관측 가능했습니다.
"""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}")