Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 15 additions & 15 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ CLI wallet for one-shell-command agent payments across x402 (Base) and MPP (Temp

## Purpose

One shell command for shell-tool LLM agents to pay any 402/MPP merchant. POST body preserved through the round-trip; agent never sees a private key on the wire. Works with any 402-gated merchant — AgentScore-gated or not.
One shell command for shell-tool LLM agents to pay any 402/MPP merchant. POST body preserved through the round-trip; agent never sees a private key on the wire. Works with any 402-gated merchant, AgentScore-gated or not.

## Architecture

Expand All @@ -24,16 +24,16 @@ Single-package TypeScript CLI published to npm. Runnable via `npx @agent-score/p
| `src/commands/wallet.ts` | `wallet create/import/address/list/remove/export/show-mnemonic` |
| `src/commands/balance.ts` | `balance` across chains |
| `src/commands/qr.ts` | `qr` with optional amount |
| `src/commands/fund.ts` | `fund` — receive QR + balance polling (default); `--via stripe-onramp` mints a Stripe Crypto Onramp session (base/solana mainnet only); `--quote-only` returns the Stripe price preview without minting. Tempo testnet uses programmatic mint via tempo_fundAddress. |
| `src/onramp.ts` | API client for the AgentScore Crypto Onramp endpoints (POST /v1/onramp/sessions + POST /v1/onramp/quotes). Sends X-Client-Id + the stored passport operator_token in the body — no merchant API key required. |
| `src/commands/pay.ts` | `pay <METHOD> <URL>` — routes to `@x402/fetch` (base) or `mppx/client` (tempo, solana via `@solana/mpp/client`). `--identity auto\|operator\|wallet` selects the identity to present (`operator` uses the stored passport operator_token; `wallet` uses the wallet address; `auto` is the default passport/wallet path). |
| `src/commands/fund.ts` | `fund`, receive QR + balance polling (default); `--via stripe-onramp` mints a Stripe Crypto Onramp session (base/solana mainnet only); `--quote-only` returns the Stripe price preview without minting. Tempo testnet uses programmatic mint via tempo_fundAddress. |
| `src/onramp.ts` | API client for the AgentScore Crypto Onramp endpoints (POST /v1/onramp/sessions + POST /v1/onramp/quotes). Sends X-Client-Id + the stored passport operator_token in the body, no merchant API key required. |
| `src/commands/pay.ts` | `pay <METHOD> <URL>`, routes to `@x402/fetch` (base) or `mppx/client` (tempo, solana via `@solana/mpp/client`). `--identity auto\|operator\|wallet` selects the identity to present (`operator` uses the stored passport operator_token; `wallet` uses the wallet address; `auto` is the default passport/wallet path). |
| `src/commands/identity.ts` | `assess`, `sessions create/get`, `credentials create/list/revoke`, `associate-wallet` (wraps `@agent-score/sdk`) |
| `src/commands/send.ts` | `send --chain <chain> --to <addr> --amount <n> [--asset usdc\|native]` — raw transfer (no merchant / 402). Default `--asset usdc` (ERC20 transfer on EVM / SPL transferChecked + idempotent ATA on Solana). `--asset native` sends ETH/TEMPO/SOL via viem sendTransaction (EVM) or `getTransferSolInstruction` (Solana). EVM transfers confirm the on-chain receipt (`src/chains/evm-confirm.ts`) and surface a reverted tx as `transfer_reverted` instead of returning a hash for a tx that never settled. Note Tempo pays the network fee in the stablecoin itself, so a full-balance USDC send reverts (no headroom for the fee); leave a little `--amount` headroom. Works on mainnet + testnet (`--network testnet`). |
| `src/commands/passport.ts` | `passport login/status/logout` — AgentScore Passport (buyer-side identity); stores opc_ at `~/.agentscore/passport.json`, auto-attached on `agentscore-pay <url>` settle leg |
| `src/commands/send.ts` | `send --chain <chain> --to <addr> --amount <n> [--asset usdc\|native]`, raw transfer (no merchant / 402). Default `--asset usdc` (ERC20 transfer on EVM / SPL transferChecked + idempotent ATA on Solana). `--asset native` sends ETH/TEMPO/SOL via viem sendTransaction (EVM) or `getTransferSolInstruction` (Solana). EVM transfers confirm the on-chain receipt (`src/chains/evm-confirm.ts`) and surface a reverted tx as `transfer_reverted` instead of returning a hash for a tx that never settled. Note Tempo pays the network fee in the stablecoin itself, so a full-balance USDC send reverts (no headroom for the fee); leave a little `--amount` headroom. Works on mainnet + testnet (`--network testnet`). |
| `src/commands/passport.ts` | `passport login/status/logout`, AgentScore Passport (buyer-side identity); stores opc_ at `~/.agentscore/passport.json`, auto-attached on `agentscore-pay <url>` settle leg |
| `src/passport/{auth,storage,attach}.ts` | Passport login flow (mint+poll), conf-style local keystore, X-Operator-Token attach decision tree |
| `src/progress.ts` | stderr-only structured progress events (stdout belongs to incur) |
| `tests/` | Vitest unit tests |
| `dist/` | tsup output — ESM only with shebang banner |
| `dist/` | tsup output, ESM only with shebang banner |

## Chain-to-protocol routing

Expand All @@ -45,12 +45,12 @@ Both paths preserve POST bodies through the 402 round-trip. The CLI's passphrase

## Tooling

- **Bun** — package manager. Use `bun install`, `bun run <script>`.
- **ESLint 9** — linting. `bun run lint`.
- **tsup** — builds ESM with `#!/usr/bin/env node` banner. `bun run build`.
- **Vitest** — tests. `bun run test`.
- **knip** — dead-code check. `bun run knip` (src/aip/ excluded: dormant surface kept for re-enable).
- **Lefthook** — git hooks. Pre-commit: lint. Pre-push: typecheck.
- **Bun**, package manager. Use `bun install`, `bun run <script>`.
- **ESLint 9**, linting. `bun run lint`.
- **tsup**, builds ESM with `#!/usr/bin/env node` banner. `bun run build`.
- **Vitest**, tests. `bun run test`.
- **knip**, dead-code check. `bun run knip` (src/aip/ excluded: dormant surface kept for re-enable).
- **Lefthook**, git hooks. Pre-commit: lint. Pre-push: typecheck.

## Patched dependencies

Expand Down Expand Up @@ -86,14 +86,14 @@ bun run build
1. Create a branch
2. Make changes
3. Lefthook runs lint on commit, typecheck on push
4. Open a PR — CI runs automatically
4. Open a PR, CI runs automatically
5. Merge (squash)

## Rules

- **No silent refactors**
- **Never commit .env files or secrets**
- **Use PRs** — never push directly to main
- **Use PRs**, never push directly to main

## Releasing

Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ Thanks for your interest in contributing! Here's how to get started.

- All PRs require 1 approval before merging
- Squash merge to `main` is the standard
- Keep PRs focused — one feature or fix per PR
- Keep PRs focused, one feature or fix per PR
- Include tests for new functionality
- Make sure CI passes before requesting review

Expand Down
10 changes: 4 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ agentscore-pay pay POST https://merchant.example/api --chain tempo -d '...' --ma

## Agents (scripting)

For LLM tool-loop agents, run `agentscore-pay agent-guide` (add `--json` for parseable output). It prints the structured how-to: golden path (init → discover → balance → check → dry-run → pay), testnet path, funding, auxiliary commands (`unlock`, `limits`, `whoami`, `history`), pitfalls, and exit-code branching. The same notes also surface in `agentscore-pay <command> --help` for `check` and `pay`.
For LLM tool-loop agents, run `agentscore-pay agent-guide` (add `--json` for parseable output). It prints the structured how-to: golden path (init → discover → balance → check → dry-run → pay), testnet path, funding, auxiliary commands (`limits`, `whoami`, `history`), pitfalls, and exit-code branching. The same notes also surface in `agentscore-pay <command> --help` for `check` and `pay`.

### Output formats

Expand Down Expand Up @@ -281,7 +281,6 @@ Each row below is a subcommand of `agentscore-pay`, invoke as `agentscore-pay <c
| `limits show \| set \| clear` | Persistent per-call / daily / per-merchant USD spending limits |
| `config get [key] \| set <k> <v> \| unset <k> \| path` | Read/write `~/.agentscore/config.json` (e.g. `config set preferred_chains tempo,base`) |
| `discover [--search q] [--chain c] [--max-price N] [--limit N] [--protocol x402\|mpp\|both]` | List paid services from the x402 Bazaar (Coinbase) and MPP services directory (Tempo). Both queried in parallel by default. |
| `unlock [--for 15m] \| --clear` | Cache passphrase to `~/.agentscore/.unlock` (mode 0600) for a bounded TTL, skip per-call prompts during a session |
| `revoke --chain c --token <addr> --spender <addr> [--network n]` | Send `approve(spender, 0)` on EVM (base/tempo). Requires native gas. |

### Identity commands
Expand Down Expand Up @@ -419,15 +418,14 @@ AGENTSCORE_PAY_HOME=~/.agentscore-test agentscore-pay pay --network testnet ...

## Unlocking the keystore

Three ways to satisfy the passphrase prompt, in precedence order:
Two ways to satisfy the passphrase prompt, in precedence order:

| Mechanism | Best for | Persistence |
|---|---|---|
| `AGENTSCORE_PAY_PASSPHRASE=<pass>` env var | Containers, CI, serverless, daemons, any non-interactive agent | Lifetime of the process / shell session |
| `agentscore-pay unlock --for 1h` | Interactive shell session where you'll run multiple commands | Cached in `~/.agentscore/.unlock` (mode `0600`) until TTL expires (max 8h); `unlock --clear` wipes early |
| Per-call interactive prompt | First-time setup, one-off commands | None, never written |

**Env var wins** when set: it produces no on-disk artifact and is the right primitive for every agent context (CI secrets, container env, K8s secrets, Lambda config, MCP host config). When env vars aren't controllable (e.g. an interactive Claude Code session running locally), `unlock` is the fallback.
**Env var wins** when set: it produces no on-disk artifact and is the right primitive for every agent context (CI secrets, container env, K8s secrets, Lambda config, MCP host config). There is no on-disk passphrase cache: it would hold the passphrase in cleartext beside the keystores it unlocks.

**For agent authors**: prefer env var. Read it from your secret store at agent startup; don't bake it into the agent's prompt or memory. Pay's `agent-guide` command (`agentscore-pay agent-guide`) explains the precedence in its own structured output.

Expand Down Expand Up @@ -481,7 +479,7 @@ agentscore-pay fund-estimate https://agents.example.com/purchase \

For **interactive humans**: any 8+ char passphrase you'll remember is fine, the scrypt-256-GCM combination is robust against offline brute force at typical human-passphrase entropy levels.

For **autonomous agents**: the passphrase usually lives in `AGENTSCORE_PAY_PASSPHRASE` (env var) or `~/.agentscore/.unlock` (file). Both are filesystem/process-readable to anyone with shell access as that user, so the passphrase isn't really a separate trust boundary, it's primarily protecting the keystore against *theft of the on-disk file alone*. A long, random passphrase makes the file useless if exfiltrated:
For **autonomous agents**: the passphrase usually lives in `AGENTSCORE_PAY_PASSPHRASE` (env var), which is process-readable to anyone with shell access as that user, so the passphrase isn't really a separate trust boundary, it's primarily protecting the keystore against *theft of the on-disk file alone*. A long, random passphrase makes the file useless if exfiltrated:

```sh
# generate a 32-byte (256-bit) random passphrase
Expand Down
4 changes: 2 additions & 2 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ We will acknowledge receipt within 48 hours and aim to release a fix within 7 da

This is a CLI wallet. The security model assumes:

- The machine running the CLI is trusted — keys are encrypted at rest, but a compromised host with keyboard/memory access can recover them
- The machine running the CLI is trusted, keys are encrypted at rest, but a compromised host with keyboard/memory access can recover them
- The passphrase is not logged anywhere (we prompt interactively or read from `AGENTSCORE_PAY_PASSPHRASE`)
- `wallet export` and `wallet show-mnemonic` print secret material to stdout — only run these on trusted terminals
- `wallet export` and `wallet show-mnemonic` print secret material to stdout, only run these on trusted terminals

Issues outside this scope (e.g., extracting keys from a machine you already control, upstream `@x402/fetch` / `mppx` / `viem` vulnerabilities) should be reported to the respective projects.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@agent-score/pay",
"version": "0.6.2",
"version": "0.6.3",
"description": "CLI wallet for one-shell-command agent payments across x402 (Base) and MPP (Tempo, Solana)",
"type": "module",
"main": "./dist/index.js",
Expand Down
14 changes: 7 additions & 7 deletions src/aip/agent-key.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/**
* AIP agent key — the Ed25519 keypair pay uses as the AIT `cnf` (proof-of-possession) key.
* AIP agent key, the Ed25519 keypair pay uses as the AIT `cnf` (proof-of-possession) key.
*
* One Ed25519 key, independent of payment wallets (which are chain-typed secp256k1/ed25519). The
* public half is bound into a minted AIT's `cnf` claim; the private half signs each merchant
Expand All @@ -21,7 +21,7 @@ interface AgentKeyFile {
version: 1;
kind: 'aip-agent-key';
crv: 'Ed25519';
/** Public x coordinate (base64url) — kept in the clear for quick public-JWK reads. */
/** Public x coordinate (base64url), kept in the clear for quick public-JWK reads. */
x: string;
/** Encrypted private scalar `d` (the 32-byte Ed25519 seed). */
encryption: KeystoreFile['encryption'];
Expand All @@ -37,7 +37,7 @@ export interface AgentKey {
const isNotFound = (err: unknown): boolean =>
typeof err === 'object' && err !== null && (err as { code?: string }).code === 'ENOENT';

/** True if an agent key already exists on disk. Only ENOENT means "missing" — any other
/** True if an agent key already exists on disk. Only ENOENT means "missing", any other
* error (EACCES, EIO, ...) propagates so a transient failure never silently regenerates
* (and overwrites) the key. */
export async function agentKeyExists(): Promise<boolean> {
Expand All @@ -62,11 +62,11 @@ async function loadAgentKey(passphrase: string): Promise<AgentKey> {
} catch {
throw new CliError(
'wrong_passphrase',
`Could not decrypt the AIP agent key at ${agentKeyPath()} — the passphrase does not match the one the key was created with.`,
`Could not decrypt the AIP agent key at ${agentKeyPath()}, the passphrase does not match the one the key was created with.`,
{
nextSteps: {
action: 'retry_or_regenerate_agent_key',
suggestion: `Retry with the passphrase the agent key was created under, or delete ${agentKeyPath()} to regenerate it — the cnf public key is re-sent on every mint, so deleting it is safe.`,
suggestion: `Retry with the passphrase the agent key was created under, or delete ${agentKeyPath()} to regenerate it, the cnf public key is re-sent on every mint, so deleting it is safe.`,
},
extra: { path: agentKeyPath() },
},
Expand Down Expand Up @@ -96,7 +96,7 @@ async function createAgentKey(passphrase: string): Promise<AgentKey> {
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
try {
// Exclusive create: never clobber a key another process persisted between the exists
// check and this write — losing the create race means the other key is canonical.
// check and this write, losing the create race means the other key is canonical.
await writeFile(path, JSON.stringify(file, null, 2) + '\n', { mode: 0o600, flag: 'wx' });
} catch (err: unknown) {
if ((err as { code?: string } | null)?.code === 'EEXIST') return loadAgentKey(passphrase);
Expand All @@ -108,7 +108,7 @@ async function createAgentKey(passphrase: string): Promise<AgentKey> {

/**
* Load the agent key, creating + persisting one on first use. `passphrase` unlocks (or encrypts,
* on first create) the private scalar — same passphrase model as the wallet keystore.
* on first create) the private scalar, same passphrase model as the wallet keystore.
*/
export async function getOrCreateAgentKey(passphrase: string): Promise<AgentKey> {
try {
Expand Down
2 changes: 1 addition & 1 deletion src/aip/http-signature.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/**
* RFC 9421 HTTP Message Signature — the AIP proof-of-possession SIGNER (client side).
* RFC 9421 HTTP Message Signature, the AIP proof-of-possession SIGNER (client side).
*
* pay presents an Agent Identity Token (AIT) to a merchant by signing the request with the
* Ed25519 key whose public half is bound in the token's `cnf` claim. This module produces the
Expand Down
6 changes: 3 additions & 3 deletions src/aip/mint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
*
* pay sends X-Client-Id `agentscore_pay_pubclient_v1` when minting.
* The issuer resolves the operator's verified identity from its passport/account and stamps the
* derived compliance claims (id_verified, age bands, jurisdiction, sanctions) — pay does NOT and
* derived compliance claims (id_verified, age bands, jurisdiction, sanctions), pay does NOT and
* cannot assert those. pay supplies only the operator_token, the agent cnf public key, and an
* honest `trust_level` reflecting whether a human is at the terminal.
*
Expand All @@ -17,7 +17,7 @@ import type { Ed25519PublicJwk } from './http-signature';
const DEFAULT_BASE_URL = process.env.AGENTSCORE_BASE_URL ?? 'https://api.agentscore.com';
const PUBLIC_CLIENT_ID = 'agentscore_pay_pubclient_v1';

/** AIP trust level — the per-action human-authorization strength. `autonomous` (no human) and
/** AIP trust level, the per-action human-authorization strength. `autonomous` (no human) and
* `human_present` (TTY) are inferred from terminal presence; `human_confirmed` is set only when the
* human explicitly confirms the intent at the terminal (requires auth.amr + intent.description). */
export type TrustLevel = 'autonomous' | 'human_present' | 'human_confirmed';
Expand All @@ -32,7 +32,7 @@ export interface MintAitInput {
intent?: string;
/** Machine-readable intent actions (e.g. `['purchase']`), stamped into `intent.actions`. */
actions?: string[];
/** Authentication context for `human_confirmed` — `auth.amr` (RFC 8176) MUST carry ≥1 value
/** Authentication context for `human_confirmed`, `auth.amr` (RFC 8176) MUST carry ≥1 value
* (e.g. `['user']` for a terminal confirmation). The issuer rejects human_confirmed without it. */
auth?: { amr?: string[]; time?: number };
baseUrl?: string;
Expand Down
2 changes: 1 addition & 1 deletion src/chains/solana.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ export async function nativeBalance(ownerBase58: string, network: Network = 'mai
}

export function formatNative(raw: bigint): string {
// SOL has 9 decimals (lamports). Show 4 fractional digits — enough for
// SOL has 9 decimals (lamports). Show 4 fractional digits, enough for
// typical fee-balance reads.
const whole = raw / 10n ** 9n;
const frac = raw % 10n ** 9n;
Expand Down
2 changes: 1 addition & 1 deletion src/chains/tempo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ export async function transfer(input: {
/**
* Calls Moderato testnet's `tempo_fundAddress` JSON-RPC method, which mints
* test stablecoins (pathUSD, AlphaUSD, BetaUSD, ThetaUSD) to the address.
* Testnet only — mainnet has no programmatic faucet.
* Testnet only, mainnet has no programmatic faucet.
*/
export async function fundTestnet(address: string): Promise<string[]> {
const cfg = evmConfig('tempo', 'testnet');
Expand Down
Loading
Loading