# Axim External Wallet Address 수정 지침서

> **작성일**: 2026-03-31
> **관련 이슈**: `external_wallets.wallet_addresses` 에 `'{}'` 만 저장되는 버그
> **수정 대상**: `open-api` 모듈 (AximWebhookController), `core` 모듈 (AximService), `common` 모듈 (DTO)

---

## 1. 문제 분석

### 현상

`connection.succeeded` webhook 수신 시 `external_wallets.wallet_addresses` 컬럼에 `'{}'` (빈 JSON)만 저장된다.

### 근본 원인

v2 `AximWebhookController.handleConnectionSucceeded()` 가 **webhook payload 의 `wallets` 필드에서만** 지갑 주소를 추출하려 하지만,
Axim의 `connection.succeeded` webhook은 **`wallets` 데이터를 포함하지 않는다**.

### v1 (coin-payments) 동작 방식 비교

| 단계 | v1 (정상) | v2 (버그) |
|------|-----------|-----------|
| 1. `connection.succeeded` 수신 | DB에 연결 정보 저장 | DB에 연결 정보 저장 (`walletAddresses = '{}'`) |
| 2. **Axim API 호출** | `aximPayClient.getConnection(connectId)` 로 지갑 주소 조회 | **누락** -- webhook payload만 의존 |
| 3. 지갑 주소 저장 | `blockchainApiClient.registerAximWallet()` 로 체인별 등록 | `walletAddresses` JSON에 직접 저장 |
| 4. `payment.succeeded` 수신 | `fromAddress` 로 체인별 주소 보강 저장 | 상태만 CONFIRMED 처리, **주소 미저장** |

**핵심 누락**: v1의 `registAximWalletAddress()` 에 해당하는 "Axim API를 통한 지갑 주소 조회" 로직이 v2에 없다.

---

## 2. 수정 항목 (3건)

### 2-1. AximConnectionInfoResponse DTO 확장 (common 모듈)

**파일**: `common/src/main/java/com/cryptoments/common/client/dto/axim/AximConnectionInfoResponse.java`

**현재 상태**: `walletAddresses` 가 `String` 타입 -- Axim API 응답의 `wallets` (List) 를 역직렬화 못함

**수정 내용**:

```java
package com.cryptoments.common.client.dto.axim;

import lombok.*;
import java.util.List;

/**
 * Axim Pay 연결 정보 조회 응답 DTO.
 * <p>GET /api/v1/open/connections/{connectId} 응답</p>
 */
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AximConnectionInfoResponse {

    /** 연결 ID */
    private String connectId;

    /** 연결 지갑 토큰 */
    private String connectWalletToken;

    /** 연결 상태 (ACTIVE, INACTIVE) */
    private String status;

    /** 지갑 정보 목록 (체인별) — Axim API 응답 구조 */
    private List<WalletInfo> wallets;

    /**
     * 체인별 지갑 정보.
     */
    @Getter @Setter @Builder
    @NoArgsConstructor @AllArgsConstructor
    public static class WalletInfo {
        /** 체인 타입 (BSC, POLYGON, TRON 등) */
        private String chainType;
        /** 지갑 주소 */
        private String address;
    }
}
```

**변경 포인트**:
- `walletAddresses` (String) 제거
- `wallets` (List<WalletInfo>) 추가 — Axim API 실제 응답과 일치
- inner class `WalletInfo` 추가 (v1의 `AximConnectionInfoResponse.WalletInfo` 와 동일 구조)

---

### 2-2. handleConnectionSucceeded() 에 Axim API 호출 추가 (open-api 모듈)

**파일**: `open-api/src/main/java/com/cryptoments/webhook/controller/AximWebhookController.java`

**현재 로직** (100~125행):
```java
private void handleConnectionSucceeded(Long partnerId, AximWebhookEvent event) {
    ConnectionEventData data = ...;
    // webhook payload의 wallets 에서 주소 추출 → 항상 빈 JSON
    String walletAddressesJson = "{}";
    if (data.getWallets() != null && !data.getWallets().isEmpty()) { ... }
    aximService.connectWallet(..., walletAddressesJson, ...);
}
```

**수정 로직**:

```java
private void handleConnectionSucceeded(Long partnerId, AximWebhookEvent event) {
    ConnectionEventData data = objectMapper.convertValue(event.getData(), ConnectionEventData.class);
    if (data == null || data.getConnectionId() == null) return;
    String partnerUserId = extractPartnerUserId(data.getConnectionId(), partnerId);

    // ── Step 1: DB에 연결 정보 먼저 저장 (walletAddresses = '{}') ──
    ExternalWallet wallet = aximService.connectWallet(
            partnerId, partnerUserId,
            data.getConnectionId(), "{}",
            data.getSiteId(), data.getConnectWalletToken());

    log.info("Axim 연결 저장 완료: partnerId={}, connectionId={}, externalWalletId={}",
            partnerId, data.getConnectionId(), wallet.getId());

    // ── Step 2: Axim API로 지갑 주소 조회 후 업데이트 ──
    try {
        String walletAddressesJson = fetchAndBuildWalletAddresses(partnerId, data.getConnectionId());
        if (walletAddressesJson != null) {
            aximService.updateWalletAddresses(wallet.getId(), walletAddressesJson);
            log.info("Axim 지갑 주소 업데이트 완료: externalWalletId={}, wallets={}",
                    wallet.getId(), walletAddressesJson);
        }
    } catch (Exception e) {
        log.warn("Axim 지갑 주소 조회 실패 (연결은 유지): partnerId={}, connectionId={}, error={}",
                partnerId, data.getConnectionId(), e.getMessage());
        // 주소 조회 실패해도 연결 자체는 유효 — payment.succeeded 에서 보강 가능
    }
}

/**
 * Axim API를 호출하여 연결된 지갑 주소를 JSON으로 변환한다.
 *
 * <p>v1의 {@code registAximWalletAddress()} 에 대응하는 로직.
 * Axim API {@code GET /api/v1/open/connections/{connectId}} 호출 후
 * 응답의 wallets 목록을 {@code {"BSC": "0x...", "TRON": "T..."}} JSON으로 변환한다.</p>
 *
 * @return wallet JSON string, or null if no wallets available
 */
private String fetchAndBuildWalletAddresses(Long partnerId, String connectionId) {
    PartnerAximSettings settings = aximSettingsRepository.findByPartnerId(partnerId);
    if (settings == null || settings.getApiKey() == null || settings.getApiSecretEnc() == null) {
        log.warn("Axim 설정 부족으로 지갑 주소 조회 불가: partnerId={}", partnerId);
        return null;
    }

    AximConnectionInfoResponse connInfo =
            aximPayClient.getConnectionInfo(settings.getApiKey(), settings.getApiSecretEnc(), connectionId);

    if (connInfo == null || !"ACTIVE".equals(connInfo.getStatus())) {
        log.warn("Axim 연결 상태 비활성: partnerId={}, connectionId={}, status={}",
                partnerId, connectionId, connInfo != null ? connInfo.getStatus() : "null");
        return null;
    }

    if (connInfo.getWallets() == null || connInfo.getWallets().isEmpty()) {
        log.info("Axim 연결에 지갑 없음: partnerId={}, connectionId={}", partnerId, connectionId);
        return null;
    }

    java.util.Map<String, String> addrMap = new java.util.LinkedHashMap<>();
    for (AximConnectionInfoResponse.WalletInfo w : connInfo.getWallets()) {
        if (w.getChainType() != null && w.getAddress() != null) {
            addrMap.put(w.getChainType(), w.getAddress());
        }
    }

    try {
        return objectMapper.writeValueAsString(addrMap);
    } catch (Exception e) {
        log.error("지갑 주소 JSON 변환 실패: {}", e.getMessage());
        return null;
    }
}
```

**필요한 import 추가**:
```java
import com.cryptoments.common.client.AximPayClient;
import com.cryptoments.common.client.dto.axim.AximConnectionInfoResponse;
import com.cryptoments.common.entity.ExternalWallet;
```

**생성자 DI 추가**:
```java
private final AximPayClient aximPayClient;  // 추가

public AximWebhookController(ObjectMapper objectMapper,
                              PartnerAximSettingsRepository aximSettingsRepository,
                              AximService aximService,
                              AximPayClient aximPayClient) {    // 추가
    this.objectMapper = objectMapper;
    this.aximSettingsRepository = aximSettingsRepository;
    this.aximService = aximService;
    this.aximPayClient = aximPayClient;    // 추가
}
```

**설계 결정**:
- **Step 1 → Step 2 분리**: 연결 저장은 무조건 성공시키고, 주소 조회는 try-catch로 감싼다
- **주소 조회 실패 시 연결 유지**: `payment.succeeded` 에서 `fromAddress` 로 보강 가능
- `ConnectionEventData.wallets` 기반 코드는 **제거** — webhook에 wallets가 오지 않으므로 dead code

---

### 2-3. AximService에 지갑 주소 업데이트 메서드 추가 (core 모듈)

**파일**: `core/src/main/java/com/cryptoments/core/axim/AximService.java`

**추가할 메서드 2개**:

#### (A) `updateWalletAddresses()` — 연결 시점 주소 일괄 업데이트

```java
/**
 * 외부 지갑의 주소 정보를 업데이트한다.
 * connection.succeeded 이후 Axim API 조회 결과로 호출된다.
 *
 * @param externalWalletId external_wallets.id
 * @param walletAddressesJson JSON string (e.g., {"BSC":"0x...","TRON":"T..."})
 */
public void updateWalletAddresses(Long externalWalletId, String walletAddressesJson) {
    ExternalWallet wallet = externalWalletRepository.findOne(externalWalletId);
    if (wallet == null) {
        throw new NotFoundException(ErrorCodes.WALLET_NOT_FOUND);
    }

    ExternalWallet updated = wallet.toBuilder()
            .walletAddresses(walletAddressesJson)
            .build();
    externalWalletRepository.modify(updated);

    log.info("외부 지갑 주소 업데이트: id={}, addresses={}", externalWalletId, walletAddressesJson);
}
```

#### (B) `enrichWalletAddress()` — 결제 성공 시 체인별 주소 보강

```java
/**
 * 결제 성공 시 실제 발신 주소로 지갑 주소를 보강한다.
 * payment.succeeded webhook에서 fromAddress + network를 전달받아,
 * 기존 walletAddresses JSON에 해당 체인 주소가 없으면 추가한다.
 *
 * @param connectionId Axim 연결 ID
 * @param fromAddress 실제 발신 지갑 주소
 * @param chainType 체인 타입 (BSC, POLYGON, TRON 등)
 */
public void enrichWalletAddress(String connectionId, String fromAddress, String chainType) {
    if (connectionId == null || fromAddress == null || chainType == null) return;

    ExternalWallet wallet = externalWalletRepository.findByConnectionId(connectionId);
    if (wallet == null) {
        log.warn("enrichWalletAddress: 외부 지갑 없음 connectionId={}", connectionId);
        return;
    }

    try {
        // 기존 JSON 파싱
        com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
        @SuppressWarnings("unchecked")
        java.util.Map<String, String> addrMap = mapper.readValue(
                wallet.getWalletAddresses() != null ? wallet.getWalletAddresses() : "{}",
                java.util.Map.class);

        // 해당 체인 주소가 없을 때만 추가 (첫 결제 주소 우선)
        if (!addrMap.containsKey(chainType)) {
            addrMap.put(chainType, fromAddress);
            String updatedJson = mapper.writeValueAsString(addrMap);

            ExternalWallet updated = wallet.toBuilder()
                    .walletAddresses(updatedJson)
                    .build();
            externalWalletRepository.modify(updated);

            log.info("지갑 주소 보강: connectionId={}, chain={}, address={}", connectionId, chainType, fromAddress);
        }
    } catch (Exception e) {
        log.error("지갑 주소 보강 실패: connectionId={}, error={}", connectionId, e.getMessage());
    }
}
```

**필요한 import**: 없음 (ObjectMapper는 메서드 내 직접 생성. 또는 생성자 DI 권장)

> **권장**: `AximService` 생성자에 `ObjectMapper` DI 추가하여 `enrichWalletAddress()` 에서 재사용

---

### 2-4. handlePaymentSucceeded() 에 주소 보강 추가 (open-api 모듈)

**파일**: `open-api/src/main/java/com/cryptoments/webhook/controller/AximWebhookController.java`

**현재 로직** (127~132행):
```java
private void handlePaymentSucceeded(Long partnerId, AximWebhookEvent event) {
    PaymentEventData data = objectMapper.convertValue(event.getData(), PaymentEventData.class);
    if (data == null || data.getPaymentId() == null) return;
    aximService.handleCallback(data.getPaymentId(), AximPaymentStatus.CONFIRMED, null);
    log.info("Axim 결제 성공: partnerId={}, paymentId={}", partnerId, data.getPaymentId());
}
```

**수정 로직**:
```java
private void handlePaymentSucceeded(Long partnerId, AximWebhookEvent event) {
    PaymentEventData data = objectMapper.convertValue(event.getData(), PaymentEventData.class);
    if (data == null || data.getPaymentId() == null) return;

    // 1) 결제 상태 업데이트
    aximService.handleCallback(data.getPaymentId(), AximPaymentStatus.CONFIRMED, null);

    // 2) 지갑 주소 보강 — fromAddress + network 로 체인별 주소 저장
    if (data.getConnectionId() != null && data.getFromAddress() != null && data.getNetwork() != null) {
        try {
            aximService.enrichWalletAddress(
                    data.getConnectionId(), data.getFromAddress(), data.getNetwork());
        } catch (Exception e) {
            log.warn("결제 성공 시 지갑 주소 보강 실패: paymentId={}, error={}",
                    data.getPaymentId(), e.getMessage());
        }
    }

    log.info("Axim 결제 성공: partnerId={}, paymentId={}, fromAddress={}, network={}",
            partnerId, data.getPaymentId(), data.getFromAddress(), data.getNetwork());
}
```

---

## 3. wallet_addresses JSON 키 형식 통일 (Critical)

### 불일치 현황

| 위치 | 키 형식 | 예시 |
|------|---------|------|
| **DDL 주석** | `networks.id` (숫자) | `{"1": "0x...", "3": "T..."}` |
| **ExternalWalletMapper SQL** | `networkId` (숫자) | `JSON_EXTRACT(wallet_addresses, '$."1"')` |
| **AximWebhookController (현재)** | `chainType` (문자열) | `{"BSC": "0x...", "TRON": "T..."}` |
| **Axim API 응답** | `chainType` (문자열) | `WalletInfo.chainType = "BSC"` |
| **enrichWalletAddress() (현재)** | `chainType` (문자열) | Axim `network` 필드 그대로 사용 |

### 문제점

`ExternalWalletMapper.findByNetworkIdAndAddress()` 가 `networkId` (숫자)를 키로 조회한다:

```sql
JSON_UNQUOTE(JSON_EXTRACT(wallet_addresses, CONCAT('$."', #{networkId}, '"'))) = #{address}
-- 예: JSON_EXTRACT(wallet_addresses, '$."2"') — BSC의 networkId가 2인 경우
```

그런데 Axim API 응답으로 저장하면 키가 `"BSC"`, `"TRON"` 등 문자열이 된다:
```json
{"BSC": "0xABC...", "TRON": "TXYZ..."}
```

**결과**: `WebhookProcessingService.processDeposit()` 에서 EXTERNAL_WALLET 입금 시
`externalWalletMapper.findByNetworkIdAndAddress(networkId, fromAddress)` 호출 → **매칭 실패** →
EXTERNAL_WALLET 입금이 항상 DIRECT(파트너 충전)로 잘못 분류된다.

### 결정: **DDL 기준 `networkId` (숫자) 를 키로 사용**

**이유**:
1. DDL이 Single Source of Truth — `COMMENT '네트워크별 주소 — {"1": "0x...", "3": "T..."}  (key = networks.id)'`
2. `ExternalWalletMapper` SQL이 이미 `networkId` 기반으로 작성되어 있음
3. `WebhookProcessingService.processDeposit()` 에서 `networkId` 로 조회하므로, 모니터 webhook 전체 파이프라인과 일관성 유지
4. `chainType` ("BSC") 은 Axim 외부 시스템 값이라 매핑 테이블 없이는 네트워크 식별이 불안정

### 수정 3-1: chainType → networkId 변환 유틸 추가

Axim API 응답의 `chainType` ("BSC", "POLYGON", "TRON")을 `blockchain_networks.id` 로 변환해야 한다.

**방법 A — BlockchainNetworkRepository 조회** (권장):

```java
// BlockchainNetworkRepository에 추가 (이미 존재할 수 있음)
BlockchainNetwork findByChainSymbol(String chainSymbol);
```

**변환 헬퍼** (AximWebhookController 또는 별도 유틸):

```java
/**
 * Axim chainType을 blockchain_networks.id로 변환한다.
 * Axim: "BSC", "POLYGON", "TRON" 등 → blockchain_networks.chain_symbol 매핑.
 */
private Long resolveNetworkId(String aximChainType) {
    if (aximChainType == null) return null;
    BlockchainNetwork network = blockchainNetworkRepository.findByChainSymbol(aximChainType);
    return network != null ? network.getId() : null;
}
```

### 수정 3-2: fetchAndBuildWalletAddresses() 키 변환

**현재** (chainType 키):
```java
addrMap.put(w.getChainType(), w.getAddress());
// 결과: {"BSC": "0x...", "TRON": "T..."}
```

**수정** (networkId 키):
```java
for (AximConnectionInfoResponse.WalletInfo w : connInfo.getWallets()) {
    if (w.getChainType() != null && w.getAddress() != null) {
        Long netId = resolveNetworkId(w.getChainType());
        if (netId != null) {
            addrMap.put(String.valueOf(netId), w.getAddress());
        } else {
            log.warn("Axim chainType 매핑 실패: chainType={}", w.getChainType());
        }
    }
}
// 결과: {"2": "0x...", "4": "T..."} — DDL 설계와 일치
```

### 수정 3-3: enrichWalletAddress() 키 변환

**현재**: Axim `payment.succeeded` webhook의 `network` 필드 ("BSC") 를 키로 그대로 사용

**수정**: `AximWebhookController.handlePaymentSucceeded()` 에서 변환 후 전달

```java
private void handlePaymentSucceeded(Long partnerId, AximWebhookEvent event) {
    PaymentEventData data = objectMapper.convertValue(event.getData(), PaymentEventData.class);
    if (data == null || data.getPaymentId() == null) return;

    // 1) 결제 상태 업데이트
    aximService.handleCallback(data.getPaymentId(), AximPaymentStatus.CONFIRMED, null);

    // 2) 지갑 주소 보강 — network를 networkId로 변환 후 저장
    if (data.getConnectionId() != null && data.getFromAddress() != null && data.getNetwork() != null) {
        try {
            Long networkId = resolveNetworkId(data.getNetwork());
            if (networkId != null) {
                aximService.enrichWalletAddress(
                        data.getConnectionId(), data.getFromAddress(), String.valueOf(networkId));
            }
        } catch (Exception e) {
            log.warn("결제 성공 시 지갑 주소 보강 실패: paymentId={}, error={}",
                    data.getPaymentId(), e.getMessage());
        }
    }

    log.info("Axim 결제 성공: partnerId={}, paymentId={}, fromAddress={}, network={}",
            partnerId, data.getPaymentId(), data.getFromAddress(), data.getNetwork());
}
```

### 수정 3-4: AximWebhookController에 BlockchainNetworkRepository DI 추가

```java
private final BlockchainNetworkRepository blockchainNetworkRepository;  // 추가

public AximWebhookController(ObjectMapper objectMapper,
                              PartnerAximSettingsRepository aximSettingsRepository,
                              AximService aximService,
                              AximPayClient aximPayClient,
                              BlockchainNetworkRepository blockchainNetworkRepository) {  // 추가
    // ...
    this.blockchainNetworkRepository = blockchainNetworkRepository;
}
```

### 수정 3-5: AximService.enrichWalletAddress() — 파라미터명 변경

키가 networkId 문자열("2")로 오므로 파라미터명을 명확히 한다:

```java
/**
 * 결제 성공 시 실제 발신 주소로 지갑 주소를 보강한다.
 *
 * @param connectionId Axim 연결 ID
 * @param fromAddress 실제 발신 지갑 주소
 * @param networkIdStr networks.id 문자열 (예: "2") — JSON 키로 사용
 */
public void enrichWalletAddress(String connectionId, String fromAddress, String networkIdStr) {
    // ... 기존 로직 동일, chainType → networkIdStr 으로 변수명만 변경
}
```

### 검증 포인트

수정 후 wallet_addresses JSON 이 다음 형식이어야 한다:

```json
{"2": "0xABC123...", "4": "TXYZ456..."}
```

`ExternalWalletMapper` SQL 검증:
```sql
-- networkId=2 (BSC), address=0xABC123 으로 조회 시 매칭 성공
SELECT * FROM external_wallets
WHERE status = 'CONNECTED'
  AND JSON_UNQUOTE(JSON_EXTRACT(wallet_addresses, '$."2"')) = '0xABC123...'
LIMIT 1;
```

---

## 4. 기존 데이터 복구 SQL

이미 `'{}'`로 저장된 레코드의 주소를 보강하려면, **수동으로 Axim API를 호출**하여 각 연결의 지갑 주소를 확인한 후 업데이트해야 한다.

```sql
-- 1) 현재 빈 주소 레코드 확인
SELECT id, partner_id, partner_user_id, connection_id, wallet_addresses, status, connected_at
FROM external_wallets
WHERE wallet_addresses = '{}' AND status = 'CONNECTED';

-- 2) Axim API 조회 후 수동 업데이트 (예시 — networkId 키 사용!)
-- BSC=2, TRON=4 인 경우:
UPDATE external_wallets
SET wallet_addresses = '{"2": "0xABC...", "4": "TXYZ..."}'
WHERE connection_id = '31343a61313233'
  AND partner_id = 7;
```

**자동 복구 방안** (선택사항):
- admin-api에 "지갑 주소 재조회" 엔드포인트를 추가
- `external_wallets` 중 `wallet_addresses = '{}'` 인 레코드를 순회하며 `aximPayClient.getConnectionInfo()` 호출
- chainType → networkId 변환 후 `wallet_addresses` 업데이트

---

## 5. 수정 파일 목록

| # | 모듈 | 파일 | 작업 |
|---|------|------|------|
| 1 | common | `client/dto/axim/AximConnectionInfoResponse.java` | DTO 확장 (`walletAddresses` String → `wallets` List<WalletInfo>) |
| 2 | common | `repository/BlockchainNetworkRepository.java` | `findByChainSymbol()` 추가 (없으면) |
| 3 | core | `axim/AximService.java` | `updateWalletAddresses()` + `enrichWalletAddress()` 메서드 추가 |
| 4 | open-api | `webhook/controller/AximWebhookController.java` | 생성자에 `AximPayClient` + `BlockchainNetworkRepository` DI 추가 |
| 5 | open-api | `webhook/controller/AximWebhookController.java` | `resolveNetworkId()` 변환 헬퍼 추가 |
| 6 | open-api | `webhook/controller/AximWebhookController.java` | `handleConnectionSucceeded()` 재작성 + `fetchAndBuildWalletAddresses()` — networkId 키 사용 |
| 7 | open-api | `webhook/controller/AximWebhookController.java` | `handlePaymentSucceeded()` — networkId 변환 후 주소 보강 |

---

## 6. 수정 순서 (IntelliJ)

```
1. common 모듈 → AximConnectionInfoResponse DTO 확장
2. common 모듈 → BlockchainNetworkRepository에 findByChainSymbol() 확인/추가
3. core 모듈  → AximService에 updateWalletAddresses() + enrichWalletAddress() 추가
4. open-api   → AximWebhookController 생성자에 AximPayClient + BlockchainNetworkRepository DI 추가
5. open-api   → resolveNetworkId() 헬퍼 추가
6. open-api   → handleConnectionSucceeded() 재작성 + fetchAndBuildWalletAddresses() (networkId 키)
7. open-api   → handlePaymentSucceeded() 에 주소 보강 추가 (networkId 변환)
8. 컴파일 확인: ./gradlew :open-api:compileJava
9. 기존 데이터 복구 SQL 실행 (networkId 키로!)
```

---

## 7. 주의사항

### apiSecretEnc 암호화

`PartnerAximSettings.apiSecretEnc` 은 AES 암호화 저장으로 설계되어 있다.
현재 `AximPayClient.getConnectionInfo()` 에 `apiSecretEnc` 를 직접 전달하고 있는데,
**실제 암호화가 적용된 상태라면** decrypt 후 전달해야 한다.

v1에서는 `aximSettings.getApiSecretKey()` 를 사용했는데 이것이 복호화된 값인지 확인 필요.

```java
// v1 코드:
aximPayClient.getConnection(aximSettings.getApiKey(), aximSettings.getApiSecretKey(), connectId);
// v2 코드: (현재)
aximPayClient.getConnectionInfo(settings.getApiKey(), settings.getApiSecretEnc(), connectionId);
// 만약 apiSecretEnc가 암호화 상태라면:
// String decrypted = aesUtil.decrypt(settings.getApiSecretEnc());
// aximPayClient.getConnectionInfo(settings.getApiKey(), decrypted, connectionId);
```

**확인 필요**: `apiSecretEnc` 에 실제 암호화된 값이 들어가는지, 평문이 들어가는지

### connection.succeeded webhook에 wallets가 올 수도 있는 경우

향후 Axim 측에서 webhook payload에 wallets를 추가할 가능성이 있으므로,
`ConnectionEventData.wallets` 필드와 파싱 로직은 **삭제하지 않고 유지**한다.
단, 이것에만 의존하지 않고 Axim API 조회를 메인 경로로 사용한다.

### 멱등성

`connectWallet()` 이 현재 INSERT only — 동일 connectionId로 중복 webhook 수신 시 duplicate key 오류 가능.
v1처럼 "기존 연결이 있으면 UPDATE, 없으면 INSERT" 패턴으로 변경을 권장한다:

```java
// AximService.connectWallet() 개선 (선택사항)
ExternalWallet existing = externalWalletRepository.findByConnectionId(connectionId);
if (existing != null) {
    ExternalWallet updated = existing.toBuilder()
            .connectWalletToken(connectWalletToken)
            .siteId(siteId)
            .status(ExternalWalletStatus.CONNECTED)
            .connectedAt(LocalDateTime.now())
            .revokedAt(null)
            .build();
    externalWalletRepository.modify(updated);
    return updated;
}
// 없으면 새로 생성
```

---

## 8. 전체 흐름 (수정 후)

```
Axim Pay ─── connection.succeeded webhook ──→ AximWebhookController
  │
  ├── Step 1: DB 저장 (wallet_addresses = '{}')
  │             └── aximService.connectWallet(...)
  │
  ├── Step 2: Axim API 호출로 지갑 주소 조회
  │             ├── aximPayClient.getConnectionInfo(apiKey, secret, connectId)
  │             ├── response.wallets → chainType을 networkId로 변환
  │             ├── {"2":"0x...", "4":"T..."} JSON 생성 (key = networks.id)
  │             └── aximService.updateWalletAddresses(walletId, json)
  │
  └── [실패 시] payment.succeeded 에서 보강
                  ├── fromAddress + network("BSC") → resolveNetworkId() → "2"
                  └── aximService.enrichWalletAddress(connectionId, address, "2")

모니터 Webhook (입금 감지) ──→ WebhookProcessingService.processDeposit()
  │
  ├── MASTER 지갑으로 입금 감지
  ├── externalWalletMapper.findByNetworkIdAndAddress(networkId, fromAddress)
  │     └── JSON_EXTRACT(wallet_addresses, '$."2"') = '0xABC...'  ← networkId 키와 일치
  └── 매칭 성공 → depositMethod = EXTERNAL_WALLET
```

---

## 9. 누락 Widget API 엔드포인트 추가 (2건)

v1 `WidgetApiController`에 존재하지만 v2 `open-api/controller/widget/`에 누락된 엔드포인트.

### 9-1. GET /widgets/api/widget-config — 위젯 설정 조회

v1에서 Widget UI가 초기 로딩 시 호출하여 금액 프리셋, 한도, UI 토글 등을 가져온다.

**v2 상태**: `WidgetConfigResponse` DTO는 이미 존재하지만 **엔드포인트 없음**.

**파일**: `open-api/.../dto/response/WidgetConfigResponse.java` (기존)

기존 DTO 필드 (8개):
```java
private BigDecimal presetAmount;        // 기본 설정 금액
private BigDecimal minAmount;           // 최소 금액
private BigDecimal maxAmount;           // 최대 금액
private BigDecimal dailyLimit;          // 일일 한도
private Boolean showBalance;            // 잔액 표시 여부
private String balanceSource;           // 잔액 조회 소스
private Boolean usePartnerExchangeRate; // 파트너 환율 사용 여부
private Boolean allowAmountEdit;        // 금액 수정 허용 여부
```

**추가할 엔드포인트** — `InfoController.java`에 추가 (위젯 공통 정보):

```java
/**
 * 위젯 설정 조회.
 * 위젯 UI 동작을 제어하는 파트너별 설정 정보를 반환한다.
 *
 * @param session 세션 데이터
 * @return 위젯 설정
 * @response 200 조회 성공
 * @auth true
 */
@GetMapping(name = "위젯 설정 조회", value = "/widget-config")
public WidgetConfigResponse getWidgetConfig(WidgetSessionData session) {
    // TODO: 파트너별 설정을 DB에서 조회 (partner_settings 등)
    // 현재는 v1과 동일하게 기본값 반환
    return WidgetConfigResponse.builder()
            .presetAmount(new BigDecimal("100"))
            .minAmount(new BigDecimal("10"))
            .maxAmount(new BigDecimal("10000"))
            .dailyLimit(new BigDecimal("50000"))
            .showBalance(true)
            .balanceSource("partner")
            .usePartnerExchangeRate(true)
            .allowAmountEdit(true)
            .build();
}
```

**import 추가** (InfoController.java):
```java
import com.cryptoments.openapi.dto.response.WidgetConfigResponse;
import java.math.BigDecimal;
```

> **향후**: `partner_settings` 또는 `system_settings` 테이블에서 파트너별 위젯 설정을 관리하도록 확장.
> v1도 하드코딩이었으므로 동일 수준으로 먼저 배포 후 DB 연동은 후속 작업으로 처리.

---

### 9-2. GET /widgets/api/axim/best-networks — Axim 최적 네트워크 조회 (✅ 코드 추가 완료)

v1에서 Widget UI가 결제 시 최적 네트워크 목록(잔액 높은 순, TX 수 기준 정렬)을 조회하는 엔드포인트.

**v2 상태**: ✅ **이번 세션에서 코드 직접 추가 완료** — 배포만 하면 동작.

**추가된 파일 2개**:

#### (A) AximPayClient.java — `getBestNetworks()` 메서드 추가

```java
/**
 * 최적 네트워크 목록을 조회한다.
 * connectWalletToken 기반으로 잔액/TX 수 기준 정렬된 네트워크 목록을 반환한다.
 */
public List<AximNetworkInfoResponse> getBestNetworks(String apiKey, String secretKey,
                                                      String connectWalletToken) {
    try {
        return restClient.get()
                .uri(baseUrl + "/api/v1/open/networks/best?connectWalletToken={token}", connectWalletToken)
                .headers(h -> addAuthHeaders(h, apiKey, secretKey))
                .retrieve()
                .body(new ParameterizedTypeReference<List<AximNetworkInfoResponse>>() {});
    } catch (Exception e) {
        log.error("Axim getBestNetworks 실패: {}", e.getMessage());
        throw new AximApiException("최적 네트워크 조회 실패: " + e.getMessage(), e);
    }
}
```

#### (B) AximController.java — `GET /widgets/api/axim/best-networks` 엔드포인트 추가

```java
@GetMapping(name = "Axim 최적 네트워크 조회", value = "/best-networks")
public List<AximNetworkInfoResponse> getBestNetworks(@RequestParam String partnerUserId,
                                                      WidgetSessionData session) {
    // 1) 외부 지갑 조회 → connectWalletToken 취득
    ExternalWallet wallet = externalWalletRepository
            .findByPartnerIdAndPartnerUserId(session.getPartnerId(), partnerUserId)
            .stream()
            .filter(w -> w.getStatus() == ExternalWalletStatus.CONNECTED)
            .findFirst()
            .orElse(null);

    if (wallet == null || wallet.getConnectWalletToken() == null) {
        return List.of();
    }

    // 2) Axim API 호출
    PartnerAximSettings settings = aximSettingsRepository.findByPartnerId(session.getPartnerId());
    if (settings == null || !Boolean.TRUE.equals(settings.getIsEnabled())) {
        return List.of();
    }

    try {
        List<AximNetworkInfoResponse> networks = aximPayClient.getBestNetworks(
                settings.getApiKey(), settings.getApiSecretEnc(), wallet.getConnectWalletToken());
        return networks != null ? networks : List.of();
    } catch (Exception e) {
        log.error("Axim best-networks API 실패: {}", e.getMessage());
        return List.of();
    }
}
```

**v1 동작과 동일**: `connectWalletToken`으로 Axim API를 호출, 잔액/TX 수 기준 정렬된 네트워크 목록 반환.
실패 시 빈 리스트 반환 (Widget UI에서 전체 네트워크 목록 fallback 처리).

---

### 9-3. GET /widgets/api/axim/partner-info — Axim 파트너 정보 조회

v1에서 Widget UI가 Axim 파트너 정보 (siteId, 파트너명 등)를 별도로 조회하는 엔드포인트.
`init-info`와 일부 겹치지만, v1 Widget UI에서 **별도로 호출**하고 있으므로 하위 호환성을 위해 필요.

**v2 상태**: `AximPartnerInfoResponse` DTO 이미 존재 (5필드), `AximPayClient.getPartnerInfo()` 도 있음. **엔드포인트만 없음**.

**추가할 엔드포인트** — `AximController.java`에 추가:

```java
/**
 * Axim 파트너 정보 조회.
 * Axim에 등록된 파트너 정보를 반환한다.
 *
 * @param session 세션 데이터
 * @return Axim 파트너 정보
 * @response 200 조회 성공
 * @response 404 Axim 설정 없음
 * @auth true
 */
@GetMapping(name = "Axim 파트너 정보 조회", value = "/partner-info")
public AximPartnerInfoResponse getPartnerInfo(WidgetSessionData session) {
    PartnerAximSettings settings = aximSettingsRepository.findByPartnerId(session.getPartnerId());
    if (settings == null || !Boolean.TRUE.equals(settings.getIsEnabled())) {
        throw new NotFoundException(ErrorCodes.AXIM_SETTINGS_NOT_FOUND);
    }

    try {
        return aximPayClient.getPartnerInfo(settings.getApiKey(), settings.getApiSecretEnc());
    } catch (Exception e) {
        log.error("Axim 파트너 정보 조회 실패: partnerId={}, error={}",
                session.getPartnerId(), e.getMessage());
        // DB fallback
        return AximPartnerInfoResponse.builder()
                .partnerId(null)
                .name(session.getPartnerName())
                .apiKey(settings.getApiKey())
                .webhookUrl(settings.getWebhookUrl())
                .status("API_ERROR")
                .build();
    }
}
```

**import 추가** (AximController.java):
```java
import com.cryptoments.common.client.dto.axim.AximPartnerInfoResponse;
```

> 이 import는 `AximPayClient`이 이미 import 되어 있으므로 DTO 패키지만 추가하면 됨.

---

### 9-4. 수정 파일 요약

| # | 파일 | 작업 | 상태 |
|---|------|------|------|
| 1 | `common/.../client/AximPayClient.java` | `getBestNetworks()` 메서드 추가 | ✅ 코드 추가 완료 |
| 2 | `open-api/.../controller/widget/AximController.java` | `GET /axim/best-networks` 엔드포인트 추가 | ✅ 코드 추가 완료 |
| 3 | `open-api/.../controller/widget/InfoController.java` | `GET /widget-config` 엔드포인트 추가 | ⚠️ 구현 필요 |
| 4 | `open-api/.../controller/widget/AximController.java` | `GET /axim/partner-info` 엔드포인트 추가 | ⚠️ 구현 필요 |

### 9-5. 수정 순서

```
1. InfoController.java → getWidgetConfig() 추가 + import
2. AximController.java → getPartnerInfo() 추가 + import
3. 컴파일 확인: ./gradlew :open-api:compileJava
```

---

## 10. v1 vs v2 Widget API 전체 매핑

| # | v1 경로 | v2 경로 | 상태 |
|---|---------|---------|------|
| 1 | `POST /widgets/auth/token` | `POST /widgets/auth/token` | ✅ |
| 2 | `POST /widgets/auth/refresh` | `POST /widgets/auth/refresh` | ✅ |
| 3 | `GET /widgets/api/chains` | `GET /widgets/api/chains` | ✅ |
| 4 | `GET /widgets/api/chains/{chain}/tokens` | `GET /widgets/api/chains/{chain}/tokens` | ✅ |
| 5 | `GET /widgets/api/tokens` | `GET /widgets/api/tokens` | ✅ |
| 6 | `GET /widgets/api/exchange-rates` | `GET /widgets/api/exchange-rates` | ✅ |
| 7 | `POST /widgets/api/deposit-address` | `POST /widgets/api/deposit-address` | ✅ |
| 8 | `GET /widgets/api/balance` | `GET /widgets/api/balance` | ✅ |
| 9 | `GET /widgets/api/transactions` | `GET /widgets/api/transactions` | ✅ |
| 10 | `POST /widgets/api/withdrawal` | `POST /widgets/api/withdrawal` | ✅ |
| 11 | `GET /widgets/api/withdrawal-fee` | `GET /widgets/api/withdrawal-fee` | ✅ |
| 12 | `GET /widgets/api/withdrawal-limits` | `GET /widgets/api/withdrawal-limits` | ✅ |
| 13 | `GET /widgets/api/withdrawal-fees` | — | ❌ v2 미구현 (전체 체인별 수수료 일괄 — Widget UI 미사용이면 불필요) |
| 14 | `GET /widgets/api/widget-config` | `GET /widgets/api/widget-config` | ⚠️ Section 9-1로 추가 |
| 15 | `POST /widgets/api/deposit-reservations` | `POST /widgets/api/deposit-reservations` | ✅ |
| 16 | `GET /widgets/api/deposit-reservations` | `GET /widgets/api/deposit-reservations` | ✅ |
| 17 | `PUT /widgets/api/deposit-reservations` | `PUT /widgets/api/deposit-reservations` | ✅ |
| 18 | `DELETE /widgets/api/deposit-reservations` | `DELETE /widgets/api/deposit-reservations` | ✅ |
| 19 | `POST .../deposit-reservations/complete` | `POST .../deposit-reservations/complete` | ✅ |
| 20 | `GET /widgets/api/axim/init-info` | `GET /widgets/api/axim/init-info` | ✅ |
| 21 | `GET /widgets/api/axim/connection-status` | `GET /widgets/api/axim/connection-status` | ✅ |
| 22 | `GET /widgets/api/axim/partner-info` | `GET /widgets/api/axim/partner-info` | ⚠️ Section 9-2로 추가 |
| 23 | `GET /widgets/api/axim/best-networks` | `GET /widgets/api/axim/best-networks` | ✅ 이번 세션 코드 추가 (Section 9-2) |
| 24 | `POST /widgets/api/axim/payments` | `POST /widgets/api/axim/payments` | ✅ |
| 25 | `GET /widgets/api/axim/payments/{id}` | `GET /widgets/api/axim/payments/{id}` | ✅ |
| 26 | `DELETE /widgets/api/axim/payments/{id}` | `DELETE /widgets/api/axim/payments/{id}` | ✅ |
| 27 | `GET /widgets/payment/links/{id}` | `GET /widgets/payment/links/{id}` | ✅ |
| 28 | `POST .../links/{id}/activate` | `POST .../links/{id}/activate` | ✅ |
| 29 | `GET .../links/{id}/status` | `GET .../links/{id}/status` | ✅ |
| 30 | `POST .../links/{id}/complete` | `POST .../links/{id}/complete` | ✅ |
| 31 | `POST .../links/{id}/expire` | `POST .../links/{id}/expire` | ✅ |
| 32 | `POST .../webhooks/axim/{partnerId}` | `POST /webhooks/axim/{partnerId}` | ✅ |

**결과**: v1 32개 중 **31개 매핑 완료**, `withdrawal-fees` (복수형) 1개만 미구현 (Widget UI 미사용 확인 후 판단).

---

## 11. 관련 문서

| 문서 | 용도 |
|------|------|
| `SESSION_CONTEXT.md` | 이 이슈 진행 상황 기록 |
| `CRYPTOMENTS_V2_DDL.sql` | `external_wallets` 테이블 정의 |
| `SPRING_NODEJS_INTEGRATION_GUIDE.md` | Spring ↔ Node.js 연동 (blockchain-api 호출 시 참고) |
