Core concepts
Four things to understand before integrating: the entry point, the connected buyer, the thing being paid for, and who's allowed to sign for it.
Morva
createMorva(config) is the entry point — one instance per app, not per request. It holds your Particle credentials and (optionally) a registry address, and exposes both the merchant-facing registry calls and connect() for buyers.
const morva = createMorva({
particle: { projectId, projectClientKey, projectAppUuid },
registryAddress: "0x...", // optional — see "Direct vs registry intents" below
});BuyerSession
morva.connect(signer) returns a BuyerSession — a Particle Universal Account (EIP-7702 mode) paired with the signer that authorizes it. This is the buyer, connected. Two methods:
session.getUnifiedBalance()— the buyer's real cross-chain balance, read live.session.pay(intent, { onStatus })— executes a payment against aPaymentIntent.
Nothing about connecting a session touches the network beyond constructing the account object — the actual balance read and payment happen on their own calls.
PaymentIntent
A plain description of what's being paid: amount, settlement token, settlement recipient, and a settlement chain — see why Arbitrum One is the default, not the only option. Nothing about an intent is buyer-specific; it describes the sale, not the payer.
Direct vs registry intents
Two ways to build one, and they're not mutually exclusive within one app:
| createDirectIntent | createPaymentIntent | |
|---|---|---|
| Needs a registry deployed | No | Yes |
| Network call | None — synchronous | Reads MorvaRegistry on-chain |
| Settlement config source | Whatever you pass in directly | Merchant's on-chain MerchantConfig |
| Use when | You already store merchant payout config yourself (a database, for example) | You want settlement config to be a public, merchant-owned on-chain record |
This isn't an either/or for your whole app
Most integrations use createDirectIntent— it's simpler and has one less moving part (no registry deployment, no merchant transaction to register). The registry path exists for when you want merchant settlement config to be self-sovereign and independently verifiable rather than something your backend alone controls. Nothing stops you from offering both and letting merchants choose.
MorvaSigner
The contract the SDK needs from whoever can sign for the buyer — three methods, nothing more:
interface MorvaSigner {
address: Address;
signMessage(message: Uint8Array): Promise<Hex>;
signAuthorization(auth: AuthorizationRequest): Promise<SignedAuthorization>;
}session.pay()doesn't care how those three things happen — only that they do. LocalSigner (bundled) implements this over a raw private key, for scripts and tests only. A real frontend implements the same interface against an embedded wallet provider — walked through in full in the integration guide.
Why not MetaMask / injected wallets?
EIP-7702 mode requires producing a real EIP-7702 authorization signature, which standard injected JSON-RPC wallets don't expose a method for today. Supported signers are embedded providers with native 7702 support (Magic is the one proven in this repo) or a raw key.
The payment status lifecycle
session.pay(intent, { onStatus }) reports the same sequence on every payment:
- building — reading the buyer's unified balance, constructing the transfer transaction.
- authorizing — only emitted when the buyer's EOA needs a fresh EIP-7702 delegation on a chain this payment touches; skipped silently on repeat payments once delegated.
- signing — the buyer signs the transaction's root hash.
- submitted — broadcast succeeded;
transactionId/explorerUrlexist from here on. - settled — funds confirmed at
settlementRecipienton Arbitrum One. - failed —
pay()never resolves without either a settledPaymentResultor a thrown, typed error. See the errors reference.