feat: auto-verify USDC wallet receipts
All checks were successful
CI and Production Smoke / smoke (push) Successful in 8s
All checks were successful
CI and Production Smoke / smoke (push) Successful in 8s
This commit is contained in:
@@ -166,6 +166,12 @@ export default async function ProposalSuccessPage({ searchParams }: { searchPara
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{receiptStatus === "auto_verified" ? (
|
||||
<div className="mt-3 rounded-md border border-emerald-300/30 bg-emerald-300/10 px-3 py-2 text-xs leading-5 text-emerald-100">
|
||||
已在 Base 鏈上自動驗證 USDC 轉帳,paid conversion 已入帳。
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{receiptStatus === "already_verified" ? (
|
||||
<div className="mt-3 rounded-md border border-emerald-300/30 bg-emerald-300/10 px-3 py-2 text-xs leading-5 text-emerald-100">
|
||||
此 wallet receipt 已經確認過,請勿重複轉帳。
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
191
apps/web/src/lib/usdc-transfer-verifier.ts
Normal file
191
apps/web/src/lib/usdc-transfer-verifier.ts
Normal file
@@ -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<T>(method: string, params: unknown[]): Promise<T> {
|
||||
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<UsdcTransferVerification> {
|
||||
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<RpcReceipt | null>("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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user