The infrastructure
Morva doesn't reimplement chain abstraction, wallet custody, or settlement — it wires together four existing pieces of infrastructure into one payment call. This page explains what each one actually does, why it was chosen over the alternatives, and exactly how the SDK talks to it, file by file.
Particle Network — Universal Accounts
Universal Accounts are the piece that makes "pay from whatever you hold, wherever you hold it" possible at all. A Universal Account reads a wallet's balances across every chain it supports, and when asked to move value to a destination chain/token, it works out how to source that liquidity — from a single chain, or split across several — and routes it there as part of one signed request.
Why EIP-7702 mode specifically
Particle's Universal Accounts support two account models. The default is a separate ERC-4337 smart-contract account at a new, counterfactual address — meaning the buyer would need to fund a second address before anything works. EIP-7702 modeis the other option, and it's the one this SDK exclusively uses: it upgrades the buyer's own externally-owned account (EOA) in place, at the same address, via the EIP-7702 delegation the buyer already has in their wallet. No new address. No deposit step. The wallet a buyer already has just becomes the account.
This is a one-way architectural decision baked into sdk/src/ua/account.ts:
export function createUniversalAccount(config: MorvaConfig, ownerAddress: Address): UniversalAccount {
return new UniversalAccount({
projectId: config.particle.projectId,
projectClientKey: config.particle.projectClientKey,
projectAppUuid: config.particle.projectAppUuid,
smartAccountOptions: {
name: "UNIVERSAL",
version: UNIVERSAL_ACCOUNT_VERSION,
ownerAddress,
useEIP7702: true, // always — no ERC-4337-default path exists in this SDK
},
tradeConfig: { slippageBps: config.slippageBps ?? DEFAULT_SLIPPAGE_BPS },
});
}Version-pinned on purpose
@particle-network/universal-account-sdk is pinned to an exact version (no ^range) in the SDK's package.json. It was mid-migration to a V2 API surface at the time this was built, and every type this SDK reads off it — IUserOpWithChain, EIP7702Authorization, IAssetsResponse— was read directly from that exact version's type declarations, not from memory of public docs. Bumping it requires re-checking those types by hand.
What each call actually does
| Call | Where | Does |
|---|---|---|
getPrimaryAssets() | ua/balance.ts | Reads the buyer's real balance across every supported chain, mapped into a per-asset, per-chain breakdown. |
createUniversalTransaction() | ua/pay.ts | Builds the actual cross-chain transfer for any settlement token Particle recognizes as a primary asset (USDC, USDT, ETH, ...) — destination token, chain, and receiver, all merchant-configured via the intent (Arbitrum One is only the default). See the callout below for why this call, not createTransferTransaction, is what actually sources liquidity cross-chain correctly. |
createTransferTransaction() | ua/pay.ts | Fallback used only when the settlement token isn't one of Particle's recognized primary assets (an exotic ERC-20 with no cross-chain routing, e.g. an NFT-drop token) — correct for amounts the settlement chain already holds, not for amounts needing real cross-chain sourcing. |
sendTransaction() | ua/pay.ts | Submits the signed transaction + any EIP-7702 authorizations. |
getTransaction() | ua/pay.ts | Polled after submission to detect settlement — see the note below, this one's typed as `any` upstream. |
Why createUniversalTransaction, not the more obviously-named createTransferTransaction
Particle's SDK exposes both. createTransferTransaction() (internally tagged transfer_v2) is the obvious choice for "send this amount to this address" — it was the first thing this SDK used, and it's what the examples in Particle's own docs show. Direct, repeated live testing found it rejects payments that need real cross-chain sourcing as "Insufficient primary token balance" (code -32653), even against a wallet with genuinely sufficient funds spread across chains — a buyer holding $0.94 on the settlement chain and $0.80 on another chain could not complete a $1.20 payment, no matter how much of that $0.80 should have been routable.
Particle's own reference app for this exact pattern — universal-accounts-7702, specifically its TransferCard.tsx— doesn't use createTransferTransaction() either. It uses createUniversalTransaction() with an explicit expectTokens requirement plus a raw ERC-20 transfer() call. Re-running the exact amounts that failed above through createUniversalTransaction() instead succeeded for every one of them, against the same real backend.expectTokensonly accepts Particle's small closed set of primary token types (ETH/USDT/USDC/BNB/SOL), so ua/pay.ts uses getSupportedToken() — also exported publicly from their SDK — to check whether intent.settlementToken is one of them, and only falls back to createTransferTransaction() when it isn't.
Settlement detection has an intentional fallback
getTransaction()'s response is untyped (Promise<any>) in the pinned SDK version. pay() tries reading a numeric .status field against UA_TRANSACTION_STATUS first, but if that never resolves to a recognized terminal value before the timeout, it falls back to polling settlementRecipient's real ERC-20 balance on Arbitrum One directly via viem — a rise in balance is settlement, independent of whatever Particle's own status field says. This is why pay() needs an RPC URL, and why SettlementTimeout exists as a distinct, catchable error rather than a bare failure.
The full mechanism, bottom to top
Everything above explains which calls this SDK makes. This is what actually happensunderneath them — the part that matters when someone asks a harder question than "does it work." Running example: a real $1.20 payment during development, sourced as 0.94 USDC already on Arbitrum (the settlement chain) + 0.80 USDC on Base.
Layer 1 — the account: why one signature can act on two chains
In EIP-7702 mode, the buyer's own EOA gets delegated smart-account code — Universal Accounts are ERC-4337 smart-account implementations attached to a pre-existing EOA, and 7702 lets that attachment happen at the same address instead of deploying a separate contract. Because the account is programmable, it executes UserOperations — signed instruction bundles — instead of raw transactions. When createUniversalTransaction() is called, Particle plans a set of userops, one per chain involved (a Base-side op releasing funds, an Arbitrum-side op executing the actual ERC-20 transfer), and hands back a single root hash covering all of them.
- The one signature over that root —
sdk/src/ua/pay.ts#L151-L155— the buyer signstransaction.rootHashexactly once; that one signature authorizes every userop underneath it. - The 7702 delegation authorizations — a separate signed object from the payment signature above —
sdk/src/ua/authorization.ts#L15-L45— one per chain that still needs its delegation activated, deduped by chain+nonce (an authorization nonce is scoped per chain, so two chains can legitimately share nonce0).
Layer 2 — the movement: no bridge, an atomic swap with a counterparty
Nothing the buyer holds ever crosses a bridge. Instead, a decentralized network of liquidity providers already holds inventory on every chain Particle supports. The buyer's Base USDC goes to an LP on Base; that same LP network releases the equivalent amount from inventory it already holds on Arbitrum. Two local transfers, economically linked by the LP's own fee — nothing physically travels between chains.
This is exactly why sub-$1 test payments failed outright
An LP filling a $0.10 leg earns less than the leg costs to facilitate — the route simply doesn't get built. It's also why the settlement token has to be one of Particle's recognized "primary assets" (USDC/USDT/ETH/BNB/SOL): the LP network only makes markets in tokens it can safely hold and rebalance. A token it doesn't inventory on a given chain is stranded there, full stop — no amount of retrying fixes it.
This is the same mechanism the callout above (createUniversalTransaction vs. createTransferTransaction) already describes at the API level — expectTokens is literally how our code tells Particle "solve this LP-mediated routing problem for me."
Layer 3 — the coordinator: who makes it atomic-ish
Something has to sequence "funds locked on Base" before "LP releases on Arbitrum" before "the merchant transfer executes," and handle a leg failing. That's Particle's own coordination layer — bundler nodes land each userop on its chain, and execution is optimistic with a refund path if a leg dies.
- Where our code recognizes that refund path —
sdk/src/ua/pay.ts#L80-L85(the status codes) and#L405-L414(checked against a live transaction) —REFUND_FINISHEDspecifically means a cross-chain leg died and the buyer's source funds came back. This error handling has been modeling this architecture the whole time, not guessing at it.
Layer 4 — gas: why the buyer never needed ETH anywhere
A paymaster fronts native gas on whichever chain needs it and recoups the cost from the buyer's own primary assets — visible as gasFeeTokenAmountInUSDin Particle's own fee quotes. This is inherent to calling createUniversalTransaction() / createTransferTransaction()at all — there is no gas-sponsorship flag anywhere in this SDK's own code, because we don't configure this; Particle's backend just does it.
The one-sentence version, for when someone asks
The buyer's EOA becomes a smart account in place via EIP-7702; one signature authorizes a tree of userops across chains; liquidity providers who already hold inventory on both sides atomically take the buyer's funds locally and release equivalent funds on the settlement chain; Particle's own coordination layer sequences it and handles refunds; and a paymaster covers gas everywhere. Nothing bridges — value changes hands.
Magic — the buyer's signer
Universal Accounts in EIP-7702 mode need a real EIP-7702 authorization signature and message signing from the buyer's actual wallet. The SDK defines the contract for that (MorvaSigner) but deliberately doesn't implement a browser signer itself — that's the frontend's job, because it depends on whatever auth provider your product already uses. The reference implementation in this repo uses Magic, and it's a complete, working example of what any embedded-wallet integration needs to do.
Magic's embedded wallet never exposes the buyer's private key to the app — signing happens inside Magic's own secure iframe. MagicSigner (frontend/src/lib/magic-signer.ts) implements MorvaSigner's two methods entirely through Magic's own API surface:
export class MagicSigner implements MorvaSigner {
constructor(private readonly magic: Magic, public readonly address: Address) {}
async signMessage(message: Uint8Array): Promise<Hex> {
// Wraps Magic's EIP-1193 provider in a viem wallet client — produces
// the exact same personal_sign-over-raw-bytes signature LocalSigner
// produces via viem's own account.signMessage, just routed through
// Magic's iframe instead of a raw key.
const walletClient = createWalletClient({ account: this.address, transport: custom(this.magic.rpcProvider) });
return walletClient.signMessage({ account: this.address, message: { raw: message } });
}
async signAuthorization(auth: AuthorizationRequest): Promise<SignedAuthorization> {
// Magic's native EIP-7702 authorization signing.
const signed = await this.magic.wallet.sign7702Authorization({
contractAddress: auth.contractAddress,
chainId: auth.chainId,
nonce: auth.nonce,
});
return { address: signed.contractAddress, chainId: signed.chainId, nonce: signed.nonce,
r: signed.r, s: signed.s, yParity: signed.v >= 27 ? signed.v - 27 : signed.v };
}
}The two methods map directly onto what the payment flow needs: signAuthorizationis what lets the buyer's EOA delegate to the Universal Account implementation the first time it's needed on a given chain; signMessageis what authorizes the actual transfer's root hash before submission.
Why not a standard injected wallet (MetaMask, Rabby)?
Standard JSON-RPC injected wallets don't expose a method for producing EIP-7702 authorization signatures today. Embedded providers with native 7702 support — Magic, and others with an equivalent API — are what this mode requires. If your product uses a different embedded wallet provider, implementing MorvaSigner against it is normally this same shape: one call for message signing, one for 7702 authorization signing.
Arbitrum One — the default settlement chain
Every payment settles to a chain the merchant chooses per intent — PaymentIntent.settlementChainIdis a real, configurable field, typed as a union of the five EVM chains Particle's Universal Account SDK supports in EIP-7702 mode (Ethereum, BNB Chain, Base, X Layer, and Arbitrum One — Solana is excluded, since EIP-7702 has no Solana equivalent). Arbitrum One is the defaultwhen a caller doesn't specify one, not the only option: ua/pay.ts passes intent.settlementChainId straight through to whichever of createUniversalTransaction() / createTransferTransaction() ends up building the transfer (see the callout above). A merchant chooses both which token and which chain they settle to.
Morva Plaza, this repo's reference storefront, lets each merchant pick their own settlement token and chain at /merchant/settings— from a short, verified list of USD stablecoins (Arbitrum One is the default a new stall starts on, not the only option). It's deliberately a short list, not "any token on any of the SDK's SUPPORTED_SETTLEMENT_CHAIN_IDS": Plaza's product prices are entered in USD, and settling in a non-1:1-USD token (ETH, BNB, ...) would need a live price conversion this app has no oracle for. Whichever one a merchant picks, they get one chain to monitor, low gas for the final settlement leg, and no cross-chain settlement risk on their side regardless of how many chains the buyer's liquidity was actually sourced from.
MorvaRegistry — optional on-chain merchant identity
A small, deliberately minimal Solidity contract, itself always deployed to Arbitrum One. It never holds, receives, or forwards funds — it only stores where a merchant's payments should be aimed, including which chain they should land on (independent of which chain the registry contract itself lives on):
struct MerchantConfig {
address settlementToken;
address settlementRecipient;
bool active;
uint32 settlementChainId; // e.g. 42161 for Arbitrum One
string metadataURI; // off-chain JSON: { name, logoUrl, description }
}Merchant identity is the registering address — one store per address, no delegation, no admin role over anyone else's config. morva.createPaymentIntent() reads this on-chain and validates settlementChainId against the SDK's supported set before building an intent; morva.createDirectIntent() skips the registry entirely and takes settlementChainId as a direct argument instead. Whether you use it depends on whether you want settlement config to be a self-sovereign, publicly verifiable record, or something your own backend stores and controls — see direct vs registry intents for the tradeoff.
viem — the connective tissue
Every piece of signature handling in this SDK — building the raw message bytes, serializing an EIP-7702 authorization into the exact compact format sendTransaction() expects, reading registry contract state, verifying addresses — runs through viem, deliberately to the exclusion of a second EVM library. Even LocalSigner's raw-key signing uses viem's native signAuthorization support rather than a separate signing library — one dependency surface for anything touching a signature, not two doing overlapping jobs.
How a payment actually moves through all four
- 1. The merchant's settlement token/recipient comes from either your own storage (
createDirectIntent) or MorvaRegistry (createPaymentIntent). - 2. Magic (or your embedded wallet provider) signs an EIP-7702 authorization the first time the buyer's EOA needs to delegate on a chain this payment touches.
- 3. Particle's Universal Account reads the buyer's real cross-chain balance, builds a transfer sourcing liquidity from wherever it actually lives, and the buyer signs its root hash — again through Magic.
- 4. The transaction submits, and settlement is confirmed on Arbitrum One — either via Particle's own status signal or, as a fallback, a direct viem balance read against the settlement token.
One buyer signature covers the whole sequence. Everything from "which chain has my funds" to "did the merchant actually get paid" happens without the buyer doing anything else.