feat: add escrow:arbitrate CLI command for manual arbitration

Add nla escrow:arbitrate with support for a specific escrow UID or "all"
to scan for unarbitrated fulfillments demanding the user as oracle.
Supports interactive mode (approve/reject/skip) and --auto mode using
the LLM specified in the escrow's demand. Update nla-arbitrate skill
to reference the new CLI command instead of raw SDK scripts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
疒奀 2026-03-05 19:39:12 +08:00
parent c01364a594
commit ba498be935
4 changed files with 448 additions and 117 deletions

View File

@ -132,6 +132,7 @@ nla stop
| `nla escrow:fulfill [options]` | Submit fulfillment for an escrow |
| `nla escrow:collect [options]` | Collect approved escrow funds |
| `nla escrow:status --escrow-uid <uid>` | Check escrow status |
| `nla escrow:arbitrate [options]` | Manually arbitrate escrow fulfillments |
### Environment Management

View File

@ -0,0 +1,381 @@
#!/usr/bin/env node
/**
* CLI tool to manually arbitrate NLA escrow fulfillments
*
* This allows an oracle operator to manually review and submit
* arbitration decisions for escrow fulfillments, as an alternative
* to the automated oracle listener.
*/
import { parseArgs } from "util";
import { createWalletClient, createPublicClient, http, publicActions, fromHex } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { makeClient } from "alkahest-ts";
import { contracts } from "alkahest-ts";
import { makeLLMClient } from "../..";
import { ProviderName } from "../../nla";
import {
getCurrentEnvironment,
getChainFromNetwork,
loadDeploymentWithDefaults,
getPrivateKey,
loadEnvFile,
} from "../utils.js";
import { existsSync } from "fs";
import { resolve } from "path";
import { createInterface } from "readline";
// Helper function to display usage
function displayHelp() {
console.log(`
Natural Language Agreement - Arbitrate Escrow Fulfillments
Manually review and submit arbitration decisions for escrow fulfillments.
Your wallet address must match the oracle specified in the escrow.
Usage:
nla escrow:arbitrate [options]
Options:
--escrow-uid <uid|all> Escrow UID to arbitrate, or "all" to scan for pending requests (required)
--private-key <key> Oracle operator's private key
--deployment <file> Load addresses from deployment file (optional)
--rpc-url <url> RPC URL (optional, from deployment file)
--auto Auto-arbitrate using LLM (skip interactive confirmation)
--openai-api-key <key> OpenAI API key (for auto mode)
--anthropic-api-key <key> Anthropic API key (for auto mode)
--openrouter-api-key <key> OpenRouter API key (for auto mode)
--perplexity-api-key <key> Perplexity API key (for auto mode)
--env <file> Path to .env file (default: .env)
--help, -h Display this help message
Examples:
# Arbitrate a specific escrow (interactive)
nla escrow:arbitrate --escrow-uid 0x...
# Scan for all pending arbitration requests
nla escrow:arbitrate --escrow-uid all
# Auto-arbitrate using LLM (non-interactive)
nla escrow:arbitrate --escrow-uid 0x... --auto
`);
}
// Parse command line arguments
function parseCliArgs() {
const { values } = parseArgs({
args: process.argv.slice(2),
options: {
"escrow-uid": { type: "string" },
"private-key": { type: "string" },
"deployment": { type: "string" },
"rpc-url": { type: "string" },
"auto": { type: "boolean" },
"openai-api-key": { type: "string" },
"anthropic-api-key": { type: "string" },
"openrouter-api-key": { type: "string" },
"perplexity-api-key": { type: "string" },
"env": { type: "string" },
"help": { type: "boolean", short: "h" },
},
strict: true,
});
return values;
}
function prompt(question: string): Promise<string> {
const rl = createInterface({ input: process.stdin, output: process.stdout });
return new Promise((resolve) => {
rl.question(question, (answer) => {
rl.close();
resolve(answer.trim());
});
});
}
type PendingArbitration = {
escrowUid: `0x${string}`;
fulfillmentUid: `0x${string}`;
arbitrationRequestUid: `0x${string}`;
demand: `0x${string}`;
demandText: string;
fulfillmentText: string;
nlaDemand: {
arbitrationProvider: string;
arbitrationModel: string;
arbitrationPrompt: string;
demand: string;
};
};
async function main() {
try {
const args = parseCliArgs();
if (args.help) {
displayHelp();
process.exit(0);
}
// Load .env file
const envPath = args.env || ".env";
const resolvedEnvPath = resolve(process.cwd(), envPath);
if (existsSync(resolvedEnvPath)) {
loadEnvFile(resolvedEnvPath);
}
const escrowUidArg = args["escrow-uid"];
const privateKey = args["private-key"] || getPrivateKey();
const deploymentFile = args["deployment"];
const autoMode = args["auto"] || false;
if (!escrowUidArg) {
console.error("❌ Error: --escrow-uid is required (use a UID or \"all\")");
console.error("Run with --help for usage information.");
process.exit(1);
}
if (!privateKey) {
console.error("❌ Error: Private key is required");
console.error("\n💡 You can either:");
console.error(" 1. Set it globally: nla wallet:set --private-key <your-key>");
console.error(" 2. Use for this command only: --private-key <your-key>");
console.error(" 3. Set PRIVATE_KEY environment variable");
process.exit(1);
}
// Load deployment
const deployment = loadDeploymentWithDefaults(deploymentFile);
const rpcUrl = args["rpc-url"] || deployment.rpcUrl;
const chain = getChainFromNetwork(deployment.network);
const addresses = deployment.addresses;
// Create clients
const account = privateKeyToAccount(privateKey as `0x${string}`);
const walletClient = createWalletClient({
account,
chain,
transport: http(rpcUrl),
}).extend(publicActions) as any;
const publicClient = createPublicClient({
chain,
transport: http(rpcUrl),
});
const client = makeClient(walletClient, addresses);
const llmClient = makeLLMClient([]);
console.log(`⚖️ NLA Manual Arbitration\n`);
console.log(` Oracle address: ${account.address}`);
console.log(` Network: ${deployment.network}`);
console.log(` RPC URL: ${rpcUrl}\n`);
// Set up LLM providers for auto mode
if (autoMode) {
const openaiApiKey = args["openai-api-key"] || process.env.OPENAI_API_KEY;
const anthropicApiKey = args["anthropic-api-key"] || process.env.ANTHROPIC_API_KEY;
const openrouterApiKey = args["openrouter-api-key"] || process.env.OPENROUTER_API_KEY;
const perplexityApiKey = args["perplexity-api-key"] || process.env.PERPLEXITY_API_KEY;
if (!openaiApiKey && !anthropicApiKey && !openrouterApiKey) {
console.error("❌ Error: Auto mode requires at least one LLM provider API key.");
console.error(" Set OPENAI_API_KEY, ANTHROPIC_API_KEY, or OPENROUTER_API_KEY");
process.exit(1);
}
if (openaiApiKey) {
llmClient.addProvider({ providerName: ProviderName.OpenAI, apiKey: openaiApiKey, perplexityApiKey });
}
if (anthropicApiKey) {
llmClient.addProvider({ providerName: ProviderName.Anthropic, apiKey: anthropicApiKey, perplexityApiKey });
}
if (openrouterApiKey) {
llmClient.addProvider({ providerName: ProviderName.OpenRouter, apiKey: openrouterApiKey, perplexityApiKey });
}
}
// Find pending arbitration requests
const pending: PendingArbitration[] = [];
console.log("🔍 Scanning for arbitration requests...\n");
// Get all Attested events from EAS
const filter = await publicClient.createContractEventFilter({
address: addresses.eas as `0x${string}`,
abi: contracts.IEAS.abi.abi,
eventName: "Attested",
fromBlock: 0n,
});
const events = await publicClient.getFilterLogs({ filter });
// Determine which escrows to check
const scanAll = escrowUidArg.toLowerCase() === "all";
const targetEscrowUid = scanAll ? null : escrowUidArg as `0x${string}`;
// Build a map of all attestation UIDs for quick lookup
const attestationUids = new Set(events.map((e: any) => e.args?.uid?.toLowerCase()));
// Find fulfillments (attestations that reference an escrow)
for (const event of events) {
const fulfillmentUid = (event as any).args?.uid as `0x${string}`;
const refUid = (event as any).args?.refUID as `0x${string}`;
if (!fulfillmentUid || !refUid) continue;
// Skip if we're targeting a specific escrow and this doesn't match
if (!scanAll && refUid.toLowerCase() !== targetEscrowUid!.toLowerCase()) continue;
// Check if refUid points to a valid escrow
// A fulfillment references an escrow, and an arbitration decision references a fulfillment
// We need to check if this is a fulfillment (not an arbitration decision)
let escrowAttestation: any;
try {
escrowAttestation = await publicClient.readContract({
address: addresses.eas as `0x${string}`,
abi: contracts.IEAS.abi.abi,
functionName: "getAttestation",
args: [refUid],
});
} catch {
continue;
}
// Try to decode as an escrow obligation to verify it's actually an escrow
let escrowData: any;
try {
escrowData = client.erc20.escrow.nonTierable.decodeObligation(escrowAttestation.data);
} catch {
continue; // Not an escrow
}
// Decode the demand to check if our address is the oracle
let trustedOracleDemand: any;
let nlaDemand: any;
try {
trustedOracleDemand = client.arbiters.general.trustedOracle.decodeDemand(escrowData.demand);
nlaDemand = llmClient.decodeDemand(trustedOracleDemand.data);
} catch {
continue; // Not an NLA demand or not using trusted oracle arbiter
}
// Check if we are the oracle for this escrow
if (trustedOracleDemand.oracle.toLowerCase() !== account.address.toLowerCase()) {
continue;
}
// Check if this fulfillment already has an arbitration decision
const hasDecision = events.some((e) => {
const eRefUid = (e as any).args?.refUID;
return eRefUid && eRefUid.toLowerCase() === fulfillmentUid.toLowerCase();
});
if (hasDecision) continue;
// Decode the fulfillment text
let fulfillmentText: string;
try {
const fulfillmentAttestation = await publicClient.readContract({
address: addresses.eas as `0x${string}`,
abi: contracts.IEAS.abi.abi,
functionName: "getAttestation",
args: [fulfillmentUid],
});
const commitRevealData = client.commitReveal.decode((fulfillmentAttestation as any).data);
fulfillmentText = fromHex(commitRevealData.payload, "string");
} catch {
continue; // Can't decode fulfillment
}
pending.push({
escrowUid: refUid,
fulfillmentUid,
arbitrationRequestUid: fulfillmentUid,
demand: escrowData.demand,
demandText: nlaDemand.demand,
fulfillmentText,
nlaDemand,
});
}
if (pending.length === 0) {
if (scanAll) {
console.log("✅ No pending arbitration requests found for your oracle address.\n");
} else {
console.log(`✅ No pending arbitration requests found for escrow ${escrowUidArg}.\n`);
console.log(" This could mean:");
console.log(" - No fulfillments have been submitted yet");
console.log(" - All fulfillments have already been arbitrated");
console.log(" - Your address is not the oracle for this escrow");
}
process.exit(0);
}
console.log(`📋 Found ${pending.length} pending arbitration request(s):\n`);
// Process each pending arbitration
for (const item of pending) {
console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
console.log(`📦 Escrow: ${item.escrowUid}`);
console.log(`📨 Fulfillment: ${item.fulfillmentUid}`);
console.log(`📝 Demand: "${item.demandText}"`);
console.log(`💬 Fulfillment: "${item.fulfillmentText}"`);
console.log(`🤖 Provider: ${item.nlaDemand.arbitrationProvider} / ${item.nlaDemand.arbitrationModel}`);
console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
let decision: boolean;
if (autoMode) {
// Use LLM to arbitrate
console.log(`🤔 Arbitrating with ${item.nlaDemand.arbitrationProvider}...`);
decision = await llmClient.arbitrate(item.nlaDemand, item.fulfillmentText);
console.log(` LLM decision: ${decision ? "✅ APPROVE" : "❌ REJECT"}\n`);
} else {
// Interactive mode - ask the user
const answer = await prompt(" Enter decision (approve/reject): ");
const normalized = answer.toLowerCase();
if (normalized === "approve" || normalized === "a" || normalized === "yes" || normalized === "y" || normalized === "true") {
decision = true;
} else if (normalized === "reject" || normalized === "r" || normalized === "no" || normalized === "n" || normalized === "false") {
decision = false;
} else if (normalized === "skip" || normalized === "s") {
console.log(" Skipped.\n");
continue;
} else {
console.log(" Unrecognized input, skipping.\n");
continue;
}
}
// Submit the decision on-chain
console.log(`📤 Submitting ${decision ? "APPROVE" : "REJECT"} decision on-chain...`);
try {
const { unwatch } = await client.arbiters.general.trustedOracle.arbitrateMany(
async () => decision,
{
onAfterArbitrate: async (result: any) => {
console.log(` ✅ Decision recorded!`);
console.log(` Decision UID: ${result.attestation.uid}`);
console.log(` Result: ${result.decision ? "APPROVED" : "REJECTED"}\n`);
},
pollingInterval: 1000,
}
);
// Wait briefly for the arbitration to be picked up and processed
await new Promise(resolve => setTimeout(resolve, 3000));
unwatch();
} catch (error: any) {
console.error(` ❌ Failed to submit decision: ${error.message}\n`);
}
}
console.log("✨ Arbitration complete!\n");
} catch (error) {
console.error("❌ Fatal error:", error);
process.exit(1);
}
}
main();

View File

@ -39,6 +39,7 @@ Commands:
escrow:fulfill Fulfill an existing escrow
escrow:collect Collect an approved escrow
escrow:status Check the status of an escrow
escrow:arbitrate Manually arbitrate escrow fulfillments
help Display this help message
Options (vary by command):
@ -98,6 +99,12 @@ Examples:
# Check escrow status
nla escrow:status --escrow-uid 0x...
# Manually arbitrate a specific escrow
nla escrow:arbitrate --escrow-uid 0x...
# Scan for all pending arbitration requests
nla escrow:arbitrate --escrow-uid all
`);
}
@ -135,6 +142,11 @@ function parseCliArgs() {
"arbitration-prompt": { type: "string" },
"env": { type: "string" },
"environment": { type: "string" },
"auto": { type: "boolean" },
"openai-api-key": { type: "string" },
"anthropic-api-key": { type: "string" },
"openrouter-api-key": { type: "string" },
"perplexity-api-key": { type: "string" },
"help": { type: "boolean", short: "h" },
},
strict: command !== "switch" && command !== "network", // Allow positional args for switch command
@ -235,6 +247,9 @@ async function main() {
case "escrow:status":
scriptPath = "./client/status-escrow.js";
break;
case "escrow:arbitrate":
scriptPath = "./client/arbitrate-escrow.js";
break;
default:
console.error(`❌ Unknown command: ${command}`);
console.error("Run 'nla help' for usage information.");

View File

@ -1,167 +1,101 @@
---
name: nla-arbitrate
description: Manually arbitrate NLA escrows as an alternative to the automated oracle. Use when the user wants to act as an AI arbiter themselves - fetching pending escrows that name the agent as oracle, evaluating demands against fulfillments, and submitting on-chain arbitration decisions via the alkahest TypeScript SDK.
description: Manually arbitrate NLA escrow fulfillments as an alternative to the automated oracle. Use when the user wants to review pending arbitration requests, evaluate demands against fulfillments, and submit on-chain decisions. Supports both interactive and LLM-auto modes.
metadata:
author: arkhai
version: "1.0"
compatibility: Requires bun or node. Requires alkahest-ts and nla packages. Requires a funded Ethereum wallet whose address matches the oracle specified in escrows.
allowed-tools: Bash Read Write
compatibility: Requires nla CLI installed (npm install -g nla). Requires a funded Ethereum wallet whose address matches the oracle specified in escrows.
allowed-tools: Bash(nla:*) Read
---
# Manual NLA Arbitration
Act as an AI arbiter for NLA escrows, bypassing the automated oracle listener. Fetch escrow data, evaluate demands against fulfillments, and submit arbitration decisions on-chain.
Manually arbitrate escrow fulfillments using the `nla escrow:arbitrate` CLI command, bypassing the automated oracle listener.
## When to use this
- The user wants to manually review and decide on escrow fulfillments instead of relying on the automated oracle
- The user wants to manually review and decide on escrow fulfillments
- The user is the oracle (their wallet address was specified as the oracle when escrows were created)
- The automated oracle is not running, or the user wants more control over decisions
## Step-by-step instructions
### 1. Determine the oracle address
### 1. Verify oracle identity
The user's wallet address must be the oracle specified in the escrow. Check:
The user's wallet must be the oracle address specified in the escrow:
```bash
nla wallet:show
```
### 2. Fetch pending arbitration requests
### 2a. Arbitrate a specific escrow
Write and run a TypeScript script to find escrows pending arbitration. The script uses the alkahest-ts SDK to query on-chain state.
To review fulfillments for a known escrow UID:
```typescript
import { createWalletClient, createPublicClient, http, publicActions, fromHex } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { makeClient } from "alkahest-ts";
import { contracts } from "alkahest-ts";
import { makeLLMClient } from "nla";
```bash
# Interactive mode - prompts for approve/reject
nla escrow:arbitrate --escrow-uid <uid>
// Setup - adjust these for the user's environment
const deployment = JSON.parse(require("fs").readFileSync("<deployment-file>", "utf-8"));
// Deployment files are at: cli/deployments/anvil.json, sepolia.json, base-sepolia.json, mainnet.json
// Or detect automatically using nla's utils
const chain = /* appropriate viem chain */;
const account = privateKeyToAccount("<private-key>" as `0x${string}`);
const walletClient = createWalletClient({
account,
chain,
transport: http(deployment.rpcUrl),
}).extend(publicActions);
const client = makeClient(walletClient as any, deployment.addresses);
const llmClient = makeLLMClient([]);
const publicClient = createPublicClient({
chain,
transport: http(deployment.rpcUrl),
});
// Query all Attested events from EAS
const filter = await publicClient.createContractEventFilter({
address: deployment.addresses.eas as `0x${string}`,
abi: contracts.IEAS.abi.abi,
eventName: "Attested",
fromBlock: 0n,
});
const events = await publicClient.getFilterLogs({ filter });
// For each event, check if it's an arbitration request targeting our oracle address
// Arbitration requests reference a fulfillment UID which in turn references an escrow UID
# Auto mode - uses the LLM specified in the escrow's demand
nla escrow:arbitrate --escrow-uid <uid> --auto
```
### 3. Decode escrow and fulfillment data
### 2b. Scan for all pending requests
For a specific escrow UID:
To find all unarbitrated fulfillments where the user is the oracle:
```typescript
// Get the escrow attestation
const escrow = await client.getAttestation(escrowUid);
const escrowData = client.erc20.escrow.nonTierable.decodeObligation(escrow.data);
```bash
# Interactive mode
nla escrow:arbitrate --escrow-uid all
// The demand is double-encoded:
// Layer 1: TrustedOracleArbiter demand (contains oracle address + inner data)
const trustedOracleDemand = client.arbiters.general.trustedOracle.decodeDemand(escrowData.demand);
// trustedOracleDemand.oracle - the oracle address (should match user's address)
// trustedOracleDemand.data - the inner NLA demand data
// Layer 2: NLA LLM demand (contains provider, model, prompt, demand text)
const nlaDemand = llmClient.decodeDemand(trustedOracleDemand.data);
// nlaDemand.demand - the natural language demand text
// nlaDemand.arbitrationProvider - e.g. "OpenAI"
// nlaDemand.arbitrationModel - e.g. "gpt-4o-mini"
// nlaDemand.arbitrationPrompt - the prompt template with {{demand}} and {{obligation}} placeholders
// Get fulfillment data (fulfillments use CommitRevealObligation)
const fulfillment = await client.getAttestation(fulfillmentUid);
const commitRevealData = client.commitReveal.decode(fulfillment.data);
const fulfillmentText = fromHex(commitRevealData.payload, "string");
# Auto mode
nla escrow:arbitrate --escrow-uid all --auto
```
### 4. Evaluate the fulfillment
### 3. Review and decide
Present the decoded data to the user (or evaluate it yourself as the AI agent):
In **interactive mode**, the command displays each pending fulfillment with:
- Escrow UID and fulfillment UID
- The demand text
- The fulfillment text
- The arbitration provider/model specified
- **Demand**: The natural language condition
- **Fulfillment**: The submitted text
- **Arbitration prompt**: The template that guides evaluation
- **Model/Provider**: What was originally specified (informational for manual arbitration)
Then prompts for a decision: `approve`, `reject`, or `skip`.
Apply the arbitration prompt logic: substitute `{{demand}}` and `{{obligation}}` with actual values, then determine if the fulfillment satisfies the demand. The result is a boolean: `true` (approved) or `false` (rejected).
In **auto mode** (`--auto`), the command uses the LLM provider/model specified in the escrow's demand to arbitrate automatically. Requires at least one LLM API key via environment variables or flags (`--openai-api-key`, `--anthropic-api-key`, `--openrouter-api-key`).
### 5. Submit the arbitration decision
### 4. Verify
Use the `arbitrateMany` callback or submit directly:
```typescript
// Option A: Use arbitrateMany with a custom callback (handles polling and submission)
const { unwatch } = await client.arbiters.general.trustedOracle.arbitrateMany(
async ({ attestation, demand }) => {
// decode and evaluate as shown above
// return true or false
return decision;
},
{
onAfterArbitrate: async (result) => {
console.log(`Decision UID: ${result.attestation.uid}`);
console.log(`Result: ${result.decision ? "APPROVED" : "REJECTED"}`);
},
pollingInterval: 1000,
}
);
// Option B: For one-shot arbitration, use arbitrateMany with a short polling interval
// and call unwatch() after the decision is submitted
```
### 6. Verify the decision
After arbitration, check the result:
```bash
nla escrow:status --escrow-uid <escrow_uid>
```
Confirm the arbitration decision appears in the status output.
## Key details
- The user's wallet address MUST match the oracle address in the escrow's demand - otherwise the on-chain arbiter contract will reject the decision
- Demands are double-encoded: TrustedOracleArbiter wraps NLA LLM demand data
- Fulfillments use CommitRevealObligation - decode with `client.commitReveal.decode()`, then `fromHex(payload, "string")`
- The `arbitrateMany` method handles both polling for pending requests and submitting decisions on-chain
- Each arbitration decision is recorded as an on-chain attestation (permanent and immutable)
- Deployment files with contract addresses are at `cli/deployments/<network>.json`
- Use `nla network` to check which network is currently active
- The user's wallet address MUST match the oracle address in the escrow - otherwise the on-chain contract rejects the decision
- Each arbitration decision is recorded as a permanent on-chain attestation
- In interactive mode, type `skip` or `s` to skip a fulfillment without deciding
- Auto mode reads LLM API keys from environment variables (OPENAI_API_KEY, etc.) or CLI flags
- If no pending requests are found, the command explains possible reasons (no fulfillments yet, already arbitrated, or wrong oracle address)
## Alternative: use nla escrow:status for read-only inspection
## Prerequisites
If the user just wants to inspect escrow state without submitting decisions:
- `nla` CLI installed and configured
- Private key set via `nla wallet:set`, `--private-key` flag, or `PRIVATE_KEY` env var
- ETH in the oracle's account for gas (submitting decisions costs gas)
- For auto mode: at least one LLM provider API key
## Examples
```bash
# View escrow details, fulfillments, and existing arbitration results
nla escrow:status --escrow-uid <uid>
```
# Scan for all pending requests, decide interactively
nla escrow:arbitrate --escrow-uid all
This does not require being the oracle.
# Auto-arbitrate a specific escrow using LLM
nla escrow:arbitrate --escrow-uid 0xabc123... --auto
# Auto-arbitrate all pending, with explicit API key
nla escrow:arbitrate --escrow-uid all --auto --openai-api-key sk-...
```