Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ jobs:
exit 1
fi
version=$(python scripts/release.py)
if git show-ref --verify --quiet "refs/tags/v$version"; then
if [[ "$GITHUB_EVENT_NAME" != pull_request ]] && git show-ref --verify --quiet "refs/tags/v$version"; then
test "$(git rev-parse "v$version^{commit}")" = "$GITHUB_SHA"
fi
echo "version=$version" >> "$GITHUB_OUTPUT"
Expand Down Expand Up @@ -106,7 +106,10 @@ jobs:
node scripts/interoperability.mjs ../node-sdk "$RUNNER_TEMP/release-reports/interoperability.json"
make build
uv run --locked python scripts/verify_distribution.py --dist-dir dist
python scripts/release.py --dist-dir dist
# Feature PRs can keep the published version; release candidates must be uploadable.
if [[ "$GITHUB_EVENT_NAME" != pull_request ]]; then
python scripts/release.py --dist-dir dist
fi
cp coverage.json "$RUNNER_TEMP/release-reports/coverage.json"
(cd dist && sha256sum * > "$RUNNER_TEMP/release-reports/SHA256SUMS")
- uses: actions/upload-artifact@v7
Expand Down
99 changes: 98 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,109 @@ pinned InFlow contract fixtures and produce reports for Python 3.11–3.14.
Protocol and framework dependencies are optional. The `mpp` and `x402` extras
select payment libraries; `evm` and `svm` select x402 external-wallet dependencies;
`mcp` selects MCP dependencies for both protocols; `fastapi` selects the optional
web framework. The base package does not install those extras.
web framework. The `tap` extra installs Ed25519 cryptography for agent recognition,
independently of payment libraries. The base package does not install those extras.

Runtime dependencies use compatible version ranges; `uv.lock` records the exact
versions used in development and CI. MCP dependencies use the 1.x line required by
pympp. No framework or blockchain package is imported by `import inflowpay`.

## TAP agent recognition

Visa Trusted Agent Protocol (TAP) lets a Seller recognize a request signed by a
trusted Agent key. It does **not** identify the customer, authorize access to their
account, approve a purchase, or prove payment. Keep application authentication,
AEP enrollment, and MPP or x402 payment checks separate.

```sh
pip install 'inflowpay[tap]'
```

The import is `inflowpay.tap.seller`. No InFlow account or API key is needed for
verification. The default resolver retrieves public keys from
`https://mcp.visa.com/.well-known/jwks`; a successful request needs a signature
created with a private key registered with that trusted source.

```python
from inflowpay.tap.seller import TapRequest, TapVerifier


async def verify_agent_request(verifier: TapVerifier, method, absolute_url, headers, body):
return await verifier.verify(
TapRequest(
method=method,
url=absolute_url,
headers=headers,
body=body,
)
)
```

Create the verifier at server startup and close it at shutdown with `aclose()`
or an application-lifetime `async with TapVerifier()` scope. Pass that verifier
to request handlers, as the [runnable TAP example](examples/README.md#tap-agent-recognition)
does. Creating one per request discards its key cache and process-local replay
history. `verify()` returns immutable `TapVerificationFacts`; alternatively,
`await verifier.with_verified(request, async_handler)` invokes your handler only
after signature verification and the replay claim succeed, returning its result.

Supply the method without changing its case, the absolute external URL with its
original encoded path and query, and the exact body bytes. `body=None` means no
body; `b""` is a supplied empty body and still requires signed `content-digest`
and `content-type` fields. Strings are encoded as UTF-8. Do not parse and
reserialize JSON before verification. Header names are case-insensitive. A mapping
can contain string values or lists of values; `httpx.Headers` is also accepted.
Duplicate values for required fields are rejected, not silently selected.
Obtain the external origin from trusted deployment configuration rather than
unvalidated `Forwarded` or `X-Forwarded-*` headers.

This implements InFlow's restricted Visa profile: one `sig2` Ed25519 signature
covering method, authority, path and query, plus body digest and content type when
a body is supplied. Signature lifetimes are at most eight minutes. Both
`ed25519` and `Ed25519` are accepted; facts report `ed25519`. Intent is `browse`
or `pay`, reflecting the signer's tag, not proof of payment. Repeated signature
parameters use their last value while retaining their first position, as required
by Structured Fields; duplicate covered components remain invalid.

### Keys, replay storage and cleanup

`VisaTapKeyResolver` accepts `url`, `cache_ttl` (3600 seconds), `cache_max_age`
(86400 seconds), `timeout` (3 seconds), `clock` and an optional HTTPX `transport`.
It shares concurrent refreshes, replaces the whole key set on success, and
remembers missing identifiers within that cache generation. A failed retrieval
can use a previously trusted matching key within `cache_max_age`; it cannot
introduce an unknown key or restore one removed by a successful refresh.
Redirects are not followed. The resolver owns its transport and closes it with
`aclose()`; do not share that transport with other clients.

For another trusted key source, pass `key_resolver` to `TapVerifier`. Implement
the public `TapKeyResolver` protocol's async `resolve(keyid, algorithm)` method,
returning a trusted `cryptography` `Ed25519PublicKey` or `None`. The request's
untrusted key identifier must not choose a network destination. The verifier
still checks the signature. An explicitly supplied resolver remains application-owned;
the verifier closes only the default resolver it creates itself.

`MemoryTapReplayStore` atomically claims a `(keyid, nonce)` pair until expiration,
but only within one process. For multiple workers or servers, supply a shared
`TapReplayStore` with an async, atomic `claim(keyid, nonce, expires)` operation.
Return `False` for a retained duplicate; let storage failures propagate. Invalid
signatures never consume a claim, and store failures never invoke the handler.
Nonce replay protection is not payment idempotency.

Python clocks return Unix seconds (`time.time` by default), rather than Node's
milliseconds. Validity is checked when verification starts, not again after
key retrieval or replay storage. Cancelling a caller stops its wait without
cancelling a shared key refresh needed by other requests. Closing the resolver
cancels and drains that refresh. Use a resolver on one event loop, and keep it
open until requests have finished.

`TapVerificationError.code` distinguishes malformed input, digest mismatch,
invalid lifetime, not-yet-valid or expired signatures, missing or unavailable
keys, invalid signatures, and replayed nonces. Exceptions from a custom resolver,
store, or handler propagate unchanged. Your application chooses the HTTP response;
the example returns a generic 401 for verification failures without exposing key
service details. TAP does not enable or modify any payment route automatically.

## Client configuration and lifetime

`ClientOptions` selects `production` (the default, `https://api.inflowpay.ai`) or
Expand Down
8 changes: 8 additions & 0 deletions conformance/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,14 @@ artifact per Python version. A failed required case fails the job.
| MPP | Public codecs, `BuyerMethod.create_credential`, Seller preparation/validation, and pympp's `broadcast_credential` and `pay` | Wire data, approvals, cancellation, Buyer subscriptions, validation before broadcast, idempotency, and route binding. |
| x402 | Public identifier helpers, `Seller.offers`/`route`, `Buyer.prepare`, and `Facilitator.verify`/`settle` | Offer construction, sponsorship declarations, approval lifecycle, cancellation, concurrent waits, payment identifiers, and verification/settlement. |

The TAP suite calls `TapVerifier.with_verified`, `VisaTapKeyResolver` and
`MemoryTapReplayStore` through the public `inflowpay.tap.seller` module. Its 92
cases use real Ed25519 signatures, controlled clocks and loopback key endpoints.
They cover request binding, Structured Field parameters, validity intervals,
replay, cache replacement, outages and application-supplied failures. The adapter
does not parse signatures, construct signature bases or implement verification.
Handler and replay-claim counts come from the actual callback and store boundary.

The runner owns the loopback HTTP servers, expected request sequences and results.
The Python process receives inputs, not expected outcomes or response scripts.
Polling, retry, cancellation, validation and broadcast remain in the SDK and its
Expand Down
5 changes: 5 additions & 0 deletions conformance/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from starlette.responses import JSONResponse
from x402.schemas import PaymentPayload, PaymentRequirements, ResourceInfo

from conformance.tap import tap_execute
from inflowpay import ClientOptions, InflowApiError, mpp, x402
from inflowpay.mpp.buyer import (
BuyerMethod,
Expand Down Expand Up @@ -438,6 +439,10 @@ async def respond(request: Data) -> Data:
result = await x402_execute(operation, data)
elif operation.startswith("runtime."):
result = await runtime_execute(operation, data)
elif operation == "tap.seller.verify":
if data.get("resolver") == "http":
options(data)
result = await tap_execute(data)
else:
raise RuntimeError("Unknown operation")
observation = {"result": result}
Expand Down
2 changes: 1 addition & 1 deletion conformance/inflow-specs.lock.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"repository": "inflowpayai/inflow-specs",
"revision": "d79cc3ab3b3acde196e41d15783ed3d119f19379"
"revision": "7737099308106a1cabaab57de1d621d881cce0e0"
}
108 changes: 108 additions & 0 deletions conformance/tap.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import asyncio
import base64
from contextlib import AsyncExitStack
from copy import deepcopy
from dataclasses import asdict
from typing import Any

from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey

from inflowpay.tap.seller import (
MemoryTapReplayStore,
TapKeyResolver,
TapRequest,
TapVerificationError,
TapVerificationFacts,
TapVerifier,
VisaTapKeyResolver,
)


class InjectedFailure(Exception):
pass


async def tap_execute(data: dict[str, Any]) -> dict[str, Any]:
now = data["steps"][0]["now_ms"] / 1000
handler_calls = 0
claim_calls = 0
memory = MemoryTapReplayStore(lambda: now)

class Store:
async def claim(self, keyid: str, nonce: str, expires: int) -> bool:
nonlocal claim_calls
claim_calls += 1
if data.get("store_failure"):
raise InjectedFailure("CUSTOM_STORE_FAILED")
return await memory.claim(keyid, nonce, expires)

class Resolver:
async def resolve(self, keyid: str, algorithm: str) -> Ed25519PublicKey | None:
nonlocal now
if data.get("resolver_failure"):
raise InjectedFailure("CUSTOM_RESOLVER_FAILED")
if "resolver_completion_ms" in data:
now = data["resolver_completion_ms"] / 1000
if keyid != data["key"]["kid"] or algorithm != "ed25519":
return None
return Ed25519PublicKey.from_public_bytes(
base64.urlsafe_b64decode(data["key"]["x"] + "=")
)

async with AsyncExitStack() as stack:
resolver: TapKeyResolver = Resolver()
if data.get("resolver") == "http":
resolver = await stack.enter_async_context(
VisaTapKeyResolver(
url=data["base_url"] + "/keys",
clock=lambda: now,
cache_ttl=data.get("cache_ttl_ms", 3600000) / 1000,
cache_max_age=data.get("cache_max_age_ms", 86400000) / 1000,
)
)
verifier = await stack.enter_async_context(
TapVerifier(
key_resolver=resolver,
replay_store=Store(),
clock=lambda: now,
)
)
steps = []

async def handler(facts: TapVerificationFacts) -> None:
nonlocal handler_calls
handler_calls += 1
value = asdict(facts)
value["coveredComponents"] = list(value.pop("covered_components"))
accepted.append(value)

async def verify(request: TapRequest) -> None:
before = deepcopy(request)
try:
await verifier.with_verified(request, handler)
except TapVerificationError as error:
rejected.append(error.code)
except InjectedFailure as error:
rejected.append(str(error))
if request != before:
raise RuntimeError("TAP request was mutated")

for step in data["steps"]:
now = step["now_ms"] / 1000
accepted: list[dict[str, Any]] = []
rejected: list[str] = []

requests = [
TapRequest(
method=item["method"],
url=item["url"],
headers=item["headers"],
body=base64.b64decode(item["body_base64"], validate=True)
if "body_base64" in item
else None,
)
for item in step["requests"]
]
await asyncio.gather(*(verify(request) for request in requests))
steps.append({"accepted": accepted, "rejected": sorted(rejected)})
return {"steps": steps, "handler_calls": handler_calls, "claim_calls": claim_calls}
43 changes: 41 additions & 2 deletions examples/README.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
# Run a Sandbox payment
# Run the examples

These four programs connect to **InFlow Sandbox**. The Sellers run on your computer;
The four payment programs connect to **InFlow Sandbox**. The Sellers run on your computer;
configuration, approvals, and payments use Sandbox accounts. They are not simulated
payments. Run the commands from the repository root with Python 3.11 or newer.

## Set up your accounts

These accounts are for payment examples. The separate [TAP example](#tap-agent-recognition)
verifies signed requests without an InFlow account or payment.

1. Register at [InFlow Sandbox](https://sandbox.inflowpay.ai). Accepting payments
requires a **Seller** account and an API key from its dashboard. A Developer
key cannot be used as a Seller key.
Expand Down Expand Up @@ -133,6 +136,42 @@ verify settlement. These programs print neither payment credentials nor signatur
The Sellers bind only to loopback and have no application login. Add your own
application authentication when required; paying is not a substitute for logging in.

## TAP agent recognition

The [TAP Seller](tap_seller.py) recognizes signed Agent requests independently of
payments. It needs no InFlow account, API key, or balance. From this checkout:

```sh
make sync
export PUBLIC_ORIGIN='http://127.0.0.1:3002'
uv run --locked python -m examples.tap_seller
curl -i http://127.0.0.1:3002/api/catalog
```

The unsigned request returns HTTP 401 with `{"error":"TAP verification failed"}`.
For HTTP 200, send a request signed by an Agent whose public key is available from
Visa's trusted key endpoint. That signature must cover the method, external
authority, encoded path and query, and, for POST bodies, the exact bytes' digest
and content type. The response contains verified Agent facts and a small catalog;
it does not grant access to a customer's account or charge them.

`PUBLIC_ORIGIN` is the origin the Agent signs. Behind a proxy, set it to the public
HTTPS origin, not the internal listening address. The example deliberately ignores
client-supplied forwarding headers. A proxy must preserve the signed path, query,
method, content type and body bytes. The example binds only to loopback, accepts
GET and POST, limits bodies to one mebibyte, and uses a process-local replay store.
Production multi-worker applications need a shared atomic replay store.

For a separate application, install `inflowpay[tap,fastapi]` and `uvicorn`. The TAP
SDK itself has no FastAPI requirement. Keep one `TapVerifier` open for the server's
lifetime, as `run()` does, then add your own account authorization and payment
checks inside the protected handler if needed.

The automated example tests create real Ed25519 signatures with synthetic keys
and exercise both the ASGI application and a loopback HTTP server. They require
neither a live Visa registration nor a payment. They do not establish that a
production proxy or registered Agent is configured correctly.

## Adapt the examples

- [Manual MPP waiting and cancellation](../README.md#waiting-errors-and-shutdown)
Expand Down
Loading
Loading