diff --git a/apps/web/src/app/propose/success/page.tsx b/apps/web/src/app/propose/success/page.tsx index 26d0beb..eb55a0d 100644 --- a/apps/web/src/app/propose/success/page.tsx +++ b/apps/web/src/app/propose/success/page.tsx @@ -166,6 +166,12 @@ export default async function ProposalSuccessPage({ searchParams }: { searchPara ) : null} + {receiptStatus === "auto_verified" ? ( +
+ 已在 Base 鏈上自動驗證 USDC 轉帳,paid conversion 已入帳。 +
+ ) : null} + {receiptStatus === "already_verified" ? (
此 wallet receipt 已經確認過,請勿重複轉帳。 diff --git a/apps/web/src/app/propose/wallet/actions.ts b/apps/web/src/app/propose/wallet/actions.ts index a39b720..aa566a7 100644 --- a/apps/web/src/app/propose/wallet/actions.ts +++ b/apps/web/src/app/propose/wallet/actions.ts @@ -3,6 +3,8 @@ import { prisma } from "@/lib/prisma"; import { logA2aTrafficEvent } from "@/lib/a2a-traffic"; import { sanitizeAgentId } from "@/lib/a2a-growth"; +import { recordDemandProposalFeeCaptured } from "@/lib/proposal-payment-ledger"; +import { verifyTreasuryUsdcTransfer } from "@/lib/usdc-transfer-verifier"; import { headers } from "next/headers"; import { redirect } from "next/navigation"; @@ -110,6 +112,17 @@ export async function submitWalletReceipt(formData: FormData) { }, select: { id: true }, }); + const autoVerification = existingCapture + ? ({ + verified: false as const, + status: "skipped" as const, + reason: "receipt_already_verified", + tx_hash: paymentReference, + }) + : await verifyTreasuryUsdcTransfer({ + paymentReference, + expectedAmountCents: normalizedAmountCents, + }); await prisma.auditEvent.create({ data: { @@ -133,6 +146,7 @@ export async function submitWalletReceipt(formData: FormData) { note: note || null, idempotency_key: idempotencyKey, already_verified: Boolean(existingCapture), + auto_verification: autoVerification, }, }, }); @@ -155,13 +169,45 @@ export async function submitWalletReceipt(formData: FormData) { referral_agent: referralAgent, source, campaign, - response_status: 202, - response_summary: existingCapture + response_status: autoVerification.verified || existingCapture ? 200 : 202, + response_summary: autoVerification.verified + ? "wallet_receipt_auto_verified" + : existingCapture ? "wallet_receipt_already_verified" : "wallet_receipt_pending_manual_verification", + auto_verification_status: autoVerification.status, }, }); } - redirect(buildWalletSuccessUrl(task.id, packageId, existingCapture ? "already_verified" : "submitted")); + if (autoVerification.verified && !existingCapture) { + await recordDemandProposalFeeCaptured({ + taskId: task.id, + referralAgent: referralAgent || null, + proposalFeeCents: normalizedAmountCents, + paymentProvider: "wallet", + paymentReference: capturedReference, + idempotencyKey: `proposal-wallet-fee:${paymentReference}`, + responseStatus: "verified", + packageId: packageId || null, + source, + campaign, + verificationNote: [ + "auto_verified_base_usdc_transfer", + `amount=${autoVerification.amount_usdc}`, + autoVerification.block_number ? `block=${autoVerification.block_number}` : "", + autoVerification.log_index ? `log_index=${autoVerification.log_index}` : "", + ] + .filter(Boolean) + .join("; "), + }); + } + + redirect( + buildWalletSuccessUrl( + task.id, + packageId, + autoVerification.verified ? "auto_verified" : existingCapture ? "already_verified" : "submitted" + ) + ); } diff --git a/apps/web/src/lib/usdc-transfer-verifier.ts b/apps/web/src/lib/usdc-transfer-verifier.ts new file mode 100644 index 0000000..c212270 --- /dev/null +++ b/apps/web/src/lib/usdc-transfer-verifier.ts @@ -0,0 +1,191 @@ +import { + TREASURY_USDC_ADDRESS, + TREASURY_USDC_RPC_URL, + TREASURY_USDC_TOKEN_ADDRESS, + usdcAtomicUnitsFromCents, +} from "@/lib/a2a-growth"; + +const ERC20_TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; + +type RpcLog = { + address?: string; + topics?: string[]; + data?: string; + blockNumber?: string; + transactionHash?: string; + transactionIndex?: string; + logIndex?: string; +}; + +type RpcReceipt = { + status?: string; + transactionHash?: string; + blockNumber?: string; + transactionIndex?: string; + logs?: RpcLog[]; +}; + +export type UsdcTransferVerification = + | { + verified: true; + status: "verified"; + tx_hash: string; + block_number?: string; + transaction_index?: string; + log_index?: string; + amount_raw: string; + amount_usdc: string; + treasury_wallet: string; + token_address: string; + } + | { + verified: false; + status: + | "skipped" + | "invalid_tx_hash" + | "not_configured" + | "not_found" + | "failed_transaction" + | "no_matching_transfer" + | "rpc_error"; + reason: string; + tx_hash?: string; + }; + +function normalizeTxHash(value: string) { + const normalized = value.trim(); + return /^0x[a-fA-F0-9]{64}$/.test(normalized) ? normalized.toLowerCase() : ""; +} + +function normalizeAddress(value: string) { + const normalized = value.trim().toLowerCase(); + return /^0x[a-f0-9]{40}$/.test(normalized) ? normalized : ""; +} + +function topicAddress(value: string) { + const address = normalizeAddress(value).replace(/^0x/, ""); + return address ? `0x${address.padStart(64, "0")}` : ""; +} + +function formatUsdc(raw: bigint) { + const decimals = BigInt(1_000_000); + return `${raw / decimals}.${String(raw % decimals).padStart(6, "0")}`; +} + +async function rpcCall(method: string, params: unknown[]): Promise { + const response = await fetch(TREASURY_USDC_RPC_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: Date.now(), + method, + params, + }), + cache: "no-store", + }); + const data = await response.json().catch(() => ({})); + + if (!response.ok || data.error) { + throw new Error(data.error?.message || `RPC ${method} failed (${response.status})`); + } + + return data.result as T; +} + +export async function verifyTreasuryUsdcTransfer(params: { + paymentReference: string; + expectedAmountCents: number; +}): Promise { + const txHash = normalizeTxHash(params.paymentReference); + if (!txHash) { + return { + verified: false, + status: "invalid_tx_hash", + reason: "payment_reference_is_not_a_transaction_hash", + }; + } + + const treasuryWallet = normalizeAddress(TREASURY_USDC_ADDRESS); + const tokenAddress = normalizeAddress(TREASURY_USDC_TOKEN_ADDRESS); + if (!treasuryWallet || !tokenAddress || !TREASURY_USDC_RPC_URL) { + return { + verified: false, + status: "not_configured", + reason: "treasury_usdc_rpc_or_wallet_is_not_configured", + tx_hash: txHash, + }; + } + + const expectedRaw = BigInt(usdcAtomicUnitsFromCents(params.expectedAmountCents)); + if (expectedRaw <= BigInt(0)) { + return { + verified: false, + status: "skipped", + reason: "expected_amount_is_missing", + tx_hash: txHash, + }; + } + + try { + const receipt = await rpcCall("eth_getTransactionReceipt", [txHash]); + if (!receipt) { + return { + verified: false, + status: "not_found", + reason: "transaction_receipt_not_found", + tx_hash: txHash, + }; + } + + if (receipt.status && receipt.status !== "0x1") { + return { + verified: false, + status: "failed_transaction", + reason: "transaction_status_is_not_success", + tx_hash: txHash, + }; + } + + const expectedToTopic = topicAddress(treasuryWallet); + const matchingLog = (receipt.logs || []).find((log) => { + const topics = log.topics || []; + if ((log.address || "").toLowerCase() !== tokenAddress) return false; + if ((topics[0] || "").toLowerCase() !== ERC20_TRANSFER_TOPIC) return false; + if ((topics[2] || "").toLowerCase() !== expectedToTopic) return false; + + const rawAmount = BigInt(log.data || "0x0"); + return rawAmount >= expectedRaw; + }); + + if (!matchingLog) { + return { + verified: false, + status: "no_matching_transfer", + reason: "no_usdc_transfer_to_treasury_for_expected_amount", + tx_hash: txHash, + }; + } + + const rawAmount = BigInt(matchingLog.data || "0x0"); + return { + verified: true, + status: "verified", + tx_hash: txHash, + block_number: matchingLog.blockNumber || receipt.blockNumber, + transaction_index: matchingLog.transactionIndex || receipt.transactionIndex, + log_index: matchingLog.logIndex, + amount_raw: rawAmount.toString(), + amount_usdc: formatUsdc(rawAmount), + treasury_wallet: treasuryWallet, + token_address: tokenAddress, + }; + } catch (error) { + return { + verified: false, + status: "rpc_error", + reason: error instanceof Error ? error.message : "rpc_error", + tx_hash: txHash, + }; + } +}