walletcap 0.2.1__tar.gz → 0.2.2__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.
- {walletcap-0.2.1 → walletcap-0.2.2}/.circleci/config.yml +5 -0
- {walletcap-0.2.1 → walletcap-0.2.2}/.gitignore +1 -0
- walletcap-0.2.2/CHANGELOG.md +28 -0
- {walletcap-0.2.1 → walletcap-0.2.2}/PKG-INFO +34 -8
- {walletcap-0.2.1 → walletcap-0.2.2}/README.md +33 -7
- walletcap-0.2.2/SECURITY.md +64 -0
- walletcap-0.2.2/docs/threat-model.md +54 -0
- walletcap-0.2.2/docs/vendor-disclosure.md +48 -0
- walletcap-0.2.2/examples/quickstart/README.md +24 -0
- walletcap-0.2.2/examples/quickstart/quickstart.mjs +59 -0
- walletcap-0.2.2/examples/quickstart/quickstart.py +70 -0
- {walletcap-0.2.1 → walletcap-0.2.2}/js/README.md +4 -0
- walletcap-0.2.2/js/_keccak.mjs +87 -0
- {walletcap-0.2.1 → walletcap-0.2.2}/js/ledger.mjs +6 -0
- {walletcap-0.2.1 → walletcap-0.2.2}/js/money.mjs +28 -3
- {walletcap-0.2.1 → walletcap-0.2.2}/js/package.json +2 -1
- {walletcap-0.2.1 → walletcap-0.2.2}/js/tests/budget.test.mjs +8 -1
- {walletcap-0.2.1 → walletcap-0.2.2}/js/tests/cli.test.mjs +1 -1
- walletcap-0.2.2/js/tests/keccak.test.mjs +19 -0
- {walletcap-0.2.1 → walletcap-0.2.2}/js/tests/money.test.mjs +10 -1
- {walletcap-0.2.1 → walletcap-0.2.2}/pyproject.toml +1 -1
- {walletcap-0.2.1 → walletcap-0.2.2}/scripts/ci.sh +4 -0
- walletcap-0.2.2/solidity/README.md +79 -0
- {walletcap-0.2.1 → walletcap-0.2.2}/solidity/evm/package.json +1 -1
- {walletcap-0.2.1 → walletcap-0.2.2}/solidity/src/AccountCapSpender.sol +1 -0
- {walletcap-0.2.1 → walletcap-0.2.2}/src/walletcap/__init__.py +1 -1
- walletcap-0.2.2/src/walletcap/_keccak.py +71 -0
- {walletcap-0.2.1 → walletcap-0.2.2}/src/walletcap/ledger.py +9 -0
- {walletcap-0.2.1 → walletcap-0.2.2}/src/walletcap/money.py +35 -4
- {walletcap-0.2.1 → walletcap-0.2.2}/tests/test_budget.py +12 -1
- walletcap-0.2.2/tests/test_keccak.py +10 -0
- walletcap-0.2.2/tests/test_money.py +36 -0
- walletcap-0.2.1/CHANGELOG.md +0 -24
- {walletcap-0.2.1 → walletcap-0.2.2}/.github/workflows/ci.yml +0 -0
- {walletcap-0.2.1 → walletcap-0.2.2}/.github/workflows/publish.yml +0 -0
- {walletcap-0.2.1 → walletcap-0.2.2}/LICENSE +0 -0
- {walletcap-0.2.1 → walletcap-0.2.2}/NOTICE +0 -0
- {walletcap-0.2.1 → walletcap-0.2.2}/js/LICENSE +0 -0
- {walletcap-0.2.1 → walletcap-0.2.2}/js/NOTICE +0 -0
- {walletcap-0.2.1 → walletcap-0.2.2}/js/agentic.mjs +0 -0
- {walletcap-0.2.1 → walletcap-0.2.2}/js/cdp.mjs +0 -0
- {walletcap-0.2.1 → walletcap-0.2.2}/js/cli.mjs +0 -0
- {walletcap-0.2.1 → walletcap-0.2.2}/js/errors.mjs +0 -0
- {walletcap-0.2.1 → walletcap-0.2.2}/js/index.d.ts +0 -0
- {walletcap-0.2.1 → walletcap-0.2.2}/js/index.mjs +0 -0
- {walletcap-0.2.1 → walletcap-0.2.2}/js/tests/agentic.test.mjs +0 -0
- {walletcap-0.2.1 → walletcap-0.2.2}/js/tests/cdp-sdk.test.mjs +0 -0
- {walletcap-0.2.1 → walletcap-0.2.2}/js/tests/cdp.test.mjs +0 -0
- {walletcap-0.2.1 → walletcap-0.2.2}/js/tests/processes.test.mjs +0 -0
- {walletcap-0.2.1 → walletcap-0.2.2}/js/tests/support/fake-awal/dist/index.js +0 -0
- {walletcap-0.2.1 → walletcap-0.2.2}/js/tests/support/fake-awal/dist/ipcClient.js +0 -0
- {walletcap-0.2.1 → walletcap-0.2.2}/js/tests/support/fake-awal/package.json +0 -0
- {walletcap-0.2.1 → walletcap-0.2.2}/js/tests/support/fake-payments-mcp.mjs +0 -0
- {walletcap-0.2.1 → walletcap-0.2.2}/js/tests/support/payer.mjs +0 -0
- {walletcap-0.2.1 → walletcap-0.2.2}/js/tests/support/race.mjs +0 -0
- {walletcap-0.2.1 → walletcap-0.2.2}/js/tests/support/x402-kit.mjs +0 -0
- {walletcap-0.2.1 → walletcap-0.2.2}/js/tests/support/x402-payer.mjs +0 -0
- {walletcap-0.2.1 → walletcap-0.2.2}/js/tests/x402-fetch.test.mjs +0 -0
- {walletcap-0.2.1 → walletcap-0.2.2}/js/tests/x402.test.mjs +0 -0
- {walletcap-0.2.1 → walletcap-0.2.2}/js/x402.mjs +0 -0
- {walletcap-0.2.1 → walletcap-0.2.2}/pytest.ini +0 -0
- {walletcap-0.2.1 → walletcap-0.2.2}/solidity/evm/check.mjs +0 -0
- {walletcap-0.2.1 → walletcap-0.2.2}/solidity/foundry.toml +0 -0
- {walletcap-0.2.1 → walletcap-0.2.2}/solidity/test/AccountCapSpender.t.sol +0 -0
- {walletcap-0.2.1 → walletcap-0.2.2}/solidity/test/Mocks.sol +0 -0
- {walletcap-0.2.1 → walletcap-0.2.2}/src/walletcap/__main__.py +0 -0
- {walletcap-0.2.1 → walletcap-0.2.2}/src/walletcap/budget.py +0 -0
- {walletcap-0.2.1 → walletcap-0.2.2}/src/walletcap/errors.py +0 -0
- {walletcap-0.2.1 → walletcap-0.2.2}/src/walletcap/py.typed +0 -0
- {walletcap-0.2.1 → walletcap-0.2.2}/tests/test_ledger.py +0 -0
|
@@ -22,6 +22,11 @@ jobs:
|
|
|
22
22
|
python3 -m venv /tmp/venv
|
|
23
23
|
/tmp/venv/bin/pip install --quiet pytest
|
|
24
24
|
/tmp/venv/bin/python -m pytest -q
|
|
25
|
+
- run:
|
|
26
|
+
name: Examples
|
|
27
|
+
command: |
|
|
28
|
+
node examples/quickstart/quickstart.mjs
|
|
29
|
+
python3 examples/quickstart/quickstart.py
|
|
25
30
|
- run:
|
|
26
31
|
name: Solidity
|
|
27
32
|
command: |
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
<!-- SPDX-License-Identifier: Apache-2.0 -->
|
|
2
|
+
# Changelog
|
|
3
|
+
|
|
4
|
+
## 0.2.2 — 2026-11-05
|
|
5
|
+
|
|
6
|
+
### Changed
|
|
7
|
+
|
|
8
|
+
- A mixed-case EVM address is checked against EIP-55 before it is accepted. A bad
|
|
9
|
+
checksum is refused; all-lowercase and all-uppercase hex are still accepted.
|
|
10
|
+
- A Solana CAIP-10 address is reduced to its address, so it and the bare address
|
|
11
|
+
share one budget.
|
|
12
|
+
- Numeric `window` values are milliseconds in Python too, matching JavaScript.
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- The rolling window cannot be shortened once the budget has spend. An edit can
|
|
17
|
+
change the cap and the per-payment limit, but it cannot reset the count by
|
|
18
|
+
shrinking the window.
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- `SECURITY.md`, `docs/threat-model.md`, `docs/vendor-disclosure.md`, and the
|
|
23
|
+
Solidity audit status in `solidity/README.md`.
|
|
24
|
+
- `examples/quickstart/` in JavaScript and Python, run in CI. No wallet, chain,
|
|
25
|
+
keys or network.
|
|
26
|
+
- EIP-55, Solana CAIP-10, and window-freeze tests in both languages.
|
|
27
|
+
|
|
28
|
+
## 0.2.1 — 2026-11-05
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: walletcap
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
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
|
|
@@ -60,6 +60,21 @@ with budget.pay("$12.50"):
|
|
|
60
60
|
`npm install walletcap` (Node 22.13+) · `pip install walletcap` (Python 3.11+) ·
|
|
61
61
|
no dependencies · Apache-2.0
|
|
62
62
|
|
|
63
|
+
## Try it without a wallet
|
|
64
|
+
|
|
65
|
+
Two runnable demos use a fake payee and a temporary file: no wallet, chain, keys or
|
|
66
|
+
network.
|
|
67
|
+
|
|
68
|
+
```console
|
|
69
|
+
$ node examples/quickstart/quickstart.mjs
|
|
70
|
+
$ python3 examples/quickstart/quickstart.py
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
They show three `$0.05` payments against a `$0.15` rolling cap, a `$1.00` refusal, a
|
|
74
|
+
fourth `$0.05` refusal, and a restart that cannot raise the limits. The demo uses
|
|
75
|
+
`create: true`; a production agent opens with the default `create: false` after the
|
|
76
|
+
owner runs `walletcap set`.
|
|
77
|
+
|
|
63
78
|
## Why a per-wallet count
|
|
64
79
|
|
|
65
80
|
Most agent spend controls keep "spent so far" next to something temporary or repeatable: a
|
|
@@ -280,8 +295,9 @@ only for a demo or a first-run setup.
|
|
|
280
295
|
go around it: call the wallet directly, or delete the file. To bound an agent like that, keep
|
|
281
296
|
the budget out of its reach (another OS user, another service) or enforce on chain, as
|
|
282
297
|
`AccountCapSpender` does for Spend Permissions.
|
|
283
|
-
- **One file is one host
|
|
284
|
-
|
|
298
|
+
- **One file is one host.** A shared volume can work for one host's containers, but SQLite
|
|
299
|
+
over NFS is not a multi-host database. A Postgres backend for replicas on separate hosts is
|
|
300
|
+
next.
|
|
285
301
|
- **It can't see a revoke that lands later in the same block,** and for x402 schemes that settle
|
|
286
302
|
later (batch vouchers), a payment counts from when the server confirms it.
|
|
287
303
|
- **Some x402 payments can't be confirmed.** `@x402/fetch` doesn't report v1 settlements
|
|
@@ -300,8 +316,8 @@ only for a demo or a first-run setup.
|
|
|
300
316
|
| `tests/` | the Python suite, including the tests that drive both languages against one budget file |
|
|
301
317
|
|
|
302
318
|
```console
|
|
303
|
-
$ cd js && npm install && node --test #
|
|
304
|
-
$ uv run --with pytest pytest -q #
|
|
319
|
+
$ cd js && npm install && node --test # 61 tests
|
|
320
|
+
$ uv run --with pytest pytest -q # 59 tests, from the repository root
|
|
305
321
|
$ cd solidity/evm && npm install && node check.mjs .. # the contract on an EVM, no Foundry needed
|
|
306
322
|
$ cd solidity && forge test # the contract's canonical tests, if you have Foundry
|
|
307
323
|
```
|
|
@@ -312,9 +328,19 @@ MCP proxy against stand-ins for Coinbase's wallet and MCP server. The Python sui
|
|
|
312
328
|
the CLI and one budget shared between both languages. `solidity/evm/check.mjs` compiles the
|
|
313
329
|
contract with solc-js and drives it on an in-process EVM, covering what the Foundry tests cover.
|
|
314
330
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
331
|
+
The JavaScript suite also drives the published `@coinbase/cdp-sdk@1.60.0` through the
|
|
332
|
+
shared store, so the CDP integration is covered against the package users install.
|
|
333
|
+
|
|
334
|
+
## Security and audit
|
|
335
|
+
|
|
336
|
+
- [Security policy](SECURITY.md): how to report a vulnerability, supported versions,
|
|
337
|
+
and the 90-day coordinated-disclosure window for walletcap itself.
|
|
338
|
+
- [Threat model](docs/threat-model.md): what the guard stops, what it does not, and the
|
|
339
|
+
deployment patterns that hold.
|
|
340
|
+
- [Vendor benchmark disclosure](docs/vendor-disclosure.md): the separate 7-day
|
|
341
|
+
pre-publication notice for vendor findings in the Agent Payment Benchmark.
|
|
342
|
+
- `AccountCapSpender` is **unaudited**. Read [solidity/README.md](solidity/README.md)
|
|
343
|
+
before deploying it; testnet only until an independent audit.
|
|
318
344
|
|
|
319
345
|
## Prove it holds in your deployment
|
|
320
346
|
|
|
@@ -37,6 +37,21 @@ with budget.pay("$12.50"):
|
|
|
37
37
|
`npm install walletcap` (Node 22.13+) · `pip install walletcap` (Python 3.11+) ·
|
|
38
38
|
no dependencies · Apache-2.0
|
|
39
39
|
|
|
40
|
+
## Try it without a wallet
|
|
41
|
+
|
|
42
|
+
Two runnable demos use a fake payee and a temporary file: no wallet, chain, keys or
|
|
43
|
+
network.
|
|
44
|
+
|
|
45
|
+
```console
|
|
46
|
+
$ node examples/quickstart/quickstart.mjs
|
|
47
|
+
$ python3 examples/quickstart/quickstart.py
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
They show three `$0.05` payments against a `$0.15` rolling cap, a `$1.00` refusal, a
|
|
51
|
+
fourth `$0.05` refusal, and a restart that cannot raise the limits. The demo uses
|
|
52
|
+
`create: true`; a production agent opens with the default `create: false` after the
|
|
53
|
+
owner runs `walletcap set`.
|
|
54
|
+
|
|
40
55
|
## Why a per-wallet count
|
|
41
56
|
|
|
42
57
|
Most agent spend controls keep "spent so far" next to something temporary or repeatable: a
|
|
@@ -257,8 +272,9 @@ only for a demo or a first-run setup.
|
|
|
257
272
|
go around it: call the wallet directly, or delete the file. To bound an agent like that, keep
|
|
258
273
|
the budget out of its reach (another OS user, another service) or enforce on chain, as
|
|
259
274
|
`AccountCapSpender` does for Spend Permissions.
|
|
260
|
-
- **One file is one host
|
|
261
|
-
|
|
275
|
+
- **One file is one host.** A shared volume can work for one host's containers, but SQLite
|
|
276
|
+
over NFS is not a multi-host database. A Postgres backend for replicas on separate hosts is
|
|
277
|
+
next.
|
|
262
278
|
- **It can't see a revoke that lands later in the same block,** and for x402 schemes that settle
|
|
263
279
|
later (batch vouchers), a payment counts from when the server confirms it.
|
|
264
280
|
- **Some x402 payments can't be confirmed.** `@x402/fetch` doesn't report v1 settlements
|
|
@@ -277,8 +293,8 @@ only for a demo or a first-run setup.
|
|
|
277
293
|
| `tests/` | the Python suite, including the tests that drive both languages against one budget file |
|
|
278
294
|
|
|
279
295
|
```console
|
|
280
|
-
$ cd js && npm install && node --test #
|
|
281
|
-
$ uv run --with pytest pytest -q #
|
|
296
|
+
$ cd js && npm install && node --test # 61 tests
|
|
297
|
+
$ uv run --with pytest pytest -q # 59 tests, from the repository root
|
|
282
298
|
$ cd solidity/evm && npm install && node check.mjs .. # the contract on an EVM, no Foundry needed
|
|
283
299
|
$ cd solidity && forge test # the contract's canonical tests, if you have Foundry
|
|
284
300
|
```
|
|
@@ -289,9 +305,19 @@ MCP proxy against stand-ins for Coinbase's wallet and MCP server. The Python sui
|
|
|
289
305
|
the CLI and one budget shared between both languages. `solidity/evm/check.mjs` compiles the
|
|
290
306
|
contract with solc-js and drives it on an in-process EVM, covering what the Foundry tests cover.
|
|
291
307
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
308
|
+
The JavaScript suite also drives the published `@coinbase/cdp-sdk@1.60.0` through the
|
|
309
|
+
shared store, so the CDP integration is covered against the package users install.
|
|
310
|
+
|
|
311
|
+
## Security and audit
|
|
312
|
+
|
|
313
|
+
- [Security policy](SECURITY.md): how to report a vulnerability, supported versions,
|
|
314
|
+
and the 90-day coordinated-disclosure window for walletcap itself.
|
|
315
|
+
- [Threat model](docs/threat-model.md): what the guard stops, what it does not, and the
|
|
316
|
+
deployment patterns that hold.
|
|
317
|
+
- [Vendor benchmark disclosure](docs/vendor-disclosure.md): the separate 7-day
|
|
318
|
+
pre-publication notice for vendor findings in the Agent Payment Benchmark.
|
|
319
|
+
- `AccountCapSpender` is **unaudited**. Read [solidity/README.md](solidity/README.md)
|
|
320
|
+
before deploying it; testnet only until an independent audit.
|
|
295
321
|
|
|
296
322
|
## Prove it holds in your deployment
|
|
297
323
|
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# Security policy
|
|
2
|
+
|
|
3
|
+
walletcap is a spend guard that sits on the payment path. If you find a way
|
|
4
|
+
around its count, its reservations, or its failure modes, we want to know.
|
|
5
|
+
|
|
6
|
+
## Supported versions
|
|
7
|
+
|
|
8
|
+
The latest **0.2.x** release is supported. The 0.2.1 release fixed the
|
|
9
|
+
fail-open default and the CDP store/guard collision; earlier 0.2.0 installs
|
|
10
|
+
should upgrade before relying on the guard.
|
|
11
|
+
|
|
12
|
+
## Reporting a vulnerability
|
|
13
|
+
|
|
14
|
+
Email **paul@coriolislabs.ca** with `walletcap security` in the subject. If you
|
|
15
|
+
want to encrypt, ask for a PGP key in the first message; do not send the details
|
|
16
|
+
in a public GitHub issue.
|
|
17
|
+
|
|
18
|
+
Please include:
|
|
19
|
+
|
|
20
|
+
- the version, language (JavaScript or Python), and platform;
|
|
21
|
+
- the configuration (single process or replicas, one file or a shared volume);
|
|
22
|
+
- a minimal reproduction, or the sequence of calls;
|
|
23
|
+
- what you expected and what happened.
|
|
24
|
+
|
|
25
|
+
## What to expect
|
|
26
|
+
|
|
27
|
+
- We aim to acknowledge a report within **5 business days**.
|
|
28
|
+
- For walletcap itself we use a **90-day** coordinated disclosure window, and
|
|
29
|
+
we will credit you unless you ask us not to.
|
|
30
|
+
- If a fix ships earlier, we will publish the advisory as soon as users can
|
|
31
|
+
upgrade.
|
|
32
|
+
|
|
33
|
+
## In scope
|
|
34
|
+
|
|
35
|
+
- the SQLite ledger (`ledger.mjs`, `src/walletcap/ledger.py`) and its
|
|
36
|
+
transaction/counting rules;
|
|
37
|
+
- the JavaScript and Python budget APIs and the CLI;
|
|
38
|
+
- the x402 guard and the CDP `SpendStore`;
|
|
39
|
+
- the Agentic Wallet CLI/MCP wrapper;
|
|
40
|
+
- `AccountCapSpender` and its transfer/allowance handling.
|
|
41
|
+
|
|
42
|
+
## Out of scope
|
|
43
|
+
|
|
44
|
+
- an agent with shell access on the same OS user: it can call the wallet
|
|
45
|
+
directly or delete the file. The guard is not an OS sandbox.
|
|
46
|
+
- editing the SQLite file by someone who can write it;
|
|
47
|
+
- private-key custody and the wallet or chain itself;
|
|
48
|
+
- vulnerabilities in Coinbase, Safe, MetaMask, Tempo, Google or other vendor
|
|
49
|
+
products. Those are handled under the separate benchmark disclosure policy
|
|
50
|
+
in [docs/vendor-disclosure.md](docs/vendor-disclosure.md).
|
|
51
|
+
|
|
52
|
+
## Known limitations
|
|
53
|
+
|
|
54
|
+
See [docs/threat-model.md](docs/threat-model.md) for the full model. The short
|
|
55
|
+
version:
|
|
56
|
+
|
|
57
|
+
- the guard binds only the calls that pass through it;
|
|
58
|
+
- the budget file is single-host; a shared volume is not a substitute for a
|
|
59
|
+
database that speaks a network protocol;
|
|
60
|
+
- an x402 payment whose settlement cannot be confirmed keeps counting until its
|
|
61
|
+
authorization expires;
|
|
62
|
+
- `AccountCapSpender` is **unaudited**; see
|
|
63
|
+
[solidity/README.md](solidity/README.md). Do not deploy it with real funds
|
|
64
|
+
until it has an independent audit.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# Threat model
|
|
2
|
+
|
|
3
|
+
This is the model a security reviewer should test against. It is deliberately
|
|
4
|
+
narrow: walletcap is a spend *counter*, not a sandbox, a key store, or a chain
|
|
5
|
+
observer.
|
|
6
|
+
|
|
7
|
+
## Assets
|
|
8
|
+
|
|
9
|
+
- the funds in the wallet;
|
|
10
|
+
- the budget file (limits, reservations, payment history);
|
|
11
|
+
- the agent's ability to keep paying after a restart, a replica, or a key
|
|
12
|
+
rotation.
|
|
13
|
+
|
|
14
|
+
## Adversaries and controls
|
|
15
|
+
|
|
16
|
+
| Adversary | What they can do | Control | Residual risk |
|
|
17
|
+
|---|---|---|---|
|
|
18
|
+
| A buggy agent | Pay more than intended | Per-payment cap, rolling cap, `max_uses`, one reservation per payment | A bug that bypasses the guard entirely |
|
|
19
|
+
| An agent with the same OS user | Delete the budget file, edit it, call the wallet directly | A missing file refuses by default (`create: false`); owner-only `walletcap set`; the file is the source of truth | It can still call the wallet or edit the file. Use a separate OS user, a separate service, or the on-chain cap |
|
|
20
|
+
| A second process or replica on the same host | Race for the last dollar | `BEGIN IMMEDIATE` + a re-check inside the write transaction | A process that writes the file outside the library |
|
|
21
|
+
| A payee | Ask for a different asset, a longer validity, or a "failed" settlement it keeps | The guard prices known USD stablecoins and refuses the rest; the CDP store refuses a different asset; an unknown outcome keeps counting | An asset the guard cannot price is refused, not valued |
|
|
22
|
+
| A payee with a 402 it can replay | Hold authorizations and settle later | A reservation counts until it is committed/released, or its expiry plus one window | A confirmed settlement still needs the chain to be read back |
|
|
23
|
+
| Another local user | Read the budget file | The parent directory is created `0700`; use a `0600` file if you pass a custom `path` | Files copied out of that directory keep their mode |
|
|
24
|
+
| A host failure | Lose the file | The database is one file; back it up like any other state | A lost file is a lost count; the default refuses to recreate it |
|
|
25
|
+
| A later on-chain revoke | Land in the same block after a payment | The payment is already ordered ahead of the revoke | The owner's revocation is not retroactive |
|
|
26
|
+
|
|
27
|
+
## Deployment patterns
|
|
28
|
+
|
|
29
|
+
1. **One agent, one OS user.** The owner runs `walletcap set` and keeps the
|
|
30
|
+
budget file out of the agent's reach. This is the strong pattern.
|
|
31
|
+
2. **A service the agent calls.** The service owns the file and the wallet; the
|
|
32
|
+
agent gets an API, not the key.
|
|
33
|
+
3. **On chain.** `AccountCapSpender` makes the cap part of the payment path, so
|
|
34
|
+
the agent's own code cannot raise it. It is unaudited; testnet only until an
|
|
35
|
+
audit.
|
|
36
|
+
4. **One host, one volume.** SQLite locking is a local-filesystem contract. A
|
|
37
|
+
shared volume is not a multi-host database; replicas on separate hosts need
|
|
38
|
+
the Postgres backend (next), not a network mount.
|
|
39
|
+
|
|
40
|
+
## What the guard does not do
|
|
41
|
+
|
|
42
|
+
- It does not decide whether a payment *should* happen; it only bounds it.
|
|
43
|
+
- It does not confirm settlement. A payment the payee calls failed keeps
|
|
44
|
+
counting until its authorization expires, and a payment with no expiry keeps
|
|
45
|
+
counting until an owner releases it.
|
|
46
|
+
- It does not protect the wallet key or the chain.
|
|
47
|
+
- It does not make an unaudited contract safe.
|
|
48
|
+
|
|
49
|
+
## CDP store vs guard
|
|
50
|
+
|
|
51
|
+
Use **one** per client. `budget.guard(client)` is walletcap's own counter and
|
|
52
|
+
prices every asset. `applySpendControls(..., { store: budget.cdpStore() })`
|
|
53
|
+
keeps CDP's policy layer but replaces its store with walletcap's ledger. Both
|
|
54
|
+
write the same budget, so both on one client double-count the payment.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# Vendor benchmark disclosure policy
|
|
2
|
+
|
|
3
|
+
This policy covers findings about **vendor products** in the Agent Payment
|
|
4
|
+
Benchmark. It is separate from walletcap's own 90-day policy in
|
|
5
|
+
[SECURITY.md](../SECURITY.md).
|
|
6
|
+
|
|
7
|
+
## The notice
|
|
8
|
+
|
|
9
|
+
Before publishing a named benchmark result, we send a **7-day pre-publication
|
|
10
|
+
notice** to the vendor's security contact, the product owner, and the relevant
|
|
11
|
+
executive. The notice includes:
|
|
12
|
+
|
|
13
|
+
- the affected product and version;
|
|
14
|
+
- the configuration tested;
|
|
15
|
+
- the measured impact;
|
|
16
|
+
- a private reproduction package;
|
|
17
|
+
- the exact publication date and time;
|
|
18
|
+
- an offer of a technical call within 48 hours.
|
|
19
|
+
|
|
20
|
+
We publish on the stated date whether or not the vendor replies. If the vendor
|
|
21
|
+
replies, their statement is included verbatim in the benchmark's response
|
|
22
|
+
column.
|
|
23
|
+
|
|
24
|
+
## Status labels
|
|
25
|
+
|
|
26
|
+
- **Unpatched** — no fixed public release as of the publication timestamp.
|
|
27
|
+
- **Acknowledged** — the vendor replied; a fix is in progress.
|
|
28
|
+
- **Fix in review** — a public PR or release candidate exists.
|
|
29
|
+
- **Fixed** — re-tested on the fixed release; the date and version are shown.
|
|
30
|
+
- **Disputed** — the vendor disagrees; their response and our evidence are both
|
|
31
|
+
published.
|
|
32
|
+
- **No response** — the notice was delivered; no reply by the publication date.
|
|
33
|
+
|
|
34
|
+
## What we do not do
|
|
35
|
+
|
|
36
|
+
- We do not change a status because a vendor buys an evaluation, a re-test, or a
|
|
37
|
+
CI gate. The benchmark publishes regardless.
|
|
38
|
+
- We do not publish a vendor's private data or a customer's identity.
|
|
39
|
+
- We do not treat an in-review report as a public finding before the notice
|
|
40
|
+
window. Until publication, the public material uses counts and classes, not
|
|
41
|
+
vendor names or routes.
|
|
42
|
+
|
|
43
|
+
## Re-tests
|
|
44
|
+
|
|
45
|
+
Every benchmark entry has a publication timestamp and the exact version tested.
|
|
46
|
+
When a vendor ships a fix, we re-test and update the entry. The paid evaluation
|
|
47
|
+
and annual CI gate are the productized form of that re-test, not a condition of
|
|
48
|
+
the label.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Quickstart
|
|
2
|
+
|
|
3
|
+
No wallet, no chain, no keys, no network. The demo uses a fake payee and a
|
|
4
|
+
temporary SQLite file.
|
|
5
|
+
|
|
6
|
+
```sh
|
|
7
|
+
node examples/quickstart/quickstart.mjs
|
|
8
|
+
python3 examples/quickstart/quickstart.py
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Both print the same story: three `$0.05` payments against a `$0.15` rolling
|
|
12
|
+
cap; a `$1.00` payment refused by the per-payment cap; a fourth `$0.05`
|
|
13
|
+
refused by the rolling cap; a restart that asks for looser limits and is
|
|
14
|
+
refused because the stored count and limits stay.
|
|
15
|
+
|
|
16
|
+
The demo opens with `create: true` because it starts from an empty file. A
|
|
17
|
+
production agent opens with the default `create: false`, after the owner has
|
|
18
|
+
created the budget once:
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
npx walletcap set --wallet 0xYourAgentWallet --per-payment 50 --per-day 150
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
The demo file is `examples/quickstart/demo.sqlite` and is gitignored.
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// A ten-second demo with a fake payee: no wallet, chain, key or network.
|
|
3
|
+
// Production code opens the budget with create: false; the owner creates it
|
|
4
|
+
// once with `npx walletcap set`.
|
|
5
|
+
import { rmSync } from 'node:fs';
|
|
6
|
+
import { dirname, join } from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { BudgetRefused, openBudget } from '../../js/index.mjs';
|
|
9
|
+
|
|
10
|
+
const PATH = join(dirname(fileURLToPath(import.meta.url)), 'demo.sqlite');
|
|
11
|
+
const WALLET = '0xd797ead28c38575fdf4fc523866287372340ca02';
|
|
12
|
+
const TX = `0x${'ab'.repeat(32)}`;
|
|
13
|
+
|
|
14
|
+
rmSync(PATH, { force: true });
|
|
15
|
+
|
|
16
|
+
const budget = openBudget({
|
|
17
|
+
wallet: WALLET,
|
|
18
|
+
path: PATH,
|
|
19
|
+
perPayment: '$0.05',
|
|
20
|
+
perDay: '$0.15',
|
|
21
|
+
create: true, // demo only
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const attempt = async (target, label, amount) => {
|
|
25
|
+
try {
|
|
26
|
+
await target.pay(amount, async () => TX);
|
|
27
|
+
console.log(`paid ${label.padEnd(28)} ${amount}`);
|
|
28
|
+
} catch (error) {
|
|
29
|
+
if (!(error instanceof BudgetRefused)) throw error;
|
|
30
|
+
console.log(`REFUSED ${label.padEnd(28)} ${amount} (${error.reason})`);
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
console.log(`Budget: ${budget.status().summary}\n`);
|
|
35
|
+
for (const amount of ['$0.05', '$0.05', '$0.05']) await attempt(budget, 'three small payments', amount);
|
|
36
|
+
await attempt(budget, 'past the per-payment cap', '$1.00');
|
|
37
|
+
await attempt(budget, 'past the rolling cap', '$0.05');
|
|
38
|
+
|
|
39
|
+
const beforeRestart = budget.status().spent;
|
|
40
|
+
budget.close();
|
|
41
|
+
|
|
42
|
+
const warnings = [];
|
|
43
|
+
const restarted = openBudget({
|
|
44
|
+
wallet: WALLET,
|
|
45
|
+
path: PATH,
|
|
46
|
+
perPayment: '$2.50',
|
|
47
|
+
perDay: '$5',
|
|
48
|
+
onWarning: message => warnings.push(message),
|
|
49
|
+
});
|
|
50
|
+
console.log(`\nRestart asks for looser limits; stored limits stay: ${warnings[0] ?? 'no warning'}`);
|
|
51
|
+
console.log(`Count before restart: ${beforeRestart}; after restart: ${restarted.status().spent}\n`);
|
|
52
|
+
await attempt(restarted, 'the same $0.05 after restart', '$0.05');
|
|
53
|
+
|
|
54
|
+
const final = openBudget({ wallet: WALLET, path: PATH });
|
|
55
|
+
console.log(`\nFinal: ${final.status().summary}`);
|
|
56
|
+
console.log(`Result: ${final.status().spent} of ${final.status().cap} spent, ${final.status().remaining} left.`);
|
|
57
|
+
console.log(`History: ${final.history().length} payments; the first is ${final.history()[0].status}.`);
|
|
58
|
+
final.close();
|
|
59
|
+
restarted.close();
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""A ten-second demo with a fake payee: no wallet, chain, key or network.
|
|
3
|
+
|
|
4
|
+
Production code opens the budget with create=False; the owner creates it once
|
|
5
|
+
with walletcap set.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import sys
|
|
11
|
+
import warnings
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "src"))
|
|
15
|
+
|
|
16
|
+
from walletcap import BudgetRefused, open_budget
|
|
17
|
+
|
|
18
|
+
PATH = Path(__file__).with_name("demo.sqlite")
|
|
19
|
+
WALLET = "0xd797ead28c38575fdf4fc523866287372340ca02"
|
|
20
|
+
TX = "0x" + "ab" * 32
|
|
21
|
+
|
|
22
|
+
PATH.unlink(missing_ok=True)
|
|
23
|
+
|
|
24
|
+
budget = open_budget(
|
|
25
|
+
WALLET,
|
|
26
|
+
path=PATH,
|
|
27
|
+
per_payment="$0.05",
|
|
28
|
+
per_day="$0.15",
|
|
29
|
+
create=True, # demo only
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def attempt(target: object, label: str, amount: str) -> None:
|
|
34
|
+
try:
|
|
35
|
+
with target.pay(amount) as payment: # type: ignore[attr-defined]
|
|
36
|
+
payment.tx = TX
|
|
37
|
+
print(f"paid {label:<28} {amount}")
|
|
38
|
+
except BudgetRefused as error:
|
|
39
|
+
print(f"REFUSED {label:<28} {amount} ({error.reason})")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
print(f"Budget: {budget.status().summary}\n")
|
|
43
|
+
for amount in ("$0.05", "$0.05", "$0.05"):
|
|
44
|
+
attempt(budget, "three small payments", amount)
|
|
45
|
+
attempt(budget, "past the per-payment cap", "$1.00")
|
|
46
|
+
attempt(budget, "past the rolling cap", "$0.05")
|
|
47
|
+
|
|
48
|
+
before_restart = budget.status().spent
|
|
49
|
+
budget.close()
|
|
50
|
+
|
|
51
|
+
messages: list[str] = []
|
|
52
|
+
with warnings.catch_warnings(record=True) as caught:
|
|
53
|
+
warnings.simplefilter("always")
|
|
54
|
+
restarted = open_budget(
|
|
55
|
+
WALLET,
|
|
56
|
+
path=PATH,
|
|
57
|
+
per_payment="$2.50",
|
|
58
|
+
per_day="$5",
|
|
59
|
+
)
|
|
60
|
+
messages = [str(item.message) for item in caught]
|
|
61
|
+
print(f"\nRestart asks for looser limits; stored limits stay: {messages[0] if messages else 'no warning'}")
|
|
62
|
+
print(f"Count before restart: {before_restart}; after restart: {restarted.status().spent}\n")
|
|
63
|
+
attempt(restarted, "the same $0.05 after restart", "$0.05")
|
|
64
|
+
|
|
65
|
+
final = open_budget(WALLET, path=PATH)
|
|
66
|
+
print(f"\nFinal: {final.status().summary}")
|
|
67
|
+
print(f"Result: {final.status().spent} of {final.status().cap} spent, {final.status().remaining} left.")
|
|
68
|
+
print(f"History: {len(final.history())} payments; the first is {final.history()[0].status}.")
|
|
69
|
+
final.close()
|
|
70
|
+
restarted.close()
|
|
@@ -37,6 +37,10 @@ $ npx walletcap set --wallet 0xYourAgentWallet --per-payment 0.05 --per-day 0.15
|
|
|
37
37
|
$ npx walletcap freeze
|
|
38
38
|
```
|
|
39
39
|
|
|
40
|
+
Security: [policy](https://github.com/coriolislabs/WalletCap/blob/main/SECURITY.md) ·
|
|
41
|
+
[threat model](https://github.com/coriolislabs/WalletCap/blob/main/docs/threat-model.md).
|
|
42
|
+
`AccountCapSpender` is unaudited; testnet only until an independent audit.
|
|
43
|
+
|
|
40
44
|
[Source, Python package and full documentation](https://github.com/coriolislabs/WalletCap)
|
|
41
45
|
|
|
42
46
|
Apache-2.0. Copyright 2026 Coriolis Labs Inc.
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Keccak-256, for EIP-55 address checksums. No dependencies: this is
|
|
3
|
+
// Keccak-f[1600] with the original Keccak padding, which is what Ethereum
|
|
4
|
+
// uses. (node:crypto's sha3-256 is NIST SHA3, a different padding.)
|
|
5
|
+
|
|
6
|
+
const MASK = (1n << 64n) - 1n;
|
|
7
|
+
|
|
8
|
+
const RC = [
|
|
9
|
+
0x0000000000000001n, 0x0000000000008082n, 0x800000000000808an, 0x8000000080008000n,
|
|
10
|
+
0x000000000000808bn, 0x0000000080000001n, 0x8000000080008081n, 0x8000000000008009n,
|
|
11
|
+
0x000000000000008an, 0x0000000000000088n, 0x0000000080008009n, 0x000000008000000an,
|
|
12
|
+
0x000000008000808bn, 0x800000000000008bn, 0x8000000000008089n, 0x8000000000008003n,
|
|
13
|
+
0x8000000000008002n, 0x8000000000000080n, 0x000000000000800an, 0x800000008000000an,
|
|
14
|
+
0x8000000080008081n, 0x8000000000008080n, 0x0000000080000001n, 0x8000000080008008n,
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
const ROT = [
|
|
18
|
+
[0, 36, 3, 41, 18],
|
|
19
|
+
[1, 44, 10, 45, 2],
|
|
20
|
+
[62, 6, 43, 15, 61],
|
|
21
|
+
[28, 55, 25, 21, 56],
|
|
22
|
+
[27, 20, 39, 8, 14],
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
const rotl = (value, amount) => amount === 0
|
|
26
|
+
? value & MASK
|
|
27
|
+
: ((value << BigInt(amount)) | (value >> BigInt(64 - amount))) & MASK;
|
|
28
|
+
|
|
29
|
+
function permute(state) {
|
|
30
|
+
for (const rc of RC) {
|
|
31
|
+
const c = [];
|
|
32
|
+
for (let x = 0; x < 5; x += 1) {
|
|
33
|
+
c[x] = state[x] ^ state[x + 5] ^ state[x + 10] ^ state[x + 15] ^ state[x + 20];
|
|
34
|
+
}
|
|
35
|
+
const d = [];
|
|
36
|
+
for (let x = 0; x < 5; x += 1) {
|
|
37
|
+
d[x] = c[(x + 4) % 5] ^ rotl(c[(x + 1) % 5], 1);
|
|
38
|
+
}
|
|
39
|
+
for (let x = 0; x < 5; x += 1) {
|
|
40
|
+
for (let y = 0; y < 5; y += 1) state[x + 5 * y] ^= d[x];
|
|
41
|
+
}
|
|
42
|
+
const b = new Array(25).fill(0n);
|
|
43
|
+
for (let x = 0; x < 5; x += 1) {
|
|
44
|
+
for (let y = 0; y < 5; y += 1) {
|
|
45
|
+
b[y + 5 * ((2 * x + 3 * y) % 5)] = rotl(state[x + 5 * y], ROT[x][y]);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
for (let x = 0; x < 5; x += 1) {
|
|
49
|
+
for (let y = 0; y < 5; y += 1) {
|
|
50
|
+
state[x + 5 * y] = (b[x + 5 * y] ^ ((~b[(x + 1) % 5 + 5 * y] & MASK) & b[(x + 2) % 5 + 5 * y])) & MASK;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
state[0] ^= rc;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function keccak256(data) {
|
|
58
|
+
const rate = 136;
|
|
59
|
+
const bytes = Array.from(data);
|
|
60
|
+
bytes.push(0x01);
|
|
61
|
+
while (bytes.length % rate !== rate - 1) bytes.push(0x00);
|
|
62
|
+
bytes.push(0x80);
|
|
63
|
+
|
|
64
|
+
const state = new Array(25).fill(0n);
|
|
65
|
+
for (let offset = 0; offset < bytes.length; offset += rate) {
|
|
66
|
+
for (let lane = 0; lane < rate / 8; lane += 1) {
|
|
67
|
+
let value = 0n;
|
|
68
|
+
for (let j = 0; j < 8; j += 1) {
|
|
69
|
+
value |= BigInt(bytes[offset + lane * 8 + j]) << BigInt(8 * j);
|
|
70
|
+
}
|
|
71
|
+
state[lane] ^= value;
|
|
72
|
+
}
|
|
73
|
+
permute(state);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const out = new Uint8Array(32);
|
|
77
|
+
for (let i = 0; i < 4; i += 1) {
|
|
78
|
+
let value = state[i];
|
|
79
|
+
for (let j = 0; j < 8; j += 1) {
|
|
80
|
+
out[i * 8 + j] = Number(value & 0xffn);
|
|
81
|
+
value >>= 8n;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export const keccak256Hex = text => Buffer.from(keccak256(new TextEncoder().encode(text))).toString('hex');
|
|
@@ -291,6 +291,12 @@ export class Ledger {
|
|
|
291
291
|
return this.#row(instrument);
|
|
292
292
|
}
|
|
293
293
|
if (!unitsMatch(row.unit, unit)) throw new BudgetError(`${shortWallet(instrument)}'s budget counts in ${row.unit}, not ${unit}`);
|
|
294
|
+
if (limits.windowMs !== undefined && limits.windowMs !== row.window_ms) {
|
|
295
|
+
const used = this.statement('SELECT 1 FROM spend WHERE instrument = ? LIMIT 1').get(instrument);
|
|
296
|
+
if (used) {
|
|
297
|
+
throw new BudgetError(`${shortWallet(instrument)} has spend in this budget; the window cannot change. Create a new budget instead.`);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
294
300
|
this.statement('UPDATE budgets SET cap = ?, window_ms = ?, per_payment = ?, max_uses = ? WHERE instrument = ?').run(
|
|
295
301
|
limits.cap ?? row.cap,
|
|
296
302
|
limits.windowMs ?? row.window_ms,
|