Skip to content

feat(mpp): sign charges via sign_transaction (hardware-wallet compatible)#206

Open
ledgicr wants to merge 3 commits into
solana-foundation:mainfrom
ledgicr:ledger-charge-sign-transaction
Open

feat(mpp): sign charges via sign_transaction (hardware-wallet compatible)#206
ledgicr wants to merge 3 commits into
solana-foundation:mainfrom
ledgicr:ledger-charge-sign-transaction

Conversation

@ledgicr

@ledgicr ledgicr commented Jul 1, 2026

Copy link
Copy Markdown

What

Sign MPP charge and subscription transactions via signer.sign_transaction(&mut tx) instead of signer.sign_message(&tx.message_data()) + manual signature insertion.

Three client-side signing sites in crates/mpp:

  • client/charge.rs — the charge transaction
  • client/subscription.rs — the subscriber signature, and the SubscriptionAuthority init tx

Why

The client currently signs by calling sign_message over the raw serialized message bytes and splicing the returned signature into tx.signatures[i]. That assumes a software key that can raw-ed25519-sign arbitrary bytes. A hardware wallet (e.g. Ledger) 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 the current code path makes MPP charges impossible to sign with a hardware wallet.

SolanaSigner::sign_transaction already exists on the trait and is the correct abstraction: for a MemorySigner it produces the byte-identical ed25519 signature over the same message and inserts it at the signer's index; for a hardware backend it routes through the device.

Backward compatibility

  • No server change. Charge/subscription verification is simulation-based — the Solana runtime validates the signature — so a sign_transaction signature verifies identically to the previous sign_message-spliced one.
  • For all existing software signers (memory, and the KMS/custody backends) the produced bytes are identical; sign_transaction inserts at the same signer index the manual code computed.
  • Removes now-dead Signature imports and the manual account-index lookup.

Testing

  • cargo check -p solana-mpp green against upstream solana-keychain (rev d788028, sdk-v3).
  • Validated end-to-end in a sandbox against a self-hosted pay server: a real Nano Gen5 signed an MPP charge (device prompted → approved → credential built + submitted).

Context

This is the first ("Tier 1") of a small series making Pay.sh hardware-wallet-native. It's the lowest-risk piece (client-only, backward-compatible) and stands on its own. A fuller write-up of the findings — including x402 exact (VersionedTransaction) signing and the SIWMPP off-chain-message credential path, which need trait/protocol design discussion — is being shared with the team separately. Happy to iterate.

…atible)

The MPP client signed the charge/subscription transactions with
`signer.sign_message(&tx.message_data())`, which assumes a software key that
raw-ed25519-signs arbitrary bytes. A hardware wallet (Ledger) cannot do that;
it signs a transaction through the device's tx-parsing APDU. Switch the three
client signing sites to `signer.sign_transaction(&mut tx)`:

- client/charge.rs (charge tx)
- client/subscription.rs (subscriber sig + SubscriptionAuthority init)

For a MemorySigner this produces the identical ed25519 signature over the same
message bytes, so it is fully backward-compatible; for a Ledger it routes
through the tx APDU. No server change: verification is simulation-based (the
Solana runtime validates signatures), so the result verifies identically.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Jul 1, 2026

Copy link
Copy Markdown

Greptile Summary

This PR replaces manual sign_message-plus-splice signing with sign_transaction across the three MPP client-side signing sites (charge.rs and two locations in subscription.rs), making MPP charges compatible with hardware wallets (e.g. Ledger). The change is backward-compatible: for software signers the produced signature bytes are identical, and server-side verification is simulation-based.

  • charge.rs: drops the Signature import and the manual account-index lookup; delegates entirely to signer.sign_transaction(&mut tx).
  • subscription.rs: same refactor at both signing points (subscriber activation tx and SA-init tx); adds a new signature-equivalence test and strengthens the existing partial-signing test with assertions that index 0 remains Signature::default() and the subscriber slot is filled.

Confidence Score: 5/5

Safe to merge; the change is client-only, backward-compatible, and the core correctness claim (byte-identical output for software signers, correct non-zero slot placement for partial signing) is validated by the new test in subscription.rs.

The three changed signing sites are straightforward: each builds an unsigned transaction, calls sign_transaction, and serialises. subscription.rs now contains both an equivalence test and a partial-signing assertion. No server-side changes are required and verification is simulation-based. The only gap is that charge.rs does not have a matching equivalence test for its fee_payer path, but the subscription.rs test proves the same MemorySigner mechanism works correctly for a non-zero signer index.

charge.rs — no test exercises the non-fee-payer partial-signing path; subscription.rs is well-covered.

Important Files Changed

Filename Overview
rust/crates/kit/src/mpp/client/charge.rs Signing refactored from sign_message + manual splice to sign_transaction; no test verifies the non-fee-payer partial-signing path (signer != fee payer) in this file.
rust/crates/kit/src/mpp/client/subscription.rs Signing refactored at two sites; new sign_transaction_matches_manual_message_signature_for_fee_payer_tx test and strengthened partial-signing assertions fully cover the subscriber-not-fee-payer scenario.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Client
    participant SolanaSigner
    participant HW as Hardware Wallet (Ledger)
    participant Server

    Note over Client,Server: Before this PR (sign_message path)
    Client->>SolanaSigner: sign_message(tx.message_data())
    SolanaSigner-->>Client: raw sig bytes
    Client->>Client: "tx.signatures[signer_index] = sig (manual splice)"
    Client->>Server: submit signed tx

    Note over Client,Server: After this PR (sign_transaction path)
    Client->>SolanaSigner: "sign_transaction(&mut tx)"
    alt MemorySigner (software key)
        SolanaSigner->>SolanaSigner: find signer index in account_keys
        SolanaSigner->>SolanaSigner: ed25519 sign message bytes
        SolanaSigner-->>Client: "tx.signatures[signer_index] = sig"
    else Hardware wallet (Ledger)
        SolanaSigner->>HW: APDU (transaction-parsing path)
        HW-->>SolanaSigner: sig at signer slot
        SolanaSigner-->>Client: "tx.signatures[signer_index] = sig"
    end
    Client->>Server: submit signed tx (byte-identical for software signers)
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Client
    participant SolanaSigner
    participant HW as Hardware Wallet (Ledger)
    participant Server

    Note over Client,Server: Before this PR (sign_message path)
    Client->>SolanaSigner: sign_message(tx.message_data())
    SolanaSigner-->>Client: raw sig bytes
    Client->>Client: "tx.signatures[signer_index] = sig (manual splice)"
    Client->>Server: submit signed tx

    Note over Client,Server: After this PR (sign_transaction path)
    Client->>SolanaSigner: "sign_transaction(&mut tx)"
    alt MemorySigner (software key)
        SolanaSigner->>SolanaSigner: find signer index in account_keys
        SolanaSigner->>SolanaSigner: ed25519 sign message bytes
        SolanaSigner-->>Client: "tx.signatures[signer_index] = sig"
    else Hardware wallet (Ledger)
        SolanaSigner->>HW: APDU (transaction-parsing path)
        HW-->>SolanaSigner: sig at signer slot
        SolanaSigner-->>Client: "tx.signatures[signer_index] = sig"
    end
    Client->>Server: submit signed tx (byte-identical for software signers)
Loading

Reviews (3): Last reviewed commit: "test(mpp): cover transaction signing equ..." | Re-trigger Greptile

lgalabru added 2 commits July 1, 2026 07:00
Merge current main into the external PR after the Rust crate move and add coverage for fee-payer partial signing slot placement.
@lgalabru lgalabru requested a review from EfeDurmaz16 July 1, 2026 11:35

@EfeDurmaz16 EfeDurmaz16 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review summary — adversarial line-by-line pass

Verdict: no approve-blocking findings. Clean, well-motivated signing-path change that enables hardware-wallet (Ledger APDU) signing without changing the software-key wire output.

  • Signature equivalence holds. For a software key, signer.sign_transaction(&mut tx) is byte-identical to the removed sign_message(&tx.message_data()) + manual splice: it signs the same serialized message and inserts at the signer's own account-key index. Pinned by the new sign_transaction_matches_manual_message_signature_for_fee_payer_tx test.
  • Correct partial-signing index. The fee-sponsored subscription test confirms only the subscriber slot (index != 0) is signed while the fee-payer slot (index 0) stays default, so the sponsor can co-sign later. SA-init (subscriber == fee payer == index 0) still signs index 0.
  • Removed Signer not found check is safely subsumedsign_transaction derives the insertion index from the signer's pubkey, and the signer is always a required signer by construction.

Blockers: none.

Follow-up (optional, non-blocking): subscription.rs — the SA-init sign_transaction site (index 0) has no direct equivalence test here (all tests use pinned_options() and skip that branch); parity is covered transitively by charge.rs. A subscription_authority_init_id = None equivalence test would close the gap.

@EfeDurmaz16 EfeDurmaz16 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approve

Verified clean: sign_transaction preserves byte-identical software-key wire output (equivalence test), partial-signs only the subscriber slot in the fee-sponsored case, and the removed index lookup is safely subsumed. Enables hardware-wallet signing with no behavior change for software keys. CI green (bar the unrelated Lua job). LGTM.

(Optional follow-up noted in the prior comment: add a subscription_authority_init_id = None equivalence test for the SA-init signing site.)

@EfeDurmaz16

Copy link
Copy Markdown
Collaborator

@ledgicr thanks for your contribution! you have to sign your commits before merging

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.

3 participants