382 lines
15 KiB
JavaScript
382 lines
15 KiB
JavaScript
#!/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();
|