0

Tiền đã về ví nhưng đơn hàng chưa nên "paid": 6 bước kiểm tra phía người nhận khi AI agent tạo link thanh toán

Bối cảnh Khi để AI agent tạo hoá đơn hoặc link thanh toán bằng stablecoin, phần khó thường không nằm ở bước tạo link, mà ở câu hỏi: "Đơn này đã thực sự được thanh toán chưa?". Gần đây trên Viblo có bài về x402 nói chuyện "đã settle nhưng không ai tìm thấy". Bài này nói về chiều ngược lại, phía người nhận tiền: tiền đã về ví, nhưng hệ thống vẫn chưa nên đánh dấu đơn là đã trả. Dưới đây là 6 bước kiểm tra chúng tôi rút ra khi xây dựng một API tạo link thanh toán không lưu ký (non-custodial) cho agent, kèm code Python đã chạy thử.

Nguyên tắc nền: agent chỉ chuẩn bị, người trả tiền tự ký Agent không giữ private key. Nó chỉ tạo một yêu cầu thanh toán: số tiền (có thể định giá bằng tiền pháp định), các loại tiền chấp nhận, địa chỉ nhận của chính bạn và mã đơn hàng. Người trả tiền mở link, chọn loại tiền và ký giao dịch trong ví của họ. Phía máy chủ không có khoá và không có code ký, nên API key đưa cho agent không thể di chuyển tiền, kể cả khi bị lộ. Nhưng chính vì agent không "trả tiền", việc xác nhận "đã nhận đủ" phải được làm cẩn thận ở phía người nhận.

1. So khớp địa chỉ hợp đồng token, không so ký hiệu Trên Arbitrum và Polygon có hai token cùng tên USDC: USDC gốc do Circle phát hành và USDC.e (bản bridge cũ). Nếu chỉ kiểm tra chữ "USDC", bạn có thể nhận nhầm tài sản. Hãy giữ một danh sách địa chỉ hợp đồng cố định (allowlist) và so khớp đúng địa chỉ đó. Đừng mặc định mọi tài sản đều có trên mọi chain: ví dụ chúng tôi chỉ hỗ trợ USDT trên Solana và Ethereum.

2. So sánh bằng đơn vị nhỏ nhất, và làm cho số tiền là duy nhất USDC có 6 chữ số thập phân. Hãy so sánh số nguyên ở đơn vị nhỏ nhất (base units), không so chuỗi hiển thị như "25.00". Nếu nhiều hoá đơn cùng mở trên một địa chỉ, hai khoản chờ thanh toán không được có cùng số tiền, nếu không bạn sẽ không biết giao dịch nào thuộc hoá đơn nào. Vì vậy số tiền quy đổi từ tiền pháp định có thể cao hơn vài đơn vị nhỏ nhất so với phép tính thông thường. Tốt nhất là dùng một địa chỉ riêng cho mỗi khoản thanh toán.

3. Thiếu tiền là một trạng thái riêng, không phải lỗi Rút tiền từ sàn thường về thiếu một chút vì sàn trừ phí rút ngay trên số tiền. Tự động xác nhận thì ai cũng có thể trả thiếu. Bỏ qua thì tiền đã nằm trong ví bạn mà hệ thống không biết. Cách chúng tôi làm: giao dịch lệch trong phạm vi 10% so với hoá đơn không tự xác nhận mà chuyển sang trạng thái pending. Người có quyền chấp nhận (lưu lý do) hoặc từ chối (bắt buộc ghi lý do). Hết hạn mà chưa ai quyết định thì thành unresolved, và vẫn có thể chấp nhận sau. Có thể cho phép tự chấp nhận khoản thiếu nhỏ theo phần trăm định trước, nhưng trả thừa thì luôn chờ người quyết định. Hoàn tiền luôn làm thủ công từ ví người nhận.

4. Chọn mức finality cho từng chain và kiểm tra lại ở nguồn thứ hai Trên Solana chỉ tính giao dịch finalized. Trên các chain EVM, hãy chọn số xác nhận cho từng mạng (chúng tôi mặc định 1, riêng Polygon chờ 16). Trước khi đánh dấu đã thanh toán, tra lại giao dịch ở một RPC hoặc explorer thứ hai. Giao dịch bị thay thế hoặc bị reorg phải đưa đơn về trạng thái chờ.

5. Xác thực webhook trên byte thô, có timestamp Nếu dịch vụ báo kết quả qua webhook, hãy kiểm tra chữ ký HMAC-SHA256 trên chuỗi <timestamp>.<delivery id>.<raw body>, dùng byte thô trước khi parse JSON, so sánh trong thời gian hằng số, và từ chối mọi lần gửi cũ hơn 300 giây. Vì timestamp và delivery id cũng được ký, một request bị sao chép không thể bị phát lại.

import hmac, hashlib, re, time

def verify_v2(raw_body: bytes, sig_header: str, delivery_id: str, secret: str) -> bool:
    t = re.search(r"(?:^|,)t=(\d+)", sig_header or "")
    v1 = re.search(r"(?:^|,)v1=([0-9a-f]{64})", sig_header or "")
    if not (t and v1 and delivery_id):
        return False
    if abs(time.time() - int(t.group(1))) > 300:   # quá 5 phút: từ chối
        return False
    signed = t.group(1).encode() + b"." + delivery_id.encode() + b"." + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, v1.group(1))

6. Chống trùng lặp trong cùng một transaction, rồi đối chiếu với đơn hàng Webhook sẽ được gửi lại khi bạn không trả 2xx, với cùng delivery id, và có thể đến không đúng thứ tự. Phía dịch vụ của chúng tôi có loại bỏ bản trùng, nhưng phía nhận vẫn phải tự kiểm tra trùng lặp. Cách an toàn: ghi delivery id trong cùng transaction với việc cập nhật đơn hàng. Nếu xử lý lỗi thì cả hai cùng rollback và lần gửi lại sẽ được xử lý; nếu thành công thì lần gửi lại sẽ vướng khoá chính. Sau đó đối chiếu số tiền, tài sản, mạng và mã đơn với đơn hàng của chính bạn. Trạng thái "confirmed" chỉ là thông tin về blockchain; quyết định giao hàng là của server bạn.

import sqlite3

def handle_confirmed(db: sqlite3.Connection, delivery_id: str, data: dict) -> str:
    try:
        with db:  # delivery id và đơn hàng được commit cùng nhau, hoặc không gì cả
            db.execute("INSERT INTO deliveries (id) VALUES (?)", (delivery_id,))
            row = db.execute(
                "SELECT amount_base_units, currency, network, status FROM orders WHERE id = ?",
                (data["orderId"],)).fetchone()
            if row is None:
                raise ValueError("unknown order")      # rollback: lần gửi lại sẽ được xử lý
            amount, currency, network, status = row
            if (data["amountSats"], data["sentCurrency"], data["network"]) != (amount, currency, network):
                db.execute("UPDATE orders SET status = 'needs_review' WHERE id = ?", (data["orderId"],))
                return "needs_review"
            if status == "awaiting":
                db.execute("UPDATE orders SET status = 'paid' WHERE id = ?", (data["orderId"],))
            return "paid"
    except sqlite3.IntegrityError:
        return "duplicate"

(Trường amountSats chứa đơn vị nhỏ nhất của tài sản, dù tên gọi là lịch sử. Kiểm tra status == 'awaiting' giúp một sự kiện cũ đến sau không ghi đè trạng thái mới.)

Kết Agent chuẩn bị, con người ký, và server của bạn mới là nơi quyết định đơn đã được thanh toán hay chưa. Sáu bước trên không phụ thuộc vào nhà cung cấp nào. Nếu bạn đã gặp trường hợp USDC.e hoặc rút từ sàn bị thiếu tiền, rất mong được nghe cách bạn xử lý.

Minh bạch: bài viết do chính đội phát triển sản phẩm viết (ký tên bên dưới). Chúng tôi làm API tạo link thanh toán non-custodial cho người và AI agent (không bao giờ giữ tiền hay khoá). Tài liệu webhook: https://send21.io/docs/webhooks. Đây là mô tả thiết kế, chưa phải số liệu đo ở quy mô lớn.

- send21


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí