Skip to main content

moltrust-x402

An offline trust gate for x402 endpoints. Charge less, or open a route at all, for an agent that can prove who it is and what MolTrust says about it — without your server calling ours on the request path.

pip install moltrust-x402
curl -fsS https://api.moltrust.ch/.well-known/jwks.json > /etc/moltrust/jwks.json

Five lines put it in front of a route:

from moltrust_x402 import require_moltrust, load_jwks

jwks = load_jwks("/etc/moltrust/jwks.json")
gate = require_moltrust(min_score=60, jwks=jwks)

decision = gate(request.method, request.path, request.headers)

decision.allowed is the answer, decision.did and decision.trust_score describe the caller, and decision.reason names the denial with decision.detail saying what to change. The TypeScript package @moltrust/x402 exposes the same three names and replays the same test vectors, so a mixed stack answers a caller the same way in both languages.

Where the code lives

The verification itself is moltrust_enforce.gate, and it stays there. This distribution re-exports it so that somebody searching PyPI for "x402" finds it. A second copy of the logic would be a fourth implementation to keep in step with the parity vectors, and duplicated security code drifts without anybody noticing, because each copy passes its own tests.

Every name you import here is the same object as in moltrust_enforce, which a test asserts by identity rather than by behaviour.

Why offline

Version 1 of the TypeScript package asked api.moltrust.ch for a score on every request and believed the answer. Two things were wrong with that.

It put a network call from your server into your request path, so an outage on our side became latency on yours, and a timeout became a decision nobody designed. And it verified nothing — the response was plain JSON over TLS, so anyone able to answer that request could set any score they liked.

This version calls nobody. The caller brings a MolTrust-signed attestation and a signature made with its own key. Both are checked against a key set you already hold. The only thing that ever has to reach us is a periodic refresh of that key set, on your schedule, outside the request path.

What the caller sends

Header What it is
X-MolTrust-Attestation the gate_attestation field from GET https://api.moltrust.ch/skill/trust-score/<did>
X-MolTrust-Timestamp unix seconds, the moment the proof was made
X-MolTrust-Proof base64url Ed25519 signature over the binding string, made with the DID's own key

The attestation is a compact JWS carrying the DID, its public key, the trust score, whether the score is withheld, the credential types the DID holds, and an expiry. The offline check depends on that public key: did:moltrust:<hex> is a random identifier rather than a hash of a key, so without a signed statement binding the two you could verify that a score exists for some DID and never that the caller is that DID.

The proof binds one method, one path, one DID and one moment:

moltrust-gate/v1\nPOST\n/audit\ndid:moltrust:abc123\n1790003453

Signing a bare nonce would let anyone who saw the proof replay it against a different route.

A discount rather than a door

Two prices on the same route. Verified agents pay 0.05 USDC, everyone else 0.20, and nobody is turned away:

decision = gate(request.method, request.path, request.headers)
price = "0.05" if decision.allowed else "0.20"

The other shape is a route only verified agents may call, with a credential requirement on top:

issue_gate = require_moltrust(
    min_score=70,
    credential_type="SkillAuditCredential",
    jwks=jwks,
)

Deny by default

Everything that is not an explicit allow is a denial, including the cases that look like infrastructure problems: a malformed header, an unknown key id, a key set with nothing usable in it. Each denial names a reason, so a caller can fix it without asking you what happened.

reason Meaning
attestation_missing no X-MolTrust-Attestation
proof_missing no proof or no timestamp
attestation_invalid bad signature, unknown kid, expired, or a v1 payload
proof_invalid wrong key, wrong route, wrong method, or stale
proof_replayed seen before, when a replay store is configured
score_withheld no score has been computed for this agent
score_missing nothing to compare against min_score
score_below_minimum the score is real and too low
track_record_invalid the track_record is malformed or has no usable anchor
credential_missing the DID does not hold a required credential

A withheld score is a denial. A score we have not computed is neither a low score nor a pass. allow_withheld=True lets those agents through, which is a reasonable choice for a discount tier and a bad one for a spend authorisation. It does not bypass min_score: a withheld score is None, so a numeric threshold still denies.

See it before you build it

scripts/gate_probe.py in the moltrust-api repository asks one endpoint for its price twice — once plain, once presenting your attestation — and prints both. It pays nothing and it signs with your key, not ours; there is no wallet key in it and no address hardcoded.

pip install pynacl

# Read it first. --register generates a private key, and a script that
# generates a key is not one to run unread. It is 232 lines.
less scripts/gate_probe.py

# No DID yet? This mints one and prints its key. Two calls, no account.
python3 scripts/gate_probe.py --register

python3 scripts/gate_probe.py --did did:moltrust:... --key <64-hex-chars>
trust_score     None  (withheld: True)
track_record    anchored 0xf207a648cc41973000ec2594d8821623e04a813311d4577a869b3bc9a84a5adc

  without proof 50000   (0.050000 USDC)
  with proof    40000   (0.040000 USDC)

The proof is worth 20 % here. Nothing has been paid.

A DID minted a minute ago prints the same number twice and says why. Point --api and --guard at your own host to probe a gate you run yourself.

How an agent qualifies

A score is withheld until three endorsers exist, and an agent that registered this morning has none. Left there, the gate denies every newcomer for a reason none of them can act on. allow_track_record=True is the way out.

An agent reaches it in three steps:

  1. Register. GET /identity/register-challenge, solve the proof of work, POST /identity/register-pop.
  2. Bind a wallet. GET /identity/nonce, then POST /identity/bind with the DID, the address, wallet_chain: "base" and a signature over the nonce.
  3. Issue a track record. POST /credentials/track-record. MolTrust reads what that wallet has done on Base and issues a TrackRecordCredential carrying the numbers and the thresholds they were judged against.

The wallet clears two published thresholds: at least one transaction it sent itself, and at least seven days of age. Both are a cost: a wallet with its own history cannot be produced at the moment someone wants a discount. The first one bites hardest: most agent marketplaces relay gaslessly, so a worker wallet can be busy for weeks and still sit at nonce 0.

gate = require_moltrust(
    min_score=50,
    allow_track_record=True,   # off by default
    jwks=JWKS,
)

With it on, an attestation carrying a well-formed track_record passes where a withheld score alone would not, and min_score is not consulted for that caller — there is no score to compare. decision.via says which requirement carried it, "score" or "track_record".

The substitute covers a score nobody computed, never one that was computed and came out low. track_record reaches the attestation only once the credential is anchored, which runs in a batch every two hours; anchor_tx is the field a relying party checks, and it cannot be filled in before the transaction is real. The anchor itself is not verified here — this module makes no network calls. Read decision.track_record["anchor_tx"] and check it on your own schedule if you want that too.

Replay

The proof is fresh within max_age_seconds (default 300). Inside that window the same proof can be presented more than once unless you pass seen, a callable that records a proof and returns False if it has seen it before:

used: dict[str, float] = {}

def seen(proof: str) -> bool:
    if proof in used:
        return False
    used[proof] = time.time()
    return True

gate = require_moltrust(min_score=60, jwks=jwks, seen=seen)

Without it the gate is replay-resistant rather than replay-proof. That is stated here instead of left to be discovered.

Keeping the key set current

curl -fsS https://api.moltrust.ch/.well-known/jwks.json > /etc/moltrust/jwks.json.new \
  && mv /etc/moltrust/jwks.json.new /etc/moltrust/jwks.json

Run it on whatever schedule suits you and reload the process. A rotated key shows up as attestation_invalid with no key for kid … in the detail, which is the one denial that means refresh the file and not the caller did something wrong.

MIT.

Metadata

Release files for moltrust-x402 2.0.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for moltrust-x402 2.0.0
File Size Uploaded
moltrust_x402-2.0.0.tar.gz 6.4 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for moltrust-x402 2.0.0
File Interpreter ABI Platform
moltrust_x402-2.0.0-py3-none-any.whl Python 3 none any Details

Total release size: 12.2 kB

Release files / moltrust_x402-2.0.0.tar.gz

Download URL moltrust_x402-2.0.0.tar.gz
Size 6.4 kB
Tags Source
SHA-256 checksum
How to use checksums
bcf83083fa8003da670455ddb826203c5dc5c8146692e3417e465ebfb6c6fc11
BLAKE2b-256 checksum
How to use checksums
41cf27c53d83e4dd46526496234761c68415c9e78a435fbd8e53e20ce0a2c411
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.3

Release files / moltrust_x402-2.0.0-py3-none-any.whl

Download URL moltrust_x402-2.0.0-py3-none-any.whl
Size 5.7 kB
Tags Python 3
SHA-256 checksum
How to use checksums
4a13d1df21c26ceef9fdcde0fde1914682b4180a0c9d45712fc3db91d57fd250
BLAKE2b-256 checksum
How to use checksums
4f7cd8b015ac9f5014293100a0627b664781de3b96459e62855dd787a634d0d5
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.3

Release history Release notifications | RSS feed

This release

2.0.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page