diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 12c9228..b89115b 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -13,6 +13,7 @@ Python client for the AgentScore trust and reputation API. - `create_credential` / `acreate_credential` — create operator credential (24h TTL default) - `list_credentials` / `alist_credentials` — list active credentials - `revoke_credential` / `arevoke_credential` — revoke a credential +- `associate_wallet` / `aassociate_wallet` — report a signer wallet seen paying under a credential (TEC-189). Accepts optional `idempotency_key` (payment intent id / tx hash) so retries don't inflate transaction_count. ## Architecture diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 95a5af3..1d250c2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,12 +9,24 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: ci: - runs-on: blacksmith-2vcpu-ubuntu-2404 + runs-on: blacksmith-2vcpu-ubuntu-2404-arm + timeout-minutes: 10 steps: - uses: useblacksmith/checkout@v1 - uses: astral-sh/setup-uv@v7 + - uses: actions/cache@v4 + with: + path: | + ~/.cache/uv + ~/.local/share/uv + key: ${{ runner.os }}-uv-${{ hashFiles('uv.lock') }} + restore-keys: ${{ runner.os }}-uv- - run: uv sync --frozen --all-extras - run: uv run ruff check . - run: uv run ruff format --check . diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 0f74b84..ff15fd6 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -8,9 +8,14 @@ permissions: contents: write id-token: write +concurrency: + group: publish + cancel-in-progress: false + jobs: publish: runs-on: ubuntu-latest + timeout-minutes: 15 steps: - uses: actions/checkout@v6 diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 58a1b30..2ff07f1 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -16,7 +16,7 @@ permissions: jobs: osv-scan: name: Dependency Scan - runs-on: blacksmith-2vcpu-ubuntu-2404 + runs-on: blacksmith-2vcpu-ubuntu-2404-arm timeout-minutes: 5 steps: - uses: useblacksmith/checkout@v1 @@ -31,7 +31,7 @@ jobs: pip-audit: name: Python Audit - runs-on: blacksmith-2vcpu-ubuntu-2404 + runs-on: blacksmith-2vcpu-ubuntu-2404-arm timeout-minutes: 5 steps: - uses: useblacksmith/checkout@v1 diff --git a/README.md b/README.md index ec7989a..56cc7d8 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,19 @@ credentials = client.list_credentials() client.revoke_credential(cred["id"]) ``` +### Report an Agent's Wallet (Cross-Merchant Attribution) + +After an agent authenticated via `operator_token` completes a payment, report the signer wallet so AgentScore can build a cross-merchant credential↔wallet profile. Fire-and-forget — `first_seen` is informational only. `network` is the key-derivation family: `"evm"` for any EVM chain (Base, Tempo, Ethereum, …) or `"solana"` for Solana. + +```python +client.associate_wallet( + operator_token="opc_...", + wallet_address=signer_from_payment, # e.g. EIP-3009 `from` or Tempo MPP DID address + network="evm", + idempotency_key=payment_intent_id, # optional — agent retries of the same payment no-op +) +``` + ### Async All methods have async variants prefixed with `a`: @@ -88,6 +101,11 @@ async with AgentScore(api_key="as_live_...") as client: cred = await client.acreate_credential(label="my-agent") await client.alist_credentials() await client.arevoke_credential(cred["id"]) + await client.aassociate_wallet( + operator_token="opc_...", + wallet_address="0x...", + network="evm", + ) ``` ### Context Manager diff --git a/agentscore/__init__.py b/agentscore/__init__.py index 0391370..cf225ae 100644 --- a/agentscore/__init__.py +++ b/agentscore/__init__.py @@ -4,12 +4,14 @@ from agentscore.errors import AgentScoreError from agentscore.types import ( AssessResponse, + AssociateWalletResponse, CredentialCreateResponse, CredentialItem, CredentialListResponse, DecisionPolicy, EntityType, Grade, + Network, OperatorVerification, Reputation, ReputationResponse, @@ -26,12 +28,14 @@ "AgentScore", "AgentScoreError", "AssessResponse", + "AssociateWalletResponse", "CredentialCreateResponse", "CredentialItem", "CredentialListResponse", "DecisionPolicy", "EntityType", "Grade", + "Network", "OperatorVerification", "Reputation", "ReputationResponse", diff --git a/agentscore/client.py b/agentscore/client.py index a733e0d..cab9aef 100644 --- a/agentscore/client.py +++ b/agentscore/client.py @@ -1,5 +1,6 @@ from __future__ import annotations +import logging from importlib.metadata import version as _pkg_version from typing import TYPE_CHECKING, Any @@ -7,12 +8,20 @@ from agentscore.errors import AgentScoreError +logger = logging.getLogger("agentscore") + +# Server truncates idempotency_key at 200 chars; warn the caller past that so two +# distinct payments that share the first 200 chars don't silently dedup. +_IDEMPOTENCY_KEY_MAX = 200 + if TYPE_CHECKING: from agentscore.types import ( AssessResponse, + AssociateWalletResponse, CredentialCreateResponse, CredentialListResponse, DecisionPolicy, + Network, ReputationResponse, SessionCreateResponse, SessionPollResponse, @@ -182,6 +191,41 @@ def revoke_credential(self, id: str) -> dict: response = client.delete(f"/v1/credentials/{id}") return self._handle_response(response) + def associate_wallet( + self, + operator_token: str, + wallet_address: str, + network: Network, + idempotency_key: str | None = None, + ) -> AssociateWalletResponse: + """Report that a wallet paid under an operator credential. + + ``network`` is the key-derivation family (``"evm"`` or ``"solana"``) — EVM EOAs share + identity across every EVM chain (Base, Tempo, Ethereum, …) so one value covers them all. + + ``idempotency_key`` is optional — pass a stable per-payment key (e.g., payment intent id, + x402 tx hash) so agent retries of the same logical payment don't inflate transaction_count. + + Fire-and-forget friendly — the returned ``first_seen`` boolean is informational only. + """ + body: dict[str, Any] = { + "operator_token": operator_token, + "wallet_address": wallet_address, + "network": network, + } + # Truthy check (not `is not None`) so empty strings don't ship a useless key — mirrors + # node-sdk's behavior of only forwarding when the key actually has content. + if idempotency_key: + if len(idempotency_key) > _IDEMPOTENCY_KEY_MAX: + logger.warning( + "associate_wallet: idempotency_key longer than %d chars will be truncated server-side.", + _IDEMPOTENCY_KEY_MAX, + ) + body["idempotency_key"] = idempotency_key + client = self._get_sync_client() + response = client.post("/v1/credentials/wallets", json=body) + return self._handle_response(response) + # --- Async methods --- async def aget_reputation(self, address: str, chain: str | None = None) -> ReputationResponse: @@ -268,6 +312,32 @@ async def arevoke_credential(self, id: str) -> dict: response = await client.delete(f"/v1/credentials/{id}") return self._handle_response(response) + async def aassociate_wallet( + self, + operator_token: str, + wallet_address: str, + network: Network, + idempotency_key: str | None = None, + ) -> AssociateWalletResponse: + """Async variant of :meth:`associate_wallet`.""" + body: dict[str, Any] = { + "operator_token": operator_token, + "wallet_address": wallet_address, + "network": network, + } + # Truthy check (not `is not None`) so empty strings don't ship a useless key — mirrors + # node-sdk's behavior of only forwarding when the key actually has content. + if idempotency_key: + if len(idempotency_key) > _IDEMPOTENCY_KEY_MAX: + logger.warning( + "aassociate_wallet: idempotency_key longer than %d chars will be truncated server-side.", + _IDEMPOTENCY_KEY_MAX, + ) + body["idempotency_key"] = idempotency_key + client = self._get_async_client() + response = await client.post("/v1/credentials/wallets", json=body) + return self._handle_response(response) + def close(self): if self._sync_client: self._sync_client.close() diff --git a/agentscore/types.py b/agentscore/types.py index 546bbd7..4fb80d8 100644 --- a/agentscore/types.py +++ b/agentscore/types.py @@ -241,3 +241,15 @@ class CredentialCreateResponse(TypedDict): class CredentialListResponse(TypedDict): credentials: list[CredentialItem] account_verification: NotRequired[dict] + + +Network = Literal["evm", "solana"] +"""Key-derivation family for associate_wallet. EVM covers any EVM chain (Base, Tempo, Ethereum, …) +because EOA addresses derive from the same private key on every EVM chain. Solana lives in its own +namespace with a different key scheme.""" + + +class AssociateWalletResponse(TypedDict): + associated: bool + first_seen: bool + deduped: NotRequired[bool] diff --git a/tests/test_client.py b/tests/test_client.py index c8a691f..3a81a7e 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1138,3 +1138,211 @@ async def test_arevoke_credential_raises_on_404(): assert exc_info.value.status_code == 404 assert exc_info.value.code == "not_found" await client.aclose() + + +# --------------------------------------------------------------------------- +# associate_wallet +# --------------------------------------------------------------------------- + +ASSOCIATE_TOKEN = "opc_" + "a" * 48 +ASSOCIATE_WALLET = "0xabcdef1234567890abcdef1234567890abcdef12" +ASSOCIATE_NETWORK = "evm" + + +@respx.mock +def test_associate_wallet_returns_first_seen_true(): + respx.post(f"{BASE_URL}/v1/credentials/wallets").mock( + return_value=httpx.Response(200, json={"associated": True, "first_seen": True}), + ) + client = AgentScore(api_key=API_KEY) + result = client.associate_wallet(ASSOCIATE_TOKEN, ASSOCIATE_WALLET, ASSOCIATE_NETWORK) + assert result == {"associated": True, "first_seen": True} + + +@respx.mock +def test_associate_wallet_sends_snake_case_body(): + route = respx.post(f"{BASE_URL}/v1/credentials/wallets").mock( + return_value=httpx.Response(200, json={"associated": True, "first_seen": False}), + ) + client = AgentScore(api_key=API_KEY) + client.associate_wallet(ASSOCIATE_TOKEN, ASSOCIATE_WALLET, ASSOCIATE_NETWORK) + assert route.called + body = json.loads(route.calls[0].request.content.decode()) + assert body == { + "operator_token": ASSOCIATE_TOKEN, + "wallet_address": ASSOCIATE_WALLET, + "network": ASSOCIATE_NETWORK, + } + + +@respx.mock +def test_associate_wallet_forwards_idempotency_key(): + route = respx.post(f"{BASE_URL}/v1/credentials/wallets").mock( + return_value=httpx.Response(200, json={"associated": True, "first_seen": False, "deduped": True}), + ) + client = AgentScore(api_key=API_KEY) + result = client.associate_wallet(ASSOCIATE_TOKEN, ASSOCIATE_WALLET, ASSOCIATE_NETWORK, idempotency_key="pi_abc") + assert result == {"associated": True, "first_seen": False, "deduped": True} + body = json.loads(route.calls[0].request.content.decode()) + assert body["idempotency_key"] == "pi_abc" + + +@respx.mock +def test_associate_wallet_omits_idempotency_key_when_not_provided(): + route = respx.post(f"{BASE_URL}/v1/credentials/wallets").mock( + return_value=httpx.Response(200, json={"associated": True, "first_seen": True}), + ) + client = AgentScore(api_key=API_KEY) + client.associate_wallet(ASSOCIATE_TOKEN, ASSOCIATE_WALLET, ASSOCIATE_NETWORK) + body = json.loads(route.calls[0].request.content.decode()) + assert "idempotency_key" not in body + + +@respx.mock +def test_associate_wallet_omits_empty_string_idempotency_key(): + """Empty string is not a valid key — match node-sdk behavior and skip forwarding.""" + route = respx.post(f"{BASE_URL}/v1/credentials/wallets").mock( + return_value=httpx.Response(200, json={"associated": True, "first_seen": True}), + ) + client = AgentScore(api_key=API_KEY) + client.associate_wallet(ASSOCIATE_TOKEN, ASSOCIATE_WALLET, ASSOCIATE_NETWORK, idempotency_key="") + body = json.loads(route.calls[0].request.content.decode()) + assert "idempotency_key" not in body + + +@respx.mock +def test_associate_wallet_raises_on_401_invalid_credential(): + """Matches /v1/assess's anti-enumeration status code for unknown credentials.""" + respx.post(f"{BASE_URL}/v1/credentials/wallets").mock( + return_value=httpx.Response( + 401, + json={"error": {"code": "invalid_credential", "message": "Operator credential not found"}}, + ), + ) + client = AgentScore(api_key=API_KEY) + with pytest.raises(AgentScoreError) as exc_info: + client.associate_wallet(ASSOCIATE_TOKEN, ASSOCIATE_WALLET, ASSOCIATE_NETWORK) + assert exc_info.value.status_code == 401 + assert exc_info.value.code == "invalid_credential" + + +@respx.mock +def test_associate_wallet_raises_on_400_invalid_wallet(): + respx.post(f"{BASE_URL}/v1/credentials/wallets").mock( + return_value=httpx.Response( + 400, + json={"error": {"code": "invalid_wallet", "message": "bad wallet"}}, + ), + ) + client = AgentScore(api_key=API_KEY) + with pytest.raises(AgentScoreError) as exc_info: + client.associate_wallet(ASSOCIATE_TOKEN, "0xnope", ASSOCIATE_NETWORK) + assert exc_info.value.code == "invalid_wallet" + + +@respx.mock +def test_associate_wallet_raises_on_402_payment_required(): + respx.post(f"{BASE_URL}/v1/credentials/wallets").mock( + return_value=httpx.Response( + 402, + json={"error": {"code": "payment_required", "message": "paid only"}}, + ), + ) + client = AgentScore(api_key=API_KEY) + with pytest.raises(AgentScoreError) as exc_info: + client.associate_wallet(ASSOCIATE_TOKEN, ASSOCIATE_WALLET, ASSOCIATE_NETWORK) + assert exc_info.value.status_code == 402 + assert exc_info.value.code == "payment_required" + + +@pytest.mark.asyncio +@respx.mock +async def test_aassociate_wallet_returns_first_seen_true(): + respx.post(f"{BASE_URL}/v1/credentials/wallets").mock( + return_value=httpx.Response(200, json={"associated": True, "first_seen": True}), + ) + client = AgentScore(api_key=API_KEY) + result = await client.aassociate_wallet(ASSOCIATE_TOKEN, ASSOCIATE_WALLET, ASSOCIATE_NETWORK) + assert result == {"associated": True, "first_seen": True} + await client.aclose() + + +@pytest.mark.asyncio +@respx.mock +async def test_aassociate_wallet_sends_snake_case_body(): + route = respx.post(f"{BASE_URL}/v1/credentials/wallets").mock( + return_value=httpx.Response(200, json={"associated": True, "first_seen": False}), + ) + client = AgentScore(api_key=API_KEY) + await client.aassociate_wallet(ASSOCIATE_TOKEN, ASSOCIATE_WALLET, ASSOCIATE_NETWORK) + assert route.called + body = json.loads(route.calls[0].request.content.decode()) + assert body == { + "operator_token": ASSOCIATE_TOKEN, + "wallet_address": ASSOCIATE_WALLET, + "network": ASSOCIATE_NETWORK, + } + await client.aclose() + + +@pytest.mark.asyncio +@respx.mock +async def test_aassociate_wallet_forwards_idempotency_key(): + route = respx.post(f"{BASE_URL}/v1/credentials/wallets").mock( + return_value=httpx.Response(200, json={"associated": True, "first_seen": False, "deduped": True}), + ) + client = AgentScore(api_key=API_KEY) + result = await client.aassociate_wallet( + ASSOCIATE_TOKEN, ASSOCIATE_WALLET, ASSOCIATE_NETWORK, idempotency_key="pi_abc" + ) + assert result == {"associated": True, "first_seen": False, "deduped": True} + body = json.loads(route.calls[0].request.content.decode()) + assert body["idempotency_key"] == "pi_abc" + await client.aclose() + + +@pytest.mark.asyncio +@respx.mock +async def test_aassociate_wallet_omits_empty_string_idempotency_key(): + route = respx.post(f"{BASE_URL}/v1/credentials/wallets").mock( + return_value=httpx.Response(200, json={"associated": True, "first_seen": True}), + ) + client = AgentScore(api_key=API_KEY) + await client.aassociate_wallet(ASSOCIATE_TOKEN, ASSOCIATE_WALLET, ASSOCIATE_NETWORK, idempotency_key="") + body = json.loads(route.calls[0].request.content.decode()) + assert "idempotency_key" not in body + await client.aclose() + + +@pytest.mark.asyncio +@respx.mock +async def test_aassociate_wallet_raises_on_401_invalid_credential(): + respx.post(f"{BASE_URL}/v1/credentials/wallets").mock( + return_value=httpx.Response( + 401, + json={"error": {"code": "invalid_credential", "message": "Operator credential not found"}}, + ), + ) + client = AgentScore(api_key=API_KEY) + with pytest.raises(AgentScoreError) as exc_info: + await client.aassociate_wallet(ASSOCIATE_TOKEN, ASSOCIATE_WALLET, ASSOCIATE_NETWORK) + assert exc_info.value.status_code == 401 + assert exc_info.value.code == "invalid_credential" + await client.aclose() + + +@pytest.mark.asyncio +@respx.mock +async def test_aassociate_wallet_raises_on_402_payment_required(): + respx.post(f"{BASE_URL}/v1/credentials/wallets").mock( + return_value=httpx.Response( + 402, + json={"error": {"code": "payment_required", "message": "paid only"}}, + ), + ) + client = AgentScore(api_key=API_KEY) + with pytest.raises(AgentScoreError) as exc_info: + await client.aassociate_wallet(ASSOCIATE_TOKEN, ASSOCIATE_WALLET, ASSOCIATE_NETWORK) + assert exc_info.value.status_code == 402 + assert exc_info.value.code == "payment_required" + await client.aclose() diff --git a/uv.lock b/uv.lock index 2618f39..7e4b783 100644 --- a/uv.lock +++ b/uv.lock @@ -407,11 +407,11 @@ wheels = [ [[package]] name = "idna" -version = "3.11" +version = "3.12" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +sdist = { url = "https://files.pythonhosted.org/packages/22/12/2948fbe5513d062169bd91f7d7b1cd97bc8894f32946b71fa39f6e63ca0c/idna-3.12.tar.gz", hash = "sha256:724e9952cc9e2bd7550ea784adb098d837ab5267ef67a1ab9cf7846bdbdd8254", size = 194350, upload-time = "2026-04-21T13:32:48.916Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, + { url = "https://files.pythonhosted.org/packages/53/b2/acc33950394b3becb2b664741a0c0889c7ef9f9ffbfa8d47eddb53a50abd/idna-3.12-py3-none-any.whl", hash = "sha256:60ffaa1858fac94c9c124728c24fcde8160f3fb4a7f79aa8cdd33a9d1af60a67", size = 68634, upload-time = "2026-04-21T13:32:47.403Z" }, ] [[package]]