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 tofalse"fee_payer_private_key"— (required whenfee_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, optionalmemo, optionalataCreationRequired)"store"— (optional) replay-dedup store, on by default (seeMPP.Methods.EVMfor the same contract). Passstore: falseto opt out"req_options"— (optional) merged intoCartouche.Solana.RPCcalls (e.g.[plug: {Req.Test, MyMod}]) for testing stubs"wait_for_confirmation"— (optional) whenfalse, pull mode broadcasts without waiting for confirmation. Defaulttrue"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) enablestype="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 ispush_memo/1of the challenge id. Challenges advertise"pushBinding" => "challengeMemo"so clients know to add it."unbound"— thedraft-solana-charge-00base 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
| Function | Arity | Description | Param Kinds |
|---|---|---|---|
challenge_method_details | 1 | Return Solana-specific fields (network, credentialTypes, feePayer, optional decimals/tokenProgram/splits) for the 402 challenge. | charge: value |
verify | 2 | Verify a Solana credential by checking on-chain settlement. | payload: value, charge: value |
push_memo | 1 | Return the Memo Program text a push (type="signature") transaction must carry under "push" => "challenge_memo". | challenge_id: value |
validate_config! | 1 | Validate Solana method_config at init time. Raises on missing rpc_url or invalid fee-payer / splits config. | config: value |
credential_types | 0 | Return the Solana charge payload types: transaction, signature, and bundle. | - |
method_name | 0 | Return 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
@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"
}
}
Return the Solana charge payload types: transaction, signature, and bundle.
# descripex:contract
%{}
@spec method_name() :: String.t()
Return the payment method identifier for Solana.
# descripex:contract
%{}
Return the Memo Program text a push (type="signature") transaction must carry under "push" => "challenge_memo".
Parameters
challenge_id- Theidof 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 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"
}
}
@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 (includingrpc_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"
}
}