walletcap 0.2.2__tar.gz → 0.2.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. walletcap-0.2.3/.github/workflows/publish.yml +69 -0
  2. walletcap-0.2.3/CHANGELOG.md +58 -0
  3. {walletcap-0.2.2 → walletcap-0.2.3}/PKG-INFO +27 -17
  4. {walletcap-0.2.2 → walletcap-0.2.3}/README.md +26 -16
  5. {walletcap-0.2.2 → walletcap-0.2.3}/js/agentic.mjs +89 -16
  6. {walletcap-0.2.2 → walletcap-0.2.3}/js/cli.mjs +2 -1
  7. {walletcap-0.2.2 → walletcap-0.2.3}/js/index.d.ts +6 -1
  8. {walletcap-0.2.2 → walletcap-0.2.3}/js/index.mjs +4 -1
  9. {walletcap-0.2.2 → walletcap-0.2.3}/js/package.json +2 -1
  10. {walletcap-0.2.2 → walletcap-0.2.3}/js/tests/agentic.test.mjs +67 -3
  11. walletcap-0.2.3/js/tests/agentkit.test.mjs +80 -0
  12. {walletcap-0.2.2 → walletcap-0.2.3}/js/tests/support/fake-payments-mcp.mjs +4 -1
  13. walletcap-0.2.3/js/tests/types/agentkit.d.ts +10 -0
  14. walletcap-0.2.3/js/tests/types/recipe.ts +8 -0
  15. walletcap-0.2.3/js/tests/types/subclass.ts +13 -0
  16. {walletcap-0.2.2 → walletcap-0.2.3}/js/x402.mjs +22 -0
  17. {walletcap-0.2.2 → walletcap-0.2.3}/pyproject.toml +1 -1
  18. {walletcap-0.2.2 → walletcap-0.2.3}/solidity/README.md +15 -9
  19. {walletcap-0.2.2 → walletcap-0.2.3}/solidity/evm/check.mjs +35 -14
  20. {walletcap-0.2.2 → walletcap-0.2.3}/solidity/evm/package.json +1 -1
  21. {walletcap-0.2.2 → walletcap-0.2.3}/solidity/src/AccountCapSpender.sol +42 -37
  22. {walletcap-0.2.2 → walletcap-0.2.3}/solidity/test/AccountCapSpender.t.sol +45 -3
  23. {walletcap-0.2.2 → walletcap-0.2.3}/src/walletcap/__init__.py +1 -1
  24. walletcap-0.2.2/.github/workflows/publish.yml +0 -35
  25. walletcap-0.2.2/CHANGELOG.md +0 -28
  26. {walletcap-0.2.2 → walletcap-0.2.3}/.circleci/config.yml +0 -0
  27. {walletcap-0.2.2 → walletcap-0.2.3}/.github/workflows/ci.yml +0 -0
  28. {walletcap-0.2.2 → walletcap-0.2.3}/.gitignore +0 -0
  29. {walletcap-0.2.2 → walletcap-0.2.3}/LICENSE +0 -0
  30. {walletcap-0.2.2 → walletcap-0.2.3}/NOTICE +0 -0
  31. {walletcap-0.2.2 → walletcap-0.2.3}/SECURITY.md +0 -0
  32. {walletcap-0.2.2 → walletcap-0.2.3}/docs/threat-model.md +0 -0
  33. {walletcap-0.2.2 → walletcap-0.2.3}/docs/vendor-disclosure.md +0 -0
  34. {walletcap-0.2.2 → walletcap-0.2.3}/examples/quickstart/README.md +0 -0
  35. {walletcap-0.2.2 → walletcap-0.2.3}/examples/quickstart/quickstart.mjs +0 -0
  36. {walletcap-0.2.2 → walletcap-0.2.3}/examples/quickstart/quickstart.py +0 -0
  37. {walletcap-0.2.2 → walletcap-0.2.3}/js/LICENSE +0 -0
  38. {walletcap-0.2.2 → walletcap-0.2.3}/js/NOTICE +0 -0
  39. {walletcap-0.2.2 → walletcap-0.2.3}/js/README.md +0 -0
  40. {walletcap-0.2.2 → walletcap-0.2.3}/js/_keccak.mjs +0 -0
  41. {walletcap-0.2.2 → walletcap-0.2.3}/js/cdp.mjs +0 -0
  42. {walletcap-0.2.2 → walletcap-0.2.3}/js/errors.mjs +0 -0
  43. {walletcap-0.2.2 → walletcap-0.2.3}/js/ledger.mjs +0 -0
  44. {walletcap-0.2.2 → walletcap-0.2.3}/js/money.mjs +0 -0
  45. {walletcap-0.2.2 → walletcap-0.2.3}/js/tests/budget.test.mjs +0 -0
  46. {walletcap-0.2.2 → walletcap-0.2.3}/js/tests/cdp-sdk.test.mjs +0 -0
  47. {walletcap-0.2.2 → walletcap-0.2.3}/js/tests/cdp.test.mjs +0 -0
  48. {walletcap-0.2.2 → walletcap-0.2.3}/js/tests/cli.test.mjs +0 -0
  49. {walletcap-0.2.2 → walletcap-0.2.3}/js/tests/keccak.test.mjs +0 -0
  50. {walletcap-0.2.2 → walletcap-0.2.3}/js/tests/money.test.mjs +0 -0
  51. {walletcap-0.2.2 → walletcap-0.2.3}/js/tests/processes.test.mjs +0 -0
  52. {walletcap-0.2.2 → walletcap-0.2.3}/js/tests/support/fake-awal/dist/index.js +0 -0
  53. {walletcap-0.2.2 → walletcap-0.2.3}/js/tests/support/fake-awal/dist/ipcClient.js +0 -0
  54. {walletcap-0.2.2 → walletcap-0.2.3}/js/tests/support/fake-awal/package.json +0 -0
  55. {walletcap-0.2.2 → walletcap-0.2.3}/js/tests/support/payer.mjs +0 -0
  56. {walletcap-0.2.2 → walletcap-0.2.3}/js/tests/support/race.mjs +0 -0
  57. {walletcap-0.2.2 → walletcap-0.2.3}/js/tests/support/x402-kit.mjs +0 -0
  58. {walletcap-0.2.2 → walletcap-0.2.3}/js/tests/support/x402-payer.mjs +0 -0
  59. {walletcap-0.2.2 → walletcap-0.2.3}/js/tests/x402-fetch.test.mjs +0 -0
  60. {walletcap-0.2.2 → walletcap-0.2.3}/js/tests/x402.test.mjs +0 -0
  61. {walletcap-0.2.2 → walletcap-0.2.3}/pytest.ini +0 -0
  62. {walletcap-0.2.2 → walletcap-0.2.3}/scripts/ci.sh +0 -0
  63. {walletcap-0.2.2 → walletcap-0.2.3}/solidity/foundry.toml +0 -0
  64. {walletcap-0.2.2 → walletcap-0.2.3}/solidity/test/Mocks.sol +0 -0
  65. {walletcap-0.2.2 → walletcap-0.2.3}/src/walletcap/__main__.py +0 -0
  66. {walletcap-0.2.2 → walletcap-0.2.3}/src/walletcap/_keccak.py +0 -0
  67. {walletcap-0.2.2 → walletcap-0.2.3}/src/walletcap/budget.py +0 -0
  68. {walletcap-0.2.2 → walletcap-0.2.3}/src/walletcap/errors.py +0 -0
  69. {walletcap-0.2.2 → walletcap-0.2.3}/src/walletcap/ledger.py +0 -0
  70. {walletcap-0.2.2 → walletcap-0.2.3}/src/walletcap/money.py +0 -0
  71. {walletcap-0.2.2 → walletcap-0.2.3}/src/walletcap/py.typed +0 -0
  72. {walletcap-0.2.2 → walletcap-0.2.3}/tests/test_budget.py +0 -0
  73. {walletcap-0.2.2 → walletcap-0.2.3}/tests/test_keccak.py +0 -0
  74. {walletcap-0.2.2 → walletcap-0.2.3}/tests/test_ledger.py +0 -0
  75. {walletcap-0.2.2 → walletcap-0.2.3}/tests/test_money.py +0 -0
@@ -0,0 +1,69 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Publish walletcap to PyPI and npm when a GitHub Release is published, or by hand.
3
+ # Both use trusted publishing, so no token is stored. PyPI matches this repository,
4
+ # the workflow filename (publish.yml) and the "pypi" environment, and attaches PEP 740
5
+ # attestations. npm matches this repository, publish.yml and the "npm" environment,
6
+ # and attaches provenance.
7
+ name: Publish
8
+
9
+ on:
10
+ release:
11
+ types: [published]
12
+ workflow_dispatch:
13
+
14
+ permissions:
15
+ contents: read
16
+ id-token: write
17
+
18
+ jobs:
19
+ pypi:
20
+ name: PyPI
21
+ runs-on: ubuntu-latest
22
+ environment: pypi
23
+ steps:
24
+ - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
25
+ - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
26
+ with:
27
+ python-version: "3.12"
28
+ - name: Check the release tag matches the package version
29
+ if: github.event_name == 'release'
30
+ env:
31
+ TAG: ${{ github.event.release.tag_name }}
32
+ run: test "$TAG" = "v$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])")"
33
+ - name: Run the Python suite
34
+ run: |
35
+ python -m pip install --upgrade pytest
36
+ python -m pytest -q
37
+ - name: Build the distributions
38
+ run: |
39
+ python -m pip install --upgrade build
40
+ python -m build
41
+ - name: Publish to PyPI
42
+ uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # v1.14.3
43
+
44
+ npm:
45
+ name: npm
46
+ runs-on: ubuntu-latest
47
+ environment: npm
48
+ steps:
49
+ - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
50
+ - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
51
+ with:
52
+ node-version: "22"
53
+ registry-url: "https://registry.npmjs.org"
54
+ package-manager-cache: false
55
+ - name: Use an npm that supports trusted publishing (11.5.1 or later)
56
+ run: npm install --global npm@11.21.1
57
+ - name: Check the release tag matches the package version
58
+ if: github.event_name == 'release'
59
+ env:
60
+ TAG: ${{ github.event.release.tag_name }}
61
+ run: test "$TAG" = "v$(node -p "require('./js/package.json').version")"
62
+ - name: Run the JavaScript suite
63
+ working-directory: js
64
+ run: |
65
+ npm install --no-audit --no-fund --no-package-lock
66
+ node --test
67
+ - name: Publish to npm with provenance
68
+ working-directory: js
69
+ run: npm publish --provenance
@@ -0,0 +1,58 @@
1
+ <!-- SPDX-License-Identifier: Apache-2.0 -->
2
+ # Changelog
3
+
4
+ ## 0.2.3 — 2026-11-06
5
+
6
+ ### Changed
7
+
8
+ - Releases are published from GitHub Actions with trusted publishing: npm with provenance,
9
+ PyPI with PEP 740 attestations. No token is stored. A GitHub Release whose tag doesn't
10
+ match the package version is refused.
11
+
12
+ ### Fixed
13
+
14
+ - `AccountCapSpender` no longer locks an account after a burst of payments. An unbounded log
15
+ made the first payment after 3,000 tiny ones cost about 17.7M gas, past the 2^24
16
+ per-transaction cap. The window is now counted in 24 buckets in a fixed ring of 25 slots, so
17
+ every `pay` costs the same gas. A payment counts for up to one bucket (window/24) past its
18
+ window, never less. The window must be a multiple of 24 seconds.
19
+ - `walletcap awal-mcp` ran `npx -y @coinbase/payments-mcp`, which is Coinbase's installer, not
20
+ the MCP server. It now runs the installed server, `~/.payments-mcp/bundle.js`, and says how to
21
+ install it when it is missing.
22
+ - `walletcap awal-mcp` forwarded every tool it didn't count. It now passes through only the
23
+ tools that move no money, and counts `send` and `make_http_request_with_x402`. It refuses
24
+ everything else, `trade` included, and leaves those tools out of `tools/list`, unless the
25
+ owner names one with `--allow-tool`. JSON-RPC batches and lines that aren't JSON are refused.
26
+
27
+ ### Added
28
+
29
+ - `budget.guardAgentKit(provider)` puts the budget on every x402 client an AgentKit
30
+ `X402ActionProvider` builds. The README's subclass recipe didn't compile in TypeScript,
31
+ where `createX402Client` is private. `tsc` now checks the recipe against AgentKit's declared
32
+ types in the test suite.
33
+
34
+ ## 0.2.2 — 2026-11-05
35
+
36
+ ### Changed
37
+
38
+ - A mixed-case EVM address is checked against EIP-55 before it is accepted. A bad
39
+ checksum is refused; all-lowercase and all-uppercase hex are still accepted.
40
+ - A Solana CAIP-10 address is reduced to its address, so it and the bare address
41
+ share one budget.
42
+ - Numeric `window` values are milliseconds in Python too, matching JavaScript.
43
+
44
+ ### Fixed
45
+
46
+ - The rolling window cannot be shortened once the budget has spend. An edit can
47
+ change the cap and the per-payment limit, but it cannot reset the count by
48
+ shrinking the window.
49
+
50
+ ### Added
51
+
52
+ - `SECURITY.md`, `docs/threat-model.md`, `docs/vendor-disclosure.md`, and the
53
+ Solidity audit status in `solidity/README.md`.
54
+ - `examples/quickstart/` in JavaScript and Python, run in CI. No wallet, chain,
55
+ keys or network.
56
+ - EIP-55, Solana CAIP-10, and window-freeze tests in both languages.
57
+
58
+ ## 0.2.1 — 2026-11-05
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: walletcap
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Summary: One spend budget per wallet for AI agents: counted across every process, replica, key, permission and payment rail.
5
5
  Project-URL: Homepage, https://github.com/coriolislabs/WalletCap
6
6
  Project-URL: Repository, https://github.com/coriolislabs/WalletCap
@@ -209,16 +209,23 @@ $ npx walletcap awal x402 pay https://api.example.com/data # instead of: npx a
209
209
  $ npx walletcap awal send 0.05 0xRecipient --chain base # instead of: npx awal send …
210
210
  ```
211
211
 
212
- For an MCP client such as Claude Desktop, start Coinbase's server behind walletcap. It runs
213
- `npx -y @coinbase/payments-mcp`; put another command after `--`.
212
+ For an MCP client such as Claude Desktop, start Coinbase's server behind walletcap. Install the
213
+ server once: `npx @coinbase/payments-mcp install --client other --no-auto-config` puts it in
214
+ `~/.payments-mcp`, and walletcap runs `~/.payments-mcp/bundle.js` from there. (`npx
215
+ @coinbase/payments-mcp` is the installer, not the server.) To run another server, put its
216
+ command after `--`.
214
217
 
215
218
  ```json
216
219
  { "mcpServers": { "payments": { "command": "npx", "args": ["walletcap", "awal-mcp", "--wallet", "0xYourAgentWallet"] } } }
217
220
  ```
218
221
 
219
222
  - `send` and `x402 pay` (over MCP, `send` and `make_http_request_with_x402`) are checked against
220
- the wallet's budget before the wallet signs anything, and counted. Every other command and tool
221
- is the wallet's own.
223
+ the wallet's budget before the wallet signs anything, and counted. Every other CLI command is
224
+ the wallet's own.
225
+ - Over MCP, the tools that move no money (balance, address, sign-in, bazaar search, payment
226
+ discovery) pass through. Any other tool, `trade` or one the server adds later, is refused and
227
+ left out of the tool list: walletcap can't count it. `--allow-tool <name>` lets one through,
228
+ uncounted. JSON-RPC batches are refused.
222
229
  - An x402 payment is priced from the endpoint's own 402 answer, and the wallet is capped at exactly
223
230
  that amount, so it refuses to sign for more.
224
231
  - A payment the payee confirms as settled counts for one window. One it doesn't confirm keeps
@@ -234,21 +241,20 @@ For an MCP client such as Claude Desktop, start Coinbase's server behind walletc
234
241
 
235
242
  ### Coinbase AgentKit
236
243
 
237
- AgentKit's x402 actions build their own x402 client on every call. Put the budget on that client
238
- with a three-line subclass, and every payment is checked against the wallet's budget before it
239
- is signed, whatever terms the merchant sends:
244
+ AgentKit's x402 actions build their own x402 client on every call. `budget.guardAgentKit()` puts
245
+ the budget on every client the provider builds, so each payment is checked against the wallet's
246
+ budget before it is signed, whatever terms the merchant sends:
240
247
 
241
248
  ```js
242
249
  import { X402ActionProvider } from '@coinbase/agentkit';
243
250
 
244
- class BudgetedX402ActionProvider extends X402ActionProvider {
245
- async createX402Client(walletProvider) {
246
- return budget.guard(await super.createX402Client(walletProvider));
247
- }
248
- }
249
- // AgentKit.from({ walletProvider, actionProviders: [new BudgetedX402ActionProvider()] })
251
+ const x402 = budget.guardAgentKit(new X402ActionProvider());
252
+ // AgentKit.from({ walletProvider, actionProviders: [x402] })
250
253
  ```
251
254
 
255
+ It compiles in TypeScript, where a subclass can't override AgentKit's private
256
+ `createX402Client`. Tested against the shape of AgentKit 0.10.4.
257
+
252
258
  ### Coinbase Spend Permissions, on chain
253
259
 
254
260
  For a limit the agent's own code can't go around, `solidity/src/AccountCapSpender.sol` is the
@@ -256,7 +262,9 @@ spender for Spend Permissions. Deploy it with the SpendPermissionManager, the ag
256
262
  address, the cap, the window, the per-payment max and the payees allowed. Grant Spend
257
263
  Permissions to the contract instead of the agent; the agent calls
258
264
  `pay(permission, amount, payee)`. Every permission the account grants it then shares one
259
- rolling cap.
265
+ rolling cap. The window is counted in 24 buckets, so every payment costs the same gas however
266
+ many came before it; a payment counts for up to one bucket (window/24) past its window, never
267
+ less.
260
268
 
261
269
  ## The owner's controls
262
270
 
@@ -316,7 +324,7 @@ only for a demo or a first-run setup.
316
324
  | `tests/` | the Python suite, including the tests that drive both languages against one budget file |
317
325
 
318
326
  ```console
319
- $ cd js && npm install && node --test # 61 tests
327
+ $ cd js && npm install && node --test # 65 tests
320
328
  $ uv run --with pytest pytest -q # 59 tests, from the repository root
321
329
  $ cd solidity/evm && npm install && node check.mjs .. # the contract on an EVM, no Foundry needed
322
330
  $ cd solidity && forge test # the contract's canonical tests, if you have Foundry
@@ -324,7 +332,9 @@ $ cd solidity && forge test # the contract's canonical test
324
332
 
325
333
  The JavaScript suite runs the x402 guard against x402's own client and fetch wrapper, and runs
326
334
  restarts and racing replicas as separate processes, and drives the Agentic Wallet wrapper and
327
- MCP proxy against stand-ins for Coinbase's wallet and MCP server. The Python suite covers the ledger, the API,
335
+ MCP proxy against stand-ins for Coinbase's wallet and MCP server. It builds AgentKit's x402
336
+ client the way AgentKit 0.10.4 does, and type-checks the AgentKit recipe with `tsc` against
337
+ AgentKit's declared types. The Python suite covers the ledger, the API,
328
338
  the CLI and one budget shared between both languages. `solidity/evm/check.mjs` compiles the
329
339
  contract with solc-js and drives it on an in-process EVM, covering what the Foundry tests cover.
330
340
 
@@ -186,16 +186,23 @@ $ npx walletcap awal x402 pay https://api.example.com/data # instead of: npx a
186
186
  $ npx walletcap awal send 0.05 0xRecipient --chain base # instead of: npx awal send …
187
187
  ```
188
188
 
189
- For an MCP client such as Claude Desktop, start Coinbase's server behind walletcap. It runs
190
- `npx -y @coinbase/payments-mcp`; put another command after `--`.
189
+ For an MCP client such as Claude Desktop, start Coinbase's server behind walletcap. Install the
190
+ server once: `npx @coinbase/payments-mcp install --client other --no-auto-config` puts it in
191
+ `~/.payments-mcp`, and walletcap runs `~/.payments-mcp/bundle.js` from there. (`npx
192
+ @coinbase/payments-mcp` is the installer, not the server.) To run another server, put its
193
+ command after `--`.
191
194
 
192
195
  ```json
193
196
  { "mcpServers": { "payments": { "command": "npx", "args": ["walletcap", "awal-mcp", "--wallet", "0xYourAgentWallet"] } } }
194
197
  ```
195
198
 
196
199
  - `send` and `x402 pay` (over MCP, `send` and `make_http_request_with_x402`) are checked against
197
- the wallet's budget before the wallet signs anything, and counted. Every other command and tool
198
- is the wallet's own.
200
+ the wallet's budget before the wallet signs anything, and counted. Every other CLI command is
201
+ the wallet's own.
202
+ - Over MCP, the tools that move no money (balance, address, sign-in, bazaar search, payment
203
+ discovery) pass through. Any other tool, `trade` or one the server adds later, is refused and
204
+ left out of the tool list: walletcap can't count it. `--allow-tool <name>` lets one through,
205
+ uncounted. JSON-RPC batches are refused.
199
206
  - An x402 payment is priced from the endpoint's own 402 answer, and the wallet is capped at exactly
200
207
  that amount, so it refuses to sign for more.
201
208
  - A payment the payee confirms as settled counts for one window. One it doesn't confirm keeps
@@ -211,21 +218,20 @@ For an MCP client such as Claude Desktop, start Coinbase's server behind walletc
211
218
 
212
219
  ### Coinbase AgentKit
213
220
 
214
- AgentKit's x402 actions build their own x402 client on every call. Put the budget on that client
215
- with a three-line subclass, and every payment is checked against the wallet's budget before it
216
- is signed, whatever terms the merchant sends:
221
+ AgentKit's x402 actions build their own x402 client on every call. `budget.guardAgentKit()` puts
222
+ the budget on every client the provider builds, so each payment is checked against the wallet's
223
+ budget before it is signed, whatever terms the merchant sends:
217
224
 
218
225
  ```js
219
226
  import { X402ActionProvider } from '@coinbase/agentkit';
220
227
 
221
- class BudgetedX402ActionProvider extends X402ActionProvider {
222
- async createX402Client(walletProvider) {
223
- return budget.guard(await super.createX402Client(walletProvider));
224
- }
225
- }
226
- // AgentKit.from({ walletProvider, actionProviders: [new BudgetedX402ActionProvider()] })
228
+ const x402 = budget.guardAgentKit(new X402ActionProvider());
229
+ // AgentKit.from({ walletProvider, actionProviders: [x402] })
227
230
  ```
228
231
 
232
+ It compiles in TypeScript, where a subclass can't override AgentKit's private
233
+ `createX402Client`. Tested against the shape of AgentKit 0.10.4.
234
+
229
235
  ### Coinbase Spend Permissions, on chain
230
236
 
231
237
  For a limit the agent's own code can't go around, `solidity/src/AccountCapSpender.sol` is the
@@ -233,7 +239,9 @@ spender for Spend Permissions. Deploy it with the SpendPermissionManager, the ag
233
239
  address, the cap, the window, the per-payment max and the payees allowed. Grant Spend
234
240
  Permissions to the contract instead of the agent; the agent calls
235
241
  `pay(permission, amount, payee)`. Every permission the account grants it then shares one
236
- rolling cap.
242
+ rolling cap. The window is counted in 24 buckets, so every payment costs the same gas however
243
+ many came before it; a payment counts for up to one bucket (window/24) past its window, never
244
+ less.
237
245
 
238
246
  ## The owner's controls
239
247
 
@@ -293,7 +301,7 @@ only for a demo or a first-run setup.
293
301
  | `tests/` | the Python suite, including the tests that drive both languages against one budget file |
294
302
 
295
303
  ```console
296
- $ cd js && npm install && node --test # 61 tests
304
+ $ cd js && npm install && node --test # 65 tests
297
305
  $ uv run --with pytest pytest -q # 59 tests, from the repository root
298
306
  $ cd solidity/evm && npm install && node check.mjs .. # the contract on an EVM, no Foundry needed
299
307
  $ cd solidity && forge test # the contract's canonical tests, if you have Foundry
@@ -301,7 +309,9 @@ $ cd solidity && forge test # the contract's canonical test
301
309
 
302
310
  The JavaScript suite runs the x402 guard against x402's own client and fetch wrapper, and runs
303
311
  restarts and racing replicas as separate processes, and drives the Agentic Wallet wrapper and
304
- MCP proxy against stand-ins for Coinbase's wallet and MCP server. The Python suite covers the ledger, the API,
312
+ MCP proxy against stand-ins for Coinbase's wallet and MCP server. It builds AgentKit's x402
313
+ client the way AgentKit 0.10.4 does, and type-checks the AgentKit recipe with `tsc` against
314
+ AgentKit's declared types. The Python suite covers the ledger, the API,
305
315
  the CLI and one budget shared between both languages. `solidity/evm/check.mjs` compiles the
306
316
  contract with solc-js and drives it on an in-process EVM, covering what the Foundry tests cover.
307
317
 
@@ -6,8 +6,10 @@
6
6
  // signs it, and the wallet is capped at that amount. A confirmed settlement counts for one
7
7
  // window; a payment the payee never confirms keeps counting until its owner releases it.
8
8
  import { spawn, spawnSync } from 'node:child_process';
9
+ import { randomUUID } from 'node:crypto';
9
10
  import { existsSync } from 'node:fs';
10
11
  import { createRequire } from 'node:module';
12
+ import { homedir } from 'node:os';
11
13
  import { dirname, join, resolve } from 'node:path';
12
14
  import { createInterface } from 'node:readline';
13
15
  import { pathToFileURL } from 'node:url';
@@ -338,11 +340,28 @@ export async function awalMain(argv, { env = process.env, stdout = process.stdou
338
340
  }
339
341
 
340
342
  // ---------------------------------------------------------------------------------------------
341
- // npx walletcap awal-mcp [--wallet <address>] [--path <file>] [-- <server command>]: Coinbase's
342
- // payments-mcp server behind a proxy that counts its send and make_http_request_with_x402 calls.
343
+ // npx walletcap awal-mcp [--wallet <address>] [--path <file>] [--allow-tool <name>]... [-- <server command>]:
344
+ // Coinbase's Agentic Wallet MCP server behind a proxy that counts its send and
345
+ // make_http_request_with_x402 calls, and refuses every tool it does not know.
343
346
 
344
347
  const MCP_PAYMENT_TOOLS = { make_http_request_with_x402: 'make-x402-request', send: 'send-transaction' };
345
- const DEFAULT_SERVER = ['npx', '-y', '@coinbase/payments-mcp'];
348
+
349
+ /**
350
+ * The server's tools that move no money (payments-mcp 2.12.2), passed through as they are. Any
351
+ * other tool, `trade` included, is refused unless the wallet's owner names it with --allow-tool:
352
+ * a tool walletcap does not know may move money it cannot count.
353
+ */
354
+ export const MCP_PASS_THROUGH_TOOLS = Object.freeze([
355
+ 'bazaar_get_resource_details', 'bazaar_list', 'bazaar_search', 'check_session_status',
356
+ 'get_wallet_address', 'get_wallet_balance', 'show_wallet_app', 'sign_in_with_email',
357
+ 'verify_email_otp', 'x402_discover_payment_requirements',
358
+ ]);
359
+
360
+ /**
361
+ * Where Coinbase's server runs from. `npx @coinbase/payments-mcp` is its installer, not the
362
+ * server: it downloads the server to ~/.payments-mcp, and MCP clients run bundle.js with node.
363
+ */
364
+ export const paymentsMcpServer = (home = homedir()) => [process.execPath, join(home, '.payments-mcp', 'bundle.js')];
346
365
 
347
366
  const toolResult = raw => {
348
367
  const text = raw?.content?.find(item => item?.type === 'text')?.text;
@@ -356,18 +375,38 @@ const toolResult = raw => {
356
375
  }
357
376
  };
358
377
 
359
- /** Proxy an MCP client (stdin/stdout) to the payments-mcp server. Resolves with the server's exit code. */
360
- export async function awalMcpMain(argv, { env = process.env, input = process.stdin, output = process.stdout, stderr = process.stderr } = {}) {
378
+ /**
379
+ * Proxy an MCP client (stdin/stdout) to Coinbase's Agentic Wallet MCP server. Payments are
380
+ * counted, the tools in MCP_PASS_THROUGH_TOOLS and --allow-tool pass through, and every other
381
+ * tool is refused and left out of tools/list. Resolves with the server's exit code.
382
+ */
383
+ export async function awalMcpMain(argv, { env = process.env, input = process.stdin, output = process.stdout, stderr = process.stderr, home } = {}) {
361
384
  const split = argv.indexOf('--');
362
385
  const own = split < 0 ? argv : argv.slice(0, split);
363
- const server = split < 0 ? DEFAULT_SERVER : argv.slice(split + 1);
364
- const { values } = parseArgs({ args: own, options: { wallet: { type: 'string' }, path: { type: 'string' } }, strict: true });
386
+ const { values } = parseArgs({
387
+ args: own,
388
+ options: { wallet: { type: 'string' }, path: { type: 'string' }, 'allow-tool': { type: 'string', multiple: true } },
389
+ strict: true,
390
+ });
391
+ const server = split < 0 ? paymentsMcpServer(home) : argv.slice(split + 1);
392
+ if (server.length === 0) {
393
+ stderr.write('walletcap: give the MCP server command after --\n');
394
+ return 2;
395
+ }
396
+ if (split < 0 && !existsSync(server[1])) {
397
+ stderr.write(`walletcap: Coinbase's Agentic Wallet MCP server is not installed at ${server[1]}. `
398
+ + 'Install it once with: npx @coinbase/payments-mcp install --client other --no-auto-config '
399
+ + '(or give walletcap the server command after --)\n');
400
+ return 1;
401
+ }
402
+ const passed = new Set([...MCP_PASS_THROUGH_TOOLS, ...(values['allow-tool'] ?? [])]);
403
+ const offered = name => Object.hasOwn(MCP_PAYMENT_TOOLS, name) || passed.has(name);
365
404
  const child = spawn(server[0], server.slice(1), { stdio: ['pipe', 'pipe', 'inherit'], env });
366
405
  const write = (stream, message) => stream.write(`${JSON.stringify(message)}\n`);
367
406
  const pending = new Map();
368
- let next = 0;
407
+ const listing = new Set(); // the client's tools/list requests, whose answers are filtered
369
408
  const callTool = (name, args) => new Promise((resolveCall, rejectCall) => {
370
- const id = `walletcap-${next += 1}`;
409
+ const id = `walletcap-${randomUUID()}`;
371
410
  pending.set(id, { resolve: resolveCall, reject: rejectCall });
372
411
  write(child.stdin, { jsonrpc: '2.0', id, method: 'tools/call', params: { name, arguments: args } });
373
412
  });
@@ -376,10 +415,18 @@ export async function awalMcpMain(argv, { env = process.env, input = process.std
376
415
  let message;
377
416
  try { message = JSON.parse(line); } catch { output.write(`${line}\n`); return; }
378
417
  const waiting = pending.get(message?.id);
379
- if (waiting === undefined) { output.write(`${line}\n`); return; }
380
- pending.delete(message.id);
381
- if (message.error) waiting.reject(new Error(message.error.message ?? JSON.stringify(message.error)));
382
- else waiting.resolve(message.result);
418
+ if (waiting !== undefined) {
419
+ pending.delete(message.id);
420
+ if (message.error) waiting.reject(new Error(message.error.message ?? JSON.stringify(message.error)));
421
+ else waiting.resolve(message.result);
422
+ return;
423
+ }
424
+ const key = message?.id === undefined ? undefined : JSON.stringify(message.id);
425
+ if (key !== undefined && listing.delete(key) && Array.isArray(message.result?.tools)) {
426
+ write(output, { ...message, result: { ...message.result, tools: message.result.tools.filter(tool => offered(tool?.name)) } });
427
+ return;
428
+ }
429
+ output.write(`${line}\n`);
383
430
  });
384
431
 
385
432
  // The guard talks to the wallet through the server's own tools, one guard per call so concurrent
@@ -419,11 +466,37 @@ export async function awalMcpMain(argv, { env = process.env, input = process.std
419
466
 
420
467
  createInterface({ input }).on('line', line => {
421
468
  let message;
422
- try { message = JSON.parse(line); } catch { child.stdin.write(`${line}\n`); return; }
423
- if (message?.method === 'tools/call' && Object.hasOwn(MCP_PAYMENT_TOOLS, message.params?.name ?? '')) {
424
- handle(message).catch(error => stderr.write(`walletcap: ${error.message}\n`));
469
+ try {
470
+ message = JSON.parse(line);
471
+ } catch {
472
+ stderr.write('walletcap: dropped a line from the MCP client that is not JSON\n');
425
473
  return;
426
474
  }
475
+ if (Array.isArray(message)) {
476
+ // A batch would carry calls past the checks below; MCP dropped batches in 2025-06-18.
477
+ write(output, { jsonrpc: '2.0', id: null, error: { code: -32600, message: 'walletcap does not forward JSON-RPC batches' } });
478
+ return;
479
+ }
480
+ if (message?.method === 'tools/call') {
481
+ const name = String(message.params?.name);
482
+ if (Object.hasOwn(MCP_PAYMENT_TOOLS, name)) {
483
+ handle(message).catch(error => stderr.write(`walletcap: ${error.message}\n`));
484
+ return;
485
+ }
486
+ if (!passed.has(name)) {
487
+ write(output, {
488
+ jsonrpc: '2.0',
489
+ id: message.id,
490
+ result: {
491
+ content: [{ type: 'text', text: `walletcap refused ${name}: it can't tell whether this tool moves money, so it can't count it. `
492
+ + `The wallet's owner can let it through, uncounted, with --allow-tool ${name}` }],
493
+ isError: true,
494
+ },
495
+ });
496
+ return;
497
+ }
498
+ }
499
+ if (message?.method === 'tools/list' && message.id !== undefined && message.id !== null) listing.add(JSON.stringify(message.id));
427
500
  child.stdin.write(`${line}\n`);
428
501
  }).on('close', () => child.stdin.end());
429
502
 
@@ -24,7 +24,8 @@ Commands
24
24
  history The newest payments first
25
25
  release <id> Give back an in-flight payment you know never left the wallet
26
26
  awal <command> Coinbase's Agentic Wallet CLI, with send and x402 pay counted in the budget
27
- awal-mcp [-- <cmd>] Coinbase's payments-mcp server behind a proxy that counts its payments
27
+ awal-mcp [-- <cmd>] Coinbase's Agentic Wallet MCP server (~/.payments-mcp) behind a proxy that
28
+ counts its payments and refuses tools it can't count (--allow-tool <name>)
28
29
 
29
30
  Options
30
31
  --wallet <address> The wallet the money leaves from (optional when the file holds one budget)
@@ -162,12 +162,17 @@ export declare class Budget {
162
162
  history(options?: { limit?: number }): Payment[];
163
163
  /** Count every payment an x402 client (@x402/core 2.x) makes in this budget. Returns the client. */
164
164
  guard<C extends X402HookedClient>(client: C, options?: { assets?: AssetEntry[] }): C;
165
+ /**
166
+ * Count every payment a Coinbase AgentKit X402ActionProvider makes in this budget: each x402
167
+ * client the provider builds is guarded before it can sign. Returns the provider.
168
+ */
169
+ guardAgentKit<P extends object>(provider: P, options?: { assets?: AssetEntry[] }): P;
165
170
  /** A SpendStore for Coinbase's CDP applySpendControls that counts in this budget. */
166
171
  cdpStore(options: { asset: string; scale?: bigint | number; clock?: () => number }): SharedBudgetStore;
167
172
  close(): void;
168
173
  }
169
174
 
170
- /** Open the wallet's budget, creating it on first use. */
175
+ /** Open the wallet's budget. Its owner creates it with `walletcap set`; pass create: true only for a demo. */
171
176
  export declare function openBudget(options: BudgetOptions): Budget;
172
177
 
173
178
  /** Dollars as micro-dollars. */
@@ -17,7 +17,7 @@ import {
17
17
  defaultBudgetPath, describeLimits, expandHome, formatUsd, formatWindow, limitFlags, MAX_AMOUNT,
18
18
  normalizeWallet, parseLimits, shortWallet, toMicros,
19
19
  } from './money.mjs';
20
- import { guardX402Client, USD_ASSETS } from './x402.mjs';
20
+ import { guardX402ActionProvider, guardX402Client, USD_ASSETS } from './x402.mjs';
21
21
 
22
22
  export {
23
23
  BudgetError, BudgetRefused, defaultBudgetPath, formatUsd, MAX_AMOUNT, notSent, SCHEMA_VERSION,
@@ -304,6 +304,9 @@ export class Budget {
304
304
  /** Count every payment an x402 client (@x402/core 2.x) makes in this budget. Returns the client. */
305
305
  guard(client, options) { return guardX402Client(client, this, options); }
306
306
 
307
+ /** Count every payment a Coinbase AgentKit X402ActionProvider makes in this budget. Returns the provider. */
308
+ guardAgentKit(provider, options) { return guardX402ActionProvider(provider, this, options); }
309
+
307
310
  /** A SpendStore for Coinbase's CDP applySpendControls, counting in this budget. For a 6-decimal USD stablecoin. */
308
311
  cdpStore({ asset, scale = 1n, clock = Date.now } = {}) {
309
312
  return new SharedBudgetStore({ path: this.#path, instrument: this.#wallet, asset, scale, clock });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "walletcap",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "description": "One spend budget per wallet for AI agents: counted across every process, replica, key, permission and payment rail. Restarts don't reset it, limit changes don't zero it, and if it can't be read nothing is paid.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Coriolis Labs Inc.",
@@ -55,6 +55,7 @@
55
55
  "@x402/extensions": "2.28.0",
56
56
  "@x402/fetch": "2.28.0",
57
57
  "@x402/svm": "2.28.0",
58
+ "typescript": "5.6.3",
58
59
  "viem": "2.37.13"
59
60
  },
60
61
  "repository": {
@@ -3,12 +3,12 @@
3
3
  // server are stand-ins here; the payee is a local HTTP server that answers 402 like an x402 one.
4
4
  import assert from 'node:assert/strict';
5
5
  import { execFile, spawn, spawnSync } from 'node:child_process';
6
- import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
6
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
7
7
  import { createServer } from 'node:http';
8
8
  import { tmpdir } from 'node:os';
9
9
  import { join } from 'node:path';
10
10
  import { createInterface } from 'node:readline';
11
- import { fileURLToPath } from 'node:url';
11
+ import { fileURLToPath, pathToFileURL } from 'node:url';
12
12
  import { after, before, test } from 'node:test';
13
13
  import { BudgetRefused, openBudget } from '../index.mjs';
14
14
  import { guardAgenticWallet, usdcAtomic } from '../agentic.mjs';
@@ -202,7 +202,8 @@ test('npx walletcap awal-mcp: the MCP server answers, and payments past the cap
202
202
  };
203
203
  try {
204
204
  assert.equal((await ask(1, 'initialize', {})).serverInfo.name, 'payments-mcp');
205
- assert.equal((await ask(2, 'tools/list', {})).tools.length, 3);
205
+ // The server also offers trade and a tool walletcap has never seen: neither is listed.
206
+ assert.deepEqual((await ask(2, 'tools/list', {})).tools.map(t => t.name), ['make_http_request_with_x402', 'send', 'get_wallet_balance']);
206
207
  const pay = { baseURL: base, path: '/pay?ttl=300', method: 'GET' };
207
208
  const results = [];
208
209
  for (let id = 3; id < 7; id += 1) results.push(await ask(id, 'tools/call', { name: 'make_http_request_with_x402', arguments: pay }));
@@ -211,6 +212,12 @@ test('npx walletcap awal-mcp: the MCP server answers, and payments past the cap
211
212
  const sent = await ask(7, 'tools/call', { name: 'send', arguments: { recipient: PAY_TO, amountAtomic: '50000', chain: 'base-sepolia' } });
212
213
  assert.equal(sent.isError, true);
213
214
  assert.equal((await ask(8, 'tools/call', { name: 'get_wallet_balance', arguments: {} })).content.length, 1);
215
+ // A tool walletcap can't count is refused before it reaches the wallet.
216
+ for (const [id, name] of [[9, 'trade'], [10, 'drain_wallet']]) {
217
+ const refused = await ask(id, 'tools/call', { name, arguments: { amount: '1000' } });
218
+ assert.equal(refused.isError, true);
219
+ assert.match(refused.content[0].text, new RegExp(`walletcap refused ${name}.*--allow-tool ${name}`));
220
+ }
214
221
  } finally {
215
222
  proxy.stdin.end();
216
223
  await new Promise(done => proxy.on('exit', done));
@@ -222,3 +229,60 @@ test('npx walletcap awal-mcp: the MCP server answers, and payments past the cap
222
229
  assert.equal(budget.status().spent, 150_000n);
223
230
  budget.close();
224
231
  });
232
+
233
+ // Drive `npx walletcap awal-mcp` over stdio: send lines, collect answers by id.
234
+ const mcpProxy = (args, env) => {
235
+ const proxy = spawn(process.execPath, [CLI, 'awal-mcp', ...args], { env: { ...process.env, ...env }, stdio: ['pipe', 'pipe', 'pipe'] });
236
+ const replies = new Map();
237
+ let stderr = '';
238
+ proxy.stderr.on('data', chunk => { stderr += chunk; });
239
+ createInterface({ input: proxy.stdout }).on('line', line => { const m = JSON.parse(line); replies.set(m.id, m); });
240
+ const send = line => proxy.stdin.write(`${line}\n`);
241
+ const reply = async id => {
242
+ for (let i = 0; i < 200 && !replies.has(id); i += 1) await new Promise(done => setTimeout(done, 25));
243
+ assert.ok(replies.has(id), `no reply to ${id}; stderr: ${stderr}`);
244
+ return replies.get(id);
245
+ };
246
+ const ask = async (id, method, params) => {
247
+ send(JSON.stringify({ jsonrpc: '2.0', id, method, params }));
248
+ return (await reply(id)).result;
249
+ };
250
+ const close = async () => {
251
+ proxy.stdin.end();
252
+ return new Promise(done => proxy.on('exit', code => done({ code, stderr })));
253
+ };
254
+ return { ask, send, reply, close };
255
+ };
256
+
257
+ test('npx walletcap awal-mcp runs the server Coinbase installed, not its installer', async () => {
258
+ const home = mkdtempSync(join(DIR, 'home-'));
259
+ const path = join(DIR, 'mcp-default.sqlite');
260
+ const log = join(DIR, 'mcp-default.log');
261
+ writeFileSync(log, '');
262
+ openBudget({ wallet: WALLET, path, perPayment: '$0.05', perDay: '$0.15', create: true }).close();
263
+ const env = { HOME: home, USERPROFILE: home, FAKE_MCP_LOG: log };
264
+
265
+ // Before Coinbase's installer has run, there is no server to start: say so, start nothing.
266
+ const missing = await mcpProxy(['--wallet', WALLET, '--path', path], env).close();
267
+ assert.equal(missing.code, 1);
268
+ assert.match(missing.stderr, /npx @coinbase\/payments-mcp install/);
269
+
270
+ // The installer leaves ~/.payments-mcp/bundle.js, an ES module MCP clients run with node.
271
+ mkdirSync(join(home, '.payments-mcp'));
272
+ writeFileSync(join(home, '.payments-mcp', 'package.json'), '{"type":"module"}');
273
+ writeFileSync(join(home, '.payments-mcp', 'bundle.js'), `import ${JSON.stringify(pathToFileURL(FAKE_MCP).href)};\n`);
274
+ const mcp = mcpProxy(['--wallet', WALLET, '--path', path, '--allow-tool', 'trade'], env);
275
+ try {
276
+ assert.equal((await mcp.ask(1, 'initialize', {})).serverInfo.name, 'payments-mcp');
277
+ // --allow-tool lets trade through, uncounted; a tool nobody named stays out.
278
+ assert.deepEqual((await mcp.ask(2, 'tools/list', {})).tools.map(t => t.name), ['make_http_request_with_x402', 'send', 'get_wallet_balance', 'trade']);
279
+ assert.notEqual((await mcp.ask(3, 'tools/call', { name: 'trade', arguments: {} })).isError, true);
280
+ assert.equal((await mcp.ask(4, 'tools/call', { name: 'drain_wallet', arguments: {} })).isError, true);
281
+ // A JSON-RPC batch could carry a call past those checks, so it is refused whole.
282
+ mcp.send(JSON.stringify([{ jsonrpc: '2.0', id: 5, method: 'tools/call', params: { name: 'drain_wallet', arguments: {} } }]));
283
+ assert.equal((await mcp.reply(null)).error.code, -32600);
284
+ } finally {
285
+ await mcp.close();
286
+ }
287
+ assert.deepEqual(readFileSync(log, 'utf8').trim().split('\n').map(line => JSON.parse(line).name), ['trade']);
288
+ });