Skip to content

fix(tempo/server): full Tempo CLI 1.6+ compatibility (keychain + 0x78 sponsored form)#34

Open
jake-bp wants to merge 3 commits into
tempoxyz:mainfrom
jake-bp:fix/keychain-signature-verification
Open

fix(tempo/server): full Tempo CLI 1.6+ compatibility (keychain + 0x78 sponsored form)#34
jake-bp wants to merge 3 commits into
tempoxyz:mainfrom
jake-bp:fix/keychain-signature-verification

Conversation

@jake-bp

@jake-bp jake-bp commented May 5, 2026

Copy link
Copy Markdown

Summary

Three bug fixes that together let mpp-go's Tempo charge server accept payments from Tempo CLI ≥ 1.6 wallets end-to-end. The first commit (the original PR scope) handles the keychain signature itself; the next two cover failures that surface only after the first one is fixed:

# Commit What Mirrors downstream PR
1 775e41d Keychain (AA-wallet) signature verification on the pre-co-sign path blockparty-global/sre-services#181
2 fb202d2 Accept Tempo CLI fee-payer-signing form (0x78) credentials blockparty-global/sre-services#192
3 90b0536 Keychain-aware verification on the post-co-sign path blockparty-global/sre-services#193

All three were independently merged into a downstream mpp-go-patched fork at blockparty-global/sre-services and validated end-to-end against https://skills.onesource.io with a Tempo CLI 1.6 wallet (USDC.e access key, USDC.e-funded). Live successful tx: 0x00658a50…77e0b3.

Diagnostic logging that helped us track each of these down (deserialize-error surfacing, broadcast/receipt logging) is intentionally not in this PR — it's a separate concern from the bug fixes themselves and can land as a follow-up if there's interest.

Why each fix

1. Keychain signature verification (commit 775e41d)

Tempo CLI ≥ v1.6 signs every MPP charge with a Keychain (0x04) envelope — the AA-style wrapper that lets a session key sign on behalf of the smart-account root. Three things in mpp-go v0.1.0 conspired to reject every such payment with 400 transaction signature is invalid:

  1. verifyTransaction only handled secp256k1. It called tempotx.VerifySignature, whose off-chain ecrecover path returns ErrUnsupportedSignatureType for any non-secp256k1 envelope.
  2. Inner-V encoding mismatch. Tempo CLI emits the inner secp256k1 V inside the keychain envelope as legacy Ethereum {27, 28} rather than EIP-2 {0, 1}, so even a hand-rolled keychain branch trips crypto.ValidateSignatureValues with invalid signature values.
  3. transactionMatches rejected KeyAuthorization. AA wallets populate tx.KeyAuthorization on every tx to scope which session key may execute on the root's behalf. The reject fired before sender recovery could even run.

I deliberately did not add a getKey() precompile pre-check before accepting the charge. That would false-reject counterfactual smart accounts whose access key is registered as part of execution at first-tx time — and the chain rejects unauthorised keys at submission anyway. Trading early-bail latency for accepting the wallet category that matters in practice.

2. 0x78 fee-payer-signing-form deserialize (commit fb202d2)

After (1), live tempo request calls reach the gateway and fail at deserialization. Tempo CLI 1.6 sends sponsored credentials in the fee-payer-signing form (0x78 prefix) rather than the broadcast/normal form (0x76). tempo-go v0.4.1's Deserialize hard-codes 0x76 acceptance.

Per tempo-go/pkg/transaction/serialize.go::buildRLPList, the two formats share the same 13/14/15-field RLP body — they only differ in:

  • the prefix byte itself
  • field 11: 0x76 holds the fee-payer signature (or 0x00 marker / empty); 0x78 holds the sender address

Sender's signing digest is computed via SerializeForSigning (FormatNormal+ForSigning, skipFeeToken=true when fee-payer is involved) — independent of which prefix the wire form used. So the patch swaps 0x780x76 for parsing, then post-processes:

  • tx.AwaitingFeePayer = true (the prefix inherently means it; the deserializer can only infer this from a 0x00 field-11 marker, which 0x78 doesn't carry).
  • tx.FeeToken cleared (the downstream "must omit fee token before co-signing" check expects empty; the server overwrites with request.Currency on co-sign anyway, and the sender's digest excludes fee_token in fee-payer flows so the signature stays valid).

3. Keychain-aware post-co-sign verification (commit 90b0536)

Once (1) and (2) ship, sponsored Tempo CLI requests get past the access-key check and fee-payer co-sign, then fail at the next step with "co-signed transaction failed signature verification".

tempotx.VerifyDualSignatures calls VerifySignature, which is secp256k1-only — the same asymmetry as (1), but on the post-co-sign verification path. Mirrors the existing keychain branch: re-verify the access key via keychain.VerifyAccessKeySignature (with the YParity 27/28 → 0/1 fixup), then verify the fee-payer signature separately via tempotx.VerifyFeePayerSignature against the recovered root account. The coSignedSender == sender invariant is preserved.

Test coverage

  • TestChargeFlow_TransactionCredentialKeychain (existing) — happy path through intent.Verify against a keychain-signed tx, with a subtest that flips the inner V to {27, 28} after signing. The mock RPC's onSend asserts the broadcast envelope is byte-identical to what the verifier received, proving the YParity normalisation didn't mutate the original.
  • TestTransactionMatches_AllowsKeyAuthorization (existing) — direct transactionMatches test that asserts a tx with KeyAuthorization set is accepted while a tx with non-empty AccessList is still rejected.
  • TestTranslateFeePayerFormToNormal — 7 sub-cases for the prefix swap (lowercase 0x78, uppercase 0X78, no 0x prefix, 0x76 unchanged, unknown prefix unchanged, empty, too-short).
  • TestVerifyTransaction_AcceptsFeePayerSigningForm — round-trips a synthetic 0x78 payload through tempotx.Serialize(FormatFeePayer) + the prefix-swap + tempotx.Deserialize, asserts the post-fixup Tx matches what verifyTransaction's downstream code expects.
  • go test ./... — full suite green.

Repro that motivated each fix

Any tempo MPP server (e.g. https://skills.onesource.io) hit by a tempo-cli ≥ v1.6 wallet:

$ tempo request -X GET https://skills.onesource.io/api/chain/block-number

Pre-(1): transaction signature is invalid.
Pre-(2): failed to deserialize transaction payload (chain rejected 0x78 prefix before sender recovery ran).
Pre-(3): co-signed transaction failed signature verification (under sponsorship).
With all three: 200 OK with the JSON body.

Prior art / parallel investigation

Independently fixed downstream by @shawnwollenberg + @jake-bp at blockparty-global/sre-services PRs #181, #192, #193, all integrated into a vendored mpp-go-patched/ wired via a replace in their gateway go.mod. This PR upstreams the same approach so consumers don't need to maintain their own fork.

🤖 Generated with Claude Code

@jake-bp jake-bp force-pushed the fix/keychain-signature-verification branch from 73fb118 to 775e41d Compare May 5, 2026 18:03
@jake-bp jake-bp changed the title fix(tempo/server): verify Keychain transaction signatures via precompile fix(tempo/server): support Account Abstraction (Keychain) wallet signatures May 5, 2026
jake-bp added a commit to jake-bp/mpp-go that referenced this pull request May 6, 2026
The original keychain (AA-wallet) signature handling added in PR tempoxyz#34
covers the pre-co-sign sender-recovery path in verifyTransaction, but
not the post-co-sign dual-signature verification at the end of the
fee-payer branch. tempotx.VerifyDualSignatures calls VerifySignature
which is secp256k1-only and returns "only secp256k1 can be verified
off-chain" for keychain envelopes — so any sponsored charge from a
Tempo CLI managed wallet failed at this step with "co-signed
transaction failed signature verification" after sponsorship was
otherwise wired up.

Mirrors the existing pre-co-sign keychain branch:

  - When tx.Signature.Type == "keychain": re-verify the access key
    via keychain.VerifyAccessKeySignature (with the same YParity
    27/28 → 0/1 fixup the pre-co-sign branch uses), then verify the
    fee-payer signature separately via tempotx.VerifyFeePayerSignature
    against the recovered root account.
  - Otherwise: fall back to tempotx.VerifyDualSignatures (the existing
    plain-ECDSA path).

The recovered root account is then compared against the pre-co-sign
sender, preserving the invariant that the same wallet is referenced
before and after the co-signature.

Mirrored from blockparty-global/sre-services PR #193.
jake-bp added a commit to jake-bp/mpp-go that referenced this pull request May 6, 2026
The patched chargeserver.verifyTransaction currently swallows the
underlying tx hash on broadcast and the revert reason on a non-0x1
receipt. Operators investigating "transaction reverted" failures have
no way to inspect the failed tx without code-level instrumentation.
This is the same diagnostic gap PR tempoxyz#34 (this PR) closed for the
keychain signature path; extending it to broadcast/receipt:

  - Log txHash on successful broadcast (and on SendRawTransaction
    failure, with the underlying error).
  - On a non-0x1 receipt: log status / blockNumber / gasUsed /
    revertReason and the full receipt map. Surface the revert
    reason in the response body when the chain provides one.
  - Log fetchReceipt errors with the txHash for context.

Receipt bodies are chain-public; no secrets exposed. Production cost
is one extra log line per successful broadcast plus a multi-field log
on failure.

Mirrored from blockparty-global/sre-services PR #190 + #194.
@jake-bp jake-bp changed the title fix(tempo/server): support Account Abstraction (Keychain) wallet signatures fix(tempo/server): full Tempo CLI 1.6+ compatibility (keychain, 0x78 sponsored form, diagnostics) May 6, 2026
@jake-bp jake-bp force-pushed the fix/keychain-signature-verification branch from 5d05375 to 90b0536 Compare May 6, 2026 04:18
@jake-bp jake-bp changed the title fix(tempo/server): full Tempo CLI 1.6+ compatibility (keychain, 0x78 sponsored form, diagnostics) fix(tempo/server): full Tempo CLI 1.6+ compatibility (keychain + 0x78 sponsored form) May 6, 2026
jake-bp added 3 commits June 2, 2026 13:38
…atures

Tempo CLI ≥ v1.6 signs every MPP charge with a Keychain (0x04) envelope —
the AA-style wrapper that lets a session key sign on behalf of the
smart-account root. Three things in mpp-go conspired to reject every
such payment:

  1. `verifyTransaction` called only `tempotx.VerifySignature`, whose
     off-chain ecrecover path is hard-wired to secp256k1 and returns
     `ErrUnsupportedSignatureType` for any other envelope. Every
     keychain-signed payment surfaced as `transaction signature is invalid`
     before the on-chain submission step ever ran.
  2. The Tempo CLI emits the inner secp256k1 signature inside the keychain
     envelope with V in the legacy Ethereum {27, 28} form rather than the
     {0, 1} form tempo-go's `RecoverAddress` requires under EIP-2 strict
     validation, so even a hand-rolled keychain branch would fail
     `ValidateSignatureValues` until the byte was normalised.
  3. `transactionMatches` rejected any tx where `tx.KeyAuthorization != nil`,
     but AA wallets populate that field on every tx to scope which session
     key is authorised to execute. The reject fired before sender recovery
     could even run.

Fix:

  - Branch on `tx.Signature.Type == "keychain"` in `verifyTransaction`.
    Patch byte 85 of a copy of `tx.Signature.Raw` if it's in {27, 28},
    delegate to the upstream `keychain.VerifyAccessKeySignature` to
    recover the access key, and treat the embedded root account as the
    authorising sender. The original `tx.Signature.Raw` bytes are
    untouched, so `SendRawTransaction` broadcasts whatever the client
    sent. We deliberately do not pre-check `isActiveAccessKey` against
    the keychain precompile: that would false-reject counterfactual
    smart accounts whose access key is registered as part of execution
    at first-tx time, and the chain rejects unauthorised keys at
    submission anyway.
  - Drop the `tx.KeyAuthorization != nil` clause from `transactionMatches`.
    The field controls execution authority; payment correctness is
    established by walking `tx.Calls` (target == request.Currency,
    value zero, decoded transfer matches expected destination, amount,
    memo). `tx.AccessList != 0` is intentionally left as a reject —
    EIP-2930 prewarming is unrelated to AA and tempo-cli does not emit
    it for charges.

Bumps tempo-go to v0.4.1 for `keychain.VerifyAccessKeySignature` and
`KeychainSignatureLength`.

Test coverage:

  - `TestChargeFlow_TransactionCredentialKeychain` — happy-path through
    `intent.Verify` against a keychain-signed tx, with a subtest that
    flips the inner V to {27, 28} after signing. The mock RPC's onSend
    asserts the broadcast envelope is byte-identical to what the verifier
    received, proving the YParity normalisation didn't mutate the original.
  - `TestTransactionMatches_AllowsKeyAuthorization` — direct
    `transactionMatches` test that asserts a tx with `KeyAuthorization`
    set is accepted while a tx with non-empty `AccessList` is still
    rejected.

Independently fixed downstream by @shawnwollenberg at
blockparty-global/sre-services#181 (vendored `mpp-go-patched/` fork wired
via `replace`); this PR upstreams the same approach so consumers don't
need to maintain their own fork.
…dentials

Tempo CLI 1.6.0 sends sponsored credentials in the fee-payer-signing
form (0x78 prefix) rather than the broadcast/normal form (0x76).
tempo-go's Deserialize hard-codes 0x76 acceptance, so verifyTransaction
rejects every managed-wallet credential with "expected TempoTransaction
prefix 0x76, got 0x78".

Per tempo-go/pkg/transaction/serialize.go::buildRLPList, the two
formats share the same 13/14/15-field RLP body; they only differ in
field 11 (0x76 holds the fee-payer signature or 0x00 marker; 0x78
holds the sender address). The deserializer's field-11 logic falls
through "non-tuple non-0x00 bytes" as an unusual-case no-op, leaving
Tx.FeePayerSignature nil and AwaitingFeePayer false — and the sender's
signing digest is computed via SerializeForSigning (FormatNormal +
ForSigning, with skipFeeToken=true when fee-payer is involved), so
it's independent of which prefix the wire form used.

This change adds a translateFeePayerFormToNormal helper that swaps
0x78 → 0x76 for parsing, then post-processes the resulting Tx:

  - tx.AwaitingFeePayer = true (the 0x78 prefix inherently means it).
  - tx.FeeToken cleared (0x78 always includes it; the downstream
    "must omit fee token before co-signing" check expects empty, and
    the server overwrites with request.Currency on co-sign anyway).

Tests:
  - TestTranslateFeePayerFormToNormal: 7 sub-cases covering the
    case-insensitive 0x prefix matrix.
  - TestVerifyTransaction_AcceptsFeePayerSigningForm: round-trips a
    real 0x78 payload through tempo-go's own Serialize(FormatFeePayer)
    + the prefix-swap + tempo-go's Deserialize, asserting the post-
    fixup Tx state matches what verifyTransaction's downstream code
    expects.

Mirrored from blockparty-global/sre-services PR #192.
The keychain signature handling added in the previous commit covers
the pre-co-sign sender-recovery path in verifyTransaction, but not
the post-co-sign dual-signature verification at the end of the
fee-payer branch. tempotx.VerifyDualSignatures calls VerifySignature
which is secp256k1-only and returns "only secp256k1 can be verified
off-chain" for keychain envelopes — so any sponsored charge from a
Tempo CLI managed wallet failed at this step with "co-signed
transaction failed signature verification" after sponsorship was
otherwise wired up.

Mirrors the existing pre-co-sign keychain branch:

  - When tx.Signature.Type == "keychain": re-verify the access key
    via keychain.VerifyAccessKeySignature (with the same YParity
    27/28 → 0/1 fixup the pre-co-sign branch uses), then verify the
    fee-payer signature separately via tempotx.VerifyFeePayerSignature
    against the recovered root account.
  - Otherwise: fall back to tempotx.VerifyDualSignatures (the existing
    plain-ECDSA path).

The recovered root account is then compared against the pre-co-sign
sender, preserving the invariant that the same wallet is referenced
before and after the co-signature.

Mirrored from blockparty-global/sre-services PR #193.
@jake-bp jake-bp force-pushed the fix/keychain-signature-verification branch from 90b0536 to 42982f2 Compare June 2, 2026 18:43
@jake-bp

jake-bp commented Jun 2, 2026

Copy link
Copy Markdown
Author

Rebased onto main (v0.1.1, 68938c9). The only conflict was a go.mod dependency-version bump, resolved in favor of main. The three fixes sit in different regions of verifyTransaction than the v0.1.1 reservation-before-broadcast refactor, so they replayed unchanged and coexist with it — go build ./... is clean and go test ./... passes across all packages (including the new fiber/echo adapters).

One question on direction, since v0.1.1 added keychain handling in the proof-signer path (recoverProofSigner / pkg/keychain): do you intend to extend keychain (Account Abstraction) support to the charge transaction-verify path as well? That's what commits 1 and 3 here do — tempotx.VerifySignature / VerifyDualSignatures are still secp256k1-only on that path, so keychain-signed transactions are rejected there today. Happy to reshape these to match whatever approach you prefer (e.g. if you'd rather push keychain awareness down into tempo-go's verify primitives instead of branching in mpp-go).

The 0x78 fee-payer-signing-form fix (commit 2) is independent of the keychain question — I can split it into its own PR if that's easier to review separately.

@brendanjryan

brendanjryan commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Hi! can you retry this with the latest cli version?

@jake-bp

jake-bp commented Jul 1, 2026

Copy link
Copy Markdown
Author

Retried with the latest CLI — tempo node 1.10.1, tempo-request/tempo-wallet plugins 0.6.2 (original validation was on plugin 0.4.4). It still reproduces with a keychain (AA/passkey) wallet, and this PR still fixes it.

Latest CLI against minimal charge servers (charge intent, USDC.e, chainId 4217) from identical config:

Scenario Result
default tempo wallet (passkey ⇒ keychain sig) → unpatched main HTTP 400 transaction signature is invalid
same wallet → this PR 200 + real broadcast tx on Tempo mainnet
--private-key (plain EOA / secp256k1) → unpatched main passes verification; fails only at broadcast (InsufficientBalance on the empty throwaway account)

So on main, tempotx.VerifySignature still accepts plain secp256k1 but rejects the keychain (0x04) envelope — a plain-key charge is probably why it can look fixed. The failure is specific to keychain/AA wallets, i.e. the default tempo wallet login, which is what commit 1 targets.

Two things I checked in case main already covered it:

  • Push/hash mode — the 0.6.2 CLI is pull-only (Challenge does not support pull mode against a push-only server), so it can't avoid the transaction-verify path.
  • Proof path (recoverProofSigner, keychain-aware since v0.1.1) — zero-amount only, so it doesn't apply to a paid charge.

This minimal repro is the non-fee-payer path (commit 1). The 0x78/co-sign paths (commits 2 & 3) only trigger under fee-payer sponsorship — happy to stand up a sponsored repro if you'd like all three covered.

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.

2 participants