diff --git a/src/app/(dashboard)/policies/page.tsx b/src/app/(dashboard)/policies/page.tsx index 0274172..699131c 100644 --- a/src/app/(dashboard)/policies/page.tsx +++ b/src/app/(dashboard)/policies/page.tsx @@ -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'; @@ -58,6 +59,9 @@ export default function PoliciesPage() { Spending rule designer + Policy simulation sandbox + + {data.length} policies
{data.map((policy) => ( diff --git a/src/features/policies/PolicyRulesBuilder.tsx b/src/features/policies/PolicyRulesBuilder.tsx index 45bc6da..efd2c5b 100644 --- a/src/features/policies/PolicyRulesBuilder.tsx +++ b/src/features/policies/PolicyRulesBuilder.tsx @@ -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, @@ -16,20 +17,7 @@ import { export function PolicyRulesBuilder() { const [draft, setDraft] = useState(defaultRule); - const [rules, setRules] = useState([ - { - 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(defaultPolicyRules); const [error, setError] = useState(null); const [simulationAmount, setSimulationAmount] = useState(500); diff --git a/src/features/policies/PolicySimulationSandbox.tsx b/src/features/policies/PolicySimulationSandbox.tsx new file mode 100644 index 0000000..b67d21e --- /dev/null +++ b/src/features/policies/PolicySimulationSandbox.tsx @@ -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 ( + + +
+
+

+ Rule tester +

+ Simulation sandbox +
+ + {result.passed ? 'Pass' : 'Fail'} + +
+
+ + +
+ + + + + + + +
+ +
+ + + Evaluating {defaultPolicyRules.length} active rules against this payload. + +
+ +
+
+ {result.passed ? ( + + ) : ( + + )} +

+ {result.passed ? 'Transaction passes all configured rules.' : 'Transaction violates one or more rules.'} +

+
+ + {result.failingRules.length > 0 ? ( +
    + {result.failingRules.map(({ rule, evaluation }, index) => ( +
  • +
    + {rule.field} + + {rule.action.replace(/_/g, ' ')} + +
    +

    {evaluation.reason}

    +
  • + ))} +
+ ) : ( +
+ + No rule violations for this payload. +
+ )} +
+ +
+

+ Rule result JSON +

+
+            {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,
+            )}
+          
+
+
+
+ ); +} + +export default PolicySimulationSandbox; diff --git a/src/features/policies/index.ts b/src/features/policies/index.ts index bbac7e9..09ace33 100644 --- a/src/features/policies/index.ts +++ b/src/features/policies/index.ts @@ -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'; \ No newline at end of file +export { default as PolicyRulesBuilder } from './PolicyRulesBuilder'; +export { default as PolicySimulationSandbox } from './PolicySimulationSandbox'; diff --git a/src/features/policies/rulesSchema.ts b/src/features/policies/rulesSchema.ts index ab62f76..47fc4e0 100644 --- a/src/features/policies/rulesSchema.ts +++ b/src/features/policies/rulesSchema.ts @@ -90,6 +90,18 @@ export const ruleSchema = z.object({ export type PolicyRule = z.infer; +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', @@ -97,6 +109,154 @@ export const defaultRule: PolicyRule = { 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) { 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(); + 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, + }; +}