Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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