@
支付提交增加 Tx 预校验:提交前链上验签收款地址与金额,防止转错地址;409 错误展示友好中文提示并自动对账恢复
- 新增 validate_intent_tx 方法及 POST /api/payments/intents/{id}/validate 端点,
在提交前查链上 receipt 对比收款地址和金额,mismatch 直接拦截
- 新增 handleSubmit409 辅助函数,根据后端错误详情分流处理:
已支付→自动 reconcile,已过期→提示重下单,其他→透传具体原因
- submit/validate 路由透传后端 detail 字段,生产环境也能看到具体错误
- 手动转账面板粘贴 tx hash 后自动触发验证,绿色/红色提示,
验证不通过时禁用提交按钮
Tested: tsc --noEmit + ruff check . 均通过
@
This commit is contained in:
@@ -37,8 +37,14 @@ export async function POST(
|
|||||||
);
|
);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const raw = await res.text();
|
const raw = await res.text();
|
||||||
|
let detail = raw.slice(0, 350);
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (parsed.detail) detail = String(parsed.detail).slice(0, 350);
|
||||||
|
} catch {}
|
||||||
const response = buildUpstreamErrorResponse(res.status, raw, {
|
const response = buildUpstreamErrorResponse(res.status, raw, {
|
||||||
detailLimit: 350,
|
detailLimit: 350,
|
||||||
|
error: detail || undefined,
|
||||||
});
|
});
|
||||||
return applyAuthResponseCookies(response, auth.response);
|
return applyAuthResponseCookies(response, auth.response);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import {
|
||||||
|
applyAuthResponseCookies,
|
||||||
|
buildBackendRequestHeaders,
|
||||||
|
} from "@/lib/backend-auth";
|
||||||
|
import {
|
||||||
|
buildProxyExceptionResponse,
|
||||||
|
buildUpstreamErrorResponse,
|
||||||
|
} from "@/lib/api-proxy";
|
||||||
|
|
||||||
|
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
req: NextRequest,
|
||||||
|
context: { params: Promise<{ intentId: string }> },
|
||||||
|
) {
|
||||||
|
if (!API_BASE) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const { intentId } = await context.params;
|
||||||
|
try {
|
||||||
|
const body = await req.json();
|
||||||
|
const auth = await buildBackendRequestHeaders(req);
|
||||||
|
const proxiedHeaders = new Headers(auth.headers);
|
||||||
|
proxiedHeaders.set("Content-Type", "application/json");
|
||||||
|
const res = await fetch(
|
||||||
|
`${API_BASE}/api/payments/intents/${encodeURIComponent(intentId)}/validate`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: proxiedHeaders,
|
||||||
|
body: JSON.stringify(body ?? {}),
|
||||||
|
cache: "no-store",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
const raw = await res.text();
|
||||||
|
let detail = raw.slice(0, 350);
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (parsed.detail) detail = String(parsed.detail).slice(0, 350);
|
||||||
|
} catch {}
|
||||||
|
const response = buildUpstreamErrorResponse(res.status, raw, {
|
||||||
|
detailLimit: 350,
|
||||||
|
error: detail || undefined,
|
||||||
|
});
|
||||||
|
return applyAuthResponseCookies(response, auth.response);
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
const response = NextResponse.json(data);
|
||||||
|
return applyAuthResponseCookies(response, auth.response);
|
||||||
|
} catch (error) {
|
||||||
|
return buildProxyExceptionResponse(error, {
|
||||||
|
publicMessage: "Failed to validate payment tx",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -945,6 +945,14 @@ export function AccountCenter() {
|
|||||||
CreatedIntent["direct_payment"] | null
|
CreatedIntent["direct_payment"] | null
|
||||||
>(null);
|
>(null);
|
||||||
const [manualTxHash, setManualTxHash] = useState("");
|
const [manualTxHash, setManualTxHash] = useState("");
|
||||||
|
const [txValidation, setTxValidation] = useState<{
|
||||||
|
loading: boolean;
|
||||||
|
checked: boolean;
|
||||||
|
valid?: boolean;
|
||||||
|
reason?: string;
|
||||||
|
detail?: string;
|
||||||
|
checks?: Record<string, unknown>;
|
||||||
|
}>({ loading: false, checked: false });
|
||||||
const [paymentMethodTab, setPaymentMethodTab] = useState<"wallet" | "manual">("wallet");
|
const [paymentMethodTab, setPaymentMethodTab] = useState<"wallet" | "manual">("wallet");
|
||||||
const [lastPaymentStartedAt, setLastPaymentStartedAt] = useState(0);
|
const [lastPaymentStartedAt, setLastPaymentStartedAt] = useState(0);
|
||||||
const [showSecondarySections, setShowSecondarySections] = useState(false);
|
const [showSecondarySections, setShowSecondarySections] = useState(false);
|
||||||
@@ -1520,6 +1528,67 @@ export function AccountCenter() {
|
|||||||
reconcileBusy,
|
reconcileBusy,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const handleSubmit409 = useCallback(
|
||||||
|
async (intentId: string, txHashNorm: string, raw: string) => {
|
||||||
|
const lowerRaw = raw.toLowerCase();
|
||||||
|
// If intent was already confirmed (maybe by confirm loop), reconcile
|
||||||
|
if (
|
||||||
|
lowerRaw.includes("已支付") ||
|
||||||
|
lowerRaw.includes("already confirmed") ||
|
||||||
|
lowerRaw.includes("already paid")
|
||||||
|
) {
|
||||||
|
const ok = await reconcileLatestPayment();
|
||||||
|
if (ok) return;
|
||||||
|
setPaymentInfo("该订单已支付,正在恢复订阅...");
|
||||||
|
await loadSnapshot();
|
||||||
|
await loadPaymentSnapshot();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// If intent expired, tell user to create a new order
|
||||||
|
if (lowerRaw.includes("expired")) {
|
||||||
|
throw new Error("支付订单已过期(30分钟有效),请重新创建订单。");
|
||||||
|
}
|
||||||
|
// Try fetching intent status as fallback
|
||||||
|
try {
|
||||||
|
const headers = await buildAuthedHeaders(true, false);
|
||||||
|
const intentRes = await fetch(`/api/payments/intents/${intentId}`, {
|
||||||
|
headers,
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
if (intentRes.ok) {
|
||||||
|
const intentJson = (await intentRes.json()) as {
|
||||||
|
intent?: { status?: string; tx_hash?: string };
|
||||||
|
};
|
||||||
|
const status = intentJson.intent?.status;
|
||||||
|
if (status === "confirmed") {
|
||||||
|
await reconcileLatestPayment();
|
||||||
|
const txHash =
|
||||||
|
intentJson.intent?.tx_hash || txHashNorm;
|
||||||
|
setPaymentInfo(
|
||||||
|
`支付已确认,交易: ${shortAddress(txHash)}`,
|
||||||
|
);
|
||||||
|
setPaymentError("");
|
||||||
|
await loadSnapshot();
|
||||||
|
await loadPaymentSnapshot();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (status === "expired") {
|
||||||
|
throw new Error("支付订单已过期(30分钟有效),请重新创建订单。");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Error && e.message !== raw) throw e;
|
||||||
|
}
|
||||||
|
throw new Error(`submit tx failed: ${raw}`);
|
||||||
|
},
|
||||||
|
[
|
||||||
|
buildAuthedHeaders,
|
||||||
|
loadPaymentSnapshot,
|
||||||
|
loadSnapshot,
|
||||||
|
reconcileLatestPayment,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!authIsAuthenticated) return;
|
if (!authIsAuthenticated) return;
|
||||||
if (backend?.subscription_active) return;
|
if (backend?.subscription_active) return;
|
||||||
@@ -2535,6 +2604,10 @@ export function AccountCenter() {
|
|||||||
);
|
);
|
||||||
if (!submitRes.ok) {
|
if (!submitRes.ok) {
|
||||||
const raw = (await submitRes.text()).slice(0, 350);
|
const raw = (await submitRes.text()).slice(0, 350);
|
||||||
|
if (submitRes.status === 409) {
|
||||||
|
await handleSubmit409(intentId, txHashNorm, raw);
|
||||||
|
return;
|
||||||
|
}
|
||||||
throw new Error(`submit tx failed: ${raw}`);
|
throw new Error(`submit tx failed: ${raw}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2707,6 +2780,10 @@ export function AccountCenter() {
|
|||||||
);
|
);
|
||||||
if (!submitRes.ok) {
|
if (!submitRes.ok) {
|
||||||
const raw = (await submitRes.text()).slice(0, 350);
|
const raw = (await submitRes.text()).slice(0, 350);
|
||||||
|
if (submitRes.status === 409) {
|
||||||
|
await handleSubmit409(intentId, txHashNorm, raw);
|
||||||
|
return;
|
||||||
|
}
|
||||||
throw new Error(`submit tx failed: ${raw}`);
|
throw new Error(`submit tx failed: ${raw}`);
|
||||||
}
|
}
|
||||||
const confirmRes = await fetch(
|
const confirmRes = await fetch(
|
||||||
@@ -2738,6 +2815,7 @@ export function AccountCenter() {
|
|||||||
setPaymentInfo(`支付确认成功,交易: ${shortAddress(txHashNorm)}`);
|
setPaymentInfo(`支付确认成功,交易: ${shortAddress(txHashNorm)}`);
|
||||||
setManualPayment(null);
|
setManualPayment(null);
|
||||||
setManualTxHash("");
|
setManualTxHash("");
|
||||||
|
setTxValidation({ loading: false, checked: false });
|
||||||
trackAppEvent("checkout_succeeded", {
|
trackAppEvent("checkout_succeeded", {
|
||||||
entry: "account_center_manual_transfer",
|
entry: "account_center_manual_transfer",
|
||||||
plan_code: selectedPlan?.plan_code || "pro_monthly",
|
plan_code: selectedPlan?.plan_code || "pro_monthly",
|
||||||
@@ -2753,6 +2831,42 @@ export function AccountCenter() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const validateTxHash = useCallback(
|
||||||
|
async (intentId: string, hash: string) => {
|
||||||
|
const hashNorm = String(hash || "").trim().toLowerCase();
|
||||||
|
if (!hashNorm.startsWith("0x") || hashNorm.length !== 66) {
|
||||||
|
setTxValidation({ loading: false, checked: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTxValidation({ loading: true, checked: false });
|
||||||
|
try {
|
||||||
|
const headers = await buildAuthedHeaders(true, false);
|
||||||
|
const res = await fetch(`/api/payments/intents/${intentId}/validate`, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({ tx_hash: hashNorm }),
|
||||||
|
});
|
||||||
|
const json = (await res.json()) as {
|
||||||
|
valid?: boolean;
|
||||||
|
reason?: string;
|
||||||
|
detail?: string;
|
||||||
|
checks?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
setTxValidation({
|
||||||
|
loading: false,
|
||||||
|
checked: true,
|
||||||
|
valid: Boolean(json.valid),
|
||||||
|
reason: json.reason,
|
||||||
|
detail: json.detail,
|
||||||
|
checks: json.checks,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
setTxValidation({ loading: false, checked: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[buildAuthedHeaders],
|
||||||
|
);
|
||||||
|
|
||||||
const handleOverlayCheckout = async () => {
|
const handleOverlayCheckout = async () => {
|
||||||
if (!paymentHostAllowed) {
|
if (!paymentHostAllowed) {
|
||||||
setPaymentError(
|
setPaymentError(
|
||||||
@@ -3512,17 +3626,53 @@ export function AccountCenter() {
|
|||||||
</p>
|
</p>
|
||||||
<input
|
<input
|
||||||
value={manualTxHash}
|
value={manualTxHash}
|
||||||
onChange={(event) =>
|
onChange={(event) => {
|
||||||
setManualTxHash(event.target.value)
|
setManualTxHash(event.target.value);
|
||||||
}
|
void validateTxHash(
|
||||||
|
manualPayment.intent_id ||
|
||||||
|
lastIntentId ||
|
||||||
|
"",
|
||||||
|
event.target.value,
|
||||||
|
);
|
||||||
|
}}
|
||||||
placeholder="0x..."
|
placeholder="0x..."
|
||||||
className="mt-1 w-full rounded-xl border border-white/10 bg-black/30 px-3 py-2 font-mono text-xs text-slate-100 outline-none focus:border-emerald-400/50"
|
className="mt-1 w-full rounded-xl border border-white/10 bg-black/30 px-3 py-2 font-mono text-xs text-slate-100 outline-none focus:border-emerald-400/50"
|
||||||
/>
|
/>
|
||||||
|
{txValidation.loading ? (
|
||||||
|
<p className="mt-1 text-[10px] text-slate-500">
|
||||||
|
验证中...
|
||||||
|
</p>
|
||||||
|
) : txValidation.checked && txValidation.valid ? (
|
||||||
|
<p className="mt-1 text-[10px] text-emerald-400">
|
||||||
|
收款地址和金额匹配
|
||||||
|
</p>
|
||||||
|
) : txValidation.checked &&
|
||||||
|
txValidation.valid === false ? (
|
||||||
|
<p className="mt-1 text-[10px] text-red-400">
|
||||||
|
{txValidation.reason ===
|
||||||
|
"tx_not_mined"
|
||||||
|
? "交易未上链,请等待"
|
||||||
|
: txValidation.reason === "receiver_mismatch"
|
||||||
|
? "收款地址不匹配!请检查是否转到了正确的地址"
|
||||||
|
: txValidation.reason ===
|
||||||
|
"amount_insufficient"
|
||||||
|
? "转账金额不足"
|
||||||
|
: txValidation.reason === "tx_reverted"
|
||||||
|
? "该交易已回滚"
|
||||||
|
: txValidation.detail ||
|
||||||
|
"验证失败: " +
|
||||||
|
(txValidation.reason ||
|
||||||
|
"未知错误")}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void submitManualPaymentTx()}
|
onClick={() => void submitManualPaymentTx()}
|
||||||
disabled={paymentBusy}
|
disabled={
|
||||||
|
paymentBusy ||
|
||||||
|
(txValidation.checked && !txValidation.valid)
|
||||||
|
}
|
||||||
className="w-full rounded-xl bg-emerald-600 px-3 py-2 text-xs font-bold text-white transition-all hover:bg-emerald-500 disabled:opacity-50"
|
className="w-full rounded-xl bg-emerald-600 px-3 py-2 text-xs font-bold text-white transition-all hover:bg-emerald-500 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{copy.paymentManualSubmit}
|
{copy.paymentManualSubmit}
|
||||||
|
|||||||
@@ -1666,6 +1666,168 @@ class PaymentContractCheckoutService:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
def validate_intent_tx(
|
||||||
|
self,
|
||||||
|
user_id: str,
|
||||||
|
intent_id: str,
|
||||||
|
tx_hash: str,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Pre-check a tx hash against an intent before submission.
|
||||||
|
|
||||||
|
Returns a validation report with ``valid`` and per-field checks.
|
||||||
|
Does NOT mutate any database state.
|
||||||
|
"""
|
||||||
|
self._ensure_enabled()
|
||||||
|
intent = self.get_intent(user_id, intent_id)
|
||||||
|
tx_hash_text = str(tx_hash or "").strip().lower()
|
||||||
|
if not (tx_hash_text.startswith("0x") and len(tx_hash_text) == 66):
|
||||||
|
return {
|
||||||
|
"valid": False,
|
||||||
|
"reason": "invalid_tx_hash_format",
|
||||||
|
"checks": {"tx_hash_format": False},
|
||||||
|
}
|
||||||
|
if intent.status not in {"created", "submitted"}:
|
||||||
|
return {
|
||||||
|
"valid": False,
|
||||||
|
"reason": f"intent status is {intent.status}, cannot validate",
|
||||||
|
"checks": {"intent_status": intent.status},
|
||||||
|
}
|
||||||
|
now = _now_utc()
|
||||||
|
try:
|
||||||
|
expires_at = datetime.fromisoformat(intent.expires_at)
|
||||||
|
except Exception:
|
||||||
|
expires_at = now - timedelta(seconds=1)
|
||||||
|
if expires_at <= now:
|
||||||
|
return {
|
||||||
|
"valid": False,
|
||||||
|
"reason": "payment intent expired",
|
||||||
|
"checks": {"intent_expired": True},
|
||||||
|
}
|
||||||
|
|
||||||
|
w3 = self._get_web3()
|
||||||
|
try:
|
||||||
|
receipt = w3.eth.get_transaction_receipt(tx_hash_text)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
w3 = self._get_web3(force_refresh=True)
|
||||||
|
receipt = w3.eth.get_transaction_receipt(tx_hash_text)
|
||||||
|
except Exception:
|
||||||
|
receipt = None
|
||||||
|
|
||||||
|
if receipt is None:
|
||||||
|
return {
|
||||||
|
"valid": False,
|
||||||
|
"reason": "tx_not_mined",
|
||||||
|
"checks": {"tx_mined": False},
|
||||||
|
}
|
||||||
|
if int(receipt.get("status") or 0) != 1:
|
||||||
|
return {
|
||||||
|
"valid": False,
|
||||||
|
"reason": "tx_reverted",
|
||||||
|
"checks": {"tx_mined": True, "tx_status": "reverted"},
|
||||||
|
}
|
||||||
|
|
||||||
|
tx_to = _normalize_address(receipt.get("to") or "")
|
||||||
|
is_direct = intent.payment_mode == "direct"
|
||||||
|
|
||||||
|
checks: Dict[str, Any] = {
|
||||||
|
"tx_mined": True,
|
||||||
|
"tx_status": "success",
|
||||||
|
"tx_to": tx_to,
|
||||||
|
"block_number": int(receipt.get("blockNumber") or 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
if is_direct:
|
||||||
|
event_match = self._extract_direct_transfer_event(receipt, intent)
|
||||||
|
if not event_match:
|
||||||
|
return {
|
||||||
|
"valid": False,
|
||||||
|
"reason": "direct_transfer_not_found",
|
||||||
|
"detail": "ERC20 Transfer event not found on token contract. "
|
||||||
|
"Ensure you transferred the correct token to the receiver address.",
|
||||||
|
"checks": checks,
|
||||||
|
}
|
||||||
|
event_from = _normalize_address(event_match.get("from"))
|
||||||
|
event_to = _normalize_address(event_match.get("to"))
|
||||||
|
event_amount = int(event_match.get("amount_units") or 0)
|
||||||
|
expected_receiver = intent.receiver_address
|
||||||
|
expected_amount = int(intent.amount_units)
|
||||||
|
|
||||||
|
receiver_match = event_to == expected_receiver
|
||||||
|
amount_match = event_amount >= expected_amount
|
||||||
|
|
||||||
|
checks["event"] = "Transfer"
|
||||||
|
checks["event_from"] = event_from
|
||||||
|
checks["event_to"] = event_to
|
||||||
|
checks["event_amount"] = str(event_amount)
|
||||||
|
checks["expected_receiver"] = expected_receiver
|
||||||
|
checks["expected_amount"] = str(expected_amount)
|
||||||
|
checks["receiver_match"] = receiver_match
|
||||||
|
checks["amount_match"] = amount_match
|
||||||
|
|
||||||
|
if not receiver_match:
|
||||||
|
return {
|
||||||
|
"valid": False,
|
||||||
|
"reason": "receiver_mismatch",
|
||||||
|
"detail": f"Transfer went to {event_to}, expected {expected_receiver}",
|
||||||
|
"checks": checks,
|
||||||
|
}
|
||||||
|
if not amount_match:
|
||||||
|
return {
|
||||||
|
"valid": False,
|
||||||
|
"reason": "amount_insufficient",
|
||||||
|
"detail": f"Transfer amount {event_amount} is less than expected {expected_amount}",
|
||||||
|
"checks": checks,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
event_match = self._extract_matching_event(receipt, intent)
|
||||||
|
if not event_match:
|
||||||
|
return {
|
||||||
|
"valid": False,
|
||||||
|
"reason": "order_paid_event_not_found",
|
||||||
|
"detail": "OrderPaid event not found. "
|
||||||
|
"Ensure the tx was sent to the correct receiver contract.",
|
||||||
|
"checks": checks,
|
||||||
|
}
|
||||||
|
event_payer = _normalize_address(event_match.get("payer"))
|
||||||
|
event_order_id = str(event_match.get("order_id_hex") or "")
|
||||||
|
event_plan_id = int(event_match.get("plan_id") or 0)
|
||||||
|
event_amount = int(event_match.get("amount_units") or 0)
|
||||||
|
event_token = _normalize_address(event_match.get("token_address") or "")
|
||||||
|
|
||||||
|
order_match = event_order_id == intent.order_id_hex.lower()
|
||||||
|
plan_match = event_plan_id == int(intent.plan_id)
|
||||||
|
token_match = event_token == intent.token_address
|
||||||
|
amount_match = event_amount == int(intent.amount_units)
|
||||||
|
|
||||||
|
checks["event"] = "OrderPaid"
|
||||||
|
checks["event_payer"] = event_payer
|
||||||
|
checks["order_id_match"] = order_match
|
||||||
|
checks["plan_id_match"] = plan_match
|
||||||
|
checks["token_match"] = token_match
|
||||||
|
checks["amount_match"] = amount_match
|
||||||
|
checks["event_amount"] = str(event_amount)
|
||||||
|
checks["expected_amount"] = str(intent.amount_units)
|
||||||
|
|
||||||
|
if not all([order_match, plan_match, token_match, amount_match]):
|
||||||
|
failures = []
|
||||||
|
if not order_match:
|
||||||
|
failures.append(f"order_id mismatch: got {event_order_id}, expected {intent.order_id_hex.lower()}")
|
||||||
|
if not plan_match:
|
||||||
|
failures.append(f"plan_id mismatch: got {event_plan_id}, expected {intent.plan_id}")
|
||||||
|
if not token_match:
|
||||||
|
failures.append(f"token mismatch: got {event_token}, expected {intent.token_address}")
|
||||||
|
if not amount_match:
|
||||||
|
failures.append(f"amount mismatch: got {event_amount}, expected {intent.amount_units}")
|
||||||
|
return {
|
||||||
|
"valid": False,
|
||||||
|
"reason": "event_mismatch",
|
||||||
|
"detail": "; ".join(failures),
|
||||||
|
"checks": checks,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {"valid": True, "checks": checks}
|
||||||
|
|
||||||
def submit_intent_tx(
|
def submit_intent_tx(
|
||||||
self,
|
self,
|
||||||
user_id: str,
|
user_id: str,
|
||||||
|
|||||||
@@ -376,6 +376,10 @@ class SubmitPaymentTxRequest(BaseModel):
|
|||||||
from_address: Optional[str] = None
|
from_address: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ValidatePaymentTxRequest(BaseModel):
|
||||||
|
tx_hash: str = Field(..., min_length=10)
|
||||||
|
|
||||||
|
|
||||||
class ConfirmPaymentTxRequest(BaseModel):
|
class ConfirmPaymentTxRequest(BaseModel):
|
||||||
tx_hash: Optional[str] = None
|
tx_hash: Optional[str] = None
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from web.core import (
|
|||||||
ConfirmPaymentTxRequest,
|
ConfirmPaymentTxRequest,
|
||||||
CreatePaymentIntentRequest,
|
CreatePaymentIntentRequest,
|
||||||
SubmitPaymentTxRequest,
|
SubmitPaymentTxRequest,
|
||||||
|
ValidatePaymentTxRequest,
|
||||||
WalletChallengeRequest,
|
WalletChallengeRequest,
|
||||||
WalletUnbindRequest,
|
WalletUnbindRequest,
|
||||||
WalletVerifyRequest,
|
WalletVerifyRequest,
|
||||||
@@ -21,6 +22,7 @@ from web.services.payment_api import (
|
|||||||
reconcile_latest_payment,
|
reconcile_latest_payment,
|
||||||
submit_payment_tx as submit_payment_tx_service,
|
submit_payment_tx as submit_payment_tx_service,
|
||||||
unbind_payment_wallet,
|
unbind_payment_wallet,
|
||||||
|
validate_payment_tx,
|
||||||
verify_payment_wallet,
|
verify_payment_wallet,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -67,6 +69,15 @@ async def payment_get_intent(request: Request, intent_id: str):
|
|||||||
return get_payment_intent(request, intent_id)
|
return get_payment_intent(request, intent_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/payments/intents/{intent_id}/validate")
|
||||||
|
async def payment_validate_tx(
|
||||||
|
request: Request,
|
||||||
|
intent_id: str,
|
||||||
|
body: ValidatePaymentTxRequest,
|
||||||
|
):
|
||||||
|
return validate_payment_tx(request, intent_id, body)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/payments/intents/{intent_id}/submit")
|
@router.post("/api/payments/intents/{intent_id}/submit")
|
||||||
async def payment_submit_tx(
|
async def payment_submit_tx(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from web.core import (
|
|||||||
ConfirmPaymentTxRequest,
|
ConfirmPaymentTxRequest,
|
||||||
CreatePaymentIntentRequest,
|
CreatePaymentIntentRequest,
|
||||||
SubmitPaymentTxRequest,
|
SubmitPaymentTxRequest,
|
||||||
|
ValidatePaymentTxRequest,
|
||||||
WalletChallengeRequest,
|
WalletChallengeRequest,
|
||||||
WalletUnbindRequest,
|
WalletUnbindRequest,
|
||||||
WalletVerifyRequest,
|
WalletVerifyRequest,
|
||||||
@@ -155,6 +156,22 @@ def submit_payment_tx(
|
|||||||
_raise_payment_error(exc)
|
_raise_payment_error(exc)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_payment_tx(
|
||||||
|
request: Request,
|
||||||
|
intent_id: str,
|
||||||
|
body: ValidatePaymentTxRequest,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
identity = _require_payment_identity(request)
|
||||||
|
try:
|
||||||
|
return legacy_routes.PAYMENT_CHECKOUT.validate_intent_tx(
|
||||||
|
user_id=identity["user_id"],
|
||||||
|
intent_id=intent_id,
|
||||||
|
tx_hash=body.tx_hash,
|
||||||
|
)
|
||||||
|
except legacy_routes.PaymentCheckoutError as exc:
|
||||||
|
_raise_payment_error(exc)
|
||||||
|
|
||||||
|
|
||||||
def confirm_payment_tx(
|
def confirm_payment_tx(
|
||||||
request: Request,
|
request: Request,
|
||||||
intent_id: str,
|
intent_id: str,
|
||||||
|
|||||||
Reference in New Issue
Block a user