@yodlpay/react-native-eip-7702-provider
v0.6.0
Published
Yodl React Native EIP-7702 fallback provider for EIP-5792 wallet calls
Readme
@yodlpay/react-native-eip-7702-provider
Optional EIP-7702 fallback for
@yodlpay/react-native. Wraps your EIP-1193 wallet so atomic EIP-5792 batching and paymasters work even on wallets that don't support them natively.
Wrap your wallet's EIP-1193 provider, pass the result to <YodlProvider>, and unsupported wallet_sendCalls requests fall back to an EIP-7702 smart-account path. Prefer your wallet's native EIP-5792 support — reach for this only when it lacks it.
import { signAuthorizationViaProvider, withYodl7702 } from '@yodlpay/react-native-eip-7702-provider';
const provider = withYodl7702(walletProvider, {
// You sign the 7702 authorization. Everything else has a default.
signAuthorization: signAuthorizationViaProvider(walletProvider),
});
// then: <YodlProvider provider={provider} ... />Contents
- When do I need this?
- Requirements
- Installation
- Quick Start
- Options
- Chain reads
- Using Yodl's bundler
- How it works
- Behavior reference
- Troubleshooting
- License
When do I need this?
Add this package only when the host wallet:
- exposes an EIP-1193 provider, and
- can sign EIP-7702 authorizations (
wallet_signAuthorizationor compatible secp256k1 signing), but - does not natively support the EIP-5792
wallet_sendCallsflow your Yodl integration needs.
If your wallet already handles wallet_sendCalls natively, skip this — @yodlpay/react-native forwards those calls straight through.
How the three standards relate:
- EIP-1193 — the JS provider interface. Both
@yodlpay/react-nativeand this add-on talk to the wallet viaprovider.request({ method, params }). - EIP-5792 — the wallet call API the Yodl UI uses (
wallet_getCapabilities,wallet_sendCalls,wallet_getCallsStatus,wallet_showCallsStatus). - EIP-7702 — not a provider API; it lets an EOA delegate execution to smart-account code. This add-on uses that path to implement EIP-5792-style atomic batch execution when the wallet can't.
Requirements
| Requirement | Version |
| ------------- | ----------------- |
| React Native | >= 0.72 |
| Hermes engine | Enabled (default) |
Bundles a native module (
react-native-get-random-values), so it requires a custom dev build — it does not run in Expo Go.
Installation
npm install @yodlpay/react-native-eip-7702-provider viem react-native-get-random-values # or: pnpm add / yarn add
npx pod-install # iOSThen rebuild the native app. viem and react-native-get-random-values are peer dependencies — install them alongside (npm v7+ and pnpm add peers automatically; Yarn does not). The package bundles permissionless itself, so the base @yodlpay/react-native package stays wallet-provider agnostic.
Quick Start
Zero config by default. Omit
chainsand the wrapper uses Yodl's own bundler on the chains Yodl sponsors gas for — see Using Yodl's bundler for exactly what that delegates. The paymaster path uses the standard ERC-7677 interface.Bring your own bundler by passing
chainsexplicitly, and none of your traffic touches Yodl.
rpcUrlis optional. Omit it and the wrapper serves its three chain reads over the wrapped wallet's own transport — see Chain reads.One required hook. You supply
signAuthorization(sign the 7702 authorization).estimateUserOperationFeesis optional and defaults to asking the bundler you configured. The account implementation and ERC-7677 sponsorship are handled internally.
import { signAuthorizationViaProvider, withYodl7702 } from '@yodlpay/react-native-eip-7702-provider';
import { YodlProvider } from '@yodlpay/react-native';
const provider = withYodl7702(walletProvider, {
// Optional — omit entirely for Yodl's hosted bundler on Yodl's sponsored chains.
// List only the chains you can actually serve the fallback on.
chains: [
{
id: 8453,
bundlerUrl: 'https://api.pimlico.io/v2/8453/rpc?apikey=YOUR_KEY', // your ERC-4337 bundler
// rpcUrl is optional too — omit it to read over the wallet (see "Chain reads").
},
],
// Required: sign the EIP-7702 authorization (see "Authorization signing").
signAuthorization: signAuthorizationViaProvider(walletProvider),
});
// Pass the wrapped provider exactly where you'd pass the wallet provider.
<YodlProvider sdk={sdk} provider={provider} address={address} chainId={chainId} />;Options
| Option | Type | Required | Description |
| --------------------------- | ------------------------------------------------------------------------------------------ | -------- | ----------------------------------------------------------------------- |
| chains | { id: number; bundlerUrl: string; rpcUrl?: string }[] | No | Chains where the wrapper serves the EIP-7702 fallback. Defaults to yodlHostedChains(). rpcUrl is optional; see Chain reads. |
| yodl | YodlLike — a Yodl from @yodlpay/sdk-core | No | Defaults chains to the chains the instance reads on that Yodl sponsors (never mainnet), routed to its baseUrl's bundler (with its RPC proxy as rpcUrl where it names one), and getAuthHeaders to config.getAuthHeaders, sent only to baseUrl's origin. Explicit chains / getAuthHeaders win. See Using Yodl's bundler. |
| estimateUserOperationFees | (chainId: number) => Promise<{ maxFeePerGas: bigint; maxPriorityFeePerGas: bigint }> | No | Overrides the built-in pricing. See Gas pricing. |
| signAuthorization | (request: { account; chainId; contractAddress; nonce }) => Promise<SignedAuthorization> | Yes | Signs the EIP-7702 authorization (no universal wallet method exists). See Authorization signing. |
| foreignDelegations | Record<Address, 'repoint' \| EIP1193Provider> | No | What to do with an account delegated to another implementation, per delegate: hand its bundles to an EIP-5792 provider of your own, or re-point it to Yodl's. Unnamed delegates are refused with 5760. See A wallet delegated elsewhere. |
The wrapper reads the account (from) and chainId from each wallet_sendCalls bundle (both supplied by the Yodl UI). The account implementation is the canonical SimpleAccount (EntryPoint 0.8) — used as both the delegated account logic and the authorization's delegate target so they can't drift — and sponsorship uses the standard ERC-7677 paymaster carried in the bundle's capabilities.
Gas pricing
ERC-7769 standardises the ERC-4337 JSON-RPC surface and deliberately leaves gas price out of it, so the right value is bundler policy, not chain state — and there is no vendor-neutral way to compute it. Pimlico serves a tiered oracle; Alchemy's Rundler expects a hand-assembled base fee plus a per-chain-buffered priority fee, and rejects underpriced ops on submission.
You usually need to do nothing. By default the wrapper asks the bundler you configured:
pimlico_getUserOperationGasPriceagainst that chain'sbundlerUrl, taking thefasttier.- If the bundler doesn't implement it, an EIP-1559 estimate from the chain, doubled — the same buffer viem applies for a bundler it knows nothing about.
Only an unsupported-method reply falls through to step 2. A real failure (auth, rate limit, network) propagates, because pricing against a bundler that is refusing to talk produces a submission rejected for an unactionable reason.
Override it when your bundler prescribes something else — notably Rundler, whose prechecks the generic estimate does not satisfy:
estimateUserOperationFees: async (chainId) =>
// e.g. Pimlico: `(await pimlicoClient.getUserOperationGasPrice()).fast`
getYourBundlerGasPrice(chainId),maxFeePerGas is a refundable ceiling (a paymaster or refund covers the delta), so returning the fast tier never overcharges. Note: even paymaster-sponsored ops need real, non-zero fees — sponsorship covers the cost, not the gas-price fields — so don't return 0n.
Chain reads
The wrapper makes three chain reads to send a payment: the EOA's pending nonce for the authorization, eth_getCode for delegation status, and the EntryPoint's getNonce per UserOperation. A delegation read as ours is kept, so one read serves the capability probe, the warm-up and the send alike, until a send fails and puts it in doubt. An account that is not delegated yet is read again at each send, since the delegation may land in between, and with foreignDelegations set every send reads, as does the probe. Either way the clients behind it are built once and kept, and viem is told not to check the account's deployment on its own. wallet_getCapabilities answers for each chain it is asked about, and @yodlpay/sdk-core asks about the payment's chain only. Two more come after a send, and only when something has gone wrong: the EntryPoint's UserOperationEvent logs for an operation's hash in the last 1,000 blocks, with the receipt of the transaction that carried it, when the bundler cannot say what became of the operation; and getNonce for the account's nonce lane when the bundler refuses a send with AA25.
Omit rpcUrl and they go over the wrapped wallet's own EIP-1193 transport. The wallet is already connected to a node, so there is no second endpoint to configure or to drift out of step with the wallet mid-payment. Supply rpcUrl when you would rather not depend on the wallet vendor's node — it always takes precedence.
Two things to know if you omit it:
- EIP-1193 carries no per-request chain. Reads land on whichever chain the wallet is currently on, so the wrapper checks the wallet's
eth_chainIdagainst the bundle's chain once per bundle and refuses on a mismatch (-32602). Switch the wallet before sending — which any EIP-5792 caller already does. The two reads after a send checketh_chainIdagain and skip a wallet that has moved to another network rather than read that network. A skipped read counts as no answer: the operation stays pending, and the lane held, until the wallet is back or the bundler can say. blockTag: 'pending'is the one read a wallet may refuse. A wallet that method-whitelists its node proxy can reject it. That fails loudly and namesrpcUrl, rather than retrying atlatest— which could sign an authorization for an already-used nonce.
Authorization signing
There is no universal way to sign an EIP-7702 authorization across wallets — some expose wallet_signAuthorization over EIP-1193, some only a raw signer, and embedded/SDK wallets sign through their own SDK. So the wrapper doesn't guess: you supply signAuthorization. It hands you the unsigned request (the EOA account, chainId, the delegate contractAddress, and a fresh pending nonce) and you return the signed authorization however your wallet does it.
Try the built-in helper first. For wallets that sign over EIP-1193, the package ships signAuthorizationViaProvider — it tries wallet_signAuthorization, then raw secp256k1_sign. Drop it in and see if your wallet (e.g. Privy's provider) works:
import { signAuthorizationViaProvider, withYodl7702 } from '@yodlpay/react-native-eip-7702-provider';
const provider = withYodl7702(walletProvider, {
chains,
signAuthorization: signAuthorizationViaProvider(walletProvider),
});If that wallet doesn't sign over EIP-1193, sign through its SDK instead:
signAuthorization: ({ account, chainId, contractAddress, nonce }) =>
yourWallet.signAuthorization({ address: account, chainId, contractAddress, nonce }),The wrapper always fetches the nonce itself so the authorization can't go stale; you only sign.
signAuthorizationViaProvider is a discovery convenience — it tries wallet_signAuthorization and falls back to raw secp256k1_sign only when the first is unsupported (real errors like user rejection surface, never masked). Once you know which your wallet uses, prefer the single-strategy primitive so failures stay precise:
| Helper | Method |
| --- | --- |
| signAuthorizationViaWalletRpc(provider) | wallet_signAuthorization only |
| signAuthorizationViaSecp256k1(provider) | raw secp256k1_sign only |
| signAuthorizationViaProvider(provider) | the former, falling back to the latter on unsupported-method |
A wallet delegated elsewhere
An EOA carries one EIP-7702 delegation at a time. When another app has already delegated the payer's account to its own implementation (Privy's native gas sponsorship, for example, delegates to ZeroDev Kernel), the wrapper reads that delegate on each send and does what foreignDelegations says for it:
| foreignDelegations[delegate] | What happens | Sponsored by | Atomic |
| --- | --- | --- | --- |
| a provider of yours | Theirs. The bundle goes to that EIP-5792 provider as it stands: same from, calls and capabilities, no builder-code suffix. The id it answers with is what wallet_getCallsStatus and wallet_showCallsStatus are forwarded under, and wallet_getCapabilities for the account is its answer too. | whoever sponsors your stack | as it says |
| 'repoint' | Re-pointed. The send asks signAuthorization for an authorization to the SimpleAccount implementation, the same request an undelegated account gets, and the payment goes through the sponsored UserOperation path. If that callback rejects, the send fails with its error. | Yodl | yes |
| unnamed | Refused with EIP-5792 5760 (atomicity not supported). The Yodl UI then sends the calls one transaction at a time through the wallet. Sending a UserOperation without an authorization would run validation in a contract that has never seen it, and signing one would replace a smart account the payer set up somewhere else. | nobody | no |
/** The implementations other apps delegate your payers' wallets to. */
const Delegate = {
// ZeroDev Kernel v3.3, which Privy's native gas sponsorship delegates to.
KernelV3_3: '0xd6CEDDe84be40893d153Be9d467CD6aD37875b28',
// Your other app's smart account.
MyOtherApp: '0x1111111111111111111111111111111111111111',
} as const;
const provider = withYodl7702(walletProvider, {
chains,
signAuthorization: signAuthorizationViaSecp256k1(walletProvider),
foreignDelegations: {
// A stack you run: its bundles go to your provider over ZeroDev's SDK.
[Delegate.KernelV3_3]: kernelProvider,
// An app of yours: take the delegation and pay through Yodl.
[Delegate.MyOtherApp]: 'repoint',
},
});With anything named, the wrapper reads the delegation on every send (one eth_getCode), because the other app can take it back between payments. With the option left out, a delegation read as ours is kept and trusted until a send fails, and an account that is not delegated yet is read again at each send. Keys match in any case and checksums aren't required, but one delegate gets one fate: the same address under two spellings is refused at construction, and so is an empty map. Leave the option out for "none".
A provider of yours is an EIP-1193 provider that speaks EIP-5792 for those accounts: wallet_getCapabilities, wallet_sendCalls, wallet_getCallsStatus and wallet_showCallsStatus (ZeroDev's SDK builds Kernel's UserOperations). The batching, sponsorship and status are yours to provide. This wrapper is the router: a wallet that Privy re-delegates between payments reaches your provider when the delegate is Kernel's and Yodl's sponsored path when it is ours, decided per send. Bundles reach your provider after this wrapper's own checks, so the chain has to be one in chains, and a capability the bundle requires has to be paymasterService or marked optional. Nothing here warms up, signs or suffixes for them.
'repoint' is the choice for an app that is yours too, or one you have agreed to alternate with, when you want Yodl's sponsorship. The address is the consent that names the other app.
- An authorization is attached only when the delegate isn't ours: ERC-7769 provides the tuple to change a delegation, not to repeat it on every operation.
- While this delegation is in place, the other implementation's modules, session keys and ERC-1271 signatures don't apply. That app needs an authorization of its own to take the delegation back.
- A wallet policy that restricts which contracts it signs authorizations for (Privy has one) must allow
0xe6Cae83BdE06E4c305530e199D7217f42808555B.
The wrapper refuses a contract deployed at the payer's address either way (5760), because no authorization can replace contract code.
Using Yodl's bundler
If you are integrating against Yodl's hosted payment UI, the bundler you need is the host that already sponsors the payment — the same URL the UI attaches to every bundle as capabilities.paymasterService.url. yodlHostedChains() names it for you:
import { withYodl7702, signAuthorizationViaProvider } from '@yodlpay/react-native-eip-7702-provider';
const provider = withYodl7702(walletProvider, {
signAuthorization: signAuthorizationViaProvider(walletProvider),
});If you already have a createYodlClient(...) instance, hand it over and the wrapper follows it — the same baseUrl (so a staging or box build submits to staging's bundler, where yodlHostedChains() is production-only), the chains it reads on that Yodl sponsors (mainnet stays out, so a mainnet bundle fails with 5710 instead of building an operation the user would have to fund), the same getAuthHeaders:
const provider = withYodl7702(walletProvider, {
yodl,
signAuthorization: signAuthorizationViaProvider(walletProvider),
});yodl is typed structurally (YodlLike: an object with a config carrying baseUrl, chainIds, rpcUrls and getAuthHeaders), so this package takes no dependency on @yodlpay/sdk-core. Explicit chains or getAuthHeaders beside it still win. The inherited getAuthHeaders keeps sdk-core's promise and is only sent to baseUrl's origin — pairing yodl with your own bundler, RPC or paymaster never hands the host session token to a third party. Pass getAuthHeaders explicitly to put credentials on your own endpoints.
That is the whole configuration — chains defaults to yodlHostedChains(), which you can also pass explicitly (or narrowed, yodlHostedChains([8453])) when you want the choice visible at the call site. Gas pricing defaults to that bundler's own oracle, chain reads go over the wallet, and Yodl sponsors the gas — so there is no bundler account, no API key, and no per-op cost.
Pass ids to narrow it (yodlHostedChains([8453])). A chain Yodl does not sponsor throws rather than being given an invented endpoint; mainnet is deliberately excluded, because on L1 gas is bundled into the payment rather than sponsored.
What the default delegates. Worth reading once, even though it needs no action. The bundler receives the signed UserOperation and the signed EIP-7702 authorization, decides whether and when to submit, and is the only source of the settlement status this provider reports through wallet_getCallsStatus. On the default, that host is Yodl's — the same one already sponsoring the payment.
Two consequences: the sponsored-chain list comes from Yodl's chain table in @yodlpay/tokenlists, so Yodl adding a corridor reaches you as a package upgrade rather than a config edit; and with no rpcUrl, chain reads depend on the wallet being on the bundle's chain, which the wrapper verifies per bundle (see Chain reads).
Prefer your own bundler if you have one: pass chains entries directly and none of your traffic touches Yodl.
How it works
withYodl7702 accepts and returns @yodlpay/sdk-core's EIP1193Provider — request only, so no cast is needed between this package and @yodlpay/react-native; on/removeListener are forwarded only when the wrapped provider has them. The wrapper is native-first: every request is tried against the original provider, and the EIP-7702 path is used only as a fallback. wallet_sendCalls falls back when native support is unavailable, rejects a required capability with EIP-5792 code 5700, or reports atomicity unsupported with code 5760. User rejection and unrelated wallet errors are surfaced as-is, never hidden by the fallback.
When it does fall back, the wrapper sends the calls through the Yodl EIP-7702 smart-account path, returns an EIP-5792 { id }, and tracks that id for later status checks.
How a send is delivered. An operation the account can sign — during yodl_prewarmAccount with presignUserOperations on, or at the tap — is submitted hedged rather than waited on. A copy leaves at once, again at 2 s and at 5 s while nothing has answered, and from 2 s the bundler is asked by the operation's EntryPoint 0.8 hash (pimlico_getUserOperationStatus, 4 s, once a second after each answer) whether it already has it. The first confirmation wins: a copy answered with its hash, or a status read that finds the operation submitted, queued, included, even reverted — then the send resolves with the hash it was signed under, and wallet_getCallsStatus follows it from there. A copy the bundler turns away is followed by one status read, which covers every wording of "already have it"; found nothing, the next copy leaves at once, and only once all three are refused does the send reject with the refusal. A failed status (a bundle transaction that reverted as a whole, so the operation never ran) counts as nothing found. When the status read cannot answer and the refusal is AA25 or Already known, which may be the operation itself, the send waits for the next read or its deadline instead. At 12 s it stops waiting and resolves with the operation's hash, as it does when every copy's connection failed to open, because the operation may have arrived. wallet_getCallsStatus then reports it pending while it may still land, 200 or 500 once it does, and EIP-5792 400 (not included) if the bundler still does not have it 30 s later. Three copies are safe because every signature for an account shares one EntryPoint nonce key, so at most one operation in it can be included, and the bundler dedupes an identical hash. On a healthy network that is one request, under a second. An account with no signer of its own hands the calls to its client instead, one request with a 15 s allowance; that send has no hash of its own to follow, so a timeout there still rejects.
Whatever the failure, the account's nonce lane is kept, so a retry signs into the same EntryPoint slot and at most one of the two can land. The lane rotates only once an operation is known to hold its slot: a send that succeeded, a status read that found this bundle's own, one that found an earlier operation already on chain (a pending one for other calls keeps the lane held), or a wallet_getCallsStatus that finds an unanswered operation landed. A send refused with AA25 that none of those explains also rotates the lane, but only once the EntryPoint's getNonce for the lane's key shows the slot spent: a bundler also answers AA25 for another operation still pending in the slot. A rotation drops any signature a warm-up made into the old lane, so the next payment signs into the new one instead of submitting an operation the EntryPoint can only refuse.
A send that got no answer — the deadline, or a connection that never opened — is remembered by hash. A retry for the same calls resubmits that very operation, with no new signature, and the bundler dedupes it. A retry for other calls that the bundler turns away asks about the earlier operation, and rejects with an error named UnresolvedSubmissionError (code -32002) while it is pending or on chain. Any other failure rejects with its own error: a refusal with no earlier operation behind it, or a send that failed before anything went out, such as a signature the payer declined.
Behavior reference
| Method | Behavior |
| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| wallet_getCapabilities | Advertises fallback capabilities for the configured chains, per account: atomic: supported and paymasterService available for an EOA that is ours or undelegated; atomic: ready (an upgrade, the authorization signature) for a delegate the wrapper may re-point; the plugged-in provider's own answer for a delegate one of them drives, with a chain that answer leaves out counting as unsupported; atomic: unsupported for the rest. With no account, or no answer from the chain, it advertises atomic: ready and paymasterService available and leaves the verdict to the send. |
| wallet_sendCalls | Tries native first; falls back to the EIP-7702 path only on 5700 / 5760 / unsupported. A capabilities.paymasterService.url is used via the standard ERC-7677 paymaster client. |
| wallet_getCallsStatus | EIP-5792-shaped status for wrapper-tracked ids (pending, confirmed, reverted, partially reverted, not included). A bundle a plugged-in provider sent is asked of that provider. |
| wallet_showCallsStatus | Resolves with no result for wrapper-tracked ids (no native status screen for fallback bundles); a bundle a plugged-in provider sent is forwarded to it. Unknown ids delegate to the original provider, then report the EIP-5792 unknown-bundle error. |
| everything else | Passed straight through to the original provider. |
Troubleshooting
| Symptom | Cause & fix |
| --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| crypto.getRandomValues ... not supported | The react-native-get-random-values native module isn't linked. Reinstall pods and rebuild the native app (not Expo Go). |
| Can't find variable: BigInt | Hermes is disabled. Re-enable Hermes or add a BigInt polyfill. |
| Unsupported chain id (EIP-5792 5710) | The bundle's chain isn't in chains. Add a { id, bundlerUrl } entry for it. |
| already delegates to 0x… on chain … (5760) | The payer's EOA is delegated to an implementation nobody here drives. The Yodl UI falls back to one transaction at a time. Pay through the wallet that set it, or name it in foreignDelegations with a provider of your own or 'repoint'. |
| holds contract code … rather than an EIP-7702 delegation (5760) | A contract is deployed at the payer's address. No authorization can replace it, so this account can't use the fallback. |
| answered wallet_sendCalls with no bundle id (-32603) | A provider in foreignDelegations resolved wallet_sendCalls with neither { id } nor a bare id string. Return the EIP-5792 shape. |
| an id already tracked (-32603) | A provider in foreignDelegations answered wallet_sendCalls with an id another bundle already holds, or the bundler answered with one a plugged bundle was tracked under meanwhile. The send went out, but it is not tracked: its status questions would otherwise reach the wrong stack. Return an id unique to each bundle. |
| Fallback never triggers | The wallet supports wallet_sendCalls natively — that's expected. Native always wins. |
| maxFeePerGas/maxPriorityFeePerGas too low | Your estimateUserOperationFees returned a value below the bundler's floor (e.g. 0n, or a stale cached price). Return real fees from your bundler's oracle; see Gas pricing. |
| Fallback fails at submission | Check that chain's bundlerUrl (and API key), and its rpcUrl if you configured one. |
| Wallet is on chain X but this bundle targets chain Y | You omitted rpcUrl, so reads use the wallet's transport, and the wallet is on another chain. Switch it before sending, or configure rpcUrl. |
| Wallet could not report its chain | The wallet's transport can't answer eth_chainId, so reads over it can't be verified. Configure rpcUrl for that chain. |
| rejected eth_getTransactionCount at blockTag 'pending' | The wallet's node proxy won't serve the pending nonce. Configure rpcUrl for that chain. |
License
BUSL-1.1 © Yodl
