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.
@@ -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"