walletbrake 0.1.0__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.
- walletbrake-0.1.0/.gitignore +23 -0
- walletbrake-0.1.0/LICENSE +21 -0
- walletbrake-0.1.0/PKG-INFO +123 -0
- walletbrake-0.1.0/README.md +103 -0
- walletbrake-0.1.0/examples/pay.py +33 -0
- walletbrake-0.1.0/pyproject.toml +41 -0
- walletbrake-0.1.0/tests/conftest.py +63 -0
- walletbrake-0.1.0/tests/test_sdk.py +251 -0
- walletbrake-0.1.0/walletbrake/__init__.py +462 -0
- walletbrake-0.1.0/walletbrake/py.typed +0 -0
- walletbrake-0.1.0/walletbrake/vault_abi.json +299 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
*.zip
|
|
2
|
+
.DS_Store
|
|
3
|
+
.env
|
|
4
|
+
.env.alerts
|
|
5
|
+
.env.sepolia
|
|
6
|
+
/bin/
|
|
7
|
+
audit.jsonl
|
|
8
|
+
contracts/broadcast/
|
|
9
|
+
contracts/cache/
|
|
10
|
+
contracts/lib/
|
|
11
|
+
contracts/out/
|
|
12
|
+
data/
|
|
13
|
+
web/walletconnect/node_modules/
|
|
14
|
+
sdk-ts/node_modules/
|
|
15
|
+
sdk-ts/dist/
|
|
16
|
+
agentkit/node_modules/
|
|
17
|
+
agentkit/dist/
|
|
18
|
+
mcp/node_modules/
|
|
19
|
+
mcp/dist/
|
|
20
|
+
sdk-py/.venv/
|
|
21
|
+
sdk-py/dist/
|
|
22
|
+
__pycache__/
|
|
23
|
+
.pytest_cache/
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 hd5-labs
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: walletbrake
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Pay from a WalletBrake vault from a bot or AI agent: spending limits, a trusted-address list and owner approval on Telegram, enforced on chain.
|
|
5
|
+
Project-URL: Homepage, https://walletbrake.com
|
|
6
|
+
Project-URL: Documentation, https://walletbrake.com/docs#python
|
|
7
|
+
Author-email: WalletBrake <support@walletbrake.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: agent-payments,ai-agent,base,ethereum,prompt-injection,spending-limits,usdc,wallet,walletbrake,web3
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Office/Business :: Financial
|
|
15
|
+
Classifier: Typing :: Typed
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Requires-Dist: requests>=2.31
|
|
18
|
+
Requires-Dist: web3<9,>=7
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# walletbrake (Python)
|
|
22
|
+
|
|
23
|
+
Pay from a [WalletBrake](https://walletbrake.com) vault from a bot or AI agent, in Python. It works with LangChain, CrewAI, or plain scripts.
|
|
24
|
+
|
|
25
|
+
WalletBrake checks every payment against the vault's rules: limits, a trusted-address list, and asking the owner on Telegram. The vault contract enforces the result on chain. A payment needs WalletBrake's signature and the spending wallet's transaction, so neither one can move money alone. There's no API key: each request is signed with the spending wallet's key.
|
|
26
|
+
|
|
27
|
+
> Test networks only for now (Base Sepolia, BNB testnet, Ethereum Sepolia). Mainnet comes after the security audit.
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
pip install walletbrake
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Python 3.10 or newer; web3 7 or 8.
|
|
36
|
+
|
|
37
|
+
## Pay
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
import os
|
|
41
|
+
from walletbrake import WalletBrake
|
|
42
|
+
|
|
43
|
+
wb = WalletBrake(
|
|
44
|
+
url="https://app.walletbrake.com/c/84532/v/0xYourVault", # the vault's WalletBrake address
|
|
45
|
+
private_key=os.environ["AGENT_KEY"], # the vault's spending wallet
|
|
46
|
+
rpc_url="https://sepolia.base.org",
|
|
47
|
+
)
|
|
48
|
+
paid = wb.pay("0x…", "10") # 10 USDC
|
|
49
|
+
print(paid.tx_hash)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
- `amount` is in coin units, as a string or `Decimal` (`"10.5"`). Ints and floats are refused, because `10` could mean 10 USDC or 10 base units, and floats lose cents. For base units, use `amount_base_units=10_500_000`.
|
|
53
|
+
- `token` is a symbol (`"USDC"`, `"ETH"`) or a token address. The default is `"USDC"`.
|
|
54
|
+
|
|
55
|
+
## When the owner has to approve
|
|
56
|
+
|
|
57
|
+
On the **AI agent** plan, the vault pays only trusted addresses: the ones listed when the vault was created, or added later from the Safety tab. A payment to any other address goes to the owner on Telegram, with Approve and Reject buttons. Until they answer, `pay` raises `DeniedError` and `is_awaiting_approval(err)` returns true. Asking again with the same coin, address and amount goes through once they tap Approve. `pay_and_wait` does the asking again for you:
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
paid = wb.pay_and_wait("0x…", "10", on_waiting=lambda _: print("Asked the owner on Telegram…"))
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Each approval covers one payment, for 15 minutes. It doesn't make the address trusted.
|
|
64
|
+
|
|
65
|
+
## Tasks
|
|
66
|
+
|
|
67
|
+
Before an agent starts a job, before it reads any web page, email or file,
|
|
68
|
+
tell WalletBrake what the job is. Payments in that task can't go past it,
|
|
69
|
+
even to addresses you trust:
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
task = wb.open_task(
|
|
73
|
+
"Pay the March hosting invoice",
|
|
74
|
+
"100", # USDC, for the whole task
|
|
75
|
+
pay_only=["0xHostingCompany…"], # optional
|
|
76
|
+
minutes=60, # optional, up to 1440
|
|
77
|
+
)
|
|
78
|
+
wb.pay_and_wait("0xHostingCompany…", "60", task=task, note="invoice 1042")
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
A payment over what's left of the budget, to an address the task didn't
|
|
82
|
+
list, or after the task ends, is refused with rule `"task"`. When the
|
|
83
|
+
server's intent check is on, a payment that doesn't clearly fit the task's
|
|
84
|
+
description goes to the owner on Telegram first, with the reason.
|
|
85
|
+
|
|
86
|
+
## Refusals
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
from walletbrake import DeniedError, WalletBrakeError
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
wb.pay(to, "5000")
|
|
93
|
+
except DeniedError as err:
|
|
94
|
+
print(err.rule, err.reason) # e.g. "per-tx-cap", why
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
`err.verdict == "freeze"` means the request looked like an attack and WalletBrake froze the vault. Only the owner can unfreeze it. Other problems raise:
|
|
98
|
+
- `WalletBrakeError`: unreachable, wrong key (`status` 401)
|
|
99
|
+
- `VaultError`: the vault contract refused, e.g. `NotReady`
|
|
100
|
+
|
|
101
|
+
## Held payments
|
|
102
|
+
|
|
103
|
+
The vault holds a large payment to an address it doesn't trust for an hour, so the owner can cancel it. In that case `pay` returns `queued=True` and a `queue_id`. Run it once the hour has passed:
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
if paid.queued:
|
|
107
|
+
wb.execute_queued(paid.queue_id)
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Also
|
|
111
|
+
|
|
112
|
+
- `wb.info()`: the vault's coins, balances, limits, and whether it's frozen.
|
|
113
|
+
- `wb.request_approval(...)` then `wb.send(approval)`: the two halves of `pay`. Before sending, the SDK checks that WalletBrake's approval matches the payment you asked for.
|
|
114
|
+
|
|
115
|
+
## Docs
|
|
116
|
+
|
|
117
|
+
Quickstart, every refusal reason, the networks and addresses, and the HTTP API for other languages: **https://walletbrake.com/docs**
|
|
118
|
+
|
|
119
|
+
Questions: support@walletbrake.com. Security issues: security@walletbrake.com.
|
|
120
|
+
|
|
121
|
+
## License
|
|
122
|
+
|
|
123
|
+
MIT
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# walletbrake (Python)
|
|
2
|
+
|
|
3
|
+
Pay from a [WalletBrake](https://walletbrake.com) vault from a bot or AI agent, in Python. It works with LangChain, CrewAI, or plain scripts.
|
|
4
|
+
|
|
5
|
+
WalletBrake checks every payment against the vault's rules: limits, a trusted-address list, and asking the owner on Telegram. The vault contract enforces the result on chain. A payment needs WalletBrake's signature and the spending wallet's transaction, so neither one can move money alone. There's no API key: each request is signed with the spending wallet's key.
|
|
6
|
+
|
|
7
|
+
> Test networks only for now (Base Sepolia, BNB testnet, Ethereum Sepolia). Mainnet comes after the security audit.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
pip install walletbrake
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Python 3.10 or newer; web3 7 or 8.
|
|
16
|
+
|
|
17
|
+
## Pay
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
import os
|
|
21
|
+
from walletbrake import WalletBrake
|
|
22
|
+
|
|
23
|
+
wb = WalletBrake(
|
|
24
|
+
url="https://app.walletbrake.com/c/84532/v/0xYourVault", # the vault's WalletBrake address
|
|
25
|
+
private_key=os.environ["AGENT_KEY"], # the vault's spending wallet
|
|
26
|
+
rpc_url="https://sepolia.base.org",
|
|
27
|
+
)
|
|
28
|
+
paid = wb.pay("0x…", "10") # 10 USDC
|
|
29
|
+
print(paid.tx_hash)
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
- `amount` is in coin units, as a string or `Decimal` (`"10.5"`). Ints and floats are refused, because `10` could mean 10 USDC or 10 base units, and floats lose cents. For base units, use `amount_base_units=10_500_000`.
|
|
33
|
+
- `token` is a symbol (`"USDC"`, `"ETH"`) or a token address. The default is `"USDC"`.
|
|
34
|
+
|
|
35
|
+
## When the owner has to approve
|
|
36
|
+
|
|
37
|
+
On the **AI agent** plan, the vault pays only trusted addresses: the ones listed when the vault was created, or added later from the Safety tab. A payment to any other address goes to the owner on Telegram, with Approve and Reject buttons. Until they answer, `pay` raises `DeniedError` and `is_awaiting_approval(err)` returns true. Asking again with the same coin, address and amount goes through once they tap Approve. `pay_and_wait` does the asking again for you:
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
paid = wb.pay_and_wait("0x…", "10", on_waiting=lambda _: print("Asked the owner on Telegram…"))
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Each approval covers one payment, for 15 minutes. It doesn't make the address trusted.
|
|
44
|
+
|
|
45
|
+
## Tasks
|
|
46
|
+
|
|
47
|
+
Before an agent starts a job, before it reads any web page, email or file,
|
|
48
|
+
tell WalletBrake what the job is. Payments in that task can't go past it,
|
|
49
|
+
even to addresses you trust:
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
task = wb.open_task(
|
|
53
|
+
"Pay the March hosting invoice",
|
|
54
|
+
"100", # USDC, for the whole task
|
|
55
|
+
pay_only=["0xHostingCompany…"], # optional
|
|
56
|
+
minutes=60, # optional, up to 1440
|
|
57
|
+
)
|
|
58
|
+
wb.pay_and_wait("0xHostingCompany…", "60", task=task, note="invoice 1042")
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
A payment over what's left of the budget, to an address the task didn't
|
|
62
|
+
list, or after the task ends, is refused with rule `"task"`. When the
|
|
63
|
+
server's intent check is on, a payment that doesn't clearly fit the task's
|
|
64
|
+
description goes to the owner on Telegram first, with the reason.
|
|
65
|
+
|
|
66
|
+
## Refusals
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
from walletbrake import DeniedError, WalletBrakeError
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
wb.pay(to, "5000")
|
|
73
|
+
except DeniedError as err:
|
|
74
|
+
print(err.rule, err.reason) # e.g. "per-tx-cap", why
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
`err.verdict == "freeze"` means the request looked like an attack and WalletBrake froze the vault. Only the owner can unfreeze it. Other problems raise:
|
|
78
|
+
- `WalletBrakeError`: unreachable, wrong key (`status` 401)
|
|
79
|
+
- `VaultError`: the vault contract refused, e.g. `NotReady`
|
|
80
|
+
|
|
81
|
+
## Held payments
|
|
82
|
+
|
|
83
|
+
The vault holds a large payment to an address it doesn't trust for an hour, so the owner can cancel it. In that case `pay` returns `queued=True` and a `queue_id`. Run it once the hour has passed:
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
if paid.queued:
|
|
87
|
+
wb.execute_queued(paid.queue_id)
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Also
|
|
91
|
+
|
|
92
|
+
- `wb.info()`: the vault's coins, balances, limits, and whether it's frozen.
|
|
93
|
+
- `wb.request_approval(...)` then `wb.send(approval)`: the two halves of `pay`. Before sending, the SDK checks that WalletBrake's approval matches the payment you asked for.
|
|
94
|
+
|
|
95
|
+
## Docs
|
|
96
|
+
|
|
97
|
+
Quickstart, every refusal reason, the networks and addresses, and the HTTP API for other languages: **https://walletbrake.com/docs**
|
|
98
|
+
|
|
99
|
+
Questions: support@walletbrake.com. Security issues: security@walletbrake.com.
|
|
100
|
+
|
|
101
|
+
## License
|
|
102
|
+
|
|
103
|
+
MIT
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""An AI agent's payment, with the Python SDK. Test networks only.
|
|
2
|
+
|
|
3
|
+
uv run examples/pay.py <vault> <to> <amount>
|
|
4
|
+
|
|
5
|
+
Uses the spending key from `go run ./examples/agentpay keygen`
|
|
6
|
+
(~/.config/walletbrake/agent.key), or AGENT_KEY. Base Sepolia by default;
|
|
7
|
+
set WALLETBRAKE_URL and RPC_URL for another network.
|
|
8
|
+
"""
|
|
9
|
+
import os
|
|
10
|
+
import pathlib
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
from walletbrake import DeniedError, WalletBrake
|
|
14
|
+
|
|
15
|
+
if len(sys.argv) != 4:
|
|
16
|
+
sys.exit("usage: uv run examples/pay.py <vault> <to> <amount>")
|
|
17
|
+
vault, to, amount = sys.argv[1:]
|
|
18
|
+
key = os.environ.get("AGENT_KEY") or (pathlib.Path.home() / ".config/walletbrake/agent.key").read_text().strip()
|
|
19
|
+
|
|
20
|
+
wb = WalletBrake(
|
|
21
|
+
url=os.environ.get("WALLETBRAKE_URL", f"https://app.walletbrake.com/c/84532/v/{vault}"),
|
|
22
|
+
private_key=key,
|
|
23
|
+
rpc_url=os.environ.get("RPC_URL", "https://sepolia.base.org"),
|
|
24
|
+
)
|
|
25
|
+
print(f"Paying {amount} USDC to {to}…", flush=True)
|
|
26
|
+
try:
|
|
27
|
+
res = wb.pay_and_wait(
|
|
28
|
+
to, amount,
|
|
29
|
+
on_waiting=lambda _: print("This address isn't trusted: WalletBrake asked the owner on Telegram. Waiting for Approve…", flush=True),
|
|
30
|
+
)
|
|
31
|
+
except DeniedError as err:
|
|
32
|
+
sys.exit(f"WalletBrake refused: {err.reason} ({err.rule})")
|
|
33
|
+
print(f"Approved; the vault holds it (payment #{res.queue_id}). Tx {res.tx_hash}" if res.queued else f"Paid. Tx {res.tx_hash}")
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "walletbrake"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Pay from a WalletBrake vault from a bot or AI agent: spending limits, a trusted-address list and owner approval on Telegram, enforced on chain."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
requires-python = ">=3.10"
|
|
8
|
+
authors = [{ name = "WalletBrake", email = "support@walletbrake.com" }]
|
|
9
|
+
keywords = ["walletbrake", "ai-agent", "agent-payments", "wallet", "spending-limits", "prompt-injection", "web3", "ethereum", "base", "usdc"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 3 - Alpha",
|
|
12
|
+
"Intended Audience :: Developers",
|
|
13
|
+
"Programming Language :: Python :: 3",
|
|
14
|
+
"Topic :: Office/Business :: Financial",
|
|
15
|
+
"Typing :: Typed",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
dependencies = [
|
|
19
|
+
"web3>=7,<9",
|
|
20
|
+
"requests>=2.31",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://walletbrake.com"
|
|
25
|
+
Documentation = "https://walletbrake.com/docs#python"
|
|
26
|
+
|
|
27
|
+
[dependency-groups]
|
|
28
|
+
dev = ["pytest>=8"]
|
|
29
|
+
|
|
30
|
+
[build-system]
|
|
31
|
+
requires = ["hatchling"]
|
|
32
|
+
build-backend = "hatchling.build"
|
|
33
|
+
|
|
34
|
+
[tool.hatch.build.targets.wheel]
|
|
35
|
+
packages = ["walletbrake"]
|
|
36
|
+
|
|
37
|
+
[tool.hatch.build.targets.sdist]
|
|
38
|
+
include = ["walletbrake", "tests", "examples", "README.md", "LICENSE", "pyproject.toml"]
|
|
39
|
+
|
|
40
|
+
[tool.pytest.ini_options]
|
|
41
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Starts cmd/testenv (anvil + a vault + the real guardian) for the tests."""
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import pathlib
|
|
5
|
+
import secrets
|
|
6
|
+
import subprocess
|
|
7
|
+
import tempfile
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
import requests
|
|
11
|
+
from web3 import Web3
|
|
12
|
+
|
|
13
|
+
REPO = pathlib.Path(__file__).resolve().parents[2]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@pytest.fixture(scope="session")
|
|
17
|
+
def env():
|
|
18
|
+
bin_path = pathlib.Path(tempfile.mkdtemp(prefix="wb-testenv-")) / "testenv"
|
|
19
|
+
subprocess.run(["go", "build", "-o", str(bin_path), "./cmd/testenv"], cwd=REPO, check=True, env={**os.environ, "CGO_ENABLED": "0"})
|
|
20
|
+
p = subprocess.Popen([str(bin_path)], stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True)
|
|
21
|
+
line = p.stdout.readline()
|
|
22
|
+
if not line:
|
|
23
|
+
raise RuntimeError(f"testenv exited ({p.wait()})")
|
|
24
|
+
yield json.loads(line)
|
|
25
|
+
p.stdin.close()
|
|
26
|
+
p.wait(timeout=10)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Tools:
|
|
30
|
+
def __init__(self, env):
|
|
31
|
+
self.env = env
|
|
32
|
+
self.w3 = Web3(Web3.HTTPProvider(env["rpc"]))
|
|
33
|
+
self.usdc = self.w3.eth.contract(
|
|
34
|
+
address=env["usdc"],
|
|
35
|
+
abi=[{"type": "function", "name": "balanceOf", "stateMutability": "view",
|
|
36
|
+
"inputs": [{"name": "", "type": "address"}], "outputs": [{"name": "", "type": "uint256"}]}],
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
def balance(self, who):
|
|
40
|
+
return self.usdc.functions.balanceOf(Web3.to_checksum_address(who)).call()
|
|
41
|
+
|
|
42
|
+
def warp(self, seconds):
|
|
43
|
+
self.w3.provider.make_request("evm_increaseTime", [seconds])
|
|
44
|
+
self.w3.provider.make_request("evm_mine", [])
|
|
45
|
+
|
|
46
|
+
def tap(self, approve):
|
|
47
|
+
"""Answers the oldest open Telegram question, like the owner tapping a button."""
|
|
48
|
+
r = requests.post(self.env["guardianUrl"] + "/test/answer", json={"approve": approve}, timeout=10)
|
|
49
|
+
r.raise_for_status()
|
|
50
|
+
return r.json()["reply"]
|
|
51
|
+
|
|
52
|
+
def questions(self):
|
|
53
|
+
return requests.get(self.env["guardianUrl"] + "/test/questions", timeout=10).json()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@pytest.fixture(scope="session")
|
|
57
|
+
def tools(env):
|
|
58
|
+
return Tools(env)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@pytest.fixture
|
|
62
|
+
def new_address():
|
|
63
|
+
return lambda: Web3.to_checksum_address("0x" + secrets.token_hex(20))
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""End to end against the real guardian (cmd/testenv: anvil, a vault that
|
|
2
|
+
pays only trusted addresses, and a stand-in for Telegram)."""
|
|
3
|
+
import threading
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from web3 import Web3
|
|
7
|
+
|
|
8
|
+
from walletbrake import (
|
|
9
|
+
RULE_OWNER_REJECTED,
|
|
10
|
+
DeniedError,
|
|
11
|
+
VaultError,
|
|
12
|
+
WalletBrake,
|
|
13
|
+
WalletBrakeError,
|
|
14
|
+
Task,
|
|
15
|
+
is_awaiting_approval,
|
|
16
|
+
payment_message,
|
|
17
|
+
task_message,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def usdc(n):
|
|
22
|
+
return int(n * 1_000_000)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@pytest.fixture(scope="session")
|
|
26
|
+
def wb(env):
|
|
27
|
+
return WalletBrake(env["guardianUrl"], env["operatorKey"], env["rpc"])
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def awaiting(wb, *args, **kw):
|
|
31
|
+
with pytest.raises(DeniedError) as e:
|
|
32
|
+
wb.pay(*args, **kw)
|
|
33
|
+
assert is_awaiting_approval(e.value), e.value
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_reads_the_vault(wb, env):
|
|
37
|
+
info = wb.info()
|
|
38
|
+
assert info["vault"] == Web3.to_checksum_address(env["vault"])
|
|
39
|
+
assert info["chainId"] == env["chainId"]
|
|
40
|
+
assert info["trustedOnly"] is True
|
|
41
|
+
coin = next(t for t in info["tokens"] if t["symbol"] == "USDC")
|
|
42
|
+
assert coin["decimals"] == 6 and coin["address"] == Web3.to_checksum_address(env["usdc"])
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_pays_a_trusted_address(wb, env, tools):
|
|
46
|
+
before = tools.balance(env["trusted"])
|
|
47
|
+
res = wb.pay(env["trusted"], "100.5")
|
|
48
|
+
assert res.queued is False and res.tx_hash.startswith("0x") and len(res.tx_hash) == 66
|
|
49
|
+
assert tools.balance(env["trusted"]) - before == 100_500_000
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_base_units_and_token_by_address(wb, env, tools):
|
|
53
|
+
before = tools.balance(env["trusted"])
|
|
54
|
+
wb.pay(env["trusted"], token=env["usdc"], amount_base_units=7)
|
|
55
|
+
assert tools.balance(env["trusted"]) - before == 7
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_untrusted_waits_for_owner_then_pays(wb, tools, new_address):
|
|
59
|
+
to = new_address()
|
|
60
|
+
asked = tools.questions()["asked"]
|
|
61
|
+
awaiting(wb, to, "25")
|
|
62
|
+
assert tools.questions()["asked"] == asked + 1
|
|
63
|
+
awaiting(wb, to, "25") # asking again while waiting doesn't ask twice
|
|
64
|
+
assert tools.questions()["asked"] == asked + 1
|
|
65
|
+
|
|
66
|
+
assert "Approved" in tools.tap(True)
|
|
67
|
+
assert wb.pay(to, "25").queued is False
|
|
68
|
+
assert tools.balance(to) == usdc(25)
|
|
69
|
+
|
|
70
|
+
awaiting(wb, to, "25") # one payment per approval
|
|
71
|
+
tools.tap(False)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_pay_and_wait_waits_for_the_tap(wb, tools, new_address):
|
|
75
|
+
to = new_address()
|
|
76
|
+
waited = []
|
|
77
|
+
|
|
78
|
+
def on_waiting(_err):
|
|
79
|
+
waited.append(1)
|
|
80
|
+
threading.Timer(0.3, tools.tap, args=(True,)).start()
|
|
81
|
+
|
|
82
|
+
res = wb.pay_and_wait(to, "30", poll=0.1, timeout=20, on_waiting=on_waiting)
|
|
83
|
+
assert waited == [1] and res.queued is False
|
|
84
|
+
assert tools.balance(to) == usdc(30)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_pay_and_wait_gives_up(wb, tools, new_address):
|
|
88
|
+
with pytest.raises(WalletBrakeError, match="didn't answer"):
|
|
89
|
+
wb.pay_and_wait(new_address(), "31", poll=0.05, timeout=0.3)
|
|
90
|
+
tools.tap(False)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_rejected_payment_is_refused(wb, tools, new_address):
|
|
94
|
+
to = new_address()
|
|
95
|
+
awaiting(wb, to, "40")
|
|
96
|
+
assert "Rejected" in tools.tap(False)
|
|
97
|
+
with pytest.raises(DeniedError) as e:
|
|
98
|
+
wb.pay(to, "40")
|
|
99
|
+
assert e.value.rule == RULE_OWNER_REJECTED
|
|
100
|
+
assert tools.balance(to) == 0
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_over_the_per_payment_limit(wb, env):
|
|
104
|
+
with pytest.raises(DeniedError) as e:
|
|
105
|
+
wb.pay(env["trusted"], "6000")
|
|
106
|
+
assert e.value.rule == "per-tx-cap" and e.value.verdict == "deny"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_wrong_key_is_turned_away(env):
|
|
110
|
+
other = WalletBrake(env["guardianUrl"], env["otherKey"], env["rpc"])
|
|
111
|
+
with pytest.raises(WalletBrakeError) as e:
|
|
112
|
+
other.pay(env["trusted"], "1")
|
|
113
|
+
assert e.value.status == 401 and not isinstance(e.value, DeniedError)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_never_sends_a_different_payment(env, tools, new_address):
|
|
117
|
+
import requests
|
|
118
|
+
|
|
119
|
+
thief = new_address()
|
|
120
|
+
|
|
121
|
+
class Lying(requests.Session):
|
|
122
|
+
def request(self, method, url, **kw):
|
|
123
|
+
r = super().request(method, url, **kw)
|
|
124
|
+
if url.endswith("/v1/approve") and r.ok:
|
|
125
|
+
body = r.json()
|
|
126
|
+
body["to"] = thief
|
|
127
|
+
r._content = __import__("json").dumps(body).encode()
|
|
128
|
+
return r
|
|
129
|
+
|
|
130
|
+
tricked = WalletBrake(env["guardianUrl"], env["operatorKey"], env["rpc"], session=Lying())
|
|
131
|
+
before = tools.balance(env["trusted"])
|
|
132
|
+
with pytest.raises(WalletBrakeError, match="doesn't match"):
|
|
133
|
+
tricked.pay(env["trusted"], "5")
|
|
134
|
+
assert tools.balance(thief) == 0 and tools.balance(env["trusted"]) == before
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@pytest.mark.parametrize(
|
|
138
|
+
"kw, err, match",
|
|
139
|
+
[
|
|
140
|
+
({"amount": "abc"}, ValueError, "not a number"),
|
|
141
|
+
({"amount": "0"}, ValueError, "more than 0"),
|
|
142
|
+
({"amount": "1.0000001"}, ValueError, "only 6 decimals"),
|
|
143
|
+
({"amount": 10}, TypeError, "string or Decimal"),
|
|
144
|
+
({"amount": 1.5}, TypeError, "string or Decimal"),
|
|
145
|
+
({"amount": "1", "token": "DOGE"}, ValueError, "isn't a coin this vault pays"),
|
|
146
|
+
({}, ValueError, "give amount"),
|
|
147
|
+
({"amount": "1", "amount_base_units": 1}, ValueError, "not both"),
|
|
148
|
+
],
|
|
149
|
+
)
|
|
150
|
+
def test_bad_input(wb, env, kw, err, match):
|
|
151
|
+
with pytest.raises(err, match=match):
|
|
152
|
+
wb.pay(env["trusted"], **kw)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def test_bad_address(wb):
|
|
156
|
+
with pytest.raises(ValueError, match="not an address"):
|
|
157
|
+
wb.pay("0x1234", "1")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def test_unreachable(env):
|
|
161
|
+
down = WalletBrake("http://127.0.0.1:1", env["operatorKey"], env["rpc"])
|
|
162
|
+
with pytest.raises(WalletBrakeError, match="unreachable"):
|
|
163
|
+
down.pay(env["trusted"], "1")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def test_payment_message_format():
|
|
167
|
+
assert payment_message(
|
|
168
|
+
vault="0xcf7ed3acca5a467e9e704c703e8d87f634fb0fc9",
|
|
169
|
+
chain_id=84532,
|
|
170
|
+
token="0x5fbdb2315678afecb367f032d93f642f64180aa3",
|
|
171
|
+
to="0x000000000000000000000000000000000000beef",
|
|
172
|
+
amount=10_000_000,
|
|
173
|
+
time=1700000000,
|
|
174
|
+
id="00112233445566778899aabbccddeeff",
|
|
175
|
+
) == (
|
|
176
|
+
"WalletBrake payment request\n\nVault: 0xCf7Ed3AccA5a467e9e704C703E8D87F634fB0Fc9\nChain ID: 84532\n"
|
|
177
|
+
"Token: 0x5FbDB2315678afecb367f032d93F642f64180aa3\nTo: 0x000000000000000000000000000000000000bEEF\n"
|
|
178
|
+
"Amount: 10000000\nTime: 1700000000\nRequest ID: 00112233445566778899aabbccddeeff"
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def test_task_caps_payments_even_to_trusted(wb, env, tools):
|
|
183
|
+
task = wb.open_task("Pay the March hosting invoice", "100", pay_only=[env["trusted"].lower()])
|
|
184
|
+
assert isinstance(task, Task) and task.id.startswith("t_") and task.budget == usdc(100)
|
|
185
|
+
before = tools.balance(env["trusted"])
|
|
186
|
+
wb.pay(env["trusted"], "60", task=task, note="hosting invoice 1042")
|
|
187
|
+
with pytest.raises(DeniedError) as e:
|
|
188
|
+
wb.pay(env["trusted"], "50", task=task.id)
|
|
189
|
+
assert e.value.rule == "task" and "budget" in e.value.reason
|
|
190
|
+
assert tools.balance(env["trusted"]) - before == usdc(60)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def test_payment_that_does_not_fit_its_task_asks_the_owner(wb, env, tools):
|
|
194
|
+
task = wb.open_task("Pay the March hosting invoice", "100")
|
|
195
|
+
asked = tools.questions()["asked"]
|
|
196
|
+
awaiting(wb, env["trusted"], "20", task=task, note="refund to customer")
|
|
197
|
+
q = tools.questions()
|
|
198
|
+
assert q["asked"] == asked + 1
|
|
199
|
+
assert q["last"] == {"task": "Pay the March hosting invoice", "note": "refund to customer", "concern": "a refund isn't part of this task", "trusted": True}
|
|
200
|
+
tools.tap(True)
|
|
201
|
+
before = tools.balance(env["trusted"])
|
|
202
|
+
wb.pay(env["trusted"], "20", task=task, note="refund to customer")
|
|
203
|
+
assert tools.balance(env["trusted"]) - before == usdc(20)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def test_bad_task_input(wb):
|
|
207
|
+
with pytest.raises(ValueError, match="more than 0"):
|
|
208
|
+
wb.open_task("x", "0")
|
|
209
|
+
with pytest.raises(ValueError, match="not an address"):
|
|
210
|
+
wb.open_task("x", "5", pay_only=["0x12"])
|
|
211
|
+
with pytest.raises(WalletBrakeError) as e:
|
|
212
|
+
wb.open_task("two\nlines", "5")
|
|
213
|
+
assert e.value.status == 400
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def test_task_message_format():
|
|
217
|
+
vault, token = "0xcf7ed3acca5a467e9e704c703e8d87f634fb0fc9", "0x5fbdb2315678afecb367f032d93f642f64180aa3"
|
|
218
|
+
assert task_message(
|
|
219
|
+
vault=vault, chain_id=84532, description="Buy API credits", token=token, budget=50_000_000,
|
|
220
|
+
pay_only=["0x000000000000000000000000000000000000beef"], minutes=60, time=1700000000, id="ab" * 16,
|
|
221
|
+
) == (
|
|
222
|
+
"WalletBrake task\n\nVault: 0xCf7Ed3AccA5a467e9e704C703E8D87F634fB0Fc9\nChain ID: 84532\nTask: Buy API credits\n"
|
|
223
|
+
"Token: 0x5FbDB2315678afecb367f032d93F642f64180aa3\nBudget: 50000000\nPay only: 0x000000000000000000000000000000000000bEEF\n"
|
|
224
|
+
"Open for: 60 minutes\nTime: 1700000000\nRequest ID: " + "ab" * 16
|
|
225
|
+
)
|
|
226
|
+
pay = payment_message(vault=vault, chain_id=84532, token=token, to=token, amount=1, time=1, id="x", task="t_1", note="credits")
|
|
227
|
+
assert pay.endswith("\nRequest ID: x\nTask: t_1\nNote: credits")
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def test_zz_held_payment_runs_after_the_delay(wb, env, tools, new_address):
|
|
231
|
+
"""Last (zz): it moves the chain clock forward."""
|
|
232
|
+
to = new_address()
|
|
233
|
+
awaiting(wb, to, "2000")
|
|
234
|
+
tools.tap(True)
|
|
235
|
+
res = wb.pay(to, "2000")
|
|
236
|
+
assert res.queued is True and isinstance(res.queue_id, int)
|
|
237
|
+
assert tools.balance(to) == 0
|
|
238
|
+
|
|
239
|
+
with pytest.raises(VaultError) as e:
|
|
240
|
+
wb.execute_queued(res.queue_id)
|
|
241
|
+
assert e.value.name == "NotReady"
|
|
242
|
+
tools.warp(env["recipientDelay"] + 1)
|
|
243
|
+
wb.execute_queued(res.queue_id)
|
|
244
|
+
assert tools.balance(to) == usdc(2000)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def test_lowercase_vault_url_is_checksummed():
|
|
248
|
+
key = "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d"
|
|
249
|
+
wb = WalletBrake("https://app.walletbrake.com/c/84532/v/0x976f718682c69bd2eb54f11370bc1d32df18318a/", key, "http://127.0.0.1:1")
|
|
250
|
+
assert wb.url == "https://app.walletbrake.com/c/84532/v/0x976F718682c69bD2eb54F11370BC1d32df18318a"
|
|
251
|
+
assert WalletBrake("http://127.0.0.1:8080", key, "http://127.0.0.1:1").url == "http://127.0.0.1:8080"
|