# Spring Boot admin-api → Node.js API 연동 지침서

**작업일**: 2026-03-16
**대상 모듈**: `admin-api` (Spring Boot 3.3.1, Java 17)
**연동 대상**: `blockchain-api` (Port 3001), `relayer-api` (Port 3002)
**참고 문서**: `NODEJS_API_INTEGRATION_GUIDE.md` (API 스펙 상세)
**빌드 검증**: `./gradlew :admin-api:compileJava` 성공 기준

---

## 개요

admin-api의 서비스 계층에 TODO로 남아있는 Node.js 연동 작업을 완료합니다.
현재 admin-api는 DB CRUD만 수행하며, 블록체인 온체인 작업(지갑 파생, 잔액 동기화, 컨트랙트 관리 등)은
모두 Node.js 서비스에 위임해야 합니다.

### 작업 범위

| # | 항목 | 우선순위 | 대상 서비스 |
|---|------|----------|------------|
| S-1 | application.yml + REST 클라이언트 설정 | 필수 (선행) | config |
| S-2 | BlockchainApiClient 인터페이스 생성 | 필수 (선행) | client |
| S-3 | RelayerApiClient 인터페이스 생성 | 필수 (선행) | client |
| S-4 | Node.js 연동 DTO 생성 | 필수 (선행) | dto/blockchain |
| S-5 | WalletManagementService — 잔액 동기화 연동 | HIGH | service |
| S-6 | InfraWalletService — ADMIN/FEE 지갑 생성 연동 | HIGH | service |
| S-7 | NonceTrackerService — 온체인 논스 동기화 | HIGH | service |
| S-8 | RelayerManagementService — Relayer 등록/해제 연동 | HIGH | service |
| S-9 | ContractManagementService (신규) — 컨트랙트 관리 | HIGH | service + controller |
| S-10 | WalletApprovalService — approve 재시도 연동 | MEDIUM | service |
| S-11 | DepositManagementService — TX 상태 조회 연동 | LOW | service |
| S-12 | WithdrawalManagementService — TX 상태 조회 연동 | LOW | service |

### 작업 순서

```
S-1 (yml) → S-2, S-3, S-4 (병렬) → S-5~S-9 (병렬) → S-10~S-12 → 빌드 검증
```

---

## S-1. application.yml 설정 추가

**파일**: `admin-api/src/main/resources/application.yml`

`axim:` 섹션에 다음을 추가:

```yaml
axim:
  rest:
    debug: false
    client:
      pool-size: 200
      connection-request-timeout: 30
      response-timeout: 60          # 온체인 TX 포함 API를 위해 60초로 설정
    session:
      secret-key: ${AXIM_SESSION_SECRET_KEY:default-secret-key-change-in-production}
      token-expire-days: 90

# ── Node.js 서버 연결 ──
node-service:
  blockchain-api:
    url: ${NODE_BLOCKCHAIN_API_URL:http://localhost:3001}
  relayer-api:
    url: ${NODE_RELAYER_API_URL:http://localhost:3002}
```

> **주의**: 기존 `axim.rest` 섹션에 `client` 항목이 없으면 추가. 기존 항목은 유지.

---

## S-2. BlockchainApiClient 인터페이스

**신규 파일**: `admin-api/src/main/java/com/cryptoments/adminapi/client/BlockchainApiClient.java`

```java
package com.cryptoments.adminapi.client;

import com.cryptoments.adminapi.dto.blockchain.*;
import one.axim.framework.rest.annotation.*;
import one.axim.framework.rest.enums.XHttpMethod;
import org.springframework.web.bind.annotation.*;

/**
 * blockchain-api (Node.js) REST 클라이언트
 * 지갑 파생, 잔액 조회, TX 조회, 가스, 컨트랙트 관리
 */
@XRestService(value = "blockchain-api",
    host = "${node-service.blockchain-api.url:http://localhost:3001}")
public interface BlockchainApiClient {

    // ━━━ 지갑 ━━━

    /** HD 지갑 주소 파생 */
    @XRestAPI(value = "/api/wallet/derive", method = XHttpMethod.POST)
    WalletDeriveResponse deriveWallet(@RequestBody WalletDeriveRequest request);

    /** ERC-20/TRC-20 approve 상태 조회 */
    @XRestAPI(value = "/api/wallet/approve-status/{address}", method = XHttpMethod.GET)
    ApproveStatusResponse getApproveStatus(
        @PathVariable("address") String address,
        @RequestParam("networkId") int networkId,
        @RequestParam("tokenContract") String tokenContract,
        @RequestParam("spender") String spender);

    // ━━━ 잔액 ━━━

    /** 토큰 잔액 조회 (캐시 30초) */
    @XRestAPI(value = "/api/balance/token/{address}", method = XHttpMethod.GET)
    TokenBalanceResponse getTokenBalance(
        @PathVariable("address") String address,
        @RequestParam("networkId") int networkId,
        @RequestParam("tokenContract") String tokenContract);

    /** 네이티브 잔액 조회 */
    @XRestAPI(value = "/api/balance/native/{address}", method = XHttpMethod.GET)
    NativeBalanceResponse getNativeBalance(
        @PathVariable("address") String address,
        @RequestParam("networkId") int networkId);

    /** 온체인 잔액 일괄 동기화 */
    @XRestAPI(value = "/api/balance/sync", method = XHttpMethod.POST)
    BalanceSyncResponse syncBalances(@RequestBody BalanceSyncRequest request);

    // ━━━ 트랜잭션 ━━━

    /** TX 상태 조회 */
    @XRestAPI(value = "/api/tx/status/{txHash}", method = XHttpMethod.GET)
    TxStatusResponse getTxStatus(
        @PathVariable("txHash") String txHash,
        @RequestParam("networkId") int networkId);

    /** TX 영수증 조회 */
    @XRestAPI(value = "/api/tx/receipt/{txHash}", method = XHttpMethod.GET)
    TxReceiptResponse getTxReceipt(
        @PathVariable("txHash") String txHash,
        @RequestParam("networkId") int networkId);

    // ━━━ 가스 ━━━

    /** 가스 가격 조회 (캐시 15초) */
    @XRestAPI(value = "/api/gas/price", method = XHttpMethod.GET)
    GasPriceResponse getGasPrice(@RequestParam("networkId") int networkId);

    /** 가스 추정 */
    @XRestAPI(value = "/api/gas/estimate", method = XHttpMethod.POST)
    GasEstimateResponse estimateGas(@RequestBody GasEstimateRequest request);

    // ━━━ 블록 ━━━

    /** 최신 블록 번호 (캐시 5초) */
    @XRestAPI(value = "/api/block/latest", method = XHttpMethod.GET)
    LatestBlockResponse getLatestBlock(@RequestParam("networkId") int networkId);

    // ━━━ 시스템 관리 (Admin 전용) ━━━

    /** ADMIN 지갑 생성 (네트워크당 1개) */
    @XRestAPI(value = "/api/admin/wallet/create-admin", method = XHttpMethod.POST)
    AdminWalletResponse createAdminWallet(@RequestBody AdminWalletRequest request);

    /**
     * CryptoRelayer 컨트랙트 등록 (수동 배포 후)
     * ⚠️ deploy가 아닌 register — Hardhat/Tronbox로 수동 배포 후 등록
     */
    @XRestAPI(value = "/api/admin/contract/register", method = XHttpMethod.POST)
    ContractRegisterResponse registerContract(@RequestBody ContractRegisterRequest request);

    /** Relayer EOA 컨트랙트 등록 (온체인 TX 포함, 타임아웃 주의) */
    @XRestAPI(value = "/api/admin/contract/add-relayer", method = XHttpMethod.POST)
    ContractRelayerResponse addRelayerToContract(@RequestBody ContractRelayerRequest request);

    /** Relayer EOA 컨트랙트 해제 (온체인 TX 포함) */
    @XRestAPI(value = "/api/admin/contract/remove-relayer", method = XHttpMethod.POST)
    ContractRelayerResponse removeRelayerFromContract(@RequestBody ContractRelayerRequest request);

    /** 컨트랙트 긴급 정지 (온체인 TX 포함) */
    @XRestAPI(value = "/api/admin/contract/pause", method = XHttpMethod.POST)
    ContractPauseResponse pauseContract(@RequestBody ContractPauseRequest request);

    /** 컨트랙트 정지 해제 (온체인 TX 포함) */
    @XRestAPI(value = "/api/admin/contract/unpause", method = XHttpMethod.POST)
    ContractPauseResponse unpauseContract(@RequestBody ContractUnpauseRequest request);

    /** 컨트랙트 상태 조회 (온체인 + DB) */
    @XRestAPI(value = "/api/admin/contract/status/{networkId}", method = XHttpMethod.GET)
    ContractStatusResponse getContractStatus(@PathVariable("networkId") int networkId);

    /** 전체 ACTIVE 컨트랙트 목록 */
    @XRestAPI(value = "/api/admin/contract/list", method = XHttpMethod.GET)
    ContractListResponse getContractList();
}
```

---

## S-3. RelayerApiClient 인터페이스

**신규 파일**: `admin-api/src/main/java/com/cryptoments/adminapi/client/RelayerApiClient.java`

```java
package com.cryptoments.adminapi.client;

import com.cryptoments.adminapi.dto.blockchain.*;
import one.axim.framework.rest.annotation.*;
import one.axim.framework.rest.enums.XHttpMethod;
import org.springframework.web.bind.annotation.*;

/**
 * relayer-api (Node.js) REST 클라이언트
 * Relayer 등록/해제, 상태 조회
 */
@XRestService(value = "relayer-api",
    host = "${node-service.relayer-api.url:http://localhost:3002}")
public interface RelayerApiClient {

    /** Relayer 등록 (DB + 온체인) */
    @XRestAPI(value = "/api/relayer/register", method = XHttpMethod.POST)
    RelayerRegisterResponse registerRelayer(@RequestBody RelayerRegisterRequest request);

    /** Relayer 해제 (pending TX 있으면 DEREGISTERING 상태) */
    @XRestAPI(value = "/api/relayer/unregister", method = XHttpMethod.POST)
    RelayerUnregisterResponse unregisterRelayer(@RequestBody RelayerUnregisterRequest request);

    /** 전체 Relayer 목록 */
    @XRestAPI(value = "/api/relayer/list", method = XHttpMethod.GET)
    RelayerListResponse getRelayerList();

    /** 단일 Relayer 상태 조회 */
    @XRestAPI(value = "/api/relayer/status/{walletId}", method = XHttpMethod.GET)
    RelayerDetailResponse getRelayerStatus(@PathVariable("walletId") int walletId);
}
```

---

## S-4. Node.js 연동 DTO

**신규 패키지**: `admin-api/src/main/java/com/cryptoments/adminapi/dto/blockchain/`

> DTO 멤버 변수에 JavaDoc 주석 필수 (프로젝트 규칙)

### 4-1. 지갑 DTO

```java
// WalletDeriveRequest.java
package com.cryptoments.adminapi.dto.blockchain;

import lombok.*;

@Getter @Setter @Builder
@NoArgsConstructor @AllArgsConstructor
public class WalletDeriveRequest {
    /** blockchain_networks.id */
    private Integer networkId;
    /** hd_wallets.id */
    private Integer hdWalletId;
    /** 생략 시 자동 증가 (wallet_index_manager 기반) */
    private Integer derivationIndex;
}
```

```java
// WalletDeriveResponse.java
@Getter @Setter
@NoArgsConstructor @AllArgsConstructor
public class WalletDeriveResponse {
    /** 파생된 지갑 주소 */
    private String address;
    /** 공개키 */
    private String publicKey;
    /** wallet_addresses.id (Node.js가 INSERT 후 반환) */
    private Integer walletAddressId;
    /** HD 파생 경로 (e.g. m/44'/60'/0'/0/5) */
    private String derivationPath;
}
```

```java
// AdminWalletRequest.java
@Getter @Setter @Builder
@NoArgsConstructor @AllArgsConstructor
public class AdminWalletRequest {
    /** 네트워크 ID (네트워크당 1개만 생성 가능) */
    private Integer networkId;
    /** HD 지갑 마스터 ID */
    private Integer hdWalletId;
}
```

```java
// AdminWalletResponse.java
@Getter @Setter
@NoArgsConstructor @AllArgsConstructor
public class AdminWalletResponse {
    /** wallet_addresses.id */
    private Integer addressId;
    /** ADMIN 지갑 주소 */
    private String address;
    /** 네트워크 ID */
    private Integer networkId;
    /** HD 파생 경로 */
    private String derivationPath;
}
```

```java
// ApproveStatusResponse.java
@Getter @Setter
@NoArgsConstructor @AllArgsConstructor
public class ApproveStatusResponse {
    /** 현재 allowance (String — BigInt 직렬화) */
    private String allowance;
    /** MAX_UINT256 수준으로 승인되었는지 */
    private Boolean isApproved;
}
```

### 4-2. 잔액 DTO

```java
// TokenBalanceResponse.java
@Getter @Setter
@NoArgsConstructor @AllArgsConstructor
public class TokenBalanceResponse {
    /** 토큰 잔액 (최소 단위, String) — BigDecimal로 변환 필요 */
    private String balance;
    /** 토큰 소수점 자릿수 */
    private Integer decimals;
}
```

```java
// NativeBalanceResponse.java
@Getter @Setter
@NoArgsConstructor @AllArgsConstructor
public class NativeBalanceResponse {
    /** 네이티브 잔액 (wei/sun 단위, String) */
    private String balance;
}
```

```java
// BalanceSyncRequest.java
@Getter @Setter @Builder
@NoArgsConstructor @AllArgsConstructor
public class BalanceSyncRequest {
    /** 동기화 대상 wallet_addresses.id 목록 */
    private java.util.List<Integer> walletAddressIds;
}
```

```java
// BalanceSyncResponse.java
@Getter @Setter
@NoArgsConstructor @AllArgsConstructor
public class BalanceSyncResponse {
    /** 동기화 결과 목록 */
    private java.util.List<BalanceSyncResult> results;

    @Getter @Setter
    @NoArgsConstructor @AllArgsConstructor
    public static class BalanceSyncResult {
        /** wallet_addresses.id */
        private Integer walletAddressId;
        /** currencies.id */
        private Integer currencyId;
        /** 온체인 잔액 (최소 단위, String) */
        private String onchainBalance;
        /** DB 잔액과의 차이 (String) */
        private String diff;
    }
}
```

### 4-3. 트랜잭션 DTO

```java
// TxStatusResponse.java
@Getter @Setter
@NoArgsConstructor @AllArgsConstructor
public class TxStatusResponse {
    /** 트랜잭션 해시 */
    private String txHash;
    /** "pending" | "confirmed" | "failed" */
    private String status;
    /** 블록 번호 (pending이면 null) */
    private Long blockNumber;
    /** 확인 수 */
    private Integer confirmations;
    /** 사용된 가스 (String) */
    private String gasUsed;
}
```

```java
// TxReceiptResponse.java
@Getter @Setter
@NoArgsConstructor @AllArgsConstructor
public class TxReceiptResponse {
    /** 트랜잭션 해시 */
    private String txHash;
    /** 성공 여부 */
    private Boolean status;
    /** 블록 번호 */
    private Long blockNumber;
    /** 사용된 가스 (String) */
    private String gasUsed;
    /** 유효 가스 가격 (String) */
    private String effectiveGasPrice;
    /** 이벤트 로그 */
    private java.util.List<TxLog> logs;

    @Getter @Setter
    @NoArgsConstructor @AllArgsConstructor
    public static class TxLog {
        private String address;
        private java.util.List<String> topics;
        private String data;
        private Integer logIndex;
    }
}
```

### 4-4. 가스 DTO

```java
// GasPriceResponse.java
@Getter @Setter
@NoArgsConstructor @AllArgsConstructor
public class GasPriceResponse {
    /** 가스 가격 (wei, String) */
    private String gasPrice;
    /** EIP-1559 maxFeePerGas (null일 수 있음) */
    private String maxFeePerGas;
    /** EIP-1559 maxPriorityFeePerGas (null일 수 있음) */
    private String maxPriorityFeePerGas;
}
```

```java
// GasEstimateRequest.java
@Getter @Setter @Builder
@NoArgsConstructor @AllArgsConstructor
public class GasEstimateRequest {
    /** 네트워크 ID */
    private Integer networkId;
    /** 발신 주소 */
    private String from;
    /** 수신 주소 */
    private String to;
    /** TX input data (hex, optional) */
    private String data;
    /** 전송량 (wei, optional) */
    private String value;
}
```

```java
// GasEstimateResponse.java
@Getter @Setter
@NoArgsConstructor @AllArgsConstructor
public class GasEstimateResponse {
    /** 가스 한도 (String) */
    private String gasLimit;
    /** 예상 수수료 (wei, String) */
    private String estimatedFee;
}
```

```java
// LatestBlockResponse.java
@Getter @Setter
@NoArgsConstructor @AllArgsConstructor
public class LatestBlockResponse {
    /** 블록 번호 */
    private Long blockNumber;
    /** 블록 타임스탬프 (Unix) */
    private Long timestamp;
}
```

### 4-5. 컨트랙트 DTO

```java
// ContractRegisterRequest.java
@Getter @Setter @Builder
@NoArgsConstructor @AllArgsConstructor
public class ContractRegisterRequest {
    /** 네트워크 ID */
    private Integer networkId;
    /** 배포된 컨트랙트 주소 */
    private String contractAddress;
    /** 배포 트랜잭션 해시 */
    private String deployTxHash;
    /** ADMIN 지갑 wallet_addresses.id */
    private Integer ownerAddressId;
    /** ABI 버전 (optional) */
    private String abiVersion;
}
```

```java
// ContractRegisterResponse.java
@Getter @Setter
@NoArgsConstructor @AllArgsConstructor
public class ContractRegisterResponse {
    /** relayer_contracts.id */
    private Integer contractId;
    /** 컨트랙트 주소 */
    private String contractAddress;
    /** 배포 TX 해시 */
    private String deployTxHash;
    /** 네트워크 ID */
    private Integer networkId;
    /** ADMIN 지갑 ID */
    private Integer ownerAddressId;
    /** 상태 */
    private String status;
}
```

```java
// ContractRelayerRequest.java — add-relayer / remove-relayer 공용
@Getter @Setter @Builder
@NoArgsConstructor @AllArgsConstructor
public class ContractRelayerRequest {
    /** 네트워크 ID */
    private Integer networkId;
    /** relayer_wallets.id */
    private Integer relayerWalletId;
}
```

```java
// ContractRelayerResponse.java
@Getter @Setter
@NoArgsConstructor @AllArgsConstructor
public class ContractRelayerResponse {
    /** 온체인 TX 해시 */
    private String txHash;
    /** Relayer 주소 */
    private String relayerAddress;
    /** 컨트랙트 주소 */
    private String contractAddress;
}
```

```java
// ContractPauseRequest.java
@Getter @Setter @Builder
@NoArgsConstructor @AllArgsConstructor
public class ContractPauseRequest {
    /** 네트워크 ID */
    private Integer networkId;
    /** 정지 사유 (필수) */
    private String reason;
}
```

```java
// ContractUnpauseRequest.java
@Getter @Setter @Builder
@NoArgsConstructor @AllArgsConstructor
public class ContractUnpauseRequest {
    /** 네트워크 ID */
    private Integer networkId;
}
```

```java
// ContractPauseResponse.java
@Getter @Setter
@NoArgsConstructor @AllArgsConstructor
public class ContractPauseResponse {
    /** 온체인 TX 해시 */
    private String txHash;
    /** 변경된 상태 ("PAUSED" 또는 "ACTIVE") */
    private String status;
    /** 컨트랙트 주소 */
    private String contractAddress;
    /** 정지 시각 (pause 시에만) */
    private String pausedAt;
    /** 정지 사유 (pause 시에만) */
    private String pauseReason;
}
```

```java
// ContractStatusResponse.java — status/:networkId 응답
@Getter @Setter
@NoArgsConstructor @AllArgsConstructor
public class ContractStatusResponse {
    /** 컨트랙트 주소 */
    private String contractAddress;
    /** 온체인 owner 주소 */
    private String owner;
    /** 온체인 paused 상태 */
    private Boolean paused;
    /** 등록된 Relayer 수 */
    private Integer relayerCount;
    /** 등록된 Relayer 주소 목록 */
    private java.util.List<String> relayers;
    /** ABI 버전 */
    private String abiVersion;
    /** DB 상태 */
    private String dbStatus;
}
```

```java
// ContractListResponse.java
@Getter @Setter
@NoArgsConstructor @AllArgsConstructor
public class ContractListResponse {
    /** 컨트랙트 목록 */
    private java.util.List<ContractInfo> contracts;

    @Getter @Setter
    @NoArgsConstructor @AllArgsConstructor
    public static class ContractInfo {
        private Integer id;
        private Integer network_id;
        private String contract_address;
        private Integer owner_address_id;
        private String deploy_tx_hash;
        private String abi_version;
        private String status;
        private String paused_at;
        private String paused_reason;
        private String created_at;
        private String updated_at;
    }
}
```

### 4-6. Relayer DTO

```java
// RelayerRegisterRequest.java
@Getter @Setter @Builder
@NoArgsConstructor @AllArgsConstructor
public class RelayerRegisterRequest {
    /** 네트워크 ID */
    private Integer networkId;
    /** wallet_addresses.id (Relayer EOA) */
    private Integer walletAddressId;
    /** "COLLECTION" 또는 "WITHDRAWAL" */
    private String relayerRole;
}
```

```java
// RelayerRegisterResponse.java
@Getter @Setter
@NoArgsConstructor @AllArgsConstructor
public class RelayerRegisterResponse {
    /** relayer_wallets.id */
    private Integer relayerId;
    /** Relayer 주소 */
    private String address;
    /** 운영 상태 ("ACTIVE") */
    private String status;
    /** 등록 상태 ("REGISTERED") */
    private String registrationStatus;
    /** 온체인 TX 해시 (addRelayer) */
    private String txHash;
    /** 컨트랙트 주소 */
    private String contractAddress;
}
```

```java
// RelayerUnregisterRequest.java
@Getter @Setter @Builder
@NoArgsConstructor @AllArgsConstructor
public class RelayerUnregisterRequest {
    /** relayer_wallets.id */
    private Integer relayerId;
}
```

```java
// RelayerUnregisterResponse.java
@Getter @Setter
@NoArgsConstructor @AllArgsConstructor
public class RelayerUnregisterResponse {
    /** relayer_wallets.id */
    private Integer relayerId;
    /** 운영 상태 ("PAUSED") */
    private String status;
    /** 등록 상태 ("DEREGISTERING" 또는 "REVOKED") */
    private String registrationStatus;
    /** 온체인 TX 해시 (즉시 해제 시) */
    private String txHash;
    /** 대기 메시지 (DEREGISTERING 시) */
    private String message;
}
```

```java
// RelayerListResponse.java
@Getter @Setter
@NoArgsConstructor @AllArgsConstructor
public class RelayerListResponse {
    /** 총 Relayer 수 */
    private Integer count;
    /** Relayer 목록 */
    private java.util.List<RelayerInfo> relayers;
}
```

```java
// RelayerDetailResponse.java — 단일 Relayer (list 내부와 동일 구조)
@Getter @Setter
@NoArgsConstructor @AllArgsConstructor
public class RelayerDetailResponse {
    private Integer id;
    private Integer networkId;
    private String address;
    private String role;
    /** 운영 상태: ACTIVE / PAUSED */
    private String status;
    /** 등록 상태: PENDING / REGISTERING / REGISTERED / DEREGISTERING / REVOKED */
    private String registrationStatus;
    private Integer priority;
    private Integer pendingTxCount;
    private Integer maxPendingTxCount;
}
```

```java
// RelayerInfo.java — 목록용 VO
@Getter @Setter
@NoArgsConstructor @AllArgsConstructor
public class RelayerInfo {
    private Integer id;
    private Integer networkId;
    private String address;
    private String role;
    private String status;
    private String registrationStatus;
    private Integer priority;
    private Integer pendingTxCount;
    private Integer maxPendingTxCount;
}
```

---

## S-5. WalletManagementService — 잔액 동기화 연동

**파일**: `admin-api/src/main/java/com/cryptoments/adminapi/service/WalletManagementService.java`

### 변경 내용

1. `BlockchainApiClient` 필드 주입
2. `syncBalances()` 메서드의 TODO 교체

### Before (현재 TODO)

```java
// TODO: 블록체인 서비스를 통한 온체인 잔액 동기화 연동 필요.
// 현재는 DB 잔액만 조회하여 반환
```

### After

```java
private final BlockchainApiClient blockchainApiClient;

// syncBalances 메서드 내부:
public BalanceSyncResultDto syncBalances(List<Long> walletAddressIds) {
    // 1. Node.js blockchain-api에 온체인 잔액 동기화 요청
    BalanceSyncRequest syncRequest = BalanceSyncRequest.builder()
        .walletAddressIds(walletAddressIds.stream()
            .map(Long::intValue)
            .collect(Collectors.toList()))
        .build();

    BalanceSyncResponse syncResponse;
    try {
        syncResponse = blockchainApiClient.syncBalances(syncRequest);
    } catch (Exception e) {
        log.error("온체인 잔액 동기화 실패: walletIds={}", walletAddressIds, e);
        throw new NotFoundException(ErrorCodes.BLOCKCHAIN_API_ERROR.code(),
            "온체인 잔액 동기화에 실패했습니다: " + e.getMessage());
    }

    // 2. 동기화 결과 반환 (Node.js가 wallet_balances 테이블 직접 UPDATE)
    return convertSyncResponse(syncResponse);
}
```

### 추가: 개별 토큰/네이티브 잔액 조회 메서드

```java
/**
 * 단일 지갑의 토큰 잔액 실시간 조회 (온체인)
 */
public TokenBalanceResponse getOnchainTokenBalance(int networkId, String address, String tokenContract) {
    return blockchainApiClient.getTokenBalance(address, networkId, tokenContract);
}

/**
 * 단일 지갑의 네이티브 잔액 실시간 조회 (온체인)
 */
public NativeBalanceResponse getOnchainNativeBalance(int networkId, String address) {
    return blockchainApiClient.getNativeBalance(address, networkId);
}
```

---

## S-6. InfraWalletService — ADMIN/FEE 지갑 생성 연동

**파일**: `admin-api/src/main/java/com/cryptoments/adminapi/service/InfraWalletService.java`

### 변경 내용

1. `BlockchainApiClient` 필드 주입
2. `createFeeWallet()`, `createSettlementWallet()` TODO 교체
3. ADMIN 지갑 생성 메서드 추가

### ADMIN 지갑 생성 (신규 메서드)

```java
/**
 * ADMIN 지갑 생성 — 네트워크당 1개만 가능
 * Hardhat/Tronbox 배포 후 transferOwnership 대상 지갑
 */
public AdminWalletResponse createAdminWallet(int networkId, int hdWalletId) {
    AdminWalletRequest request = AdminWalletRequest.builder()
        .networkId(networkId)
        .hdWalletId(hdWalletId)
        .build();

    try {
        return blockchainApiClient.createAdminWallet(request);
    } catch (XRestException e) {
        if (e.getCode() == 409) {
            throw new BusinessException(ErrorCodes.ADMIN_WALLET_ALREADY_EXISTS.code(),
                "해당 네트워크에 ADMIN 지갑이 이미 존재합니다.");
        }
        throw new BusinessException(ErrorCodes.BLOCKCHAIN_API_ERROR.code(), e.getMessage());
    }
}
```

### FEE 지갑 생성 (TODO 교체)

```java
/**
 * FEE 지갑 생성 — 가스비 전송 전용
 */
public WalletDeriveResponse createFeeWallet(int networkId, int hdWalletId) {
    // 1. Node.js에 HD 파생 요청
    WalletDeriveRequest request = WalletDeriveRequest.builder()
        .networkId(networkId)
        .hdWalletId(hdWalletId)
        .build();

    WalletDeriveResponse response;
    try {
        response = blockchainApiClient.deriveWallet(request);
    } catch (Exception e) {
        throw new BusinessException(ErrorCodes.BLOCKCHAIN_API_ERROR.code(),
            "FEE 지갑 파생 실패: " + e.getMessage());
    }

    // 2. wallet_assignments에 FEE 용도 할당 (Spring Boot 관리)
    walletAssignmentRepository.save(WalletAssignment.builder()
        .walletAddressId(response.getWalletAddressId().longValue())
        .walletType(WalletType.FEE)
        .networkId((long) networkId)
        .build());

    return response;
}
```

> **참고**: SETTLEMENT 지갑도 동일한 패턴 (walletType만 `SETTLEMENT`로 변경)

---

## S-7. NonceTrackerService — 온체인 논스 동기화

**파일**: `admin-api/src/main/java/com/cryptoments/adminapi/service/NonceTrackerService.java`

### 변경 내용

`syncNonce()` 메서드의 TODO 교체

### Before

```java
// TODO: 블록체인 서비스 연동하여 실제 온체인 논스 조회 필요
// 현재는 더미 계산: syncedNonce = lastConfirmedNonce + 1
```

### After

```java
/**
 * 온체인 논스 동기화
 * blockchain-api의 TX 조회 기능을 활용하여 실제 온체인 상태 확인
 */
public NonceTracker syncNonce(Long nonceTrackerId) {
    NonceTracker tracker = nonceTrackerRepository.findById(nonceTrackerId);
    if (tracker == null) {
        throw new NotFoundException(ErrorCodes.NONCE_TRACKER_NOT_FOUND);
    }

    // 지갑 주소 조회
    WalletAddress walletAddress = walletAddressRepository.findById(tracker.getWalletAddressId());
    if (walletAddress == null) {
        throw new NotFoundException(ErrorCodes.WALLET_NOT_FOUND);
    }

    // Node.js blockchain-api에서 네이티브 잔액 조회 (getTransactionCount 대용)
    // ⚠️ 현재 blockchain-api에 getTransactionCount 전용 엔드포인트 없음
    // → 향후 /api/nonce/:address?networkId= 엔드포인트 추가 검토
    // → 현재는 DB 기반 동기화 (lastConfirmedNonce + pendingTx 카운트)
    long pendingCount = nonceTrackerRepository.countPendingTransactions(nonceTrackerId);
    long syncedNonce = tracker.getLastConfirmedNonce() + pendingCount;

    tracker.setCurrentNonce(syncedNonce);
    nonceTrackerRepository.save(tracker);

    // 감사 로그
    auditLogService.log("NONCE_SYNC", "NonceTracker " + nonceTrackerId
        + " synced: " + syncedNonce);

    return tracker;
}
```

> **⚠️ Node.js 추가 작업 필요**: `blockchain-api`에 `GET /api/nonce/:address?networkId=` 엔드포인트 추가 검토.
> `web3.eth.getTransactionCount(address, 'pending')` 결과를 반환하는 API.
> 이 엔드포인트가 추가되면 위 로직을 온체인 직접 동기화로 교체.

---

## S-8. RelayerManagementService — Relayer 등록/해제 연동

**파일**: `admin-api/src/main/java/com/cryptoments/adminapi/service/RelayerManagementService.java`

### 변경 내용

1. `RelayerApiClient` 필드 주입
2. `createRelayer()` → Node.js `relayer-api` 호출로 변경
3. `updateRelayerStatus()` → 해제 시 `unregister` 호출 추가

### createRelayer (수정)

```java
private final RelayerApiClient relayerApiClient;

/**
 * Relayer 생성 + 온체인 등록
 * → relayer-api가 DB INSERT + CryptoRelayer.addRelayer() TX 실행
 */
public RelayerRegisterResponse createRelayer(int networkId, int walletAddressId, String role) {
    RelayerRegisterRequest request = RelayerRegisterRequest.builder()
        .networkId(networkId)
        .walletAddressId(walletAddressId)
        .relayerRole(role)
        .build();

    try {
        RelayerRegisterResponse response = relayerApiClient.registerRelayer(request);

        // 감사 로그
        auditLogService.log("RELAYER_REGISTER",
            String.format("Relayer %d registered on network %d, txHash=%s",
                response.getRelayerId(), networkId, response.getTxHash()));

        return response;
    } catch (XRestException e) {
        if (e.getCode() == 404) {
            throw new NotFoundException(ErrorCodes.WALLET_NOT_FOUND.code(), e.getMessage());
        }
        throw new BusinessException(ErrorCodes.BLOCKCHAIN_API_ERROR.code(),
            "Relayer 등록 실패: " + e.getMessage());
    }
}
```

### deactivateRelayer (수정)

```java
/**
 * Relayer 비활성화 + 온체인 해제
 * pending TX가 있으면 DEREGISTERING 상태로 전환 (Poller가 완료 후 자동 해제)
 */
public RelayerUnregisterResponse deactivateRelayer(int relayerId) {
    RelayerUnregisterRequest request = RelayerUnregisterRequest.builder()
        .relayerId(relayerId)
        .build();

    try {
        RelayerUnregisterResponse response = relayerApiClient.unregisterRelayer(request);

        auditLogService.log("RELAYER_UNREGISTER",
            String.format("Relayer %d: registrationStatus=%s",
                relayerId, response.getRegistrationStatus()));

        return response;
    } catch (XRestException e) {
        if (e.getCode() == 404) {
            throw new NotFoundException(ErrorCodes.RELAYER_NOT_FOUND.code(), e.getMessage());
        }
        throw new BusinessException(ErrorCodes.BLOCKCHAIN_API_ERROR.code(),
            "Relayer 해제 실패: " + e.getMessage());
    }
}
```

### Relayer 목록/상태 조회 (추가 또는 수정)

```java
/**
 * Relayer 목록 — Node.js relayer-api에서 실시간 데이터 조회
 * (pendingTxCount 등 폴러 상태 포함)
 */
public RelayerListResponse getRelayerListFromNodeJs() {
    return relayerApiClient.getRelayerList();
}

/**
 * 단일 Relayer 상태 — 온체인 등록 상태 포함
 */
public RelayerDetailResponse getRelayerStatus(int walletId) {
    try {
        return relayerApiClient.getRelayerStatus(walletId);
    } catch (XRestException e) {
        if (e.getCode() == 404) {
            throw new NotFoundException(ErrorCodes.RELAYER_NOT_FOUND);
        }
        throw new BusinessException(ErrorCodes.BLOCKCHAIN_API_ERROR.code(), e.getMessage());
    }
}
```

---

## S-9. ContractManagementService (신규)

### 신규 서비스

**신규 파일**: `admin-api/src/main/java/com/cryptoments/adminapi/service/ContractManagementService.java`

```java
package com.cryptoments.adminapi.service;

import com.cryptoments.adminapi.client.BlockchainApiClient;
import com.cryptoments.adminapi.dto.blockchain.*;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import one.axim.framework.rest.exception.*;
import org.springframework.stereotype.Service;

/**
 * CryptoRelayer 컨트랙트 관리 서비스
 * → blockchain-api (Node.js) 위임
 *
 * 주요 기능:
 * - 수동 배포 후 컨트랙트 등록 (register)
 * - Relayer EOA 추가/제거 (add-relayer / remove-relayer)
 * - 긴급 정지/해제 (pause / unpause)
 * - 상태 조회 (온체인 + DB)
 */
@Slf4j
@Service
@RequiredArgsConstructor
public class ContractManagementService {

    private final BlockchainApiClient blockchainApiClient;
    private final AdminAuditLogService auditLogService;

    /**
     * 컨트랙트 등록 — Hardhat/Tronbox로 수동 배포 후 호출
     * Node.js가 온체인 owner 검증 수행
     */
    public ContractRegisterResponse registerContract(ContractRegisterRequest request) {
        try {
            ContractRegisterResponse response = blockchainApiClient.registerContract(request);

            auditLogService.log("CONTRACT_REGISTER",
                String.format("Contract %s registered on network %d",
                    response.getContractAddress(), response.getNetworkId()));

            return response;
        } catch (XRestException e) {
            log.error("컨트랙트 등록 실패: networkId={}, address={}",
                request.getNetworkId(), request.getContractAddress(), e);
            throw new BusinessException(ErrorCodes.BLOCKCHAIN_API_ERROR.code(),
                "컨트랙트 등록 실패: " + e.getMessage());
        }
    }

    /**
     * Relayer EOA를 컨트랙트에 추가
     * 온체인 TX 포함 — ADMIN 지갑이 CryptoRelayer.addRelayer() 호출
     */
    public ContractRelayerResponse addRelayer(int networkId, int relayerWalletId) {
        ContractRelayerRequest request = ContractRelayerRequest.builder()
            .networkId(networkId)
            .relayerWalletId(relayerWalletId)
            .build();

        try {
            ContractRelayerResponse response = blockchainApiClient.addRelayerToContract(request);

            auditLogService.log("CONTRACT_ADD_RELAYER",
                String.format("Relayer %s added to contract %s, txHash=%s",
                    response.getRelayerAddress(), response.getContractAddress(), response.getTxHash()));

            return response;
        } catch (XRestException e) {
            throw new BusinessException(ErrorCodes.BLOCKCHAIN_API_ERROR.code(),
                "Relayer 컨트랙트 등록 실패: " + e.getMessage());
        }
    }

    /**
     * Relayer EOA를 컨트랙트에서 제거
     * 온체인 TX 포함 — ADMIN 지갑이 CryptoRelayer.removeRelayer() 호출
     */
    public ContractRelayerResponse removeRelayer(int networkId, int relayerWalletId) {
        ContractRelayerRequest request = ContractRelayerRequest.builder()
            .networkId(networkId)
            .relayerWalletId(relayerWalletId)
            .build();

        try {
            ContractRelayerResponse response = blockchainApiClient.removeRelayerFromContract(request);

            auditLogService.log("CONTRACT_REMOVE_RELAYER",
                String.format("Relayer %s removed from contract %s, txHash=%s",
                    response.getRelayerAddress(), response.getContractAddress(), response.getTxHash()));

            return response;
        } catch (XRestException e) {
            throw new BusinessException(ErrorCodes.BLOCKCHAIN_API_ERROR.code(),
                "Relayer 컨트랙트 해제 실패: " + e.getMessage());
        }
    }

    /** 컨트랙트 긴급 정지 */
    public ContractPauseResponse pause(int networkId, String reason) {
        ContractPauseRequest request = ContractPauseRequest.builder()
            .networkId(networkId)
            .reason(reason)
            .build();

        try {
            ContractPauseResponse response = blockchainApiClient.pauseContract(request);
            auditLogService.log("CONTRACT_PAUSE",
                String.format("Contract %s paused: %s", response.getContractAddress(), reason));
            return response;
        } catch (XRestException e) {
            throw new BusinessException(ErrorCodes.BLOCKCHAIN_API_ERROR.code(),
                "컨트랙트 정지 실패: " + e.getMessage());
        }
    }

    /** 컨트랙트 정지 해제 */
    public ContractPauseResponse unpause(int networkId) {
        ContractUnpauseRequest request = ContractUnpauseRequest.builder()
            .networkId(networkId)
            .build();

        try {
            ContractPauseResponse response = blockchainApiClient.unpauseContract(request);
            auditLogService.log("CONTRACT_UNPAUSE",
                String.format("Contract %s unpaused", response.getContractAddress()));
            return response;
        } catch (XRestException e) {
            throw new BusinessException(ErrorCodes.BLOCKCHAIN_API_ERROR.code(),
                "컨트랙트 정지 해제 실패: " + e.getMessage());
        }
    }

    /** 컨트랙트 상태 조회 (온체인 + DB) */
    public ContractStatusResponse getStatus(int networkId) {
        try {
            return blockchainApiClient.getContractStatus(networkId);
        } catch (XRestException e) {
            if (e.getCode() == 404) {
                throw new NotFoundException(ErrorCodes.CONTRACT_NOT_FOUND.code(),
                    "해당 네트워크에 ACTIVE 컨트랙트가 없습니다.");
            }
            throw new BusinessException(ErrorCodes.BLOCKCHAIN_API_ERROR.code(), e.getMessage());
        }
    }

    /** 전체 ACTIVE 컨트랙트 목록 */
    public ContractListResponse getList() {
        return blockchainApiClient.getContractList();
    }
}
```

### 신규 컨트롤러 (또는 기존 SystemManagementController 확장)

**신규 파일**: `admin-api/src/main/java/com/cryptoments/adminapi/controller/ContractManagementController.java`

```java
@RestController
@RequestMapping("/api/admin/contracts")
@RequiredArgsConstructor
public class ContractManagementController extends XSessionController<AdminSessionData> {

    private final ContractManagementService contractManagementService;

    /** 컨트랙트 등록 */
    @PostMapping("/register")
    public ContractRegisterResponse register(@RequestBody ContractRegisterRequest request) {
        return contractManagementService.registerContract(request);
    }

    /** Relayer 추가 */
    @PostMapping("/add-relayer")
    public ContractRelayerResponse addRelayer(@RequestBody ContractRelayerRequest request) {
        return contractManagementService.addRelayer(request.getNetworkId(), request.getRelayerWalletId());
    }

    /** Relayer 제거 */
    @PostMapping("/remove-relayer")
    public ContractRelayerResponse removeRelayer(@RequestBody ContractRelayerRequest request) {
        return contractManagementService.removeRelayer(request.getNetworkId(), request.getRelayerWalletId());
    }

    /** 긴급 정지 */
    @PostMapping("/pause")
    public ContractPauseResponse pause(@RequestBody ContractPauseRequest request) {
        return contractManagementService.pause(request.getNetworkId(), request.getReason());
    }

    /** 정지 해제 */
    @PostMapping("/unpause")
    public ContractPauseResponse unpause(@RequestBody ContractUnpauseRequest request) {
        return contractManagementService.unpause(request.getNetworkId());
    }

    /** 상태 조회 (온체인 + DB) */
    @GetMapping("/status/{networkId}")
    public ContractStatusResponse getStatus(@PathVariable int networkId) {
        return contractManagementService.getStatus(networkId);
    }

    /** 전체 목록 */
    @GetMapping
    public ContractListResponse getList() {
        return contractManagementService.getList();
    }
}
```

---

## S-10. WalletApprovalService — approve 상태 조회 연동

**파일**: `admin-api/src/main/java/com/cryptoments/adminapi/service/WalletApprovalService.java`

### 추가 메서드

```java
private final BlockchainApiClient blockchainApiClient;

/**
 * 온체인 approve 상태 확인
 * → blockchain-api에 allowance 조회
 */
public ApproveStatusResponse checkApproveStatus(int networkId, String walletAddress,
        String tokenContract, String spenderContract) {
    return blockchainApiClient.getApproveStatus(walletAddress, networkId, tokenContract, spenderContract);
}
```

> **참고**: approve 재시도(`retryApproval`)는 `wallet_approvals.status`를 `PENDING`으로 되돌리면
> wallet-activator Poller가 자동으로 재처리합니다. Node.js API 호출 불필요.

---

## S-11, S-12. Deposit/Withdrawal — TX 상태 조회 연동

### 공통 패턴

```java
private final BlockchainApiClient blockchainApiClient;

/**
 * 입금/출금 TX의 온체인 상태 확인
 */
public TxStatusResponse checkTxStatus(int networkId, String txHash) {
    try {
        return blockchainApiClient.getTxStatus(txHash, networkId);
    } catch (XRestException e) {
        throw new BusinessException(ErrorCodes.BLOCKCHAIN_API_ERROR.code(),
            "TX 상태 조회 실패: " + e.getMessage());
    }
}
```

> 기존 서비스 메서드에서 TX 상세 정보가 필요한 곳에 위 패턴으로 연동.
> `DepositManagementService`, `WithdrawalManagementService` 모두 동일.

---

## ErrorCodes 추가

**파일**: `admin-api/src/main/java/com/cryptoments/adminapi/exception/ErrorCodes.java`

```java
// Node.js API 연동 에러
public static final ErrorCode BLOCKCHAIN_API_ERROR =
    new ErrorCode("800", "블록체인 API 호출에 실패했습니다.");
public static final ErrorCode ADMIN_WALLET_ALREADY_EXISTS =
    new ErrorCode("801", "해당 네트워크에 ADMIN 지갑이 이미 존재합니다.");
public static final ErrorCode CONTRACT_NOT_FOUND =
    new ErrorCode("802", "해당 네트워크에 활성 컨트랙트가 없습니다.");
public static final ErrorCode RELAYER_NOT_FOUND =
    new ErrorCode("803", "Relayer를 찾을 수 없습니다.");
public static final ErrorCode NONCE_TRACKER_NOT_FOUND =
    new ErrorCode("804", "NonceTracker를 찾을 수 없습니다.");
```

---

## Application 설정 확인

**파일**: `admin-api/src/main/java/.../AdminApiApplication.java`

`@XRestServiceScan` 어노테이션이 클라이언트 패키지를 포함하는지 확인:

```java
@XRestServiceScan("com.cryptoments.adminapi.client")  // ← 확인 필요
```

만약 `@XRestServiceScan`이 없으면 추가. Axim REST Framework가 `@XRestService` 인터페이스를 프록시로 생성하여 Spring Bean으로 등록합니다.

---

## 서비스 ↔ Node.js API 매핑 요약

| admin-api 서비스 | 메서드 | Node.js API | HTTP |
|-----------------|--------|-------------|------|
| WalletManagementService | syncBalances | `POST /api/balance/sync` | blockchain-api |
| WalletManagementService | getOnchainTokenBalance | `GET /api/balance/token/:address` | blockchain-api |
| WalletManagementService | getOnchainNativeBalance | `GET /api/balance/native/:address` | blockchain-api |
| InfraWalletService | createAdminWallet | `POST /api/admin/wallet/create-admin` | blockchain-api |
| InfraWalletService | createFeeWallet | `POST /api/wallet/derive` | blockchain-api |
| NonceTrackerService | syncNonce | (DB 기반, 향후 확장) | — |
| RelayerManagementService | createRelayer | `POST /api/relayer/register` | relayer-api |
| RelayerManagementService | deactivateRelayer | `POST /api/relayer/unregister` | relayer-api |
| RelayerManagementService | getRelayerListFromNodeJs | `GET /api/relayer/list` | relayer-api |
| RelayerManagementService | getRelayerStatus | `GET /api/relayer/status/:walletId` | relayer-api |
| ContractManagementService | registerContract | `POST /api/admin/contract/register` | blockchain-api |
| ContractManagementService | addRelayer | `POST /api/admin/contract/add-relayer` | blockchain-api |
| ContractManagementService | removeRelayer | `POST /api/admin/contract/remove-relayer` | blockchain-api |
| ContractManagementService | pause | `POST /api/admin/contract/pause` | blockchain-api |
| ContractManagementService | unpause | `POST /api/admin/contract/unpause` | blockchain-api |
| ContractManagementService | getStatus | `GET /api/admin/contract/status/:networkId` | blockchain-api |
| ContractManagementService | getList | `GET /api/admin/contract/list` | blockchain-api |
| WalletApprovalService | checkApproveStatus | `GET /api/wallet/approve-status/:address` | blockchain-api |
| DepositManagementService | checkTxStatus | `GET /api/tx/status/:txHash` | blockchain-api |
| WithdrawalManagementService | checkTxStatus | `GET /api/tx/status/:txHash` | blockchain-api |
| (공통 조회용) | getTxReceipt | `GET /api/tx/receipt/:txHash` | blockchain-api |
| (공통 조회용) | getGasPrice | `GET /api/gas/price` | blockchain-api |
| (공통 조회용) | estimateGas | `POST /api/gas/estimate` | blockchain-api |
| (공통 조회용) | getLatestBlock | `GET /api/block/latest` | blockchain-api |

---

## 변경 파일 목록 (예상)

| 유형 | 파일 | 내용 |
|------|------|------|
| 설정 | `application.yml` | Node.js 서버 URL + REST client 타임아웃 |
| **신규** | `client/BlockchainApiClient.java` | @XRestService 인터페이스 |
| **신규** | `client/RelayerApiClient.java` | @XRestService 인터페이스 |
| **신규** | `dto/blockchain/*.java` | ~25개 DTO 클래스 |
| **신규** | `service/ContractManagementService.java` | 컨트랙트 관리 서비스 |
| **신규** | `controller/ContractManagementController.java` | 컨트랙트 관리 API |
| 수정 | `service/WalletManagementService.java` | syncBalances 연동 |
| 수정 | `service/InfraWalletService.java` | ADMIN/FEE 지갑 생성 연동 |
| 수정 | `service/NonceTrackerService.java` | syncNonce 로직 |
| 수정 | `service/RelayerManagementService.java` | register/unregister 연동 |
| 수정 | `service/WalletApprovalService.java` | approve 상태 조회 |
| 수정 | `service/DepositManagementService.java` | TX 상태 조회 (LOW) |
| 수정 | `service/WithdrawalManagementService.java` | TX 상태 조회 (LOW) |
| 수정 | `exception/ErrorCodes.java` | Node.js 연동 에러 코드 추가 |
| 수정 | `AdminApiApplication.java` | @XRestServiceScan 확인 |

---

## 주의사항

1. **온체인 TX API 타임아웃**: `register`, `add-relayer`, `remove-relayer`, `pause`, `unpause` 등은 온체인 TX 대기가 포함되어 응답이 5~60초 걸림. `response-timeout: 60`으로 설정.

2. **deploy → register 변경**: Node.js가 더 이상 컨트랙트를 직접 배포하지 않음. Hardhat/Tronbox로 수동 배포 후 `register` API로 등록하는 구조. 기존 `NODEJS_API_INTEGRATION_GUIDE.md`의 deploy 관련 내용은 별도 업데이트 예정.

3. **DB 동시 접근**: Node.js가 관리하는 테이블(`wallet_addresses`, `wallet_keys`, `relayer_wallets` 등)은 Spring Boot에서 READ만 수행. INSERT/UPDATE는 Node.js API를 통해서만 수행.

4. **BigInt → String**: Node.js 응답의 큰 숫자(잔액, 가스)는 `String`으로 전달됨. Java에서 `BigDecimal`로 변환 시 `new BigDecimal(stringValue)` 사용.

5. **Relayer 이중 등록 경로**: `blockchain-api`의 `add-relayer`와 `relayer-api`의 `register` 두 가지 경로가 있음.
   - `relayer-api/register`: DB INSERT + 온체인 등록 (신규 Relayer 최초 등록 시)
   - `blockchain-api/add-relayer`: 기존 DB 레코드의 Relayer를 컨트랙트에만 추가
   - **권장**: 신규 Relayer는 `relayer-api/register` 사용, 컨트랙트 교체 시에만 `blockchain-api/add-relayer` 사용

6. **approve 재시도**: `wallet_approvals.status`를 `PENDING`으로 UPDATE하면 wallet-activator가 자동 재처리. Spring Boot에서 별도 Node.js API 호출 불필요.

---

## 빌드 검증 체크리스트

- [x] `./gradlew :admin-api:compileJava` 성공
- [x] application.yml에 `axim.web-client.services` Node.js 서버 URL 설정
- [x] BlockchainApiClient (18 methods), RelayerApiClient (5 methods) — `common/client/`
- [x] Node.js 연동 DTO 32개 — `common/client/dto/`
- [x] ContractManagementService (7 methods) + Controller 신규 생성
- [x] WalletManagementService — syncBalances, syncWalletBalance, getTxStatus 연동
- [x] InfraWalletService — createAdminWallet 연동 (FEE/SETTLEMENT는 blockchain-api 확장 대기)
- [x] NonceTrackerService — DB 기반 동기화 (blockchain-api에 nonce 전용 엔드포인트 없음)
- [x] RelayerManagementService — register/unregister/getRelayerListFromApi 연동
- [x] ErrorCodes 900 시리즈 5개 추가
- [x] @XRestServiceScan("com.cryptoments.common.client") 확인
- [x] AuditLogService 리팩토링 — AuditAction enum + Map.of() + ObjectMapper (17/17 서비스)

---

## 구현 결과 요약

### 실제 파일 위치 (가이드 원안과 차이)

| 가이드 원안 위치 | 실제 구현 위치 | 사유 |
|-----------------|---------------|------|
| `admin-api/client/BlockchainApiClient` | `common/client/BlockchainApiClient` | core/partner-api 등 다른 모듈에서도 공유 |
| `admin-api/client/RelayerApiClient` | `common/client/RelayerApiClient` | 동일 |
| `admin-api/dto/blockchain/*.java` | `common/client/dto/*.java` | 동일 |

### S-7, S-10~S-12 결정 사항

| 항목 | 결정 | 사유 |
|------|------|------|
| S-7 NonceTrackerService | DB 기반 유지 | blockchain-api에 nonce 조회 전용 API 없음 |
| S-10 WalletApprovalService | core.WalletService 위임 | Pattern B (DB 폴링) — Spring Boot에서 직접 호출 불필요 |
| S-11 DepositManagementService | WalletManagementController.getTxStatus() 공용 | 별도 TX 조회 메서드 불필요 |
| S-12 WithdrawalManagementService | 동일 | 동일 |

---

## 변경 이력

| 버전 | 날짜 | 변경 내용 |
|------|------|----------|
| v1.0 | 2026-03-16 | 초안 — 12개 작업 항목, 서비스-API 매핑, DTO 정의 |
| v1.1 | 2026-03-16 | 완료 — S-1~S-12 전체 구현, AuditLog 리팩토링, 체크리스트 갱신 |
