# 레시피: 결제가 도착했고 동결되지 않았는지 확인하기

레시피 · 해시 하나를 찾아지고 확정될 때까지 네트워크의 최신성 범위 안에서 폴링한 뒤, 수신자의 동결 상태 확인

*API 가이드 · 9분 · 고급 · 개발자 · verify → sync → overview*

결제는 `/v1/verify?tx=`가 `found: true`로 응답하고 해당 행의 `finality`가 `final`일 때 확인된 것입니다. 그때까지 프로그램은 네트워크의 `freshness_seconds`가 허용하는 동안 15초마다(이 경로의 `max-age`) 폴링하며, `found: null`을 *아니요*가 아니라 *다시 요청*으로 취급합니다. 찾은 뒤에는 수신자의 개요를 한 번 조회하면, 받은 스테이블코인이 그 체인에서 동결되었는지 알 수 있습니다 — 결제가 도착하고도 잃을 수 있는 경우입니다. 프로그램은 `confirmed`, `pending`, `not-found`, `frozen` 중 하나를, 각각 본 내용과 함께 출력합니다.

## 상태

| `/v1/verify`의 응답 | 프로그램의 동작 |
|---|---|
| `found: true`, 모든 전송이 `finality: "final"` | 수신자의 개요를 조회하고 **confirmed** 또는 **frozen** 출력 |
| `found: true`, 어떤 전송이 `finality: "pending"` | 15초 기다린 뒤 다시 요청 — 행은 인덱싱되었지만 블록이 아직 확정되지 않았습니다 |
| `found: false` | 각 네트워크의 동기화 시점 기준으로 어떤 핫 윈도우에도 없습니다. 해시가 네트워크의 인덱싱된 헤드보다 새로울 수 있는 동안(`freshness_seconds`) 기다린 뒤 **not-found** 출력 |
| `found: null` | 어떤 네트워크가 응답하지 않았습니다. 기다렸다가 다시 요청 — 부정 결과가 아닙니다 |
| 해시가 `hot_window_start`보다 오래됨 | API가 볼 수 없습니다. 그 설명과 함께 **not-found** 출력 |

## curl

```
#!/bin/sh
# confirm.sh <tx-hash>  → confirmed | pending | not-found | frozen
B=https://stablescan.achivx.com; TX=$1; tries=0
while [ $tries -lt 40 ]; do                                   # 40 × 15 s = ten minutes
  v=$(curl -sf "$B/v1/verify?tx=$TX") || { sleep 15; tries=$((tries+1)); continue; }
  found=$(echo "$v" | jq -r .found)
  if [ "$found" = true ]; then
    pending=$(echo "$v" | jq '[.transfers[] | select(.finality != "final")] | length')
    [ "$pending" = 0 ] || { sleep 15; tries=$((tries+1)); continue; }
    chain=$(echo "$v" | jq -r .chain_id); to=$(echo "$v" | jq -r '.transfers[0].recipient'); token=$(echo "$v" | jq -r '.transfers[0].amount.token')
    status=$(curl -sf "$B/v1/addresses/$to?chain=$chain" | jq -r --arg t "$token" '.freeze[] | select(.token == $t) | .status')
    case "$status" in frozen|seized) echo "frozen ($token on chain $chain)"; exit 3 ;; *) echo confirmed; exit 0 ;; esac
  fi
  sleep 15; tries=$((tries+1))                                 # false or null: the hash may be newer than a head
done
echo not-found; exit 1
```

## Python

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

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

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

def confirm(tx, max_wait=600):
    '''returns ("confirmed"|"pending"|"not-found"|"frozen", detail)'''
    deadline = time.time() + max_wait
    while time.time() < deadline:
        v = scan("/v1/verify", tx=tx)
        if v["found"] is True:
            rows = v["transfers"]
            if rows and all(r["finality"] == "final" for r in rows):
                to, token, chain = rows[0]["recipient"], rows[0]["amount"]["token"], v["chain_id"]
                facts = scan(f"/v1/addresses/{to}", chain=chain)
                pair = next((r for r in facts["freeze"] if r["token"] == token), None)
                if pair and pair["status"] in ("frozen", "seized"):
                    return "frozen", {"chain": v["chain_slug"], "token": token, "recipient": to, "since": pair.get("since_time")}
                return "confirmed", {"chain": v["chain_slug"], "block": rows[0]["block_number"], "block_time": rows[0]["block_time"],
                                     "amount": int(rows[0]["amount"]["amount"]) / 10 ** rows[0]["amount"]["decimals"], "token": token,
                                     "freeze_state": pair["status"] if pair else "not freezable"}
            elif v["freeze"] and v["freeze"]["items"] and not rows:
                return "confirmed", {"note": "the hash is an issuer act, not a transfer", "acts": v["freeze"]["items"]}
        elif v["found"] is False:
            # a negative over every hot window as of each network's sync — but the hash may be newer than a head
            max_lag = max((c["sync"].get("freshness_seconds") or 0) for c in v["chains"])
            if time.time() + max_lag > deadline:
                return "not-found", {"as_of": v["as_of"], "hot_window_start": v["hot_window_start"], "max_freshness_seconds": max_lag}
        time.sleep(15)                                  # found: null, pending, or false-but-recent: ask again at max-age
    return "pending", {"note": "still not final after %d s" % max_wait}

if __name__ == "__main__":
    state, detail = confirm(sys.argv[1])
    print(state); print(json.dumps(detail, indent=1), file=sys.stderr)
```

## Go

```
package main

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

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

type transfer struct {
	Recipient   string `json:"recipient"`
	BlockNumber int64  `json:"block_number"`
	Finality    string `json:"finality"`
	Amount      struct {
		Amount   string `json:"amount"`
		Decimals int    `json:"decimals"`
		Token    string `json:"token"`
	} `json:"amount"`
}

type verify struct {
	Found     *bool      `json:"found"`
	ChainID   *int64     `json:"chain_id"`
	ChainSlug string     `json:"chain_slug"`
	Transfers []transfer `json:"transfers"`
	Chains    []struct {
		Sync struct {
			FreshnessSeconds *int `json:"freshness_seconds"`
		} `json:"sync"`
	} `json:"chains"`
}

func get(path string, q url.Values, out any) error {
	res, err := http.Get(base + path + "?" + q.Encode())
	if err != nil {
		return err
	}
	defer res.Body.Close()
	if res.StatusCode >= 400 {
		return fmt.Errorf("%s: HTTP %d", path, res.StatusCode)
	}
	return json.NewDecoder(res.Body).Decode(out)
}

func confirm(tx string, maxWait time.Duration) (string, string) {
	deadline := time.Now().Add(maxWait)
	for time.Now().Before(deadline) {
		var v verify
		if err := get("/v1/verify", url.Values{"tx": {tx}}, &v); err == nil && v.Found != nil {
			if *v.Found {
				final := len(v.Transfers) > 0
				for _, t := range v.Transfers {
					final = final && t.Finality == "final"
				}
				if final {
					t := v.Transfers[0]
					var facts struct {
						Freeze []struct {
							Token, Status string
						} `json:"freeze"`
					}
					_ = get("/v1/addresses/"+t.Recipient, url.Values{"chain": {fmt.Sprint(*v.ChainID)}}, &facts)
					for _, p := range facts.Freeze {
						if p.Token == t.Amount.Token && (p.Status == "frozen" || p.Status == "seized") {
							return "frozen", fmt.Sprintf("%s %s on %s", t.Recipient, p.Status, v.ChainSlug)
						}
					}
					return "confirmed", fmt.Sprintf("%s %s in block %d on %s", t.Amount.Amount, t.Amount.Token, t.BlockNumber, v.ChainSlug)
				}
			} else {
				maxLag := 0
				for _, c := range v.Chains {
					if c.Sync.FreshnessSeconds != nil && *c.Sync.FreshnessSeconds > maxLag {
						maxLag = *c.Sync.FreshnessSeconds
					}
				}
				if time.Now().Add(time.Duration(maxLag) * time.Second).After(deadline) {
					return "not-found", "not in any hot window; the newest head is " + fmt.Sprint(maxLag) + " s behind"
				}
			}
		}
		time.Sleep(15 * time.Second) // null, pending or recent false: ask again at the route's max-age
	}
	return "pending", "not final within " + maxWait.String()
}

func main() {
	state, detail := confirm(os.Args[1], 10*time.Minute)
	fmt.Println(state)
	fmt.Fprintln(os.Stderr, detail)
}
```

## TypeScript

```
const BASE = "https://stablescan.achivx.com";
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

type Transfer = { recipient: string; block_number: number; block_time: string; finality: "final" | "pending"; amount: { amount: string; decimals: number; token: string } };
type Verify = { found: boolean | null; chain_id: number | null; chain_slug?: string; transfers: Transfer[]; freeze: { items: unknown[] } | null; chains: { sync: { freshness_seconds: number | null } }[]; as_of: string; hot_window_start: string };
type Overview = { freeze: { token: string; status: string; since_time?: string }[] };

async function scan<T>(path: string, params: Record<string, string>): Promise<T> {
  const res = await fetch(`${BASE}${path}?${new URLSearchParams(params)}`, { headers: { accept: "application/json" } });
  if (!res.ok) throw new Error(`${path}: HTTP ${res.status}`);
  return (await res.json()) as T;
}

export async function confirm(tx: string, maxWaitMs = 600_000): Promise<[string, Record<string, unknown>]> {
  const deadline = Date.now() + maxWaitMs;
  while (Date.now() < deadline) {
    const v = await scan<Verify>("/v1/verify", { tx });
    if (v.found === true) {
      const rows = v.transfers;
      if (rows.length && rows.every((r) => r.finality === "final")) {
        const [t] = rows;
        const facts = await scan<Overview>(`/v1/addresses/${t.recipient}`, { chain: String(v.chain_id) });
        const pair = facts.freeze.find((r) => r.token === t.amount.token);
        if (pair && (pair.status === "frozen" || pair.status === "seized")) return ["frozen", { chain: v.chain_slug, token: t.amount.token, recipient: t.recipient, since: pair.since_time }];
        return ["confirmed", { chain: v.chain_slug, block: t.block_number, block_time: t.block_time, amount: Number(BigInt(t.amount.amount)) / 10 ** t.amount.decimals, token: t.amount.token }];
      }
      if (!rows.length && v.freeze?.items.length) return ["confirmed", { note: "the hash is an issuer act, not a transfer", acts: v.freeze.items }];
    } else if (v.found === false) {
      const maxLag = Math.max(0, ...v.chains.map((c) => c.sync.freshness_seconds ?? 0));
      if (Date.now() + maxLag * 1000 > deadline) return ["not-found", { as_of: v.as_of, hot_window_start: v.hot_window_start, max_freshness_seconds: maxLag }];
    }
    await sleep(15_000);          // null, pending, or a false that may still be newer than a head
  }
  return ["pending", { note: `not final within ${maxWaitMs / 1000} s` }];
}

const [state, detail] = await confirm(process.argv[2]);
console.log(state); console.error(JSON.stringify(detail, null, 1));
```

## 예시에 대해 실행하기

2026-10-24에 `confirm 0x2b3252e52889309dcf91d8b24034d28a01d247e6229189e05e16409a1eb12ac3`은 이더리움(Ethereum) 블록 26261992에서 USDC 결제를 10:49 UTC에 `finality: "pending"` 상태로 찾았습니다 — 블록은 네트워크의 확정된 헤드보다 73 뒤에 있었습니다. 프로그램은 행이 `final`이 될 때까지 폴링을 계속한 뒤, 수신자의 개요(`USDC: clear`)를 조회하고 **confirmed**를 출력했습니다. 0으로 이루어진 해시는 최신성 범위가 지난 뒤 **not-found**를 출력했습니다. 전날 한 주소를 동결한 기록인 `0x00c3…1410`은 발행사 기록이라는 설명과 함께 **confirmed**를 출력했습니다. 거래이긴 하지만 결제는 아닙니다.

## 프로그램이 알려 줄 수 없는 것

- `hot_window_start`보다 오래된 전송(그날 아침 EVM 네트워크에서는 약 이틀)은 보관 처리되어 있습니다. 그 경우 `found: false`는 *일어나지 않음*이 아니라 *보이지 않음*입니다. 과거 기록은 사이트의 [거래 페이지](https://achivx.kr/academy/guides/transaction-page/)를 여십시오.
- 솔라나(Solana) 전송이 [동결](https://achivx.kr/academy/how-it-works/how-does-a-freeze-happen/)되었는지 여부: 솔라나 [발행사 기록](https://achivx.kr/academy/guides/issuer-freezes/)은 수집되지 않으며, 개요의 솔라나 쌍은 `unknown`으로 응답합니다.
- 결제가 이 수신자에게 *의도된* 것이었는지 여부. 스캐너는 체인을 볼 뿐, 청구서를 보지 않습니다.

## Stablecoin scanner ACHIVX에서

- 레시피를 실행한 결제: [/v1/verify?tx=0x2b32…2ac3](https://stablescan.achivx.com/v1/verify?tx=0x2b3252e52889309dcf91d8b24034d28a01d247e6229189e05e16409a1eb12ac3). 네트워크의 최신성: [/v1/sync/status?chain=1](https://stablescan.achivx.com/v1/sync/status?chain=1).
- 같은 거래를 페이지로: [/ethereum/tx/0x2b32…2ac3](https://stablescan.achivx.com/ethereum/tx/0x2b3252e52889309dcf91d8b24034d28a01d247e6229189e05e16409a1eb12ac3).

## 출처

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

## 더 알아보기

- [가이드: 거래 페이지](https://achivx.kr/academy/guides/transaction-page/)
- [USDT가 도착하지 않았습니다 — 어디에 있습니까?](https://achivx.kr/academy/how-it-works/my-usdt-has-not-arrived/)
- [과정: 스테이블코인 따라가기](https://achivx.kr/academy/courses/following-the-stablecoins/)

## 관련 질문

- [해시로 거래를 찾으려면 어떻게 합니까: /v1/verify 또는 /v1/transfers?tx=?](https://achivx.kr/academy/api/find-a-transaction/)
- [지금 네트워크의 데이터는 얼마나 최신입니까?](https://achivx.kr/academy/api/sync-status/)
- [주소가 제재 대상인지, 동결되었는지, 알려진 서비스인지 확인하려면 어떻게 합니까?](https://achivx.kr/academy/api/address-overview/)

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

---

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