# VAP 연동 — 문서 색인과 확정 상태

VAP(출금솔루션·VA)와 주고받은 문서가 여기 모인다. **먼저 이 파일을 읽고**, 필요한 것만 펴 보면 된다.

> ☠️ **코드에서 규격을 역추정하지 말 것.** 2026-09-19 에 336 커밋 뒤처진 트리에서 파트너 서버
> API 용 HMAC 만 보고 「문서와 구현이 다르다」고 판단해, 이미 합의된 계약과 어긋나는 정정안을
> VAP 에 보냈다. 규격은 **이 디렉터리가 정본**이다.

---

## 1. 지금 확정된 것 (2026-09-20)

### 입금 (온램프)

| | |
|---|---|
| 계약 정본 | `PARTNER_WEBHOOK_CONTRACT_v1.4.md` |
| 구조 | VA 는 **P2P 매칭 엔진의 leg** 다. `VapLegBinder` 가 세션을 `p2p_matches` 에 묶고 원장은 `LedgerReferenceType.P2P_SETTLEMENT` 로 들어간다 |
| 웹훅 서명 | `X-VAP-Signature: v1=hex(HMAC-SHA256(secret, timestamp + "." + eventId + "." + rawBody))` · `X-VAP-Timestamp`(±300초) · `X-VAP-Key-Id` |
| 시크릿 | **VAP 이 발급**한다. 64자 hex 를 **문자열 그대로** 키로 쓴다(디코딩하지 않는다). `vap.webhook.secrets`(keyId → secret) 맵 |
| 이벤트 | `DEPOSIT.SETTLED` · `SESSION.CLOSED` 2종 |

### 출금 (오프램프) — 우리가 구현 중

| | |
|---|---|
| API 규격 | `WITHDRAWAL_API_v0.4` (VAP 작성, 이 디렉터리 밖) + 아래 정정들 |
| 웹훅 | `PARTNER_WEBHOOK_WITHDRAWAL_v0.2.md` — **VAP 이 보낼 출금 이벤트는 `WITHDRAWAL.MANUAL_REVIEW` 1종뿐** |
| **C-1~C-5 인증** | **`Authorization: Bearer {vap.api-key}`** — 입금 연동 키를 **양방향 공용**. 서명 스킴을 두지 않는다 |
| 교체 창 | 인바운드만 `vap.api-key-previous` 를 병행 수용. VAP 절차 ①발급 →②우리 전환 →③VAP 요청키 교체 →④옛 키 폐기, ④ 뒤 previous 를 비운다 |
| `memberRef` | **받지 않는다.** PG 쇼핑몰 ID 라서 존재하는 값이고 출금에는 PG 가 없다 |
| 수취 주소 | **B-1 응답으로 받아 주문에 각인.** 전송은 각인값으로만 |
| 체인 | A-1 이 단일 출처. 지정 체인 가용액만 보고 **자동 폴백 없음** |

### ☠️ 되풀이하지 말 것

- **`409 ALREADY_APPROVED` 는 종결이 아니다.** `ALREADY_CLOSED` 와 갈라야 한다 — 묶어서
  「409 면 닫는다」로 구현하면 VAP 은 닫히고 우리는 열린 분기가 생기고, `APPROVED` 에는 TTL 이
  없어 파트너 자금이 무기한 묶인다
- **`WITHDRAWAL.MANUAL_REVIEW` 에 자동 보정을 붙이지 않는다.** 종결된 주문을 되살리는 전이를
  만들어야 하고, 이 이벤트 자체가 불변식이 깨진 신호라 자동 보정은 원인을 덮는다.
  전용 알림 + 예외 큐 + **최고운영자 수동**이다
- **출금 인증에 서명 스킴을 다시 만들지 않는다.** C-1~C-4 가 전부 상태 기반 멱등이라 재생이
  무해하고, 재조준에도 키가 필요하다. (이 판단을 세 번 뒤집었다)

---

## 2. 문서 목록

### VAP 이 보낸 것

| 파일 | 내용 |
|---|---|
| `PARTNER_WEBHOOK_CONTRACT_v1.4.md` | 입금 웹훅 계약 **정본** (2026-09-18 확정, 고정 검증 벡터 포함) |
| `PARTNER_WEBHOOK_WITHDRAWAL_v0.2.md` | 출금 웹훅 계약. `ALREADY_APPROVED` 세 갈래 반영본 (v0.1 은 폐기·삭제) |
| `REPLY_2026-09-20_auth.md` | 우리 v0.6 에 대한 회신 — Bearer 수용, 키 교체 절차 |
| `REPLY_2026-09-20_v0.7.md` | 우리 v0.7 에 대한 회신 — `ALREADY_APPROVED` 정정 수용 |
| `REPLY_2026-09-20_settlement-agreement.md` | 첫 건 보고 + **C-5 부재** 지적. 그쪽은 USDT 를 받았고 **주문에 잇지 못한 것**이었다 |
| `REPLY_2026-09-21_c5-schema-questions.md` | **C-5 연동 완료** — 첫 건이 사람 손 없이 닫힘. 스키마 확인 4건 |

### 우리가 보낸 것

| 파일 | 내용 |
|---|---|
| `KRW_WITHDRAWAL_AUTH_v0.6.md` | 인증 확정 통지 — **v0.5 폐기**(우리 오기) + Bearer |
| `KRW_WITHDRAWAL_REPLY_v0.7.md` | v0.6 회신에 대한 답 — `ALREADY_APPROVED` 정정 요청 |
| `KRW_WITHDRAWAL_REPLY_C5.md` | **C-5 정산 장부 회신** — 스키마·예시·묶음 전송 안 함·B-1 에 예고액 |
| `KRW_WITHDRAWAL_REPLY_C5_SCHEMA.md` | **스키마 확인 4건 회신** — status 4값·FAILED 는 retryCount 로 갈림·BSC 18자리·krwAmount 문자열 |

### 우리 내부용

| 파일 | 내용 |
|---|---|
| `KRW_WITHDRAWAL_REDESIGN_ON_V14.md` | 기존 VA 연동 위에서 출금을 재설계한 내역. **무엇이 이미 있는지**의 인벤토리 |
| `KRW_WITHDRAWAL_HANDOFF.md` | **☠️ 이어받으면 이것부터** — 현재 상태·남은 일·함정 |
| `KRW_WITHDRAWAL_FLOW_RUN.md` | **한 건을 실제로 흘려보내기** — 준비물 5가지·행위자별 단계·막히는 지점 |
| `KRW_WITHDRAWAL_E2E_TEST.md` | 배포 후 E2E 확인 항목 — 연결 확인부터 실패 경로까지 |
| `KRW_WITHDRAWAL_FIRST_RUN_INQUIRY.md` | **첫 건 USDT 수령 확인 요청** — 온체인 근거 + 절삭 기준 협의 |

### 이 디렉터리 밖

| | |
|---|---|
| 도면 정본 | `WITHDRAWAL_FLOW_V59.html` — docshare 서빙. 생성기는 `gen_v59.py` → `build_v59.py` |
| 스키마 | `v2-docs/KRW_OFFRAMP_DDL.sql` |
| VAP API 규격 | `WITHDRAWAL_API_v0.4` — VAP 이 보유 |

---

## 3. 진행 상태 (2026-09-20)

### 첫 건이 끝까지 갔다

**`kwo_1afbf15d6747` — 10,000원.** 18:09:52 접수 → 18:11:02 승인 → 18:12:56 완료 처리 →
18:13:24 온체인 확정. 접수부터 종결까지 3분 36초, 정산 재시도 0회. 실제 원화가 은행으로
나갔고 USDT 7.304601 이 TRON 에서 정산됐다.

**불변식 5개가 실데이터로 확인됐다** — 재차감 없음 · 환급 없음(정상 종결) · APPROVED 는
C-3 로만 탈출 · **전송액 = principal(debit 아님)** · 정산 실패 경로 미발생. 네 번째는
테스트 파트너 요율을 1% 로 먼저 넣었기에 구분이 드러났다(0이면 `debit = principal` 이라
버그가 있어도 통과한다).

회수(A-2) 경로도 `kwo_20f4cba73e1d` 로 확인했다 — TTL 소거·전액 환급(CREDIT 2건)·
Webhook 미발송·가용액 원복.

### ☠️ 「USDT 를 못 받았다」가 아니었다

처음엔 미수령으로 이해했으나 **그쪽은 받았다**(7.304601 USDT, 블록 86407783).
**어느 주문의 정산인지 확정할 수 없었던 것**이다 — 수취 지갑이 **체인별 공용 하나**라
주소로도, 발신 주소(파트너 MASTER)로도, 금액으로도 주문을 지목할 수 없다.

원인은 우리 쪽이다. **합의된 `C-5` 를 만들지 않았다**(P5 로 미뤄둔 항목).
그쪽은 담당자가 화면에서 수동으로 이어 왔고, 정황 자동연결은 스스로 폐기했다 —
되돌릴 수 없는 종결을 추론으로 보낼 수 없어서다. 옳은 판단이다.

**C-5 를 구현·배포했다**(2026-09-20 19:58 KST). 회신: `KRW_WITHDRAWAL_REPLY_C5.md`.
배포본을 직접 호출해 검증했다 — 정상 키 200(첫 건 그대로) · 틀린 키 401 · 헤더 없음 401 ·
`txHash` 필터 적중 · 기간/형식 오류 400(`1129`).
겸해서 **B-1 요청에 `settlementUsdt`(실제 도착 예정액)를 싣는다** — 그쪽이 「받을 금액을
끝까지 모른다」고 한 문제를, 돈이 움직이기 전 가장 이른 시점에 푼다.

**두 번째 건은 C-5 배포·대조 뒤** 진행한다.

### 운영 배포 완료

| 시각(KST) | 내용 |
|---|---|
| 2026-09-20 | DDL 운영 적용 (엔티티↔DB 양방향 0건) |
| 17:45 | 최소 한도 200,000 → **10,000원** (MR !121) |
| 18:08 | `withdrawals.request_source` 누락 수정 (MR !122) — A-1 이 500 으로 터지던 원인 |
| 18:41 | 첫 건이 드러낸 3건 수정 (MR !123) |
| 19:58 | **C-5 정산 장부 + B-1 도착 예정액** (MR !124) |

인증 3방향 확인 완료 — 정상 키 404 / 틀린 키 401 / 헤더 없음 401.

### 첫 건이 드러낸 것 (전부 수정·배포됨)

- **`request_source` 누락** — 운영 `withdrawals` 는 NOT NULL·기본값 없음. 엔티티엔 필드가
  있는데 KRW 빌더만 안 채웠다. INSERT 시점에만 드러나 A-1 이 500 이었다. 자금은 안전했다
  (withdrawals 저장이 원장 쓰기보다 앞서 차감 전에 롤백)
- **`ttl_expires_at` 미소거** — `IXRepository.modify()` 가 null 필드를 건너뛴다
  (`selectiveUpdate`). `setTtlExpiresAt(null)` 이 무효였고 같은 호출의 `status`·`approved_at`
  은 반영돼 겉보기엔 정상이었다. `findTtlExpired` 의 `status='WAITING'` 필터가 막아줘서
  사고는 없었다 — **방어선 둘 중 하나가 작동한 적이 없었다**
- **파트너 콘솔에 TX 가 안 보임** — 콘솔은 `withdrawals` 만 읽는데 원화 출금은 온체인 정보를
  `krw_settlements` 에 둔다. 정산 확정 시 되쓰도록 고쳤고, 지난 1건은 백필했다
- **D-1 payload** — `BigDecimal` 을 JSON 숫자로 내보내 18자리가 16자리로 잘렸다.
  문자열로 전환. `"chain": 3`(내부 id) → `"chainType": "TRON"`

### 남은 것

- ~~A-4 기간 조회~~ ✅ · ~~C-5 정산 장부~~ ✅ (P5 완료)
- 소수점 6자리 절삭 대사 기준 합의 (조회 문서 §6)
- `p2p_transfer_requests` 가 `BROADCASTED` 로 잔류 — **KRW 고유가 아니다.** 73건 전부
  그렇고 8월부터다. 그 테이블로 미결을 세는 쪽이 오탐한다
- `withdrawals.confirmed_at < created_at` 88ms 역전 — **의도적으로 두었다.** 고치려 시각을
  옮기면 `DATE(COALESCE(confirmed_at, created_at))` 로 버킷팅하는 일별 정산이 어긋난다
