Skip to content

fix(payments): drop unsupported paymentConnectorId + add http_request plugin tool + EIP-3009 timing fix#493

Merged
jariy17 merged 6 commits into
mainfrom
fix/process-payment-remove-connector-id
May 22, 2026
Merged

fix(payments): drop unsupported paymentConnectorId + add http_request plugin tool + EIP-3009 timing fix#493
jariy17 merged 6 commits into
mainfrom
fix/process-payment-remove-connector-id

Conversation

@aidandaly24
Copy link
Copy Markdown
Contributor

@aidandaly24 aidandaly24 commented May 22, 2026

Summary

Four related changes that get the AgentCorePaymentsPlugin's 402 auto-pay path working end-to-end and verified on chain:

  1. Fix: ProcessPayment was failing at the boto3 layer with an Unknown parameter: paymentConnectorId validation error. Drop the field — public Data Plane API does not accept it.
  2. Feat: Ship an http_request @tool on AgentCorePaymentsPlugin so adding the plugin to a Strands agent gives a turnkey paid-HTTP experience without strands-agents-tools or hand-rolled httpx.
  3. Feat: Add provide_http_request: bool = True config flag so callers shipping their own http_request can opt out without hitting Strands' duplicate-tool-name ValueError.
  4. Fix: Add post_payment_retry_delay_seconds: float = 3.0 so the plugin sleeps one chain block after signing before retrying. Without this, the EIP-3009 strict timestamp check on USDC fires before the chain advances and the seller returns a misleading 402.

End-to-end verified on Base Sepolia. Real settle transaction:
0x0e542e2d5c3c97521bd47231d299c6d56841fe0865f63ebfaa92d6f47e28b6b6 — 0.001 USDC moved from a CDP-managed wallet to the merchant via SDK-generated PAYMENT-SIGNATURE.

Commit 1 — Drop unsupported paymentConnectorId from ProcessPayment

What broke

Hitting a 402 with AgentCorePaymentsPlugin triggered the auto-pay path:

  1. generate_payment_header called get_payment_instrument (succeeded)
  2. generate_payment_header then called process_payment, forwarding payment_connector_id
  3. process_payment appended paymentConnectorId to the request params
  4. botocore validated against the bedrock-agentcore service model and raised:
Parameter validation failed:
Unknown parameter in input: "paymentConnectorId", must be one of:
userId, agentName, paymentManagerArn, paymentSessionId,
paymentInstrumentId, paymentType, paymentInput, clientToken

Confirmed against public docs: https://docs.aws.amazon.com/bedrock-agentcore/latest/APIReference/API_ProcessPayment.html — the request body has no paymentConnectorId field.

Why unit tests didn't catch it

Existing tests mock _payment_client.process_payment with MagicMock, which accepts any kwargs without validating against the service model. Bug only surfaces against a real (or botocore-validated) client.

Fix

  • process_payment: stop adding paymentConnectorId to the request params. The connector is resolved server-side from the payment instrument.
  • generate_payment_header: stop forwarding payment_connector_id to process_payment.
  • Both keep payment_connector_id in their signatures for backward compatibility. Docstrings flag that the value is accepted but no longer forwarded.
  • Adds a regression test asserting paymentConnectorId is absent from process_payment kwargs even when callers pass it explicitly.

paymentConnectorId is still forwarded for the API operations where it IS valid (CreatePaymentInstrument, GetPaymentInstrument, ListPaymentInstruments, DeletePaymentInstrument, etc.) — those code paths are unchanged.

Commit 2 — http_request @tool on AgentCorePaymentsPlugin

Why on the plugin

Aligns with how getPaymentInstrument / listPaymentInstruments / getPaymentSession / getPaymentInstrumentBalance are already exposed. Adding the plugin to a Strands agent now produces a turnkey paid-HTTP experience without requiring strands-agents-tools. httpx is already a hard SDK dep; no new dependencies.

Wire format

The 402 envelope (statusCode + headers + body) matches what GenericPaymentHandler / HttpRequestPaymentHandler already parse via the PAYMENT_REQUIRED: content-block marker. Tool returns a Strands ToolResult dict ({status, content}) — the right shape for Strands' tool-result decorator path.

Tests (11 new)

  • 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

Commit 3 — provide_http_request opt-out config flag

Why

Strands' ToolRegistry.register_tool raises ValueError on duplicate tool names. Without an opt-out, a caller who passes their own http_request to Agent(tools=[...], plugins=[plugin]) crashes at agent construction.

Behavior

  • AgentCorePaymentsPluginConfig.provide_http_request: bool = True (default preserves current behavior)
  • When False, the plugin filters http_request out of self._tools immediately after super().__init__(). Other plugin tools unaffected.
  • auto_payment interception is independent — the after_tool_call hook still triggers on any tool whose output carries the PAYMENT_REQUIRED: marker.

Tests (5 new)

  • default registers http_request
  • opt-out drops only http_request; other plugin tools remain
  • opt-out preserves after_tool_call/before_tool_call hooks
  • config validator rejects non-bool values
  • init_agent works with the flag off

Commit 4 — post_payment_retry_delay_seconds for EIP-3009 timing race

What broke

USDC v2's transferWithAuthorization enforces:

require(now > validAfter, "FiatTokenV2: authorization is not yet valid");

Strict greater-than. When the signing service sets validAfter ≈ current_unix_time, fast merchant facilitators submit transferWithAuthorization in the same second the signature was minted, and the contract reverts because block.timestamp == validAfter. The seller decodes the on-chain failure and returns 402 with reason invalid_payload or invalid_exact_evm_transaction_simulation_failed.

I confirmed this empirically: simulated the SAME signed payload via eth_call within the validAfter second → revert. Waited 5 seconds, simulated again → success. Submitted to merchant after waiting → real on-chain settle (tx above).

Fix

Add post_payment_retry_delay_seconds: float = 3.0 config knob. Plugin sleeps that many seconds in after_tool_call between calling generate_payment_header and 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.

Tests (7 new)

  • default delay is 3.0
  • custom positive delay is honored verbatim
  • 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))

Test plan

  • All 502 payments tests pass (was 479 + 23 new across the four commits)
  • ruff check passes; ruff format --check passes
  • End-to-end on Base Sepolia: real on-chain USDC settle
    • tx: 0x0e542e2d5c3c97521bd47231d299c6d56841fe0865f63ebfaa92d6f47e28b6b6
    • 0.001 USDC from 0x7139d0a9fEf181FBc06A4c48e43143622466fAE4 (CDP-managed wallet) to the merchant
    • Decoded PAYMENT-RESPONSE shows success: true, payer: 0x7139..., transaction: 0x0e54...
    • HTTP 200 response from https://x402.bitcoinsapi.com/weather with the paid body

Backward compatibility

  • API-level: ProcessPayment requests no longer carry an unknown field — fixes calls that were 100% broken.
  • SDK-level: kwarg signatures unchanged; callers that previously passed payment_connector_id still work, the value is now ignored on the ProcessPayment path specifically.
  • Plugin-level: existing tools unchanged; http_request is purely additive and gated by provide_http_request=True (default).
  • Config-level: both new flags default to safe values that match the verified end-to-end behavior.

…t 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.
@aidandaly24 aidandaly24 requested a review from a team May 22, 2026 02:41
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 22, 2026

✅ No Breaking Changes Detected

No public API breaking changes found in this PR.

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.
@aidandaly24 aidandaly24 changed the title fix(payments): drop unsupported paymentConnectorId from ProcessPayment call fix(payments): drop unsupported paymentConnectorId from ProcessPayment + add http_request plugin tool May 22, 2026
aidandaly24 added a commit to aws/agentcore-cli that referenced this pull request May 22, 2026
…prompt update

The deployed agent runtime role was missing every Get/List/Create payment
instrument+session action and ProcessPayment. The L3 stack only granted
sts:AssumeRole on the ProcessPaymentRole, which carries only
ProcessPayment — and the SDK plugin's auto-pay path calls
GetPaymentInstrument on the runtime's own credentials before any role
assumption. Without this grant the plugin fails with AccessDeniedException
on the very first 402 it tries to settle.

Add the seven required actions to the runtime role's inline policy in
the vended CDK stack template (src/assets/cdk/lib/cdk-stack.ts). Scoped
to the manager ARN.

Also update PAYMENT_SYSTEM_PROMPT in the Strands payments capability to
mention the http_request tool, which the AgentCorePaymentsPlugin now
provides automatically (see SDK PR
aws/bedrock-agentcore-sdk-python#493). The other prompt lines for
get_payment_session / get_payment_instrument_balance /
list_payment_instruments are unchanged — those tools were already
provided by the plugin.

Snapshot updated to reflect the cdk-stack.ts addition.
…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.
@aidandaly24 aidandaly24 changed the title fix(payments): drop unsupported paymentConnectorId from ProcessPayment + add http_request plugin tool with opt-out fix(payments): drop unsupported paymentConnectorId + add http_request plugin tool + EIP-3009 timing fix May 22, 2026
}

return {
"status": "success",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: non-402 HTTP errors (500, 503, 429) come back as status: "success" which is kinda misleading for the LLM — it sees "success" and may not dig into the nested JSON to find the actual status code. could be a follow up to either return "error" for 4xx/5xx or at least surface the status code more prominently in the result shape.

# wants to ship their own http_request, drop ours so Strands' tool registry
# doesn't raise ValueError on duplicate tool name.
if not self.config.provide_http_request:
self._tools = [t for t in self._tools if t.tool_name != "http_request"]
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: self._tools is a private attr on the Strands Plugin base class — if they rename or restructure it in a future version, this opt-out breaks silently (http_request gets registered even when provide_http_request=False). might be worth a follow up to see if strands exposes a public API for deregistering tools, or at least pin a comment here noting the coupling so we catch it if strands changes.

@jariy17 jariy17 enabled auto-merge (squash) May 22, 2026 13:55
Copy link
Copy Markdown
Contributor

@jariy17 jariy17 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a few follow ups no blockers

@jariy17 jariy17 merged commit d5428b2 into main May 22, 2026
36 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants