verex

Phase 1 — W1 Implementation (2026-05-07)

첫 컨트랙트, SDK, CLI까지 묶어 anvil 위에서 end-to-end loop이 도는 시점의 스냅샷. 후속 W2 (MCP server) 진입 전 reference로 두고, 이후 변경은 git 이력에 맡긴다.


1. 결과 (마일스톤 검증)

마일스톤 정의 상태
M1 (Day 3) forge test 100% 통과 (plan 5개 시나리오 포함) 17/17 pass
M2 (Day 7) SDK CLI로 anvil 위 create → bet → resolve → claim 시연 demo 완주 (alice +5 ETH 정확)

v1 백본 범위: fixed-price 1:1 escrow (parimutuel), native ETH 콜래터럴, owner manual resolve, 단일 outcome (binary). v2 (Polymarket CTF Exchange)는 Phase 2 W6에 백본 교체 예정 — docs/plan/README.md §4.5 참고.


2. 변경된 파일 목록

파일 분류 줄 수 (대략)
packages/contracts/src/Market.sol 컨트랙트 (재작성) 80
packages/contracts/src/MarketFactory.sol 컨트랙트 (신규) 36
packages/contracts/test/Market.t.sol 테스트 (재작성) 165
packages/contracts/test/MarketFactory.t.sol 테스트 (신규) 75
packages/contracts/script/Deploy.s.sol 배포 스크립트 (신규) 30
packages/sdk/scripts/sync-abis.mjs 빌드 헬퍼 (신규) 35
packages/sdk/src/factory.ts SDK (신규) 75
packages/sdk/src/market.ts SDK (신규) 110
packages/sdk/src/types.ts SDK 타입 (재작성) 28
packages/sdk/src/index.ts SDK barrel (재작성) 4
packages/sdk/src/abis/{Market,MarketFactory,index}.ts 자동 생성 (커밋 안 함 권장)
packages/sdk/package.json viem 2.21+, prebuild script
packages/sdk/tsconfig.json DOM lib + skipLibCheck + resolveJsonModule
packages/cli/ 신규 패키지 전체
packages/contracts/lib/forge-std/ dependency (forge install)

제거됨:


3. 컨트랙트 설계

3.1 Market.sol (per-market 컨트랙트)

constructor(question, endTime, owner)
  - endTime > block.timestamp 강제
  - owner ≠ address(0) 강제

buyYes() payable / buyNo() payable
  - block.timestamp < endTime 가드
  - msg.value > 0 가드
  - {yes,no}Pool 누적, {yes,no}Shares[user] += msg.value
  - emit Bought(buyer, isYes, amount)

resolve(bool outcome)
  - msg.sender == owner
  - !resolved
  - block.timestamp >= endTime
  - resolved = true; outcome = _outcome
  - emit Resolved(outcome)

claim() returns (uint256 payout)
  - resolved 강제
  - userShares == 0 → return 0 (revert 아님 — 핵심 invariant)
  - payout = (userShares * totalPool) / winningPool  (pro-rata)
  - CEI: shares 먼저 0으로, then call{value: payout}
  - emit Claimed(user, payout)

의도된 단순화:

3.2 MarketFactory.sol

constructor(owner)
  - owner ≠ address(0) 강제
  - 이 owner가 모든 spawned Market의 resolver가 됨

createMarket(question, endTime) → market address
  - permissionless (누구나 마켓 생성 가능)
  - new Market(question, endTime, owner)  ← factory.owner를 inject
  - markets.push(address)
  - emit MarketCreated(market, creator, question, endTime)

getMarkets() → address[]
marketCount() → uint256

핵심 디자인 결정:

3.3 이벤트 시그니처

이벤트 파라미터 indexed
MarketCreated address market, address creator, string question, uint256 endTime market, creator
Bought address buyer, bool isYes, uint256 amount buyer
Resolved bool outcome
Claimed address user, uint256 amount user

Phase 2 W4~5에서 indexer 짤 때 위 이벤트들을 listen.


4. 테스트 커버리지

4.1 Plan 요구 5개 시나리오 (모두 Market.t.sol)

  1. test_BothSidesBet_WinnerGetsAllPool — 양쪽 베팅 후 resolve → winner만 분배
  2. test_RevertWhen_BuyAfterEndTime — endTime 이후 베팅 실패
  3. test_RevertWhen_DoubleResolve — 같은 market 중복 resolve 실패
  4. test_LoserClaimReturnsZeroNoRevert — loser claim 시 0 반환 (revert 아님)
  5. test_Invariant_BalanceEqualsPoolsBeforeResolve — 총 escrow == YES pool + NO pool (resolve 전)

4.2 추가 sanity tests

4.3 MarketFactory 테스트 (MarketFactory.t.sol)

17 tests, 17 pass.


5. SDK 구조

5.1 ABI sync 파이프라인 (수동 복사 금지 원칙 충족)

packages/contracts/out/{Market,MarketFactory}.sol/*.json
                     ↓
        scripts/sync-abis.mjs (forge build 산출물 → TS const)
                     ↓
        packages/sdk/src/abis/{Market,MarketFactory,index}.ts
                     ↓
        packages/sdk/src/{factory,market}.ts (import { MarketAbi } from "./abis")

5.2 클라이언트 API

import {
  createFactoryClient,
  createMarketClient,
  type Address,
  type MarketInfo,
  type PositionInfo,
} from "@verex/sdk";

// 팩토리
const factory = createFactoryClient({
  address: "0x...",
  publicClient,
  walletClient, // optional, write 함수에 필요
});
await factory.getMarkets();           // → Address[]
await factory.getMarketCount();        // → bigint
await factory.createMarket("Q", end);  // → Address (newly deployed)

// 마켓
const market = createMarketClient({ address, publicClient, walletClient });
await market.getInfo();                // → MarketInfo
await market.getPosition(userAddr);    // → PositionInfo
await market.buyYes(parseEther("1"));  // → tx hash
await market.buyNo(parseEther("0.5"));
await market.resolve(true);
await market.claim();

의도: factory와 market을 별도 클라이언트로 분리 — 호출자가 어느 컨트랙트 단위로 작업하는지 명확.

5.3 v1 → v2 호환 의도

SDK 인터페이스를 다음 원칙으로 설계해 v2 (CTF Exchange) 전환 시 변경 표면 최소화:


6. CLI 패키지 (신규: packages/cli/)

packages/cli/
├── package.json              # bin: { "verex": "dist/index.js" }, dep: @verex/sdk + commander
├── tsconfig.json             # ES2022, DOM lib, skipLibCheck
└── src/
    ├── clients.ts            # anvil 10개 default account의 publicClient/walletClient 헬퍼
    ├── index.ts              # commander 기반 CLI 정의 (verex create/list/info/buy/resolve/claim/position)
    └── demo.ts               # end-to-end 자동 시연 (forge script로 deploy → 모든 단계 → balance 검증)

6.1 CLI 명령

Run with pnpm verex <command> from the repo root (the root package.json has a verex script). For other ways to invoke (alias, global link), see §7.2.

명령 인자 역할
verex create -f <factory> -q <Q> -e <unix-sec> [-a 0] factory + question + endTime 새 마켓 배포
verex list -f <factory> factory 모든 마켓 + open/resolved 상태 + pool 크기
verex info -m <market> market full info JSON (bigint → string)
verex buy -m <market> -s yes\|no -v <eth> [-a 1] market + side + ETH 베팅
verex resolve -m <market> -o yes\|no [-a 0] market + outcome 정산 (owner only)
verex claim -m <market> [-a 1] market 청구 (loser면 no-op)
verex position -m <market> [-a 1] market yesShares / noShares 잔액

-a 는 anvil default 10 accounts 인덱스 (0=deployer/owner, 1=alice, 2=bob, …).

6.2 demo.ts — one-shot 시연

[1] deploying factory via forge script...
    factory=0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0
[2] creating market...
    market=0x75537828f2ce51be7289709686A69CbFDbB714F1
[3] alice (account 1) bets 2 ETH on YES...
    bob (account 2) bets 3 ETH on NO...
    pools: yes=2 no=3
[4] advancing anvil time past endTime...
[5] owner (account 0) resolves YES...
    resolved=true outcome=YES
[6] alice claims (winner)... bob claims (loser)...
[7] balance changes (incl. gas):
    alice: +4.99...  ETH (expect ~+5)
    bob:   -0.000033 ETH (expect ~0)
✓ end-to-end demo complete

evm_increaseTime + evm_mine JSON-RPC로 anvil 시간을 endTime 너머로 이동시킨 후 resolve.


7. 어떻게 테스트하나 (재현 절차)

7.1 Foundry 단위 테스트만

# 도구 (한 번만)
curl -L https://foundry.paradigm.xyz | bash && foundryup
export PATH="$HOME/.foundry/bin:$PATH"
cd packages/contracts && forge install foundry-rs/forge-std

# 매번
cd packages/contracts && forge test
# expect: 17 tests passed

7.2 End-to-end demo (anvil + SDK + CLI)

# 의존성 설치 (한 번만)
corepack enable pnpm
pnpm install

# 컨트랙트 빌드 (forge out/*.json 생성 — SDK가 ABI sync에 사용)
cd packages/contracts && forge build

# SDK + CLI 빌드
cd ../.. && pnpm --filter @verex/sdk build
pnpm --filter @verex/cli build

# anvil 띄우고 데모
anvil &
pnpm --filter @verex/cli demo

# Or run individual steps. Use `pnpm verex <subcommand>` from the repo root —
# the root package.json has a `verex` script that delegates to packages/cli/dist/index.js.
pnpm verex create -f 0xFACTORY -q "Will X happen?" -e 1800000000 -a 0
pnpm verex buy     -m 0xMARKET -s yes -v 1 -a 1
pnpm verex resolve -m 0xMARKET -o yes -a 0
pnpm verex claim   -m 0xMARKET -a 1

# Cleanup
pkill anvil

Why pnpm verex and not just verex? The CLI’s bin: { verex: ... } field is declared, but because @verex/cli is a workspace sibling (not a root dependency), pnpm doesn’t create a shim in node_modules/.bin/verex. Running plain verex from a shell gives command not found. The root verex script makes pnpm verex <args> work from any path under the repo.

If you want a shorter command from anywhere on disk, add an alias to your shell rc: alias verex='node /Users/jay/work/verex/packages/cli/dist/index.js'

7.3 환경 변수

변수 기본값 용도
VEREX_RPC_URL http://127.0.0.1:8545 CLI가 사용할 anvil RPC
PRIVATE_KEY anvil account[0] forge script Deploy 의 broadcaster
FACTORY (미설정) demo.ts 가 deploy 단계 skip하고 재사용할 factory 주소

8. 알려진 한계 (의도된 v1 단순화)


9. 디자인 결정 메모

9.1 왜 factory.owner가 모든 market의 owner?

대안: 각 market의 owner = creator (createMarket 호출자).

선택한 이유:

단점: 운영자 키가 단일 실패점. v2 (Phase 2 W6) 에 oracle로 분산.

9.2 왜 claim이 revert 안 하고 0 반환?

대안: require(userShares > 0, "no winnings").

선택한 이유:

단점: 명백한 호출 실수가 silent하게 통과 — 하지만 0 반환은 멱등성 측면에서 더 안전.

9.3 왜 별도 SDK 클라이언트 (factory + market)를 분리?

대안: 단일 VerexClient 가 factory address 하나로 모든 일.

선택한 이유:

9.4 왜 CLI 패키지를 새로 만들었나?

대안: SDK 안에 CLI command 포함.

선택한 이유:


10. 참고 (plan 정합성 체크)

Plan 명세 실제 구현 상태
Market.sol: buyYes() payable, buyNo() payable, resolve(bool) onlyOwner, claim() pro-rata 동일
MarketFactory.sol: createMarket(question, endTime) → Market 배포, markets() 조회 createMarket, getMarkets(), marketCount(), markets(uint256) (배열 직접 access)
이벤트: MarketCreated, Bought, Resolved, Claimed 동일 (시그니처 §3.3)
Foundry 5개 테스트 시나리오 5개 + sanity 6 + factory 6
script/Deploy.s.sol: anvil에 factory 배포 동일, PRIVATE_KEY env 옵션
SDK: createFactoryClient, getMarkets, getMarket, buyYes/buyNo/resolve/claim 동일 (factory + market 분리)
ABI는 forge build 산출물에서 import (수동 복사 금지) sync-abis.mjsas const TS 모듈
M1 (Day 3): forge test 통과 17/17 pass
M2 (Day 7): SDK CLI로 anvil end-to-end demo 완주