open-agentpay 0.1.0__py3-none-any.whl
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.
- agentpay/__init__.py +45 -0
- agentpay/budget/__init__.py +37 -0
- agentpay/client/__init__.py +41 -0
- agentpay/client/client.py +109 -0
- agentpay/client/errors.py +28 -0
- agentpay/client/interfaces.py +53 -0
- agentpay/client/signer.py +340 -0
- agentpay/client/transport.py +223 -0
- agentpay/core/__init__.py +73 -0
- agentpay/core/codecs.py +237 -0
- agentpay/core/errors.py +70 -0
- agentpay/core/interop.py +69 -0
- agentpay/core/models.py +462 -0
- agentpay/core/state.py +161 -0
- agentpay/evm/__init__.py +79 -0
- agentpay/evm/constants.py +118 -0
- agentpay/evm/eip3009.py +194 -0
- agentpay/evm/rpc.py +291 -0
- agentpay/integrations/__init__.py +10 -0
- agentpay/integrations/langchain/__init__.py +20 -0
- agentpay/integrations/langchain/http.py +174 -0
- agentpay/integrations/langchain/tool.py +210 -0
- agentpay/policy/__init__.py +39 -0
- agentpay/policy/config.py +118 -0
- agentpay/policy/db.py +128 -0
- agentpay/policy/errors.py +65 -0
- agentpay/policy/evaluator.py +114 -0
- agentpay/policy/interfaces.py +109 -0
- agentpay/policy/models.py +343 -0
- agentpay/policy/sqlite_engine.py +622 -0
- agentpay/protocol/__init__.py +73 -0
- agentpay/protocol/codecs.py +237 -0
- agentpay/protocol/errors.py +70 -0
- agentpay/protocol/interop.py +69 -0
- agentpay/protocol/models.py +462 -0
- agentpay/protocol/state.py +161 -0
- agentpay/signer/__init__.py +39 -0
- agentpay/state/__init__.py +65 -0
- agentpay/state/buyer_coordinator.py +537 -0
- agentpay/state/errors.py +104 -0
- agentpay/state/guards.py +344 -0
- agentpay/state/reconciliation.py +422 -0
- agentpay/testing/__init__.py +17 -0
- agentpay/testing/mock_seller.py +296 -0
- open_agentpay-0.1.0.dist-info/METADATA +101 -0
- open_agentpay-0.1.0.dist-info/RECORD +47 -0
- open_agentpay-0.1.0.dist-info/WHEEL +4 -0
agentpay/__init__.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AgentPay Python SDK
|
|
3
|
+
The Open-Source Payment Infrastructure & Financial Firewall for Autonomous AI Agents.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
__version__ = "0.1.0"
|
|
7
|
+
|
|
8
|
+
from agentpay.client import (
|
|
9
|
+
AgentPayClient,
|
|
10
|
+
AgentPayTransport,
|
|
11
|
+
AsyncAgentPayClient,
|
|
12
|
+
AsyncAgentPayTransport,
|
|
13
|
+
ISigner,
|
|
14
|
+
LocalSigner,
|
|
15
|
+
)
|
|
16
|
+
from agentpay.core import (
|
|
17
|
+
HEADER_PAYMENT_REQUIRED,
|
|
18
|
+
HEADER_PAYMENT_SIGNATURE,
|
|
19
|
+
PaymentAuthorization,
|
|
20
|
+
PaymentChallenge,
|
|
21
|
+
SignatureData,
|
|
22
|
+
decode_authorization,
|
|
23
|
+
decode_challenge,
|
|
24
|
+
encode_authorization,
|
|
25
|
+
encode_challenge,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"HEADER_PAYMENT_REQUIRED",
|
|
30
|
+
"HEADER_PAYMENT_SIGNATURE",
|
|
31
|
+
"AgentPayClient",
|
|
32
|
+
"AgentPayTransport",
|
|
33
|
+
"AsyncAgentPayClient",
|
|
34
|
+
"AsyncAgentPayTransport",
|
|
35
|
+
"ISigner",
|
|
36
|
+
"LocalSigner",
|
|
37
|
+
"PaymentAuthorization",
|
|
38
|
+
"PaymentChallenge",
|
|
39
|
+
"SignatureData",
|
|
40
|
+
"__version__",
|
|
41
|
+
"decode_authorization",
|
|
42
|
+
"decode_challenge",
|
|
43
|
+
"encode_authorization",
|
|
44
|
+
"encode_challenge",
|
|
45
|
+
]
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AgentPay Budget sub-package.
|
|
3
|
+
|
|
4
|
+
Exposes budget models, ledger records, and reservation tokens for buyer spending limits.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from agentpay.core.state import BudgetLedgerState
|
|
10
|
+
from agentpay.policy.models import (
|
|
11
|
+
BreakerState,
|
|
12
|
+
LedgerEntryState,
|
|
13
|
+
PaymentRequest,
|
|
14
|
+
PolicyConfig,
|
|
15
|
+
PolicyDecision,
|
|
16
|
+
ReservationToken,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
# Backward compatibility / convenient domain aliases
|
|
20
|
+
BudgetReservation = ReservationToken
|
|
21
|
+
LedgerEntry = LedgerEntryState
|
|
22
|
+
PolicyEvaluationResult = PolicyDecision
|
|
23
|
+
PolicyStatus = BreakerState
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"BreakerState",
|
|
27
|
+
"BudgetLedgerState",
|
|
28
|
+
"BudgetReservation",
|
|
29
|
+
"LedgerEntry",
|
|
30
|
+
"LedgerEntryState",
|
|
31
|
+
"PaymentRequest",
|
|
32
|
+
"PolicyConfig",
|
|
33
|
+
"PolicyDecision",
|
|
34
|
+
"PolicyEvaluationResult",
|
|
35
|
+
"PolicyStatus",
|
|
36
|
+
"ReservationToken",
|
|
37
|
+
]
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AgentPay Client SDK and Signers.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from agentpay.client.client import AgentPayClient, AsyncAgentPayClient
|
|
8
|
+
from agentpay.client.errors import (
|
|
9
|
+
ClientError,
|
|
10
|
+
InvalidPrivateKeyError,
|
|
11
|
+
ReservationMismatchError,
|
|
12
|
+
SignerError,
|
|
13
|
+
UnsupportedNetworkError,
|
|
14
|
+
)
|
|
15
|
+
from agentpay.client.interfaces import ISigner
|
|
16
|
+
from agentpay.client.signer import (
|
|
17
|
+
DEFAULT_TOKEN_NAMES,
|
|
18
|
+
EIP3009_MESSAGE_TYPES,
|
|
19
|
+
LocalSigner,
|
|
20
|
+
generate_nonce,
|
|
21
|
+
parse_caip2_chain_id,
|
|
22
|
+
)
|
|
23
|
+
from agentpay.client.transport import AgentPayTransport, AsyncAgentPayTransport
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"DEFAULT_TOKEN_NAMES",
|
|
27
|
+
"EIP3009_MESSAGE_TYPES",
|
|
28
|
+
"AgentPayClient",
|
|
29
|
+
"AgentPayTransport",
|
|
30
|
+
"AsyncAgentPayClient",
|
|
31
|
+
"AsyncAgentPayTransport",
|
|
32
|
+
"ClientError",
|
|
33
|
+
"ISigner",
|
|
34
|
+
"InvalidPrivateKeyError",
|
|
35
|
+
"LocalSigner",
|
|
36
|
+
"ReservationMismatchError",
|
|
37
|
+
"SignerError",
|
|
38
|
+
"UnsupportedNetworkError",
|
|
39
|
+
"generate_nonce",
|
|
40
|
+
"parse_caip2_chain_id",
|
|
41
|
+
]
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AgentPayClient: Drop-in httpx.Client with automatic 402 payment negotiation.
|
|
3
|
+
|
|
4
|
+
Wraps the financial firewall and EIP-712 cryptographic signer to automatically
|
|
5
|
+
satisfy HTTP 402 Payment Required challenges.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from agentpay.client.interfaces import ISigner
|
|
16
|
+
from agentpay.client.transport import AgentPayTransport, AsyncAgentPayTransport
|
|
17
|
+
from agentpay.policy.interfaces import IPolicyEngine
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AgentPayClient(httpx.Client):
|
|
21
|
+
"""
|
|
22
|
+
HTTP client for autonomous AI agents with integrated financial firewall.
|
|
23
|
+
|
|
24
|
+
Inherits from httpx.Client: fully compatible with LangChain, LlamaIndex,
|
|
25
|
+
and standard Python HTTP toolchains.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
signer: ISigner,
|
|
31
|
+
policy: IPolicyEngine,
|
|
32
|
+
*,
|
|
33
|
+
transport: httpx.BaseTransport | None = None,
|
|
34
|
+
auto_retry_on_402: bool = True,
|
|
35
|
+
raise_on_policy_rejection: bool = False,
|
|
36
|
+
clock: Callable[[], int] | None = None,
|
|
37
|
+
**kwargs: Any,
|
|
38
|
+
) -> None:
|
|
39
|
+
self.signer = signer
|
|
40
|
+
self.policy = policy
|
|
41
|
+
self.auto_retry_on_402 = auto_retry_on_402
|
|
42
|
+
self.raise_on_policy_rejection = raise_on_policy_rejection
|
|
43
|
+
self._clock = clock
|
|
44
|
+
|
|
45
|
+
inner = transport or httpx.HTTPTransport()
|
|
46
|
+
agent_transport = AgentPayTransport(
|
|
47
|
+
inner_transport=inner,
|
|
48
|
+
signer=signer,
|
|
49
|
+
policy=policy,
|
|
50
|
+
auto_retry_on_402=auto_retry_on_402,
|
|
51
|
+
raise_on_policy_rejection=raise_on_policy_rejection,
|
|
52
|
+
clock=clock,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
super().__init__(transport=agent_transport, **kwargs)
|
|
56
|
+
|
|
57
|
+
def async_client(
|
|
58
|
+
self,
|
|
59
|
+
*,
|
|
60
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
61
|
+
**kwargs: Any,
|
|
62
|
+
) -> AsyncAgentPayClient:
|
|
63
|
+
"""Create an asynchronous AsyncAgentPayClient sharing the same signer and policy."""
|
|
64
|
+
return AsyncAgentPayClient(
|
|
65
|
+
signer=self.signer,
|
|
66
|
+
policy=self.policy,
|
|
67
|
+
transport=transport,
|
|
68
|
+
auto_retry_on_402=self.auto_retry_on_402,
|
|
69
|
+
raise_on_policy_rejection=self.raise_on_policy_rejection,
|
|
70
|
+
clock=self._clock,
|
|
71
|
+
**kwargs,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class AsyncAgentPayClient(httpx.AsyncClient):
|
|
76
|
+
"""
|
|
77
|
+
Asynchronous HTTP client for autonomous AI agents with integrated financial firewall.
|
|
78
|
+
|
|
79
|
+
Inherits from httpx.AsyncClient.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
def __init__(
|
|
83
|
+
self,
|
|
84
|
+
signer: ISigner,
|
|
85
|
+
policy: IPolicyEngine,
|
|
86
|
+
*,
|
|
87
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
88
|
+
auto_retry_on_402: bool = True,
|
|
89
|
+
raise_on_policy_rejection: bool = False,
|
|
90
|
+
clock: Callable[[], int] | None = None,
|
|
91
|
+
**kwargs: Any,
|
|
92
|
+
) -> None:
|
|
93
|
+
self.signer = signer
|
|
94
|
+
self.policy = policy
|
|
95
|
+
self.auto_retry_on_402 = auto_retry_on_402
|
|
96
|
+
self.raise_on_policy_rejection = raise_on_policy_rejection
|
|
97
|
+
self._clock = clock
|
|
98
|
+
|
|
99
|
+
inner = transport or httpx.AsyncHTTPTransport()
|
|
100
|
+
agent_transport = AsyncAgentPayTransport(
|
|
101
|
+
inner_transport=inner,
|
|
102
|
+
signer=signer,
|
|
103
|
+
policy=policy,
|
|
104
|
+
auto_retry_on_402=auto_retry_on_402,
|
|
105
|
+
raise_on_policy_rejection=raise_on_policy_rejection,
|
|
106
|
+
clock=clock,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
super().__init__(transport=agent_transport, **kwargs)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Signer and Client Exceptions for AgentPay.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ClientError(Exception):
|
|
9
|
+
"""Base exception for all AgentPay client and signer operations."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SignerError(ClientError):
|
|
13
|
+
"""Base exception for cryptographic signing errors."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class InvalidPrivateKeyError(SignerError):
|
|
17
|
+
"""Raised when an invalid EVM private key is provided."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class UnsupportedNetworkError(SignerError):
|
|
21
|
+
"""Raised when a challenge specifies a network unsupported by the signer (e.g. non-EIP155)."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ReservationMismatchError(SignerError):
|
|
25
|
+
"""
|
|
26
|
+
Raised when an approved ReservationToken does not match the PaymentChallenge.
|
|
27
|
+
Critical financial firewall guardrail violation.
|
|
28
|
+
"""
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Signer interface protocols for AgentPay.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from typing import Protocol, runtime_checkable
|
|
8
|
+
|
|
9
|
+
from agentpay.core.models import PaymentAuthorization, PaymentChallenge
|
|
10
|
+
from agentpay.policy.models import ReservationToken
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@runtime_checkable
|
|
14
|
+
class ISigner(Protocol):
|
|
15
|
+
"""
|
|
16
|
+
Protocol for signing agents and cryptographic keystores.
|
|
17
|
+
Enforces firewall reservation binding before signing.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def address(self) -> str:
|
|
22
|
+
"""The canonical lowercase EVM address of the signing wallet."""
|
|
23
|
+
...
|
|
24
|
+
|
|
25
|
+
def sign_payment_authorization(
|
|
26
|
+
self,
|
|
27
|
+
challenge: PaymentChallenge,
|
|
28
|
+
reservation: ReservationToken,
|
|
29
|
+
*,
|
|
30
|
+
nonce: str | None = None,
|
|
31
|
+
token_name: str | None = None,
|
|
32
|
+
token_version: str = "2",
|
|
33
|
+
) -> PaymentAuthorization:
|
|
34
|
+
"""
|
|
35
|
+
Sign an EIP-712/3009 transfer authorization for the given challenge.
|
|
36
|
+
|
|
37
|
+
MUST verify that the reservation matches the challenge before signing.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
challenge: The PaymentChallenge from the 402 PAYMENT-REQUIRED header.
|
|
41
|
+
reservation: The Approved ReservationToken from the policy engine.
|
|
42
|
+
nonce: Optional 32-byte hex nonce. If omitted, a random nonce is generated.
|
|
43
|
+
token_name: Optional EIP-712 domain token name (e.g. 'USD Coin').
|
|
44
|
+
token_version: EIP-712 domain version (default '2').
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
PaymentAuthorization populated with cryptographic signature.
|
|
48
|
+
|
|
49
|
+
Raises:
|
|
50
|
+
ReservationMismatchError: If the reservation does not match the challenge.
|
|
51
|
+
SignerError: If cryptographic signing fails.
|
|
52
|
+
"""
|
|
53
|
+
...
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
"""
|
|
2
|
+
EIP-712 & EIP-3009 LocalSigner implementation for AgentPay.
|
|
3
|
+
|
|
4
|
+
Uses eth-account for all cryptographic operations.
|
|
5
|
+
Enforces firewall reservation binding and secret redaction.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
import secrets
|
|
12
|
+
from collections.abc import Mapping
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from eth_account import Account
|
|
16
|
+
from eth_account.messages import encode_typed_data
|
|
17
|
+
from eth_account.signers.local import LocalAccount
|
|
18
|
+
|
|
19
|
+
from agentpay.client.errors import (
|
|
20
|
+
InvalidPrivateKeyError,
|
|
21
|
+
ReservationMismatchError,
|
|
22
|
+
SignerError,
|
|
23
|
+
UnsupportedNetworkError,
|
|
24
|
+
)
|
|
25
|
+
from agentpay.client.interfaces import ISigner
|
|
26
|
+
from agentpay.core.models import (
|
|
27
|
+
PaymentAuthorization,
|
|
28
|
+
PaymentChallenge,
|
|
29
|
+
SignatureData,
|
|
30
|
+
)
|
|
31
|
+
from agentpay.policy.models import ReservationToken
|
|
32
|
+
|
|
33
|
+
# CAIP-2 parsing regex for eip155 namespace: eip155:<chain_id>
|
|
34
|
+
_CAIP2_EIP155_RE = re.compile(r"^eip155:([0-9]+)$")
|
|
35
|
+
|
|
36
|
+
# Standard EIP-3009 message type definition for TransferWithAuthorization
|
|
37
|
+
EIP3009_MESSAGE_TYPES = {
|
|
38
|
+
"TransferWithAuthorization": [
|
|
39
|
+
{"name": "from", "type": "address"},
|
|
40
|
+
{"name": "to", "type": "address"},
|
|
41
|
+
{"name": "value", "type": "uint256"},
|
|
42
|
+
{"name": "validAfter", "type": "uint256"},
|
|
43
|
+
{"name": "validBefore", "type": "uint256"},
|
|
44
|
+
{"name": "nonce", "type": "bytes32"},
|
|
45
|
+
]
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
# Standard token names for major stablecoins
|
|
49
|
+
DEFAULT_TOKEN_NAMES = {
|
|
50
|
+
"usdc": "USD Coin",
|
|
51
|
+
"eurc": "Euro Coin",
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def generate_nonce() -> str:
|
|
56
|
+
"""Generate a cryptographically secure 32-byte hex nonce with 0x prefix."""
|
|
57
|
+
return "0x" + secrets.token_bytes(32).hex()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def parse_caip2_chain_id(network: str) -> int:
|
|
61
|
+
"""
|
|
62
|
+
Extract the integer chain ID from a CAIP-2 network string.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
network: CAIP-2 network identifier (e.g. 'eip155:84532').
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
Integer chain ID.
|
|
69
|
+
|
|
70
|
+
Raises:
|
|
71
|
+
UnsupportedNetworkError: If the network is not an eip155 EVM chain.
|
|
72
|
+
"""
|
|
73
|
+
match = _CAIP2_EIP155_RE.match(network.strip())
|
|
74
|
+
if not match:
|
|
75
|
+
raise UnsupportedNetworkError(
|
|
76
|
+
f"Unsupported network identifier: {network!r}. "
|
|
77
|
+
"LocalSigner only supports EVM chains conforming to 'eip155:<chainId>'."
|
|
78
|
+
)
|
|
79
|
+
return int(match.group(1))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class LocalSigner(ISigner):
|
|
83
|
+
"""
|
|
84
|
+
Local private key signer for AgentPay clients.
|
|
85
|
+
|
|
86
|
+
- Adheres to EIP-712 and EIP-3009 specifications.
|
|
87
|
+
- Uses eth-account strictly; implements zero custom cryptography.
|
|
88
|
+
- Enforces mandatory policy reservation verification before signing.
|
|
89
|
+
- Redacts private key in __repr__ and __str__ for financial safety.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
def __init__(
|
|
93
|
+
self,
|
|
94
|
+
private_key: str | bytes,
|
|
95
|
+
*,
|
|
96
|
+
token_name_map: Mapping[str, str] | None = None,
|
|
97
|
+
) -> None:
|
|
98
|
+
"""
|
|
99
|
+
Initialize LocalSigner with an EVM private key.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
private_key: Hex string (with or without 0x) or 32 raw bytes.
|
|
103
|
+
token_name_map: Optional mapping of contract address or symbol to EIP-712 domain name.
|
|
104
|
+
"""
|
|
105
|
+
try:
|
|
106
|
+
if isinstance(private_key, str):
|
|
107
|
+
clean_key = private_key.strip()
|
|
108
|
+
if not clean_key.startswith("0x"):
|
|
109
|
+
clean_key = "0x" + clean_key
|
|
110
|
+
self._account: LocalAccount = Account.from_key(clean_key)
|
|
111
|
+
elif isinstance(private_key, (bytes, bytearray)):
|
|
112
|
+
self._account = Account.from_key(private_key)
|
|
113
|
+
else:
|
|
114
|
+
raise TypeError(f"private_key must be str or bytes, got {type(private_key).__name__}")
|
|
115
|
+
except Exception as exc:
|
|
116
|
+
raise InvalidPrivateKeyError(f"Failed to load EVM private key: {exc}") from exc
|
|
117
|
+
|
|
118
|
+
self._address = self._account.address.lower()
|
|
119
|
+
self._token_name_map: dict[str, str] = dict(token_name_map) if token_name_map else {}
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def address(self) -> str:
|
|
123
|
+
"""Canonical lowercase EVM address of the signing wallet."""
|
|
124
|
+
return self._address
|
|
125
|
+
|
|
126
|
+
def __repr__(self) -> str:
|
|
127
|
+
# Redact private key strictly
|
|
128
|
+
return f"<LocalSigner address={self._address}>"
|
|
129
|
+
|
|
130
|
+
def __str__(self) -> str:
|
|
131
|
+
# Redact private key strictly
|
|
132
|
+
return f"LocalSigner(address={self._address})"
|
|
133
|
+
|
|
134
|
+
@classmethod
|
|
135
|
+
def generate(cls) -> LocalSigner:
|
|
136
|
+
"""Generate a new random LocalSigner using cryptographically secure randomness."""
|
|
137
|
+
random_key = "0x" + secrets.token_bytes(32).hex()
|
|
138
|
+
return cls(random_key)
|
|
139
|
+
|
|
140
|
+
def resolve_token_name(self, challenge: PaymentChallenge, explicit_name: str | None = None) -> str:
|
|
141
|
+
"""Resolve the EIP-712 domain token name for a payment challenge."""
|
|
142
|
+
if explicit_name:
|
|
143
|
+
return explicit_name
|
|
144
|
+
# Check custom token_name_map by contract address
|
|
145
|
+
if challenge.asset in self._token_name_map:
|
|
146
|
+
return self._token_name_map[challenge.asset]
|
|
147
|
+
# Check by symbol
|
|
148
|
+
if challenge.asset_symbol and challenge.asset_symbol.lower() in self._token_name_map:
|
|
149
|
+
return self._token_name_map[challenge.asset_symbol.lower()]
|
|
150
|
+
# Check default stablecoin mappings
|
|
151
|
+
if challenge.asset_symbol and challenge.asset_symbol.lower() in DEFAULT_TOKEN_NAMES:
|
|
152
|
+
return DEFAULT_TOKEN_NAMES[challenge.asset_symbol.lower()]
|
|
153
|
+
# Fallback to symbol or USD Coin
|
|
154
|
+
return challenge.asset_symbol or "USD Coin"
|
|
155
|
+
|
|
156
|
+
def build_eip712_data(
|
|
157
|
+
self,
|
|
158
|
+
challenge: PaymentChallenge,
|
|
159
|
+
nonce: str,
|
|
160
|
+
*,
|
|
161
|
+
token_name: str | None = None,
|
|
162
|
+
token_version: str = "2",
|
|
163
|
+
) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]:
|
|
164
|
+
"""
|
|
165
|
+
Build EIP-712 domain, types, and message dictionaries conforming to EIP-3009.
|
|
166
|
+
"""
|
|
167
|
+
chain_id = parse_caip2_chain_id(challenge.network)
|
|
168
|
+
domain_name = self.resolve_token_name(challenge, token_name)
|
|
169
|
+
|
|
170
|
+
domain_data = {
|
|
171
|
+
"name": domain_name,
|
|
172
|
+
"version": token_version,
|
|
173
|
+
"chainId": chain_id,
|
|
174
|
+
"verifyingContract": challenge.asset,
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
types_data = EIP3009_MESSAGE_TYPES
|
|
178
|
+
|
|
179
|
+
# Nonce format: ensure 0x prefix and lowercase
|
|
180
|
+
norm_nonce = nonce if nonce.startswith("0x") else ("0x" + nonce)
|
|
181
|
+
|
|
182
|
+
message_data = {
|
|
183
|
+
"from": self.address,
|
|
184
|
+
"to": challenge.payee,
|
|
185
|
+
"value": challenge.amount_base_units,
|
|
186
|
+
"validAfter": challenge.valid_after,
|
|
187
|
+
"validBefore": challenge.valid_before,
|
|
188
|
+
"nonce": norm_nonce,
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return domain_data, types_data, message_data
|
|
192
|
+
|
|
193
|
+
def sign_payment_authorization(
|
|
194
|
+
self,
|
|
195
|
+
challenge: PaymentChallenge,
|
|
196
|
+
reservation: ReservationToken,
|
|
197
|
+
*,
|
|
198
|
+
nonce: str | None = None,
|
|
199
|
+
token_name: str | None = None,
|
|
200
|
+
token_version: str = "2",
|
|
201
|
+
) -> PaymentAuthorization:
|
|
202
|
+
"""
|
|
203
|
+
Sign an EIP-712/3009 transfer authorization after verifying the policy reservation.
|
|
204
|
+
|
|
205
|
+
Safety Guardrails:
|
|
206
|
+
1. Verifies reservation.intent_id == challenge.challenge_id
|
|
207
|
+
2. Verifies reservation.amount == challenge.amount_base_units
|
|
208
|
+
3. Verifies reservation.expires_at >= challenge.valid_before
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
PaymentAuthorization with cryptographic signature.
|
|
212
|
+
"""
|
|
213
|
+
# Guardrail 1: Policy intent binding
|
|
214
|
+
if reservation.intent_id != challenge.challenge_id:
|
|
215
|
+
raise ReservationMismatchError(
|
|
216
|
+
f"Reservation intent_id ({reservation.intent_id!r}) does not match "
|
|
217
|
+
f"challenge_id ({challenge.challenge_id!r}). Refusing to sign."
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
# Guardrail 2: Amount exact match
|
|
221
|
+
if reservation.amount != challenge.amount_base_units:
|
|
222
|
+
raise ReservationMismatchError(
|
|
223
|
+
f"Reservation amount ({reservation.amount}) does not match "
|
|
224
|
+
f"challenge amount ({challenge.amount_base_units}). Refusing to sign."
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
# Guardrail 3: Expiration safety window
|
|
228
|
+
if reservation.expires_at < challenge.valid_before:
|
|
229
|
+
raise ReservationMismatchError(
|
|
230
|
+
f"Reservation expires_at ({reservation.expires_at}) does not cover "
|
|
231
|
+
f"challenge valid_before ({challenge.valid_before}). Refusing to sign."
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
# Determine nonce
|
|
235
|
+
sign_nonce = nonce if nonce is not None else generate_nonce()
|
|
236
|
+
|
|
237
|
+
# Build EIP-712 data
|
|
238
|
+
domain_data, types_data, message_data = self.build_eip712_data(
|
|
239
|
+
challenge,
|
|
240
|
+
sign_nonce,
|
|
241
|
+
token_name=token_name,
|
|
242
|
+
token_version=token_version,
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
try:
|
|
246
|
+
encoded = encode_typed_data(
|
|
247
|
+
domain_data=domain_data,
|
|
248
|
+
message_types=types_data,
|
|
249
|
+
message_data=message_data,
|
|
250
|
+
)
|
|
251
|
+
signed = self._account.sign_message(encoded)
|
|
252
|
+
except Exception as exc:
|
|
253
|
+
raise SignerError(f"Cryptographic signing failed: {exc}") from exc
|
|
254
|
+
|
|
255
|
+
r_hex = f"0x{signed.r:064x}"
|
|
256
|
+
s_hex = f"0x{signed.s:064x}"
|
|
257
|
+
v_val = signed.v
|
|
258
|
+
|
|
259
|
+
# Serialized hex: 65 bytes (r 32B + s 32B + v 1B)
|
|
260
|
+
serialized_hex = f"{signed.r:064x}{signed.s:064x}{v_val:02x}"
|
|
261
|
+
|
|
262
|
+
sig_data = SignatureData(
|
|
263
|
+
v=v_val,
|
|
264
|
+
r=r_hex,
|
|
265
|
+
s=s_hex,
|
|
266
|
+
serialized=serialized_hex,
|
|
267
|
+
compact="0x" + serialized_hex,
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
return PaymentAuthorization(
|
|
271
|
+
version="2.0",
|
|
272
|
+
challenge_id=challenge.challenge_id,
|
|
273
|
+
payer=self.address,
|
|
274
|
+
scheme=challenge.scheme,
|
|
275
|
+
nonce=sign_nonce,
|
|
276
|
+
valid_after=challenge.valid_after,
|
|
277
|
+
valid_before=challenge.valid_before,
|
|
278
|
+
signature=sig_data,
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
@classmethod
|
|
282
|
+
def verify_authorization(
|
|
283
|
+
cls,
|
|
284
|
+
authorization: PaymentAuthorization,
|
|
285
|
+
challenge: PaymentChallenge,
|
|
286
|
+
*,
|
|
287
|
+
expected_payer: str | None = None,
|
|
288
|
+
token_name: str | None = None,
|
|
289
|
+
token_version: str = "2",
|
|
290
|
+
) -> bool:
|
|
291
|
+
"""
|
|
292
|
+
Verify that a PaymentAuthorization cryptographically matches the PaymentChallenge
|
|
293
|
+
and was signed by the expected payer.
|
|
294
|
+
|
|
295
|
+
Returns:
|
|
296
|
+
True if signature is valid and signer matches expected payer; False otherwise.
|
|
297
|
+
"""
|
|
298
|
+
target_payer = (expected_payer or authorization.payer).lower()
|
|
299
|
+
chain_id = parse_caip2_chain_id(challenge.network)
|
|
300
|
+
d_name = token_name or (
|
|
301
|
+
DEFAULT_TOKEN_NAMES.get((challenge.asset_symbol or "").lower()) or challenge.asset_symbol or "USD Coin"
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
domain_data = {
|
|
305
|
+
"name": d_name,
|
|
306
|
+
"version": token_version,
|
|
307
|
+
"chainId": chain_id,
|
|
308
|
+
"verifyingContract": challenge.asset,
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
types_data = EIP3009_MESSAGE_TYPES
|
|
312
|
+
|
|
313
|
+
message_data = {
|
|
314
|
+
"from": authorization.payer,
|
|
315
|
+
"to": challenge.payee,
|
|
316
|
+
"value": challenge.amount_base_units,
|
|
317
|
+
"validAfter": authorization.valid_after,
|
|
318
|
+
"validBefore": authorization.valid_before,
|
|
319
|
+
"nonce": authorization.nonce,
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
try:
|
|
323
|
+
encoded = encode_typed_data(
|
|
324
|
+
domain_data=domain_data,
|
|
325
|
+
message_types=types_data,
|
|
326
|
+
message_data=message_data,
|
|
327
|
+
)
|
|
328
|
+
# Obtain serialized signature bytes
|
|
329
|
+
if isinstance(authorization.signature, SignatureData):
|
|
330
|
+
sig_bytes = bytes.fromhex(authorization.signature.serialized or "")
|
|
331
|
+
elif isinstance(authorization.signature, str):
|
|
332
|
+
sig_clean = authorization.signature.removeprefix("0x")
|
|
333
|
+
sig_bytes = bytes.fromhex(sig_clean)
|
|
334
|
+
else:
|
|
335
|
+
return False
|
|
336
|
+
|
|
337
|
+
recovered = Account.recover_message(encoded, signature=sig_bytes).lower()
|
|
338
|
+
return recovered == target_payer
|
|
339
|
+
except (ValueError, TypeError, KeyError):
|
|
340
|
+
return False
|