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,
+ };
+ }
+}