Skip to content

proposal: hardware-wallet signing for x402 exact (sign_transaction_message)#210

Draft
ledgicr wants to merge 1 commit into
solana-foundation:mainfrom
ledgicr:x402-hardware-wallet-signing
Draft

proposal: hardware-wallet signing for x402 exact (sign_transaction_message)#210
ledgicr wants to merge 1 commit into
solana-foundation:mainfrom
ledgicr:x402-hardware-wallet-signing

Conversation

@ledgicr

@ledgicr ledgicr commented Jul 2, 2026

Copy link
Copy Markdown

Draft / proposal — for discussion. This is a concrete recommendation, not a take-it-or-leave-it PR. The consumer change is one line; the design decision is the companion SolanaSigner trait method it calls. Reject, rework, or rename freely — I'd like written feedback on the API shape before we finalize.

Problem

x402 exact on Solana signs a partially-signed VersionedTransaction (v0) — a standard ed25519 signature over the serialized message, per the spec (specs/schemes/exact/scheme_exact_svm.md: "the transaction field contains the base64-encoded, serialized, partially-signed versioned Solana transaction"). The facilitator co-signs as fee-payer and submits.

The client produces the payer signature with:

signer.sign_message(&tx.message.serialize())

sign_message assumes a software key that can raw-ed25519-sign arbitrary bytes. A hardware wallet cannot do that — it signs a transaction through the device's transaction-parsing APDU, or an off-chain message through the \xffsolana offchain envelope, but never arbitrary raw bytes. So x402 exact payments can't be made with a hardware wallet (Ledger) today — and exact is most of the catalog.

(The legacy MPP charge path has the same root cause and was fixed separately via sign_transaction; but that method is typed to legacy Transaction and can't accept a VersionedTransaction, which is why x402 needs its own path.)

What this changes

One signing site in x402/client/exact/payment.rs:

- let sig_bytes = signer.sign_message(&tx.message.serialize()).await?;
+ let sig_bytes = signer.sign_transaction_message(&tx.message.serialize()).await?;

Everything else (message construction, the splice at the signer index, the base64 payload) is unchanged.

The proposed trait method (the actual decision)

Add to SolanaSigner (in solana-keychain) a defaulted method:

/// Sign a pre-serialized transaction message (legacy or versioned/v0).
/// Distinct from `sign_message` (arbitrary bytes → off-chain envelope on
/// hardware): this signs the bytes *as a transaction*. Default delegates to
/// `sign_message`, so software backends are byte-identical and unchanged;
/// hardware backends override it to route through the device tx APDU.
async fn sign_transaction_message(&self, message: &[u8]) -> Result<Signature, SignerError> {
    self.sign_message(message).await
}
  • Backward-compatible by construction. The default calls sign_message, so all existing backends (memory + the KMS/custody signers) produce the identical signature with no code change. Unit test asserts the equivalence for the software path.
  • Hardware override. The Ledger backend overrides it to send the serialized message to the device's transaction-parsing APDU (the same command its sign_transaction already uses).
  • Version-agnostic. Operating on serialized message bytes handles both legacy and v0, so the one method serves the MPP legacy path and the x402 versioned path.

Reference implementation, compiled + tested (incl. the Ledger override, hardware-validated on a Nano device): ledgicr/solana-keychain@ledger-x402-sign-transaction-message. This PR's solana-keychain dep is temporarily pointed there; restore to a published release once the method ships.

Why not the alternatives

  • Typed sign_versioned_transaction(&mut VersionedTransaction) (mirroring the existing legacy sign_transaction). Cleaner ergonomics, but pulls the VersionedTransaction type into the trait across the SDK-version matrix, and the x402 client already owns the splice/encode, so the byte-level method is a smaller surface and reuses the client's existing structure. Happy to go typed if you prefer symmetry.
  • Keep sign_message, special-case hardware in the client. Pushes device-awareness into every consumer and leaks backend detail across the API boundary. The whole point of the trait is that consumers don't branch on signer type.

TS parity

The TS SolanaSigner interface already distinguishes signTransactions() (handles versioned txs via @solana/kit) from signMessages(). This proposal brings the Rust trait to the same split — the Rust side is the asymmetric one today (sign_transaction is legacy-only, so consumers fall back to sign_message for versioned). So this is closing a Rust-side gap, not inventing a new concept.

Scope

In scope (this PR): x402 exact payment signing — the common catalog path.

Out of scope (separate proposals):

  • SIWMPP / SIWX off-chain credentials (pay send, subscriptions, x402 sign-in) and batch-settlement vouchers — these sign a canonical string / raw payload the verifier checks as raw ed25519, which a hardware wallet can only produce as an off-chain message (different bytes). That needs a signature_scheme addition on both client and server — a wire-protocol design worth its own thread (it also intersects the Agent-Intents roadmap).
  • Clear-signing (device shows a hash, not "$X to Y" — a v0 clear-display / device-app matter) — being handled separately.

Status

  • Compiles; all 375 kit lib tests pass against the keychain reference branch.
  • CI will be red until a solana-keychain release carries sign_transaction_message (the dep points at the branch meanwhile).
  • Hardware: the Ledger override is validated on a real Nano device; a device signs the serialized v0 message via the tx APDU (blind-signed today — see clear-signing note).

Would love written feedback on: (a) the method name/shape, (b) byte-level vs typed, (c) whether you'd rather own the keychain change or take ours.

…rdware-wallet compatible)

x402 'exact' on Solana signs a partially-signed VersionedTransaction (v0) — a
standard ed25519 signature over the serialized message, per the x402 spec
(specs/schemes/exact/scheme_exact_svm.md). The client did this with
signer.sign_message(&tx.message.serialize()), which assumes a software key that
raw-ed25519-signs arbitrary bytes. A hardware wallet (Ledger) cannot: it signs a
transaction through the device's transaction-parsing APDU, never raw bytes. So
x402 'exact' payments cannot be made with a hardware wallet today.

Switch the one signing site to the new SolanaSigner::sign_transaction_message,
which is byte-identical for software signers (default impl delegates to
sign_message) and routes through the device APDU for hardware signers. No x402
protocol / facilitator / wire-format change: the produced partially-signed
VersionedTransaction is exactly what any x402 'exact' facilitator expects.

Depends on the companion keychain trait method (ledgicr/solana-keychain@
ledger-x402-sign-transaction-message); dep temporarily pointed there.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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