Skip to content

Commit d5428b2

Browse files
authored
fix(payments): drop unsupported paymentConnectorId + add http_request plugin tool + EIP-3009 timing fix (#493)
* fix(payments): drop unsupported paymentConnectorId from ProcessPayment call ProcessPayment's public API contract (bedrock-agentcore Data Plane) does not accept a paymentConnectorId field — the connector is resolved server side from the payment instrument. The SDK was forwarding the param, causing every real ProcessPayment call to fail at the boto3 layer with: Parameter validation failed: Unknown parameter in input: "paymentConnectorId", must be one of: userId, agentName, paymentManagerArn, paymentSessionId, paymentInstrumentId, paymentType, paymentInput, clientToken The bug surfaced end-to-end when AgentCorePaymentsPlugin auto-paid a 402 response: get_payment_instrument succeeded, then generate_payment_header forwarded payment_connector_id into process_payment, which appended it to the API call and was rejected. Unit tests passed because they mock the boto3 client and don't validate the param schema. Fix: stop including paymentConnectorId in the ProcessPayment request body, and stop forwarding it from generate_payment_header. The kwarg is kept on both function signatures for backward compatibility, with docstrings updated to note that it is accepted but no longer forwarded. Adds a regression test asserting paymentConnectorId is absent from the ProcessPayment kwargs even when callers pass it explicitly. * feat(payments): add http_request tool to AgentCorePaymentsPlugin Ships a Strands @tool method on AgentCorePaymentsPlugin that performs HTTP requests and emits Strands ToolResult content blocks the SDK's payment handlers can parse. When the endpoint returns 402 Payment Required, the tool prefixes the content block with the spec-compliant PAYMENT_REQUIRED: marker, so the plugin's existing after_tool_call hook intercepts the result, generates an x402 payment header via PaymentManager, and Strands re-invokes the tool with the payment header injected. Why on the plugin: aligns with how get_payment_instrument / list_payment_instruments / get_payment_session are already exposed — adding the plugin to a Strands agent now produces a turnkey paid-HTTP experience without requiring strands-agents-tools or hand-written httpx code in customer agents. httpx is already a hard SDK dep; no new dependencies are introduced. The 402 envelope (statusCode + headers + body) matches what GenericPaymentHandler / HttpRequestPaymentHandler already parse, so the existing handler dispatch lights up automatically. Tests cover: - 200 envelope shape (Strands ToolResult dict, not a raw list) - 402 wrapped with PAYMENT_REQUIRED: marker - End-to-end contract: tool output -> get_payment_handler -> 402 - GET ignores body, POST sends dict as JSON, str body sent verbatim - Caller header dict isolated (mutations don't bleed back) - Non-JSON body falls back to {"text": ...} - httpx.RequestError returned as status='error' (not raised) - Method casing normalization - Default headers when caller omits All 490 payments tests pass. * style(payments): apply ruff format to plugin + tests * feat(payments): provide_http_request opt-out flag for AgentCorePaymentsPlugin Strands' tool registry raises ValueError on duplicate tool names, so a caller who wants to ship their own http_request currently can't — the plugin's auto-discovered http_request crashes Agent construction first. Add provide_http_request: bool = True to AgentCorePaymentsPluginConfig. When True (default), behavior is unchanged: the plugin's http_request @tool is auto-registered. When False, the plugin filters its own http_request out of self._tools right after super().__init__() so the caller can pass their own without a name collision. Auto-payment of 402 responses still works against any tool whose output emits the PAYMENT_REQUIRED: content marker — the after_tool_call hook is independent of the tool registration, so disabling the flag does not disable interception. Adds 5 tests: - default registers http_request - opt-out drops only http_request (other plugin tools intact) - opt-out preserves the after_tool_call/before_tool_call hooks - config validator rejects non-bool values for the flag - init_agent still works with the flag off All 495 payments tests pass. * feat(payments): add post_payment_retry_delay_seconds to fix EIP-3009 timing race The x402 EIP-3009 transferWithAuthorization contract requires block.timestamp > validAfter — a strict greater-than check, not greater-or-equal. When the signing service mints a signature with validAfter set to ~current Unix time, fast merchant facilitators submit the transaction within the same second the signature was minted, hitting the contract's strict check before block.timestamp advances. The seller returns a misleading 402 with reason "invalid_payload" or "invalid_exact_evm_transaction_simulation_failed" — but the signature is structurally valid; it's a race between minting and submission. Verified empirically: same signed payload reverts via eth_call within the validAfter second, then succeeds 5+ seconds later. Once the chain advances one block past validAfter, the seller's facilitator submits and the on-chain transferWithAuthorization settles. Real Base Sepolia transaction proving the round-trip: 0x0e542e2d5c3c97521bd47231d299c6d56841fe0865f63ebfaa92d6f47e28b6b6 Add post_payment_retry_delay_seconds: float = 3.0 to AgentCorePaymentsPluginConfig. Sleeps that many seconds in after_tool_call right before setting event.retry = True, so by the time Strands re-invokes the tool with the PAYMENT-SIGNATURE header, the chain has advanced one block past validAfter. 3.0s default chosen as ~one Base Sepolia block (~2s) plus margin. Set to 0 to disable for tests or chains with sub-second blocks. Adds 7 tests: - default delay is 3.0 - custom positive delay is honored - zero delay skips the sleep entirely (event.retry still set) - no sleep when signing fails (we never reach the retry path) - validator rejects negative values - validator rejects non-numeric values (str) - validator rejects bool (which would otherwise sneak through isinstance(int)) All 502 payments tests pass.
1 parent 29287c2 commit d5428b2

6 files changed

Lines changed: 697 additions & 18 deletions

File tree

src/bedrock_agentcore/payments/integrations/config.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,24 @@ class AgentCorePaymentsPluginConfig:
3939
eligible (preserving existing behavior). When set, only tool calls whose
4040
name appears in this list will trigger payment processing; all others are
4141
skipped.
42+
provide_http_request: Whether the plugin should register its built-in
43+
``http_request`` ``@tool`` on the agent. Defaults to True so adding the
44+
plugin gives a turnkey paid-HTTP experience. Set to False if you want
45+
to ship your own ``http_request`` tool — Strands raises a ValueError
46+
on duplicate tool names, so you must opt out of the plugin's version
47+
before passing your own. Auto-payment of 402 responses still works
48+
against any tool whose output carries the ``PAYMENT_REQUIRED:``
49+
content marker, so disabling this flag does not disable interception.
50+
post_payment_retry_delay_seconds: Seconds to wait after generating a
51+
payment header before allowing the tool to be retried. The x402
52+
EIP-3009 ``transferWithAuthorization`` contract requires
53+
``block.timestamp > validAfter`` (strict greater-than). Some signing
54+
services set ``validAfter`` close to the current time, which can
55+
cause the merchant facilitator to submit before ``validAfter``
56+
elapses, producing a misleading "invalid_payload" response. A small
57+
delay between signing and retry lets the chain advance one block so
58+
the authorization is valid by the time the seller submits. Defaults
59+
to 3.0 seconds (about one Base Sepolia block). Set to 0 to disable.
4260
"""
4361

4462
payment_manager_arn: str
@@ -54,6 +72,8 @@ class AgentCorePaymentsPluginConfig:
5472
bearer_token: Optional[str] = None
5573
token_provider: Optional[Callable[[], str]] = None
5674
payment_tool_allowlist: Optional[List[str]] = None
75+
provide_http_request: bool = True
76+
post_payment_retry_delay_seconds: float = 3.0
5777

5878
def __post_init__(self) -> None:
5979
"""Validate configuration after initialization."""
@@ -87,6 +107,21 @@ def __post_init__(self) -> None:
87107
if not all(isinstance(t, str) for t in self.payment_tool_allowlist):
88108
raise ValueError("All entries in payment_tool_allowlist must be strings")
89109

110+
if not isinstance(self.provide_http_request, bool):
111+
raise ValueError(f"provide_http_request must be a boolean, got {type(self.provide_http_request).__name__}")
112+
113+
if not isinstance(self.post_payment_retry_delay_seconds, (int, float)) or isinstance(
114+
self.post_payment_retry_delay_seconds, bool
115+
):
116+
raise ValueError(
117+
"post_payment_retry_delay_seconds must be a number, got "
118+
f"{type(self.post_payment_retry_delay_seconds).__name__}"
119+
)
120+
if self.post_payment_retry_delay_seconds < 0:
121+
raise ValueError(
122+
f"post_payment_retry_delay_seconds must be >= 0, got {self.post_payment_retry_delay_seconds}"
123+
)
124+
90125
def update_payment_session_id(self, payment_session_id: str) -> None:
91126
"""Update the payment session ID.
92127

src/bedrock_agentcore/payments/integrations/strands/plugin.py

Lines changed: 116 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
"""AgentCorePaymentsPlugin for Strands Agents framework."""
22

3+
import json
34
import logging
5+
import time
46
import uuid
5-
from typing import Any, Dict, Optional
7+
from typing import Any, Dict, Optional, Union
68

9+
import httpx
710
from strands.hooks import AfterToolCallEvent, BeforeToolCallEvent
811
from strands.plugins import Plugin, hook
912
from strands.tools import tool
@@ -25,7 +28,8 @@
2528
class AgentCorePaymentsPlugin(Plugin):
2629
"""Plugin for handling X402 payment requirements and providing payment tools in Strands Agents.
2730
28-
This plugin provides three tools for querying payment information:
31+
This plugin provides tools for querying payment information and making paid HTTP calls:
32+
- http_request: Call a (paid) HTTP endpoint; 402 responses are settled automatically
2933
- getPaymentInstrument: Retrieve details about a specific payment instrument
3034
- listPaymentInstruments: List all payment instruments for a user
3135
- getPaymentSession: Retrieve details about a specific payment session
@@ -55,6 +59,18 @@ def __init__(self, config: AgentCorePaymentsPluginConfig):
5559
super().__init__()
5660
self.config = config
5761
self.payment_manager: Optional[PaymentManager] = None
62+
63+
# Honor the provide_http_request opt-out: Strands' Plugin base auto-discovers
64+
# every @tool method into self._tools at super().__init__(). If the caller
65+
# wants to ship their own http_request, drop ours so Strands' tool registry
66+
# doesn't raise ValueError on duplicate tool name.
67+
if not self.config.provide_http_request:
68+
self._tools = [t for t in self._tools if t.tool_name != "http_request"]
69+
logger.info(
70+
"provide_http_request=False — plugin's http_request tool will not be registered. "
71+
"Auto-payment still triggers on any tool emitting a PAYMENT_REQUIRED: marker."
72+
)
73+
5874
logger.info("Initialized AgentCorePaymentsPlugin")
5975

6076
def init_agent(self, agent) -> None:
@@ -236,6 +252,20 @@ def after_tool_call(self, event: AfterToolCallEvent) -> None:
236252
# after this retry, we know it's a server-side rejection, not a signing failure.
237253
self._mark_successful_signing(event)
238254

255+
# Wait one chain-block before letting the tool retry, so the merchant's
256+
# facilitator has time to see block.timestamp > validAfter when it submits
257+
# transferWithAuthorization to USDC. Without this delay, fast facilitators
258+
# can submit in the same second the signature was minted, hitting the
259+
# contract's strict ``block.timestamp > validAfter`` check and producing
260+
# a misleading "invalid_payload" 402 from the seller.
261+
delay = self.config.post_payment_retry_delay_seconds
262+
if delay > 0:
263+
logger.info(
264+
"Waiting %.1fs before retry to allow chain to advance past validAfter",
265+
delay,
266+
)
267+
time.sleep(delay)
268+
239269
# Set retry flag to re-execute the tool with payment credentials.
240270
event.retry = True
241271
self._reset_interrupt_retry_count(event)
@@ -726,3 +756,87 @@ def get_payment_session(
726756
str(e),
727757
)
728758
raise
759+
760+
@tool
761+
def http_request(
762+
self,
763+
url: str,
764+
method: str = "GET",
765+
headers: Optional[Dict[str, str]] = None,
766+
body: Optional[Union[Dict[str, Any], str]] = None,
767+
) -> Dict[str, Any]:
768+
"""Call an HTTP endpoint. 402 Payment Required responses are settled automatically.
769+
770+
When the endpoint responds with HTTP 402, this plugin's after_tool_call hook
771+
intercepts the result, generates an x402 payment header via the configured
772+
PaymentManager, mutates ``headers`` with the X-PAYMENT (v1) or
773+
PAYMENT-SIGNATURE (v2) header, and Strands re-invokes this tool — yielding
774+
the final 200 response and (when applicable) a settle hash in the
775+
PAYMENT-RESPONSE header.
776+
777+
Returns a Strands ToolResult dict: ``status`` is always ``success`` (HTTP
778+
errors are returned in the body, not raised), and ``content`` is a single
779+
text block. On 402 the text is prefixed with ``PAYMENT_REQUIRED:`` so the
780+
SDK's payment handlers can extract the x402 payload.
781+
782+
Args:
783+
url: The full URL to request.
784+
method: HTTP method. Defaults to ``GET``.
785+
headers: Optional request headers. The plugin mutates this dict to add
786+
the payment header on retry.
787+
body: Optional request body. ``dict`` is sent as JSON; ``str`` is sent
788+
as-is. Ignored for ``GET``/``HEAD``.
789+
790+
Returns:
791+
Strands ToolResult dict with ``status`` and ``content``.
792+
"""
793+
request_headers = dict(headers) if headers else {}
794+
method_upper = method.upper()
795+
796+
try:
797+
with httpx.Client(timeout=30.0, follow_redirects=True) as client:
798+
if body is None or method_upper in ("GET", "HEAD"):
799+
resp = client.request(method_upper, url, headers=request_headers)
800+
elif isinstance(body, str):
801+
resp = client.request(method_upper, url, headers=request_headers, content=body)
802+
else:
803+
resp = client.request(method_upper, url, headers=request_headers, json=body)
804+
except httpx.RequestError as exc:
805+
logger.error("http_request failed for %s: %s", url, exc)
806+
return {
807+
"status": "error",
808+
"content": [
809+
{
810+
"text": json.dumps(
811+
{
812+
"statusCode": 0,
813+
"error": f"Request failed: {exc}",
814+
"url": url,
815+
}
816+
)
817+
}
818+
],
819+
}
820+
821+
response_headers = dict(resp.headers)
822+
try:
823+
response_body: Any = resp.json()
824+
except Exception:
825+
response_body = {"text": resp.text}
826+
827+
payload = {
828+
"statusCode": resp.status_code,
829+
"headers": response_headers,
830+
"body": response_body,
831+
}
832+
833+
if resp.status_code == 402:
834+
return {
835+
"status": "success",
836+
"content": [{"text": f"PAYMENT_REQUIRED: {json.dumps(payload)}"}],
837+
}
838+
839+
return {
840+
"status": "success",
841+
"content": [{"text": json.dumps(payload)}],
842+
}

src/bedrock_agentcore/payments/manager.py

Lines changed: 23 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -844,7 +844,10 @@ def process_payment(
844844
payment_input: Payment input details specific to the payment type
845845
user_id: Unique identifier for the user (optional, omitted for bearer auth)
846846
client_token: Optional idempotency token for request uniqueness
847-
payment_connector_id: Optional payment connector ID to route the payment
847+
payment_connector_id: Accepted for backward compatibility but no longer
848+
forwarded to the service. ProcessPayment derives the connector from
849+
the payment instrument; sending paymentConnectorId on this call was
850+
rejected by the API as an unknown parameter.
848851
849852
Returns:
850853
Dictionary containing processPaymentId and transaction details
@@ -873,8 +876,9 @@ def process_payment(
873876
"paymentInput": payment_input,
874877
"clientToken": client_token,
875878
}
876-
if payment_connector_id is not None:
877-
params["paymentConnectorId"] = payment_connector_id
879+
# paymentConnectorId is intentionally NOT included — the ProcessPayment
880+
# API does not accept it and rejects requests that contain it. The
881+
# connector is resolved server-side from the payment instrument.
878882

879883
result = self._payment_client.process_payment(**params)
880884
logger.info("Successfully processed payment for user %s", user_id)
@@ -935,7 +939,10 @@ def generate_payment_header(
935939
network_preferences: Optional list of network identifiers in order of preference.
936940
If not provided, defaults to NETWORK_PREFERENCES from constants.
937941
client_token: Optional unique token for idempotency. If not provided, a new one is generated.
938-
payment_connector_id: Optional payment connector ID to pass to process_payment.
942+
payment_connector_id: Accepted for backward compatibility but no longer
943+
forwarded to process_payment. ProcessPayment derives the connector
944+
from the payment instrument; sending paymentConnectorId on that call
945+
was rejected by the API as an unknown parameter.
939946
940947
Returns:
941948
Dictionary with header name and value (e.g., {"X-PAYMENT": "base64..."} or
@@ -1045,18 +1052,18 @@ def generate_payment_header(
10451052
}
10461053
}
10471054

1048-
process_payment_params = {
1049-
"user_id": user_id,
1050-
"payment_session_id": payment_session_id,
1051-
"payment_instrument_id": payment_instrument_id,
1052-
"payment_type": "CRYPTO_X402",
1053-
"payment_input": payment_input,
1054-
"client_token": client_token,
1055-
}
1056-
if payment_connector_id is not None:
1057-
process_payment_params["payment_connector_id"] = payment_connector_id
1058-
1059-
payment_result = self.process_payment(**process_payment_params)
1055+
# ProcessPayment does not accept paymentConnectorId — the connector is
1056+
# resolved server-side from the payment instrument. The argument is
1057+
# intentionally not forwarded, even when callers (e.g. plugins) supply
1058+
# it via plugin config.
1059+
payment_result = self.process_payment(
1060+
user_id=user_id,
1061+
payment_session_id=payment_session_id,
1062+
payment_instrument_id=payment_instrument_id,
1063+
payment_type="CRYPTO_X402",
1064+
payment_input=payment_input,
1065+
client_token=client_token,
1066+
)
10601067
logger.debug("Payment processed successfully")
10611068

10621069
# Extract cryptoX402 proof from payment result

0 commit comments

Comments
 (0)