feat: auto-verify USDC wallet receipts
All checks were successful
CI and Production Smoke / smoke (push) Successful in 8s

This commit is contained in:
OG T
2026-06-12 00:43:42 +08:00
parent ed82eae1a8
commit c8ab251669
3 changed files with 246 additions and 3 deletions

View File

@@ -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

View File

@@ -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"
)
);
}

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