From 3810ed138418b02efae82070ba815297561ad549 Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Mon, 20 Apr 2026 23:48:03 -0700 Subject: [PATCH 1/9] feat: TEC-189 associate_wallet + TEC-200 user_agent (v1.7.0) TEC-189: associate_wallet() and aassociate_wallet() post to POST /v1/credentials/wallets. Fire-and-forget semantics documented; idempotency_key is dropped when empty-string for parity with node-sdk. TEC-200: new `user_agent` constructor option. Outbound User-Agent becomes "{user_agent} (agentscore-py/{version})" when set. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/CLAUDE.md | 1 + README.md | 18 ++++ agentscore/__init__.py | 4 + agentscore/client.py | 53 +++++++++++ agentscore/types.py | 12 +++ tests/test_client.py | 210 +++++++++++++++++++++++++++++++++++++++++ 6 files changed, 298 insertions(+) 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/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..7407923 100644 --- a/agentscore/client.py +++ b/agentscore/client.py @@ -10,9 +10,11 @@ if TYPE_CHECKING: from agentscore.types import ( AssessResponse, + AssociateWalletResponse, CredentialCreateResponse, CredentialListResponse, DecisionPolicy, + Network, ReputationResponse, SessionCreateResponse, SessionPollResponse, @@ -182,6 +184,36 @@ 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: + 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 +300,27 @@ 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: + 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..f029883 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1138,3 +1138,213 @@ 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() From 2e9e9e319f5bd5600c8069279398081f3d836c2e Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Tue, 21 Apr 2026 00:12:31 -0700 Subject: [PATCH 2/9] style: ruff format (CI fix) Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/test_client.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/test_client.py b/tests/test_client.py index f029883..3a81a7e 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1308,9 +1308,7 @@ async def test_aassociate_wallet_omits_empty_string_idempotency_key(): 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="" - ) + 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() From 6e893d01b86d85ddee61389482964041dcb1e3a9 Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Tue, 21 Apr 2026 13:04:22 -0700 Subject: [PATCH 3/9] =?UTF-8?q?ci:=20swap=20Blacksmith=20x86=20=E2=86=92?= =?UTF-8?q?=20ARM=20for=20CI=20and=20security=20jobs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI runs lint/typecheck/test; security runs osv-scan (and pip-audit for python). No Docker artifacts produced. ARM is cheaper and faster for Bun/Python tooling. publish.yml stays on ubuntu-latest for trusted publishing. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 2 +- .github/workflows/security.yml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 95a5af3..4182baa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,7 @@ permissions: jobs: ci: - runs-on: blacksmith-2vcpu-ubuntu-2404 + runs-on: blacksmith-2vcpu-ubuntu-2404-arm steps: - uses: useblacksmith/checkout@v1 - uses: astral-sh/setup-uv@v7 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 From 0a74099cb06ac8d56fd24dc280f5512cf3ffa568 Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Tue, 21 Apr 2026 13:14:56 -0700 Subject: [PATCH 4/9] ci: add Blacksmith sticky-disk cache for install step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useblacksmith/cache@v1 before install — caches the package manager's download cache on Blacksmith's persistent NVMe disk. ~2-3× faster installs on warm reruns. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4182baa..3ad9151 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,6 +15,13 @@ jobs: steps: - uses: useblacksmith/checkout@v1 - uses: astral-sh/setup-uv@v7 + - uses: useblacksmith/cache@v1 + 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 . From 2bf0290a3274104970421bac110c7a0ad9b5354b Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Tue, 21 Apr 2026 13:24:00 -0700 Subject: [PATCH 5/9] ci: add concurrency + timeout-minutes to CI job - concurrency cancel-in-progress so rapid pushes don't stack runs - 10-minute timeout so hung tests don't eat 6h of compute Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3ad9151..83a3b9d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,9 +9,14 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: ci: runs-on: blacksmith-2vcpu-ubuntu-2404-arm + timeout-minutes: 10 steps: - uses: useblacksmith/checkout@v1 - uses: astral-sh/setup-uv@v7 From 2d12ce366af9d0c73116a9b447982e65fc63c087 Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Tue, 21 Apr 2026 13:27:05 -0700 Subject: [PATCH 6/9] ci: add concurrency + timeout-minutes to publish workflow - concurrency group=publish with cancel-in-progress: false so back-to-back tag pushes queue rather than cancel a mid-publish (partial releases are worse than waiting) - 15-minute timeout so a stuck publish doesn't hold the queue for 6h Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/publish.yml | 5 +++++ 1 file changed, 5 insertions(+) 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 From 12a5db05d7bd9c275b2811e32c2caa6fc17ff86f Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Tue, 21 Apr 2026 13:34:31 -0700 Subject: [PATCH 7/9] ci: migrate from archived useblacksmith/cache to actions/cache@v4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Blacksmith archived useblacksmith/cache on 2025-10-14 — their runners now transparently intercept upstream actions/cache@v4 and route it to the same fast colocated backend. Same performance, actively maintained, no Node.js 20 deprecation warning. https://github.com/useblacksmith/cache (archived) https://www.blacksmith.sh/blog/cache (migration rationale) Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 83a3b9d..1d250c2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,7 +20,7 @@ jobs: steps: - uses: useblacksmith/checkout@v1 - uses: astral-sh/setup-uv@v7 - - uses: useblacksmith/cache@v1 + - uses: actions/cache@v4 with: path: | ~/.cache/uv From 9e11ea8fab1ad261073626901f399558bb7b89c2 Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Tue, 21 Apr 2026 13:49:59 -0700 Subject: [PATCH 8/9] chore: uv lock --upgrade (semver-safe) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - idna 3.11 → 3.12 Co-Authored-By: Claude Opus 4.7 (1M context) --- uv.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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]] From 260a38a66fc76a665391bac8b2d529dfa70c1ddf Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Tue, 21 Apr 2026 19:22:02 -0700 Subject: [PATCH 9/9] chore: warn when associate_wallet idempotency_key exceeds 200 chars Parity with node-sdk. Server truncates at 200 chars; two distinct keys sharing the first 200 chars would silently dedup. Emit a logger.warning on the "agentscore" logger so this is visible in dev without changing behavior (still sends the full key; server truncates). Co-Authored-By: Claude Opus 4.7 (1M context) --- agentscore/client.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/agentscore/client.py b/agentscore/client.py index 7407923..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,6 +8,12 @@ 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, @@ -209,6 +216,11 @@ def associate_wallet( # 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) @@ -316,6 +328,11 @@ async def aassociate_wallet( # 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)