Skip to content
1 change: 1 addition & 0 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
14 changes: 13 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 .
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/security.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:
Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions agentscore/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -26,12 +28,14 @@
"AgentScore",
"AgentScoreError",
"AssessResponse",
"AssociateWalletResponse",
"CredentialCreateResponse",
"CredentialItem",
"CredentialListResponse",
"DecisionPolicy",
"EntityType",
"Grade",
"Network",
"OperatorVerification",
"Reputation",
"ReputationResponse",
Expand Down
70 changes: 70 additions & 0 deletions agentscore/client.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,27 @@
from __future__ import annotations

import logging
from importlib.metadata import version as _pkg_version
from typing import TYPE_CHECKING, Any

import httpx

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,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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()
Expand Down
12 changes: 12 additions & 0 deletions agentscore/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Loading
Loading