Skill: billynothack/confidential-agentic-payment-stack (version 1.0.0)

--- name: fhe-x402-payment description: FHE-encrypted x402 payments for OpenClaw agents. Use when the agent needs to make private on-chain payments, wrap/unwrap encrypted tokens, manage escrow jobs, register agent identity, give reputation feedback, or delegate balance viewing. Runs on Ethereum Sepolia (default) or Mainnet with Zama fhEVM. Supports three wallet modes — local private key, DFNS MPC, and Ledger hardware wallet. version: 1.0.0 metadata: openclaw: requires: env: - RPC_URL anyBins: - node primaryEnv: USER_PRIVATE_KEY emoji: "🔐" homepage: "https://gitlab.com/bea7892046/fhex402" --- # FHE x402 Payment Skill Private, encrypted payments for autonomous agents on Ethereum using Zama's Fully Homomorphic Encryption (fhEVM). All payment amounts are encrypted on-chain — only the payer and payee can see them. ## Quick Start Set a wallet and RPC, then run any command: ```bash # Minimal setup (local dev) export USER_PRIVATE_KEY=0x... export RPC_URL=https://sepolia.infura.io/v3/YOUR_KEY # Check wallet info run info # Wrap 10 USDC into encrypted cUSDC run wrap --amount 10 # Send 1 encrypted cUSDC run pay --to 0xRecipient --amount 1 # Check balance (with optional decrypt) run balance run balance --decrypt true ``` ## Commands ### Token Operations | Command | Description | Required Args | |---------|-------------|---------------| | `wrap` | Wrap USDC into encrypted cUSDC (ERC-7984) | `--amount` | | `unwrap` | Initiate unwrap of cUSDC back to USDC (step 1 of 2) | `--amount` | | `finalize-unwrap` | Complete unwrap via KMS decryption proof | `--handle` (recommended) or `--requestId --cleartextAmount --proof` (legacy) | | `pay` | Send encrypted cUSDC via verifier relay | `--to --amount` | | `balance` | Check USDC + cUSDC balances | Optional: `--decrypt true`, `--of 0xAddress` | | `info` | Display wallet, network, and contract addresses | (none) | ### Escrow (Agentic Commerce Protocol — ERC-8183) | Command | Description | Required Args | |---------|-------------|---------------| | `create-job` | Create an escrow job with provider + evaluator | `--provider --evaluator --expiry --description` Optional: `--hook` | | `fund-job` | Encrypt budget + fund a job (3-step TX) | `--jobId --amount` | | `complete-job` | Approve or reject a submitted job | `--jobId --action` (approve/reject) Optional: `--reason` | ### Identity & Reputation (ERC-8004) | Command | Description | Required Args | |---------|-------------|---------------| | `register-agent` | Mint an agent identity NFT | `--uri` | | `give-feedback` | Submit proof-linked reputation feedback | `--agentId --score --nonce` Optional: `--tag1 --tag2 --endpoint --feedbackURI --feedbackHash` | ### Delegation (FHE Viewing Keys) | Command | Description | Required Args | |---------|-------------|---------------| | `grant-view` | Grant read access to your encrypted balance | `--delegate` Optional: `--hours --permanent --contract` | | `revoke-view` | Revoke a delegate's view access | `--delegate` | | `view-as` | Read another agent's balance via delegation | `--delegator` | ### Demo Orchestrators | Command | Description | Required Args | |---------|-------------|---------------| | `research-and-visualize` | Chain 3 paid API calls (search + LLM + image) | `--query` | | `review-and-rate` | Buy a code review + submit feedback | `--code` Optional: `--language --score` | ## Wallet Modes Set `WALLET_MODE` to choose explicitly, or omit for auto-detection (DFNS > user key): | Mode | Env Var | Best For | |------|---------|----------| | `user` | `USER_PRIVATE_KEY` | Local development, testing | | `dfns` | `DFNS_WALLET_ID` + `DFNS_AUTH_TOKEN` + `DFNS_CREDENTIAL_ID` + (`DFNS_CREDENTIAL_PRIVATE_KEY` or `DFNS_PRIVATE_KEY_PATH`) | Unattended MPC agents | | `ledger-bridge` | `LEDGER_BRIDGE_URL` + `LEDGER_BRIDGE_TOKEN` | Supervised hardware wallet | ## Environment Variables ### Required | Variable | Description | |----------|-------------| | `RPC_URL` | Ethereum RPC endpoint (default: Sepolia public node) | | `USER_PRIVATE_KEY` or `DFNS_WALLET_ID` | At least one wallet source | ### Optional | Variable | Description | |----------|-------------| | `CHAIN` | `mainnet` or `sepolia` (auto-detected from RPC_URL) | | `CUSDC_ADDRESS` | Override cUSDC token address | | `VERIFIER_ADDRESS` | Override X402PaymentVerifier address | | `ESCROW_ADDRESS` | Override AgenticCommerceProtocol address | | `IDENTITY_ADDRESS` | Override AgentIdentityRegistry address | | `REPUTATION_ADDRESS` | Override AgentReputationRegistry address | All contract addresses auto-fill for Sepolia when `RPC_URL` points to chain ID 11155111. ## How It Works 1. **Encrypted Payments**: Uses Zama's fhEVM to encrypt USDC into cUSDC (ERC-7984). All transfers are confidential — amounts are FHE-encrypted on-chain. 2. **x402 Protocol**: Implements the HTTP 402 payment flow — servers return `402 Payment Required`, the agent encrypts and pays, then retries with a payment proof header. 3. **Escrow**: The AgenticCommerceProtocol (ERC-8183) holds encrypted budgets in escrow. Jobs flow through: create → setBudget → fund → submit → complete/reject. 4. **Identity**: Agents register as ERC-721 NFTs with EIP-712 wallet linking and on-chain metadata. 5. **Reputation**: Proof-of-payment feedback system prevents sybil attacks on agent ratings. ## Deployed Contracts (Sepolia) | Contract | Address | |----------|---------| | cUSDC (ERC-7984) | [REDACTED_SECRET] | | X402PaymentVerifier | [REDACTED_SECRET] | | AgenticCommerceProtocol | [REDACTED_SECRET] | | AgentIdentityRegistry | [REDACTED_SECRET] | | AgentReputationRegistry | [REDACTED_SECRET] | ## Output Format All commands return JSON strings: `{ "ok": true, ... }` on success, `{ "ok": false, "error": "..." }` on failure. Commands never throw — errors are returned as JSON.

path: _paid-request.ts
content: import { FhePayingClient, type FheWallet, type ZamaFheSession } from '../../core/src/index.js';
import { getContracts } from './_wallet.js';

export interface DemoPaymentMetadata {
  amountMicros: bigint;
  txHash: string;
  nonce: string;
  payee: string;
  resourceUrl: string;
  etherscanUrl: string;
  mode?: 'live' | 'mock';
}

export interface DemoPaymentContext {
  wallet: FheWallet;
  session: ZamaFheSession;
  client: FhePayingClient;
  payerAddress: string;
}

interface PaymentChallenge {
  scheme?: string;
  price: string;
  tokenAddress: string;
  verifierAddress: string;
  recipientAddress: string;
  chainId: number;
}

export interface PaidJsonRequestResult<T = any> {
  body: T;
  payment: DemoPaymentMetadata;
}

export interface PaidBinaryRequestResult {
  contentType: string;
  bytes: number;
  headers: Record<string, string>;
  payment: DemoPaymentMetadata;
}

export async function createDemoPaymentContext(maxPayment: bigint = 100_000n): Promise<DemoPaymentContext> {
  const { wallet, session } = await getContracts();
  const payerAddress = await wallet.getAddress();

  const maxUsd = Number(maxPayment) / 1_000_000;
  const client = new FhePayingClient(wallet, session, {
    maxTotalUsd: maxUsd,
    maxPerRequestUsd: maxUsd,
    warnThresholdUsd: maxUsd * 0.8,
  });

  return { wallet, session, client, payerAddress };
}

function buildEtherscanUrl(chainId: number, txHash: string): string {
  const base = chainId === 1
    ? 'https://etherscan.io/tx/'
    : 'https://sepolia.etherscan.io/tx/';
  return `${base}${txHash}`;
}

function cloneHeaders(headers?: HeadersInit): Headers {
  return new Headers(headers);
}

function headersToObject(headers: Headers): Record<string, string> {
  return Object.fromEntries(headers.entries());
}

function latestReceipt(context: DemoPaymentContext, chainId: number, resourceUrl: string): DemoPaymentMetadata {
  const receipt = context.client.getReceipts().at(-1);
  if (!receipt) {
    throw new Error('Payment created but no receipt was recorded');
  }

  return {
    amountMicros: receipt.amountMicros,
    txHash: receipt.txHash,
    nonce: receipt.nonce,
    payee: receipt.payee,
    resourceUrl: receipt.resourceUrl ?? resourceUrl,
    etherscanUrl: buildEtherscanUrl(chainId, receipt.txHash),
  };
}

async function createMockMetadata(url: string): Promise<DemoPaymentMetadata> {
  return {
    amountMicros: 0n,
    txHash: 'mock-unpaid',
    nonce: 'mock-unpaid',
    payee: url,
    resourceUrl: url,
    etherscanUrl: '',
    mode: 'mock',
  };
}

function parseChallenge(response: Response): PaymentChallenge {
  const header = response.headers.get('x-payment-required');
  if (!header) {
    throw new Error('402 without X-Payment-Required header');
  }

  let parsed: unknown;
  try {
    parsed = JSON.parse(header);
  } catch {
    throw new Error('Malformed X-Payment-Required header');
  }

  const challenge = parsed as Partial<PaymentChallenge>;
  if (
    !challenge.price ||
    !challenge.tokenAddress ||
    !challenge.verifierAddress ||
    !challenge.recipientAddress ||
    challenge.chainId === undefined
  ) {
    throw new Error('Incomplete X-Payment-Required header');
  }

  return challenge as PaymentChallenge;
}

async function executePaidRequest(
  context: DemoPaymentContext,
  url: string,
  init: RequestInit,
  options?: {
    fetchFn?: typeof fetch;
    allowUnpaidSuccess?: boolean;
  },
): Promise<{ response: Response; payment: DemoPaymentMetadata }> {
  const fetchFn = options?.fetchFn ?? globalThis.fetch;
  const allowUnpaidSuccess = options?.allowUnpaidSuccess ?? (process.env.MOCK_PAYWALL === 'true');

  const initial = await fetchFn(url, init);

  if (initial.status !== 402) {
    if (initial.ok && allowUnpaidSuccess) {
      return {
        response: initial,
        payment: await createMockMetadata(url),
      };
    }
    throw new Error(`Expected HTTP 402 challenge from ${url}, got ${initial.status}`);
  }

  const challenge = parseChallenge(initial);
  const proof = await context.client.createPayment({
    price: BigInt(challenge.price),
    payTo: challenge.recipientAddress,
    verifierAddress: challenge.verifierAddress,
    tokenAddress: challenge.tokenAddress,
    chainId: challenge.chainId,
    resourceUrl: url,
  });

  const retryHeaders = cloneHeaders(init.headers);
  retryHeaders.set('x-payment', JSON.stringify(proof));

  const retried = await fetchFn(url, {
    ...init,
    headers: retryHeaders,
  });

  if (!retried.ok) {
    throw new Error(`Paid retry failed for ${url}: ${retried.status}`);
  }

  return {
    response: retried,
    payment: latestReceipt(context, challenge.chainId, url),
  };
}

export async function paidJsonRequest<T = any>(
  context: DemoPaymentContext,
  url: string,
  init: RequestInit,
  options?: {
    fetchFn?: typeof fetch;
    allowUnpaidSuccess?: boolean;
  },
): Promise<PaidJsonRequestResult<T>> {
  const { response, payment } = await executePaidRequest(context, url, init, options);

  let body: T;
  try {
    body = await response.json() as T;
  } catch {
    throw new Error(`Expected JSON response from ${url}`);
  }

  return { body, payment };
}

export async function paidBinaryRequest(
  context: DemoPaymentContext,
  url: string,
  init: RequestInit,
  options?: {
    fetchFn?: typeof fetch;
    allowUnpaidSuccess?: boolean;
  },
): Promise<PaidBinaryRequestResult> {
  const { response, payment } = await executePaidRequest(context, url, init, options);
  const bytes = (await response.arrayBuffer()).byteLength;

  return {
    contentType: response.headers.get('content-type') ?? '',
    bytes,
    headers: headersToObject(response.headers),
    payment,
  };
}


path: balance.ts
content: import { getContracts, ok, fail, formatUSDC, parseCliArgs } from './_wallet.js';

export async function run(args: Record<string, string>): Promise<string> {
  try {
    const { usdc, cUSDC, session, wallet } = await getContracts();
    const ownAddress = await wallet.getAddress();

    // --of: read another agent's balance via delegation
    const targetAddress = args.of ?? ownAddress;
    const isDelegated = !!args.of && args.of.toLowerCase() !== ownAddress.toLowerCase();

    if (isDelegated && !/^0x[a-fA-F0-9]{40}$/.test(args.of)) {
      return fail('--of must be a valid Ethereum address (0x + 40 hex chars)');
    }

    // Public USDC balance (always readable — ERC-20)
    const publicBalance: bigint = await usdc.balanceOf(targetAddress);

    // Encrypted cUSDC balance handle
    const zeroBits = '0x' + '00'.repeat(32);
    let encryptedHandle: string;
    if (isDelegated) {
      // For delegated reads: let errors propagate — a network error is not "zero balance"
      encryptedHandle = await cUSDC.confidentialBalanceOf(targetAddress);
    } else {
      // For own balance: graceful fallback (existing behavior)
      try {
        encryptedHandle = await cUSDC.confidentialBalanceOf(targetAddress);
      } catch {
        encryptedHandle = zeroBits;
      }
    }
    const hasEncryptedBalance = encryptedHandle !== zeroBits;

    // Decrypt own balance if --decrypt flag is set
    let decryptedBalance: string | null = null;
    let decryptedBalanceRaw: string | null = null;
    let delegationError: string | null = null;

    const wantDecrypt = args.decrypt === 'true' || args.decrypt === '';

    if (!isDelegated && wantDecrypt && hasEncryptedBalance) {
      try {
        const cUSDCAddress = await cUSDC.getAddress();
        const balance = await session.decryptBalance(
          encryptedHandle,
          cUSDCAddress,
          ownAddress,
          wallet,
        );
        decryptedBalance = formatUSDC(balance);
        decryptedBalanceRaw = balance.toString();
      } catch (e: unknown) {
        const msg = e instanceof Error ? e.message : String(e);
        if (msg.includes('stub mode') || msg.includes('Cannot create')) {
          delegationError = 'Decrypt requires @zama-fhe/sdk (not available in stub mode)';
        } else {
          throw e;
        }
      }
    }

    // If delegated and has balance, attempt decryption via ReadonlyToken
    if (isDelegated && hasEncryptedBalance) {
      try {
        const cUSDCAddress = await cUSDC.getAddress();
        const readonlyToken = await session.createReadonlyToken(wallet, cUSDCAddress);
        const balance = await readonlyToken.decryptBalanceAs({
          delegatorAddress: targetAddress,
        });
        decryptedBalance = formatUSDC(balance);
        decryptedBalanceRaw = balance.toString();
      } catch (e: unknown) {
        const msg = e instanceof Error ? e.message : String(e);
        const code = (e as any)?.code ?? '';

        // Only delegation-state errors are non-fatal (user can fix by running grant-view)
        // Infrastructure errors (SDK missing, network down, etc.) should propagate as fail()
        // Match by error code (stable), constructor name, AND message substring (defense in depth
        // against SDK version changes renaming error classes)
        const name = (e as any)?.constructor?.name ?? '';
        const isDelegationError =
          code === 'DELEGATION_NOT_FOUND' || name === 'DelegationNotFoundError' || msg.includes('DelegationNotFound') ||
          code === 'DELEGATION_EXPIRED' || name === 'DelegationExpiredError' || msg.includes('DelegationExpired') ||
          code === 'DELEGATION_NOT_PROPAGATED' || name === 'DelegationNotPropagatedError' || msg.includes('DelegationNotPropagated') ||
          code === 'DELEGATION_COOLDOWN' || name === 'DelegationCooldownError' || msg.includes('DelegationCooldown');

        if (code === 'DELEGATION_NOT_FOUND' || name === 'DelegationNotFoundError' || msg.includes('DelegationNotFound')) {
          delegationError = 'No delegation — ask the owner to run grant-view --delegate ' + ownAddress;
        } else if (code === 'DELEGATION_EXPIRED' || name === 'DelegationExpiredError' || msg.includes('DelegationExpired')) {
          delegationError = 'Delegation expired — ask the owner to run grant-view again';
        } else if (code === 'DELEGATION_NOT_PROPAGATED' || name === 'DelegationNotPropagatedError' || msg.includes('DelegationNotPropagated')) {
          delegationError = 'Delegation not yet propagated — wait 1-2 minutes after grant-view';
        } else if (code === 'DELEGATION_COOLDOWN' || name === 'DelegationCooldownError' || msg.includes('DelegationCooldown')) {
          delegationError = 'Delegation cooldown — wait a few minutes before delegating again';
        } else {
          // Infrastructure error — not a delegation-state problem. Fail hard.
          throw e;
        }
      }
    }

    return ok({
      action: 'balance',
      walletAddress: targetAddress,
      ...(isDelegated ? { viewingAs: ownAddress } : {}),
      publicBalance: publicBalance.toString(),
      publicBalanceUSDC: formatUSDC(publicBalance),
      hasEncryptedBalance,
      encryptedBalanceHandle: encryptedHandle,
      ...(decryptedBalance !== null ? { decryptedBalance, decryptedBalanceRaw } : {}),
      ...(delegationError !== null ? { delegationError } : {}),
    });
  } catch (e: unknown) {
    return fail(e instanceof Error ? e.message : String(e));
  }
}

// CLI entrypoint
if (import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith('balance.ts')) {
  const args = parseCliArgs(process.argv.slice(2));
  run(args).then(console.log);
}


path: wrap.ts
content: import { getContracts, parseAmount, ok, fail, parseCliArgs } from './_wallet.js';

export async function run(args: Record<string, string>): Promise<string> {
  try {
    if (!args.amount) {
      return fail('--amount is required');
    }

    const rawAmount = parseAmount(args.amount);
    const { usdc, cUSDC, wallet } = await getContracts();
    const address = await wallet.getAddress();

    await usdc.approve(await cUSDC.getAddress(), rawAmount, { gasLimit: 100_000n });

    const tx = await cUSDC.wrap(address, rawAmount, { gasLimit: 500_000n });
    const receipt = await tx.wait();

    return ok({
      action: 'wrap',
      amount: args.amount,
      txHash: receipt.hash,
      blockNumber: receipt.blockNumber,
    });
  } catch (e: unknown) {
    return fail(e instanceof Error ? e.message : String(e));
  }
}

// CLI entrypoint
if (import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith('wrap.ts')) {
  const args = parseCliArgs(process.argv.slice(2));
  run(args).then(console.log);
}


path: info.ts
content: import { getContracts, ok, fail, parseCliArgs } from './_wallet.js';

export async function run(args: Record<string, string>): Promise<string> {
  try {
    const { wallet, provider, cUSDC, verifier, identity, reputation, escrow } =
      await getContracts();

    const address = await wallet.getAddress();
    const ethBalance = await provider.getBalance(address);

    const network = await provider.getNetwork();
    const networkName =
      network.chainId === 1n ? 'Ethereum Mainnet' :
      network.chainId === 11155111n ? 'Ethereum Sepolia' :
      `Chain ${network.chainId}`;

    return ok({
      action: 'info',
      network: networkName,
      chainId: Number(network.chainId),
      walletType: wallet.type,
      walletAddress: address,
      ethBalance: ethBalance.toString(),
      contracts: {
        cUSDC: await cUSDC.getAddress(),
        verifier: await verifier.getAddress(),
        identity: await identity.getAddress(),
        reputation: await reputation.getAddress(),
        escrow: await escrow.getAddress(),
      },
      scheme: 'fhe-confidential-v1',
    });
  } catch (e: unknown) {
    return fail(e instanceof Error ? e.message : String(e));
  }
}

// CLI entrypoint
if (import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith('info.ts')) {
  const args = parseCliArgs(process.argv.slice(2));
  run(args).then(console.log);
}


path: view-as.ts
content: import { getContracts, ok, fail, formatUSDC, parseCliArgs } from './_wallet.js';

export async function run(args: Record<string, string>): Promise<string> {
  try {
    if (!args.delegator) {
      return fail('--delegator is required (Ethereum address of the balance owner who granted you view access)');
    }
    if (!/^0x[a-fA-F0-9]{40}$/.test(args.delegator)) {
      return fail('--delegator must be a valid Ethereum address (0x + 40 hex chars)');
    }

    const { session, cUSDC, wallet } = await getContracts();
    const delegateAddress = await wallet.getAddress();
    const cUSDCAddress = await cUSDC.getAddress();

    // Construct a ReadonlyToken for delegated decryption
    const readonlyToken = await session.createReadonlyToken(wallet, cUSDCAddress);

    // ReadonlyToken.decryptBalanceAs() handles:
    // - Reads confidentialBalanceOf(delegator) for the encrypted handle
    // - Returns 0n for zero handle (no balance, no KMS call)
    // - Pre-flight delegation check (DelegationNotFoundError, DelegationExpiredError)
    // - Credential management via DelegatedCredentialsManager
    // - KMS delegated decrypt
    // - Balance caching per (token, owner, handle)
    const balance = await readonlyToken.decryptBalanceAs({
      delegatorAddress: args.delegator,
    });

    // Get delegation expiry for metadata
    const expiry = await readonlyToken.getDelegationExpiry({
      delegatorAddress: args.delegator,
      delegateAddress,
    });

    return ok({
      action: 'view_as',
      delegator: args.delegator,
      delegate: delegateAddress,
      decryptedBalance: formatUSDC(balance),
      decryptedBalanceRaw: balance.toString(),
      delegationExpiry: expiry.toString(),
    });
  } catch (e: unknown) {
    const msg = e instanceof Error ? e.message : String(e);
    const name = e instanceof Error ? (e as any).code ?? e.constructor?.name : '';

    // Map SDK error types to actionable messages
    if (name === 'DELEGATION_NOT_FOUND' || msg.includes('DelegationNotFound')) {
      return fail(
        `No delegation exists from ${args.delegator} to your wallet for cUSDC. ` +
        `Ask the delegator to run: grant-view --delegate <your-address>`
      );
    }
    if (name === 'DELEGATION_EXPIRED' || msg.includes('DelegationExpired')) {
      return fail(
        `Delegation from ${args.delegator} has expired. ` +
        `Ask the delegator to run grant-view again.`
      );
    }
    if (name === 'DELEGATION_NOT_PROPAGATED' || msg.includes('DelegationNotPropagated') || msg.includes('500')) {
      return fail(
        'Delegation may not have propagated to the Zama gateway yet. ' +
        'Wait 1-2 minutes after grant-view and try again.'
      );
    }
    return fail(msg);
  }
}

// CLI entrypoint
if (import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith('view-as.ts')) {
  const args = parseCliArgs(process.argv.slice(2));
  run(args).then(console.log);
}


path: give-feedback.ts
content: import { ethers } from 'ethers';
import { getContracts, ok, fail, parseCliArgs } from './_wallet.js';

export async function run(args: Record<string, string>): Promise<string> {
  try {
    if (!args.agentId) {
      return fail('--agentId is required');
    }
    if (!args.score) {
      return fail('--score is required (integer, e.g. 80 for 80.0)');
    }
    if (!args.nonce) {
      return fail('--nonce is required (bytes32 from prior payment)');
    }

    // Parse score as BigInt to handle the full int128 range safely.
    // int128 range: -170141183460469231731687303715884105728 to 170141183460469231731687303715884105727
    // For practical reputation scores, values like -100 to 100 are typical.
    let scoreBigInt: bigint;
    try {
      scoreBigInt = BigInt(args.score);
    } catch {
      return fail('--score must be an integer (e.g. 80, -10)');
    }
    const INT128_MIN = -(2n ** 127n);
    const INT128_MAX = 2n ** 127n - 1n;
    if (scoreBigInt < INT128_MIN || scoreBigInt > INT128_MAX) {
      return fail(`--score must be within int128 range (${INT128_MIN} to ${INT128_MAX})`);
    }

    const { reputation, verifier } = await getContracts();

    // Derive proof-of-payment from payment nonce + verifier address
    const proofOfPayment = ethers.keccak256(
      ethers.solidityPacked(
        ['bytes32', 'address'],
        [args.nonce, await verifier.getAddress()],
      ),
    );

    // ERC-8004 Reputation: giveFeedback(agentId, int128 value, uint8 valueDecimals,
    //   tag1, tag2, endpoint, feedbackURI, feedbackHash, proofOfPayment)
    const tag1 = args.tag1 ?? '';
    const tag2 = args.tag2 ?? '';
    const endpoint = args.endpoint ?? '';
    const feedbackURI = args.feedbackURI ?? '';
    const feedbackHash = args.feedbackHash ?? ethers.ZeroHash;

    const tx = await reputation.giveFeedback(
      BigInt(args.agentId),
      scoreBigInt,         // int128 value
      0,                   // uint8 valueDecimals (0 = integer score)
      tag1,
      tag2,
      endpoint,
      feedbackURI,
      feedbackHash,
      ethers.getBytes(proofOfPayment), // bytes proofOfPayment
      { gasLimit: 300_000n },
    );
    const receipt = await tx.wait();

    return ok({
      action: 'feedback',
      agentId: args.agentId,
      score: scoreBigInt.toString(),
      txHash: receipt.hash,
    });
  } catch (e: unknown) {
    return fail(e instanceof Error ? e.message : String(e));
  }
}

// CLI entrypoint
if (import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith('give-feedback.ts')) {
  const args = parseCliArgs(process.argv.slice(2));
  run(args).then(console.log);
}


path: finalize-unwrap.ts
content: import { getContracts, ok, fail, parseCliArgs } from './_wallet.js';

export async function run(args: Record<string, string>): Promise<string> {
  try {
    // Two modes:
    // 1. --handle: use publicDecrypt to get cleartext + proof, then finalize (recommended)
    // 2. --requestId + --cleartextAmount + --proof: manual finalization (legacy/fallback)

    const { session, cUSDC } = await getContracts();

    if (args.handle) {
      // Mode 1: publicDecrypt flow (uses Zama KMS)
      if (session.isStub) {
        return fail('publicDecrypt requires live Zama SDK — not available in stub mode. Use --requestId + --cleartextAmount + --proof instead.');
      }

      const handle = args.handle;
      let clearValue: bigint;
      let proof: string;

      try {
        const decryptResult = await session.publicDecrypt([handle]);
        const rawValue = decryptResult.clearValues[handle];
        if (rawValue === undefined) {
          return fail('publicDecrypt returned no value for handle. The KMS may not have processed the request yet — wait 1-2 minutes and retry.');
        }
        clearValue = BigInt(rawValue as bigint | number | string);
        proof = decryptResult.decryptionProof;
      } catch (e: unknown) {
        const msg = e instanceof Error ? e.message : String(e);
        return fail(
          `publicDecrypt failed: ${msg.slice(0, 200)}. ` +
          'The KMS may not have processed the request yet. Wait 1-2 minutes and retry, or use --requestId + --cleartextAmount + --proof for manual finalization.'
        );
      }

      const tx = await cUSDC.finalizeUnwrap(
        handle,
        clearValue,
        proof,
        { gasLimit: 500_000n }
      );
      const receipt = await tx.wait();

      return ok({
        action: 'unwrap_finalized',
        handle,
        cleartextAmount: clearValue.toString(),
        txHash: receipt.hash,
        method: 'publicDecrypt',
      });
    }

    // Mode 2: Manual finalization (legacy — requires all 3 params)
    if (!args.requestId) {
      return fail('Either --handle (recommended) or --requestId + --cleartextAmount + --proof is required');
    }
    if (!args.cleartextAmount) {
      return fail('--cleartextAmount is required for manual finalization');
    }
    if (!args.proof) {
      return fail('--proof is required for manual finalization');
    }

    const tx = await cUSDC.finalizeUnwrap(
      args.requestId,
      BigInt(args.cleartextAmount),
      args.proof,
      { gasLimit: 500_000n }
    );
    const receipt = await tx.wait();

    return ok({
      action: 'unwrap_finalized',
      cleartextAmount: args.cleartextAmount,
      txHash: receipt.hash,
      method: 'manual',
    });
  } catch (e: unknown) {
    return fail(e instanceof Error ? e.message : String(e));
  }
}

// CLI entrypoint
if (import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith('finalize-unwrap.ts')) {
  const args = parseCliArgs(process.argv.slice(2));
  run(args).then(console.log);
}


path: pay.ts
content: import { ethers } from 'ethers';
import { getContracts, parseAmount, ok, fail, parseCliArgs } from './_wallet.js';

export async function run(args: Record<string, string>): Promise<string> {
  try {
    if (!args.to) {
      return fail('--to is required');
    }
    if (!/^0x[a-fA-F0-9]{40}$/.test(args.to)) {
      return fail('--to must be a valid Ethereum address (0x followed by 40 hex characters)');
    }
    if (!args.amount) {
      return fail('--amount is required');
    }

    const to = args.to;
    const rawAmount = parseAmount(args.amount);
    const { session, cUSDC, verifier, wallet } = await getContracts();
    const signerAddress = await wallet.getAddress();

    const nonce = ethers.hexlify(ethers.randomBytes(32));

    const { handles, inputProof } = await session.encrypt(
      rawAmount,
      await cUSDC.getAddress(),
      signerAddress,
    );

    const data = ethers.AbiCoder.defaultAbiCoder().encode(
      ['address', 'bytes32', 'uint64'],
      [to, nonce, rawAmount],
    );

    const tx = await cUSDC.confidentialTransferAndCall(
      await verifier.getAddress(),
      handles[0],
      inputProof,
      data,
      { gasLimit: 2_000_000n },
    );
    const receipt = await tx.wait();

    return ok({
      action: 'pay',
      to,
      amount: args.amount,
      txHash: receipt.hash,
      nonce,
    });
  } catch (e: unknown) {
    return fail(e instanceof Error ? e.message : String(e));
  }
}

// CLI entrypoint
if (import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith('pay.ts')) {
  const args = parseCliArgs(process.argv.slice(2));
  run(args).then(console.log);
}


path: complete-job.ts
content: import { ethers } from 'ethers';
import { getContracts, ok, fail, parseCliArgs } from './_wallet.js';

export async function run(args: Record<string, string>): Promise<string> {
  try {
    if (!args.jobId) {
      return fail('--jobId is required');
    }
    if (!args.action) {
      return fail('--action is required (must be "approve" or "reject")');
    }
    if (args.action !== 'approve' && args.action !== 'reject') {
      return fail('--action must be "approve" or "reject"');
    }

    const { escrow } = await getContracts();
    const reason = args.reason
      ? ethers.encodeBytes32String(args.reason.slice(0, 31))
      : ethers.ZeroHash;

    let tx;
    if (args.action === 'approve') {
      // ERC-8183: complete(jobId, reason, optParams)
      tx = await escrow.complete(BigInt(args.jobId), reason, '0x', { gasLimit: 2_000_000n });
    } else {
      // ERC-8183: reject(jobId, reason, optParams)
      tx = await escrow.reject(BigInt(args.jobId), reason, '0x', { gasLimit: 1_500_000n });
    }

    const receipt = await tx.wait();

    return ok({
      action: args.action,
      jobId: args.jobId,
      txHash: receipt.hash,
    });
  } catch (e: unknown) {
    return fail(e instanceof Error ? e.message : String(e));
  }
}

// CLI entrypoint
if (import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith('complete-job.ts')) {
  const args = parseCliArgs(process.argv.slice(2));
  run(args).then(console.log);
}


path: unwrap.ts
content: import { getContracts, parseAmount, ok, fail, parseCliArgs } from './_wallet.js';

export async function run(args: Record<string, string>): Promise<string> {
  try {
    if (!args.amount) {
      return fail('--amount is required');
    }

    const rawAmount = parseAmount(args.amount);
    const { session, cUSDC, wallet } = await getContracts();
    const signerAddress = await wallet.getAddress();
    const cUSDCAddress = await cUSDC.getAddress();

    // Step 1: Encrypt and send unwrap TX
    const encrypted = await session.encrypt(rawAmount, cUSDCAddress, signerAddress);

    const tx = await cUSDC.unwrap(
      signerAddress,
      signerAddress,
      encrypted.handles[0],
      encrypted.inputProof,
      { gasLimit: 2_000_000n }
    );
    const receipt = await tx.wait();

    // Parse the burn event to extract the encrypted amount handle.
    // The cUSDCMock emits ConfidentialTransfer(from, address(0), handle) for burns.
    // The Zama canonical wrapper may emit UnwrapRequested(receiver, handle).
    let burnHandle: string | null = null;
    let burnEventType: string | null = null;
    const iface = cUSDC.interface;
    for (const log of receipt.logs) {
      if (log.address.toLowerCase() !== cUSDCAddress.toLowerCase()) continue;
      try {
        const parsed = iface.parseLog({ topics: [...log.topics], data: log.data });
        if (!parsed) continue;
        // Look for ConfidentialTransfer to address(0) (burn)
        if (parsed.name === 'ConfidentialTransfer' &&
            parsed.args.to === '0x0000000000000000000000000000000000000000') {
          burnHandle = parsed.args.amount;
          burnEventType = 'ConfidentialTransfer(burn)';
          break;
        }
        // Or UnwrapRequested event (Zama canonical wrapper)
        if (parsed.name === 'UnwrapRequested') {
          burnHandle = parsed.args.amount;
          burnEventType = 'UnwrapRequested';
          break;
        }
      } catch { /* skip unparseable logs */ }
    }

    // Step 2: Try auto-finalization via publicDecrypt (SDK must be live, not stub)
    if (burnHandle && !session.isStub) {
      try {
        const decryptResult = await session.publicDecrypt([burnHandle]);
        const clearValue = decryptResult.clearValues[burnHandle];
        const proof = decryptResult.decryptionProof;

        if (clearValue !== undefined && proof) {
          const finalizeTx = await cUSDC.finalizeUnwrap(
            burnHandle,
            BigInt(clearValue as bigint | number | string),
            proof,
            { gasLimit: 500_000n }
          );
          const finalizeReceipt = await finalizeTx.wait();

          return ok({
            action: 'unwrap_complete',
            amount: args.amount,
            cleartextAmount: clearValue.toString(),
            unwrapTxHash: receipt.hash,
            finalizeTxHash: finalizeReceipt.hash,
            burnEventType,
            note: 'Auto-finalized via publicDecrypt. Underlying tokens released.',
          });
        }
      } catch (e: unknown) {
        // publicDecrypt failed — KMS may not be ready yet. Fall through to manual path.
        const msg = e instanceof Error ? e.message : String(e);
        return ok({
          action: 'unwrap_requested',
          amount: args.amount,
          txHash: receipt.hash,
          burnHandle,
          burnEventType,
          autoFinalizeError: msg.slice(0, 200),
          note: 'Step 1 complete. Auto-finalize failed — use finalize-unwrap --handle to complete manually after KMS processes the request.',
        });
      }
    }

    // No handle found or stub mode — return info for manual finalization
    return ok({
      action: 'unwrap_requested',
      amount: args.amount,
      txHash: receipt.hash,
      burnHandle: burnHandle ?? 'not found — check TX receipt for UnwrapRequested event',
      burnEventType,
      note: 'Step 1 complete. Run finalize-unwrap --handle <burnHandle> after KMS processes the decryption request (1-2 min).',
    });
  } catch (e: unknown) {
    return fail(e instanceof Error ? e.message : String(e));
  }
}

// CLI entrypoint
if (import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith('unwrap.ts')) {
  const args = parseCliArgs(process.argv.slice(2));
  run(args).then(console.log);
}


path: fund-job.ts
content: import { getContracts, parseAmount, ok, fail, parseCliArgs } from './_wallet.js';

export async function run(args: Record<string, string>): Promise<string> {
  try {
    if (!args.jobId) {
      return fail('--jobId is required');
    }
    if (!args.amount) {
      return fail('--amount is required');
    }

    const rawAmount = parseAmount(args.amount);

    const { session, cUSDC, escrow, wallet } = await getContracts();

    const escrowAddress = await escrow.getAddress();
    const clientAddress = await wallet.getAddress();

    // Encrypt the amount for setBudget:
    // contractAddress = escrow (where FHE.fromExternal runs)
    // userAddress = client (msg.sender when calling setBudget)
    const encrypted = await session.encrypt(
      rawAmount,
      escrowAddress,
      clientAddress,
    );

    // Step 1: Set budget (ERC-8183 2-step: setBudget then fund)
    await escrow.setBudget(
      BigInt(args.jobId),
      encrypted.handles[0],
      encrypted.inputProof,
      '0x', // optParams
      { gasLimit: 2_000_000n },
    );

    // Step 2: Set operator approval for escrow (1 hour deadline)
    const expiry = Math.floor(Date.now() / 1000) + 3600;
    await cUSDC.setOperator(escrowAddress, expiry, { gasLimit: 500_000n });

    // Step 3: Fund (pulls pre-set budget)
    const tx = await escrow.fund(
      BigInt(args.jobId),
      '0x', // optParams
      { gasLimit: 2_000_000n },
    );
    const receipt = await tx.wait();

    return ok({
      action: 'fund_job',
      jobId: args.jobId,
      txHash: receipt.hash,
    });
  } catch (e: unknown) {
    return fail(e instanceof Error ? e.message : String(e));
  }
}

// CLI entrypoint
if (import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith('fund-job.ts')) {
  const args = parseCliArgs(process.argv.slice(2));
  run(args).then(console.log);
}


path: create-job.ts
content: import { ethers } from 'ethers';
import { getContracts, ok, fail, parseCliArgs } from './_wallet.js';

export async function run(args: Record<string, string>): Promise<string> {
  try {
    if (!args.provider) {
      return fail('--provider is required');
    }
    if (!/^0x[a-fA-F0-9]{40}$/.test(args.provider)) {
      return fail('--provider must be a valid Ethereum address (0x followed by 40 hex characters)');
    }
    if (!args.evaluator) {
      return fail('--evaluator is required');
    }
    if (!/^0x[a-fA-F0-9]{40}$/.test(args.evaluator)) {
      return fail('--evaluator must be a valid Ethereum address (0x followed by 40 hex characters)');
    }
    if (!args.expiry) {
      return fail('--expiry is required (hours as a number)');
    }
    if (!args.description) {
      return fail('--description is required');
    }

    const expiredAt = Math.floor(Date.now() / 1000) + parseInt(args.expiry, 10) * 3600;

    const { escrow } = await getContracts();

    const hook = args.hook ?? ethers.ZeroAddress;
    if (hook !== ethers.ZeroAddress && !/^0x[a-fA-F0-9]{40}$/.test(hook)) {
      return fail('--hook must be a valid Ethereum address (0x followed by 40 hex characters)');
    }

    const tx = await escrow.createJob(
      args.provider,
      args.evaluator,
      expiredAt,
      args.description,
      hook,
      { gasLimit: 300_000n },
    );
    const receipt = await tx.wait();

    // Try to extract jobId from JobCreated event in receipt logs
    let jobId: bigint | undefined;
    for (const log of receipt.logs ?? []) {
      try {
        const parsed = escrow.interface.parseLog(log);
        if (parsed?.name === 'JobCreated') {
          jobId = parsed.args.jobId ?? parsed.args[0];
          break;
        }
      } catch {
        // Not this event
      }
    }

    return ok({
      action: 'create_job',
      jobId: jobId?.toString() ?? '1',
      txHash: receipt.hash,
    });
  } catch (e: unknown) {
    return fail(e instanceof Error ? e.message : String(e));
  }
}

// CLI entrypoint
if (import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith('create-job.ts')) {
  const args = parseCliArgs(process.argv.slice(2));
  run(args).then(console.log);
}


path: research-and-visualize.ts
content: import { fail, ok, parseCliArgs } from './_wallet.js';
import { createDemoPaymentContext, paidBinaryRequest, paidJsonRequest } from './_paid-request.js';

function shortStep(payment: Awaited<ReturnType<typeof paidJsonRequest>>['payment'], step: string) {
  return {
    step,
    amountMicros: payment.amountMicros.toString(),
    txHash: payment.txHash,
    nonce: payment.nonce,
    etherscanUrl: payment.etherscanUrl,
    ...(payment.mode ? { mode: payment.mode } : {}),
  };
}

export async function run(args: Record<string, string>): Promise<string> {
  try {
    if (!args.query) {
      return fail('--query is required');
    }

    const query = args.query;
    const searchUrl = process.env.SEARCH_SERVER_URL ?? 'http://127.0.0.1:3001';
    const llmUrl = process.env.LLM_SERVER_URL ?? 'http://127.0.0.1:3002';
    const imageUrl = process.env.IMAGE_SERVER_URL ?? 'http://127.0.0.1:3003';

    const context = await createDemoPaymentContext(100_000n);

    const search = await paidJsonRequest<{ results?: Array<{ title: string; url: string; snippet: string }> }>(
      context,
      `${searchUrl}/api/search`,
      {
        method: 'POST',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify({ query, count: 5 }),
      },
    );

    const analysis = await paidJsonRequest<{ response?: string; usage?: Record<string, unknown> }>(
      context,
      `${llmUrl}/api/chat`,
      {
        method: 'POST',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify({
          model: 'gpt-4o-mini',
          messages: [
            { role: 'system', content: 'Summarize these search results into 3 concise insights.' },
            { role: 'user', content: JSON.stringify(search.body.results?.slice(0, 3) ?? []) },
          ],
        }),
      },
    );

    const image = await paidBinaryRequest(
      context,
      `${imageUrl}/api/generate`,
      {
        method: 'POST',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify({
          prompt: `Visual summary of: ${analysis.body.response?.slice(0, 200) ?? query}`,
          size: '512x512',
        }),
      },
    );

    const steps = [
      shortStep(search.payment, 'search'),
      shortStep(analysis.payment, 'analysis'),
      shortStep(image.payment, 'image'),
    ];

    const totalMicros = [
      search.payment.amountMicros,
      analysis.payment.amountMicros,
      image.payment.amountMicros,
    ].reduce((sum, value) => sum + value, 0n);

    return ok({
      action: 'research_and_visualize',
      query,
      steps,
      searchResults: search.body.results ?? [],
      analysis: analysis.body.response ?? '',
      image: {
        contentType: image.contentType,
        generationId: image.headers['x-generation-id'] ?? '',
        bytes: image.bytes,
      },
      totalMicros: totalMicros.toString(),
      totalUsd: (Number(totalMicros) / 1_000_000).toFixed(3),
      note: '3 encrypted x402 payments on Sepolia. Observers can see the txs, not the amounts.',
    });
  } catch (e: unknown) {
    return fail(e instanceof Error ? e.message : String(e));
  }
}

if (import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith('research-and-visualize.ts')) {
  const args = parseCliArgs(process.argv.slice(2));
  run(args).then(console.log);
}


path: grant-view.ts
content: import { getContracts, ok, fail, parseCliArgs } from './_wallet.js';

export async function run(args: Record<string, string>): Promise<string> {
  try {
    if (!args.delegate) {
      return fail('--delegate is required (Ethereum address to grant view access)');
    }
    if (!/^0x[a-fA-F0-9]{40}$/.test(args.delegate)) {
      return fail('--delegate must be a valid Ethereum address (0x + 40 hex chars)');
    }

    const { session, cUSDC, wallet } = await getContracts();
    const cUSDCAddress = await cUSDC.getAddress();

    // --contract overrides the default cUSDC. For amount verification, use the verifier address.
    const contractAddress = args.contract ?? cUSDCAddress;
    if (args.contract && !/^0x[a-fA-F0-9]{40}$/.test(args.contract)) {
      return fail('--contract must be a valid Ethereum address');
    }

    // Construct a native Zama SDK Token for the target contract
    const token = await session.createToken(wallet, contractAddress);

    // Expiry: --permanent for no expiry, --hours for custom (default 24h)
    let expirationDate: Date | undefined;
    let expiresIn: string;

    if (args.permanent === 'true' || args.permanent === '') {
      expirationDate = undefined; // SDK defaults to uint64.max (permanent)
      expiresIn = 'permanent (never expires)';
    } else {
      const hours = parseInt(args.hours ?? '24', 10);
      if (isNaN(hours) || hours < 1) {
        return fail('--hours must be a positive integer >= 1');
      }
      expirationDate = new Date(Date.now() + hours * 3600 * 1000);
      expiresIn = `${hours} hours`;
    }

    // Token.delegateDecryption() handles:
    // - ACL address resolution
    // - Self-delegation check (throws DelegationSelfNotAllowedError)
    // - Past-expiry check (throws ConfigurationError)
    // - On-chain TX + receipt
    const result = await token.delegateDecryption({
      delegateAddress: args.delegate,
      expirationDate,
    });

    return ok({
      action: 'grant_view',
      delegator: await wallet.getAddress(),
      delegate: args.delegate,
      contract: contractAddress,
      expiresIn,
      txHash: result.txHash,
      note: 'Delegation takes 1-2 minutes to propagate to the Zama gateway before view-as works.',
    });
  } catch (e: unknown) {
    const msg = e instanceof Error ? e.message : String(e);
    // Map SDK-specific errors to user-friendly messages
    if (msg.includes('SelfNotAllowed') || msg.includes('self')) {
      return fail('Cannot delegate to yourself. The delegate must be a different address.');
    }
    return fail(msg);
  }
}

// CLI entrypoint
if (import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith('grant-view.ts')) {
  const args = parseCliArgs(process.argv.slice(2));
  run(args).then(console.log);
}


path: review-and-rate.ts
content: import { fail, ok, parseCliArgs } from './_wallet.js';
import { paidJsonRequest, createDemoPaymentContext } from './_paid-request.js';
import { run as runGiveFeedback } from './give-feedback.js';

export async function run(args: Record<string, string>): Promise<string> {
  try {
    if (!args.code) {
      return fail('--code is required');
    }

    const language = args.language ?? 'typescript';
    const score = args.score ?? '80';

    // Validate score before spending money on the paid review
    try { BigInt(score); } catch {
      return fail('--score must be an integer (e.g. 80, -10)');
    }

    const codeReviewUrl = process.env.CODE_REVIEW_URL ?? 'http://127.0.0.1:3004';
    const agentId = process.env.CODE_REVIEW_AGENT_ID;

    const context = await createDemoPaymentContext(100_000n);

    const review = await paidJsonRequest<{
      issues?: Array<Record<string, unknown>>;
      summary?: string;
    }>(
      context,
      `${codeReviewUrl}/api/review`,
      {
        method: 'POST',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify({
          code: args.code,
          language,
        }),
      },
    );

    let feedback:
      | {
          submitted: boolean;
          agentId?: string;
          txHash?: string;
        }
      | {
          submitted: false;
          skipped: true;
          reason: string;
        };

    if (!agentId) {
      feedback = {
        submitted: false,
        skipped: true,
        reason: 'CODE_REVIEW_AGENT_ID is not configured',
      };
    } else {
      const raw = await runGiveFeedback({
        agentId,
        score,
        nonce: review.payment.nonce,
        tag1: 'demo',
        tag2: 'code-review',
      });
      const parsed = JSON.parse(raw);
      if (!parsed.ok) {
        return fail(`Feedback failed after successful paid review: ${parsed.error}`);
      }
      feedback = {
        submitted: true,
        agentId,
        txHash: parsed.txHash,
      };
    }

    return ok({
      action: 'review_and_rate',
      payment: {
        amountMicros: review.payment.amountMicros.toString(),
        txHash: review.payment.txHash,
        nonce: review.payment.nonce,
        etherscanUrl: review.payment.etherscanUrl,
        ...(review.payment.mode ? { mode: review.payment.mode } : {}),
      },
      review: {
        issues: review.body.issues ?? [],
        summary: review.body.summary ?? '',
      },
      feedback,
    });
  } catch (e: unknown) {
    return fail(e instanceof Error ? e.message : String(e));
  }
}

if (import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith('review-and-rate.ts')) {
  const args = parseCliArgs(process.argv.slice(2));
  run(args).then(console.log);
}


path: revoke-view.ts
content: import { getContracts, ok, fail, parseCliArgs } from './_wallet.js';

export async function run(args: Record<string, string>): Promise<string> {
  try {
    if (!args.delegate) {
      return fail('--delegate is required (Ethereum address to revoke view access from)');
    }
    if (!/^0x[a-fA-F0-9]{40}$/.test(args.delegate)) {
      return fail('--delegate must be a valid Ethereum address (0x + 40 hex chars)');
    }

    const { session, cUSDC, wallet } = await getContracts();
    const delegatorAddress = await wallet.getAddress();
    const cUSDCAddress = await cUSDC.getAddress();

    // Pre-check: use ReadonlyToken.isDelegated() to verify delegation exists
    const readonlyToken = await session.createReadonlyToken(wallet, cUSDCAddress);
    const isDelegated = await readonlyToken.isDelegated({
      delegatorAddress,
      delegateAddress: args.delegate,
    });

    if (!isDelegated) {
      return fail(
        `No active delegation exists from ${delegatorAddress} to ${args.delegate} for cUSDC.`
      );
    }

    // Token.revokeDelegation() handles ACL address resolution + on-chain TX
    const token = await session.createToken(wallet, cUSDCAddress);
    const result = await token.revokeDelegation({
      delegateAddress: args.delegate,
    });

    return ok({
      action: 'revoke_view',
      delegator: delegatorAddress,
      delegate: args.delegate,
      contract: cUSDCAddress,
      txHash: result.txHash,
    });
  } catch (e: unknown) {
    return fail(e instanceof Error ? e.message : String(e));
  }
}

// CLI entrypoint
if (import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith('revoke-view.ts')) {
  const args = parseCliArgs(process.argv.slice(2));
  run(args).then(console.log);
}


path: register-agent.ts
content: import { ethers } from 'ethers';
import { getContracts, ok, fail, parseCliArgs } from './_wallet.js';

export async function run(args: Record<string, string>): Promise<string> {
  try {
    if (!args.uri || args.uri.trim() === '') {
      return fail('--uri is required and must be a non-empty string');
    }

    const { identity, wallet } = await getContracts();
    const signerAddress = await wallet.getAddress();

    // ERC-8004: register(string agentURI) — disambiguate overload for ethers v6
    const tx = await identity.getFunction('register(string)')(args.uri, { gasLimit: 500_000n });
    const receipt = await tx.wait();

    // Extract agentId from Registered event (ERC-8004 event name)
    let agentId: bigint = 0n;
    if (receipt?.logs) {
      for (const log of receipt.logs) {
        try {
          const iface = new ethers.Interface([
            'event Registered(uint256 indexed agentId, string agentURI, address indexed owner)',
          ]);
          const parsed = iface.parseLog(log);
          if (parsed && parsed.name === 'Registered') {
            agentId = BigInt(parsed.args.agentId);
            break;
          }
        } catch {
          // Log didn't match — continue
        }
      }
    }

    // ERC-8004: setAgentWallet requires EIP-712 signature from the wallet being linked.
    // Since the agent owner IS the wallet, we self-sign.
    const deadline = Math.floor(Date.now() / 1000) + 3600; // 1 hour
    const domain = {
      name: 'AgentIdentityRegistry',
      version: '1',
      chainId: (await wallet.getProvider().getNetwork()).chainId,
      verifyingContract: await identity.getAddress(),
    };
    const types = {
      SetAgentWallet: [
        { name: 'agentId', type: 'uint256' },
        { name: 'newWallet', type: 'address' },
        { name: 'deadline', type: 'uint256' },
      ],
    };
    const value = { agentId, newWallet: signerAddress, deadline };
    const signature = await wallet.signTypedData(domain, types, value);

    const setTx = await identity.setAgentWallet(
      agentId, signerAddress, deadline, signature,
      { gasLimit: 300_000n },
    );
    await setTx.wait();

    return ok({
      action: 'register',
      agentId: agentId.toString(),
      txHash: receipt.hash,
    });
  } catch (e: unknown) {
    return fail(e instanceof Error ? e.message : String(e));
  }
}

// CLI entrypoint
if (import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith('register-agent.ts')) {
  const args = parseCliArgs(process.argv.slice(2));
  run(args).then(console.log);
}


path: skill.json
content: {
  "name": "fhe-x402-payment",
  "version": "1.0.0",
  "description": "FHE-encrypted x402 payments for OpenClaw agents using Zama canonical wrappers",
  "network": {
    "name": "Configurable (Sepolia default, Mainnet supported)",
    "chainId": "Set via CHAIN env var (default: auto-detect from RPC_URL)"
  },
  "commands": {
    "wrap": {
      "description": "Wrap USDC into encrypted cUSDC (ERC-7984)",
      "script": "scripts/wrap.ts",
      "args": {
        "amount": {
          "type": "string",
          "required": true,
          "description": "Amount of USDC to wrap (e.g. '2' for 2 USDC)"
        }
      }
    },
    "pay": {
      "description": "Send encrypted cUSDC payment to another address via confidentialTransfer",
      "script": "scripts/pay.ts",
      "args": {
        "to": {
          "type": "string",
          "required": true,
          "description": "Recipient Ethereum address"
        },
        "amount": {
          "type": "string",
          "required": true,
          "description": "Amount of USDC to pay (e.g. '1' for 1 USDC)"
        }
      }
    },
    "unwrap": {
      "description": "Request unwrap of cUSDC back to USDC (step 1 of 2, initiates KMS decryption)",
      "script": "scripts/unwrap.ts",
      "args": {
        "amount": {
          "type": "string",
          "required": true,
          "description": "Amount to unwrap (e.g. '1' for 1 USDC)"
        }
      }
    },
    "finalize-unwrap": {
      "description": "Finalize unwrap via publicDecrypt (recommended) or manual proof. Use --handle from the unwrap command output.",
      "script": "scripts/finalize-unwrap.ts",
      "args": {
        "handle": {
          "type": "string",
          "required": false,
          "description": "Encrypted amount handle from the unwrap event (recommended — uses publicDecrypt to get cleartext + proof automatically)"
        },
        "requestId": {
          "type": "string",
          "required": false,
          "description": "Legacy: request ID for manual finalization (requires --cleartextAmount and --proof)"
        },
        "cleartextAmount": {
          "type": "string",
          "required": false,
          "description": "Legacy: decrypted cleartext amount (uint64) for manual finalization"
        },
        "proof": {
          "type": "string",
          "required": false,
          "description": "Legacy: decryption proof bytes for manual finalization"
        }
      }
    },
    "balance": {
      "description": "Check wallet USDC balance and encrypted cUSDC handle. With --decrypt, decrypts your own cUSDC balance via Zama KMS. With --of, reads another agent's balance via delegation.",
      "script": "scripts/balance.ts",
      "args": {
        "decrypt": {
          "type": "string",
          "required": false,
          "description": "Set to 'true' to decrypt your own cUSDC balance (requires @zama-fhe/sdk)"
        },
        "of": {
          "type": "string",
          "required": false,
          "description": "Ethereum address to read balance of (requires prior grant-view from that address)"
        }
      }
    },
    "info": {
      "description": "Display wallet address, contract addresses, and network info",
      "script": "scripts/info.ts",
      "args": {}
    },
    "register-agent": {
      "description": "Register this wallet as an agent in the on-chain identity registry",
      "script": "scripts/register-agent.ts",
      "args": {
        "uri": {
          "type": "string",
          "required": true,
          "description": "Agent URI (e.g. https://agent.example.com/manifest.json)"
        }
      }
    },
    "give-feedback": {
      "description": "Submit reputation feedback for an agent",
      "script": "scripts/give-feedback.ts",
      "args": {
        "agentId": {
          "type": "string",
          "required": true,
          "description": "On-chain agent ID (uint256)"
        },
        "score": {
          "type": "string",
          "required": true,
          "description": "Reputation score as int128 (e.g. '80' for positive, '-10' for negative). Typical range: -100 to 100."
        },
        "nonce": {
          "type": "string",
          "required": true,
          "description": "bytes32 nonce from a prior payment (used to derive proof-of-payment)"
        },
        "tag1": {
          "type": "string",
          "required": false,
          "description": "Optional first tag string"
        },
        "tag2": {
          "type": "string",
          "required": false,
          "description": "Optional second tag string"
        },
        "endpoint": {
          "type": "string",
          "required": false,
          "description": "Optional endpoint string"
        },
        "feedbackURI": {
          "type": "string",
          "required": false,
          "description": "Optional feedback URI"
        },
        "feedbackHash": {
          "type": "string",
          "required": false,
          "description": "Optional feedback hash (bytes32, defaults to zero)"
        }
      }
    },
    "create-job": {
      "description": "Create a new escrow job on-chain",
      "script": "scripts/create-job.ts",
      "args": {
        "provider": {
          "type": "string",
          "required": true,
          "description": "Ethereum address of the provider (worker) for the job"
        },
        "evaluator": {
          "type": "string",
          "required": true,
          "description": "Ethereum address of the evaluator who can approve/reject"
        },
        "expiry": {
          "type": "string",
          "required": true,
          "description": "Job expiry in hours from now (e.g. '24' for 24 hours)"
        },
        "description": {
          "type": "string",
          "required": true,
          "description": "Job description or metadata URI"
        },
        "hook": {
          "type": "string",
          "required": false,
          "description": "Optional IACPHook contract address for lifecycle callbacks (must be whitelisted on the escrow). Defaults to address(0) (no hook)."
        }
      }
    },
    "fund-job": {
      "description": "Fund an existing escrow job with encrypted cUSDC",
      "script": "scripts/fund-job.ts",
      "args": {
        "jobId": {
          "type": "string",
          "required": true,
          "description": "The job ID (bytes32 or uint) to fund"
        },
        "amount": {
          "type": "string",
          "required": true,
          "description": "USDC amount to add to the escrow (e.g. '5' for 5 USDC)"
        }
      }
    },
    "complete-job": {
      "description": "Approve or reject a job, releasing or refunding escrow funds",
      "script": "scripts/complete-job.ts",
      "args": {
        "jobId": {
          "type": "string",
          "required": true,
          "description": "The job ID (uint256) to complete"
        },
        "action": {
          "type": "string",
          "required": true,
          "description": "Must be \"approve\" or \"reject\""
        },
        "reason": {
          "type": "string",
          "required": false,
          "description": "Optional reason string (max 31 chars, encoded as bytes32)"
        }
      }
    },
    "grant-view": {
      "description": "Grant another address read-only access to your encrypted cUSDC balance via Zama ACL delegation",
      "script": "scripts/grant-view.ts",
      "args": {
        "delegate": {
          "type": "string",
          "required": true,
          "description": "Ethereum address to grant view access to"
        },
        "hours": {
          "type": "string",
          "required": false,
          "description": "Delegation duration in hours (default: 24). Ignored if --permanent is set."
        },
        "permanent": {
          "type": "string",
          "required": false,
          "description": "Set to 'true' for permanent delegation (never expires)"
        },
        "contract": {
          "type": "string",
          "required": false,
          "description": "Contract address to delegate for (default: cUSDC). For amount verification, use the verifier address."
        }
      }
    },
    "revoke-view": {
      "description": "Revoke a delegate's read-only access to your encrypted cUSDC balance",
      "script": "scripts/revoke-view.ts",
      "args": {
        "delegate": {
          "type": "string",
          "required": true,
          "description": "Ethereum address to revoke view access from"
        }
      }
    },
    "view-as": {
      "description": "Read another agent's encrypted cUSDC balance using delegated decryption (requires prior grant-view from the owner)",
      "script": "scripts/view-as.ts",
      "args": {
        "delegator": {
          "type": "string",
          "required": true,
          "description": "Ethereum address of the balance owner who granted you view access"
        }
      }
    },
    "research-and-visualize": {
      "description": "Make 3 chained encrypted API payments for search, LLM analysis, and image generation",
      "script": "scripts/research-and-visualize.ts",
      "args": {
        "query": {
          "type": "string",
          "required": true,
          "description": "Research topic to search, analyze, and turn into a visual summary"
        }
      }
    },
    "review-and-rate": {
      "description": "Buy a code review via encrypted payment and optionally submit proof-linked feedback",
      "script": "scripts/review-and-rate.ts",
      "args": {
        "code": {
          "type": "string",
          "required": true,
          "description": "Code snippet or source text to review"
        },
        "language": {
          "type": "string",
          "required": false,
          "description": "Language label for the reviewer (default: typescript)"
        },
        "score": {
          "type": "string",
          "required": false,
          "description": "Feedback score to submit if CODE_REVIEW_AGENT_ID is configured (default: 80)"
        }
      }
    }
  },
  "environment": {
    "WALLET_MODE": {
      "required": false,
      "description": "Explicit signer mode: 'user' for local development, 'dfns' for unattended MPC-backed agents, or 'ledger-bridge' for supervised local Ledger approvals. If omitted, the skill preserves backward-compatible DFNS > USER_PRIVATE_KEY fallback."
    },
    "DFNS_WALLET_ID": {
      "required": false,
      "description": "DFNS MPC wallet ID. Best fit for unattended agent runtimes and production-style automation."
    },
    "DFNS_AUTH_TOKEN": {
      "required": false,
      "description": "Dfns authentication token (required when DFNS_WALLET_ID is set)"
    },
    "DFNS_CREDENTIAL_ID": {
      "required": false,
      "description": "Dfns credential ID used for User Action Signing (required when DFNS_WALLET_ID is set)"
    },
    "DFNS_CREDENTIAL_PRIVATE_KEY": {
      "required": false,
      "description": "Dfns credential private key in PEM format or with \\n escapes (required when DFNS_WALLET_ID is set unless DFNS_PRIVATE_KEY_PATH is used)"
    },
    "DFNS_PRIVATE_KEY_PATH": {
      "required": false,
      "description": "Optional filesystem path to the Dfns credential private key PEM (alternative to DFNS_CREDENTIAL_PRIVATE_KEY)"
    },
    "DFNS_CREDENTIAL_ALGORITHM": {
      "required": false,
      "description": "Optional Node crypto.sign algorithm override for the Dfns credential signer"
    },
    "DFNS_APP_ID": {
      "required": false,
      "description": "Legacy Dfns application ID (optional; kept for backward compatibility)"
    },
    "DFNS_API_URL": {
      "required": false,
      "description": "Dfns API base URL (default: https://api.dfns.io). The skill uses it to auto-discover the wallet address and signingKey.id from DFNS_WALLET_ID."
    },
    "USER_PRIVATE_KEY": {
      "required": false,
      "description": "Raw Ethereum private key. Best fit for local development, quick scripts, and simple single-user testing."
    },
    "LEDGER_BRIDGE_URL": {
      "required": false,
      "description": "Localhost Ledger bridge base URL (default: http://127.0.0.1:4555). Used when WALLET_MODE=ledger-bridge, where OpenClaw stays headless and a local browser UI + Ledger device approve signatures."
    },
    "LEDGER_BRIDGE_TOKEN": {
      "required": false,
      "description": "Shared secret for the localhost Ledger bridge. The OpenClaw runtime, daemon, and browser approval UI must all use the same value."
    },
    "LEDGER_BRIDGE_TIMEOUT_MS": {
      "required": false,
      "description": "Approval timeout in milliseconds for supervised Ledger requests (default: 300000). Used when WALLET_MODE=ledger-bridge."
    },
    "RPC_URL": {
      "required": false,
      "description": "Ethereum RPC URL (default: Sepolia public node)"
    },
    "CHAIN": {
      "required": false,
      "description": "Chain name: 'mainnet' or 'sepolia'. Auto-detected from RPC_URL if not set."
    },
    "CUSDC_ADDRESS": {
      "required": false,
      "description": "Zama cUSDC (ERC-7984) address. Defaults: mainnet=0xe978..., sepolia=0x7c5B..."
    },
    "USDC_ADDRESS": {
      "required": false,
      "description": "Plaintext USDC ERC-20 address (default: Circle canonical for selected chain)"
    },
    "VERIFIER_ADDRESS": {
      "required": false,
      "description": "X402PaymentVerifier contract address (required for pay)"
    },
    "IDENTITY_ADDRESS": {
      "required": false,
      "description": "AgentIdentityRegistry address (required for register-agent)"
    },
    "REPUTATION_ADDRESS": {
      "required": false,
      "description": "AgentReputationRegistry address (required for give-feedback)"
    },
    "ESCROW_ADDRESS": {
      "required": false,
      "description": "AgenticCommerceProtocol address (required for create-job, fund-job, complete-job)"
    }
  }
}


path: _wallet.ts
content: import { Contract, JsonRpcProvider } from 'ethers';
import {
  createUserWallet,
  createDfnsWallet,
  createLedgerBridgeWallet,
  ZamaFheSession,
  type FheWallet,
  TOKEN_ABI,
  VERIFIER_ABI,
} from '../../core/src/index.js';
import { parseArgs } from 'node:util';

// ============================================================================
// Contract Addresses (mainnet defaults, overridable via env)
// ============================================================================

const RPC_URL =
  process.env.RPC_URL ?? 'https://ethereum-sepolia-rpc.publicnode.com';

// Detect chain early — needed for default address selection
const _isMainnet = process.env.CHAIN === 'mainnet' || RPC_URL.includes('mainnet');

// Default addresses match the detected chain. Sepolia uses Zama cUSDCMock + deployed x402fhe contracts.
const CUSDC_ADDRESS = process.env.CUSDC_ADDRESS ?? (
  _isMainnet ? [REDACTED_SECRET] : [REDACTED_SECRET]
);
const USDC_ADDRESS = process.env.USDC_ADDRESS ?? (
  _isMainnet ? [REDACTED_SECRET] : [REDACTED_SECRET]
);
const VERIFIER_ADDRESS = process.env.VERIFIER_ADDRESS ?? (
  _isMainnet ? '' : [REDACTED_SECRET]
);
const IDENTITY_ADDRESS = process.env.IDENTITY_ADDRESS ?? (
  _isMainnet ? '' : [REDACTED_SECRET]
);
const REPUTATION_ADDRESS = process.env.REPUTATION_ADDRESS ?? (
  _isMainnet ? '' : [REDACTED_SECRET]
);
const ESCROW_ADDRESS = process.env.ESCROW_ADDRESS ?? (
  _isMainnet ? '' : [REDACTED_SECRET]
);

// ============================================================================
// Minimal ABIs for contracts not yet in @x402fhe/core
// ============================================================================

/** Minimal ERC-20 ABI for USDC (approve + balanceOf) */
const ERC20_ABI = [
  'function approve(address spender, uint256 amount) external returns (bool)',
  'function balanceOf(address account) external view returns (uint256)',
] as const;

/** AgentIdentityRegistry minimal ABI (ERC-8004 — ERC-721 based) */
const IDENTITY_ABI = [
  'function register(string calldata agentURI) external returns (uint256)',
  'function setAgentWallet(uint256 agentId, address newWallet, uint256 deadline, bytes calldata signature) external',
  'function getAgentWallet(uint256 agentId) external view returns (address)',
  'function setAgentURI(uint256 agentId, string calldata newURI) external',
  'function setMetadata(uint256 agentId, string calldata metadataKey, bytes calldata metadataValue) external',
  'function getMetadata(uint256 agentId, string calldata metadataKey) external view returns (bytes memory)',
  'function tokenURI(uint256 agentId) external view returns (string memory)',
  'function ownerOf(uint256 agentId) external view returns (address)',
  'event Registered(uint256 indexed agentId, string agentURI, address indexed owner)',
] as const;

/** AgentReputationRegistry minimal ABI (ERC-8004 Reputation) */
const REPUTATION_ABI = [
  'function giveFeedback(uint256 agentId, int128 value, uint8 valueDecimals, string calldata tag1, string calldata tag2, string calldata endpoint, string calldata feedbackURI, bytes32 feedbackHash, bytes calldata proofOfPayment) external',
  'function revokeFeedback(uint256 agentId, uint64 feedbackIndex) external',
  'function getSummary(uint256 agentId, address[] calldata clientAddresses, string calldata tag1, string calldata tag2) external view returns (uint64, uint64, int128)',
  'function getClients(uint256 agentId) external view returns (address[] memory)',
  'event NewFeedback(uint256 indexed agentId, address indexed clientAddress, uint64 feedbackIndex, int128 value, uint8 valueDecimals, string indexed indexedTag1, string tag1, string tag2, string endpoint, string feedbackURI, bytes32 feedbackHash)',
] as const;

/** AgenticCommerceProtocol minimal ABI (ERC-8183 — encrypted escrow) */
const ESCROW_ABI = [
  'function createJob(address provider, address evaluator, uint256 expiredAt, string calldata description, address hook) external returns (uint256)',
  'function setBudget(uint256 jobId, bytes32 encAmount, bytes calldata inputProof, bytes calldata optParams) external',
  'function fund(uint256 jobId, bytes calldata optParams) external',
  'function submit(uint256 jobId, bytes32 deliverable, bytes calldata optParams) external',
  'function complete(uint256 jobId, bytes32 reason, bytes calldata optParams) external',
  'function reject(uint256 jobId, bytes32 reason, bytes calldata optParams) external',
  'function claimRefund(uint256 jobId) external',
  'function getJobStatus(uint256 jobId) external view returns (uint8)',
  'function setPlatformFee(uint64 newFee) external',
  'event JobCreated(uint256 indexed jobId, address indexed client, address indexed provider, address evaluator, uint256 expiredAt)',
  'event BudgetSet(uint256 indexed jobId, address indexed client)',
  'event JobFunded(uint256 indexed jobId, address indexed client)',
  'event JobCompleted(uint256 indexed jobId, address indexed evaluator, bytes32 reason)',
] as const;

// ============================================================================
// Singleton state
// ============================================================================

let initPromise: Promise<void> | null = null;
let wallet: FheWallet;
let session: ZamaFheSession;
let provider: JsonRpcProvider;
let signer: any;

// Lazy contract cache — constructed on first access, not at init
const _contractCache: Record<string, Contract> = {};

// ============================================================================
// Initialization
// ============================================================================

async function loadDfnsCredentialPrivateKey(): Promise<string> {
  if (process.env.DFNS_CREDENTIAL_PRIVATE_KEY) {
    return process.env.DFNS_CREDENTIAL_PRIVATE_KEY;
  }

  const privateKeyPath = process.env.DFNS_PRIVATE_KEY_PATH;
  if (!privateKeyPath) {
    throw new Error(
      'Missing DFNS credential private key. Set DFNS_CREDENTIAL_PRIVATE_KEY or DFNS_PRIVATE_KEY_PATH.',
    );
  }

  const { readFile } = await import('node:fs/promises');
  return (await readFile(privateKeyPath, 'utf8')).trim();
}

async function resolveDfnsWalletMetadata(): Promise<{ address: string; signingKeyId?: string }> {
  const apiUrl = process.env.DFNS_API_URL ?? 'https://api.dfns.io';
  const authToken = process.env.DFNS_AUTH_TOKEN!;
  const walletId = process.env.DFNS_WALLET_ID!;

  const response = await fetch(`${apiUrl}/wallets/${walletId}`, {
    headers: {
      Authorization: `Bearer ${authToken}`,
      'Content-Type': 'application/json',
    },
  });

  if (!response.ok) {
    const body = await response.text();
    throw new Error(
      `Failed to fetch DFNS wallet metadata (${response.status} ${response.statusText}): ${body || 'empty response'}`,
    );
  }

  const walletData = await response.json() as { address?: string; signingKey?: { id?: string } };
  if (!walletData.address) {
    throw new Error('DFNS wallet metadata did not include an Ethereum address.');
  }

  return {
    address: walletData.address,
    ...(walletData.signingKey?.id ? { signingKeyId: walletData.signingKey.id } : {}),
  };
}

/** Core init — wallet, encryption, provider. NO contracts constructed here. */
async function init(): Promise<void> {
  provider = new JsonRpcProvider(RPC_URL);
  const chain = (process.env.CHAIN ?? (RPC_URL.includes('mainnet') ? 'mainnet' : 'sepolia')) as 'mainnet' | 'sepolia';
  const expectedChainId = chain === 'mainnet' ? 1 : 11155111;
  const walletMode = process.env.WALLET_MODE;

  if (walletMode === 'ledger-bridge') {
    if (!process.env.LEDGER_BRIDGE_TOKEN) {
      throw new Error('Missing LEDGER_BRIDGE_TOKEN for WALLET_MODE=ledger-bridge');
    }

    const timeoutValue = process.env.LEDGER_BRIDGE_TIMEOUT_MS;
    const requestTimeoutMs = timeoutValue ? Number.parseInt(timeoutValue, 10) : undefined;
    if (timeoutValue && (!Number.isFinite(requestTimeoutMs) || requestTimeoutMs <= 0)) {
      throw new Error('LEDGER_BRIDGE_TIMEOUT_MS must be a positive integer number of milliseconds');
    }

    wallet = createLedgerBridgeWallet(
      {
        bridgeUrl: process.env.LEDGER_BRIDGE_URL ?? 'http://127.0.0.1:4555',
        authToken: process.env.LEDGER_BRIDGE_TOKEN,
        expectedChainId,
        ...(requestTimeoutMs ? { requestTimeoutMs } : {}),
      },
      provider,
    );
  } else if (walletMode === 'dfns' || (!walletMode && process.env.DFNS_WALLET_ID)) {
    const missing = ['DFNS_AUTH_TOKEN', 'DFNS_CREDENTIAL_ID'].filter((name) => !process.env[name]);

    if (missing.length > 0) {
      throw new Error(`Missing DFNS env vars: ${missing.join(', ')}`);
    }

    if (!process.env.DFNS_WALLET_ID) {
      throw new Error('Missing DFNS_WALLET_ID for WALLET_MODE=dfns');
    }

    const credentialPrivateKey = await loadDfnsCredentialPrivateKey();
    const metadata = await resolveDfnsWalletMetadata();
    if (!process.env.DFNS_KEY_ID && metadata.signingKeyId) {
      process.env.DFNS_KEY_ID = metadata.signingKeyId;
    }

    wallet = createDfnsWallet(
      {
        apiUrl: process.env.DFNS_API_URL ?? 'https://api.dfns.io',
        appId: process.env.DFNS_APP_ID,
        authToken: process.env.DFNS_AUTH_TOKEN!,
        walletId: process.env.DFNS_WALLET_ID,
        credentialId: process.env.DFNS_CREDENTIAL_ID!,
        credentialPrivateKey,
        credentialAlgorithm: process.env.DFNS_CREDENTIAL_ALGORITHM,
        address: metadata.address,
      },
      provider
    );
  } else if (walletMode === 'user' || (!walletMode && process.env.USER_PRIVATE_KEY)) {
    if (!process.env.USER_PRIVATE_KEY) {
      throw new Error('Missing USER_PRIVATE_KEY for WALLET_MODE=user');
    }
    wallet = createUserWallet(process.env.USER_PRIVATE_KEY, provider);
  } else if (walletMode) {
    throw new Error(`Unsupported WALLET_MODE: ${walletMode}`);
  } else {
    throw new Error(
      'Set WALLET_MODE=ledger-bridge with LEDGER_BRIDGE_TOKEN, USER_PRIVATE_KEY, or DFNS_WALLET_ID + DFNS_AUTH_TOKEN + DFNS_CREDENTIAL_ID + (DFNS_CREDENTIAL_PRIVATE_KEY or DFNS_PRIVATE_KEY_PATH)',
    );
  }

  session = await ZamaFheSession.create({ rpcUrl: RPC_URL, chain, sessionTTL: 3_600_000 });

  signer = wallet.getSigner();
}

/** Contract address → ABI mapping. Lazy: only constructed when first accessed. */
const CONTRACT_DEFS: Record<string, { address: () => string; abi: readonly string[] }> = {
  cUSDC:      { address: () => CUSDC_ADDRESS,      abi: TOKEN_ABI },
  usdc:       { address: () => USDC_ADDRESS,        abi: ERC20_ABI },
  verifier:   { address: () => VERIFIER_ADDRESS,    abi: VERIFIER_ABI },
  identity:   { address: () => IDENTITY_ADDRESS,    abi: IDENTITY_ABI },
  reputation: { address: () => REPUTATION_ADDRESS,  abi: REPUTATION_ABI },
  escrow:     { address: () => ESCROW_ADDRESS,      abi: ESCROW_ABI },
};

/** Get a contract instance, constructing it on first access per session. */
function getContract(name: string): Contract {
  if (!_contractCache[name]) {
    const def = CONTRACT_DEFS[name];
    if (!def) throw new Error(`Unknown contract: ${name}`);
    const addr = def.address() || ('0x' + '00'.repeat(20));
    // Empty addresses get zero-address fallback — ethers will throw on the first
    // actual RPC call, but construction succeeds (needed for test mocks).
    _contractCache[name] = new Contract(addr, def.abi, signer);
  }
  return _contractCache[name];
}

// ============================================================================
// Public API
// ============================================================================

export async function getContracts(): Promise<{
  wallet: FheWallet;
  session: ZamaFheSession;
  provider: JsonRpcProvider;
  cUSDC: Contract;
  usdc: Contract;
  verifier: Contract;
  identity: Contract;
  reputation: Contract;
  escrow: Contract;
}> {
  if (!initPromise) initPromise = init();
  await initPromise;

  // Contracts are truly lazy: only constructed when a command destructures them.
  // `const { escrow, wallet } = await getContracts()` only constructs escrow
  // (plus cUSDC/usdc which are always needed). identity/reputation/verifier
  // are NOT constructed unless the command actually reads them.
  const base = { wallet, session, provider };
  return new Proxy(base as any, {
    get(target, prop: string) {
      if (prop in target) return (target as any)[prop];
      if (prop in CONTRACT_DEFS) return getContract(prop);
      return undefined;
    },
  });
}

// ============================================================================
// Amount helpers
// ============================================================================

/**
 * Parse a human-readable USDC string to micro-USDC bigint.
 * "2.5" → 2_500_000n
 */
export function parseAmount(str: string): bigint {
  const trimmed = str.trim();
  if (!/^\d+(\.\d+)?$/.test(trimmed)) {
    throw new Error(`Invalid amount "${trimmed}". Must be a positive decimal number.`);
  }
  const [whole, dec = ''] = trimmed.split('.');
  const padded = dec.padEnd(6, '0').slice(0, 6);
  const raw = BigInt(whole) * 1_000_000n + BigInt(padded);
  if (raw <= 0n) {
    throw new Error('Amount must be positive (> 0).');
  }
  return raw;
}

/** Format micro-USDC bigint to human-readable string. 2_500_000n → "2.50" */
export function formatUSDC(raw: bigint): string {
  return (Number(raw) / 1_000_000).toFixed(2);
}

// ============================================================================
// Output helpers
// ============================================================================

export function ok(data: Record<string, unknown>): string {
  return JSON.stringify({ ok: true, ...data });
}

export function fail(msg: string): string {
  return JSON.stringify({ ok: false, error: msg });
}

// ============================================================================
// CLI argument parsing
// ============================================================================

/**
 * Parse argv-style arguments into a key→value map.
 * Handles "--key=value" and "--key value" forms.
 */
export function parseCliArgs(argv: string[]): Record<string, string> {
  const options: Record<string, { type: 'string' }> = {};
  for (const arg of argv) {
    if (arg.startsWith('--')) {
      const key = arg.split('=')[0].slice(2);
      if (key) options[key] = { type: 'string' };
    }
  }
  const { values } = parseArgs({ args: argv, options, strict: false });
  return values as Record<string, string>;
}


Security scan verdicts — ClawScan: suspicious | static analysis: suspicious | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)