# 레시피: 지급 전에 거래 상대를 세 번의 호출로 검증하기

레시피 · 세 가지 결과를 구분해 유지하는 스크리닝 루프: 거부, 허용, 상향 검토 — unknown을 괜찮음으로 처리하지 않음

*API 가이드 · 10분 · 고급 · 개발자 · risk → overview → exposure*

세 번의 호출을 이 순서로 합니다. 판정과 그 쌍이 새로운지 여부를 위해 `from=`에 자신의 주소를 넣은 `/risk`, 등급이 `none`이 아닐 때 사실 확인을 위한 개요, 순회가 중단되어 판정이 `unknown`일 때 어디까지 진행했는지 보기 위한 `/exposure`입니다. 프로그램은 `refuse`, `allow`, `escalate` 세 단어 중 하나로 답하고, 요청 ID와 `computed_at`을 로그에 기록합니다. 하드코딩된 유일한 정책은 스캐너가 고수하는 것입니다. `unknown`은 상향 검토하며, 결코 허용하지 않습니다.

## 결정

| 판정 | 프로그램의 답 | 이유 |
|---|---|---|
| `tier`가 `severe` 또는 `high` | **refuse** | [OFAC](https://achivx.kr/academy/reference/glossary/#g-ofac-sdn) 등재, 이 체인에서의 [발행사 동결](https://achivx.kr/academy/courses/freezes-and-alerts/freezes-and-alerts-01-who-can-freeze-what/), 또는 오염 출발점에 대한 직접 도달 — 디코딩된 사실 |
| `tier`가 `medium` 또는 `low` | **escalate** | 간접 도달 또는 라벨만 있음. 사람이 결정합니다 |
| `tier`가 `none` | **allow** | 충분한 집계 범위에서 확인했고, 아무것도 찾지 못함 |
| `tier`가 `unknown` | `incomplete_reasons`와 함께 **escalate** | 확인할 수 없었음. `time_budget` 이후 프로그램은 15초 기다렸다가 상향 검토 전에 한 번 더 요청합니다 |
| 재시도 후에도 호출 실패 | `unknown`으로 **escalate** | 오류는 빈 응답이 아닙니다 |

`new_counterparty: "new"`는 점수에 반영하지 않고 로그에만 남깁니다. 한 주소로의 첫 지급은 검토자를 위한 맥락입니다.

## curl

```
#!/bin/sh
# vet.sh <chain> <counterparty> <my-address>  → prints refuse | allow | escalate
B=https://stablescan.achivx.com; CHAIN=$1; ADDR=$2; ME=$3
risk=$(curl -sf "$B/v1/addresses/$ADDR/risk?chain=$CHAIN&from=$ME") || { echo "escalate (no verdict)"; exit 2; }
tier=$(echo "$risk" | jq -r .tier); reasons=$(echo "$risk" | jq -c .incomplete_reasons)
if [ "$tier" = unknown ] && echo "$reasons" | grep -q time_budget; then
  sleep 15; risk=$(curl -sf "$B/v1/addresses/$ADDR/risk?chain=$CHAIN&from=$ME") && tier=$(echo "$risk" | jq -r .tier)
fi
echo "$risk" | jq -r '"tier=\(.tier) flags=\(.flags) new=\(.new_counterparty // "n/a") computed_at=\(.computed_at) reasons=\(.incomplete_reasons)"' >&2
case "$tier" in
  severe|high) echo refuse; exit 1 ;;
  none)        echo allow ;;
  *)           echo "escalate ($tier)"; exit 2 ;;
esac
```

## Python

```
import json, sys, time, urllib.error, urllib.parse, urllib.request

BASE = "https://stablescan.achivx.com"

def scan(path, **params):
    url = BASE + path + ("?" + urllib.parse.urlencode(params) if params else "")
    req = urllib.request.Request(url, headers={"Accept": "application/json", "User-Agent": "vet/1.0"})
    with urllib.request.urlopen(req, timeout=30) as r:
        return json.load(r), r.headers.get("X-Request-Id")

def vet(chain, addr, me):
    '''returns ("refuse"|"allow"|"escalate", detail dict)'''
    try:
        risk, rid = scan(f"/v1/addresses/{addr}/risk", chain=chain, **{"from": me})
        if risk["tier"] == "unknown" and "time_budget" in risk["incomplete_reasons"]:
            time.sleep(15)                                    # the cut verdict is reused for ~10 s
            risk, rid = scan(f"/v1/addresses/{addr}/risk", chain=chain, **{"from": me})
    except urllib.error.HTTPError as e:
        err = json.loads(e.read()).get("error", {})
        return "escalate", {"error": err.get("code"), "request_id": err.get("request_id")}
    detail = {"tier": risk["tier"], "flags": risk["flags"], "reasons": risk["incomplete_reasons"],
              "new_counterparty": risk.get("new_counterparty"), "computed_at": risk["computed_at"], "request_id": rid}
    if risk["tier"] in ("severe", "high"):
        facts, _ = scan(f"/v1/addresses/{addr}", chain=chain)      # the facts for the record
        detail["sanctioned"] = facts["sanctioned"]
        detail["frozen"] = [r["token"] for r in facts["freeze"] if r["status"] in ("frozen", "seized")]
        return "refuse", detail
    if risk["tier"] == "none":
        return "allow", detail
    if risk["tier"] == "unknown":
        exp, _ = scan(f"/v1/addresses/{addr}/exposure", chain=chain)   # how far the walk got, for the reviewer
        detail["walk"] = {"exposure": exp["exposure"], "hops_completed": exp["hops_completed"], "truncation_reason": exp.get("truncation_reason")}
    return "escalate", detail

if __name__ == "__main__":
    chain, addr, me = sys.argv[1:4]
    decision, detail = vet(chain, addr, me)
    print(decision); print(json.dumps(detail, indent=1), file=sys.stderr)
    sys.exit({"allow": 0, "refuse": 1, "escalate": 2}[decision])
```

## Go

```
package main

import (
	"encoding/json"
	"fmt"
	"net/http"
	"net/url"
	"os"
	"time"
)

const base = "https://stablescan.achivx.com"

type risk struct {
	Tier              string          `json:"tier"`
	Flags             map[string]bool `json:"flags"`
	IncompleteReasons []string        `json:"incomplete_reasons"`
	NewCounterparty   string          `json:"new_counterparty"`
	ComputedAt        string          `json:"computed_at"`
}

func get(path string, q url.Values, out any) (string, error) {
	req, _ := http.NewRequest(http.MethodGet, base+path+"?"+q.Encode(), nil)
	req.Header.Set("Accept", "application/json")
	res, err := (&http.Client{Timeout: 30 * time.Second}).Do(req)
	if err != nil {
		return "", err
	}
	defer res.Body.Close()
	rid := res.Header.Get("X-Request-Id")
	if res.StatusCode >= 400 {
		var env struct {
			Error struct{ Code, Message, RequestID string } `json:"error"`
		}
		_ = json.NewDecoder(res.Body).Decode(&env)
		return rid, fmt.Errorf("%d %s: %s", res.StatusCode, env.Error.Code, env.Error.Message)
	}
	return rid, json.NewDecoder(res.Body).Decode(out)
}

// vet answers refuse, allow or escalate for one counterparty.
func vet(chain, addr, me string) (string, map[string]any) {
	q := url.Values{"chain": {chain}, "from": {me}}
	var r risk
	rid, err := get("/v1/addresses/"+addr+"/risk", q, &r)
	if err == nil && r.Tier == "unknown" && contains(r.IncompleteReasons, "time_budget") {
		time.Sleep(15 * time.Second)
		rid, err = get("/v1/addresses/"+addr+"/risk", q, &r)
	}
	if err != nil {
		return "escalate", map[string]any{"error": err.Error(), "request_id": rid}
	}
	detail := map[string]any{"tier": r.Tier, "flags": r.Flags, "reasons": r.IncompleteReasons, "new_counterparty": r.NewCounterparty, "computed_at": r.ComputedAt, "request_id": rid}
	switch r.Tier {
	case "severe", "high":
		return "refuse", detail
	case "none":
		return "allow", detail
	}
	if r.Tier == "unknown" {
		var exp struct {
			Exposure         string `json:"exposure"`
			HopsCompleted    int    `json:"hops_completed"`
			TruncationReason string `json:"truncation_reason"`
		}
		if _, err := get("/v1/addresses/"+addr+"/exposure", url.Values{"chain": {chain}}, &exp); err == nil {
			detail["walk"] = exp
		}
	}
	return "escalate", detail
}

func contains(xs []string, s string) bool {
	for _, x := range xs {
		if x == s {
			return true
		}
	}
	return false
}

func main() {
	decision, detail := vet(os.Args[1], os.Args[2], os.Args[3])
	fmt.Println(decision)
	json.NewEncoder(os.Stderr).Encode(detail)
	os.Exit(map[string]int{"allow": 0, "refuse": 1, "escalate": 2}[decision])
}
```

## TypeScript

```
// vet.ts — run with: node --experimental-strip-types vet.ts <chain> <counterparty> <my-address>  (Node 22+), or compile with tsc
const BASE = "https://stablescan.achivx.com";
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

type Risk = { tier: "severe" | "high" | "medium" | "low" | "none" | "unknown"; flags: Record<string, boolean>; incomplete_reasons: string[]; new_counterparty?: string; computed_at: string };
type Exposure = { exposure: string; hops_completed: number; truncation_reason?: string };

async function scan<T>(path: string, params: Record<string, string>): Promise<{ body: T; requestId: string | null }> {
  const res = await fetch(`${BASE}${path}?${new URLSearchParams(params)}`, { headers: { accept: "application/json", "user-agent": "vet/1.0" } });
  const requestId = res.headers.get("x-request-id");
  if (!res.ok) {
    const { error } = (await res.json().catch(() => ({ error: { code: String(res.status), message: res.statusText } }))) as { error: { code: string; message: string } };
    throw new Error(`${res.status} ${error.code}: ${error.message} (request ${requestId})`);
  }
  return { body: (await res.json()) as T, requestId };
}

export async function vet(chain: string, addr: string, me: string): Promise<["refuse" | "allow" | "escalate", Record<string, unknown>]> {
  let risk: Risk, requestId: string | null;
  try {
    ({ body: risk, requestId } = await scan<Risk>(`/v1/addresses/${addr}/risk`, { chain, from: me }));
    if (risk.tier === "unknown" && risk.incomplete_reasons.includes("time_budget")) {
      await sleep(15_000);
      ({ body: risk, requestId } = await scan<Risk>(`/v1/addresses/${addr}/risk`, { chain, from: me }));
    }
  } catch (e) {
    return ["escalate", { error: String(e) }];
  }
  const detail: Record<string, unknown> = { tier: risk.tier, flags: risk.flags, reasons: risk.incomplete_reasons, new_counterparty: risk.new_counterparty, computed_at: risk.computed_at, requestId };
  if (risk.tier === "severe" || risk.tier === "high") return ["refuse", detail];
  if (risk.tier === "none") return ["allow", detail];
  if (risk.tier === "unknown") {
    const { body: exp } = await scan<Exposure>(`/v1/addresses/${addr}/exposure`, { chain });
    detail.walk = { exposure: exp.exposure, hops_completed: exp.hops_completed, truncation_reason: exp.truncation_reason };
  }
  return ["escalate", detail];
}

const [chain, addr, me] = process.argv.slice(2);
const [decision, detail] = await vet(chain, addr, me);
console.log(decision); console.error(JSON.stringify(detail, null, 1));
process.exit({ allow: 0, refuse: 1, escalate: 2 }[decision]);
```

## 예시에 대해 실행하기

2026-10-24에 `vet 1 0x098B716B8Aaf21512996dC57EB0615e2383E2f96 <me>`는 `sanctioned: true`와 [동결된](https://achivx.kr/academy/how-it-works/how-does-a-freeze-happen/) 토큰 세 개와 함께 **refuse**로 답했습니다. `vet 1 0x28C6c06298d514Db089934071355E5743bf21d60 <me>`는 두 번째 시도 후 `tier: "unknown"`, `reasons: ["time_budget"]`, `flags: {"service": true}`와 함께 **escalate**로 답했습니다 — 바이낸스(Binance) 핫월렛은 [순회](https://achivx.kr/academy/reference/glossary/#g-walk)가 거의 완료되지 않는 허브이며, 검토자는 `detail.walk`에서 3[홉](https://achivx.kr/academy/reference/glossary/#g-hop) 중 0홉이 완료되었음을 봅니다. 기록이 없는 새 지갑은 `short_history`와 함께 **escalate**로 답합니다. 어느 것도 프로그램의 버그가 아닙니다. 이것이 세 가지 결과입니다.

## 운영 환경에서 추가할 것

단순한 헬퍼 대신 [오류와 한도](https://achivx.kr/academy/api/errors-and-limits-in-code/)의 재시도 클라이언트를 사용하고, 실행마다 `/v1/sync/status`를 한 번 호출해 오래된 네트워크를 판정 옆에 기록하며, 지급 후에는 `/v1/verify?tx=`를 호출합니다 — [다음 레시피](https://achivx.kr/academy/api/recipe-confirm-a-payment/)입니다.

## Stablecoin scanner ACHIVX에서

- `from=`이 있는 판정: [/risk?chain=1&from=0x28C6…1d60](https://stablescan.achivx.com/v1/addresses/0x098B716B8Aaf21512996dC57EB0615e2383E2f96/risk?chain=1&from=0x28C6c06298d514Db089934071355E5743bf21d60).
- 매뉴얼이 직접 쓴 A2A 기반 루프: [/agents.md](https://stablescan.achivx.com/agents.md) §5.

## 출처

- [에이전트 매뉴얼 — agents.md(버전 2026-10-05)](https://stablescan.achivx.com/agents.md)
- [REST 인터페이스의 OpenAPI 3.1 명세(API 1.4.0)](https://stablescan.achivx.com/openapi.json)

## 더 알아보기

- [사람이 개입하지 않는 일반적인 루프는 어떤 모습입니까?](https://achivx.kr/academy/for-agents/the-typical-loop-with-no-human/)
- [과정: 주소 읽기](https://achivx.kr/academy/courses/reading-an-address/)
- [레슨: 처음부터 끝까지의 스크리닝 루프](https://achivx.kr/academy/courses/the-scanner-for-agents/the-scanner-for-agents-04-the-screening-loop/)

## 관련 질문

- [주소에 대한 위험 판정 하나를 얻으려면 어떻게 하며, 언제 unknown이 됩니까?](https://achivx.kr/academy/api/address-risk/)
- [응답은 어떻게 읽습니까: unknown, null, 빈 목록, no-store](https://achivx.kr/academy/api/reading-an-answer/)
- [레시피: 결제가 도착했고 동결되지 않았는지 확인하기](https://achivx.kr/academy/api/recipe-confirm-a-payment/)

레시피 · risk → overview → exposure · 2026-10-24 작성, openapi.json, agents.md 및 당일 실제 호출 기반. 구조는 인용한 것이며, 수치는 날짜 기준이고 인덱스에 따라 바뀝니다.

---

출처: https://achivx.kr/academy/api/recipe-vet-a-counterparty/ · 영어 원문: https://achivx.com/academy/api/recipe-vet-a-counterparty.html
