MPP.Methods.Solana (mpp v0.19.0)

Copy Markdown View Source

Solana payment method — verifies native SOL and SPL token charge payments.

Three credential types are supported, matching draft-solana-charge-00:

  • type="transaction" (pull, default) — the client sends signed legacy transaction bytes; the server optionally co-signs as fee payer, simulates, broadcasts, and waits for confirmation.
  • type="signature" (push, opt-in via "push") — the client broadcasts the transaction and sends the confirmed signature; the server fetches it via RPC and matches the transfer against the charge.
  • type="bundle" (confidential) — the client sends ordered proof setup, Token-2022 confidential transfer, and proof close transactions. The server confirms the encrypted amount with its recipient ElGamal key.

Configuration

Pass Solana-specific config via :method_config in MPP.Plug opts:

plug MPP.Plug,
  secret_key: "hmac-secret",
  realm: "api.example.com",
  method: MPP.Methods.Solana,
  amount: "10000000",
  currency: "sol",
  recipient: "7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU",
  method_config: %{
    "rpc_url" => "https://api.devnet.solana.com",
    "network" => "devnet"
  }

Config Keys

  • "rpc_url" — (required) Solana JSON-RPC endpoint URL
  • "network" — (optional) "mainnet", "devnet", or "localnet"; advertised in challenge details, defaults to "mainnet"
  • "decimals" — (required for SPL) token decimal count advertised to clients
  • "token_program" — (optional) base58 Token or Token-2022 program id
  • "fee_payer" — (optional) server fee sponsorship, defaults to false
  • "fee_payer_private_key" — (required when fee_payer: true) Ed25519 seed as hex, base58, or a Solana CLI JSON keypair
  • "fee_payer_key" — (optional) base58 fee-payer pubkey; derived from the private key when omitted
  • "splits" — (optional) at most 8 extra payment legs (recipient, amount, optional memo, optional ataCreationRequired)
  • "store" — (optional) replay-dedup store, on by default (see MPP.Methods.EVM for the same contract). Pass store: false to opt out
  • "req_options" — (optional) merged into Cartouche.Solana.RPC calls (e.g. [plug: {Req.Test, MyMod}]) for testing stubs
  • "wait_for_confirmation" — (optional) when false, pull mode broadcasts without waiting for confirmation. Default true
  • "max_compute_unit_limit" / "max_compute_unit_price" — (optional) fee-payer ceilings for compute-budget instructions
  • "confidential" — (optional) enables the Token-2022 confidential profile
  • "recipient_elgamal_secret_key" — (required for confidential) base64 canonical scalar for the recipient confidential token account
  • "max_bundle_transactions" — (optional) confidential bundle bound; defaults to 8
  • "push" — (optional) enables type="signature" credentials. Disabled by default: challenges advertise only "transaction" and push credentials are rejected. Values:
    • "challenge_memo" — the confirmed transaction must carry a Memo Program instruction whose text is push_memo/1 of the challenge id. Challenges advertise "pushBinding" => "challengeMemo" so clients know to add it.
    • "unbound" — the draft-solana-charge-00 base push flow. A confirmed transfer matching the charge terms is accepted for any challenge with those terms, and whoever presents its signature first is served; use it only when the charge terms are unique per challenge.

Under "challenge_memo", a pull (type="transaction") credential may carry only the memos a reference client adds — the challenge externalId and split memo values — or push_memo/1 of the challenge being answered. Any other memo is rejected, so a push transaction bound to a different challenge cannot be redeemed through the pull path. The mpp-push: memo prefix is reserved: a pull memo carrying it must be this challenge's push_memo/1, even when it equals the externalId or a split memo, and a split memo (at init) or an externalId (at challenge issue) carrying it raises ArgumentError.

Push challenge binding

Under "push" => "challenge_memo" the client derives the memo from the challenge it is answering and includes it in the transfer transaction before broadcasting:

memo = MPP.Methods.Solana.push_memo(challenge.id)

The memo is "mpp-push:" followed by a hash of the challenge id, so a confirmed transfer satisfies only the challenge it was created for, and the on-chain memo does not reveal the challenge id itself.

Pull retries and single use

With a store, a pull transaction's signature is reserved before it is simulated and broadcast. A simulation failure releases the reservation through the store's compare-and-delete (delete/2, or update/3); a store that exports neither keeps it until its TTL expires, so the same signed bytes cannot be retried and the client must sign a new transaction. Once sendTransaction has been issued the reservation is kept whatever happens next. Presenting the same transaction again for the same challenge then re-checks the confirmed transaction without re-broadcasting it and returns the receipt, so a payment that landed while confirmation failed can still be redeemed while the challenge is valid. Any other challenge is rejected, and a settled signature is served once.

A confidential "bundle" has no such recovery: its amount is proved by the recipient balance delta around settlement, which cannot be re-measured later.

Credential Payload

  • "type" => "transaction", "transaction" => "<base64>" — signed legacy transaction bytes (max 1232 decoded)
  • "type" => "signature", "signature" => "<base58>" — confirmed transaction signature
  • "type" => "bundle", "transactions" => ["<base64>", ...] — ordered confidential transaction bundle

Currency

  • Native SOL: "sol"
  • SPL tokens: the base58 mint address

Dependencies

Uses Cartouche.Solana (RPC, legacy transaction codec, System/Token/ATA programs) already in the on-chain stack.

API Functions

FunctionArityDescriptionParam Kinds
challenge_method_details1Return Solana-specific fields (network, credentialTypes, feePayer, optional decimals/tokenProgram/splits) for the 402 challenge.charge: value
verify2Verify a Solana credential by checking on-chain settlement.payload: value, charge: value
push_memo1Return the Memo Program text a push (type="signature") transaction must carry under "push" => "challenge_memo".challenge_id: value
validate_config!1Validate Solana method_config at init time. Raises on missing rpc_url or invalid fee-payer / splits config.config: value
credential_types0Return the Solana charge payload types: transaction, signature, and bundle.-
method_name0Return the payment method identifier for Solana.-

Summary

Functions

Return Solana-specific fields (network, credentialTypes, feePayer, optional decimals/tokenProgram/splits) for the 402 challenge.

Return the Solana charge payload types: transaction, signature, and bundle.

Return the payment method identifier for Solana.

Return the Memo Program text a push (type="signature") transaction must carry under "push" => "challenge_memo".

Validate Solana method_config at init time. Raises on missing rpc_url or invalid fee-payer / splits config.

Verify a Solana credential by checking on-chain settlement.

Functions

challenge_method_details(charge)

@spec challenge_method_details(MPP.Method.intent()) :: map() | nil
@spec challenge_method_details(MPP.Intents.Charge.t()) :: map()

Return Solana-specific fields (network, credentialTypes, feePayer, optional decimals/tokenProgram/splits) for the 402 challenge.

Parameters

  • charge - Charge struct with method_details containing Solana method_config keys (value)

Returns

Public challenge methodDetails; never includes rpc_url, keys, or store (map)

# descripex:contract
%{
  params: %{
    charge: %{
      description: "Charge struct with method_details containing Solana method_config keys",
      kind: :value
    }
  },
  returns: %{
    type: :map,
    description: "Public challenge methodDetails; never includes rpc_url, keys, or store"
  }
}

credential_types()

@spec credential_types() :: [String.t()]
@spec credential_types() :: [String.t()]

Return the Solana charge payload types: transaction, signature, and bundle.

# descripex:contract
%{}

method_name()

@spec method_name() :: String.t()

Return the payment method identifier for Solana.

# descripex:contract
%{}

push_memo(challenge_id)

@spec push_memo(String.t()) :: String.t()

Return the Memo Program text a push (type="signature") transaction must carry under "push" => "challenge_memo".

Parameters

  • challenge_id - The id of the challenge the payment answers (value)

Returns

"mpp-push:" followed by the lowercase hex SHA-256 of the domain-separated challenge id (73 chars) (string)

# descripex:contract
%{
  params: %{
    challenge_id: %{
      description: "The `id` of the challenge the payment answers",
      kind: :value
    }
  },
  returns: %{
    type: :string,
    description: "\"mpp-push:\" followed by the lowercase hex SHA-256 of the domain-separated challenge id (73 chars)"
  }
}

validate_config!(config)

@spec validate_config!(map()) :: :ok
@spec validate_config!(map()) :: :ok

Validate Solana method_config at init time. Raises on missing rpc_url or invalid fee-payer / splits config.

Parameters

  • config - method_config map to validate (value)

Returns

:ok on success, raises ArgumentError on missing keys (atom)

# descripex:contract
%{
  params: %{
    config: %{description: "method_config map to validate", kind: :value}
  },
  returns: %{
    type: :atom,
    description: "`:ok` on success, raises `ArgumentError` on missing keys"
  }
}

verify(payload, charge)

@spec verify(map(), MPP.Intents.Charge.t()) ::
  {:ok, MPP.Receipt.t()} | {:error, MPP.Errors.t()}

Verify a Solana credential by checking on-chain settlement.

Parameters

  • payload - Credential payload map with "type" ("transaction", "signature", or "bundle") and the corresponding proof field (value)
  • charge - Charge intent struct with amount, currency, recipient, and method_details (including rpc_url) (value)

Returns

{:ok, receipt} on success, {:error, error} on failure (tagged_tuple)

Errors

  • :invalid_payload
  • :verification_failed
# descripex:contract
%{
  params: %{
    payload: %{
      description: "Credential payload map with `\"type\"` (`\"transaction\"`, `\"signature\"`, or `\"bundle\"`) and the corresponding proof field",
      kind: :value
    },
    charge: %{
      description: "Charge intent struct with amount, currency, recipient, and method_details (including `rpc_url`)",
      kind: :value
    }
  },
  errors: [:invalid_payload, :verification_failed],
  returns: %{
    type: :tagged_tuple,
    description: "`{:ok, receipt}` on success, `{:error, error}` on failure"
  }
}