# 프로그래밍 방식 회원가입: Agent 및 CI를 위한 지갑 로그인과 API Key 생성

> Source: https://docs.blockvectra.com/ko/guides/programmatic-signup/

브라우저 없이 실행되는 자율 AI 에이전트, CI 파이프라인 및 자동화 스크립트를 위해 BlockVectra는 이더리움 지갑 서명(EIP-4361 / EIP-191) 기반의 프로그래밍 방식 로그인 및 계정 개설 워크플로를 제공합니다.

> **Key security**
>
> 개인키, 세션 토큰 또는 API key를 AI와의 대화에 붙여넣거나 MCP 툴 인수로 전달하지 마세요.


가입하기 전에 먼저 키가 필요 없는 공개 엔드포인트 `https://api.blockvectra.com/v1/robinhood_mainnet/public`을 사용해 볼 수 있습니다(지갑용 JSON-RPC 메서드 전용, Data API는 키 필요, 메서드 및 한도는 `/v1/chains` 기준). 할당량이 부족한 경우 계정에 가입하세요.

## 워크플로 개요

프로그래밍 방식 등록 및 키 발급 흐름은 다음 4단계로 구성됩니다:

1. **챌린지 요청**: `POST /auth/siwe/challenge`로 요청을 전송하여 서버가 생성한 로그인 메시지를 받습니다.
2. **메시지 서명**: EIP-191(`personal_sign`)을 사용하여 이더리움 EOA 지갑으로 해당 메시지에 정확히 서명합니다.
3. **로그인 / 계정 개설**: 원본 메시지와 서명을 `POST /auth/siwe/login`에 제출합니다. 지갑의 첫 로그인 시 계정이 자동으로 생성됩니다(`account_created: true`). 신규 계정 가입 시 30,000,000 CU 무료 제공 — 신용카드 불필요.
4. **API key 생성**: 세션 토큰을 사용하여 `POST /keys`를 호출하고 API key를 생성합니다.

## 실행 가능한 전체 예제

여기서 시작하세요: 로컬 이더리움 EOA 서명기를 사용해 키를 생성하고 eth\_blockNumber로 검증합니다.
Bash 예제의 경우 curl, jq 및 Foundry cast가 필요합니다. 지갑 자격 증명은 로컬 서명 환경에 보관하세요.

전체 스타터 템플릿: [blockvectra/agent-quickstart](https://github.com/blockvectra/agent-quickstart)

다음 스크립트는 지갑 자격 증명을 읽고, 챌린지 및 로그인 과정을 완료하며, API key를 발급하고, 환경 변수 설정을 위해 `export BLOCKVECTRA_API_KEY=...`를 내보내거나 출력한 뒤 검증용 `eth_blockNumber` 요청을 보냅니다:

새 키가 활성화되는 데 몇 초 정도 걸릴 수 있으며, 아래 예제는 자동으로 재시도합니다.

**Bash**

```bash
BASE=https://console-api.blockvectra.com/v1
# $ADDR: Ethereum wallet address (0x...)
# $PK: wallet private key, loaded from a secrets manager (never hardcode in scripts)

# 1. Fetch server-generated SIWE message (omit Origin header)
curl -s "$BASE/auth/siwe/challenge" -H 'Content-Type: application/json' \
  -d "{\"address\":\"$ADDR\",\"purpose\":\"login\"}" > challenge.json
jq -r .message challenge.json > msg.txt

# 2. Sign the exact message with EIP-191 personal_sign
SIG=$(cast wallet sign --private-key "$PK" "$(cat msg.txt)")

# 3. Submit verbatim message and signature (omit Origin header) to log in (ref is optional)
jq -n --rawfile m msg.txt --arg s "$SIG" '{message: ($m | rtrimstr("\n")), signature: $s, ref: "docs-signup"}' |
  curl -s "$BASE/auth/siwe/login" -H 'Content-Type: application/json' -d @- > session.json
TOKEN=$(jq -r .session.token session.json)

# 4. Create an API key (the secret is returned only once)
KEY_RESP=$(curl -s "$BASE/keys" \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"label":"agent-key"}')
export BLOCKVECTRA_API_KEY=$(echo "$KEY_RESP" | jq -r .api_key)

# 5. Call JSON-RPC with the key in the x-api-key request header
RPC_DEADLINE=$((SECONDS + 10))
while true; do
  RPC_TIMEOUT=$((RPC_DEADLINE - SECONDS))
  if ((RPC_TIMEOUT <= 0)); then
    printf '%s' "${RPC_BODY:-}"
    break
  fi
  RPC_RESP=$(curl -s --max-time "$RPC_TIMEOUT" -w '\n%{http_code}' "https://api.blockvectra.com/v1/robinhood_mainnet" \
    -H "x-api-key: $BLOCKVECTRA_API_KEY" \
    -H 'Content-Type: application/json' \
    -d '{"jsonrpc":"2.0","id":1,"method":"eth_blockNumber","params":[]}') || { rc=$?; echo "request failed (curl exit $rc)" >&2; exit $rc; }
  RPC_STATUS=${RPC_RESP##*$'\n'}
  RPC_BODY=${RPC_RESP%$'\n'*}
  if ((SECONDS + 2 < RPC_DEADLINE)) &&
    printf '%s' "$RPC_BODY" | jq -e --arg status "$RPC_STATUS" '
      ($status == "401" and .error.data.reason == "invalid_api_key") or
      ($status == "503" and .error.code == -32021)
    ' >/dev/null 2>&1; then
    sleep 2
  else
    printf '%s' "$RPC_BODY"
    break
  fi
done
```


  **TypeScript**

```bash
npm i viem
```

```ts
// Requires ESM (top-level await; run with node --input-type=module or tsx)
import { privateKeyToAccount } from "viem/accounts";

const BASE = "https://console-api.blockvectra.com/v1";
const privateKey = process.env.PRIVATE_KEY as `0x${string}`;
const account = privateKeyToAccount(privateKey);

// 1. Fetch server-generated SIWE message (omit Origin header)
const challengeRes = await fetch(`${BASE}/auth/siwe/challenge`, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ address: account.address, purpose: "login" }),
});
if (!challengeRes.ok) throw new Error(`Challenge failed: ${challengeRes.status}`);
const { message } = (await challengeRes.json()) as { message: string };

// 2. Sign the exact message with EIP-191 personal_sign
const signature = await account.signMessage({ message });

// 3. Submit verbatim message and signature (omit Origin header) to log in (ref is optional)
const loginRes = await fetch(`${BASE}/auth/siwe/login`, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ message, signature, ref: "docs-signup" }),
});
if (!loginRes.ok) throw new Error(`Login failed: ${loginRes.status}`);
const { session } = (await loginRes.json()) as { session: { token: string } };

// 4. Create an API key (the secret is returned only once)
const keyRes = await fetch(`${BASE}/keys`, {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    Authorization: `Bearer ${session.token}`,
  },
  body: JSON.stringify({ label: "agent-key" }),
});
if (!keyRes.ok) throw new Error(`Create key failed: ${keyRes.status}`);
const { api_key } = (await keyRes.json()) as { api_key: string };
console.log("Created API key:", api_key);
console.log(`export BLOCKVECTRA_API_KEY=${api_key}`);

// 5. Call JSON-RPC with the key in the x-api-key request header
const rpcDeadline = performance.now() + 10_000;
while (true) {
  const rpcRes = await fetch("https://api.blockvectra.com/v1/robinhood_mainnet", {
    method: "POST",
    signal: AbortSignal.timeout(Math.max(1, Math.ceil(rpcDeadline - performance.now()))),
    headers: {
      "Content-Type": "application/json",
      "x-api-key": api_key,
    },
    body: JSON.stringify({
      jsonrpc: "2.0",
      id: 1,
      method: "eth_blockNumber",
      params: [],
    }),
  });
  const rpcBody = await rpcRes.json();
  const retryable =
    (rpcRes.status === 401 && rpcBody.error?.data?.reason === "invalid_api_key") ||
    (rpcRes.status === 503 && rpcBody.error?.code === -32021);
  if (retryable && performance.now() + 2_000 < rpcDeadline) {
    await new Promise((resolve) => setTimeout(resolve, 2_000));
    continue;
  }
  if (!rpcRes.ok) throw new Error(`RPC call failed: ${rpcRes.status}`);
  console.log("Block number response:", rpcBody);
  break;
}
```


  **Python**

```bash
pip install eth-account requests
```

```python
import os
import time
import requests
from eth_account import Account
from eth_account.messages import encode_defunct

BASE = "https://console-api.blockvectra.com/v1"
private_key = os.environ["PRIVATE_KEY"]
account = Account.from_key(private_key)
address = account.address

# 1. Fetch server-generated SIWE message (omit Origin header)
challenge_resp = requests.post(
    f"{BASE}/auth/siwe/challenge",
    json={"address": address, "purpose": "login"},
)
challenge_resp.raise_for_status()
message = challenge_resp.json()["message"]

# 2. Sign the exact message with EIP-191 personal_sign
signable = encode_defunct(text=message)
signed = Account.sign_message(signable, private_key=private_key)
signature = "0x" + bytes(signed.signature).hex()

# 3. Submit verbatim message and signature (omit Origin header) to log in (ref is optional)
login_resp = requests.post(
    f"{BASE}/auth/siwe/login",
    json={"message": message, "signature": signature, "ref": "docs-signup"},
)
login_resp.raise_for_status()
token = login_resp.json()["session"]["token"]

# 4. Create an API key (the secret is returned only once)
key_resp = requests.post(
    f"{BASE}/keys",
    headers={"Authorization": f"Bearer {token}"},
    json={"label": "agent-key"},
)
key_resp.raise_for_status()
api_key = key_resp.json()["api_key"]
print("Created API key:", api_key)
print(f"export BLOCKVECTRA_API_KEY={api_key}")

# 5. Call JSON-RPC with the key in the x-api-key request header
rpc_deadline = time.monotonic() + 10
while True:
    rpc_resp = requests.post(
        "https://api.blockvectra.com/v1/robinhood_mainnet",
        headers={"x-api-key": api_key, "Content-Type": "application/json"},
        json={"jsonrpc": "2.0", "id": 1, "method": "eth_blockNumber", "params": []},
        timeout=max(0.001, rpc_deadline - time.monotonic()),
    )
    rpc_data = rpc_resp.json()
    error = rpc_data.get("error") or {}
    retryable = (
        rpc_resp.status_code == 401
        and (error.get("data") or {}).get("reason") == "invalid_api_key"
    ) or (rpc_resp.status_code == 503 and error.get("code") == -32021)
    if retryable and time.monotonic() + 2 < rpc_deadline:
        time.sleep(2)
        continue
    rpc_resp.raise_for_status()
    print("Block number response:", rpc_data)
    break
```


## 기본 URL 및 프로그래밍 모드

모든 인증 및 키 관리 엔드포인트는 공식 기본 URL을 사용합니다:

```
https://console-api.blockvectra.com/v1
```

### Origin 헤더 생략

프로그래밍 방식 요청은 **프로그래밍 모드**로 작동합니다:

* 챌린지(`POST /auth/siwe/challenge`) 및 로그인(`POST /auth/siwe/login`) 요청 모두 **`Origin` 헤더를 포함해서는 안 됩니다**(`curl` 및 표준 HTTP 클라이언트는 이 헤더를 기본적으로 생략하므로 수동으로 추가하지 마세요).
* `Origin` 헤더를 전송했지만 구성된 웹 콘솔 도메인이 아닌 경우(빈 문자열 또는 `null` 포함), 챌린지 요청은 HTTP 400 `invalid_request`를 반환합니다.
* 로그인 시 모드가 챌린지 모드와 일치하지 않는 경우(예: `Origin` 없이 프로그래밍 방식 챌린지를 요청한 후 `Origin` 헤더를 포함하여 로그인을 제출하거나 그 반대의 경우), 로그인 요청은 `reason: domain_mismatch`와 함께 HTTP 400 `siwe_invalid`를 반환합니다.

### 메시지 무결성 및 지갑 요구사항

* **메시지 원본 서명 및 제출**: 클라이언트는 챌린지 엔드포인트가 반환한 메시지 텍스트를 있는 그대로 정확히 서명하고 제출해야 합니다. 공백, 도메인, 체인 ID 또는 기타 필드를 변경하지 마세요. 어떠한 수정이라도 가해지면 `reason: signature`와 함께 HTTP 400 `siwe_invalid`가 발생합니다.
* **지원되는 지갑**: Ethereum 메인넷(Chain ID 1) EOA(Externally Owned Accounts). 서명은 65바이트 ECDSA 서명(`personal_sign`)이어야 합니다. 컨트랙트 지갑(EIP-1271) 및 스마트 계정은 지원되지 않습니다.
* **챌린지 유효 기간**: 각 챌린지 nonce는 일회용이며 5분 후 만료됩니다.

### 요청 본문 및 가입 귀속 (선택 사항)

`POST /auth/siwe/login` 요청 본문은 필수 인증 파라미터와 선택 사항인 가입 귀속(attribution) 필드를 받습니다:

* **필수 필드**:
  * `message`: 챌린지 엔드포인트에서 획득한 완전한 SIWE 메시지 문자열.
  * `signature`: 이더리움 지갑으로 EIP-191을 통해 `message`에 서명하여 생성된 65바이트 16진수 서명(`0x` 접두사 포함).
* **선택 귀속 필드** (새 계정이 생성될 때 한 번만 저장되며, 이후 로그인에서는 무시됨):
  * `ref`: `^[a-z0-9._-]{1,64}$` 정규식과 일치하는 소문자 채널 토큰(소문자 ASCII 영문, 숫자, `.`, `_`, `-`, 1\~64자). 예를 들어 자율 에이전트는 프레임워크나 런타임 식별자(예: `my-agent.v1`)로 설정할 수 있습니다. 규격을 준수하지 않는 값(대문자, 빈 문자열, 길이 초과 또는 지원되지 않는 문자 포함)은 대소문자 변환 없이 HTTP 400 `invalid_request`를 반환하며 계정 생성을 차단합니다. 해당 사항이 없으면 생략하거나 `null`을 전달하세요.
  * `referrer`: 유입 출처 URL 또는 호스트 이름 문자열. 문자열이 아닌 타입만 HTTP 400을 반환합니다.

`signup_method`와 같이 정의되지 않은 필드를 전송하면 HTTP 400 `invalid_request`가 반환됩니다.

## 세션 토큰 및 API Key

### 세션 토큰 수명 주기

* **형식**: `rgs_` 뒤에 64자의 소문자 16진수 문자가 붙습니다.
* **유효 기간**: 절대 수명 7일, 24시간 동안 유휴 상태일 경우 자동 만료.
* **리프레시 토큰 없음**: 세션 토큰이 만료되면 새로운 챌린지 및 로그인 흐름을 시작해야 합니다.
* **헤더**: `Authorization: Bearer rgs_...` 요청 헤더에 세션 토큰을 전달합니다.

### API Key 생성

* 세션 토큰과 함께 `POST /keys`를 호출하여 API key를 생성합니다(`rgw_` 뒤에 64자의 16진수 문자가 붙음).
* 계정당 해지되지 않고 만료되지 않은 키(`active` + `disabled`)는 최대 20개까지 가능하며, 만료된 키는 계산에 포함되지 않습니다. 이를 초과하면 `reason: active_keys` 및 `limit: 20`과 함께 HTTP 409 `key_limit_reached`가 반환되므로 기존 키를 먼저 해지해야 합니다. 이 상한은 계정의 모든 ID, 세션 및 체인 전체에 적용됩니다. 키 생성 및 순환도 24시간당 20개로 제한되며, 초과 시 `Retry-After: 3600`과 함께 HTTP 429 `rate_limited`가 반환됩니다.
* 선택적 상한 및 만료: `cu_cap`(키의 누적 수명 CU 상한, 소프트 캡)과 만료 시간(`expires_in_secs` 또는 `expires_at`, 키 정책에서 허용하는 최대 일수까지)을 지정할 수 있습니다. 만료되거나 상한이 소진되면 서버는 403(JSON-RPC `-32025`, reason `key_expired` 또는 `key_cap_exhausted`)을 반환합니다.
* 보안 secret인 `api_key`는 **생성 시 단 한 번만 반환됩니다**. 즉시 시크릿 관리자나 환경 변수에 안전하게 저장하세요.
* 하나의 API key로 JSON-RPC 및 Data API가 지원하는 모든 체인에서 사용할 수 있습니다.

## 세션이나 API Key를 분실했나요?

BlockVectra에서 **에이전트의 계정 식별자는 가입 시 사용한 이더리움 지갑 주소에 바인딩됩니다**. 세션 토큰이 만료되었거나 API key를 분실 또는 유출한 경우, 해당 지갑만을 사용하여 완전한 제어권을 복구할 수 있습니다:

1. **동일한 지갑으로 재인증**: 챌린지를 요청하고, 동일한 지갑으로 서명한 뒤, 로그인 요청(`POST /auth/siwe/login`)을 제출합니다. 서버가 서명을 검증하고, `account_created: false`로 기존 계정에 로그인하며 새 세션 토큰을 발급합니다.
2. **새 API Key 생성**: 새 세션 토큰을 사용하여 `{"label": "..."}` 및 `Authorization: Bearer <token>` 헤더와 함께 `POST /keys`를 호출합니다. 엔드포인트는 `key`에 생성된 키 세부 정보와 `api_key`에 일회용 시크릿을 담아 HTTP 201을 반환합니다. 즉시 이 키를 환경 변수나 시크릿 관리자에 저장하세요.
3. **계정의 모든 키 목록 조회**:
   * 엔드포인트: `GET /keys`
   * 헤더: `Authorization: Bearer <token>`
   * 쿼리 파라미터: 선택적 `include_revoked=true`(`true`인 경우 해지된 키 포함, 기본값은 활성/비활성화 키만 반환).
   * 응답: `{"items": [...]}` JSON과 함께 HTTP 200. `items` 배열의 각 요소에는 다음이 포함됩니다:
     * `key_id`: 고유 키 식별자 (string)
     * `label`: 키 라벨 (string 또는 `null`)
     * `status`: 상태 (`"active"`, `"disabled"`, 또는 `"revoked"`)
     * `created_at`: 생성 타임스탬프 (ISO 8601 문자열)
     * `revoked_at`: 해지 타임스탬프 (문자열, 해지되지 않은 경우 `null`)
4. **미사용 또는 유출된 키 해지**:
   * 엔드포인트: `POST /keys/{key_id}/revoke` (참고: 경로에 대상 `key_id`를 포함하는 `POST` 사용, 빈 요청 본문)
   * 헤더: `Authorization: Bearer <token>`
   * 동작: 멱등성 보장. `active` 또는 `disabled` 상태의 키 모두 해지 가능합니다. 이미 해지된 경우 변경 없이 HTTP 200을 반환합니다. 해지된 후 해당 키를 사용하는 요청은 거부됩니다.
   * 응답: 해지된 키 객체를 반환하는 HTTP 200(필드는 위의 키 객체와 일치하며 `status: "revoked"` 및 `revoked_at` 타임스탬프 포함).

> **Key and secret security**
>
> 지갑 개인키와 API key는 환경 변수나 시크릿 관리자에 보관하세요. 코드 저장소에 커밋하거나, 로그에 기록하거나, AI 채팅 대화에 붙여넣지 마세요.


## 보안 권장사항

* **단기 키 사용 및 작업 완료 후 해지**: 자동화되거나 일시적인 작업의 경우 `expires_in_secs`로 수명이 짧은 키를 생성하고, 작업이 완료되면 `POST /keys/{key_id}/revoke`를 통해 즉시 해지하세요.

## 가입 속도 제한 (`signup_rate_limited`)

계정 생성에는 가입 속도 제한이 적용됩니다. IP별 토큰 버킷 용량은 100개 계정이며, IPv4 주소 또는 IPv6 /64 프리픽스당 시간당 100개 계정 비율로 채워집니다. 이는 SIWE와 OAuth 가입이 공유합니다:

* 가입 제한을 초과하면 `POST /auth/siwe/login`은 대기할 시간(초)을 나타내는 `Retry-After` 헤더와 함께 HTTP 429 `signup_rate_limited`를 반환합니다.
* `reason` 필드는 제한 범위를 구분합니다:
  * `per_ip`: 요청한 IP 프리픽스의 등록 예산이 소진되었습니다.
  * `global`: 플랫폼 전체 통합 가입 한도가 소진되었습니다.
* 가입 속도 제한은 신규 계정 등록 시에만 평가됩니다. 기존 계정의 로그인은 가입 속도 제한으로 차단되지 않습니다.

## 관련 리소스

* 키 없는 MCP 서버 및 기계 판독 가능한 컨텍스트 파일에 대해 알아보려면 [AI 에이전트 연동 가이드](https://docs.blockvectra.com/en/guides/ai-agents/)를 확인하세요.
* 다국어 클라이언트 예제는 [빠른 시작](https://docs.blockvectra.com/en/quickstart/)을 참조하세요.
* 전체 오류 코드, 원인 및 자동 복구 조치는 [오류 레퍼런스](https://docs.blockvectra.com/en/errors/)를 검토하세요.

## 다음 단계

* `x-api-key: $BLOCKVECTRA_API_KEY`를 포함하여 첫 번째 JSON-RPC 또는 Data API 호출을 전송하세요.
* `GET /v1/account`를 사용하여 [계정 잔액 및 한도 확인](https://docs.blockvectra.com/en/guides/ai-agents/#query-balance-get-v1account)을 진행하세요.
* 잔액 유지를 위해 [Agent 프로그래밍 방식 충전 가이드](https://docs.blockvectra.com/en/guides/agent-topup/)를 따르세요.
