Skip to content

Commit 8a8b547

Browse files
MajorTalclaude
andauthored
fix(billing): address accounts by :account_id; harden /wallets/:address/projects auth (#433)
Mirror the gateway api-cleanup-batch-1-accounts contract in SDK/CLI/MCP: - Account reads are keyed by the canonical billing_account_id (UUID). SDK getAccount/checkBalance route a UUID to GET /billing/v1/accounts/:account_id and a wallet/email through the new GET /billing/v1/accounts?wallet=|?email= lookup; getHistory resolves wallet/email to the account id first, then reads /accounts/:account_id/history. Add billing.lookupAccount as the resolve primitive. Response shape drops identifier_type and adds billing_account_id (BillingBalance kept as a deprecated alias of BillingAccountDetail). - Exposure hardening: projects.list (GET /wallets/v1/:address/projects) now sends SIWX — the endpoint went from public to owner-only (admin bypasses). - MCP tool descriptions, CLI billing help, and the comprehensive docs (llms-cli.txt, llms-sdk.txt, sdk/README.md, SKILL/openclaw SKILL) updated. - Tests: rewrote billing/projects URL+auth assertions, added lookupAccount coverage and a CLI-e2e lookup mock; sync.test endpoint registry updated. Closes #432 Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 95a578c commit 8a8b547

16 files changed

Lines changed: 380 additions & 184 deletions

SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -722,7 +722,7 @@ The MCP server manages a local agent allowance — a wallet key dedicated to pay
722722
- **`init`** — composes `allowance_create` + `request_faucet` + `tier_status` + `list_projects`. Use this on a fresh install.
723723
- **`allowance_create`** / **`allowance_status`** / **`allowance_export`** — granular allowance ops.
724724
- **`request_faucet`** — Base Sepolia testnet USDC.
725-
- **`check_balance`**mainnet + testnet + billing balance for an address.
725+
- **`check_balance`**run402 billing account balance (available + held) for the agent's wallet; resolves the wallet to its account over SIWX.
726726

727727
Other allowance options:
728728
- **Coinbase AgentKit** — MPC wallet on Base with built-in x402.

cli-e2e.test.mjs

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -688,8 +688,22 @@ async function mockFetch(input, init) {
688688
}
689689

690690
// Billing
691+
// Account lookup: GET /billing/v1/accounts?wallet=|?email= → resolve to a
692+
// billing_account_id (and return the same detail shape as the by-id read).
693+
if (pathNoQuery === "/billing/v1/accounts" && method === "GET") {
694+
return Promise.resolve(json({
695+
billing_account_id: "00000000-0000-4000-8000-0000000000e2",
696+
available_usd_micros: 150000,
697+
held_usd_micros: 0,
698+
email_credits_remaining: 0,
699+
tier: null,
700+
lease_expires_at: null,
701+
auto_recharge_enabled: false,
702+
auto_recharge_threshold: 0,
703+
}));
704+
}
691705
if (path.match(/^\/billing\/v1\/accounts\/[^/]+$/) && method === "GET") {
692-
return Promise.resolve(json({ available_usd_micros: 150000, held_usd_micros: 0 }));
706+
return Promise.resolve(json({ billing_account_id: "00000000-0000-4000-8000-0000000000e2", available_usd_micros: 150000, held_usd_micros: 0 }));
693707
}
694708
if (path.match(/\/history/) && method === "GET") {
695709
return Promise.resolve(json({ transactions: [{ id: "tx1", amount: -100000, description: "Tier subscription" }] }));

cli/lib/billing.mjs

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@ Subcommands:
1313
tier-checkout <tier> [--email <e> | --wallet <w>] Stripe tier checkout
1414
buy-email-pack [--email <e> | --wallet <w>] Buy \$5 email pack (10,000 emails)
1515
auto-recharge <account_id> <on|off> [--threshold <n>]
16-
balance <identifier> Balance by email or wallet (0x...)
17-
history <identifier> [--limit <n>] Ledger history by email or wallet
16+
balance <identifier> Balance by account id (UUID), wallet (0x...), or email
17+
history <identifier> [--limit <n>] Ledger history by account id (UUID), wallet, or email
1818
1919
Examples:
2020
run402 billing create-email user@example.com
@@ -70,32 +70,44 @@ Examples:
7070
run402 billing auto-recharge acct_abc on --threshold 2000
7171
run402 billing auto-recharge acct_abc off
7272
`,
73-
history: `run402 billing history — Show ledger history for an email or wallet
73+
history: `run402 billing history — Show ledger history for a billing account
7474
7575
Usage:
7676
run402 billing history <identifier> [--limit <n>]
7777
7878
Arguments:
79-
<identifier> Email address or wallet (0x...)
79+
<identifier> Billing account id (UUID), wallet (0x...), or email.
80+
Wallet/email are resolved to the account id first.
8081
8182
Options:
8283
--limit <n> Max entries to return (default: 50)
8384
85+
Auth:
86+
Requires SIWX from a wallet linked to the account (signed automatically from
87+
the local allowance), or an admin key. Email lookups require an admin key.
88+
8489
Examples:
8590
run402 billing history user@example.com
8691
run402 billing history 0x1234... --limit 100
92+
run402 billing history 00000000-0000-4000-8000-000000000001
8793
`,
88-
balance: `run402 billing balance — Show balance for an email or wallet
94+
balance: `run402 billing balance — Show balance for a billing account
8995
9096
Usage:
9197
run402 billing balance <identifier>
9298
9399
Arguments:
94-
<identifier> Email address or wallet (0x...)
100+
<identifier> Billing account id (UUID), wallet (0x...), or email.
101+
Wallet/email are resolved via the accounts lookup.
102+
103+
Auth:
104+
Requires SIWX from a wallet linked to the account (signed automatically from
105+
the local allowance), or an admin key. Email lookups require an admin key.
95106
96107
Examples:
97108
run402 billing balance user@example.com
98109
run402 billing balance 0x1234abcd...
110+
run402 billing balance 00000000-0000-4000-8000-000000000001
99111
`,
100112
"create-email": `run402 billing create-email — Create an email billing account
101113
@@ -292,8 +304,8 @@ async function balance(args) {
292304
if (!id) {
293305
fail({
294306
code: "BAD_USAGE",
295-
message: "Missing <email-or-wallet>.",
296-
hint: "run402 billing balance <email-or-wallet>",
307+
message: "Missing <identifier>.",
308+
hint: "run402 billing balance <account-id | wallet | email>",
297309
});
298310
}
299311
try {
@@ -316,8 +328,8 @@ async function history(args) {
316328
if (!id) {
317329
fail({
318330
code: "BAD_USAGE",
319-
message: "Missing <email-or-wallet>.",
320-
hint: "run402 billing history <email-or-wallet> [--limit <n>]",
331+
message: "Missing <identifier>.",
332+
hint: "run402 billing history <account-id | wallet | email> [--limit <n>]",
321333
});
322334
}
323335
const limit = parsedArgs.includes("--limit")

cli/llms-cli.txt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1342,10 +1342,10 @@ Email-based billing accounts, Stripe tier checkout, and email packs. Pay run402
13421342
- `run402 billing tier-checkout <tier> [--email <e> | --wallet <w>]` — subscribe/renew/upgrade a tier (prototype $0.10 / hobby $5 / team $20) via Stripe. Returns a checkout URL.
13431343
- `run402 billing buy-email-pack [--email <e> | --wallet <w>]` — buy a $5 email pack (10,000 emails, never expire). Returns a Stripe checkout URL.
13441344
- `run402 billing auto-recharge <account_id> <on|off> [--threshold <n>]` — auto-repurchase $5 packs when credits drop below threshold. Requires saved Stripe payment method.
1345-
- `run402 billing balance <email-or-wallet>` — balance + email_credits_remaining + tier + lease + auto_recharge state
1346-
- `run402 billing history <email-or-wallet> [--limit <n>]` — ledger history (both identifier types supported)
1345+
- `run402 billing balance <account-id | wallet | email>` — balance + email_credits_remaining + tier + lease + auto_recharge state (response includes `billing_account_id`). A wallet/email is resolved to its account via `GET /billing/v1/accounts?wallet=|?email=`; an account id (UUID) reads `GET /billing/v1/accounts/:account_id` directly.
1346+
- `run402 billing history <account-id | wallet | email> [--limit <n>]` — ledger history. Keyed by account id: a wallet/email is resolved to its `billing_account_id` first, then `GET /billing/v1/accounts/:account_id/history`.
13471347

1348-
Auth (v2.19.1+): `balance`, `history`, `link-wallet`, and `auto-recharge` require SIWX signed by the relevant wallet (the path wallet for reads, the body wallet for link, a linked wallet for auto-recharge). The CLI signs automatically from the local allowance, so these only work when the agent's allowance wallet is the one being queried/linked/billed. Email-identifier reads require an admin key. Checkout-creation commands (`create-email`, `tier-checkout`, `buy-email-pack`) remain unauthenticated.
1348+
Auth: account reads (`balance`, `history`) require SIWX from a wallet **linked to** the account (membership), or an admin key; the wallet→account lookup requires SIWX matching the queried wallet (email lookups are admin-only). `link-wallet` requires the body wallet's SIWX (or admin); `auto-recharge` a linked wallet's SIWX. The CLI signs automatically from the local allowance, so these only work when the agent's allowance wallet is on the account being queried/linked/billed. A non-member (or someone guessing an account id) gets 403 with no existence leak. Checkout-creation commands (`create-email`, `tier-checkout`, `buy-email-pack`) remain unauthenticated.
13491349

13501350
Email packs only activate when the tier daily limit is exhausted AND the project has a verified custom sender domain (spam protection for `mail.run402.com`).
13511351

openclaw/SKILL.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -846,8 +846,8 @@ run402 billing link-wallet <account_id> <wallet> # response includes pool_impl
846846
run402 billing tier-checkout hobby --email user@example.com
847847
run402 billing buy-email-pack --email user@example.com # $5 / 10k emails (never expire)
848848
run402 billing auto-recharge <account_id> on --threshold 2000
849-
run402 billing balance <email-or-wallet>
850-
run402 billing history <email-or-wallet>
849+
run402 billing balance <account-id | wallet | email> # wallet/email resolved to the account; SIWX must be linked to it (email lookups admin-only)
850+
run402 billing history <account-id | wallet | email>
851851
```
852852

853853
`run402 tier set` refetches `/tiers/v1/status` after the call and includes the refreshed account-pool snapshot as `status_after` in the JSON output, so the new pooled `api_calls` / `storage_bytes` totals come back in one step.

sdk/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ The `CredentialsProvider` interface has two required methods (`getAuth`, `getPro
7575
| `auth` | `requestMagicLink`, `verifyMagicLink`, `createUser`, `inviteUser`, `setUserPassword`, `settings`, passkey registration/login/list/delete helpers, `providers`, `promote`, `demote` |
7676
| `apps` | `browse`, `getApp`, `fork`, `publish`, `listVersions`, `updateVersion`, `deleteVersion` |
7777
| `tier` | `set`, `status` (tier pricing lives on `r.projects.getQuote()`) |
78-
| `billing` | `createEmailAccount`, `linkWallet`, `tierCheckout`, `buyEmailPack`, `setAutoRecharge`, `checkBalance`, `getAccount`, `getHistory`, `balance`, `history`, `createCheckout` |
78+
| `billing` | `createEmailAccount`, `linkWallet`, `tierCheckout`, `buyEmailPack`, `setAutoRecharge`, `checkBalance`, `getAccount`, `lookupAccount`, `getHistory`, `balance`, `history`, `createCheckout` |
7979
| `contracts` | `provisionWallet`, `getWallet`, `listWallets`, `setRecovery`, `setLowBalanceAlert`, `call`, `read`, `callStatus`, `drain`, `deleteWallet` |
8080
| `ai` | `translate`, `moderate`, `usage`, `generateImage` |
8181
| `allowance` | `status`, `create`, `export`, `faucet` |

sdk/llms-sdk.txt

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -632,7 +632,7 @@ demoteUser(id, email): Promise<void>
632632
- `lease_perpetual: boolean | null` — operator escape hatch flag. When `true`, the account never advances past `active` regardless of lease expiry.
633633
- `tier: "prototype" | "hobby" | "team" | null` — the account's active tier.
634634

635-
`r.projects.list(wallet)` returns per-project facts only (`id`, `name`, `api_calls`, `storage_bytes`, `created_at`). The v1.56 `status` / `pinned` / `tier` mirrors and the v1.57 `effective_status` / `account_lifecycle_state` / `lease_perpetual` per-project mirrors were dropped. The v1.56 `projects.pin(id)` SDK method was removed alongside the endpoint — use `r.admin.setLeasePerpetual(ba_id, true)`.
635+
`r.projects.list(wallet)` returns per-project facts only (`id`, `name`, `api_calls`, `storage_bytes`, `created_at`). It now requires SIWX from the wallet itself (or an admin key) — `GET /wallets/v1/:wallet/projects` was hardened from public to owner-only, since project ids/names/usage are private; the kernel signs the header from the provider, and listing another wallet's projects returns 403. The v1.56 `status` / `pinned` / `tier` mirrors and the v1.57 `effective_status` / `account_lifecycle_state` / `lease_perpetual` per-project mirrors were dropped. The v1.56 `projects.pin(id)` SDK method was removed alongside the endpoint — use `r.admin.setLeasePerpetual(ba_id, true)`.
636636

637637
`r.projects.getUsage(id)` still surfaces `effective_status` and `account_lifecycle_state` because that endpoint scopes to a single project and the derivation collapses per-project `archived_at` / `deleted_at` together with the account's lifecycle.
638638

@@ -1159,13 +1159,14 @@ gateways.
11591159

11601160
```
11611161
createEmailAccount(email): Promise<EmailBillingAccount>
1162-
linkWallet(billingAccountId, wallet): Promise<LinkWalletResult>
1162+
linkWallet(billingAccountId, wallet): Promise<LinkWalletResult> // billingAccountId = UUID; POST /billing/v1/accounts/:account_id/link-wallet
11631163
tierCheckout(tier, identifier: { email?: string, wallet?: string }): Promise<CreateCheckoutResult>
11641164
buyEmailPack(identifier: { email?: string, wallet?: string }): Promise<CreateCheckoutResult>
11651165
setAutoRecharge(opts: { billingAccountId: string, enabled: boolean, threshold? }): Promise<void>
1166-
checkBalance(identifier): Promise<BillingBalance>
1167-
getAccount(identifier): Promise<BillingBalance>
1168-
balance(identifier): Promise<BillingBalance> // alias of checkBalance
1166+
checkBalance(identifier): Promise<BillingAccountDetail> // identifier = account id (UUID) | wallet | email
1167+
getAccount(identifier): Promise<BillingAccountDetail>
1168+
lookupAccount(identifier): Promise<BillingAccountDetail> // resolve wallet/email → account (incl. billing_account_id)
1169+
balance(identifier): Promise<BillingAccountDetail> // alias of checkBalance
11691170
history(identifier, limit?: number): Promise<BillingHistoryResult>
11701171
getHistory(identifier, limit?: number): Promise<BillingHistoryResult>
11711172
createCheckout(wallet, amountUsdMicros: number): Promise<CreateCheckoutResult>
@@ -1175,6 +1176,17 @@ createEmail(email): Promise<EmailBillingAccount>
11751176
autoRecharge(opts): Promise<void>
11761177
```
11771178

1179+
Accounts are addressed by their canonical `billing_account_id` (UUID).
1180+
`getAccount` / `checkBalance` / `history` accept an account id, wallet, or email:
1181+
an account id reads `GET /billing/v1/accounts/:account_id` directly, while a
1182+
wallet/email is resolved through the `GET /billing/v1/accounts?wallet=|?email=`
1183+
lookup (also exposed as `lookupAccount`). The detail shape dropped
1184+
`identifier_type` and added `billing_account_id`; `BillingBalance` is a
1185+
deprecated alias of `BillingAccountDetail`. Account reads require SIWX from a
1186+
wallet **linked to** the account (or matching the looked-up `?wallet`), or an
1187+
admin key — email lookups are admin-only; `history` resolves to the account id
1188+
first, then reads `GET /billing/v1/accounts/:account_id/history`.
1189+
11781190
`linkWallet` merges a wallet into an existing billing account's pool. On v1.46+
11791191
gateways the response includes a `pool_implications` block (`tier`,
11801192
`projects_in_pool_count`, `account_api_calls_current`, `account_storage_bytes_current`,

0 commit comments

Comments
 (0)