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
4 changes: 4 additions & 0 deletions src/app/(dashboard)/policies/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { usePolicies } from '@/hooks/use-queries';
import { formatNumber } from '@/lib/format';
import { PageTransition } from '@/components/ui/motion';
import { PolicyRulesBuilder } from '@/features/policies/PolicyRulesBuilder';
import { PolicySimulationSandbox } from '@/features/policies/PolicySimulationSandbox';
import { BudgetSimulator } from '@/features/policies/BudgetSimulator';
import { PolicySandboxWidget } from '@/features/policies/PolicySandboxWidget';

Expand Down Expand Up @@ -58,6 +59,9 @@ export default function PoliciesPage() {
<SectionLabel>Spending rule designer</SectionLabel>
<PolicyRulesBuilder />

<SectionLabel>Policy simulation sandbox</SectionLabel>
<PolicySimulationSandbox />

<SectionLabel>{data.length} policies</SectionLabel>
<div className="grid gap-4 lg:grid-cols-2">
{data.map((policy) => (
Expand Down
16 changes: 2 additions & 14 deletions src/features/policies/PolicyRulesBuilder.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
defaultPolicyRules,
defaultRule,
ruleActionOptions,
ruleFieldOptions,
Expand All @@ -16,20 +17,7 @@ import {

export function PolicyRulesBuilder() {
const [draft, setDraft] = useState<PolicyRule>(defaultRule);
const [rules, setRules] = useState<PolicyRule[]>([
{
field: 'Transaction Amount',
operator: 'greater_than',
value: '2500',
action: 'require_approval',
},
{
field: 'Approved Account Whitelist',
operator: 'in_whitelist',
value: 'G...A1, G...B2',
action: 'allow',
},
]);
const [rules, setRules] = useState<PolicyRule[]>(defaultPolicyRules);
const [error, setError] = useState<string | null>(null);
const [simulationAmount, setSimulationAmount] = useState(500);

Expand Down
159 changes: 159 additions & 0 deletions src/features/policies/PolicySimulationSandbox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
'use client';

import { useMemo, useState } from 'react';
import { Check, Info, ShieldAlert, X } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { defaultPolicyRules, evaluatePolicyRules, type PolicyRule } from './rulesSchema';

const defaultTransaction = {
amount: '3200',
assetCode: 'USDC',
destinationAddress: 'G...A1',
agentId: 'agent-77',
};

export function PolicySimulationSandbox() {
const [transaction, setTransaction] = useState(defaultTransaction);

const result = useMemo(() => {
return evaluatePolicyRules(defaultPolicyRules, transaction);
}, [transaction]);

const handleFieldChange = (field: keyof typeof defaultTransaction, value: string) => {
setTransaction((current) => ({ ...current, [field]: value }));
};

return (
<Card>
<CardHeader className="gap-3">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-2xs font-semibold uppercase tracking-[0.2em] text-foreground-muted">
Rule tester
</p>
<CardTitle className="mt-1 text-xl">Simulation sandbox</CardTitle>
</div>
<Badge variant={result.passed ? 'success' : 'danger'} size="sm" dot>
{result.passed ? 'Pass' : 'Fail'}
</Badge>
</div>
</CardHeader>

<CardContent className="space-y-5 pt-0">
<div className="grid gap-3 md:grid-cols-2">
<label className="space-y-1 text-xs text-foreground-secondary">
<span>Amount</span>
<input
type="number"
min="0"
step="0.01"
value={transaction.amount}
onChange={(event) => handleFieldChange('amount', event.target.value)}
className="w-full rounded-button border border-border bg-surface px-3 py-2 text-sm text-foreground focus:border-gold focus:outline-none"
aria-label="Transaction amount"
/>
</label>

<label className="space-y-1 text-xs text-foreground-secondary">
<span>Asset code</span>
<input
value={transaction.assetCode}
onChange={(event) => handleFieldChange('assetCode', event.target.value)}
className="w-full rounded-button border border-border bg-surface px-3 py-2 text-sm text-foreground focus:border-gold focus:outline-none"
aria-label="Asset code"
/>
</label>

<label className="space-y-1 text-xs text-foreground-secondary md:col-span-2">
<span>Destination address</span>
<input
value={transaction.destinationAddress}
onChange={(event) => handleFieldChange('destinationAddress', event.target.value)}
className="w-full rounded-button border border-border bg-surface px-3 py-2 text-sm text-foreground focus:border-gold focus:outline-none"
aria-label="Destination address"
/>
</label>

<label className="space-y-1 text-xs text-foreground-secondary md:col-span-2">
<span>Agent ID</span>
<input
value={transaction.agentId}
onChange={(event) => handleFieldChange('agentId', event.target.value)}
className="w-full rounded-button border border-border bg-surface px-3 py-2 text-sm text-foreground focus:border-gold focus:outline-none"
aria-label="Agent ID"
/>
</label>
</div>

<div className="flex flex-wrap items-center gap-3">
<Button type="button" variant="secondary" size="sm" onClick={() => setTransaction(defaultTransaction)}>
Reset sample
</Button>
<span className="text-xs text-foreground-secondary">
Evaluating {defaultPolicyRules.length} active rules against this payload.
</span>
</div>

<div className="space-y-3 rounded-card border border-border bg-surface-secondary/40 p-4">
<div className="flex items-center gap-2">
{result.passed ? (
<Check className="h-4 w-4 text-success" aria-hidden />
) : (
<ShieldAlert className="h-4 w-4 text-danger" aria-hidden />
)}
<p className="text-sm font-medium text-foreground">
{result.passed ? 'Transaction passes all configured rules.' : 'Transaction violates one or more rules.'}
</p>
</div>

{result.failingRules.length > 0 ? (
<ul className="space-y-2">
{result.failingRules.map(({ rule, evaluation }, index) => (
<li key={`${rule.field}-${index}`} className="rounded-md border border-danger/30 bg-danger-soft/20 p-3 text-xs text-foreground-secondary">
<div className="mb-1 flex items-center justify-between gap-2">
<span className="font-medium text-foreground">{rule.field}</span>
<Badge variant="danger" size="sm">
{rule.action.replace(/_/g, ' ')}
</Badge>
</div>
<p className="text-danger">{evaluation.reason}</p>
</li>
))}
</ul>
) : (
<div className="flex items-center gap-2 rounded-md border border-success/30 bg-success-soft/20 p-3 text-xs text-success">
<Info className="h-4 w-4" aria-hidden />
No rule violations for this payload.
</div>
)}
</div>

<div className="space-y-2">
<p className="text-2xs font-medium uppercase tracking-[0.2em] text-foreground-muted">
Rule result JSON
</p>
<pre className="overflow-x-auto rounded-card border border-border bg-surface p-3 text-xs text-foreground-secondary">
{JSON.stringify(
{
request: transaction,
passed: result.passed,
failingRules: result.failingRules.map(({ rule, evaluation }) => ({
field: rule.field,
operator: rule.operator,
action: rule.action,
reason: evaluation.reason,
})),
},
null,
2,
)}
</pre>
</div>
</CardContent>
</Card>
);
}

export default PolicySimulationSandbox;
9 changes: 5 additions & 4 deletions src/features/policies/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export * from './BudgetSimulator';
etport * from './types';
export * from './PolicyRulesBuilder';
export * from './PolicySimulationSandbox';
export * from './types';
export { default as BudgetSimulator } from './BudgetSimulator';
export { default as PolicyForm } from './components/PolicyForm';
export * from './schemas/policySchema';
export { PolicySandboxWidget } from './PolicySandboxWidget';
export { default as PolicyRulesBuilder } from './PolicyRulesBuilder';
export { default as PolicySimulationSandbox } from './PolicySimulationSandbox';
160 changes: 160 additions & 0 deletions src/features/policies/rulesSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,13 +90,173 @@ export const ruleSchema = z.object({

export type PolicyRule = z.infer<typeof ruleSchema>;

export interface PolicySimulationInput {
amount: string;
assetCode: string;
destinationAddress: string;
agentId: string;
}

export interface PolicyRuleEvaluation {
passed: boolean;
reason: string;
}

export const defaultRule: PolicyRule = {
field: 'Transaction Amount',
operator: 'greater_than',
value: '1000',
action: 'require_approval',
};

export const defaultPolicyRules: PolicyRule[] = [
{
field: 'Transaction Amount',
operator: 'greater_than',
value: '2500',
action: 'require_approval',
},
{
field: 'Approved Account Whitelist',
operator: 'in_whitelist',
value: 'G...A1, G...B2',
action: 'allow',
},
];

export function validateRule(input: Partial<PolicyRule>) {
return ruleSchema.safeParse(input);
}

function normalizeWhitespace(value: string) {
return value.trim().replace(/\s+/g, ' ');
}

export function evaluateRuleAgainstTransaction(
rule: PolicyRule,
transaction: PolicySimulationInput,
): PolicyRuleEvaluation {
const cleanedValue = normalizeWhitespace(rule.value);
const { amount, assetCode, destinationAddress } = transaction;

switch (rule.field) {
case 'Transaction Amount': {
const parsedAmount = Number(amount);
const target = Number(cleanedValue);
const isNumeric = Number.isFinite(parsedAmount) && Number.isFinite(target);

if (!isNumeric) {
return {
passed: false,
reason: `Unable to compare ${amount} to the configured rule value ${cleanedValue}.`,
};
}

if (rule.operator === 'greater_than') {
return {
passed: parsedAmount > target,
reason: `${parsedAmount} is ${parsedAmount > target ? 'above' : 'not above'} the threshold of ${target}.`,
};
}
if (rule.operator === 'less_than') {
return {
passed: parsedAmount < target,
reason: `${parsedAmount} is ${parsedAmount < target ? 'below' : 'not below'} the threshold of ${target}.`,
};
}
return {
passed: parsedAmount === target,
reason: `${parsedAmount} ${parsedAmount === target ? 'matches' : 'does not match'} the threshold of ${target}.`,
};
}

case 'Asset Identifier': {
const transactionAsset = normalizeWhitespace(assetCode).toUpperCase();
const ruleValue = cleanedValue.toUpperCase();

if (rule.operator === 'contains') {
return {
passed: transactionAsset.includes(ruleValue),
reason: `${transactionAsset} ${transactionAsset.includes(ruleValue) ? 'contains' : 'does not contain'} ${ruleValue}.`,
};
}

return {
passed: transactionAsset === ruleValue,
reason: `${transactionAsset} ${transactionAsset === ruleValue ? 'matches' : 'does not match'} ${ruleValue}.`,
};
}

case 'Destination Target': {
const destination = normalizeWhitespace(destinationAddress);
const targetValue = cleanedValue;

if (rule.operator === 'contains') {
return {
passed: destination.includes(targetValue),
reason: `${destination} ${destination.includes(targetValue) ? 'includes' : 'does not include'} ${targetValue}.`,
};
}

return {
passed: destination === targetValue,
reason: `${destination} ${destination === targetValue ? 'matches' : 'does not match'} ${targetValue}.`,
};
}

case 'Approved Account Whitelist': {
const destinations = cleanedValue
.split(',')
.map((entry) => normalizeWhitespace(entry))
.filter(Boolean);

const destination = normalizeWhitespace(destinationAddress);
const includesTarget = destinations.includes(destination) || destinations.some((entry) => entry.toLowerCase() === destination.toLowerCase());

if (rule.operator === 'in_whitelist') {
return {
passed: includesTarget,
reason: `Destination ${destination} ${includesTarget ? 'is approved' : 'is not in the whitelist'} (${destinations.join(', ') || 'empty list'}).`,
};
}

if (rule.operator === 'contains') {
return {
passed: destinations.some((entry) => destination.includes(entry)),
reason: `${destination} ${destinations.some((entry) => destination.includes(entry)) ? 'matches' : 'does not match'} an approved destination entry.`,
};
}

return {
passed: destination === cleanedValue,
reason: `${destination} ${destination === cleanedValue ? 'matches' : 'does not match'} the configured account target.`,
};
}

default:
return {
passed: true,
reason: 'No rule logic defined for this field.',
};
}
}

export function evaluatePolicyRules(rules: PolicyRule[], transaction: PolicySimulationInput) {
const matches = new Map<number, PolicyRuleEvaluation>();
const failingRules: Array<{ index: number; rule: PolicyRule; evaluation: PolicyRuleEvaluation }> = [];

rules.forEach((rule, index) => {
const evaluation = evaluateRuleAgainstTransaction(rule, transaction);
matches.set(index, evaluation);

if (!evaluation.passed) {
failingRules.push({ index, rule, evaluation });
}
});

return {
passed: failingRules.length === 0,
matches,
failingRules,
};
}
Loading