Skip to content

Commit bbac3d8

Browse files
committed
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 b130f9a commit bbac3d8

3 files changed

Lines changed: 203 additions & 0 deletions

File tree

src/bedrock_agentcore/payments/integrations/config.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,16 @@ class AgentCorePaymentsPluginConfig:
4747
before passing your own. Auto-payment of 402 responses still works
4848
against any tool whose output carries the ``PAYMENT_REQUIRED:``
4949
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.
5060
"""
5161

5262
payment_manager_arn: str
@@ -63,6 +73,7 @@ class AgentCorePaymentsPluginConfig:
6373
token_provider: Optional[Callable[[], str]] = None
6474
payment_tool_allowlist: Optional[List[str]] = None
6575
provide_http_request: bool = True
76+
post_payment_retry_delay_seconds: float = 3.0
6677

6778
def __post_init__(self) -> None:
6879
"""Validate configuration after initialization."""
@@ -99,6 +110,18 @@ def __post_init__(self) -> None:
99110
if not isinstance(self.provide_http_request, bool):
100111
raise ValueError(f"provide_http_request must be a boolean, got {type(self.provide_http_request).__name__}")
101112

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+
102125
def update_payment_session_id(self, payment_session_id: str) -> None:
103126
"""Update the payment session ID.
104127

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import json
44
import logging
5+
import time
56
import uuid
67
from typing import Any, Dict, Optional, Union
78

@@ -251,6 +252,20 @@ def after_tool_call(self, event: AfterToolCallEvent) -> None:
251252
# after this retry, we know it's a server-side rejection, not a signing failure.
252253
self._mark_successful_signing(event)
253254

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+
254269
# Set retry flag to re-execute the tool with payment credentials.
255270
event.retry = True
256271
self._reset_interrupt_retry_count(event)

tests/bedrock_agentcore/payments/integrations/strands/test_plugin.py

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1800,3 +1800,168 @@ def test_allowlist_none_allows_all_tools(self):
18001800

18011801
mock_handler.extract_status_code.assert_called_once()
18021802
mock_pm_instance.generate_payment_header.assert_called_once()
1803+
1804+
1805+
class TestPostPaymentRetryDelay:
1806+
"""Tests for post_payment_retry_delay_seconds backoff before tool retry.
1807+
1808+
The x402 EIP-3009 transferWithAuthorization contract requires
1809+
block.timestamp > validAfter (strict greater-than). When the signing
1810+
service sets validAfter near the current time, fast facilitators submit
1811+
in the same second the signature was minted and hit a deterministic
1812+
revert. The plugin sleeps post_payment_retry_delay_seconds between
1813+
signing and re-invoking the tool to let the chain advance.
1814+
"""
1815+
1816+
@patch("bedrock_agentcore.payments.integrations.strands.plugin.time.sleep")
1817+
@patch("bedrock_agentcore.payments.integrations.strands.plugin.PaymentManager")
1818+
def test_default_delay_is_three_seconds(self, mock_payment_manager_class, mock_sleep):
1819+
"""Default post_payment_retry_delay_seconds is 3.0 — sleep is called with 3.0."""
1820+
config = AgentCorePaymentsPluginConfig(
1821+
payment_manager_arn="arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/test",
1822+
user_id="test-user",
1823+
payment_instrument_id="payment-instrument-123",
1824+
payment_session_id="payment-session-456",
1825+
)
1826+
assert config.post_payment_retry_delay_seconds == 3.0
1827+
1828+
mock_pm_instance = MagicMock()
1829+
mock_pm_instance.generate_payment_header.return_value = {"X-PAYMENT": "base64"}
1830+
plugin = AgentCorePaymentsPlugin(config=config)
1831+
plugin.payment_manager = mock_pm_instance
1832+
1833+
event, agent = _create_event_with_agent(
1834+
{
1835+
"result": [{"text": f"PAYMENT_REQUIRED: {json.dumps({'statusCode': 402, 'headers': {}, 'body': {}})}"}],
1836+
"tool_use": {"name": "http_request", "toolUseId": "tool-123", "input": {"headers": {}}},
1837+
"invocation_state": {},
1838+
"retry": False,
1839+
}
1840+
)
1841+
plugin.after_tool_call(event)
1842+
1843+
assert event.retry is True
1844+
mock_sleep.assert_called_once_with(3.0)
1845+
1846+
@patch("bedrock_agentcore.payments.integrations.strands.plugin.time.sleep")
1847+
@patch("bedrock_agentcore.payments.integrations.strands.plugin.PaymentManager")
1848+
def test_custom_delay_value(self, mock_payment_manager_class, mock_sleep):
1849+
"""A custom positive delay is honored verbatim."""
1850+
config = AgentCorePaymentsPluginConfig(
1851+
payment_manager_arn="arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/test",
1852+
user_id="test-user",
1853+
payment_instrument_id="payment-instrument-123",
1854+
payment_session_id="payment-session-456",
1855+
post_payment_retry_delay_seconds=1.5,
1856+
)
1857+
1858+
mock_pm_instance = MagicMock()
1859+
mock_pm_instance.generate_payment_header.return_value = {"X-PAYMENT": "base64"}
1860+
plugin = AgentCorePaymentsPlugin(config=config)
1861+
plugin.payment_manager = mock_pm_instance
1862+
1863+
event, agent = _create_event_with_agent(
1864+
{
1865+
"result": [{"text": f"PAYMENT_REQUIRED: {json.dumps({'statusCode': 402, 'headers': {}, 'body': {}})}"}],
1866+
"tool_use": {"name": "http_request", "toolUseId": "tool-123", "input": {"headers": {}}},
1867+
"invocation_state": {},
1868+
"retry": False,
1869+
}
1870+
)
1871+
plugin.after_tool_call(event)
1872+
1873+
mock_sleep.assert_called_once_with(1.5)
1874+
1875+
@patch("bedrock_agentcore.payments.integrations.strands.plugin.time.sleep")
1876+
@patch("bedrock_agentcore.payments.integrations.strands.plugin.PaymentManager")
1877+
def test_zero_delay_skips_sleep(self, mock_payment_manager_class, mock_sleep):
1878+
"""post_payment_retry_delay_seconds=0 skips the sleep entirely."""
1879+
config = AgentCorePaymentsPluginConfig(
1880+
payment_manager_arn="arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/test",
1881+
user_id="test-user",
1882+
payment_instrument_id="payment-instrument-123",
1883+
payment_session_id="payment-session-456",
1884+
post_payment_retry_delay_seconds=0,
1885+
)
1886+
1887+
mock_pm_instance = MagicMock()
1888+
mock_pm_instance.generate_payment_header.return_value = {"X-PAYMENT": "base64"}
1889+
plugin = AgentCorePaymentsPlugin(config=config)
1890+
plugin.payment_manager = mock_pm_instance
1891+
1892+
event, agent = _create_event_with_agent(
1893+
{
1894+
"result": [{"text": f"PAYMENT_REQUIRED: {json.dumps({'statusCode': 402, 'headers': {}, 'body': {}})}"}],
1895+
"tool_use": {"name": "http_request", "toolUseId": "tool-123", "input": {"headers": {}}},
1896+
"invocation_state": {},
1897+
"retry": False,
1898+
}
1899+
)
1900+
plugin.after_tool_call(event)
1901+
1902+
# Tool should still be set to retry — sleep is the only thing skipped.
1903+
assert event.retry is True
1904+
mock_sleep.assert_not_called()
1905+
1906+
@patch("bedrock_agentcore.payments.integrations.strands.plugin.time.sleep")
1907+
@patch("bedrock_agentcore.payments.integrations.strands.plugin.PaymentManager")
1908+
def test_sleep_does_not_fire_when_payment_fails(self, mock_payment_manager_class, mock_sleep):
1909+
"""If signing fails, no sleep should run — we never reach the retry path."""
1910+
from bedrock_agentcore.payments.manager import PaymentInstrumentNotFound
1911+
1912+
config = AgentCorePaymentsPluginConfig(
1913+
payment_manager_arn="arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/test",
1914+
user_id="test-user",
1915+
payment_instrument_id="payment-instrument-123",
1916+
payment_session_id="payment-session-456",
1917+
)
1918+
1919+
mock_pm_instance = MagicMock()
1920+
mock_pm_instance.generate_payment_header.side_effect = PaymentInstrumentNotFound("missing")
1921+
plugin = AgentCorePaymentsPlugin(config=config)
1922+
plugin.payment_manager = mock_pm_instance
1923+
1924+
event, agent = _create_event_with_agent(
1925+
{
1926+
"result": [{"text": f"PAYMENT_REQUIRED: {json.dumps({'statusCode': 402, 'headers': {}, 'body': {}})}"}],
1927+
"tool_use": {"name": "http_request", "toolUseId": "tool-123", "input": {"headers": {}}},
1928+
"invocation_state": {},
1929+
"retry": False,
1930+
}
1931+
)
1932+
plugin.after_tool_call(event)
1933+
1934+
mock_sleep.assert_not_called()
1935+
1936+
def test_validator_rejects_negative_delay(self):
1937+
"""post_payment_retry_delay_seconds cannot be negative."""
1938+
import pytest
1939+
1940+
with pytest.raises(ValueError, match="post_payment_retry_delay_seconds must be >= 0"):
1941+
AgentCorePaymentsPluginConfig(
1942+
payment_manager_arn="arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/test",
1943+
user_id="test-user",
1944+
post_payment_retry_delay_seconds=-1.0,
1945+
)
1946+
1947+
def test_validator_rejects_non_numeric_delay(self):
1948+
"""post_payment_retry_delay_seconds must be int/float, not bool/str."""
1949+
import pytest
1950+
1951+
with pytest.raises(ValueError, match="post_payment_retry_delay_seconds must be a number"):
1952+
AgentCorePaymentsPluginConfig(
1953+
payment_manager_arn="arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/test",
1954+
user_id="test-user",
1955+
post_payment_retry_delay_seconds="3", # type: ignore[arg-type]
1956+
)
1957+
1958+
def test_validator_rejects_bool_delay(self):
1959+
"""bool sneaks through isinstance(int) — explicitly reject."""
1960+
import pytest
1961+
1962+
with pytest.raises(ValueError, match="post_payment_retry_delay_seconds must be a number"):
1963+
AgentCorePaymentsPluginConfig(
1964+
payment_manager_arn="arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/test",
1965+
user_id="test-user",
1966+
post_payment_retry_delay_seconds=True, # type: ignore[arg-type]
1967+
)

0 commit comments

Comments
 (0)