Skip to content

tempo: signed transaction validBefore is ~45s after sign, much shorter than challenge.expires (5min) #395

@beckitrue

Description

@beckitrue

Summary

When mppx sign produces a transaction-type credential for a tempo.charge challenge, the underlying Tempo transaction's validBefore is set to roughly 45 seconds after sign time, regardless of the challenge's expires field (typically 5 minutes out per the mppx server defaults). On Tempo mainnet, this causes the signed transaction to be rejected on-chain (current_block_timestamp >= validBefore) whenever the round-trip from sign → submit → server verify exceeds ~45 seconds.

This is invisible in fast end-to-end tests but consistently breaks agent-driven MCP flows, where each tool call (challenge → mppx sign → submit) easily takes 30-90 seconds of agent overhead.

Environment

  • mppx CLI: 0.6.5
  • mppx server lib: tested both 0.5.7 and 0.6.5 — same symptom, different surfaced error class
  • Tempo network: mainnet (chainId 4217, https://rpc.tempo.xyz)
  • Tempo network: testnet (chainId 42431, https://rpc.moderato.tempo.xyz) — does NOT exhibit this (transaction validity is more lenient or block timestamp progresses differently)

Repro

Standalone bash repro using mppx sign + curl against any mppx-protected endpoint that returns a Tempo charge challenge with a 5-minute expires:

# 1. Get challenge (any mppx-server-protected URL on Tempo mainnet)
CHAL=$(curl -sS https://your-server/path | extract WWW-Authenticate)
echo "$CHAL"
# → Payment id="...", method="tempo", expires="2026-04-26T15:05:04.853Z" ...
#   (challenge expires ~5 minutes from now)

# 2. Sign immediately
CRED=$(npx mppx sign --account my-wallet --challenge "$CHAL")

# 3. Decode the signed transaction's validBefore
node -e '
import { Credential } from "mppx";
import { Transaction } from "viem/tempo";
const c = Credential.deserialize(process.argv[1].replace(/^Payment /, ""));
const tx = Transaction.deserialize(c.payload.signature);
console.log("challenge.expires:", c.challenge.expires);
console.log("tx.validBefore (unix):", tx.validBefore);
console.log("tx.validBefore (date):", new Date(Number(tx.validBefore) * 1000).toISOString());
console.log("delta sign-time → validBefore:", (Number(tx.validBefore) - Math.floor(Date.now()/1000)), "seconds");
console.log("delta validBefore → challenge.expires:", (Math.floor(Date.parse(c.challenge.expires)/1000) - Number(tx.validBefore)), "seconds");
' "$CRED"

Sample output observed in production:

challenge.expires: 2026-04-26T15:05:04.853Z
tx.validBefore (unix): 1777215649
tx.validBefore (date): 2026-04-26T15:00:49.000Z
delta sign-time → validBefore: ~45 seconds
delta validBefore → challenge.expires: ~255 seconds (the unused window)

Symptom on mainnet

When the credential is submitted to a server that runs tempo.charge verify(), the server's pre-flight viem_call simulation fails with the raw RPC error:

Revm error: transaction expired: current block timestamp 1777216094 >= validBefore 1777215649

(viem wraps this as CallExecutionError: An internal error was received in 0.6.5, or InvalidInputRpcError: Missing or invalid parameters in 0.5.7.)

The actual Tempo mainnet RPC is healthy — eth_chainId and eth_blockNumber work fine. The chain just refuses to simulate / accept a transaction whose validBefore is in the past.

Confirming it's purely timing

A fast-path repro succeeds first try:

# Fast (4 seconds end-to-end, mainnet, fresh wallet) → succeeds
T=$(date +%s); CHAL=$(curl ...); CRED=$(mppx sign ...); curl -d {credential=$CRED}
# → {"paid": true, ...}, ~$0.01 USDC settles on chain

A slow-path repro (sign, wait 60s, submit) fails with transaction expired. Same code, same wallet, same RPC.

Root cause (best guess)

In dist/tempo/server/Charge.js, the challenge issuance side uses defaults.expires or a 5-minute window. But on the client side, tempo.charge (or whatever mppx CLI calls under the hood) appears to set transaction validBefore to a much shorter window — possibly hardcoded to a default like now + 60s rather than challenge.expires.

I haven't dug into the CLI's signing code path, but the credential's TX consistently shows validBefore ~45s after sign-time across many test signs, regardless of how far out challenge.expires is.

Suggested fix

In mppx sign (or wherever the Tempo transaction is constructed when responding to a charge challenge), set tx.validBefore to Math.floor(Date.parse(challenge.expires) / 1000) — i.e., let the transaction be valid for the entire window the server already promised. The challenge expiry is the upper bound the server committed to honoring; the TX validity should match.

Alternative: make the validBefore window configurable via a mppx sign --valid-for <seconds> flag and/or MPPX_VALID_FOR env var.

Impact

  • Severity: high for any agent-driven flow on Tempo mainnet
  • Workarounds: retry the sign+submit cycle (sometimes works if the second attempt is faster); or use the Stripe SPT rail (no on-chain expiry)
  • Not affected: testnet (chainId 42431), fast direct HTTP integrations

Happy to test a fix or contribute a PR if helpful.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions