Skip to content

fix(x402): treat expires_at == 0 as the "no expiry" sentinel#95

Open
kite-builds wants to merge 1 commit into
kcolbchain:mainfrom
kite-builds:fix/payment-offer-zero-expiry-sentinel
Open

fix(x402): treat expires_at == 0 as the "no expiry" sentinel#95
kite-builds wants to merge 1 commit into
kcolbchain:mainfrom
kite-builds:fix/payment-offer-zero-expiry-sentinel

Conversation

@kite-builds
Copy link
Copy Markdown
Contributor

Problem

PaymentOffer.is_expired() only special-cased None:

def is_expired(self) -> bool:
    if self.expires_at is None:
        return False
    return time.time() > self.expires_at

So an offer with expires_at == 0 evaluates time.time() > 0, which is always True → the offer reports itself expired, and X402Middleware._validate_offer() rejects it with Payment offer expired at 0.

Why 0 should mean "no expiry"

0 is the project's own documented "no expiry" sentinel on the ZAP wire format. From switchboard/zap_transport.py:

  • schema comment: .uint64("expires_at") # 0 sentinel = "no expiry"
  • encode: ob.set_uint64(f["expires_at"], offer.expires_at or 0) (None0)
  • decode: expires_at=int(expires) if expires else None (0None)

So a never-expiring offer that round-trips through the binary path (None0) — or any 402 server that sends {"expiresAt": 0} to mean "never expires" — is then wrongly treated as expired on the JSON/header path. The two transports disagree.

Reproduce

from switchboard.x402_middleware import PaymentOffer
import json
offer = PaymentOffer.from_header(json.dumps(
    {"amount": "1000", "recipient": "0x1", "chainId": 1, "expiresAt": 0}))
assert offer.is_expired() is False  # currently True

Fix

Treat a falsy expires_at (None or 0) as "never expires", aligning the JSON/header path with the ZAP binary path. Behaviour for None and for positive timestamps is unchanged.

Tests

Adds two regression tests (the unit case + the _validate_offer end-to-end case). Full suite: 173 passed, 56 skipped.

🤖 Generated with Claude Code

PaymentOffer.is_expired() only special-cased None, so an offer with
expires_at == 0 evaluated `time.time() > 0` and always reported expired —
X402Middleware._validate_offer then rejected it with "Payment offer expired".

But 0 is the project's own documented "no expiry" sentinel on the ZAP wire
format: zap_transport.py encodes `offer.expires_at or 0` and decodes back with
`int(expires) if expires else None`, and the schema comment states
`# 0 sentinel = "no expiry"`. So a never-expiring offer round-tripped through
the binary path (None -> 0) and then evaluated on the JSON/header path was
wrongly treated as expired — a cross-transport inconsistency.

Fix: treat a falsy expires_at (None or 0) as "never expires" in is_expired(),
aligning the JSON/header path with the ZAP binary path. Behaviour for None and
for positive timestamps is unchanged.

Adds two regression tests (the unit case + the _validate_offer end-to-end case).
Full suite: 173 passed, 56 skipped.
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.

1 participant