feat: prioritize wallet payments and live balance
All checks were successful
CI and Production Smoke / smoke (push) Successful in 7s
All checks were successful
CI and Production Smoke / smoke (push) Successful in 7s
This commit is contained in:
@@ -53,8 +53,19 @@ type TreasuryStats = {
|
||||
}>;
|
||||
};
|
||||
|
||||
type LiveUsdcBalance = {
|
||||
wallet: string;
|
||||
network: string;
|
||||
chain_id: string;
|
||||
token_address: string;
|
||||
balance_usdc: string;
|
||||
checked_at: string;
|
||||
};
|
||||
|
||||
export default function TreasuryDashboard() {
|
||||
const [stats, setStats] = useState<TreasuryStats | null>(null);
|
||||
const [liveUsdcBalance, setLiveUsdcBalance] = useState<LiveUsdcBalance | null>(null);
|
||||
const [liveUsdcBalanceError, setLiveUsdcBalanceError] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState("");
|
||||
const [withdrawing, setWithdrawing] = useState(false);
|
||||
@@ -86,6 +97,16 @@ export default function TreasuryDashboard() {
|
||||
}
|
||||
setStats(data);
|
||||
setLoadError("");
|
||||
|
||||
const balanceRes = await fetch("/api/admin/treasury/usdc-balance");
|
||||
const balanceData = await balanceRes.json().catch(() => ({}));
|
||||
if (balanceRes.ok) {
|
||||
setLiveUsdcBalance(balanceData);
|
||||
setLiveUsdcBalanceError("");
|
||||
} else {
|
||||
setLiveUsdcBalance(null);
|
||||
setLiveUsdcBalanceError(balanceData.error || `USDC balance request failed (${balanceRes.status})`);
|
||||
}
|
||||
} catch (error) {
|
||||
setLoadError(error instanceof Error ? error.message : "Unable to load treasury stats.");
|
||||
} finally {
|
||||
@@ -324,6 +345,22 @@ export default function TreasuryDashboard() {
|
||||
<span>Lifetime Revenue: ${(cents(stats.revenue?.usdc) / 100).toLocaleString()}</span>
|
||||
<span>Total GMV: ${(cents(stats.gmv?.usdc) / 100).toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="mt-4 rounded-xl border border-emerald-500/20 bg-black/30 p-3 text-xs leading-5 text-emerald-100">
|
||||
{liveUsdcBalance ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-emerald-100/70">Live wallet balance</span>
|
||||
<span className="font-mono text-emerald-200">{Number(liveUsdcBalance.balance_usdc).toFixed(2)} USDC</span>
|
||||
</div>
|
||||
<div className="mt-2 break-all text-emerald-100/60">
|
||||
{liveUsdcBalance.network} · chain {liveUsdcBalance.chain_id}
|
||||
</div>
|
||||
<div className="mt-1 break-all text-emerald-100/60">{liveUsdcBalance.wallet}</div>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-amber-200">{liveUsdcBalanceError || "Live wallet balance is loading."}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fiat Treasury */}
|
||||
|
||||
68
apps/web/src/app/api/admin/treasury/usdc-balance/route.ts
Normal file
68
apps/web/src/app/api/admin/treasury/usdc-balance/route.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { adminUnauthorizedResponse, isAdminRequestAuthorized } from "@/lib/admin-auth";
|
||||
import {
|
||||
TREASURY_USDC_ADDRESS,
|
||||
TREASURY_USDC_CHAIN_ID,
|
||||
TREASURY_USDC_NETWORK,
|
||||
TREASURY_USDC_RPC_URL,
|
||||
TREASURY_USDC_TOKEN_ADDRESS,
|
||||
} from "@/lib/a2a-growth";
|
||||
|
||||
function encodeBalanceOf(address: string) {
|
||||
return `0x70a08231${address.toLowerCase().replace(/^0x/, "").padStart(64, "0")}`;
|
||||
}
|
||||
|
||||
function formatUsdc(raw: bigint) {
|
||||
const decimals = BigInt(1_000_000);
|
||||
return `${raw / decimals}.${String(raw % decimals).padStart(6, "0")}`;
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
if (!isAdminRequestAuthorized(request)) {
|
||||
return adminUnauthorizedResponse();
|
||||
}
|
||||
|
||||
if (!TREASURY_USDC_ADDRESS || !TREASURY_USDC_TOKEN_ADDRESS || !TREASURY_USDC_RPC_URL) {
|
||||
return NextResponse.json({ error: "Treasury USDC wallet is not configured" }, { status: 503 });
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(TREASURY_USDC_RPC_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "eth_call",
|
||||
params: [
|
||||
{
|
||||
to: TREASURY_USDC_TOKEN_ADDRESS,
|
||||
data: encodeBalanceOf(TREASURY_USDC_ADDRESS),
|
||||
},
|
||||
"latest",
|
||||
],
|
||||
}),
|
||||
cache: "no-store",
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok || data.error) {
|
||||
return NextResponse.json({
|
||||
error: data.error?.message || `USDC balance RPC failed (${response.status})`,
|
||||
}, { status: 502 });
|
||||
}
|
||||
|
||||
const raw = BigInt(typeof data.result === "string" ? data.result : "0x0");
|
||||
return NextResponse.json({
|
||||
wallet: TREASURY_USDC_ADDRESS,
|
||||
network: TREASURY_USDC_NETWORK,
|
||||
chain_id: TREASURY_USDC_CHAIN_ID,
|
||||
token_address: TREASURY_USDC_TOKEN_ADDRESS,
|
||||
balance_raw: raw.toString(),
|
||||
balance_usdc: formatUsdc(raw),
|
||||
checked_at: new Date().toISOString(),
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
console.error("[Treasury USDC Balance Error]", error);
|
||||
return NextResponse.json({ error: "Unable to read treasury USDC balance" }, { status: 502 });
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,9 @@ import {
|
||||
getProposalPackage,
|
||||
sanitizeAgentId,
|
||||
TREASURY_USDC_ADDRESS,
|
||||
TREASURY_USDC_CHAIN_ID,
|
||||
TREASURY_USDC_NETWORK,
|
||||
TREASURY_USDC_TOKEN_ADDRESS,
|
||||
TREASURY_WALLET_LABEL,
|
||||
VIBEWORK_SITE_URL,
|
||||
} from "@/lib/a2a-growth";
|
||||
@@ -217,6 +220,9 @@ export async function createDemandProposal(formData: FormData) {
|
||||
amount_cents: proposalPackage.feeCents,
|
||||
treasury_wallet_label: TREASURY_WALLET_LABEL,
|
||||
treasury_usdc_address_present: Boolean(TREASURY_USDC_ADDRESS),
|
||||
treasury_usdc_network: TREASURY_USDC_NETWORK,
|
||||
treasury_usdc_chain_id: TREASURY_USDC_CHAIN_ID,
|
||||
treasury_usdc_token_address: TREASURY_USDC_TOKEN_ADDRESS,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -234,6 +240,8 @@ export async function createDemandProposal(formData: FormData) {
|
||||
package_id: proposalPackage.id,
|
||||
amount_cents: proposalPackage.feeCents,
|
||||
referral_agent: referralAgent,
|
||||
treasury_usdc_network: TREASURY_USDC_NETWORK,
|
||||
treasury_usdc_chain_id: TREASURY_USDC_CHAIN_ID,
|
||||
response_status: 200,
|
||||
response_summary: "wallet_payment_instructions_issued",
|
||||
},
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { createDemandProposal } from "@/app/propose/actions";
|
||||
import { buildAgentGrowthKit, getProposalPackage, PROPOSAL_PACKAGES, sanitizeAgentId } from "@/lib/a2a-growth";
|
||||
import {
|
||||
buildAgentGrowthKit,
|
||||
getProposalPackage,
|
||||
PROPOSAL_PACKAGES,
|
||||
sanitizeAgentId,
|
||||
TREASURY_USDC_ADDRESS,
|
||||
TREASURY_USDC_NETWORK,
|
||||
} from "@/lib/a2a-growth";
|
||||
import { logA2aTrafficEvent } from "@/lib/a2a-traffic";
|
||||
import { Activity, ArrowRight, Bot, CreditCard, Gauge, Network, Users, Wallet } from "lucide-react";
|
||||
import { headers } from "next/headers";
|
||||
@@ -77,6 +84,7 @@ export default async function ProposePage({ searchParams }: { searchParams?: Sea
|
||||
const growthKit = referralAgent
|
||||
? buildAgentGrowthKit({ agentId: referralAgent, campaign, source })
|
||||
: null;
|
||||
const walletPaymentAvailable = Boolean(TREASURY_USDC_ADDRESS);
|
||||
|
||||
if (referralAgent) {
|
||||
const requestHeaders = await headers();
|
||||
@@ -303,14 +311,20 @@ export default async function ProposePage({ searchParams }: { searchParams?: Sea
|
||||
<legend className="mb-3 text-sm font-semibold text-zinc-100">付款方式</legend>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<label className="flex cursor-pointer items-center gap-3 rounded-md border border-zinc-700 bg-zinc-950 p-4 hover:border-emerald-400">
|
||||
<input type="radio" name="paymentMethod" value="stripe" defaultChecked className="h-4 w-4 accent-emerald-400" />
|
||||
<CreditCard className="h-5 w-5 text-emerald-300" />
|
||||
<span className="text-sm font-medium text-white">信用卡,即時收款</span>
|
||||
<input type="radio" name="paymentMethod" value="wallet" defaultChecked={walletPaymentAvailable} className="h-4 w-4 accent-emerald-400" />
|
||||
<Wallet className="h-5 w-5 text-emerald-300" />
|
||||
<span>
|
||||
<span className="block text-sm font-medium text-white">USDC 錢包,直接入 Treasury</span>
|
||||
<span className="mt-1 block text-xs text-zinc-400">{TREASURY_USDC_NETWORK}</span>
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex cursor-pointer items-center gap-3 rounded-md border border-zinc-700 bg-zinc-950 p-4 hover:border-emerald-400">
|
||||
<input type="radio" name="paymentMethod" value="wallet" className="h-4 w-4 accent-emerald-400" />
|
||||
<Wallet className="h-5 w-5 text-emerald-300" />
|
||||
<span className="text-sm font-medium text-white">USDC 錢包指示</span>
|
||||
<input type="radio" name="paymentMethod" value="stripe" defaultChecked={!walletPaymentAvailable} className="h-4 w-4 accent-emerald-400" />
|
||||
<CreditCard className="h-5 w-5 text-emerald-300" />
|
||||
<span>
|
||||
<span className="block text-sm font-medium text-white">信用卡</span>
|
||||
<span className="mt-1 block text-xs text-zinc-400">Stripe 收款後再入帳</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { submitWalletReceipt } from "@/app/propose/wallet/actions";
|
||||
import {
|
||||
buildUsdcPaymentUri,
|
||||
getProposalPackage,
|
||||
TREASURY_USDC_ADDRESS,
|
||||
TREASURY_USDC_CHAIN_ID,
|
||||
TREASURY_USDC_NETWORK,
|
||||
TREASURY_USDC_TOKEN_ADDRESS,
|
||||
TREASURY_WALLET_LABEL,
|
||||
usdcAtomicUnitsFromCents,
|
||||
} from "@/lib/a2a-growth";
|
||||
import { CheckCircle2, Copy, Wallet } from "lucide-react";
|
||||
import { CheckCircle2, Copy, ExternalLink, Wallet } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -28,6 +32,7 @@ export default async function ProposalSuccessPage({ searchParams }: { searchPara
|
||||
const payment = getParam(params, "payment") || "stripe";
|
||||
const receiptStatus = getParam(params, "receipt");
|
||||
const proposalPackage = getProposalPackage(getParam(params, "package"));
|
||||
const walletPaymentUri = buildUsdcPaymentUri(proposalPackage.feeCents);
|
||||
const task = taskId
|
||||
? await prisma.task.findUnique({
|
||||
where: { id: taskId },
|
||||
@@ -145,7 +150,7 @@ export default async function ProposalSuccessPage({ searchParams }: { searchPara
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-emerald-100/70">Network</dt>
|
||||
<dd className="mt-1 text-emerald-50">{TREASURY_USDC_NETWORK || "Confirm network with VibeWork before transfer"}</dd>
|
||||
<dd className="mt-1 text-emerald-50">{TREASURY_USDC_NETWORK}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-emerald-100/70">Wallet</dt>
|
||||
@@ -153,6 +158,20 @@ export default async function ProposalSuccessPage({ searchParams }: { searchPara
|
||||
{TREASURY_USDC_ADDRESS || `${TREASURY_WALLET_LABEL} is not configured yet`}
|
||||
</dd>
|
||||
</div>
|
||||
{TREASURY_USDC_ADDRESS ? (
|
||||
<div>
|
||||
<dt className="text-emerald-100/70">Chain / token</dt>
|
||||
<dd className="mt-1 break-all text-emerald-50">
|
||||
Chain ID {TREASURY_USDC_CHAIN_ID} · {TREASURY_USDC_TOKEN_ADDRESS}
|
||||
</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{TREASURY_USDC_ADDRESS ? (
|
||||
<div>
|
||||
<dt className="text-emerald-100/70">USDC atomic amount</dt>
|
||||
<dd className="mt-1 break-all text-emerald-50">{usdcAtomicUnitsFromCents(proposalPackage.feeCents)}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{walletCaptured ? (
|
||||
<div>
|
||||
<dt className="text-emerald-100/70">Receipt</dt>
|
||||
@@ -166,6 +185,15 @@ export default async function ProposalSuccessPage({ searchParams }: { searchPara
|
||||
轉帳備註請包含 Proposal ID;入帳確認前 referral ledger 不會進入可出金狀態。
|
||||
</div>
|
||||
) : null}
|
||||
{walletPaymentUri && !walletCaptured ? (
|
||||
<a
|
||||
href={walletPaymentUri}
|
||||
className="mt-3 inline-flex h-11 items-center justify-center gap-2 rounded-md bg-emerald-300 px-4 text-sm font-semibold text-zinc-950 hover:bg-emerald-200"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
開啟錢包付款
|
||||
</a>
|
||||
) : null}
|
||||
|
||||
{!walletCaptured ? (
|
||||
<div className="mt-4 rounded-md border border-zinc-700 bg-zinc-950/80 p-4">
|
||||
@@ -213,7 +241,7 @@ export default async function ProposalSuccessPage({ searchParams }: { searchPara
|
||||
Network
|
||||
<input
|
||||
name="network"
|
||||
defaultValue={TREASURY_USDC_NETWORK || ""}
|
||||
defaultValue={TREASURY_USDC_NETWORK}
|
||||
className="h-10 rounded-md border border-zinc-700 bg-black px-3 text-sm text-white outline-none focus:border-emerald-400"
|
||||
placeholder="Base / Ethereum / Polygon"
|
||||
/>
|
||||
|
||||
@@ -13,9 +13,31 @@ export const AGENT_GATEWAY_URL = (
|
||||
"https://agent.wooo.work"
|
||||
).replace(/\/$/, "");
|
||||
|
||||
export const DEFAULT_TREASURY_USDC_NETWORK = "Base USDC (native)";
|
||||
export const DEFAULT_TREASURY_USDC_CHAIN_ID = "8453";
|
||||
export const DEFAULT_TREASURY_USDC_TOKEN_ADDRESS = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";
|
||||
|
||||
export const TREASURY_USDC_ADDRESS = (process.env.VIBEWORK_TREASURY_USDC_ADDRESS || "").trim();
|
||||
export const TREASURY_WALLET_LABEL = (process.env.VIBEWORK_TREASURY_WALLET_LABEL || "USDC Treasury").trim();
|
||||
export const TREASURY_USDC_NETWORK = (process.env.VIBEWORK_TREASURY_USDC_NETWORK || "").trim();
|
||||
export const TREASURY_USDC_NETWORK = (process.env.VIBEWORK_TREASURY_USDC_NETWORK || DEFAULT_TREASURY_USDC_NETWORK).trim();
|
||||
export const TREASURY_USDC_CHAIN_ID = (process.env.VIBEWORK_TREASURY_USDC_CHAIN_ID || DEFAULT_TREASURY_USDC_CHAIN_ID).trim();
|
||||
export const TREASURY_USDC_TOKEN_ADDRESS = (
|
||||
process.env.VIBEWORK_TREASURY_USDC_TOKEN_ADDRESS || DEFAULT_TREASURY_USDC_TOKEN_ADDRESS
|
||||
).trim();
|
||||
export const TREASURY_USDC_RPC_URL = (process.env.VIBEWORK_TREASURY_USDC_RPC_URL || "https://mainnet.base.org").trim();
|
||||
|
||||
export function usdcAtomicUnitsFromCents(amountCents: number) {
|
||||
return (BigInt(Math.max(0, Math.floor(amountCents))) * BigInt(10_000)).toString();
|
||||
}
|
||||
|
||||
export function buildUsdcPaymentUri(amountCents: number) {
|
||||
if (!TREASURY_USDC_ADDRESS || !TREASURY_USDC_TOKEN_ADDRESS || !TREASURY_USDC_CHAIN_ID) return "";
|
||||
const params = new URLSearchParams({
|
||||
address: TREASURY_USDC_ADDRESS,
|
||||
uint256: usdcAtomicUnitsFromCents(amountCents),
|
||||
});
|
||||
return `ethereum:${TREASURY_USDC_TOKEN_ADDRESS}@${TREASURY_USDC_CHAIN_ID}/transfer?${params.toString()}`;
|
||||
}
|
||||
|
||||
export const PROPOSAL_PACKAGES = [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user