Skip to content

fix: reject zero/expired ValidBefore on co-signed fee payer transactions - #123

Open
chalomdev wants to merge 1 commit into
tempoxyz:mainfrom
chalomdev:fix/fee-payer-validbefore-guard
Open

fix: reject zero/expired ValidBefore on co-signed fee payer transactions#123
chalomdev wants to merge 1 commit into
tempoxyz:mainfrom
chalomdev:fix/fee-payer-validbefore-guard

Conversation

@chalomdev

Copy link
Copy Markdown

Problem

In pkg/tempo/server/charge.go's verifyTransaction, the fee-payer flow validates the transaction's ValidBefore field twice: once on the original client-submitted transaction (before co-signing), and once again after co-signing. But these two checks aren't equivalent:

Before co-signing (line ~286):

if tx.ValidBefore == 0 || time.Now().Unix() >= int64(tx.ValidBefore) {
    return nil, mpp.ErrVerificationFailed("fee payer transaction has expired")
}

After co-signing (line ~328, before this fix): only validateFeePayerTransaction(tx, ...) is called, which internally does:

if tx.ValidBefore != 0 {
    // ...sponsor-policy upper bound check...
}

i.e. it silently skips its check entirely when ValidBefore == 0. There is no standalone re-check afterward.

For the local feePayerSigner path this is harmless, since tx is mutated in place and its ValidBefore was already validated pre-signing. But for the remote fee payer path (request.MethodDetails.FeePayerURL), tx is replaced wholesale:

tx, err = tempotx.Deserialize(coSignedRaw)

coSignedRaw comes from an external HTTP response (signWithRemoteFeePayer). If that response contains a transaction with ValidBefore reset to 0, neither the post-co-sign validateFeePayerTransaction call nor any other check catches it, and the transaction proceeds to signature verification, preflight, and broadcast.

Why it matters / precedent

The pre-signing check already establishes that ValidBefore == 0 is treated as invalid (grouped with "already expired"), not as "never expires." The gap is that this same invariant isn't re-enforced on the transaction that actually gets broadcast when it originates from a remote fee payer response. feePayerMaxValidityWindow (15 minutes) is meant to bound how long a sponsor is on the hook for a transaction; a ValidBefore of 0 defeats that bound entirely for the remote co-signing path.

Fix

Re-apply the same ValidBefore == 0 || expired guard immediately after the second validateFeePayerTransaction call, mirroring the pre-signing check.

Tests

Added TestChargeFlow_FeePayerTransactionViaRemoteSignerRejectsZeroValidBefore in pkg/tempo/server/charge_feepayer_validbefore_test.go, modeled directly on the existing TestChargeFlow_FeePayerTransactionViaRemoteSignerRejectsTamperedFeeToken test. It runs a fake remote fee payer HTTP server that co-signs the transaction but zeroes ValidBefore, and asserts Verify() rejects it with an "expired" error before any broadcast (rpc.sentRawTxs stays empty).

I was not able to run the full go test ./pkg/tempo/server/... suite in my sandbox — network egress there blocks proxy.golang.org (this repo's go.mod requires Go 1.26, and dependencies like go-ethereum/tempo-go couldn't be fetched), the same limitation noted on #122. I traced the fix and test logic by hand against the existing TestChargeFlow_FeePayerTransactionViaRemoteSigner* tests' structure, but I'd appreciate CI or a maintainer confirming the new test passes (and fails without the fix) before merge.

Scope / trade-off

This only affects the FeePayerURL (remote fee payer) path. The local feePayerSigner path was never affected, since it mutates the original, already-validated tx in place rather than replacing it. I have not audited whether tempotx.Deserialize or VerifyDualSignatures independently enforce a non-zero ValidBefore at the transaction-format level — this fix adds the check at the mpp-go verification layer regardless, consistent with the existing pre-signing check.

Changeset

Added .changelog/fee-payer-validbefore-guard.md with a patch bump.

validateFeePayerTransaction only checks the sponsor-policy upper bound
on ValidBefore when it's non-zero, so the check is silently skipped for
a co-signed tx with ValidBefore == 0. The standalone zero/expired guard
applied to the original client-submitted transaction before co-signing
was never re-applied after co-signing, so a remote fee payer response
with ValidBefore reset to 0 could bypass the sponsor's validity-window
policy entirely. Re-apply the same guard immediately after the second
validateFeePayerTransaction call.
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