feat(mpp): sign charges via sign_transaction (hardware-wallet compatible)#206
feat(mpp): sign charges via sign_transaction (hardware-wallet compatible)#206ledgicr wants to merge 3 commits into
Conversation
…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>
Merge current main into the external PR after the Rust crate move and add coverage for fee-payer partial signing slot placement.
EfeDurmaz16
left a comment
There was a problem hiding this comment.
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 removedsign_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 newsign_transaction_matches_manual_message_signature_for_fee_payer_txtest. - 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 foundcheck is safely subsumed —sign_transactionderives 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
left a comment
There was a problem hiding this comment.
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.)
|
@ledgicr thanks for your contribution! you have to sign your commits before merging |
What
Sign MPP charge and subscription transactions via
signer.sign_transaction(&mut tx)instead ofsigner.sign_message(&tx.message_data())+ manual signature insertion.Three client-side signing sites in
crates/mpp:client/charge.rs— the charge transactionclient/subscription.rs— the subscriber signature, and theSubscriptionAuthorityinit txWhy
The client currently signs by calling
sign_messageover the raw serialized message bytes and splicing the returned signature intotx.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 offchainenvelope, but never arbitrary raw bytes. So the current code path makes MPP charges impossible to sign with a hardware wallet.SolanaSigner::sign_transactionalready exists on the trait and is the correct abstraction: for aMemorySignerit 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
sign_transactionsignature verifies identically to the previoussign_message-spliced one.memory, and the KMS/custody backends) the produced bytes are identical;sign_transactioninserts at the same signer index the manual code computed.Signatureimports and the manual account-index lookup.Testing
cargo check -p solana-mppgreen against upstreamsolana-keychain(revd788028,sdk-v3).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.