verex

v1 Security Audit Notes (2026-05-08)

Phase 1 W1 산출물(컨트랙트 + SDK + CLI)에 대한 경량 셀프 리뷰. 정식 3rd-party audit 아님 — v1은 testnet/anvil 데모 범위라 그럴 필요 없음. v2 (Phase 2 W6 — Polymarket CTF Exchange)로 갈 때 위 컨트랙트는 deprecate되므로, 본 문서의 발견은 “v1 운영 중 알아야 할 것” + “v2가 자동 해소하는 것” 두 가지로 분류.

리뷰 대상:

리뷰 대상 아님 (no on-chain attack surface):

Severity 표기

레벨 의미
HIGH v1 demo에서도 즉시 fix 필요
MEDIUM v1 운영 중 인지 + 운영 절차로 mitigate
LOW 알면 좋고 production 전 fix 필요
INFO 의도된 단순화 / linter 잡음 — 문서화만

1. forge lint 발견

1.1 block.timestamp 비교 [INFO × 4]

warning[block-timestamp]: usage of `block.timestamp` in a comparison may be manipulated by validators

위치

라인 코드 용도
Market.sol:28 require(_endTime > block.timestamp, "endTime must be future") 생성자 — 과거 시점 마켓 생성 차단
Market.sol:36 require(block.timestamp < endTime, "market closed") buyYes() 가드
Market.sol:44 require(block.timestamp < endTime, "market closed") buyNo() 가드
Market.sol:54 require(block.timestamp >= endTime, "market not ended") resolve() 가드

의미

EVM 체인의 validator/proposer가 자기 블록의 block.timestamp±12초 (Ethereum 기준) 정도 임의로 정할 수 있음. 정확히는 직전 블록 timestamp보다 크고, 미래 ~15초 이내라는 합의 룰 안에서 자유. 그래서 시간을 초 단위로 정밀하게 가정하면 안 된다는 일반 가이드.

v1에서 무시 가능한 이유

언제 신경 써야 하나

해소 시점: v2의 UMA optimistic oracle은 timestamp 의존을 외부 attestation으로 대체 → 이 경고 자체가 사라짐.

액션: 없음. linter가 잡음 — 문서로만 인지.


2. 디자인 footgun / 잠재적 risk

2.1 단일 글로벌 owner = SPOF [MEDIUM, v1 한정]

상황

MarketFactory(_owner)가 spawn하는 모든 Market의 owner는 factory의 owner 단일. 즉 어떤 마켓도 그 키 하나로 resolve됨.

위험

v1 mitigation

v2 해소

CTF Exchange + UMA oracle: resolve 권한이 owner → optimistic oracle (분쟁 가능). owner key 분실/유출 영향 거의 없음.

2.2 한쪽 풀에 0 베팅 + 그쪽이 winner = 자금 영구 동결 [LOW]

상황

yesPool = 0
noPool  = 5 ETH
owner.resolve(true) // YES wins

claim()에서 userShares = yesShares[user] = 0 이므로 모두 0 반환. 5 ETH가 컨트랙트에 영구 동결.

왜 일어나는가

운영자가 잘못된 결과로 resolve해도 막을 가드 없음 (의도적 — owner는 trusted).

v1 mitigation

v2 해소

CTF Exchange는 conditional token 모델이라 outcome shares가 한쪽으로 다 쏠려도 문제 없음 — 양쪽 token 1:1 collateral 모델이라 같은 시나리오가 발생 안 함.

액션: v1 운영 절차에 “resolve 전 양쪽 풀 > 0 확인” 추가 권장. 코드 변경은 안 함 (v1 단순함 우선).

2.3 getMarkets() 비제한 배열 반환 [LOW]

상황

MarketFactory.getMarkets()markets 배열 전체를 반환. 마켓 수가 많아지면 eth_call 가스 한도(~50M @ public RPC) 초과 → 호출 실패 (DoS).

위험

v1 mitigation

v2 해소

Phase 2 W4~5에 indexer + Postgres 들어옴 → 마켓 리스트는 DB에서 조회. 컨트랙트 직접 호출은 backup만.

2.4 CLI에 anvil 기본 키 하드코딩 [INFO]

위치: packages/cli/src/clients.ts:5-16

10개 anvil 기본 private key가 소스에 박혀있음. 이 키들은 공개된 anvil 기본값이라 secret 아님 — anvil 사용자라면 누구나 같은 키 사용. 하지만:

v1 mitigation

CLI 시작 시 chainId 검증 추가 권장:

if (chainId !== 31337) throw new Error("CLI는 anvil(chainId 31337)에서만 사용");

또는 anvil 키를 env-only로 바꾸고 코드엔 두지 않음.

액션: W2/W3 시점에 chainId 가드 추가 (지금은 anvil 전용 demo CLI라 우선순위 낮음).

2.5 Deploy.s.solPRIVATE_KEY env fallback [INFO]

위치: Deploy.s.sol:18

uint256 deployerKey = vm.envOr("PRIVATE_KEY", uint256(0xac0974...ff80));

PRIVATE_KEY env 미설정 시 anvil account[0] 키를 default로 사용. 위 2.4와 같은 위험.

v1 mitigation

production deploy 절차에 “PRIVATE_KEY env 반드시 설정” 명시. 또는 fallback 제거하고 env 필수화:

uint256 deployerKey = vm.envUint("PRIVATE_KEY");

액션: testnet 첫 deploy 직전에 fallback 제거.


3. 검증된 안전 사항 (no findings)

항목 검증 방식
Reentrancy in claim() CEI 패턴 — shares를 0으로 먼저, then call{value:}. 재진입해도 userShares == 0이라 second iteration에서 즉시 0 반환
Integer overflow/underflow Solidity 0.8.24 default checked arithmetic. unchecked 블록 사용 없음
ETH 전송 방식 .call{value: x}("") + require(ok) — Gnosis Safe 등 contract 수령자도 호환. .transfer/.send 안 씀 (gas stipend 2300 문제 회피)
Double-claim 같은 winner가 두 번 claim해도 두 번째는 0 반환. test_DoubleClaimReturnsZeroSecondTime로 검증
Zero-value 베팅 require(msg.value > 0, "zero amount")
endTime 이전 resolve require(block.timestamp >= endTime, "market not ended")
Non-owner resolve require(msg.sender == owner, "not owner")
중복 resolve require(!resolved, "already resolved")
factory의 new Market(...) 재진입 constructor만 실행, 콜백 surface 없음
owner zero address constructor에서 require(_owner != address(0))
endTime past constructor에서 require(_endTime > block.timestamp)

위 모두 Market.t.sol / MarketFactory.t.sol에 대응 테스트 있음 (총 17/17 pass).


4. Pro-rata 산술 정밀도 [INFO]

payout = (userShares * totalPool) / winningPool;

최대값 분석

실제로 베팅 규모가 millions of ETH 수준이 아니면 overflow 불가능. v1 demo 범위에선 무관.

Truncation

정수 나눗셈이라 마지막 claimer가 wei 단위로 1~N wei 적게 받을 수 있음. 컨트랙트에 wei 잔액 쌓임 (영구 동결 — 회수 함수 없음). v1 demo에선 무시 가능 수준.

v2 해소: CTF + ERC-1155 token 회계는 다른 방식이라 truncation 양상 다름.


5. v2 handoff 매핑

v1 사항 v2 (CTF Exchange + UMA + USDC)에서
1.1 block.timestamp 4 warning 사라짐 — UMA가 외부 attestation으로 시간 의존 대체
2.1 단일 owner SPOF 사라짐 — UMA optimistic oracle, 분쟁 가능
2.2 한쪽 0 풀 lock 사라짐 — CTF의 conditional token 모델은 collateral split 1:1, 같은 corner case 없음
2.3 getMarkets() DoS 완화 — indexer + DB가 1차 데이터 소스
2.4 CLI 하드코딩 키 그대로 — testnet 도구라 v2와 무관
2.5 PRIVATE_KEY fallback testnet 전 fallback 제거 (v1/v2 공통)
§4 Pro-rata truncation 사라짐 — CTF 회계 모델 다름

v1 footgun의 70%는 v2 도입 자체가 자동 해소. 따라서 v1 컨트랙트를 더 hardening할 가치는 낮음 — Phase 2 W6 일정에 집중하는 게 ROI 높음.


6. Out of scope (이번 리뷰 미포함)


7. 결론

항목 상태
HIGH 발견 0
MEDIUM 발견 1 (단일 owner SPOF — v2가 자동 해소)
LOW 발견 2 (한쪽 0 풀 lock, getMarkets DoS)
INFO 4 + 2 (block.timestamp ×4, 키 하드코딩, env fallback)
권장 즉시 조치 없음 (v1 demo 범위 내)
production 전 조치 PRIVATE_KEY fallback 제거, CLI chainId 가드 추가, 운영 절차에 “양쪽 풀 > 0 확인” 추가

v1은 “풀스택 한 바퀴 검증” 목적의 demo 백본이라 위 정도 risk는 명시적으로 수용. v2 (Phase 2 W6) 통합 시 본 발견의 대부분이 자동 해소되므로, v1 자체 hardening보다 v2 일정 진행이 우선.


부록: 다시 검증하는 방법

export PATH="$HOME/.foundry/bin:$PATH"
cd packages/contracts

# 빌드 + lint warnings 보기
forge clean && forge build 2>&1 | grep -B1 -A4 'warning\['

# 모든 테스트
forge test -vv

# 특정 보안 관점 테스트
forge test --match-test 'test_Revert|test_DoubleClaim|test_LoserClaim' -vv