singit 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,75 @@
1
+ # macOS
2
+ .DS_Store
3
+
4
+ # Python
5
+ __pycache__/
6
+ *.py[cod]
7
+ .pytest_cache/
8
+ .mypy_cache/
9
+ .ruff_cache/
10
+ *.egg-info/
11
+
12
+ # Node
13
+ node_modules/
14
+
15
+ # Local virtual environments
16
+ .venv/
17
+ venv/
18
+ env/
19
+ payment-executor/.venv/
20
+
21
+ # Secrets and local runtime config
22
+ # Ledger key ring ciphertext. The encrypted master key is still the master
23
+ # key: anyone who has it and WALLET_PASS has every managed wallet.
24
+ *.enc
25
+ .env
26
+ *.env
27
+ !*.env.example
28
+ payment-executor/.env
29
+ sign402-gateway/.bitrefill-api-key
30
+ .env.wallet-*
31
+ !.env.wallet-*.example
32
+
33
+ # Demo runtime artifacts
34
+ .demo-logs/
35
+ .ledger-live/
36
+ .graph-live/
37
+ # Runtime data retained at its historical path; includes SQLite sidecar files.
38
+ /demo-dashboard/
39
+
40
+ # Generated presentation scratch workspace
41
+ outputs/
42
+ output/
43
+ tmp/
44
+ SingIt-backgrounds.zip
45
+ SingIt-presentation-assets/
46
+
47
+ # Generated x402 survey exports; keep handwritten notes and patches visible.
48
+ /out/*.csv
49
+ /out/x402-services-live.md
50
+
51
+ # Local brainstorming sessions and preview server state
52
+ /.superpowers/
53
+
54
+ # Local tooling and duplicated/scratch workspaces
55
+ .tools/
56
+ tools/*
57
+ # …except the Ledger approval signer, which is part of the product rather than
58
+ # scratch: the local client signs a gateway operation on the owner's device.
59
+ !tools/ledger-approve/
60
+ x402HackBerlin/
61
+ .worktrees/
62
+
63
+ # Draft writing artifacts
64
+ Sign402 X Longform Post.md
65
+
66
+ # Local editor files
67
+ .vscode/
68
+ .idea/
69
+ /.codex/
70
+
71
+ # …but not its installed dependencies.
72
+ tools/ledger-approve/node_modules/
73
+
74
+ # Solana runtime state (wallets and payment journals)
75
+ .local/
singit-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SingIt
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.
singit-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,145 @@
1
+ Metadata-Version: 2.5
2
+ Name: singit
3
+ Version: 0.1.0
4
+ Summary: SingIt SDK: x402 payment and client-side spending allowance for SingIt Ask.
5
+ Project-URL: Homepage, https://singitai.app/developers/
6
+ Project-URL: Repository, https://github.com/bubon-ik/SingItAI
7
+ Project-URL: Source, https://github.com/bubon-ik/SingItAI/tree/main/sdk/python
8
+ Project-URL: Issues, https://github.com/bubon-ik/SingItAI/issues
9
+ Author-email: SingIt <contact@singitai.app>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: ai-agents,base,singit,solana,usdc,x402
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3 :: Only
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
19
+ Requires-Python: >=3.10
20
+ Requires-Dist: x402[evm]<3,>=2.25.0
21
+ Provides-Extra: svm
22
+ Requires-Dist: x402[svm]<3,>=2.25.0; extra == 'svm'
23
+ Provides-Extra: test
24
+ Requires-Dist: pytest>=8; extra == 'test'
25
+ Requires-Dist: tomli>=2; (python_version < '3.11') and extra == 'test'
26
+ Description-Content-Type: text/markdown
27
+
28
+ # singit
29
+
30
+ x402 payment and client-side spending allowance for [SingIt Ask](../../singit-ask), in a few
31
+ lines. Limits are checked in your process before signing. The separate AgentAllowance
32
+ contract covers only the SingIt app's own agent; it does not protect other agents.
33
+
34
+ ## Install
35
+
36
+ ```bash
37
+ pip install singit # Base / Base Sepolia (EVM): upto + exact
38
+ pip install singit[svm] # adds Solana: exact only (see "What isn't supported yet")
39
+ ```
40
+
41
+ ## Base
42
+
43
+ ```python
44
+ import os
45
+ from singit import SingIt
46
+
47
+ singit = SingIt(
48
+ evm_private_key=os.environ["AGENT_PRIVATE_KEY"], # or evm_account=an eth_account LocalAccount
49
+ network="base",
50
+ max_per_call_usd=0.01,
51
+ max_per_day_usd=1,
52
+ )
53
+
54
+ answer = singit.chat(messages=[{"role": "user", "content": "Hello"}])
55
+ print(answer["choices"][0]["message"]["content"], answer["payment"])
56
+ ```
57
+
58
+ ## Solana (exact only)
59
+
60
+ ```python
61
+ import os
62
+ from singit import SingIt
63
+
64
+ singit = SingIt(
65
+ solana_signer=solana_signer, # an x402[svm] ClientSvmSigner; requires `pip install singit[svm]`
66
+ network="solana",
67
+ max_per_call_usd=0.01,
68
+ max_per_day_usd=1,
69
+ )
70
+
71
+ answer = singit.chat(messages=[{"role": "user", "content": "Hello"}], scheme="exact")
72
+ ```
73
+
74
+ ## Base Sepolia (testnet)
75
+
76
+ SingIt has no testnet deployment, so there is no pinned `pay_to` to default to: pass one
77
+ explicitly (e.g. your own test merchant, or a facilitator's own test receiver).
78
+
79
+ ```python
80
+ singit = SingIt(
81
+ evm_private_key=os.environ["AGENT_PRIVATE_KEY"], network="base-sepolia",
82
+ pay_to=os.environ["TEST_MERCHANT_PAY_TO"], # required: no SingIt deployment to pin a default against
83
+ max_per_call_usd=0.01, max_per_day_usd=1,
84
+ )
85
+ ```
86
+
87
+ ## Guards
88
+
89
+ Every call pins the merchant's offer before signing anything, the same way
90
+ `singit-ask/src/metered-buyer.mjs` and `solana-buyer.mjs` do for the service's own buyers:
91
+
92
+ - **Network/asset/payTo.** The offer's `network`, `asset` and `payTo` must exactly match this
93
+ `SingIt` instance's configured network (the built-in pin for `base`/`solana`, or the `pay_to`
94
+ you pass for `base-sepolia`). A merchant offering a different asset or recipient is refused
95
+ before anything is signed.
96
+ - **Per-call ceiling (`max_per_call_usd`).** The offer's atomic amount must be at or below
97
+ `max_per_call_usd` converted to micro-USDC. A costlier offer is refused.
98
+ - **Per-day ceiling (`max_per_day_usd`).** An in-memory counter per client instance and per UTC day.
99
+ Reserved at the offer's worst-case ceiling before signing, settled down to the actual billed
100
+ amount for `upto` only. `exact` always counts the full offer amount. **Not durable**: it resets on restart and is not shared across processes —
101
+ it is a client-side allowance guard, not a ledger. For the SingIt app agent’s separate on-chain limit, see
102
+ `allowance()` below.
103
+ - **Settlement receipt.** The `payment-response` header's network and payer are checked against
104
+ the request; a mismatch is treated as unresolved rather than returned as a success.
105
+
106
+ This SDK does **not** wait for on-chain confirmation of the settlement transaction;
107
+ `payment["txHash"]` is returned as reported by the merchant's `payment-response` header for the
108
+ caller to check independently if needed.
109
+
110
+ ## API
111
+
112
+ - `SingIt(...)` — `evm_private_key | evm_account` (EVM; `evm_account` is any `eth_account`
113
+ `LocalAccount`), `solana_signer` (an `x402[svm]` signer, for `network="solana"`), `network`,
114
+ `base_url` (default `https://ask.singitai.app`), `app_base_url` (default
115
+ `https://app.singitai.app`), `pay_to`, `rpc_url`, `max_per_call_usd`, `max_per_day_usd` (both
116
+ required).
117
+ - `chat(model=None, messages=..., max_tokens=None, scheme="upto", route="completions")` —
118
+ OpenAI-shaped chat completion. `scheme="upto"` (default) pays only the actual answer cost, up
119
+ to the offered ceiling, on EVM networks; `"exact"` pays that ceiling flat and is the only
120
+ scheme supported on Solana (see below). `route="completions"` (default) calls
121
+ `POST /v1/chat/completions`, live today. `route="per-model"` calls `POST /v1/chat/{model}`
122
+ (SI-62), live; `model` is then required.
123
+ - `wallet_report(address, days=None, api_key=None)` — `GET {app_base_url}/v1/wallet/{address}/reconciliation`
124
+ (SI-67). The report is private: `api_key` must be a SingIt API key of the account that signs in
125
+ with `address`; without one the server answers 401, and the SDK says so.
126
+ - `pricing()` — `GET {base_url}/pricing.json` (SI-68), live.
127
+ - `allowance(address, **kwargs)` — `wallet_report(address, **kwargs)["allowance"]`: the on-chain
128
+ spending limit set in the SingIt app, with what has been spent/remains today.
129
+
130
+ ## What isn't supported yet
131
+
132
+ - **Solana `upto` is not supported.** The official `x402` Python package (2.25.0) ships an EVM
133
+ `upto` client (`x402.mechanisms.evm.upto.client.UptoEvmScheme`) but has no Solana equivalent —
134
+ only `x402.mechanisms.svm.exact.client.ExactSvmScheme`. This corrects an earlier assumption
135
+ that Python might lack EVM `upto` too: it does not, Base/Base Sepolia support both schemes.
136
+ Solana support here is `exact` only, and calling `chat(scheme="upto", network="solana")` raises
137
+ clearly rather than silently falling back.
138
+ - No on-chain confirmation of the settlement transaction.
139
+ - The Solana `exact` client in the official package resolves a blockhash over a live RPC call
140
+ during payload construction, so it cannot be exercised fully offline; this SDK's own test suite
141
+ therefore only covers Solana at the offer-selection/guard level (see `tests/test_solana_offer.py`).
142
+ - `base-sepolia` is useful for integration-testing against a merchant you control; SingIt itself
143
+ has no testnet deployment to pay.
144
+
145
+ Framework fetch adapters are currently available only in the TypeScript SDK.
singit-0.1.0/README.md ADDED
@@ -0,0 +1,118 @@
1
+ # singit
2
+
3
+ x402 payment and client-side spending allowance for [SingIt Ask](../../singit-ask), in a few
4
+ lines. Limits are checked in your process before signing. The separate AgentAllowance
5
+ contract covers only the SingIt app's own agent; it does not protect other agents.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pip install singit # Base / Base Sepolia (EVM): upto + exact
11
+ pip install singit[svm] # adds Solana: exact only (see "What isn't supported yet")
12
+ ```
13
+
14
+ ## Base
15
+
16
+ ```python
17
+ import os
18
+ from singit import SingIt
19
+
20
+ singit = SingIt(
21
+ evm_private_key=os.environ["AGENT_PRIVATE_KEY"], # or evm_account=an eth_account LocalAccount
22
+ network="base",
23
+ max_per_call_usd=0.01,
24
+ max_per_day_usd=1,
25
+ )
26
+
27
+ answer = singit.chat(messages=[{"role": "user", "content": "Hello"}])
28
+ print(answer["choices"][0]["message"]["content"], answer["payment"])
29
+ ```
30
+
31
+ ## Solana (exact only)
32
+
33
+ ```python
34
+ import os
35
+ from singit import SingIt
36
+
37
+ singit = SingIt(
38
+ solana_signer=solana_signer, # an x402[svm] ClientSvmSigner; requires `pip install singit[svm]`
39
+ network="solana",
40
+ max_per_call_usd=0.01,
41
+ max_per_day_usd=1,
42
+ )
43
+
44
+ answer = singit.chat(messages=[{"role": "user", "content": "Hello"}], scheme="exact")
45
+ ```
46
+
47
+ ## Base Sepolia (testnet)
48
+
49
+ SingIt has no testnet deployment, so there is no pinned `pay_to` to default to: pass one
50
+ explicitly (e.g. your own test merchant, or a facilitator's own test receiver).
51
+
52
+ ```python
53
+ singit = SingIt(
54
+ evm_private_key=os.environ["AGENT_PRIVATE_KEY"], network="base-sepolia",
55
+ pay_to=os.environ["TEST_MERCHANT_PAY_TO"], # required: no SingIt deployment to pin a default against
56
+ max_per_call_usd=0.01, max_per_day_usd=1,
57
+ )
58
+ ```
59
+
60
+ ## Guards
61
+
62
+ Every call pins the merchant's offer before signing anything, the same way
63
+ `singit-ask/src/metered-buyer.mjs` and `solana-buyer.mjs` do for the service's own buyers:
64
+
65
+ - **Network/asset/payTo.** The offer's `network`, `asset` and `payTo` must exactly match this
66
+ `SingIt` instance's configured network (the built-in pin for `base`/`solana`, or the `pay_to`
67
+ you pass for `base-sepolia`). A merchant offering a different asset or recipient is refused
68
+ before anything is signed.
69
+ - **Per-call ceiling (`max_per_call_usd`).** The offer's atomic amount must be at or below
70
+ `max_per_call_usd` converted to micro-USDC. A costlier offer is refused.
71
+ - **Per-day ceiling (`max_per_day_usd`).** An in-memory counter per client instance and per UTC day.
72
+ Reserved at the offer's worst-case ceiling before signing, settled down to the actual billed
73
+ amount for `upto` only. `exact` always counts the full offer amount. **Not durable**: it resets on restart and is not shared across processes —
74
+ it is a client-side allowance guard, not a ledger. For the SingIt app agent’s separate on-chain limit, see
75
+ `allowance()` below.
76
+ - **Settlement receipt.** The `payment-response` header's network and payer are checked against
77
+ the request; a mismatch is treated as unresolved rather than returned as a success.
78
+
79
+ This SDK does **not** wait for on-chain confirmation of the settlement transaction;
80
+ `payment["txHash"]` is returned as reported by the merchant's `payment-response` header for the
81
+ caller to check independently if needed.
82
+
83
+ ## API
84
+
85
+ - `SingIt(...)` — `evm_private_key | evm_account` (EVM; `evm_account` is any `eth_account`
86
+ `LocalAccount`), `solana_signer` (an `x402[svm]` signer, for `network="solana"`), `network`,
87
+ `base_url` (default `https://ask.singitai.app`), `app_base_url` (default
88
+ `https://app.singitai.app`), `pay_to`, `rpc_url`, `max_per_call_usd`, `max_per_day_usd` (both
89
+ required).
90
+ - `chat(model=None, messages=..., max_tokens=None, scheme="upto", route="completions")` —
91
+ OpenAI-shaped chat completion. `scheme="upto"` (default) pays only the actual answer cost, up
92
+ to the offered ceiling, on EVM networks; `"exact"` pays that ceiling flat and is the only
93
+ scheme supported on Solana (see below). `route="completions"` (default) calls
94
+ `POST /v1/chat/completions`, live today. `route="per-model"` calls `POST /v1/chat/{model}`
95
+ (SI-62), live; `model` is then required.
96
+ - `wallet_report(address, days=None, api_key=None)` — `GET {app_base_url}/v1/wallet/{address}/reconciliation`
97
+ (SI-67). The report is private: `api_key` must be a SingIt API key of the account that signs in
98
+ with `address`; without one the server answers 401, and the SDK says so.
99
+ - `pricing()` — `GET {base_url}/pricing.json` (SI-68), live.
100
+ - `allowance(address, **kwargs)` — `wallet_report(address, **kwargs)["allowance"]`: the on-chain
101
+ spending limit set in the SingIt app, with what has been spent/remains today.
102
+
103
+ ## What isn't supported yet
104
+
105
+ - **Solana `upto` is not supported.** The official `x402` Python package (2.25.0) ships an EVM
106
+ `upto` client (`x402.mechanisms.evm.upto.client.UptoEvmScheme`) but has no Solana equivalent —
107
+ only `x402.mechanisms.svm.exact.client.ExactSvmScheme`. This corrects an earlier assumption
108
+ that Python might lack EVM `upto` too: it does not, Base/Base Sepolia support both schemes.
109
+ Solana support here is `exact` only, and calling `chat(scheme="upto", network="solana")` raises
110
+ clearly rather than silently falling back.
111
+ - No on-chain confirmation of the settlement transaction.
112
+ - The Solana `exact` client in the official package resolves a blockhash over a live RPC call
113
+ during payload construction, so it cannot be exercised fully offline; this SDK's own test suite
114
+ therefore only covers Solana at the offer-selection/guard level (see `tests/test_solana_offer.py`).
115
+ - `base-sepolia` is useful for integration-testing against a merchant you control; SingIt itself
116
+ has no testnet deployment to pay.
117
+
118
+ Framework fetch adapters are currently available only in the TypeScript SDK.
@@ -0,0 +1,47 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "singit"
7
+ version = "0.1.0"
8
+ description = "SingIt SDK: x402 payment and client-side spending allowance for SingIt Ask."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{name = "SingIt", email = "contact@singitai.app"}]
14
+ keywords = ["singit", "x402", "usdc", "base", "solana", "ai-agents"]
15
+ classifiers = [
16
+ "Development Status :: 3 - Alpha",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3 :: Only",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Topic :: Software Development :: Libraries :: Python Modules",
22
+ ]
23
+ dependencies = [
24
+ "x402[evm]>=2.25.0,<3",
25
+ ]
26
+
27
+ [project.optional-dependencies]
28
+ # Solana exact-scheme payments. The official x402 package has no Solana upto client yet (see README).
29
+ svm = [
30
+ "x402[svm]>=2.25.0,<3",
31
+ ]
32
+ test = [
33
+ "pytest>=8",
34
+ "tomli>=2; python_version < '3.11'",
35
+ ]
36
+
37
+ [tool.hatch.build.targets.wheel]
38
+ packages = ["src/singit"]
39
+
40
+ [project.urls]
41
+ Homepage = "https://singitai.app/developers/"
42
+ Repository = "https://github.com/bubon-ik/SingItAI"
43
+ Source = "https://github.com/bubon-ik/SingItAI/tree/main/sdk/python"
44
+ Issues = "https://github.com/bubon-ik/SingItAI/issues"
45
+
46
+ [tool.hatch.build.targets.sdist]
47
+ include = ["src/singit", "README.md", "LICENSE", "pyproject.toml"]
@@ -0,0 +1,5 @@
1
+ from .client import SingIt
2
+ from .guards import DailyLimiter, SingItPaymentError, select_offer
3
+ from .pins import NETWORKS
4
+
5
+ __all__ = ["SingIt", "SingItPaymentError", "DailyLimiter", "select_offer", "NETWORKS"]
@@ -0,0 +1,256 @@
1
+ """SingIt: x402 payment and client-side spending allowance for SingIt Ask, in a few lines::
2
+
3
+ singit = SingIt(evm_private_key=..., network="base", max_per_call_usd=0.01, max_per_day_usd=1)
4
+ answer = singit.chat(messages=[{"role": "user", "content": "Hello"}])
5
+
6
+ Every payment is checked against a pinned network/asset/payTo and a per-call and per-day USDC
7
+ ceiling before anything is signed; see guards.py. Nothing here ever raises a configured ceiling.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import urllib.error
14
+ import urllib.parse
15
+ import urllib.request
16
+
17
+ from .guards import DailyLimiter, SingItPaymentError, decode_header, encode_header, is_atomic_amount, select_offer, usd_to_atomic
18
+ from .pins import DEFAULT_APP_BASE_URL, DEFAULT_BASE_URL, NETWORKS, ROUTE, per_model_route
19
+
20
+
21
+ def _post_json(url: str, body: bytes, headers: dict, timeout: float = 90.0):
22
+ request = urllib.request.Request(url, data=body, headers={"Content-Type": "application/json", **headers}, method="POST")
23
+ try:
24
+ with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310 (merchant URL is caller-configured)
25
+ return response.status, response.headers, response.read()
26
+ except urllib.error.HTTPError as error:
27
+ return error.code, error.headers, error.read()
28
+
29
+
30
+ def _get_json(url: str, timeout: float = 30.0, headers: dict | None = None):
31
+ request = urllib.request.Request(url, method="GET", headers=headers or {})
32
+ try:
33
+ with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310
34
+ return response.status, response.read()
35
+ except urllib.error.HTTPError as error:
36
+ return error.code, error.read()
37
+
38
+
39
+ def _required_model(model: str | None) -> str:
40
+ if not model:
41
+ raise SingItPaymentError('model is required when route is "per-model".')
42
+ return model
43
+
44
+
45
+ def _payer_from_payload(payload: dict) -> str | None:
46
+ inner = payload.get("payload") or {}
47
+ auth = inner.get("permit2Authorization") or inner.get("authorization")
48
+ if auth:
49
+ return auth.get("from")
50
+ return inner.get("from")
51
+
52
+
53
+ def _same_payer(family: str, a: object, b: object) -> bool:
54
+ if not isinstance(a, str) or not isinstance(b, str):
55
+ return False
56
+ return a.lower() == b.lower() if family == "evm" else a == b
57
+
58
+
59
+ class SingIt:
60
+ def __init__(
61
+ self,
62
+ *,
63
+ evm_private_key: str | None = None,
64
+ evm_account: object | None = None,
65
+ solana_signer: object | None = None,
66
+ network: str,
67
+ base_url: str = DEFAULT_BASE_URL,
68
+ app_base_url: str = DEFAULT_APP_BASE_URL,
69
+ pay_to: str | None = None,
70
+ rpc_url: str | None = None,
71
+ max_per_call_usd: float | None = None,
72
+ max_per_day_usd: float | None = None,
73
+ ):
74
+ pin = NETWORKS.get(network)
75
+ if pin is None:
76
+ raise SingItPaymentError(f'Unknown network "{network}". Use "base", "base-sepolia" or "solana".')
77
+ if pin.pay_to is None and not pay_to:
78
+ raise SingItPaymentError(f"SingIt has no deployment on {network}; pass pay_to explicitly (see README).")
79
+ if max_per_call_usd is None or max_per_day_usd is None:
80
+ raise SingItPaymentError("max_per_call_usd and max_per_day_usd are required: this SDK never pays without a ceiling.")
81
+ self.network = network
82
+ self.pin = pin
83
+ self.base_url = base_url.rstrip("/")
84
+ self.app_base_url = app_base_url.rstrip("/")
85
+ self.pay_to = pay_to or pin.pay_to
86
+ self.rpc_url = rpc_url or pin.default_rpc_url
87
+ self.max_per_call_atomic = usd_to_atomic(max_per_call_usd)
88
+ self.limiter = DailyLimiter(usd_to_atomic(max_per_day_usd))
89
+ self._evm_account = None
90
+ if pin.family == "evm":
91
+ if evm_account is not None:
92
+ self._evm_account = evm_account
93
+ elif evm_private_key is not None:
94
+ from eth_account import Account
95
+
96
+ self._evm_account = Account.from_key(evm_private_key)
97
+ else:
98
+ raise SingItPaymentError("Pass evm_private_key or evm_account to pay on an EVM network.")
99
+ self._solana_signer = solana_signer
100
+
101
+ def _sign(self, scheme: str, offer: dict, extensions: dict | None) -> dict:
102
+ from x402.schemas import PaymentRequirements
103
+
104
+ requirements = PaymentRequirements.model_validate(offer)
105
+ if self.pin.family == "evm":
106
+ if scheme == "upto":
107
+ from x402.mechanisms.evm.upto.client import UptoEvmScheme as Scheme
108
+ else:
109
+ from x402.mechanisms.evm.exact.client import ExactEvmScheme as Scheme
110
+ inner = Scheme(self._evm_account).create_payment_payload(requirements, extensions=extensions)
111
+ payload_extensions = inner.pop("__extensions", None)
112
+ payload = {"x402Version": 2, "accepted": offer, "payload": inner}
113
+ if payload_extensions:
114
+ payload["extensions"] = payload_extensions
115
+ return payload
116
+ # Solana: the official x402 Python package has no `upto` SVM client yet (see README).
117
+ if scheme != "exact":
118
+ raise SingItPaymentError("The official x402 Python package has no Solana `upto` client yet; use scheme='exact'.")
119
+ if not self._solana_signer:
120
+ raise SingItPaymentError("Pass solana_signer (and install singit[svm]) to pay on Solana.")
121
+ from x402.mechanisms.svm.exact.client import ExactSvmScheme
122
+
123
+ inner = ExactSvmScheme(self._solana_signer, rpc_url=self.rpc_url).create_payment_payload(requirements)
124
+ return {"x402Version": 2, "accepted": offer, "payload": inner}
125
+
126
+ def chat(
127
+ self,
128
+ *,
129
+ model: str | None = None,
130
+ messages: list[dict],
131
+ max_tokens: int | None = None,
132
+ scheme: str = "upto",
133
+ route: str = "completions",
134
+ ) -> dict:
135
+ """One OpenAI-shaped chat completion, paid over x402.
136
+
137
+ `route="per-model"` calls POST /v1/chat/{model} (SI-62), which is live;
138
+ the default calls POST /v1/chat/completions, which is live today.
139
+ """
140
+ if not messages:
141
+ raise SingItPaymentError("messages must be a non-empty list.")
142
+ if scheme not in ("upto", "exact"):
143
+ raise SingItPaymentError('scheme must be "upto" or "exact".')
144
+ if route not in ("completions", "per-model"):
145
+ raise SingItPaymentError('route must be "completions" or "per-model".')
146
+ path = per_model_route(_required_model(model)) if route == "per-model" else ROUTE
147
+ url = f"{self.base_url}{path}"
148
+ body = {"messages": messages, "stream": False}
149
+ if max_tokens is not None:
150
+ body["max_tokens"] = max_tokens
151
+ if model and route == "completions":
152
+ body["model"] = model
153
+ encoded = json.dumps(body).encode("utf-8")
154
+
155
+ status, headers, raw = _post_json(url, encoded, {})
156
+ if status != 402:
157
+ if status >= 400:
158
+ raise SingItPaymentError(f"Request failed with HTTP {status}: {raw[:300]!r}")
159
+ return {**json.loads(raw), "payment": None}
160
+
161
+ header = headers.get("payment-required")
162
+ if not header:
163
+ raise SingItPaymentError("Got HTTP 402 with no PAYMENT-REQUIRED header.")
164
+ challenge = decode_header(header)
165
+ offer = select_offer(
166
+ challenge,
167
+ scheme=scheme,
168
+ network_id=self.pin.caip,
169
+ family=self.pin.family,
170
+ asset=self.pin.asset,
171
+ pay_to=self.pay_to,
172
+ max_cap_atomic=self.max_per_call_atomic,
173
+ resource_url=url,
174
+ )
175
+
176
+ release = self.limiter.reserve(offer["amount"])
177
+ try:
178
+ payload = self._sign(scheme, offer, challenge.get("extensions"))
179
+ except Exception:
180
+ release()
181
+ raise
182
+
183
+ # Once a signed payment has left, only a 402 (payment refused) proves nothing settled. A dropped connection,
184
+ # another status or an unreadable invoice may still have settled, so the worst-case reservation stays
185
+ # counted against today's cap (Codex review).
186
+ try:
187
+ status, headers, raw = _post_json(url, encoded, {"PAYMENT-SIGNATURE": encode_header(payload)})
188
+ except Exception as error:
189
+ raise SingItPaymentError(
190
+ f"The paid request failed after the payment was sent ({error}); it was not repeated and its "
191
+ "ceiling stays counted against the daily cap.") from error
192
+ if status == 402:
193
+ release()
194
+ raise SingItPaymentError("The merchant refused the payment (HTTP 402); nothing was paid and it was not repeated.")
195
+ if status != 200:
196
+ raise SingItPaymentError(
197
+ f"Paid request returned HTTP {status}; it was not repeated, and its ceiling stays counted against "
198
+ "the daily cap in case it settled.")
199
+ try:
200
+ result = json.loads(raw)
201
+ except ValueError:
202
+ result = {}
203
+ total_atomic = ((result or {}).get("billing") or {}).get("totalAtomic")
204
+ if (not is_atomic_amount(total_atomic) or int(total_atomic) > int(offer["amount"])
205
+ or (scheme == "exact" and int(total_atomic) != int(offer["amount"]))):
206
+ raise SingItPaymentError("The invoice does not match the offered ceiling; treating the payment as unresolved "
207
+ "(its ceiling stays counted against the daily cap).")
208
+
209
+ receipt_header = headers.get("payment-response")
210
+ receipt = decode_header(receipt_header) if receipt_header else None
211
+ payer = _payer_from_payload(payload)
212
+ if receipt and (
213
+ receipt.get("network") != self.pin.caip
214
+ or (payer and receipt.get("payer") and not _same_payer(self.pin.family, receipt["payer"], payer))
215
+ ):
216
+ raise SingItPaymentError("The settlement receipt does not match this request; treating the payment as unresolved.")
217
+
218
+ if scheme == "upto":
219
+ self.limiter.settle(release, total_atomic, offer["amount"])
220
+
221
+ payment_info = {"scheme": scheme, "network": self.network, "amountAtomic": total_atomic}
222
+ if receipt and receipt.get("transaction"):
223
+ payment_info["txHash"] = receipt["transaction"]
224
+ return {**result, "payment": payment_info}
225
+
226
+ def wallet_report(self, address: str, *, days: int | None = None, api_key: str | None = None) -> dict:
227
+ """GET /v1/wallet/{address}/reconciliation on app_base_url (SI-67). The report is private: pass an
228
+ API key (sk_singit_...) of the SingIt account that signs in with `address`."""
229
+ qs = f"?days={days}" if days is not None else ""
230
+ url = f"{self.app_base_url}/v1/wallet/{urllib.parse.quote(address)}/reconciliation{qs}"
231
+ status, raw = _get_json(url, headers={"Authorization": f"Bearer {api_key}"} if api_key else None)
232
+ if status in (401, 403):
233
+ raise SingItPaymentError(
234
+ f"The wallet report is private (HTTP {status}): pass api_key, a SingIt API key of the account "
235
+ "that signs in with this wallet."
236
+ )
237
+ if status >= 400:
238
+ raise SingItPaymentError(
239
+ f"Wallet reconciliation is not available (HTTP {status})."
240
+ )
241
+ return json.loads(raw)
242
+
243
+ def pricing(self) -> dict:
244
+ """GET /pricing.json on base_url (SI-68; live)."""
245
+ url = f"{self.base_url}/pricing.json"
246
+ status, raw = _get_json(url)
247
+ if status >= 400:
248
+ raise SingItPaymentError(
249
+ f"pricing.json is not available (HTTP {status})."
250
+ )
251
+ return json.loads(raw)
252
+
253
+ def allowance(self, address: str, **kwargs) -> object:
254
+ """The on-chain spending limit set in the SingIt app for `address`, with today's spent/remaining."""
255
+ report = self.wallet_report(address, **kwargs)
256
+ return report.get("allowance") if isinstance(report, dict) else None
@@ -0,0 +1,144 @@
1
+ """Client-side allowance guards: pin the merchant's offer (network, asset, payTo) before signing,
2
+ cap a single call, and cap a rolling day of calls. Modeled on singit-ask's own buyers
3
+ (src/metered-buyer.mjs, src/solana-buyer.mjs): same idea, generalized to any x402 merchant.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import base64
9
+ import json
10
+ import re
11
+ from datetime import datetime, timezone
12
+
13
+ _ATOMIC_RE = re.compile(r"^[1-9][0-9]{0,27}$")
14
+
15
+
16
+ class SingItPaymentError(Exception):
17
+ pass
18
+
19
+
20
+ def is_atomic_amount(value: object) -> bool:
21
+ return isinstance(value, str) and bool(_ATOMIC_RE.match(value))
22
+
23
+
24
+ def _same_evm_address(a: object, b: object) -> bool:
25
+ return isinstance(a, str) and isinstance(b, str) and a.lower() == b.lower()
26
+
27
+
28
+ def _same_svm_address(a: object, b: object) -> bool:
29
+ return isinstance(a, str) and isinstance(b, str) and a == b
30
+
31
+
32
+ def decode_header(value: str) -> dict:
33
+ try:
34
+ return json.loads(base64.b64decode(value).decode("utf-8"))
35
+ except Exception as error:
36
+ raise SingItPaymentError("The payment header could not be decoded.") from error
37
+
38
+
39
+ def encode_header(value: dict) -> str:
40
+ return base64.b64encode(json.dumps(value).encode("utf-8")).decode("ascii")
41
+
42
+
43
+ def select_offer(
44
+ challenge: dict,
45
+ *,
46
+ scheme: str,
47
+ network_id: str,
48
+ family: str,
49
+ asset: str,
50
+ pay_to: str,
51
+ max_cap_atomic: str,
52
+ resource_url: str | None = None,
53
+ ) -> dict:
54
+ """Picks the single offer matching scheme+network, then refuses to sign unless its asset
55
+ and payTo are exactly the ones this SingIt instance was configured to pay, and its amount is
56
+ within the per-call ceiling. Nothing here ever raises the ceiling: a merchant asking for more
57
+ than `max_cap_atomic` is refused, never renegotiated.
58
+ """
59
+ if not challenge or challenge.get("x402Version") != 2:
60
+ raise SingItPaymentError("Unexpected x402 version in the payment challenge.")
61
+ if resource_url and (challenge.get("resource") or {}).get("url") != resource_url:
62
+ raise SingItPaymentError("The challenge names a different resource than the one called.")
63
+ offers = [a for a in (challenge.get("accepts") or []) if a.get("scheme") == scheme and a.get("network") == network_id]
64
+ if len(offers) != 1:
65
+ raise SingItPaymentError(f"Expected exactly one {scheme} offer on {network_id}; got {len(offers)}.")
66
+ offer = offers[0]
67
+ same_address = _same_evm_address if family == "evm" else _same_svm_address
68
+ if not same_address(offer.get("asset"), asset):
69
+ raise SingItPaymentError(f"Unexpected payment asset ({offer.get('asset')}); refusing to sign.")
70
+ if not same_address(offer.get("payTo"), pay_to):
71
+ raise SingItPaymentError(f"Unexpected payee ({offer.get('payTo')}); refusing to sign.")
72
+ amount = offer.get("amount")
73
+ if not is_atomic_amount(amount) or not is_atomic_amount(max_cap_atomic) or int(amount) > int(max_cap_atomic):
74
+ raise SingItPaymentError(f"Offer amount {amount} exceeds the configured per-call ceiling ({max_cap_atomic}).")
75
+ timeout = offer.get("maxTimeoutSeconds")
76
+ if not isinstance(timeout, int) or isinstance(timeout, bool) or timeout < 1 or timeout > 600:
77
+ raise SingItPaymentError("Unexpected payment timeout window in the offer.")
78
+ if family == "evm" and offer.get("scheme") == "upto":
79
+ facilitator = (offer.get("extra") or {}).get("facilitatorAddress", "")
80
+ if not re.match(r"^0x[0-9a-fA-F]{40}$", facilitator or "") or re.match(r"^0x0{40}$", facilitator, re.IGNORECASE):
81
+ raise SingItPaymentError("The offer has no usable facilitator address for the upto scheme.")
82
+ return offer
83
+
84
+
85
+ class DailyLimiter:
86
+ """A simple, in-memory, per-UTC-day spend counter. Not durable (lost on restart) and not
87
+ shared across processes: a client-side allowance guard, not a ledger. Checked against the
88
+ offer's worst-case amount before signing, then adjusted down to the amount actually billed
89
+ afterwards, so an `upto` request that settles for less than its ceiling frees the unused
90
+ headroom immediately.
91
+ """
92
+
93
+ def __init__(self, cap_atomic: str):
94
+ self.cap_atomic = int(cap_atomic)
95
+ self._day: str | None = None
96
+ self._spent_atomic = 0
97
+
98
+ def _roll_if_needed(self) -> None:
99
+ today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
100
+ if today != self._day:
101
+ self._day = today
102
+ self._spent_atomic = 0
103
+
104
+ def remaining_atomic(self) -> int:
105
+ self._roll_if_needed()
106
+ return max(self.cap_atomic - self._spent_atomic, 0)
107
+
108
+ def reserve(self, worst_case_atomic: str):
109
+ self._roll_if_needed()
110
+ amount = int(worst_case_atomic)
111
+ if self._spent_atomic + amount > self.cap_atomic:
112
+ raise SingItPaymentError(
113
+ f"Daily spend cap reached: {self._spent_atomic} already spent today, "
114
+ f"{amount} more would exceed {self.cap_atomic} micro-USDC."
115
+ )
116
+ self._spent_atomic += amount
117
+ # A release only ever touches the day it was reserved on: a request spanning UTC midnight must not take
118
+ # yesterday's reservation off today's counter (Codex review).
119
+ day = self._day
120
+ state = {"done": False}
121
+
122
+ def release():
123
+ if state["done"]:
124
+ return
125
+ state["done"] = True
126
+ self._roll_if_needed()
127
+ if self._day == day:
128
+ self._spent_atomic -= amount
129
+
130
+ release.day = day
131
+ return release
132
+
133
+ def settle(self, release, actual_atomic: str | None, worst_case_atomic: str) -> None:
134
+ self._roll_if_needed()
135
+ if getattr(release, "day", self._day) != self._day:
136
+ return # yesterday's spend; today's counter starts clean
137
+ release()
138
+ self._spent_atomic += int(actual_atomic if actual_atomic is not None else worst_case_atomic)
139
+
140
+
141
+ def usd_to_atomic(usd: float) -> str:
142
+ if not isinstance(usd, (int, float)) or isinstance(usd, bool) or usd < 0:
143
+ raise SingItPaymentError("max_per_call_usd/max_per_day_usd must be a non-negative number.")
144
+ return str(round(usd * 1_000_000))
@@ -0,0 +1,56 @@
1
+ """Pinned, per-network expectations an x402 offer must match before the SDK signs anything.
2
+
3
+ Base and Solana values are the live SingIt Ask addresses (singit-ask/src/pricing.mjs).
4
+ Base Sepolia has no SingIt deployment: its USDC address is the well-known testnet asset, and
5
+ `pay_to` is never pinned here -- the caller must pass one explicitly (SingIt(pay_to=...)).
6
+ """
7
+
8
+ from dataclasses import dataclass
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class NetworkPin:
13
+ caip: str
14
+ family: str # "evm" | "svm"
15
+ asset: str
16
+ pay_to: str | None
17
+ asset_name: str = "USDC"
18
+ asset_version: str = "2"
19
+ default_rpc_url: str | None = None
20
+
21
+
22
+ NETWORKS: dict[str, NetworkPin] = {
23
+ "base": NetworkPin(
24
+ caip="eip155:8453",
25
+ family="evm",
26
+ asset="0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
27
+ pay_to="0xC23d1Dc0f5fCe1abfFB051e06cB93f0329968B4e",
28
+ asset_name="USD Coin",
29
+ asset_version="2",
30
+ default_rpc_url="https://mainnet.base.org",
31
+ ),
32
+ "base-sepolia": NetworkPin(
33
+ caip="eip155:84532",
34
+ family="evm",
35
+ asset="0x036CbD53842c5426634e7929541eC2318f3dCF7e",
36
+ pay_to=None,
37
+ asset_name="USDC",
38
+ asset_version="2",
39
+ default_rpc_url="https://sepolia.base.org",
40
+ ),
41
+ "solana": NetworkPin(
42
+ caip="solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp",
43
+ family="svm",
44
+ asset="EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
45
+ pay_to="4an2sqamWWhny9mjLsMtGXCDXakeNtg6vSLq4QvhdmQu",
46
+ default_rpc_url="https://api.mainnet-beta.solana.com",
47
+ ),
48
+ }
49
+
50
+ DEFAULT_BASE_URL = "https://ask.singitai.app"
51
+ DEFAULT_APP_BASE_URL = "https://app.singitai.app"
52
+ ROUTE = "/v1/chat/completions"
53
+
54
+
55
+ def per_model_route(model: str) -> str:
56
+ return f"/v1/chat/{model}"