Skip to content
Open
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
105 changes: 46 additions & 59 deletions agent/agent.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { createEd25519Signer } from '@x402/stellar';
import { ExactStellarScheme } from '@x402/stellar/exact/client';
import { buildRunSummary, writeRunSummary } from './runSummary.js';
import { stroopsToUsdcDisplay } from '../packages/stroops/index.js';
import { createClient } from '../packages/client/index.js';

// ── Config ────────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -120,103 +121,98 @@ export const EVENT = {

// ── Credit scoring helpers ────────────────────────────────────────────────────

const apiClient = createClient({
baseUrl: LODESTAR_API_URL,
timeoutMs: FETCH_TIMEOUT_MS,
});

let currentScore = null;

export async function ensureRegistered() {
try {
const res = await fetchWithTimeout(`${LODESTAR_API_URL}/api/agents/${AGENT_ADDRESS}`);
if (res.status === 503) {
const data = await apiClient.getAgent(AGENT_ADDRESS);
const agent = data.agent ?? data;
currentScore = agent.score;
const policy = data.policy;
const dailyLimitUsdc = policy
? stroopsToUsdcDisplay(policy.max_per_day_stroops)
: null;
logger.info(
{ event: EVENT.AGENT_REGISTERED, agentAddress: AGENT_ADDRESS, score: agent.score, dailyLimitUsdc, scoringEnabled: true },
'Already registered'
);
return true;
} catch (err) {
if (err.status === 503) {
logger.info(
{ event: EVENT.AGENT_REGISTERED, agentAddress: AGENT_ADDRESS, scoringEnabled: false },
'Agents contract not deployed — scoring disabled'
);
return false;
}
if (res.ok) {
const data = await res.json();
const agent = data.agent ?? data;
currentScore = agent.score;
const policy = data.policy;
const dailyLimitUsdc = policy
? stroopsToUsdcDisplay(policy.max_per_day_stroops)
: null;
logger.info(
{ event: EVENT.AGENT_REGISTERED, agentAddress: AGENT_ADDRESS, score: agent.score, dailyLimitUsdc, scoringEnabled: true },
'Already registered'
);
return true;
}
if (res.status === 404) {
if (err.status === 404) {
logger.info(
{ event: EVENT.AGENT_REGISTERED, agentAddress: AGENT_ADDRESS },
'Not registered — registering now…'
);
const regRes = await fetchWithTimeout(`${LODESTAR_API_URL}/api/agents/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
try {
await apiClient.registerAgent({
agentAddress: AGENT_ADDRESS,
name: AGENT_NAME,
description: AGENT_DESC,
maxPerTxUsdc: MAX_PER_TX,
maxPerDayUsdc: MAX_PER_DAY,
allowedCategories: ALLOWED_CATS,
}),
});
if (regRes.ok) {
});
currentScore = 100;
logger.info(
{ event: EVENT.AGENT_REGISTERED, agentAddress: AGENT_ADDRESS, score: 100, scoringEnabled: true },
'Registered — starting score: 100'
);
return true;
} catch (regErr) {
logger.warn(
{ event: EVENT.AGENT_REGISTERED, agentAddress: AGENT_ADDRESS, scoringEnabled: false, err: regErr.body || regErr.message },
'Registration failed — scoring disabled'
);
return false;
}
const err = await regRes.json().catch(() => ({}));
logger.warn(
{ event: EVENT.AGENT_REGISTERED, agentAddress: AGENT_ADDRESS, scoringEnabled: false, err },
'Registration failed — scoring disabled'
);
return false;
}
} catch (err) {
logger.warn(
{ event: EVENT.AGENT_REGISTERED, agentAddress: AGENT_ADDRESS, scoringEnabled: false, err },
'Could not reach agents API — scoring disabled'
);
return false;
}
return false;
}

async function checkSpend(amountUsdc, category) {
try {
const res = await fetchWithTimeout(
`${LODESTAR_API_URL}/api/agents/${AGENT_ADDRESS}/can-spend` +
`?amount=${encodeURIComponent(amountUsdc)}&category=${encodeURIComponent(category)}`
);
if (!res.ok) return { allowed: true, reason: 'OK' };
return await res.json();
return await apiClient.checkAgentCanSpend(AGENT_ADDRESS, {
amount: amountUsdc,
category,
});
} catch {
return { allowed: true, reason: 'OK' };
}
}

async function recordOutcome(amountUsdc, success, serviceId) {
try {
const body = JSON.stringify({ amountUsdc, success, serviceId });
const headers = { 'Content-Type': 'application/json' };
const headers = {};
if (LODESTAR_HMAC_SECRET) {
const body = JSON.stringify({ amountUsdc, success, serviceId });
headers['X-Lodestar-Signature'] = crypto
.createHmac('sha256', LODESTAR_HMAC_SECRET)
.update(body)
.digest('hex');
}
const res = await fetchWithTimeout(`${LODESTAR_API_URL}/api/agents/${AGENT_ADDRESS}/payment`, {
method: 'POST',
headers,
body,
});
if (res.ok) {
const data = await res.json();
const data = await apiClient.recordAgentPayment(
AGENT_ADDRESS,
{ amountUsdc, success, serviceId },
{ headers }
);
if (data && data.newScore !== undefined) {
const scoreBefore = currentScore;
currentScore = data.newScore;
logger.info(
Expand Down Expand Up @@ -274,22 +270,13 @@ function buildHttpClient() {
// ── Registry helpers ──────────────────────────────────────────────────────────

async function fetchServices(category) {
const res = await fetchWithTimeout(`${LODESTAR_API_URL}/api/services?category=${category}`);
if (!res.ok) throw new Error(`Registry fetch failed: ${res.status}`);
const body = await res.json();
return body.services ?? [];
const data = await apiClient.getServices({ category });
return data.services ?? [];
}

async function submitReputation(id, positive) {
try {
const res = await fetchWithTimeout(`${LODESTAR_API_URL}/api/reputation/${id}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ positive, agent: AGENT_ADDRESS }),
});
if (!res.ok) {
logger.debug({ status: res.status }, 'Reputation vote not applied (best-effort)');
}
await apiClient.submitReputation(id, { positive, agent: AGENT_ADDRESS });
} catch {
// Intentionally best-effort — a failed vote must not abort the agent run.
}
Expand Down
1 change: 1 addition & 0 deletions agent/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"test": "vitest run"
},
"dependencies": {
"@lodestar/client": "file:../packages/client",
"@stellar/stellar-sdk": "^13.0.0",
"@x402/core": "^2.9.0",
"@x402/stellar": "^2.9.0",
Expand Down
2 changes: 1 addition & 1 deletion frontend/app/agents/[address]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -356,7 +356,7 @@ export default function AgentProfilePage() {
<MetaItem label="Total volume" value={`$${totalVolumeUsdc} USDC`} />
<MetaItem label="Registered at ledger" value={`#${Number(agent.registered_at).toLocaleString()}`} />
<MetaItem label="Last active at ledger" value={`#${Number(agent.last_active).toLocaleString()}`} />
<MetaItem label="Owner" value={`${agent.owner.slice(0, 6)}…${agent.owner.slice(-4)}`} mono />
<MetaItem label="Owner" value={agent.owner ? `${agent.owner.slice(0, 6)}…${agent.owner.slice(-4)}` : '—'} mono />
</div>
</div>
);
Expand Down
2 changes: 1 addition & 1 deletion frontend/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ body {
}

.input {
@apply bg-background border border-border rounded-lg px-4 py-2.5 text-sm text-primary placeholder:text-secondary focus:outline-none focus:ring-1 focus:ring-primary/30 transition-shadow;
@apply bg-background border border-border rounded-lg px-4 py-2.5 text-sm text-primary placeholder:text-secondary focus:outline-none focus:ring-1 focus:ring-primary transition-shadow;
}
}

Expand Down
2 changes: 1 addition & 1 deletion frontend/components/CreditScoreDemo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ export default function CreditScoreDemo() {
setSpendResult(res.allowed ? 'allowed' : 'blocked');
setSpendDetail(res.allowed
? `$${SIMULATE_AMOUNT} USDC within daily limit of $${DAILY_LIMIT_USDC} USDC`
: res.reason);
: res.reason ?? 'Policy limit exceeded');
} catch {
setSpendResult('blocked');
setSpendDetail('Policy check failed');
Expand Down
45 changes: 29 additions & 16 deletions frontend/components/RegisterForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,16 +62,17 @@ function validate(f: FormState): Record<string, string> {

export default function RegisterForm({ walletAddress }: Props) {
const [form, setForm] = useState<FormState>(EMPTY);
const [errors, setErrors] = useState<Record<string, string>>({});
const [errors, setErrors] = useState<Record<string, string>>(() => validate(EMPTY));
const [touched, setTouched] = useState<Record<string, boolean>>({});
const [submitting, setSubmitting] = useState(false);
const [pendingTx, setPendingTx] = useState<{ txHash: string } | null>(null);
const [result, setResult] = useState<{ txHash: string; id: number } | null>(null);
const [submitError, setSubmitError] = useState('');

function set(field: keyof FormState, value: string) {
setForm((prev) => ({ ...prev, [field]: value }));
// Validate on change
const updatedForm = { ...form, [field]: value };
setForm(updatedForm);
setTouched((prev) => ({ ...prev, [field]: true }));
const errs = validate(updatedForm);
setErrors(errs);
}
Expand All @@ -81,6 +82,7 @@ export default function RegisterForm({ walletAddress }: Props) {
const errs = validate(form);
if (Object.keys(errs).length > 0) {
setErrors(errs);
setTouched({ name: true, description: true, endpoint: true, price_usdc: true, category: true });
return;
}
setSubmitting(true);
Expand All @@ -93,6 +95,8 @@ export default function RegisterForm({ walletAddress }: Props) {
await new Promise(resolve => setTimeout(resolve, 2000));
setResult(res);
setForm(EMPTY);
setErrors(validate(EMPTY));
setTouched({});
} catch (err) {
setSubmitError(err instanceof Error ? err.message : 'Registration failed');
} finally {
Expand Down Expand Up @@ -152,66 +156,73 @@ export default function RegisterForm({ walletAddress }: Props) {
return (
<form onSubmit={handleSubmit} className="card p-8 space-y-5 fade-in">
<Field
id="service-name"
label="Service Name"
error={errors.name}
error={touched.name ? errors.name : undefined}
hint="3–64 characters"
>
<input
id="service-name"
type="text"
value={form.name}
onChange={(e) => set('name', e.target.value)}
placeholder="My Weather API"
disabled={submitting}
className={input(!!errors.name)}
className={input(!!(touched.name && errors.name))}
/>
</Field>

<Field
id="service-description"
label="Description"
error={errors.description}
error={touched.description ? errors.description : undefined}
hint="10–256 characters"
>
<textarea
id="service-description"
rows={3}
value={form.description}
onChange={(e) => set('description', e.target.value)}
placeholder="Describe what your service does and what data it returns..."
disabled={submitting}
className={input(!!errors.description)}
className={input(!!(touched.description && errors.description))}
/>
</Field>

<Field
id="service-endpoint"
label="Endpoint URL"
error={errors.endpoint}
error={touched.endpoint ? errors.endpoint : undefined}
hint="https://, max 256 characters"
>
<input
id="service-endpoint"
type="url"
value={form.endpoint}
onChange={(e) => set('endpoint', e.target.value)}
placeholder="https://api.example.com/weather"
disabled={submitting}
className={`mono ${input(!!errors.endpoint)}`}
className={`mono ${input(!!(touched.endpoint && errors.endpoint))}`}
/>
</Field>

<div className="grid grid-cols-2 gap-4">
<Field label="Price (USDC)" error={errors.price_usdc} hint="Min 0.0001">
<Field id="service-price" label="Price (USDC)" error={touched.price_usdc ? errors.price_usdc : undefined} hint="Min 0.0001">
<input
type="number"
step="0.0001"
min="0.0001"
id="service-price"
type="text"
inputMode="decimal"
value={form.price_usdc}
onChange={(e) => set('price_usdc', e.target.value)}
placeholder="0.001"
disabled={submitting}
className={`mono ${input(!!errors.price_usdc)}`}
className={`mono ${input(!!(touched.price_usdc && errors.price_usdc))}`}
/>
</Field>

<Field label="Category" error={errors.category}>
<Field id="service-category" label="Category" error={touched.category ? errors.category : undefined}>
<select
id="service-category"
value={form.category}
onChange={(e) => set('category', e.target.value as Category)}
disabled={submitting}
Expand Down Expand Up @@ -250,11 +261,13 @@ function input(hasError: boolean) {
}

function Field({
id,
label,
error,
hint,
children,
}: {
id?: string;
label: string;
error?: string;
hint?: string;
Expand All @@ -263,7 +276,7 @@ function Field({
return (
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<label className="text-sm font-medium">{label}</label>
<label htmlFor={id} className="text-sm font-medium">{label}</label>
{hint && !error && <span className="text-xs text-secondary">{hint}</span>}
{error && <span className="text-xs text-error">{error}</span>}
</div>
Expand Down
4 changes: 2 additions & 2 deletions frontend/components/SpendingPolicy.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ export default function SpendingPolicyDisplay({ policy, walletAddress, agentOwne
}

const dailyUsed = Number(
BigInt(policy.daily_spent_stroops) * 100n /
BigInt(policy.daily_spent_stroops || policy.spent_today_stroops || '0') * 100n /
BigInt(policy.max_per_day_stroops === '0' ? '1' : policy.max_per_day_stroops),
);

Expand Down Expand Up @@ -224,7 +224,7 @@ export default function SpendingPolicyDisplay({ policy, walletAddress, agentOwne
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-secondary">Daily spend used</span>
<span className="mono text-xs text-primary">
${stroopsToUsdc(policy.daily_spent_stroops)} / ${stroopsToUsdc(policy.max_per_day_stroops)} USDC
${stroopsToUsdc(policy.daily_spent_stroops || policy.spent_today_stroops || '0')} / ${stroopsToUsdc(policy.max_per_day_stroops)} USDC
</span>
</div>
<div className="w-full bg-border rounded-full h-1.5">
Expand Down
6 changes: 3 additions & 3 deletions frontend/components/StatsBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,15 +40,15 @@ export default function StatsBar() {
<div className="flex flex-wrap justify-center gap-8 sm:gap-16 py-8 border-t border-b border-border">
<Stat
label="Total Services"
value={stats ? String(stats.totalServices) : '—'}
value={stats ? String(stats.total_services ?? stats.totalServices ?? 0) : '—'}
/>
<Stat
label="Categories"
value={stats ? String(stats.categories.length) : '—'}
value={stats ? String(stats.total_categories ?? stats.categories?.length ?? 0) : '—'}
/>
<Stat
label="Latest Registration"
value={stats?.latestService ? stats.latestService.name : '—'}
value={stats?.latestService?.name ?? stats?.top_category ?? '—'}
/>
</div>
);
Expand Down
Loading