Skip to content

feat: add pay sign command and MCP transaction signing#368

Open
berkayoztunc wants to merge 2 commits into
solana-foundation:mainfrom
berkayoztunc:main
Open

feat: add pay sign command and MCP transaction signing#368
berkayoztunc wants to merge 2 commits into
solana-foundation:mainfrom
berkayoztunc:main

Conversation

@berkayoztunc
Copy link
Copy Markdown

Screenshot 2026-05-21 at 16 36 35

Summary

Add Solana transaction signing and submission support to Pay through both the CLI and MCP.

Changes

  • add CLI command:

    pay sign <BASE64_TX>
  • add MCP tool:

    sign_transaction
    
  • support base64-encoded legacy and v0 Solana transactions

  • sign with the selected Pay account and submit the transaction through RPC

  • preserve existing signatures while updating the Pay signer slot

  • print or return the confirmed Solana transaction signature on success

  • update Pay MCP instructions and Codex/Claude MCP tool allowlists

  • add tests for CLI parsing, transaction decode/signing, signer validation, RPC submission behavior, and MCP parameter routing

  • make MCP curl binary-response tempfile creation collision-safe under parallel tests

Why

Pay already manages user accounts and wallet-backed authorization. This adds a direct way for users and agents to sign externally prepared Solana transactions with a Pay account and submit them without exposing private keys.

CLI

pay sign <BASE64_TX>

The command:

  1. decodes a raw base64 Solana transaction
  2. accepts legacy and v0 transactions
  3. signs the required signer slot for the selected Pay account
  4. submits the signed transaction to RPC
  5. prints the confirmed transaction signature

MCP

The new sign_transaction tool accepts:

{
  "transaction": "<BASE64_TX>",
  "network": "mainnet",
  "account": "optional-account-name"
}

The MCP flow reuses the same core signing and submission logic as the CLI.

It also honors existing MCP routing through:

  • PAY_NETWORK_ENFORCED
  • PAY_ACTIVE_ACCOUNT
  • PAY_RPC_URL

Safety and errors

The signing flow rejects before RPC submission when:

  • the base64 input is invalid
  • the transaction bytes cannot be decoded
  • the selected Pay account is not a required signer
  • the signer slot cannot be resolved

Out of scope

This PR does not add:

  • sign-only output
  • arbitrary message signing
  • stdin transaction input
  • JSON CLI output

Validation

  • cargo test -p pay-mcp --lib
  • cargo test -p pay codex
  • cargo test -p pay allowed_tools
  • just rs fmt
  • just rs lint
  • just rs unit-test
  • just ci

@vercel
Copy link
Copy Markdown

vercel Bot commented May 21, 2026

@berkayoztunc is attempting to deploy a commit to the Solana Foundation Team on Vercel.

A member of the Team first needs to authorize it.

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented May 21, 2026

Greptile Summary

This PR adds pay sign <BASE64_TX> (CLI) and sign_transaction (MCP tool) to let users and agents sign externally-prepared Solana transactions with a Pay account and broadcast them, without exposing private keys. Both paths share a single core implementation in pay_core::sign that decodes legacy/v0 transactions, resolves the Pay signer slot, signs with the keychain, and submits via RpcClient.

  • Core signing (rust/crates/core/src/client/sign.rs): decodes base64 → attempts legacy then versioned deserialization → validates the Pay account is a required signer → signs the correct slot → preserves all other signatures → submits via send_and_confirm_transaction. Good unit-test coverage including fake-submitter injection.
  • MCP tool (rust/crates/mcp/src/tools/sign_transaction.rs): resolves network/account from PAY_NETWORK_ENFORCED / PAY_ACTIVE_ACCOUNT env vars with caller params as fallbacks, then dispatches to core via spawn_blocking.
  • Curl tempfile fix (rust/crates/mcp/src/tools/curl.rs): replaces timestamp-only naming with pid + timestamp + atomic counter + create_new(true) retry loop to eliminate collisions under parallel tests.

Confidence Score: 4/5

The signing flow is functionally correct and well-tested; the main rough edges are design-level rather than user-visible bugs.

The core logic is sound: signer-slot lookup, signature preservation, and pre-submission validation (bad base64, bad bytes, wrong signer) all work correctly and are covered by tests. The three findings are design concerns: the TransactionSubmitter::submit trait being sync while called from an async fn (safe today via block_on, fragile if the call site changes), the confusing (requested, enforced) parameter order in resolve_network/resolve_account (enforced silently wins despite being second), and the absence of a pre-submission check for still-zeroed co-signer slots (which produces an opaque RPC error instead of a clear local message). None of these break current behaviour.

rust/crates/core/src/client/sign.rs and rust/crates/mcp/src/tools/sign_transaction.rs warrant a second look for the async/blocking design and parameter ordering.

Important Files Changed

Filename Overview
rust/crates/core/src/client/sign.rs Core signing logic: decodes base64 → legacy/v0 dispatch → signer-slot resolution → sign → RPC submit. Well-tested; two design concerns: blocking submit() called inside async fn (currently safe but fragile), and no pre-submission guard for partially-signed transactions.
rust/crates/mcp/src/tools/sign_transaction.rs MCP tool wiring for sign_transaction: resolves network/account from env vars then delegates to core via spawn_blocking. Parameter order in resolve_network/resolve_account is confusing (enforced is second but wins); otherwise correct.
rust/crates/cli/src/commands/sign.rs Thin CLI shim: parses the BASE64_TX arg and delegates to core. Correctly defaults to MAINNET_NETWORK when no network override is provided.
rust/crates/mcp/src/tools/curl.rs Collision-safe tempfile creation: replaces timestamp-only naming with pid + timestamp + atomic counter + create_new(true) loop. Correct and safe under parallel tests.
rust/crates/cli/src/commands/mod.rs Registers Sign in requires_account (true) and tool_kind (Mcp); both arms match existing Send/Topup patterns correctly.
rust/crates/mcp/src/server.rs Adds sign_transaction tool registration with a clear description and explicit scope limitations; test assertion added for new tool description.
rust/crates/core/src/instructions.md Adds trigger guidance for sign_transaction and a safety note in the constraints section; wording clearly scopes the tool to explicit user requests.
rust/crates/cli/src/commands/claude/mod.rs Adds mcp__pay__sign_transaction to the Claude allowed-tools list and the corresponding test assertion.
rust/crates/cli/src/commands/codex.rs Adds sign_transaction to the Codex enabled-tools list and updates the serialised JSON assertion in tests.

Sequence Diagram

sequenceDiagram
    participant User
    participant CLI as pay sign CLI
    participant MCP as MCP sign_transaction
    participant Core as pay_core::sign
    participant KS as Keystore / Signer
    participant RPC as Solana RPC

    User->>CLI: pay sign BASE64_TX
    CLI->>Core: sign_and_submit_base64_transaction(b64, network, account)
    Core->>KS: load_signer_for_network_with_intent()
    KS-->>Core: SolanaSigner
    Core->>Core: decode_base64_transaction(b64)
    Core->>Core: sign_transaction(tx, signer)
    Note over Core: sanitize() → find signer slot → sign_message()
    Core->>RPC: send_and_confirm_transaction(tx)
    RPC-->>Core: Signature
    Core-->>CLI: signature string
    CLI-->>User: prints signature

    User->>MCP: "sign_transaction({transaction, network, account})"
    MCP->>MCP: resolve env vars (PAY_NETWORK_ENFORCED, PAY_ACTIVE_ACCOUNT)
    MCP->>Core: spawn_blocking → sign_and_submit_base64_transaction(...)
    Core->>KS: load_signer_for_network_with_intent()
    KS-->>Core: SolanaSigner
    Core->>RPC: send_and_confirm_transaction(tx)
    RPC-->>Core: Signature
    Core-->>MCP: signature string
    MCP-->>User: CallToolResult::success(signature)
Loading

Reviews (1): Last reviewed commit: "fix signer" | Re-trigger Greptile

Comment on lines +50 to +58
fn resolve_network(requested: Option<&str>, enforced: Option<&str>) -> String {
normalize(enforced)
.or_else(|| normalize(requested))
.unwrap_or_else(|| pay_core::accounts::MAINNET_NETWORK.to_string())
}

fn resolve_account(requested: Option<&str>, enforced: Option<&str>) -> Option<String> {
normalize(enforced).or_else(|| normalize(requested))
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Confusing parameter order — enforced silently wins despite being second

resolve_network and resolve_account both list requested first and enforced second, but the implementation silently prioritises the second argument. A caller who passes (enforced_value, requested_value) by mistake (or a future maintainer who reads the signature without reading the body) gets the wrong priority with no compile-time or runtime warning. Putting the higher-priority argument first, or renaming them (caller_hint, env_override), would remove the footgun.

Comment on lines +62 to +70
async fn sign_and_submit_with_submitter(
transaction_base64: &str,
signer: &dyn SolanaSigner,
submitter: &dyn TransactionSubmitter,
) -> Result<String> {
let transaction = decode_base64_transaction(transaction_base64)?;
let transaction = sign_transaction(transaction, signer).await?;
submitter.submit(&transaction)
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Blocking submit() called directly inside an async fn

sign_and_submit_with_submitter is async, but submitter.submit() is a synchronous blocking call (RpcClient::send_and_confirm_transaction is blocking). In the current code path this is safe because the function is always driven via rt.block_on(...) on a spawn_blocking thread, so there is no async executor to starve. However, the TransactionSubmitter trait's sync surface makes the design fragile — if sign_and_submit_with_submitter is ever called from a regular async context (e.g. directly awaited from the MCP handler), the blocking RPC call would hold an executor thread. Making submit async on the trait, or wrapping the call in tokio::task::spawn_blocking, would make the design self-consistent.

Comment on lines +90 to +124
async fn sign_transaction(
mut transaction: VersionedTransaction,
signer: &dyn SolanaSigner,
) -> Result<VersionedTransaction> {
transaction
.sanitize()
.map_err(|e| Error::Config(format!("Invalid Solana transaction: {e}")))?;

let signer_pubkey = signer.pubkey();
let required_signatures = usize::from(transaction.message.header().num_required_signatures);
let required_signer_keys = transaction
.message
.static_account_keys()
.get(..required_signatures)
.ok_or_else(|| {
Error::Config(
"Invalid Solana transaction: required signer keys are missing".to_string(),
)
})?;
let signature_index = required_signer_keys
.iter()
.position(|pubkey| pubkey == &signer_pubkey)
.ok_or_else(|| {
Error::Config(format!(
"Selected pay account `{signer_pubkey}` is not a required signer for this transaction"
))
})?;

let signature_bytes = signer
.sign_message(&transaction.message.serialize())
.await
.map_err(|e| Error::Mpp(format!("transaction signing failed: {e}")))?;
transaction.signatures[signature_index] = Signature::from(<[u8; 64]>::from(signature_bytes));

Ok(transaction)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Partially-signed transactions are submitted without a pre-flight check

sign_transaction fills in only the Pay account's signature slot and leaves every other slot as Signature::default() (all-zeroes). If the transaction requires additional co-signers that have not yet signed, the function submits the transaction to RPC and gets back a validator error about missing signatures rather than a clear, local error. A pre-submission guard that verifies no required slot remains at Signature::default() after the Pay account signs would give users a much clearer message (e.g. "transaction still needs signatures from [pubkeys]") instead of an opaque RPC rejection.

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