# Relayer 지갑 1-Step 생성 지침서

**대상**: VS Code (relayer-api) + IntelliJ (admin-api, common)
**우선순위**: Phase 0-3 필수
**날짜**: 2026-03-19

---

## 설계 변경

현재 relayer-api `POST /api/relayer/register`는 **이미 존재하는 `walletAddressId`**를 요구.
이를 `hdWalletId`를 받아 **HD 파생 → 지갑 생성 → DB 등록 → 온체인 addRelayer까지 1-step**으로 변경.

```
변경 전 (2-step, 비실용적):
  1. 별도 API로 SYSTEM 지갑 생성 → addressId 획득
  2. relayer/register에 addressId 전달

변경 후 (1-step):
  POST /api/relayer/register { networkId, hdWalletId, relayerRole }
    → HD 파생 → wallet_addresses(SYSTEM) + wallet_keys INSERT
    → relayer_wallets INSERT
    → 온체인 addRelayer() TX
    → 완료
```

---

## Node.js 변경: relayer-api

### 파일: `node-service/packages/relayer-api/src/routes/relayer.ts`

#### 1. import 추가 (상단)

기존 import 블록에 추가:

```typescript
import {
  // ... 기존 import에 추가
  hdDerivation,
  hdWalletRepo,
  walletIndexManagerRepo,
} from '@cryptoments/common';
```

#### 2. RegisterBody 인터페이스 변경

```typescript
// 변경 전
interface RegisterBody {
  networkId: number;
  walletAddressId: number;
  relayerRole: RelayerRole;
}

// 변경 후
interface RegisterBody {
  networkId: number;
  hdWalletId: number;
  relayerRole: RelayerRole;
}
```

#### 3. `POST /register` 핸들러 전체 교체

```typescript
router.post('/register', async (req, res) => {
  try {
    const { networkId, hdWalletId, relayerRole } = req.body as RegisterBody;

    if (!networkId || !hdWalletId || !relayerRole) {
      return res.status(400).json({ error: 'networkId, hdWalletId, relayerRole required' });
    }

    // 1. 해당 네트워크의 ACTIVE 컨트랙트 확인
    const relayerContract = await relayerContractRepo.findActiveByNetwork(networkId);
    if (!relayerContract) {
      return res.status(400).json({ error: `No active CryptoRelayer contract for networkId=${networkId}` });
    }

    // ── 2. HD 파생으로 SYSTEM 지갑 생성 ──

    const hdWallet = await hdWalletRepo.findById(hdWalletId);
    if (!hdWallet) {
      return res.status(404).json({ error: 'HD wallet not found' });
    }

    const derivationIndex = await walletIndexManagerRepo.getNextIndex(hdWalletId, networkId);
    const seedHex = await keyManager.decrypt(hdWallet.master_seed_encrypted);
    const seed = Buffer.from(seedHex, 'hex');
    const chainSymbol = await hdDerivation.getChainSymbol(networkId);
    const derived = hdDerivation.derive(seed, hdWallet.derivation_base_path, chainSymbol, derivationIndex);

    // wallet_addresses INSERT (SYSTEM)
    const walletAddressId = await walletAddressRepo.insert({
      address: derived.address,
      network_id: networkId,
      hd_wallet_id: hdWalletId,
      derivation_index: derivationIndex,
      derivation_path: derived.derivationPath,
      wallet_type: 'SYSTEM',
      partner_id: null,
    });

    // wallet_keys INSERT
    const encryptedKey = await keyManager.encrypt(derived.privateKey);
    await walletKeyRepo.insert({
      wallet_address_id: walletAddressId,
      encrypted_private_key: encryptedKey,
    });

    logger.info('SYSTEM wallet created for relayer', {
      walletAddressId,
      address: derived.address,
    });

    // ── 3. relayer_wallets INSERT ──

    const relayerId = await relayerWalletRepo.insert({
      network_id: networkId,
      wallet_address_id: walletAddressId,
      relayer_role: relayerRole,
      registration_status: 'REGISTERING',
    });

    logger.info('Relayer registration started', {
      relayerId,
      address: derived.address,
      role: relayerRole,
      contractAddress: relayerContract.contract_address,
    });

    // ── 4. 온체인 addRelayer() TX ──

    const encryptedOwnerKey = await walletKeyRepo.findByAddressId(relayerContract.owner_address_id);
    if (!encryptedOwnerKey) {
      await relayerWalletRepo.updateRegistrationStatus(relayerId, 'REVOKED');
      await relayerWalletRepo.updateOperationStatus(relayerId, 'PAUSED');
      return res.status(500).json({ error: 'Contract owner key not found' });
    }

    const ownerPrivateKey = await keyManager.decrypt(encryptedOwnerKey);
    const provider = chainProviderFactory.get(networkId);
    const networkType = await getNetworkType(networkId);

    let txHash: string;

    try {
      if (networkType === 'TVM') {
        const { TronWeb } = await import('tronweb');
        const tronWeb = new TronWeb({ fullHost: provider.getRpcUrl(), privateKey: ownerPrivateKey });
        const tronContract = await tronWeb.contract(CRYPTO_RELAYER_ABI_JSON as any, relayerContract.contract_address);
        txHash = await tronContract.addRelayer(derived.address).send({ feeLimit: 100_000_000 });
      } else {
        const jsonRpcProvider = new ethers.JsonRpcProvider(provider.getRpcUrl());
        const ownerSigner = new ethers.Wallet(ownerPrivateKey, jsonRpcProvider);
        const contract = new ethers.Contract(relayerContract.contract_address, CRYPTO_RELAYER_ABI, ownerSigner);
        const tx = await contract.addRelayer(derived.address);
        await tx.wait();
        txHash = tx.hash;
      }
    } catch (txError) {
      await relayerWalletRepo.updateRegistrationStatus(relayerId, 'REVOKED');
      await relayerWalletRepo.updateOperationStatus(relayerId, 'PAUSED');
      return res.status(500).json({
        error: 'addRelayer TX failed',
        relayerId,
        walletAddressId,
        address: derived.address,
        detail: (txError as Error).message,
      });
    }

    // 5. 등록 완료
    await relayerWalletRepo.updateRegistrationStatus(relayerId, 'REGISTERED');

    logger.info('Relayer registered on-chain', { relayerId, address: derived.address, txHash });

    res.json({
      relayerId,
      walletAddressId,
      address: derived.address,
      derivationPath: derived.derivationPath,
      relayerRole,
      status: 'ACTIVE',
      registrationStatus: 'REGISTERED',
      txHash,
      contractAddress: relayerContract.contract_address,
    });
  } catch (error) {
    logger.error('Relayer register error', error as Error);
    res.status(500).json({ error: 'Internal Server Error' });
  }
});
```

---

## Spring Boot 변경

### 1. `RelayerRegisterRequest.java` — `walletAddressId` → `hdWalletId`

**경로**: `common/src/main/java/com/cryptoments/common/client/dto/RelayerRegisterRequest.java`

```java
// 변경 전
/** wallet_addresses.id (Relayer EOA) */
private Integer walletAddressId;

// 변경 후
/** HD 지갑 마스터 ID */
private Integer hdWalletId;
```

### 2. `RelayerCreateRequest.java` — `walletAddressId` → `hdWalletId`

**경로**: `admin-api/src/main/java/com/cryptoments/adminapi/dto/request/RelayerCreateRequest.java`

```java
// 변경 전
/** wallet_addresses.id (Relayer EOA 주소) */
@NotNull
private Long walletAddressId;

// 변경 후
/** HD 지갑 마스터 ID */
@NotNull
private Long hdWalletId;
```

### 3. `RelayerManagementService.java` — 필드명 변경

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

```java
// 변경 전 (80~88행)
response = relayerApiClient.registerRelayer(
        RelayerRegisterRequest.builder()
                .networkId(request.getNetworkId().intValue())
                .walletAddressId(request.getWalletAddressId().intValue())
                .relayerRole(request.getRelayerRole())
                .build());

// 변경 후
response = relayerApiClient.registerRelayer(
        RelayerRegisterRequest.builder()
                .networkId(request.getNetworkId().intValue())
                .hdWalletId(request.getHdWalletId().intValue())
                .relayerRole(request.getRelayerRole())
                .build());
```

audit log도 `walletAddressId` → `hdWalletId` 변경:
```java
// 변경 전
Map.of("networkId", request.getNetworkId(),
        "walletAddressId", request.getWalletAddressId(), ...)

// 변경 후
Map.of("networkId", request.getNetworkId(),
        "hdWalletId", request.getHdWalletId(), ...)
```

### 4. `RelayerRegisterResponse.java` — 응답 필드 확인

relayer-api가 이제 더 많은 정보를 반환하므로, 필요한 필드 추가:

```java
/** 생성된 wallet_addresses.id */
private Integer walletAddressId;

/** 생성된 지갑 주소 */
private String address;

/** HD 파생 경로 */
private String derivationPath;

/** 컨트랙트 주소 */
private String contractAddress;
```

---

## 빌드 & 테스트

### Node.js

```bash
cd node-service
pnpm build
# relayer-api 재시작

# 직접 테스트 (BSC COLLECTION Relayer 생성)
curl -s -X POST http://localhost:3002/api/relayer/register \
  -H "Content-Type: application/json" \
  -d '{"networkId": 2, "hdWalletId": 1, "relayerRole": "COLLECTION"}'
```

### Spring Boot

```bash
./gradlew :common:compileJava :admin-api:compileJava
# admin-api 재시작

TOKEN=<login-token>
curl -s -X POST http://localhost:8080/api/admin/relayer-wallets \
  -H "Content-Type: application/json" \
  -H "Access-Token: $TOKEN" \
  -d '{"networkId": 2, "hdWalletId": 1, "relayerRole": "COLLECTION"}'
```

### DB 확인

```sql
-- SYSTEM 지갑 생성 확인
SELECT id, address, network_id, wallet_type, derivation_path
FROM wallet_addresses WHERE wallet_type = 'SYSTEM';

-- relayer_wallets 등록 확인
SELECT rw.id, wa.address, rw.network_id, rw.relayer_role,
       rw.registration_status, rw.status, rw.contract_address
FROM relayer_wallets rw
JOIN wallet_addresses wa ON rw.wallet_address_id = wa.id;
```

### 전체 Phase 0-3 실행 순서

```
1. 컨트랙트 등록
   POST /api/admin/contracts/register
   { networkId: 2, contractAddress: "0x...", deployTxHash: "0x...", ownerAddressId: 1 }

2. GAS 지갑 생성
   POST /api/admin/infra-wallets/gas
   { networkId: 2, hdWalletId: 1 }

3. GAS 지갑에 native coin 충전 (수동)

4. Relayer 생성 (COLLECTION)
   POST /api/admin/relayer-wallets
   { networkId: 2, hdWalletId: 1, relayerRole: "COLLECTION" }
   → HD 파생 + DB + 온체인 addRelayer 한번에 완료!

5. Relayer 생성 (WITHDRAWAL)
   POST /api/admin/relayer-wallets
   { networkId: 2, hdWalletId: 1, relayerRole: "WITHDRAWAL" }

6. Relayer 지갑에 native coin 충전 (수동)

7. 네트워크별 반복 (Polygon, TRON)
```
