diff --git a/tee_gateway/__main__.py b/tee_gateway/__main__.py index 3b317db..8b882f3 100644 --- a/tee_gateway/__main__.py +++ b/tee_gateway/__main__.py @@ -51,6 +51,11 @@ COMPLETIONS_OPG_SESSION_MAX_SPEND, FACILITATOR_URL, OHTTP_OPG_SESSION_MAX_SPEND, + SETTLEMENT_POLL_INTERVAL_SECONDS, + SETTLEMENT_POLL_TIMEOUT_SECONDS, + UPTO_SESSION_IDLE_TIMEOUT_SECONDS, + UPTO_SESSION_MAX_TIMEOUT_SECONDS, + UPTO_SETTLEMENT_SAFETY_MARGIN_SECONDS, ) # --------------------------------------------------------------------------- @@ -267,7 +272,17 @@ def _init_payment_middleware(facilitator_url: str) -> None: Called once from set_provider_keys() after the facilitator URL is known. Swaps application.wsgi_app so all subsequent requests flow through it. """ - facilitator = HTTPFacilitatorClientSync(FacilitatorConfig(url=facilitator_url)) + # wait_for_settlement: the facilitator settles asynchronously (202 + job id). + # The reaper must confirm the on-chain result before marking a session settled, + # otherwise an expired/reverted settlement is silently dropped. + facilitator = HTTPFacilitatorClientSync( + FacilitatorConfig( + url=facilitator_url, + wait_for_settlement=True, + settlement_poll_interval=SETTLEMENT_POLL_INTERVAL_SECONDS, + settlement_poll_timeout=SETTLEMENT_POLL_TIMEOUT_SECONDS, + ) + ) server = x402ResourceServerSync(facilitator) # type: ignore[arg-type] store = SessionStore() @@ -290,6 +305,7 @@ def _init_payment_middleware(facilitator_url: str) -> None: }, ), network=BASE_MAINNET_NETWORK, + max_timeout_seconds=UPTO_SESSION_MAX_TIMEOUT_SECONDS, ), ], extensions={ @@ -313,6 +329,7 @@ def _init_payment_middleware(facilitator_url: str) -> None: }, ), network=BASE_MAINNET_NETWORK, + max_timeout_seconds=UPTO_SESSION_MAX_TIMEOUT_SECONDS, ), ], extensions={ @@ -336,6 +353,7 @@ def _init_payment_middleware(facilitator_url: str) -> None: }, ), network=BASE_MAINNET_NETWORK, + max_timeout_seconds=UPTO_SESSION_MAX_TIMEOUT_SECONDS, ), ], extensions={ @@ -358,7 +376,8 @@ def _init_payment_middleware(facilitator_url: str) -> None: server=server, session_store=store, cost_per_request=100000000000000, # static precheck/fallback estimate - session_idle_timeout=100, + session_idle_timeout=UPTO_SESSION_IDLE_TIMEOUT_SECONDS, + settlement_safety_margin=UPTO_SETTLEMENT_SAFETY_MARGIN_SECONDS, session_cost_calculator=_session_cost_calculator, ) logger.info( diff --git a/tee_gateway/definitions.py b/tee_gateway/definitions.py index d91ae8a..4b0b8c4 100644 --- a/tee_gateway/definitions.py +++ b/tee_gateway/definitions.py @@ -66,21 +66,53 @@ # by compute_session_cost() in pricing.py. # --------------------------------------------------------------------------- -# /v1/chat/completions — maximum OPG spend per session (18 decimals: 100000000000000000 = 0.1 OPG). +# /v1/chat/completions — maximum OPG spend per session (18 decimals: 5000000000000000000 = 5 OPG). # This is the upper-bound amount presented to the client during the x402 pre-check handshake. # The x402 "upto" scheme allows the actual charge to be any value up to this cap; # the real per-request cost is settled dynamically by compute_session_cost() in pricing.py # based on actual token usage, so clients are never overcharged beyond what they consumed. -CHAT_COMPLETIONS_OPG_SESSION_MAX_SPEND: str = "100000000000000000" +CHAT_COMPLETIONS_OPG_SESSION_MAX_SPEND: str = "5000000000000000000" # /v1/ohttp — maximum OPG spend per session (18 decimals: 5000000000000000000 = 5 OPG). # OHTTP image-generation requests can be much more expensive than text chat, # so the relay-paid encrypted endpoint needs a higher per-session cap. OHTTP_OPG_SESSION_MAX_SPEND: str = "5000000000000000000" -# /v1/completions — maximum OPG spend per session (18 decimals: 100000000000000000 = 0.1 OPG). +# /v1/completions — maximum OPG spend per session (18 decimals: 5000000000000000000 = 5 OPG). # This is the upper-bound amount presented to the client during the x402 pre-check handshake. # The x402 "upto" scheme allows the actual charge to be any value up to this cap; # the real per-request cost is settled dynamically by compute_session_cost() in pricing.py # based on actual token usage, so clients are never overcharged beyond what they consumed. -COMPLETIONS_OPG_SESSION_MAX_SPEND: str = "100000000000000000" +COMPLETIONS_OPG_SESSION_MAX_SPEND: str = "5000000000000000000" + +# --------------------------------------------------------------------------- +# x402 "upto" session settlement timing +# --------------------------------------------------------------------------- +# A draw-down ("upto") session reuses ONE signed Permit2 authorization whose +# on-chain deadline is fixed at signing time (session creation) and never +# refreshed. If the accumulated tab is settled after that deadline the on-chain +# ``settle`` reverts and the whole session's payment is silently lost. These +# knobs keep the settlement schedule safely inside the signed window. +# +# INVARIANT (must hold or long sessions drop payments): +# UPTO_SESSION_IDLE_TIMEOUT_SECONDS +# < UPTO_SETTLEMENT_SAFETY_MARGIN_SECONDS +# < UPTO_SESSION_MAX_TIMEOUT_SECONDS +# and the safety margin must exceed SETTLEMENT_POLL_TIMEOUT_SECONDS so a +# force-settled tab has time to confirm on-chain before the deadline. + +# Validity window signed into the Permit2 authorization (deadline = creation + this). +# Advertised to clients as ``maxTimeoutSeconds`` in the 402 requirements. +UPTO_SESSION_MAX_TIMEOUT_SECONDS: int = 600 + +# Force-settle a session (and stop accepting new draw-downs against it) this many +# seconds before its authorization deadline, leaving room for on-chain confirmation. +UPTO_SETTLEMENT_SAFETY_MARGIN_SECONDS: int = 120 + +# Settle a session once it has been idle (no new requests) this long. +UPTO_SESSION_IDLE_TIMEOUT_SECONDS: int = 60 + +# Deferred settlement is asynchronous on the facilitator (202 + job id). Poll the +# job-status endpoint until the on-chain result is known instead of assuming success. +SETTLEMENT_POLL_INTERVAL_SECONDS: float = 2.0 +SETTLEMENT_POLL_TIMEOUT_SECONDS: float = 100.0 diff --git a/tee_gateway/test/test_settlement_timing.py b/tee_gateway/test/test_settlement_timing.py new file mode 100644 index 0000000..e72162c --- /dev/null +++ b/tee_gateway/test/test_settlement_timing.py @@ -0,0 +1,60 @@ +"""Guard tests for the x402 "upto" session settlement timing constants. + +A draw-down ("upto") session reuses ONE signed Permit2 authorization whose +on-chain deadline is fixed at session creation (created_at + max_timeout) and +never refreshed. If the accumulated tab is settled after that deadline the +on-chain ``settle`` reverts and the whole session's payment is silently lost. + +These tests lock in the ordering invariant between the settlement timing knobs +so a future edit to definitions.py can't silently reintroduce the drop. +""" + +from tee_gateway.definitions import ( + SETTLEMENT_POLL_TIMEOUT_SECONDS, + UPTO_SESSION_IDLE_TIMEOUT_SECONDS, + UPTO_SESSION_MAX_TIMEOUT_SECONDS, + UPTO_SETTLEMENT_SAFETY_MARGIN_SECONDS, +) + + +def test_timing_constants_are_positive(): + """Every timing knob must be a positive duration.""" + assert UPTO_SESSION_IDLE_TIMEOUT_SECONDS > 0 + assert UPTO_SETTLEMENT_SAFETY_MARGIN_SECONDS > 0 + assert UPTO_SESSION_MAX_TIMEOUT_SECONDS > 0 + assert SETTLEMENT_POLL_TIMEOUT_SECONDS > 0 + + +def test_idle_timeout_below_safety_margin(): + """An idle session must settle (idle timeout) well before the force-settle + safety window, so normal traffic settles via idle and never races the + deadline.""" + assert UPTO_SESSION_IDLE_TIMEOUT_SECONDS < UPTO_SETTLEMENT_SAFETY_MARGIN_SECONDS + + +def test_safety_margin_below_max_timeout(): + """The force-settle point (deadline - safety_margin) must be strictly before + the authorization deadline, leaving a positive window to settle in.""" + assert UPTO_SETTLEMENT_SAFETY_MARGIN_SECONDS < UPTO_SESSION_MAX_TIMEOUT_SECONDS + + +def test_safety_margin_covers_poll_timeout(): + """Force-settling starts safety_margin seconds before the deadline, and the + reaper then polls the facilitator for up to SETTLEMENT_POLL_TIMEOUT_SECONDS + to confirm the on-chain result. The margin must exceed the poll timeout so a + force-settled tab can confirm before its authorization expires.""" + assert UPTO_SETTLEMENT_SAFETY_MARGIN_SECONDS > SETTLEMENT_POLL_TIMEOUT_SECONDS + + +def test_full_ordering_invariant(): + """The complete invariant documented in definitions.py, in one assertion: + + poll_timeout < safety_margin < max_timeout + idle_timeout < safety_margin + """ + assert ( + UPTO_SESSION_IDLE_TIMEOUT_SECONDS + < UPTO_SETTLEMENT_SAFETY_MARGIN_SECONDS + < UPTO_SESSION_MAX_TIMEOUT_SECONDS + ) + assert SETTLEMENT_POLL_TIMEOUT_SECONDS < UPTO_SETTLEMENT_SAFETY_MARGIN_SECONDS