Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/[locale]/agent/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export default async function AgentPage({ params }: { params: Promise<{ locale:
<p className="mt-4 text-sm text-slate-600">{c.subtitle}</p>
</header>
{/* fallback は実カードの復元前 (外枠 + 見出し) と同じ形にする — 大きな予約 → 縮む → 伸びる、の 2 回シフトを避ける。 */}
<Suspense fallback={<section className="min-w-0 rounded-2xl bg-white p-5 shadow-card ring-1 ring-slate-200/70 sm:p-6"><h2 className="text-xl font-bold text-slate-900">{c.wallet.title}</h2></section>}><AgentWalletCard c={c.wallet} activity={c.activity} /></Suspense>
<Suspense fallback={<section className="min-w-0 rounded-2xl bg-white p-5 shadow-card ring-1 ring-slate-200/70 sm:p-6"><h2 className="text-xl font-bold text-slate-900">{c.wallet.title}</h2></section>}><AgentWalletCard c={c.wallet} activity={c.activity} purchases={c.purchases} /></Suspense>
<AgentConnect locale={locale} c={c.connect} />
<AgentTryPrompts locale={locale} c={c.tryPrompts} />
<section>
Expand Down
2 changes: 1 addition & 1 deletion components/X402DiscoveryView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ const MCP_CONFIG_SNIPPET = JSON.stringify(
mcpServers: {
'openpay-x402': {
command: 'npx',
args: ['-y', 'openpay-x402-mcp@0.16'],
args: ['-y', 'openpay-x402-mcp@0.17'],
env: { SIGNER_MODE: 'keystore' },
},
},
Expand Down
226 changes: 226 additions & 0 deletions components/agent/AgentPurchases.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
'use client';

import { useEffect, useId, useRef, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslations } from 'next-intl';
import { useSiweSession } from '@/hooks/useSiweSession';
import type { AgentPageContent } from '@/lib/agentPage';
import type { PurchaseItem } from '@/lib/agent/purchases';
import { trackAgentEvent } from '@/lib/agentTrack';
import { txExplorerUrl } from '@/lib/chains';
import { env } from '@/lib/env';
import { chainIdFromCaip2 } from '@/lib/x402/network';

type Props = { address: string; locale: string; c: AgentPageContent['purchases']; isConnected: boolean };
type Purchases = { ok: true; since: string; items: PurchaseItem[]; truncated: boolean; boundAt: string };
type Result = Purchases | { ok: false; reason: 'not_bound' | 'not_signed_in' | 'feature_disabled' };
class PurchaseError extends Error {
constructor(readonly reason: string) { super('agent_purchases_failed'); }
}

async function failure(response: Response, unbinding = false): Promise<PurchaseError> {
if (response.status === 404 && !unbinding) return new PurchaseError('feature_disabled');
try {
const body = await response.json();
if (response.status === 404 && body.reason !== 'not_bound') return new PurchaseError('feature_disabled');
return new PurchaseError(typeof body.reason === 'string' ? body.reason : 'storage_error');
} catch {
// CDN 等の非 JSON 応答をパネル内の固定エラーに閉じ、ウォレット全体へ波及させない。
return new PurchaseError(response.status === 404 ? 'feature_disabled' : 'storage_error');
}
}

async function post(path: string, body: object) {
const response = await fetch(`/api/agent/proof/${path}`, {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
});
if (!response.ok) throw await failure(response, path === 'unbind');
}

export function AgentPurchases(props: Props) {
if (!env.enableAgentPurchases) return null;
return <PurchasesForAddress key={props.address.toLowerCase()} {...props} address={props.address.toLowerCase()} />;
}

function PurchasesForAddress(props: Props) {
const session = useSiweSession();
const [mounted, setMounted] = useState(false);
const [proof, setProof] = useState<string | null>(null);
const [hashError, setHashError] = useState(false);
useEffect(() => {
const incoming = new URLSearchParams(window.location.hash.slice(1)).get('proof');
if (incoming !== null) {
try {
// proof は URL・履歴に残さず、この mount のメモリだけに保持する。
window.history.replaceState(window.history.state, '', window.location.pathname + window.location.search);
setProof(incoming);
} catch {
// history API が拒否された場合、秘密を URL に残したまま検証を進めない。
setHashError(true);
}
}
setMounted(true);
}, []);

return (
<section className="mt-6 min-w-0 max-w-full break-words border-t border-slate-200 pt-5">
<h3 className="text-lg font-bold text-slate-900">{props.c.title}</h3>
{!mounted || session.isLoading || hashError ? <p role="status" className="mt-3 text-sm text-slate-600">{hashError ? props.c.error : props.c.loading}</p> : (
// cookie の持ち主 O とカードの Agent A は別物。O の切替で操作・表示状態も破棄する。
<PurchasesForOwner key={session.sessionAddress?.toLowerCase() ?? 'anonymous'} {...props} session={session} proof={proof} consumeProof={() => setProof(null)} />
)}
</section>
);
}

function PurchasesForOwner({ address, locale, c, isConnected, session, proof, consumeProof }: Props & {
session: ReturnType<typeof useSiweSession>; proof: string | null; consumeProof: () => void;
}) {
const t = useTranslations('Nav');
const qc = useQueryClient();
const [authRequired, setAuthRequired] = useState(false);
const [signInFailed, setSignInFailed] = useState(false);
const [confirmUnbind, setConfirmUnbind] = useState(false);
const submitted = useRef(false);
const viewed = useRef(false);
const owner = session.sessionAddress?.toLowerCase();
const instance = useId();
const queryKey = ['agent-purchases', address, owner, instance] as const;
const [verification, setVerification] = useState<{ status: 'idle' | 'pending' | 'success' | 'error'; error?: Error }>({ status: 'idle' });
const verify = {
isPending: verification.status === 'pending', isError: verification.status === 'error',
isSuccess: verification.status === 'success', error: verification.error,
reset: () => setVerification({ status: 'idle' }),
};
useEffect(() => {
if (proof === null || !owner || submitted.current) return;
submitted.current = true;
// 1 回限りの proof は再試行しない。StrictMode や持ち主の切替で二重送信しない。
consumeProof();
setVerification({ status: 'pending' });
void post('verify', { proof }).then(() => {
setVerification({ status: 'success' });
trackAgentEvent('agent_proof_bound', { locale });
void qc.invalidateQueries({ queryKey: ['agent-purchases', address, owner] });
}, (error: Error) => {
setVerification({ status: 'error', error });
if (error instanceof PurchaseError && error.reason === 'not_signed_in') setAuthRequired(true);
});
}, [proof, owner, consumeProof, address, locale, qc]);

const query = useQuery({
queryKey,
queryFn: async ({ signal }): Promise<Result> => {
const response = await fetch(`/api/agent/purchases?address=${encodeURIComponent(address)}`, { credentials: 'same-origin', cache: 'no-store', signal });
if (!response.ok) {
const error = await failure(response);
if (response.status === 401 && (error.reason === 'not_bound' || error.reason === 'not_signed_in')) return { ok: false, reason: error.reason };
if (response.status === 404) return { ok: false, reason: 'feature_disabled' };
throw error;
}
const body = await response.json() as Purchases;
if (body.ok !== true || !Array.isArray(body.items)) throw new PurchaseError('storage_error');
return body;
},
// 検証に失敗しても、既に紐づいている一覧は隠さない (期限切れリンクを開いた持ち主が一覧を失わない)。
enabled: !!owner && !authRequired && proof === null && !verify.isPending,
staleTime: 30_000, refetchOnWindowFocus: false, retry: false,
// mount ごとの key と gcTime で、再ログイン直後にも古い認可の履歴を再表示しない。
gcTime: 0, refetchOnMount: 'always',
});
const unbind = useMutation({
mutationFn: () => post('unbind', { address }),
retry: false,
onSuccess: async () => {
await qc.cancelQueries({ queryKey });
qc.setQueryData<Result>(queryKey, { ok: false, reason: 'not_bound' });
setConfirmUnbind(false);
verify.reset();
},
onError: async (error) => {
if (!(error instanceof PurchaseError)) return;
if (error.reason === 'not_signed_in') setAuthRequired(true);
if (error.reason === 'not_bound' || error.reason === 'feature_disabled') {
await qc.cancelQueries({ queryKey });
qc.setQueryData<Result>(queryKey, { ok: false, reason: error.reason });
setConfirmUnbind(false);
verify.reset();
}
},
});
const signedOut = !owner || authRequired || (query.data?.ok === false && query.data.reason === 'not_signed_in');
const result = !signedOut && !query.isError && proof === null && !verify.isPending && query.data?.ok === true ? query.data : undefined;
useEffect(() => {
if (!result || viewed.current) return;
viewed.current = true;
trackAgentEvent('agent_purchases_view', { locale });
}, [result, locale]);

async function signIn() {
setSignInFailed(false);
trackAgentEvent('agent_purchases_signin', { locale });
try {
await session.signIn(t('siweStatement'));
setAuthRequired(false);
verify.reset();
unbind.reset();
await qc.resetQueries({ queryKey });
} catch {
// 署名の拒否を unhandled rejection にせず、このログイン操作だけの失敗として表示する。
setSignInFailed(true);
}
}
function proofFailure(error: Error) {
if (!(error instanceof PurchaseError)) return c.error;
return Object.hasOwn(c.failures, error.reason) ? c.failures[error.reason as keyof typeof c.failures] : c.failures.storage_error;
}
const notBound = !signedOut && query.data?.ok === false && query.data.reason === 'not_bound';
const unbindError = unbind.error instanceof PurchaseError && ['not_bound', 'feature_disabled'].includes(unbind.error.reason) ? null : unbind.error;
const status = session.isSigningIn ? c.signingIn
: signedOut ? (signInFailed || session.signInError ? c.signInError : proof !== null ? c.continueAfterSignIn : '')
: verify.isPending || proof !== null ? c.verifying
: verify.error ? proofFailure(verify.error)
: unbindError || query.isError ? c.error
: query.data?.ok === false && query.data.reason === 'feature_disabled' ? c.failures.feature_disabled
: unbind.isPending || query.isPending ? c.loading
: confirmUnbind ? c.unbindConfirm
: verify.isSuccess ? c.bound : result?.items.length === 0 ? c.empty : '';
const focus = 'focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-emerald-600';
const button = `min-h-11 max-w-full rounded-xl bg-slate-100 px-4 py-2 text-sm font-medium text-slate-800 disabled:opacity-50 ${focus}`;
const formatter = new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeStyle: 'short' });
return (
<>
{signedOut ? <p className="mt-3 text-sm text-slate-600">{c.lead}</p> : <p className="mt-3 text-xs text-slate-500">{c.signedInAs} <span className="font-mono">{owner!.slice(0, 6)}…{owner!.slice(-4)}</span></p>}
<p id={`${instance}-status`} role="status" className="mt-3 text-sm text-slate-600">{status}</p>
{/* 未接続ではサインインの署名ができない (useSiweSession が wallet_not_connected を投げる) → ボタンではなく接続への案内。 */}
{signedOut ? (isConnected
? <button type="button" className={`mt-3 ${button}`} disabled={session.isSigningIn} onClick={() => void signIn()}>{session.isSigningIn ? c.signingIn : c.signIn}</button>
: <p className="mt-3 text-sm text-slate-600">{c.connectFirst}</p>) : null}
{notBound && !verify.isPending && !verify.isError ? <div className="mt-3 text-sm text-slate-600"><p>{c.notBoundLead}</p><ol className="mt-2 list-decimal space-y-2 pl-5">{c.notBoundSteps.map((step) => <li key={step}>{step}</li>)}</ol></div> : null}
{result ? (
<>
{result.items.length > 0 ? <div className="mt-4 min-w-0 max-w-full overflow-x-auto">
<table className="w-full text-left text-sm">
<thead><tr>{[c.colDate, c.colItem, c.colAmount].map((label) => <th key={label} scope="col" className="px-2 py-2 font-medium text-slate-500">{label}</th>)}</tr></thead>
<tbody>{result.items.map((item, index) => {
const chainId = chainIdFromCaip2(item.network) ?? ({ polygon: 137, 'polygon-amoy': 80002, base: 8453, 'base-sepolia': 84532 } as Record<string, number>)[item.network];
// 未知の network・tx では推測した explorer へ誘導しない。
const txUrl = chainId && item.tx ? txExplorerUrl(chainId, item.tx) : undefined;
return <tr key={`${item.at}:${item.tx}:${index}`} className="border-t border-slate-100 align-top">
<td className="px-2 py-3 text-xs text-slate-600"><time dateTime={item.at}>{formatter.format(new Date(item.at))}</time>{txUrl ? <a href={txUrl} target="_blank" rel="noopener noreferrer" className={`mt-2 block underline ${focus}`}>{c.viewTx}</a> : null}</td>
<td className="max-w-48 break-words px-2 py-3 [overflow-wrap:anywhere]"><span>{item.resource.path ?? item.resource.pathTag ?? '—'}</span>{item.resource.host && item.resource.host !== 'open-pay.jp' ? <span className="mt-1 block text-xs text-slate-500">{item.resource.host}</span> : null}<span className="mt-2 inline-block rounded bg-slate-100 px-1.5 py-0.5 text-xs text-slate-700">{item.resourceOrigin === 'first-party' ? c.originFirstParty : item.resourceOrigin === 'listed' ? c.originListed : c.originClaimed}</span></td>
<td className="break-words px-2 py-3 tabular-nums [overflow-wrap:anywhere]">{item.amount} {item.asset}{item.fee !== undefined ? <span className="mt-1 block text-xs text-slate-500">+ {item.fee} {c.feeSuffix}</span> : null}</td>
</tr>;
})}</tbody>
</table>
</div> : verify.isSuccess ? <p className="mt-3 text-sm text-slate-600">{c.empty}</p> : null}
{result.truncated ? <p className="mt-3 text-xs text-slate-500">{c.truncated}</p> : null}
<p className="mt-3 text-xs text-slate-500">{c.sinceNote}</p>
<p className="mt-2 text-xs leading-relaxed text-slate-500">{c.caveat}</p>
<button type="button" className={`mt-4 ${button}`} disabled={unbind.isPending} aria-describedby={confirmUnbind ? `${instance}-status` : undefined} onKeyDown={(event) => { if (event.key === 'Escape') setConfirmUnbind(false); }} onClick={() => { if (confirmUnbind) unbind.mutate(); else setConfirmUnbind(true); }}>{c.unbind}</button>
</>
) : null}
</>
);
}
3 changes: 2 additions & 1 deletion components/agent/AgentTryPrompts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useState } from 'react';
import { useCopyToClipboard, useHydrationSafeAvailable } from '@/hooks/useCopyToClipboard';
import type { AgentPageContent } from '@/lib/agentPage';
import { trackAgentEvent } from '@/lib/agentTrack';
import { env } from '@/lib/env';

const tagColors = {
free: 'bg-slate-100 text-slate-700',
Expand All @@ -23,7 +24,7 @@ export function AgentTryPrompts({ locale, c }: { locale: string; c: AgentPageCon
<h2 className="text-xl font-bold text-slate-900">{c.title}</h2>
<p className="mt-3 text-sm text-slate-600">{c.lead}</p>
<ul className="mt-2 divide-y divide-slate-200/80">
{c.items.map((item) => (
{c.items.filter((item) => env.enableAgentPurchases || item.id !== 'history-web').map((item) => (
<li key={item.id} className="min-w-0 py-3 last:pb-0">
{/* タグとコピーを 1 行に並べ、依頼文はその下 (ボタンを依頼文の下に積むと mobile で 1 行ぶんずつ伸びる)。 */}
<div className="flex min-w-0 items-center justify-between gap-3">
Expand Down
10 changes: 9 additions & 1 deletion components/agent/AgentWalletCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { erc20Abi, formatUnits, isAddress, zeroAddress, type Address } from 'vie
import { useCopyToClipboard, useHydrationSafeAvailable } from '@/hooks/useCopyToClipboard';
import type { AgentPageContent } from '@/lib/agentPage';
import { chainNameForId } from '@/lib/chains';
import { env } from '@/lib/env';
import { defaultDeploymentForSymbol } from '@/lib/tokens';

// 再訪時に残高カードをすぐ出すための端末ローカルの控え (公開アドレスのみ・秘密ではない)。
Expand All @@ -20,7 +21,11 @@ const AgentFundFromWallet = dynamic(() => import('./AgentFundFromWallet').then((

const AgentActivity = dynamic(() => import('./AgentActivity').then((m) => m.AgentActivity), { ssr: false });

export function AgentWalletCard({ c, activity }: { c: AgentPageContent['wallet']; activity: AgentPageContent['activity'] }) {
const AgentPurchases = env.enableAgentPurchases
? dynamic(() => import('./AgentPurchases').then((m) => m.AgentPurchases), { ssr: false })
: null;

export function AgentWalletCard({ c, activity, purchases }: { c: AgentPageContent['wallet']; activity: AgentPageContent['activity']; purchases: AgentPageContent['purchases'] }) {
const locale = useLocale();
const [copiedAddress, setCopiedAddress] = useState<string | null>(null);
const params = useSearchParams();
Expand Down Expand Up @@ -49,6 +54,8 @@ export function AgentWalletCard({ c, activity }: { c: AgentPageContent['wallet']
function openFundFromHash() {
if (window.location.hash === '#agent-fund') setFundOpen(true);
}
// 紐づけリンク (#proof=) も ?address= を持つが、目的は購入履歴なので入金パネルは開かない。
if (window.location.hash.startsWith('#proof=')) setFundOpen(false);
openFundFromHash();
window.addEventListener('hashchange', openFundFromHash);
return () => window.removeEventListener('hashchange', openFundFromHash);
Expand Down Expand Up @@ -183,6 +190,7 @@ export function AgentWalletCard({ c, activity }: { c: AgentPageContent['wallet']
</div>
</div>
{address ? <AgentActivity address={address} locale={locale} c={activity} refreshKey={activityRefreshKey} /> : null}
{address && AgentPurchases ? <AgentPurchases address={address} locale={locale} c={purchases} isConnected={isConnected} /> : null}
</div>
</section>
);
Expand Down
2 changes: 1 addition & 1 deletion docs/agent-templates/jpyc-service-monitor.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ A weekly change feed for Japan-related JPYC/Web3 services, designed to be wired
"mcpServers": {
"openpay-x402": {
"command": "npx",
"args": ["--yes", "openpay-x402-mcp@0.16"],
"args": ["--yes", "openpay-x402-mcp@0.17"],
"env": {
"SIGNER_MODE": "keystore",
"MAX_PER_CALL_JPYC": "3",
Expand Down
4 changes: 4 additions & 0 deletions instrumentation-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import * as Sentry from '@sentry/nextjs';
import {
scrubSentryBreadcrumb,
scrubSentryServerEvent,
scrubSentryTransaction,
} from '@/lib/telemetryRedaction';

Expand Down Expand Up @@ -84,6 +85,9 @@ if (dsn) {
// webhook token 等が path/query/userinfo に含まれても Sentry へ送らない。
beforeBreadcrumb: scrubSentryBreadcrumb,
beforeSendTransaction: scrubSentryTransaction,
// error event の request.url は location.href そのもの。/agent の紐づけリンク (#proof=・一回限りの
// 署名) が遅延 chunk の mount 前にエラーへ乗る窓を、origin だけに切り詰めて塞ぐ。
beforeSend: scrubSentryServerEvent,
// PII (IP / ユーザ ID) は送らない。但しウォレットアドレスは breadcrumb
// に出る可能性があるので、本当に厳格にしたい場合は beforeSend で scrub。
sendDefaultPii: false,
Expand Down
Loading
Loading